synth_core/wasm_op.rs
1//! WebAssembly operation patterns — universal input IR for all backends
2//!
3//! Every backend (ARM, aWsm, wasker, w2c2) consumes `WasmOp` sequences.
4//! This enum lives in synth-core so backends can depend on it without
5//! pulling in ARM-specific synthesis types.
6
7use serde::{Deserialize, Serialize};
8
9/// WebAssembly operation patterns
10/// Note: Cannot derive Eq because f32/f64 don't implement Eq (NaN != NaN)
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub enum WasmOp {
13 // Arithmetic
14 I32Add,
15 I32Sub,
16 I32Mul,
17 I32DivS,
18 I32DivU,
19 I32RemS,
20 I32RemU,
21
22 // Bitwise
23 I32And,
24 I32Or,
25 I32Xor,
26 I32Shl,
27 I32ShrS,
28 I32ShrU,
29 I32Rotl, // Rotate left
30 I32Rotr, // Rotate right
31 I32Clz, // Count leading zeros
32 I32Ctz, // Count trailing zeros
33 I32Popcnt, // Population count (count 1 bits)
34
35 // Sign extension
36 I32Extend8S, // Sign-extend low 8 bits to 32 bits
37 I32Extend16S, // Sign-extend low 16 bits to 32 bits
38
39 // Comparison
40 I32Eqz, // Equal to zero (unary)
41 I32Eq,
42 I32Ne,
43 I32LtS,
44 I32LtU,
45 I32LeS,
46 I32LeU,
47 I32GtS,
48 I32GtU,
49 I32GeS,
50 I32GeU,
51
52 // Constants
53 I32Const(i32),
54
55 // Memory
56 I32Load {
57 offset: u32,
58 align: u32,
59 },
60 I32Store {
61 offset: u32,
62 align: u32,
63 },
64
65 // Sub-word loads (i32)
66 I32Load8S {
67 offset: u32,
68 align: u32,
69 }, // byte load, sign-extend to i32
70 I32Load8U {
71 offset: u32,
72 align: u32,
73 }, // byte load, zero-extend to i32
74 I32Load16S {
75 offset: u32,
76 align: u32,
77 }, // halfword load, sign-extend to i32
78 I32Load16U {
79 offset: u32,
80 align: u32,
81 }, // halfword load, zero-extend to i32
82
83 // Sub-word stores (i32)
84 I32Store8 {
85 offset: u32,
86 align: u32,
87 }, // store low byte
88 I32Store16 {
89 offset: u32,
90 align: u32,
91 }, // store low halfword
92
93 // Control flow
94 Block,
95 Loop,
96 Br(u32), // Branch to label
97 BrIf(u32), // Conditional branch
98 BrTable {
99 targets: Vec<u32>,
100 default: u32,
101 },
102 Return,
103 Call(u32),
104 CallIndirect {
105 type_index: u32,
106 table_index: u32,
107 },
108 LocalGet(u32),
109 LocalSet(u32),
110 LocalTee(u32),
111 GlobalGet(u32),
112 GlobalSet(u32),
113
114 // Memory management
115 MemorySize(u32), // returns current memory size in pages (memory index)
116 MemoryGrow(u32), // grow memory by N pages, returns previous size or -1 (memory index)
117
118 // Bulk memory (#374) — single linear memory (memory 0) only; the decoder
119 // loud-skips any non-zero memory index. Each pops (dst, src/val, len) = 3
120 // i32 operands and pushes nothing.
121 MemoryCopy, // memory.copy: copy `len` bytes from `src` to `dst` (memmove semantics)
122 MemoryFill, // memory.fill: set `len` bytes at `dst` to the low byte of `val`
123
124 /// VCR-MEM-002 phase 1 (#406): a load/store whose `memarg` targets a
125 /// NON-DEFAULT linear memory (`memidx > 0`, multi-memory proposal). The
126 /// decoder wraps the plain memory-0 variant instead of DROPPING the index
127 /// (the pre-#406 silent aliasing: every memory lowered to the one R11
128 /// base, so a store to memory `$b` clobbered memory `$a`). Keeping
129 /// memory-0 ops as the bare variants means every existing single-memory
130 /// match arm — and therefore every frozen fixture byte — is untouched by
131 /// construction; the multi-memory-aware path (the `--relocatable` direct
132 /// selector) unwraps this and addresses via the per-memory base symbol
133 /// (`__synth_wasm_data_<k>`), and every other path declines LOUDLY
134 /// (never a silent alias).
135 ///
136 /// `memory.size`/`memory.grow` are NOT wrapped — their variants already
137 /// carry the memory index. Invariant (decoder-enforced): `memory > 0` and
138 /// `op` is never itself a `MultiMemory`.
139 MultiMemory {
140 memory: u32,
141 op: Box<WasmOp>,
142 },
143
144 // More ops
145 Drop,
146 Select,
147 If,
148 Else,
149 End,
150 Unreachable,
151 Nop,
152
153 // ========================================================================
154 // i64 Operations
155 // ========================================================================
156
157 // i64 Arithmetic
158 I64Add,
159 I64Sub,
160 I64Mul,
161 I64DivS,
162 I64DivU,
163 I64RemS,
164 I64RemU,
165
166 // i64 Bitwise
167 I64And,
168 I64Or,
169 I64Xor,
170 I64Shl,
171 I64ShrS,
172 I64ShrU,
173 I64Rotl,
174 I64Rotr,
175 I64Clz,
176 I64Ctz,
177 I64Popcnt,
178
179 // i64 Comparison
180 I64Eqz,
181 I64Eq,
182 I64Ne,
183 I64LtS,
184 I64LtU,
185 I64LeS,
186 I64LeU,
187 I64GtS,
188 I64GtU,
189 I64GeS,
190 I64GeU,
191
192 // i64 Constants and Memory
193 I64Const(i64),
194 I64Load {
195 offset: u32,
196 align: u32,
197 },
198 I64Store {
199 offset: u32,
200 align: u32,
201 },
202
203 // Sub-word loads (i64) — load sub-word, extend to i64
204 I64Load8S {
205 offset: u32,
206 align: u32,
207 },
208 I64Load8U {
209 offset: u32,
210 align: u32,
211 },
212 I64Load16S {
213 offset: u32,
214 align: u32,
215 },
216 I64Load16U {
217 offset: u32,
218 align: u32,
219 },
220 I64Load32S {
221 offset: u32,
222 align: u32,
223 },
224 I64Load32U {
225 offset: u32,
226 align: u32,
227 },
228
229 // Sub-word stores (i64) — store low N bits
230 I64Store8 {
231 offset: u32,
232 align: u32,
233 },
234 I64Store16 {
235 offset: u32,
236 align: u32,
237 },
238 I64Store32 {
239 offset: u32,
240 align: u32,
241 },
242
243 // Conversion operations
244 I64ExtendI32S, // Sign-extend i32 to i64
245 I64ExtendI32U, // Zero-extend i32 to i64
246 I32WrapI64, // Wrap i64 to i32 (truncate)
247
248 // i64 In-place sign extension
249 I64Extend8S, // Sign-extend low 8 bits to 64 bits
250 I64Extend16S, // Sign-extend low 16 bits to 64 bits
251 I64Extend32S, // Sign-extend low 32 bits to 64 bits
252
253 // ========================================================================
254 // f32 Operations
255 // ========================================================================
256
257 // f32 Arithmetic
258 F32Add,
259 F32Sub,
260 F32Mul,
261 F32Div,
262
263 // f32 Comparisons
264 F32Eq,
265 F32Ne,
266 F32Lt,
267 F32Le,
268 F32Gt,
269 F32Ge,
270
271 // f32 Math Functions
272 F32Abs,
273 F32Neg,
274 F32Ceil,
275 F32Floor,
276 F32Trunc,
277 F32Nearest,
278 F32Sqrt,
279 F32Min,
280 F32Max,
281 F32Copysign,
282
283 // f32 Constants and Memory
284 F32Const(f32),
285 F32Load {
286 offset: u32,
287 align: u32,
288 },
289 F32Store {
290 offset: u32,
291 align: u32,
292 },
293
294 // f32 Conversions
295 F32ConvertI32S, // Convert signed i32 to f32
296 F32ConvertI32U, // Convert unsigned i32 to f32
297 F32ConvertI64S, // Convert signed i64 to f32
298 F32ConvertI64U, // Convert unsigned i64 to f32
299 F32DemoteF64, // Convert f64 to f32
300 F32ReinterpretI32, // Reinterpret i32 bits as f32
301 I32ReinterpretF32, // Reinterpret f32 bits as i32
302 I32TruncF32S, // Truncate f32 to signed i32
303 I32TruncF32U, // Truncate f32 to unsigned i32
304
305 // Nontrapping float→int (WASM saturating-float-to-int proposal, 0xFC
306 // prefix). TOTAL ops — never trap: NaN → 0, below INT_MIN → INT_MIN,
307 // above INT_MAX → INT_MAX, else truncate toward zero (§4.3.2 trunc_sat).
308 // Rust emits these for `as` casts, so real modules (falcon, #782) carry
309 // them even when the trapping forms are absent.
310 I32TruncSatF32S, // Saturating truncate f32 to signed i32
311 I32TruncSatF32U, // Saturating truncate f32 to unsigned i32
312 I64TruncSatF32S, // Saturating truncate f32 to signed i64
313 I64TruncSatF32U, // Saturating truncate f32 to unsigned i64
314
315 // ========================================================================
316 // f64 Operations
317 // ========================================================================
318
319 // f64 Arithmetic
320 F64Add,
321 F64Sub,
322 F64Mul,
323 F64Div,
324
325 // f64 Comparisons
326 F64Eq,
327 F64Ne,
328 F64Lt,
329 F64Le,
330 F64Gt,
331 F64Ge,
332
333 // f64 Math Functions
334 F64Abs,
335 F64Neg,
336 F64Ceil,
337 F64Floor,
338 F64Trunc,
339 F64Nearest,
340 F64Sqrt,
341 F64Min,
342 F64Max,
343 F64Copysign,
344
345 // f64 Constants and Memory
346 F64Const(f64),
347 F64Load {
348 offset: u32,
349 align: u32,
350 },
351 F64Store {
352 offset: u32,
353 align: u32,
354 },
355
356 // f64 Conversions
357 F64ConvertI32S, // Convert signed i32 to f64
358 F64ConvertI32U, // Convert unsigned i32 to f64
359 F64ConvertI64S, // Convert signed i64 to f64
360 F64ConvertI64U, // Convert unsigned i64 to f64
361 F64PromoteF32, // Convert f32 to f64
362 F64ReinterpretI64, // Reinterpret i64 bits as f64
363 I64ReinterpretF64, // Reinterpret f64 bits as i64
364 I64TruncF64S, // Truncate f64 to signed i64
365 I64TruncF64U, // Truncate f64 to unsigned i64
366 I32TruncF64S, // Truncate f64 to signed i32
367 I32TruncF64U, // Truncate f64 to unsigned i32
368 // #869: the f32-source i64-target TRAPPING truncations — the only two
369 // members of the 64-bit integer<->float conversion family that had no
370 // WasmOp variant at all (the rest existed but were dropped at decode).
371 I64TruncF32S, // Truncate f32 to signed i64 (traps on NaN/out-of-range)
372 I64TruncF32U, // Truncate f32 to unsigned i64 (traps on NaN/out-of-range)
373
374 // Nontrapping f64→int (saturating-float-to-int, §4.3.2 trunc_sat — see
375 // the f32 group above for the semantics).
376 I32TruncSatF64S, // Saturating truncate f64 to signed i32
377 I32TruncSatF64U, // Saturating truncate f64 to unsigned i32
378 I64TruncSatF64S, // Saturating truncate f64 to signed i64
379 I64TruncSatF64U, // Saturating truncate f64 to unsigned i64
380
381 // ========================================================================
382 // v128 SIMD Operations (WASM SIMD proposal)
383 // ========================================================================
384 // Targets ARM Cortex-M55 Helium MVE (M-Profile Vector Extension)
385
386 // v128 Constants and Memory
387 V128Const([u8; 16]), // 128-bit constant
388 V128Load {
389 offset: u32,
390 align: u32,
391 }, // v128.load
392 V128Store {
393 offset: u32,
394 align: u32,
395 }, // v128.store
396
397 // v128 Bitwise operations
398 V128And, // v128.and
399 V128Or, // v128.or
400 V128Xor, // v128.xor
401 V128Not, // v128.not
402 V128AndNot, // v128.andnot
403
404 // i8x16 integer SIMD
405 I8x16Add, // i8x16.add
406 I8x16Sub, // i8x16.sub
407 I8x16Neg, // i8x16.neg
408 I8x16Eq, // i8x16.eq
409 I8x16Ne, // i8x16.ne
410 I8x16LtS, // i8x16.lt_s
411 I8x16LtU, // i8x16.lt_u
412 I8x16GtS, // i8x16.gt_s
413 I8x16GtU, // i8x16.gt_u
414 I8x16LeS, // i8x16.le_s
415 I8x16LeU, // i8x16.le_u
416 I8x16GeS, // i8x16.ge_s
417 I8x16GeU, // i8x16.ge_u
418 I8x16Splat, // i8x16.splat
419 I8x16ExtractLaneS(u8), // i8x16.extract_lane_s
420 I8x16ExtractLaneU(u8), // i8x16.extract_lane_u
421 I8x16ReplaceLane(u8), // i8x16.replace_lane
422 I8x16Shuffle([u8; 16]), // i8x16.shuffle
423 I8x16Swizzle, // i8x16.swizzle
424
425 // i16x8 integer SIMD
426 I16x8Add, // i16x8.add
427 I16x8Sub, // i16x8.sub
428 I16x8Mul, // i16x8.mul
429 I16x8Neg, // i16x8.neg
430 I16x8Eq, // i16x8.eq
431 I16x8Ne, // i16x8.ne
432 I16x8LtS, // i16x8.lt_s
433 I16x8LtU, // i16x8.lt_u
434 I16x8GtS, // i16x8.gt_s
435 I16x8GtU, // i16x8.gt_u
436 I16x8LeS, // i16x8.le_s
437 I16x8LeU, // i16x8.le_u
438 I16x8GeS, // i16x8.ge_s
439 I16x8GeU, // i16x8.ge_u
440 I16x8Splat, // i16x8.splat
441 I16x8ExtractLaneS(u8), // i16x8.extract_lane_s
442 I16x8ExtractLaneU(u8), // i16x8.extract_lane_u
443 I16x8ReplaceLane(u8), // i16x8.replace_lane
444
445 // i32x4 integer SIMD
446 I32x4Add, // i32x4.add
447 I32x4Sub, // i32x4.sub
448 I32x4Mul, // i32x4.mul
449 I32x4Neg, // i32x4.neg
450 I32x4Eq, // i32x4.eq
451 I32x4Ne, // i32x4.ne
452 I32x4LtS, // i32x4.lt_s
453 I32x4LtU, // i32x4.lt_u
454 I32x4GtS, // i32x4.gt_s
455 I32x4GtU, // i32x4.gt_u
456 I32x4LeS, // i32x4.le_s
457 I32x4LeU, // i32x4.le_u
458 I32x4GeS, // i32x4.ge_s
459 I32x4GeU, // i32x4.ge_u
460 I32x4Splat, // i32x4.splat
461 I32x4ExtractLane(u8), // i32x4.extract_lane
462 I32x4ReplaceLane(u8), // i32x4.replace_lane
463
464 // i64x2 integer SIMD
465 I64x2Add, // i64x2.add
466 I64x2Sub, // i64x2.sub
467 I64x2Mul, // i64x2.mul
468 I64x2Neg, // i64x2.neg
469 I64x2Eq, // i64x2.eq
470 I64x2Ne, // i64x2.ne
471 I64x2LtS, // i64x2.lt_s
472 I64x2GtS, // i64x2.gt_s
473 I64x2LeS, // i64x2.le_s
474 I64x2GeS, // i64x2.ge_s
475 I64x2Splat, // i64x2.splat
476 I64x2ExtractLane(u8), // i64x2.extract_lane
477 I64x2ReplaceLane(u8), // i64x2.replace_lane
478
479 // f32x4 floating-point SIMD
480 F32x4Add, // f32x4.add
481 F32x4Sub, // f32x4.sub
482 F32x4Mul, // f32x4.mul
483 F32x4Div, // f32x4.div
484 F32x4Abs, // f32x4.abs
485 F32x4Neg, // f32x4.neg
486 F32x4Sqrt, // f32x4.sqrt
487 F32x4Eq, // f32x4.eq
488 F32x4Ne, // f32x4.ne
489 F32x4Lt, // f32x4.lt
490 F32x4Le, // f32x4.le
491 F32x4Gt, // f32x4.gt
492 F32x4Ge, // f32x4.ge
493 F32x4Splat, // f32x4.splat
494 F32x4ExtractLane(u8), // f32x4.extract_lane
495 F32x4ReplaceLane(u8), // f32x4.replace_lane
496}
497
498/// The highest local index the body references, +1 (0 when it touches none).
499///
500/// #970 (RQ-57-CONDPARAM): this is the param-count bound every backend uses
501/// when the driver SUPPLIED a declared count, because `min(referenced,
502/// declared)` is EXACT — it names every index that is really a param, and the
503/// clamp means a genuine non-param local can never be mistaken for one.
504///
505/// It replaces the `count_params` access-pattern heuristic on that path, which
506/// was UNSOUND: that heuristic counts only indices READ BEFORE WRITTEN in
507/// LINEAR op order, so a param written before it is read — but only
508/// CONDITIONALLY — was reclassified as a non-param local. Worse, its first
509/// access being a WRITE meant the read-before-write zero-init (#457) skipped it
510/// too, so the branch that does NOT write it read an UNINITIALISED frame slot:
511///
512/// ```wat
513/// (func (export "f") (param i32 i32) (result i32)
514/// (if (local.get 0) (then (local.set 1 (i32.const 5))))
515/// (local.get 1)) ;; f(0, 42): wasmtime 42, synth <previous frame>
516/// ```
517///
518/// Measured on cb80e60c under unicorn with the sub-SP stack poisoned, BOTH the
519/// ARM and RV32 backends returned the poison word rather than 42 — an
520/// information-disclosure shape, not merely a wrong value.
521///
522/// Using `min(referenced, declared)` rather than plain `declared` PRESERVES the
523/// leniency for a function with more declared params than the backend can
524/// register-home that only touches the first few: such a body still lowers,
525/// exactly as before.
526///
527/// Shared by the ARM (`synth-backend`), RISC-V (`synth-backend-riscv`) and
528/// AArch64 (`synth-backend-aarch64`) backends — three private copies of the
529/// same three-line rule is precisely the drift `rewrite_memory_grow_zero`
530/// below was centralised to avoid (#242, VCR-SEL-005).
531pub fn referenced_locals(wasm_ops: &[WasmOp]) -> u32 {
532 wasm_ops
533 .iter()
534 .filter_map(|op| match op {
535 WasmOp::LocalGet(i) | WasmOp::LocalSet(i) | WasmOp::LocalTee(i) => Some(*i + 1),
536 _ => None,
537 })
538 .max()
539 .unwrap_or(0)
540}
541
542/// The read-before-write param-count HEURISTIC, used only when the driver
543/// supplied NO declared count.
544///
545/// RQ-58-MIRRORS (#242): this existed as THREE byte-equivalent private copies —
546/// `synth-backend/src/arm_backend.rs`, `synth-backend-riscv/src/backend.rs` and
547/// `synth-backend-aarch64/src/backend.rs` — differing only in local variable
548/// names and rustfmt line breaks. #974 collapsed [`referenced_locals`], which
549/// REPLACED this heuristic on the declared-count path, but left the heuristic
550/// itself triplicated: the fix was centralised and the bug's original carrier
551/// was not. Three copies of an UNSOUND rule is worse than three copies of a
552/// sound one, because a correction applied to one of them silently does not
553/// reach the other two.
554///
555/// UNSOUND, deliberately kept and deliberately named: it counts only indices
556/// READ BEFORE WRITTEN in LINEAR op order, so a conditionally-written param is
557/// misclassified — see [`referenced_locals`] for the measured
558/// information-disclosure shape (#970). It survives ONLY on the no-declared-
559/// count path (direct `compile_function` callers and hand-built op streams),
560/// where there is no signature to clamp against. Every caller that HAS a
561/// declared count must use `min(referenced_locals(ops), declared)` instead.
562pub fn count_params_heuristic(wasm_ops: &[WasmOp]) -> u32 {
563 let mut first_access: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
564 for op in wasm_ops {
565 match op {
566 WasmOp::LocalGet(idx) => {
567 first_access.entry(*idx).or_insert(true);
568 }
569 WasmOp::LocalSet(idx) | WasmOp::LocalTee(idx) => {
570 first_access.entry(*idx).or_insert(false);
571 }
572 _ => {}
573 }
574 }
575 first_access
576 .iter()
577 .filter_map(|(&idx, &is_read_first)| if is_read_first { Some(idx + 1) } else { None })
578 .max()
579 .unwrap_or(0)
580}
581
582/// Fold `i32.const 0; memory.grow` → `memory.size` up front, on every backend.
583///
584/// WASM Core §4.4.7: growing a memory by ZERO pages can never fail — it returns
585/// the current size. But every backend's `memory.grow` lowering on FIXED
586/// (non-growable) linear memory returns the "grow failed" sentinel `-1`, which
587/// would wrongly report failure for the legal `memory.grow(0)` "read current
588/// size" idiom. Rewriting the const-0 case to the semantically identical
589/// `memory.size` BEFORE selection fixes it uniformly. (A runtime-variable page
590/// count that happens to be 0 still lowers to `-1` — that is a documented
591/// follow-up, not this fold's concern; only the SYNTACTIC `i32.const 0` form is
592/// the well-known idiom.)
593///
594/// Shared by the ARM (`synth-backend`) and RISC-V (`synth-backend-riscv`)
595/// backend entry points so the two cannot drift (#242, VCR-SEL-005) — it lives
596/// here in `synth-core` next to `WasmOp` because both crates depend on it.
597pub fn rewrite_memory_grow_zero(wasm_ops: &[WasmOp]) -> Vec<WasmOp> {
598 let mut out = Vec::with_capacity(wasm_ops.len());
599 let mut i = 0;
600 while i < wasm_ops.len() {
601 if matches!(wasm_ops[i], WasmOp::I32Const(0))
602 && let Some(WasmOp::MemoryGrow(m)) = wasm_ops.get(i + 1)
603 {
604 out.push(WasmOp::MemorySize(*m));
605 i += 2;
606 } else {
607 out.push(wasm_ops[i].clone());
608 i += 1;
609 }
610 }
611 out
612}
613
614/// #1093 — find the first PARAMETER-taking block type in a function's op
615/// stream: the k-th `Block`/`Loop`/`If` (ordinal-keyed, matching the decoder's
616/// blocktype-arity side-table `FunctionOps::block_arity` /
617/// `CompileConfig::current_func_block_arity`) whose `(params, results)` arity
618/// has `params != 0`. Returns `(construct, ordinal, arity)` for the decline
619/// message; `None` when every block type is parameter-free — including the
620/// EMPTY side-table of hand-built op streams, which reads as all-void (the
621/// legacy behaviour, so nothing moves for existing callers).
622///
623/// WHY THIS EXISTS (the aarch64 selector's VCR-A64-CF-001 frame-open refusal,
624/// ported — aarch64 was the only backend that already declined this class):
625/// the ARM direct selector and the RV32 selector both checkpoint the operand
626/// stack at frame ENTRY and reconcile if/else arms and branch edges against
627/// that checkpoint. A parameter-taking block type consumes operands that sit
628/// BELOW the checkpoint, so (all MEASURED on v0.60.0, #1093):
629/// - `if (param ..) .. else ..` PANICS in both selectors — the `Else` arm's
630/// `split_off(checkpoint)` walks past the shrunken vstack
631/// ("`at` split index (is 2) should be <= len (is 1)", exit 101);
632/// - `if (param ..)` WITHOUT an else SILENTLY returns the wrong value on the
633/// false path (measured `ipe(0)` → 0, want 7, on all four ARM/RV32 legs —
634/// the "implicit else has nothing to reconcile" assumption is false once
635/// the frame has params);
636/// - a `br_if` into a `block (param ..)` and a back-edge to a
637/// `loop (param ..)` header mis-reconcile the join value on RV32
638/// (measured wrong values; ARM already declined the loop case via #509).
639///
640/// Only the branch-free fall-through shape happens to compile correctly, and
641/// telling it apart from the broken shapes would be a NEW predicate with its
642/// own proof burden — so, exactly like aarch64, the whole class declines
643/// loudly at the first parameter-taking frame.
644pub fn find_param_block_type(
645 wasm_ops: &[WasmOp],
646 block_arity: &[(u8, u8)],
647) -> Option<(&'static str, usize, (u8, u8))> {
648 find_unlowered_param_block_type(wasm_ops, block_arity, ParamBlockLowering::DECLINED)
649}
650
651/// RQ-64-MVLOWER (#1093): which parameter-taking constructs a given codegen
652/// path has PROVEN it lowers — proven meaning the #1097 acceptance oracle
653/// (`scripts/repro/param_block_silent_1097_differential.py`) lists that
654/// (construct, backend) leg as `LOWERED` and executes every one of its
655/// vectors, the pinned pre-#1096 silent-wrong ones included, against
656/// wasmtime. Everything not set here declines exactly as before. The policy
657/// lives HERE, once, and every guard site derives from it, so the backend
658/// choke point and the selector-local guard cannot drift apart.
659#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
660pub struct ParamBlockLowering {
661 /// `block (param ..)` lowers on this path.
662 pub block: bool,
663 /// `if (param ..)` lowers on this path.
664 pub r#if: bool,
665 /// `loop (param ..)` lowers on this path.
666 pub r#loop: bool,
667}
668
669impl ParamBlockLowering {
670 /// The pre-RQ-64 reading: every parameter-taking construct declines.
671 pub const DECLINED: Self = Self {
672 block: false,
673 r#if: false,
674 r#loop: false,
675 };
676
677 fn lowers(self, what: &str) -> bool {
678 match what {
679 "block" => self.block,
680 "if" => self.r#if,
681 "loop" => self.r#loop,
682 _ => false,
683 }
684 }
685}
686
687/// RQ-64-MVLOWER: the ARM policy, keyed on the ONE configuration the #1097
688/// oracle executes — `--relocatable` (#197: the direct selector, ET_REL leaf
689/// objects the oracle's loader runs under unicorn). The self-contained image
690/// path (optimized selector, or the direct selector via `--no-optimize` /
691/// the #509 value-carry routing) has NO oracle leg executing its images, so
692/// it keeps the full decline: the same selector code is not the same
693/// evidence, and the guard is relaxed on evidence only.
694pub fn arm_param_block_lowering(relocatable: bool) -> ParamBlockLowering {
695 if relocatable {
696 ParamBlockLowering {
697 // Increment 1 (RQ-64-MVLOWER): block params are plain operand-
698 // stack entries and #509's designated-result-register landing
699 // already reconciles every forward edge into the join —
700 // measured correct on the #1097 fixture pre-#1096 and re-proven
701 // on the LOWERED leg (fixture + extra + sub-shape vectors).
702 block: true,
703 // Increment 2 (RQ-64-MVLOWER): the frame-entry checkpoint lands
704 // BELOW the params, the params are snapshotted for the else-arm /
705 // implicit else, and the else-less join is reconciled like a two-
706 // arm if (hazardous permutations decline loudly). Proven on the
707 // LOWERED if/arm leg: the fixture's 0xC0DE0003 vector now returns
708 // wasmtime's 7, plus the extra probes and `_if_lowered.wat`.
709 r#if: true,
710 // Increment 3 (RQ-64-MVLOWER): a parameter-taking loop gets a
711 // private header register per parameter, reserved for the loop's
712 // extent and written by every back-edge (the if/block designated-
713 // register idea at the loop HEADER). Proven on the LOWERED
714 // loop/arm leg: the RV32-pinned lpb(3) vector (2, want 10) plus
715 // extra probes and `_loop_lowered.wat` (two params, a value below
716 // the param, unconditional back-edge + forward exit, an aliased
717 // r0 as the loop param).
718 r#loop: true,
719 }
720 } else {
721 ParamBlockLowering::DECLINED
722 }
723}
724
725/// #1093 / RQ-64-MVLOWER — like [`find_param_block_type`], but a construct
726/// the path has proven (`lowered`) is skipped: the first parameter-taking
727/// `Block`/`Loop`/`If` that is NOT lowered on this path is returned for the
728/// decline message; `None` when every parameter-taking frame is lowered (or
729/// there is none).
730pub fn find_unlowered_param_block_type(
731 wasm_ops: &[WasmOp],
732 block_arity: &[(u8, u8)],
733 lowered: ParamBlockLowering,
734) -> Option<(&'static str, usize, (u8, u8))> {
735 if block_arity.iter().all(|&(p, _)| p == 0) {
736 return None; // fast path: no parameter-taking type anywhere
737 }
738 let mut ord = 0usize;
739 for op in wasm_ops {
740 let what = match op {
741 WasmOp::Block => "block",
742 WasmOp::Loop => "loop",
743 WasmOp::If => "if",
744 _ => continue,
745 };
746 let arity = block_arity.get(ord).copied().unwrap_or((0, 0));
747 if arity.0 != 0 && !lowered.lowers(what) {
748 return Some((what, ord, arity));
749 }
750 ord += 1;
751 }
752 None
753}
754
755/// #1093 — the one shared decline message for a parameter-taking block type.
756/// ARM and RV32 both call this, so their refusal wording is one definition
757/// with nothing to drift (the same sharing rationale as
758/// [`rewrite_memory_grow_zero`] above). `backend` names the declining
759/// selector; the needle "PARAMETER-taking block type" deliberately matches
760/// the aarch64 VCR-A64-CF-001 message so cross-backend decline-parity probes
761/// can use one predicate for all three.
762pub fn param_block_decline_msg(backend: &str, what: &str, ord: usize, arity: (u8, u8)) -> String {
763 let why = match what {
764 // Still-declined on every path (RQ-64-MVLOWER increment 1): the
765 // frame-entry checkpoint is taken with the params still on the
766 // operand stack, so they sit BELOW it.
767 "if" => {
768 "the RV32 checkpoint at frame entry cannot represent params \
769 consumed BELOW it (with an `else` the reconciliation split \
770 panics; without one the false path returns an uninitialized \
771 register — measured, #1097) and the ARM direct lowering is proven \
772 by the #1097 oracle only on --relocatable (RQ-64-MVLOWER); no \
773 other path has an execution-oracle leg"
774 }
775 "loop" => {
776 "the RV32 back-edge mis-reconciles the header value (measured \
777 lpb(3) = 2, want 10, #1097) and the ARM direct lowering is proven \
778 by the #1097 oracle only on --relocatable (RQ-64-MVLOWER); no \
779 other path has an execution-oracle leg"
780 }
781 _ => {
782 "the RV32 checkpoint drops the carried parameter on a branch edge \
783 (measured, #1097) and the ARM direct lowering is proven by the \
784 #1097 oracle only on --relocatable (RQ-64-MVLOWER); no other path \
785 has an execution-oracle leg"
786 }
787 };
788 format!(
789 "{what} #{ord} has type {arity:?} — a PARAMETER-taking block type \
790 (multi-value) is not lowered on {backend}: {why}; loud-declining \
791 (#1093, the aarch64 VCR-A64-CF-001 refusal ported)"
792 )
793}
794
795#[cfg(test)]
796mod grow_zero_tests {
797 use super::*;
798
799 #[test]
800 fn folds_const_zero_grow_to_size() {
801 assert_eq!(
802 rewrite_memory_grow_zero(&[WasmOp::I32Const(0), WasmOp::MemoryGrow(0)]),
803 vec![WasmOp::MemorySize(0)]
804 );
805 }
806
807 #[test]
808 fn leaves_nonzero_grow_alone() {
809 assert_eq!(
810 rewrite_memory_grow_zero(&[WasmOp::I32Const(2), WasmOp::MemoryGrow(0)]),
811 vec![WasmOp::I32Const(2), WasmOp::MemoryGrow(0)]
812 );
813 }
814
815 #[test]
816 fn leaves_variable_grow_alone() {
817 assert_eq!(
818 rewrite_memory_grow_zero(&[WasmOp::LocalGet(0), WasmOp::MemoryGrow(0)]),
819 vec![WasmOp::LocalGet(0), WasmOp::MemoryGrow(0)]
820 );
821 }
822
823 #[test]
824 fn preserves_memory_index() {
825 assert_eq!(
826 rewrite_memory_grow_zero(&[WasmOp::I32Const(0), WasmOp::MemoryGrow(3)]),
827 vec![WasmOp::MemorySize(3)]
828 );
829 }
830
831 // ── #1093: find_param_block_type — the ported VCR-A64-CF-001 predicate ──
832
833 #[test]
834 fn param_block_empty_table_is_void() {
835 // Hand-built op streams carry no side-table: every block reads as
836 // void, the legacy behaviour — the check must never fire.
837 let ops = [WasmOp::Block, WasmOp::If, WasmOp::End, WasmOp::End];
838 assert_eq!(find_param_block_type(&ops, &[]), None);
839 }
840
841 #[test]
842 fn param_block_all_void_is_none() {
843 let ops = [WasmOp::Block, WasmOp::Loop, WasmOp::End, WasmOp::End];
844 assert_eq!(find_param_block_type(&ops, &[(0, 1), (0, 0)]), None);
845 }
846
847 #[test]
848 fn param_block_reports_construct_and_ordinal() {
849 // The #1093 repro shape: one `if` with type (2, 1).
850 let ops = [
851 WasmOp::I32Const(1),
852 WasmOp::I32Const(2),
853 WasmOp::LocalGet(0),
854 WasmOp::If,
855 WasmOp::I32Add,
856 WasmOp::Else,
857 WasmOp::I32Sub,
858 WasmOp::End,
859 ];
860 assert_eq!(
861 find_param_block_type(&ops, &[(2, 1)]),
862 Some(("if", 0, (2, 1)))
863 );
864 }
865
866 #[test]
867 fn param_block_ordinal_keying_matches_decoder_order() {
868 // Ordinals count Block/Loop/If in op-stream order (the decoder's
869 // side-table contract) — the offender here is the SECOND construct.
870 let ops = [
871 WasmOp::Block, // ord 0, void
872 WasmOp::Loop, // ord 1, (1, 1) — parameter-taking
873 WasmOp::End,
874 WasmOp::End,
875 ];
876 assert_eq!(
877 find_param_block_type(&ops, &[(0, 0), (1, 1)]),
878 Some(("loop", 1, (1, 1)))
879 );
880 }
881
882 #[test]
883 fn param_block_msg_carries_the_parity_needle() {
884 // Cross-backend decline-parity probes match on this exact needle,
885 // shared with the aarch64 VCR-A64-CF-001 message.
886 let msg = param_block_decline_msg("the ARM direct selector", "if", 0, (2, 1));
887 assert!(msg.contains("PARAMETER-taking block type"));
888 assert!(msg.contains("if #0 has type (2, 1)"));
889 assert!(msg.contains("#1093"));
890 }
891
892 // ── RQ-64-MVLOWER: the per-construct, per-path relaxation ──────────────
893 #[test]
894 fn unlowered_predicate_skips_only_the_lowered_construct() {
895 use WasmOp::*;
896 // block #0 (1,1), then if #1 (1,1)
897 let ops = vec![
898 I32Const(7),
899 Block,
900 LocalGet(0),
901 BrIf(0),
902 End,
903 I32Const(7),
904 LocalGet(0),
905 If,
906 I32Const(1),
907 I32Add,
908 End,
909 End,
910 ];
911 let arity = vec![(1, 1), (1, 1)];
912 // Fully declined: the FIRST parameter-taking frame is reported.
913 assert_eq!(
914 find_unlowered_param_block_type(&ops, &arity, ParamBlockLowering::DECLINED),
915 Some(("block", 0, (1, 1)))
916 );
917 assert_eq!(
918 find_param_block_type(&ops, &arity),
919 Some(("block", 0, (1, 1))),
920 "the legacy entry point is the all-declined reading"
921 );
922 // block lowered: the if is the first UNLOWERED frame, ordinal intact.
923 let block_only = ParamBlockLowering {
924 block: true,
925 ..ParamBlockLowering::DECLINED
926 };
927 assert_eq!(
928 find_unlowered_param_block_type(&ops, &arity, block_only),
929 Some(("if", 1, (1, 1)))
930 );
931 // everything lowered: nothing to decline.
932 let all = ParamBlockLowering {
933 block: true,
934 r#if: true,
935 r#loop: true,
936 };
937 assert_eq!(find_unlowered_param_block_type(&ops, &arity, all), None);
938 // A param-free table never fires regardless of policy.
939 assert_eq!(
940 find_unlowered_param_block_type(&ops, &[(0, 1), (0, 1)], ParamBlockLowering::DECLINED),
941 None
942 );
943 }
944
945 #[test]
946 fn arm_policy_is_keyed_on_the_oracle_covered_configuration() {
947 // --relocatable is the ONE configuration the #1097 oracle executes:
948 // increment 1 lowers `block` there and nothing else, and nothing
949 // anywhere else.
950 assert_eq!(
951 arm_param_block_lowering(true),
952 ParamBlockLowering {
953 block: true,
954 r#if: true,
955 r#loop: true
956 }
957 );
958 assert_eq!(
959 arm_param_block_lowering(false),
960 ParamBlockLowering::DECLINED
961 );
962 }
963}