From e55ade1daec71dfc6dc7e80d6636d56d60a3d619 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 15 Jul 2026 12:20:47 +0200 Subject: [PATCH 1/6] wip: session-limit salvage snapshot (agent died mid-lane; resume from here) Co-Authored-By: Claude Opus 4.8 --- crates/synth-backend/src/arm_backend.rs | 4 + crates/synth-core/src/backend.rs | 12 + crates/synth-core/src/wasm_decoder.rs | 142 +++++++- crates/synth-core/src/wasm_op.rs | 17 + crates/synth-core/src/wasm_stack_check.rs | 5 + .../src/instruction_selector.rs | 327 +++++++++++++++++- .../synth-synthesis/src/optimizer_bridge.rs | 27 ++ 7 files changed, 518 insertions(+), 16 deletions(-) diff --git a/crates/synth-backend/src/arm_backend.rs b/crates/synth-backend/src/arm_backend.rs index d9bbccbe..8dd8d111 100644 --- a/crates/synth-backend/src/arm_backend.rs +++ b/crates/synth-backend/src/arm_backend.rs @@ -415,6 +415,10 @@ fn compile_wasm_to_arm( selector.set_reject_self_contained_call_indirect(!config.relocatable); // #237: native-pointer ABI — wasm statics become __synth_wasm_data-relative. selector.set_native_pointer_abi(config.native_pointer_abi, config.linear_memory_bytes); + // VCR-MEM-002 phase 1 (#406): per-memory initial page counts — enables + // the multi-memory arms (memory-0 lowering never reads it; empty ⇒ + // every multi-memory op declines loudly). + selector.set_memory_pages(config.memory_pages.clone()); // #311: i64 call results are register PAIRS — tag them. selector.set_result_types(config.func_ret_i64.clone(), config.type_ret_i64.clone()); // #359: declared param widths of THIS function, so the AAPCS stack-arg diff --git a/crates/synth-core/src/backend.rs b/crates/synth-core/src/backend.rs index 7e2cb0c7..6952328e 100644 --- a/crates/synth-core/src/backend.rs +++ b/crates/synth-core/src/backend.rs @@ -148,6 +148,15 @@ pub struct CompileConfig { /// → `[R11=0 + addr]`. pub linear_memory_bytes: u32, + /// VCR-MEM-002 phase 1 (#406): initial size in 64 KiB pages of EACH linear + /// memory, indexed by memory index. Consulted only by the multi-memory + /// lowering arms (loads/stores wrapped in `WasmOp::MultiMemory`, + /// `memory.size`/`grow` with a non-zero index) — memory-0 lowering never + /// reads it, so single-memory output is byte-identical whether it is set + /// or empty. Empty (the default) means "no multi-memory context": any + /// multi-memory op then declines loudly. + pub memory_pages: Vec, + /// #237: the wasm stack-pointer global as `(index, init_value)`, if the /// module has one. Under `native_pointer_abi` the backend register-promotes /// it: `global.get` materializes `__synth_wasm_data + init` (the real stack @@ -389,6 +398,9 @@ impl Default for CompileConfig { linmem_base: OPTIMIZED_LINMEM_BASE, native_pointer_abi: false, linear_memory_bytes: 0, + // #406: empty ⇒ no multi-memory context ⇒ multi-memory ops decline + // loudly; memory-0 lowering never reads it. + memory_pages: Vec::new(), stack_pointer_global: None, func_ret_i64: Vec::new(), type_ret_i64: Vec::new(), diff --git a/crates/synth-core/src/wasm_decoder.rs b/crates/synth-core/src/wasm_decoder.rs index aa16fda1..6ab13bff 100644 --- a/crates/synth-core/src/wasm_decoder.rs +++ b/crates/synth-core/src/wasm_decoder.rs @@ -278,8 +278,24 @@ pub struct DecodedModule { pub functions: Vec, /// Linear memories pub memories: Vec, - /// Data segments (offset, data) for memory initialization + /// Data segments (offset, data) for memory initialization. + /// + /// MEMORY 0 ONLY — the legacy single-memory field every existing consumer + /// reads; its shape and contents are unchanged by multi-memory (#406). + /// Segments targeting memory > 0 live in + /// [`Self::extra_memory_data_segments`]. pub data_segments: Vec<(u32, Vec)>, + /// VCR-MEM-002 phase 1 (#406): active const-offset data segments on + /// NON-DEFAULT memories, as `(memory_index, offset, bytes)` with + /// `memory_index > 0`. Previously these were silently dropped (memory k + /// shipped uninitialized while its loads compiled). Declaration order. + pub extra_memory_data_segments: Vec<(u32, u32, Vec)>, + /// VCR-MEM-002 phase 1 (#406): `Some(reason)` when the module contains a + /// multi-memory shape decode cannot lower (e.g. an active data segment on + /// memory > 0 with a non-constant offset). The multi-memory compile path + /// must decline LOUDLY with this reason; single-memory modules never set + /// it. + pub multi_memory_decline: Option, /// Import entries (module name, field name, kind) pub imports: Vec, /// Number of imported functions (for distinguishing import calls from local calls) @@ -605,6 +621,11 @@ pub fn decode_wasm_module(wasm_bytes: &[u8]) -> Result { let mut functions = Vec::new(); let mut memories = Vec::new(); let mut data_segments = Vec::new(); + // VCR-MEM-002 phase 1 (#406): (memory_index, offset, bytes) for active + // const-offset data segments on memory > 0, and the first decode-level + // reason multi-memory lowering must be declined (if any). + let mut extra_memory_data_segments: Vec<(u32, u32, Vec)> = Vec::new(); + let mut multi_memory_decline: Option = None; let mut globals: Vec = Vec::new(); let mut imports = Vec::new(); let mut func_index = 0u32; @@ -1013,13 +1034,41 @@ pub fn decode_wasm_module(wasm_bytes: &[u8]) -> Result { for data in reader { let data = data.context("Failed to parse data segment")?; if let wasmparser::DataKind::Active { - memory_index: 0, + memory_index, offset_expr, } = data.kind { let mut ops = offset_expr.get_operators_reader(); - if let Ok(wasmparser::Operator::I32Const { value }) = ops.read() { - data_segments.push((value as u32, data.data.to_vec())); + let const_off = match ops.read() { + Ok(wasmparser::Operator::I32Const { value }) => Some(value as u32), + _ => None, + }; + if memory_index == 0 { + // Memory-0 behavior unchanged (frozen): a const- + // offset segment is captured, anything else keeps + // the legacy drop. + if let Some(off) = const_off { + data_segments.push((off, data.data.to_vec())); + } + } else if let Some(off) = const_off { + // VCR-MEM-002 phase 1 (#406): capture non-default- + // memory segments — previously they were silently + // DROPPED (memory k's init data never shipped). + extra_memory_data_segments.push(( + memory_index, + off, + data.data.to_vec(), + )); + } else { + // A non-const offset on a non-default memory cannot + // be placed at compile time — record it so the + // multi-memory compile path declines LOUDLY instead + // of shipping memory k uninitialized. + multi_memory_decline.get_or_insert(format!( + "active data segment on memory {memory_index} has a \ + non-constant offset expression — cannot be placed \ + at compile time (multi-memory phase 1, #406)" + )); } } } @@ -1187,6 +1236,8 @@ pub fn decode_wasm_module(wasm_bytes: &[u8]) -> Result { functions, memories, data_segments, + extra_memory_data_segments, + multi_memory_decline, imports, num_imported_funcs, func_arg_counts, @@ -1572,13 +1623,46 @@ fn decode_function_body( for this target (#680)" )); } + // VCR-MEM-002 phase 1 (#406): a load/store whose memarg targets a + // NON-DEFAULT memory must carry its index — dropping it silently + // aliased every memory onto the one R11 base (a store to memory + // `$b` clobbered memory `$a`). memidx 0 stays the bare variant, so + // single-memory streams are bit-identical by construction. + let wasm_op = match memarg_memory_index(&op) { + Some(mem) if mem > 0 => WasmOp::MultiMemory { + memory: mem, + op: Box::new(wasm_op), + }, + _ => wasm_op, + }; ops.push(wasm_op); op_offsets.push(offset as u32); } else if unsupported.is_none() && !is_intentionally_ignored(&op) { - // The op was DROPPED by `convert_operator` (`_ => None`) and is not - // an intentional no-op (Nop) — record it so the - // function is loud-skipped rather than silently miscompiled (#369). - unsupported = Some(format!("{op:?}")); + // #406 phase 1: bulk-memory ops on a non-default memory (including + // the cross-memory `memory.copy` dst_mem != src_mem form) have no + // lowering yet — name the decline precisely instead of the generic + // dropped-op message. + unsupported = match &op { + wasmparser::Operator::MemoryCopy { dst_mem, src_mem } + if *dst_mem != 0 || *src_mem != 0 => + { + Some(format!( + "memory.copy dst_mem={dst_mem} src_mem={src_mem}: \ + cross-/non-default-memory memory.copy is not lowered \ + in multi-memory phase 1 (#406) — only memory-0 \ + memory.copy is supported" + )) + } + wasmparser::Operator::MemoryFill { mem } if *mem != 0 => Some(format!( + "memory.fill mem={mem}: non-default-memory memory.fill is \ + not lowered in multi-memory phase 1 (#406) — only \ + memory-0 memory.fill is supported" + )), + // The op was DROPPED by `convert_operator` (`_ => None`) and is + // not an intentional no-op (Nop) — record it so the function is + // loud-skipped rather than silently miscompiled (#369). + _ => Some(format!("{op:?}")), + }; } } @@ -1630,6 +1714,48 @@ fn is_simd_operator(op: &wasmparser::Operator) -> bool { wasmparser::for_each_operator!(define_match_operator) } +/// VCR-MEM-002 phase 1 (#406): the `memarg.memory` index of a load/store +/// operator that [`convert_operator`] lowers, `None` for every other op. +/// +/// MIRROR PIN: this list must cover exactly the memarg-carrying arms of +/// `convert_operator` (every `{ memarg }` load/store it returns `Some` for). +/// A memarg op missing HERE but lowered THERE would silently drop a non-zero +/// memory index again — the pre-#406 aliasing bug. Ops `convert_operator` +/// drops (`_ => None`) loud-skip their function regardless, so they need no +/// entry. `memory.size`/`grow`/`copy`/`fill` carry their indices in their own +/// `WasmOp` variants / decode-time declines, not via this helper. +fn memarg_memory_index(op: &wasmparser::Operator) -> Option { + use wasmparser::Operator::*; + match op { + I32Load { memarg } + | I32Store { memarg } + | I64Load { memarg } + | I64Store { memarg } + | I32Load8S { memarg } + | I32Load8U { memarg } + | I32Load16S { memarg } + | I32Load16U { memarg } + | I32Store8 { memarg } + | I32Store16 { memarg } + | I64Load8S { memarg } + | I64Load8U { memarg } + | I64Load16S { memarg } + | I64Load16U { memarg } + | I64Load32S { memarg } + | I64Load32U { memarg } + | I64Store8 { memarg } + | I64Store16 { memarg } + | I64Store32 { memarg } + | F32Load { memarg } + | F32Store { memarg } + | F64Load { memarg } + | F64Store { memarg } + | V128Load { memarg } + | V128Store { memarg } => Some(memarg.memory), + _ => None, + } +} + /// Convert a wasmparser Operator to our WasmOp enum fn convert_operator(op: &wasmparser::Operator) -> Option { use wasmparser::Operator::*; diff --git a/crates/synth-core/src/wasm_op.rs b/crates/synth-core/src/wasm_op.rs index d7e6fd57..c0e12da2 100644 --- a/crates/synth-core/src/wasm_op.rs +++ b/crates/synth-core/src/wasm_op.rs @@ -91,6 +91,23 @@ pub enum WasmOp { MemoryCopy, // memory.copy: copy `len` bytes from `src` to `dst` (memmove semantics) MemoryFill, // memory.fill: set `len` bytes at `dst` to the low byte of `val` + /// VCR-MEM-002 phase 1 (#406): a load/store whose `memarg` targets a + /// NON-DEFAULT linear memory (`memidx > 0`, multi-memory proposal). The + /// decoder wraps the plain memory-0 variant instead of DROPPING the index + /// (the pre-#406 silent aliasing: every memory lowered to the one R11 + /// base, so a store to memory `$b` clobbered memory `$a`). Keeping + /// memory-0 ops as the bare variants means every existing single-memory + /// match arm — and therefore every frozen fixture byte — is untouched by + /// construction; the multi-memory-aware path (the `--relocatable` direct + /// selector) unwraps this and addresses via the per-memory base symbol + /// (`__synth_wasm_data_`), and every other path declines LOUDLY + /// (never a silent alias). + /// + /// `memory.size`/`memory.grow` are NOT wrapped — their variants already + /// carry the memory index. Invariant (decoder-enforced): `memory > 0` and + /// `op` is never itself a `MultiMemory`. + MultiMemory { memory: u32, op: Box }, + // More ops Drop, Select, diff --git a/crates/synth-core/src/wasm_stack_check.rs b/crates/synth-core/src/wasm_stack_check.rs index 0d19d0a3..16eafe50 100644 --- a/crates/synth-core/src/wasm_stack_check.rs +++ b/crates/synth-core/src/wasm_stack_check.rs @@ -206,6 +206,11 @@ fn stack_effect_or_bail(op: &WasmOp) -> StackEffect { // three i32 operands and push nothing. MemoryCopy | MemoryFill => modeled(3, 0), + // ---- multi-memory (#406) ---------------------------------------- + // A wrapped load/store has exactly its inner op's stack effect — the + // memory index changes the BASE it addresses, not the operand shape. + MultiMemory { op, .. } => stack_effect_or_bail(op), + // ---- select / nop ----------------------------------------------- // select: pops two values and a condition (i32), pushes one value Select => modeled(3, 1), diff --git a/crates/synth-synthesis/src/instruction_selector.rs b/crates/synth-synthesis/src/instruction_selector.rs index a365b6f4..c2a86abf 100644 --- a/crates/synth-synthesis/src/instruction_selector.rs +++ b/crates/synth-synthesis/src/instruction_selector.rs @@ -1880,6 +1880,10 @@ pub fn infer_i64_locals( fn wasm_stack_effect(op: &WasmOp) -> (usize, usize) { use WasmOp::*; match op { + // Multi-memory (#406): a wrapped load/store has exactly its inner + // op's operand shape — only the addressed base differs. + MultiMemory { op, .. } => wasm_stack_effect(op), + // Binary ops: pop 2, push 1 I32Add | I32Sub | I32Mul | I32DivS | I32DivU | I32RemS | I32RemU | I32And | I32Or | I32Xor | I32Shl | I32ShrS | I32ShrU | I32Rotl | I32Rotr | I32Eq | I32Ne | I32LtS @@ -2836,6 +2840,14 @@ pub struct InstructionSelector { /// relative (MOVW/MOVT), so a `base=0` host-pointer trampoline doesn't /// mis-address them. native_pointer_abi: bool, + /// VCR-MEM-002 phase 1 (#406): initial size in 64 KiB pages of each linear + /// memory, indexed by memory index. Consulted ONLY by the multi-memory + /// arms (`MultiMemory` wraps, `memory.size`/`grow` with index > 0): + /// memory-0 lowering never reads it, so single-memory output is + /// byte-identical whether it is set or empty. Empty (the default and every + /// legacy caller) means "no multi-memory context" — any multi-memory op + /// then declines loudly. + memory_pages: Vec, /// #237: wasm linear-memory minimum size in bytes — the static-data extent /// (initialized `(data)` + zero-init/BSS). A const address below this is a /// static (symbol-relative under the flag). @@ -3053,6 +3065,7 @@ impl InstructionSelector { relocatable: false, reject_self_contained_call_indirect: false, native_pointer_abi: false, + memory_pages: Vec::new(), linear_memory_bytes: 0, wasm_data_base: 0, sp_global: None, @@ -3100,6 +3113,7 @@ impl InstructionSelector { relocatable: false, reject_self_contained_call_indirect: false, native_pointer_abi: false, + memory_pages: Vec::new(), linear_memory_bytes: 0, wasm_data_base: 0, sp_global: None, @@ -3422,6 +3436,67 @@ impl InstructionSelector { self.linear_memory_bytes = linear_memory_bytes; } + /// VCR-MEM-002 phase 1 (#406): per-memory initial page counts, indexed by + /// memory index. Enables the multi-memory lowering arms (`MultiMemory` + /// loads/stores via `__synth_wasm_data_`, per-memory `memory.size`). + /// Memory-0 lowering never consults this, so setting it on a + /// single-memory module changes nothing. Empty (default) declines every + /// multi-memory op loudly. + pub fn set_memory_pages(&mut self, memory_pages: Vec) { + self.memory_pages = memory_pages; + } + + /// #406: the per-memory base symbol the ELF/link layer defines for memory + /// `k`. Memory 0 keeps the historical `__synth_wasm_data` name + /// (frozen-safe); memory k > 0 is `__synth_wasm_data_`. + fn wasm_data_symbol(memory: u32) -> String { + if memory == 0 { + "__synth_wasm_data".to_string() + } else { + format!("__synth_wasm_data_{memory}") + } + } + + /// VCR-MEM-002 phase 1 (#406): validate that an op on non-default memory + /// `memory` is lowerable in THIS selector configuration and return the + /// memory's initial page count. The typed declines (never silent): + /// + /// * not `--relocatable` → a self-contained image has exactly one runtime + /// linear-memory base (R11); nothing places a second region. + /// * `--native-pointer-abi` → the static-data classification + /// (`static_data_addend`, `__synth_wasm_data` region layers #345/#354/ + /// #383/#678/#739) is memory-0-only; combining is a later phase. + /// * unknown index → the module declared fewer memories (or the driver + /// never supplied the table — legacy/unit-test construction). + fn multi_memory_pages(&self, memory: u32) -> synth_core::Result { + if !self.relocatable { + return Err(synth_core::Error::synthesis(format!( + "multi-memory: an op on memory {memory} cannot be lowered into a \ + self-contained image — there is only ONE runtime linear-memory \ + base (R11), so every memory would alias it (a store to memory \ + {memory} silently clobbering memory 0). Compile with \ + --relocatable: the host-linked object addresses memory {memory} \ + via its own `__synth_wasm_data_{memory}` region symbol, which \ + the runtime places (VCR-MEM-002 phase 1, #406)" + ))); + } + if self.native_pointer_abi { + return Err(synth_core::Error::synthesis(format!( + "multi-memory: an op on memory {memory} is not lowered under \ + --native-pointer-abi — the static-data region classification \ + (#345/#354/#678) is memory-0-only in phase 1 (#406). Drop \ + --native-pointer-abi or keep the module single-memory" + ))); + } + self.memory_pages.get(memory as usize).copied().ok_or_else(|| { + synth_core::Error::synthesis(format!( + "multi-memory: op targets memory {memory} but the module context \ + declares {} memories (#406)", + self.memory_pages.len() + )) + }) + } + /// #237: under the native-pointer ABI, a const effective address `addr` /// within the wasm linear memory `[0, linear_memory_bytes)` is a wasm static /// (whether an initialized `(data)` byte or a zero-init/BSS variable like a @@ -4235,16 +4310,50 @@ impl InstructionSelector { } // Memory management - MemorySize(_mem_idx) => { + MemorySize(mem_idx) => { // On embedded with fixed memory, return memory size in pages. // R10 holds memory size in bytes; divide by 65536 (page size) via LSR #16. + // #406: R10 is MEMORY 0's size — reading it for memory k > 0 + // silently returned the wrong memory's size. Decline; the + // per-memory lowering lives in select_with_stack. + if *mem_idx != 0 { + return Err(synth_core::Error::synthesis(format!( + "memory.size on memory {mem_idx}: select_default has no \ + per-memory size lowering (R10 is memory 0's size \ + register) — multi-memory is lowered only by \ + select_with_stack on --relocatable (#406)" + ))); + } vec![ArmOp::MemorySize { rd }] } - MemoryGrow(_mem_idx) => { + MemoryGrow(mem_idx) => { // On embedded with fixed memory, always return -1 (cannot grow). + // #406: `-1` is memory-agnostic, but keep the blind path + // memory-0-only — a multi-memory module belongs to + // select_with_stack (--relocatable). + if *mem_idx != 0 { + return Err(synth_core::Error::synthesis(format!( + "memory.grow on memory {mem_idx}: multi-memory is lowered \ + only by select_with_stack on --relocatable (#406)" + ))); + } vec![ArmOp::MemoryGrow { rd, rn }] } + // VCR-MEM-002 phase 1 (#406): select_default is the blind-alloc + // fallback — it does not track the operand stack, and it has no + // per-memory base plumbing. The real multi-memory lowering lives + // in select_with_stack (--relocatable); here we loud-decline + // (the GI-FPU-001/#372 contract), never alias memory 0. + MultiMemory { memory, op } => { + return Err(synth_core::Error::synthesis(format!( + "multi-memory: {op:?} on memory {memory} is lowered only by \ + the stack-tracking selector (select_with_stack) on the \ + --relocatable path — select_default would alias it onto \ + memory 0's base (#406)" + ))); + } + // FIXME: select_default LocalGet/Set ignores index (hardcoded SP+0). // Currently unreachable because select_with_stack handles these ops. // See issue #72. @@ -10740,7 +10849,7 @@ impl InstructionSelector { } // Memory management - MemorySize(_mem_idx) => { + MemorySize(mem_idx) => { let dst = alloc_temp_or_spill( &mut next_temp, &mut stack, @@ -10749,14 +10858,56 @@ impl InstructionSelector { &live_params, idx, )?; - instructions.push(ArmInstruction { - op: ArmOp::MemorySize { rd: dst }, - source_line: Some(idx), - }); + if *mem_idx == 0 { + // Memory 0: runtime size register (R10 >> 16 = pages), + // byte-identical to the pre-#406 lowering. + instructions.push(ArmInstruction { + op: ArmOp::MemorySize { rd: dst }, + source_line: Some(idx), + }); + } else { + // VCR-MEM-002 phase 1 (#406): memory k > 0 has no + // runtime size register (R10 belongs to memory 0 — + // reading it here silently returned memory 0's size). + // Its size is FIXED at the declared initial page count: + // `memory.grow` on this backend always lowers to the + // fixed-memory -1 (see `ArmOp::MemoryGrow`), so the + // size can never change — materialize the constant. + // The runtime contract is that the embedder maps the + // `__synth_wasm_data_` region at exactly its + // declared initial size (the ELF NOBITS/PROGBITS + // section is emitted at that size). + let pages = self.multi_memory_pages(*mem_idx)?; + instructions.push(ArmInstruction { + op: ArmOp::Movw { + rd: dst, + imm16: (pages & 0xFFFF) as u16, + }, + source_line: Some(idx), + }); + if pages > 0xFFFF { + instructions.push(ArmInstruction { + op: ArmOp::Movt { + rd: dst, + imm16: ((pages >> 16) & 0xFFFF) as u16, + }, + source_line: Some(idx), + }); + } + } stack.push(StackVal::i32(dst)); } - MemoryGrow(_mem_idx) => { + MemoryGrow(mem_idx) => { + // VCR-MEM-002 phase 1 (#406): the fixed-memory `-1` + // lowering below is memory-agnostic (no state read), so it + // is equally correct for memory k > 0 — but only in a + // configuration whose multi-memory context is validated + // (relocatable, known index). Same typed declines as the + // load/store path. + if *mem_idx != 0 { + self.multi_memory_pages(*mem_idx)?; + } // Pop the requested number of pages from stack let pages = pop_operand( &mut stack, @@ -10781,6 +10932,166 @@ impl InstructionSelector { stack.push(StackVal::i32(dst)); } + // ========================================================= + // Multi-memory (#406, VCR-MEM-002 phase 1) + // ========================================================= + // A load/store on a NON-DEFAULT memory (memidx > 0). R11 is + // memory 0's base; memory k is addressed base-independently + // via its own `__synth_wasm_data_` region symbol (Abs32 + // literal-pool load, the #345 link-survivable form), which + // build_relocatable_elf defines at the base of memory k's + // NOBITS/PROGBITS section: + // + // LDR base, =__synth_wasm_data_k + memarg.offset + // ADD base, base, addr + // LDR/STR[B/H] value, [base] + // + // Phase-1 scope: the i32 access family only. i64/f32/f64/v128 + // accesses and `--safety-bounds` on memory k decline LOUDLY + // (typed Err → loud-skip), never alias memory 0. + MultiMemory { + memory, + op: inner_op, + } => { + // Validates the configuration (relocatable, no + // native-pointer ABI, known index) — pages unused here. + self.multi_memory_pages(*memory)?; + if self.bounds_check != BoundsCheckConfig::None { + return Err(synth_core::Error::synthesis(format!( + "multi-memory: --safety-bounds is not lowered for an \ + op on memory {memory} in phase 1 — the bounds \ + machinery (R10/mask) is memory-0-only (#406)" + ))); + } + let sym = Self::wasm_data_symbol(*memory); + match inner_op.as_ref() { + I32Load { offset, .. } + | I32Load8S { offset, .. } + | I32Load8U { offset, .. } + | I32Load16S { offset, .. } + | I32Load16U { offset, .. } => { + let addr = pop_operand( + &mut stack, + &mut next_temp, + &mut instructions, + &mut spill, + &live_params, + idx, + )?; + let dst = alloc_temp_or_spill( + &mut next_temp, + &mut stack, + &mut instructions, + &mut spill, + &[live_params.as_slice(), &[addr]].concat(), + idx, + )?; + let base = alloc_temp_or_spill( + &mut next_temp, + &mut stack, + &mut instructions, + &mut spill, + &[live_params.as_slice(), &[addr, dst]].concat(), + idx, + )?; + Self::emit_sym_addr( + &mut instructions, + base, + &sym, + *offset as i32, + idx, + ); + instructions.push(ArmInstruction { + op: ArmOp::Add { + rd: base, + rn: base, + op2: Operand2::Reg(addr), + }, + source_line: Some(idx), + }); + let mem = MemAddr::imm(base, 0); + let load_op = match inner_op.as_ref() { + I32Load { .. } => ArmOp::Ldr { rd: dst, addr: mem }, + I32Load8U { .. } => ArmOp::Ldrb { rd: dst, addr: mem }, + I32Load8S { .. } => ArmOp::Ldrsb { rd: dst, addr: mem }, + I32Load16U { .. } => ArmOp::Ldrh { rd: dst, addr: mem }, + I32Load16S { .. } => ArmOp::Ldrsh { rd: dst, addr: mem }, + _ => unreachable!(), + }; + instructions.push(ArmInstruction { + op: load_op, + source_line: Some(idx), + }); + cf.add_instructions(3); + stack.push(StackVal::i32(dst)); + } + I32Store { offset, .. } + | I32Store8 { offset, .. } + | I32Store16 { offset, .. } => { + // WASM store pops: value first, then address. + let value = pop_operand( + &mut stack, + &mut next_temp, + &mut instructions, + &mut spill, + &live_params, + idx, + )?; + let addr = pop_operand( + &mut stack, + &mut next_temp, + &mut instructions, + &mut spill, + &live_params, + idx, + )?; + let base = alloc_temp_or_spill( + &mut next_temp, + &mut stack, + &mut instructions, + &mut spill, + &[live_params.as_slice(), &[addr, value]].concat(), + idx, + )?; + Self::emit_sym_addr( + &mut instructions, + base, + &sym, + *offset as i32, + idx, + ); + instructions.push(ArmInstruction { + op: ArmOp::Add { + rd: base, + rn: base, + op2: Operand2::Reg(addr), + }, + source_line: Some(idx), + }); + let mem = MemAddr::imm(base, 0); + let store_op = match inner_op.as_ref() { + I32Store { .. } => ArmOp::Str { rd: value, addr: mem }, + I32Store8 { .. } => ArmOp::Strb { rd: value, addr: mem }, + I32Store16 { .. } => ArmOp::Strh { rd: value, addr: mem }, + _ => unreachable!(), + }; + instructions.push(ArmInstruction { + op: store_op, + source_line: Some(idx), + }); + cf.add_instructions(3); + } + other => { + return Err(synth_core::Error::synthesis(format!( + "multi-memory: {other:?} on memory {memory} is not \ + lowered in phase 1 — only the i32 load/store \ + family is (#406); wider/float accesses on a \ + non-default memory decline loudly" + ))); + } + } + } + // ========================================================= // Control flow operations // ========================================================= diff --git a/crates/synth-synthesis/src/optimizer_bridge.rs b/crates/synth-synthesis/src/optimizer_bridge.rs index ea71b315..662bc857 100644 --- a/crates/synth-synthesis/src/optimizer_bridge.rs +++ b/crates/synth-synthesis/src/optimizer_bridge.rs @@ -2753,6 +2753,33 @@ impl OptimizerBridge { ))); } + // VCR-MEM-002 phase 1 (#406): the optimized path has no per-memory + // base — `wasm_to_ir` would drop a `MultiMemory` load/store to the + // catch-all `Opcode::Nop` (the #120/#93 silent-drop class) or, worse, + // alias it onto the one absolute linmem base. Decline up front; the + // direct selector lowers it on --relocatable (and declines loudly + // everywhere else). + if let Some(mm_op) = wasm_ops + .iter() + .find(|op| matches!(op, WasmOp::MultiMemory { .. })) + { + return Err(Error::UnsupportedInstruction(format!( + "optimized lowering path does not support multi-memory ops \ + ({mm_op:?}); only the direct selector on --relocatable lowers \ + a non-default memory — issue #406" + ))); + } + // #406: same for `memory.size`/`memory.grow` on a non-default memory — + // the optimized path's lowering reads R10 / emits -1 for MEMORY 0. + if let Some(ms_op) = wasm_ops.iter().find( + |op| matches!(op, WasmOp::MemorySize(k) | WasmOp::MemoryGrow(k) if *k != 0), + ) { + return Err(Error::UnsupportedInstruction(format!( + "optimized lowering path does not support {ms_op:?} on a \ + non-default memory (R10 is memory 0's size register) — issue #406" + ))); + } + // #372: the optimized path also has no IR opcode for full-width // `i64.load`/`i64.store` — it would drop them to a stub (`ld64` -> `bx // lr`). Decline so the backend falls back to the direct selector, which From a5589b573ffa1353d12cbaf81bfd9d2ec359f76b Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 15 Jul 2026 12:33:01 +0200 Subject: [PATCH 2/6] feat(#406): driver plumbing + per-memory ELF regions for multi-memory phase 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compile_all_exports threads extra_memory_data_segments + multi_memory_decline from the decoder; module-level LOUD gates (decode decline reason, non-ARM backend, self-contained/!--relocatable, --native-pointer-abi, --shadow-stack-size) fire before any op is selected. - CompileConfig.memory_pages populated from the declared memories (indexed by memory index; memory-0 lowering never reads it). - build_relocatable_elf: one reservation section per non-default memory (.synth.wasm_mem_, PROGBITS with placed init segments / NOBITS when pure zero-init) + a __synth_wasm_data_ base symbol, with segment-bounds and orphan-segment refusals. Single-memory modules pass empty lists — output byte-identical (frozen gate 10/10 green). VCR-MEM-002 phase 1, #406/#242. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- crates/synth-cli/src/main.rs | 192 +++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index 3204b999..7455e98f 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -2421,6 +2421,8 @@ fn compile_all_exports( all_type_ret_f32, // GI-FPU-002 phase 2 (#719/#369): per-type returns-f32 (call_indirect) all_type_ret_f64, // GI-FPU-002 phase 2 (#719/#369): per-type returns-f64 (call_indirect) all_wsc_facts, // VCR-PERF-002 Phase 1 (#494): loom wsc.facts premises + all_extra_memory_segments, // #406: (mem_idx>0, offset, bytes) init segments on non-default memories + multi_memory_decline, // #406: decode-level reason multi-memory must decline (if any) all_call_indirect_guards, // #642: table size + closed-world type verdicts ) = if path.extension().is_some_and(|ext| ext == "wast") { info!("Parsing WAST (extracting all modules)..."); @@ -2526,6 +2528,8 @@ fn compile_all_exports( Vec::new(), // GI-FPU-002 phase 2: WAST fixture suite is i32-only — no f32-ret types Vec::new(), // GI-FPU-002 phase 2: WAST fixture suite is i32-only — no f64-ret types Vec::new(), // #494: facts are a loom-emitted-.wasm channel; WAST fixtures carry none + Vec::new(), // #406: non-default-memory data segments are single-module .wasm only + None, // #406: the WAST fixture suite is single-memory — no decline reason // #642: the multi-module WAST merge has no single table image to // verify — the default guards DECLINE any call_indirect (the WAST // fixture suite carries none), never an unchecked branch. @@ -2627,6 +2631,11 @@ fn compile_all_exports( module.type_ret_f32, module.type_ret_f64, module.wsc_facts, + // VCR-MEM-002 phase 1 (#406): active const-offset data segments on + // memories > 0 (previously silently dropped) + the decode-level + // decline reason (e.g. a non-const segment offset on memory k). + module.extra_memory_data_segments, + module.multi_memory_decline, guards, // #642 ) }; @@ -2649,6 +2658,56 @@ fn compile_all_exports( } } + // VCR-MEM-002 phase 1 (#406): module-level multi-memory gates. The + // per-op declines in the selector catch every lowering hole, but these + // whole-module shapes are wrong BEFORE any op is selected — a multi-memory + // module on an unsupported path would ship memory k's init data dropped + // and its region unplaced even if no compiled function touches it. Refuse + // loudly up front (the #383/#739 honest-fail rule), never alias memory 0. + if let Some(reason) = &multi_memory_decline { + anyhow::bail!("multi-memory (#406): {reason}"); + } + if all_memories.len() > 1 { + if backend.name() != "arm" { + anyhow::bail!( + "multi-memory (#406): the '{}' backend has no per-memory base \ + lowering — a module with {} linear memories compiles only on \ + the ARM --relocatable path (VCR-MEM-002 phase 1)", + backend.name(), + all_memories.len() + ); + } + if !relocatable { + anyhow::bail!( + "multi-memory (#406): a module with {} linear memories cannot \ + be compiled into a self-contained image — there is only ONE \ + runtime linear-memory base (R11), so every memory would alias \ + it (a store to memory 1 silently clobbering memory 0). Compile \ + with --relocatable: memory k > 0 is addressed via its own \ + `__synth_wasm_data_` region symbol, which the host \ + linker/runtime places (VCR-MEM-002 phase 1)", + all_memories.len() + ); + } + if native_pointer_abi { + anyhow::bail!( + "multi-memory (#406): --native-pointer-abi is memory-0-only in \ + phase 1 — the static-data region classification \ + (#345/#354/#678/#739) has no per-memory layering. Drop \ + --native-pointer-abi or keep the module single-memory" + ); + } + if shadow_stack_size.is_some() { + anyhow::bail!( + "multi-memory (#406): --shadow-stack-size shrinks MEMORY 0's \ + reservation geometry and has no defined meaning for a module \ + with {} linear memories in phase 1 — refusing rather than \ + shrinking the wrong region", + all_memories.len() + ); + } + } + // Log import information if max_num_imported_funcs > 0 { info!( @@ -2692,6 +2751,22 @@ fn compile_all_exports( // become __synth_wasm_data-relative across the whole linear memory. native_pointer_abi, linear_memory_bytes: all_memories.first().map(|m| m.initial_bytes()).unwrap_or(0), + // VCR-MEM-002 phase 1 (#406): per-memory initial page counts, indexed + // by memory index. Consulted ONLY by the multi-memory lowering arms — + // memory-0 lowering never reads it, so single-memory output is + // byte-identical whether it is set or empty. + memory_pages: { + let slots = all_memories + .iter() + .map(|m| m.index as usize + 1) + .max() + .unwrap_or(0); + let mut pages = vec![0u32; slots]; + for m in &all_memories { + pages[m.index as usize] = m.initial_pages; + } + pages + }, // #237: register-promote the stack-pointer global (only consulted under // native_pointer_abi) so the dissolved object needs no R9 globals table. stack_pointer_global: stack_pointer_global_opt, @@ -3097,6 +3172,15 @@ fn compile_all_exports( target_spec, // #676: heterogeneous-table type-id sidecar (empty = no section). &config.call_indirect_guards.type_ids_image, + // VCR-MEM-002 phase 1 (#406): non-default memories become distinct + // `__synth_wasm_data_` regions. Empty for every single-memory + // module ⇒ byte-identical output. + &all_memories + .iter() + .filter(|m| m.index > 0) + .map(|m| (m.index, m.initial_bytes())) + .collect::>(), + &all_extra_memory_segments, )? } else if cortex_m { // #649: the self-contained image materializes the R9 globals table — @@ -3385,6 +3469,16 @@ fn build_relocatable_elf( // #676: the call_indirect type-id sidecar image (one u32 class id per // table slot, region order; empty = no heterogeneous table = no section). table_type_ids: &[u32], + // VCR-MEM-002 phase 1 (#406): NON-DEFAULT linear memories as + // `(memory_index, initial_bytes)` with `memory_index > 0`. Each gets its + // own reservation section + `__synth_wasm_data_` base symbol, so the + // host linker/runtime places every memory as a distinct region. Empty + // (every single-memory module) ⇒ zero new sections/symbols ⇒ output + // byte-identical. + extra_memories: &[(u32, u32)], + // #406: active const-offset data segments on non-default memories, as + // `(memory_index, offset, bytes)` — placed inside memory k's section. + extra_memory_data_segments: &[(u32, u32, Vec)], ) -> Result> { use std::collections::HashMap; @@ -4008,6 +4102,82 @@ fn build_relocatable_elf( } } + // VCR-MEM-002 phase 1 (#406): section-index bookkeeping for the per-memory + // regions below. The fixed prefix is null=0, shstrtab=1, strtab=2, + // symtab=3, `.text`=4; the memory-0 wasm-data region (when emitted) + // occupies 5, plus 6 when its PROGBITS companion was emitted (the #354 + // mixed split's `.data`, or the #345 split's globals `.data` — the latter + // is unconditional because `split_linmem_bss` requires `native_layout`). + let mut next_section_index: u16 = 5; + if emit_wasm_data { + next_section_index += if do_mixed_split || split_linmem_bss { + 2 + } else { + 1 + }; + } + + // #406: one reservation section per NON-DEFAULT memory, in memory-index + // order, each `__synth_wasm_data_`-addressable. A memory with init + // segments ships PROGBITS (segments placed at their offsets, rest zero — + // previously these segments were silently DROPPED); a pure zero-init + // memory ships NOBITS (no flash cost). The runtime contract mirrors + // memory 0's: the embedder maps each section at its full declared initial + // size, which is also what the per-memory `memory.size` constant lowering + // assumes (memory.grow on this backend always returns -1, so the initial + // size is the size). + let mut extra_memory_syms: Vec<(String, u16)> = Vec::new(); + for &(mem_idx, mem_bytes) in extra_memories { + assert!(mem_idx > 0, "#406: memory 0 is the legacy wasm-data region"); + let segs: Vec<&(u32, u32, Vec)> = extra_memory_data_segments + .iter() + .filter(|(k, _, _)| *k == mem_idx) + .collect(); + for (_, off, d) in &segs { + if (*off as u64) + (d.len() as u64) > mem_bytes as u64 { + anyhow::bail!( + "multi-memory (#406): active data segment [{off:#x}, \ + {:#x}) overflows memory {mem_idx}'s declared initial size \ + ({mem_bytes} B) — instantiation would trap; refusing to \ + truncate", + (*off as u64) + (d.len() as u64) + ); + } + } + let name = format!(".synth.wasm_mem_{mem_idx}"); + let section = if segs.is_empty() { + Section::new(&name, ElfSectionType::NoBits) + .with_flags(SectionFlags::ALLOC | SectionFlags::WRITE) + .with_addr(0) + .with_align(4) + .with_size(mem_bytes) + } else { + let mut blob = vec![0u8; mem_bytes as usize]; + for (_, off, d) in &segs { + blob[*off as usize..*off as usize + d.len()].copy_from_slice(d); + } + Section::new(&name, ElfSectionType::ProgBits) + .with_flags(SectionFlags::ALLOC | SectionFlags::WRITE) + .with_addr(0) + .with_align(4) + .with_data(blob) + }; + elf_builder.add_section(section); + extra_memory_syms.push((format!("__synth_wasm_data_{mem_idx}"), next_section_index)); + next_section_index += 1; + } + // #406: a segment targeting a memory index with no declared region would + // otherwise be silently dropped — the exact pre-#406 failure mode. + for (k, _, _) in extra_memory_data_segments { + if !extra_memories.iter().any(|(i, _)| i == k) { + anyhow::bail!( + "multi-memory (#406): active data segment targets memory {k}, \ + which has no declared region in this object — refusing to \ + drop its init bytes" + ); + } + } + // Symbol-name -> symbol index map. Symbol indices are 1-based (index 0 is // the reserved null symbol); each `add_symbol` appends one, so we track the // running count to know the index of each symbol we define. @@ -4119,6 +4289,22 @@ fn build_relocatable_elf( } } + // VCR-MEM-002 phase 1 (#406): define `__synth_wasm_data_` at the base + // of each non-default memory's reservation section. The selector's + // `LdrSym` literal-pool words carry `R_ARM_ABS32` against these, so the + // host linker places each memory as its own region — never aliased onto + // memory 0's base. + for (name, shndx) in &extra_memory_syms { + let mem_sym = Symbol::new(name) + .with_value(0) + .with_binding(SymbolBinding::Global) + .with_type(SymbolType::Object) + .with_section(*shndx); + elf_builder.add_symbol(mem_sym); + sym_count += 1; + sym_indices.insert(name.clone(), sym_count); + } + let mut import_label_to_field: HashMap = HashMap::new(); for imp in imports { if matches!(imp.kind, synth_core::ImportKind::Function(_)) { @@ -6325,6 +6511,8 @@ mod tests { None, &TargetSpec::cortex_m3(), &[], + &[], // #406: single-memory — no extra regions + &[], ) .expect("#345: native-pointer zero-linmem object builds"); @@ -6432,6 +6620,8 @@ mod tests { None, &TargetSpec::cortex_m3(), &[], + &[], // #406: single-memory — no extra regions + &[], ) .expect("#345: native-pointer literal-pool object builds"); @@ -6527,6 +6717,8 @@ mod tests { None, &TargetSpec::cortex_m3(), &[], + &[], // #406: single-memory — no extra regions + &[], ) .expect("#354: mixed-case object builds"); From 03abd37ab5b14c41362b4b8a223ade177dff8d35 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 15 Jul 2026 12:36:26 +0200 Subject: [PATCH 3/6] =?UTF-8?q?test(#406):=20multi-memory=20execution=20di?= =?UTF-8?q?fferential=20=E2=80=94=202=20memories=20at=20distinct=20unicorn?= =?UTF-8?q?=20bases=20vs=20wasmtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 29 cases green on the branch (store-both aliasing discriminators, memory-1 init-data peeks, sub-word round-trips, per-memory size/grow); RED on origin/main (exit 1: the object lacks .synth.wasm_mem_1 — the pre-#406 silent-aliasing object has no per-memory region/symbol). Structural pins: __synth_wasm_data_1 DEFINED, .synth.wasm_mem_1 PROGBITS == 3 pages with the init segment placed, every ABS32 reloc in-range, and at least one memory-1 reloc (an object with none is the aliasing bug). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- scripts/repro/mem406_multi_memory.wat | 58 +++++ .../repro/multi_memory_406_differential.py | 217 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 scripts/repro/mem406_multi_memory.wat create mode 100644 scripts/repro/multi_memory_406_differential.py diff --git a/scripts/repro/mem406_multi_memory.wat b/scripts/repro/mem406_multi_memory.wat new file mode 100644 index 00000000..3dc204c0 --- /dev/null +++ b/scripts/repro/mem406_multi_memory.wat @@ -0,0 +1,58 @@ +;; #406 (VCR-MEM-002 phase 1) — two linear memories must be two DISTINCT +;; native regions. +;; +;; Pre-#406 synth dropped the memarg memory index at decode: every load/store +;; lowered to the ONE R11 base, so a store to memory $m1 silently clobbered +;; memory $m0 (store_both_read0 returned y instead of x), memory $m1's init +;; data never shipped, and memory.size/grow on $m1 read memory $m0's R10. +;; +;; Post-#406 (--relocatable): memory 0 keeps the runtime R11 base; memory 1 is +;; addressed via its own `__synth_wasm_data_1` region symbol (R_ARM_ABS32 +;; literal pool), placed by the host linker/runtime — see +;; multi_memory_406_differential.py. +;; +;; Both memories are max==initial so memory.grow fails (-1) in wasmtime too, +;; matching synth's fixed-memory lowering. Sizes differ (1 vs 3 pages) so a +;; memory.size alias is observable. +(module + (memory $m0 1 1) + (memory $m1 3 3) + (data (memory $m1) (i32.const 16) "\aa\bb\cc\dd\11\22\33\44") + + ;; The aliasing discriminator: store x to $m0[p], y to $m1[p], read back + ;; $m0[p]. Correct: x. Pre-#406 (both stores on one base): y. + (func (export "store_both_read0") (param $p i32) (param $x i32) (param $y i32) (result i32) + (i32.store (local.get $p) (local.get $x)) + (i32.store $m1 (local.get $p) (local.get $y)) + (i32.load (local.get $p))) + + ;; Mirror: read back $m1[p]. Correct: y (and $m0[p] left = x). + (func (export "store_both_read1") (param $p i32) (param $x i32) (param $y i32) (result i32) + (i32.store (local.get $p) (local.get $x)) + (i32.store $m1 (local.get $p) (local.get $y)) + (i32.load $m1 (local.get $p))) + + ;; Init-data peeks on memory 1 (pre-#406 these segments were silently + ;; DROPPED — memory 1 shipped uninitialized). Exercises the static memarg + ;; offset (folded into the __synth_wasm_data_1 addend) + dynamic index. + (func (export "peek1_8u") (param $i i32) (result i32) + (i32.load8_u $m1 offset=16 (local.get $i))) + (func (export "peek1_16u") (param $i i32) (result i32) + (i32.load16_u $m1 offset=16 (local.get $i))) + (func (export "peek1_32") (param $i i32) (result i32) + (i32.load $m1 offset=16 (local.get $i))) + + ;; Sub-word store round-trips on memory 1 (sign + zero extends). + (func (export "narrow1_8") (param $p i32) (param $v i32) (result i32) + (i32.store8 $m1 (local.get $p) (local.get $v)) + (i32.load8_s $m1 (local.get $p))) + (func (export "narrow1_16") (param $p i32) (param $v i32) (result i32) + (i32.store16 $m1 (local.get $p) (local.get $v)) + (i32.load16_s $m1 (local.get $p))) + + ;; Per-memory size/grow. size0 = 1 (runtime R10), size1 = 3 (declared + ;; constant — memory.grow always fails on this backend, so initial == size). + (func (export "size0") (result i32) (memory.size $m0)) + (func (export "size1") (result i32) (memory.size $m1)) + (func (export "grow1") (param $n i32) (result i32) + (memory.grow $m1 (local.get $n)))) diff --git a/scripts/repro/multi_memory_406_differential.py b/scripts/repro/multi_memory_406_differential.py new file mode 100644 index 00000000..bd7eec5f --- /dev/null +++ b/scripts/repro/multi_memory_406_differential.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""#406 (VCR-MEM-002 phase 1) execution differential: two wasm linear +memories must be two DISTINCT native regions. + +Pre-#406 the decoder dropped the memarg memory index, so every load/store +lowered onto the ONE R11 base — a store to memory 1 silently clobbered +memory 0 (`store_both_read0` returned y instead of x), memory 1's init data +was silently dropped, and `memory.size` on memory 1 read memory 0's R10. + +Post-#406 (`--relocatable`): memory 0 keeps the runtime R11 base (byte- +identical to the single-memory lowering); memory 1 is addressed through its +own `__synth_wasm_data_1` symbol (R_ARM_ABS32 literal-pool words) defined at +the base of the object's `.synth.wasm_mem_1` reservation section. + +This oracle compiles `mem406_multi_memory.wat` with `--relocatable`, maps the +two memories at DISTINCT unicorn bases (memory 0 at the R11 base, memory 1 at +the address the ABS32 relocs are patched to — deliberately far apart so any +residual aliasing faults or mismatches), and checks every export against +wasmtime multi-memory ground truth. Also pins the structure: the object must +DEFINE `__synth_wasm_data_1`, carry a `.synth.wasm_mem_1` PROGBITS section of +exactly 3 wasm pages with the init segment placed at offset 16, and every +ABS32 reloc against it must land in-range. + +Run (needs wasmtime + unicorn + pyelftools): + SYNTH=./target/debug/synth python scripts/repro/multi_memory_406_differential.py +Exits nonzero on any mismatch. RED pre-#406 (silent aliasing / dropped init +data), GREEN post-#406. +""" + +import os +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + +import wasmtime +from elftools.elf.elffile import ELFFile +from unicorn import UC_ARCH_ARM, UC_MODE_THUMB, Uc, UcError +from unicorn.arm_const import ( + UC_ARM_REG_LR, + UC_ARM_REG_R0, + UC_ARM_REG_R1, + UC_ARM_REG_R2, + UC_ARM_REG_R10, + UC_ARM_REG_R11, + UC_ARM_REG_SP, +) + +WAT = Path(__file__).with_name("mem406_multi_memory.wat") +SYNTH = os.environ.get("SYNTH", "./target/release/synth") +ABS32 = 2 +PAGE = 0x10000 + +# Distinct bases per region — the point of the whole exercise. Memory 0 lives +# at the R11 base; memory 1 lives where the `__synth_wasm_data_1` relocs are +# patched to. Far apart: a store aliased onto the wrong base lands in the +# other region (value mismatch) or unmapped space (unicorn fault) — never +# accidentally correct. +TEXT_BASE = 0x10000 +MEM0_BASE = 0x200000 # R11 (1 wasm page) +MEM1_BASE = 0x400000 # __synth_wasm_data_1 (3 wasm pages) +STACK_BASE = 0x600000 +MEM1_SECTION = ".synth.wasm_mem_1" + + +def wasmtime_truth(fn, args): + """Fresh instance per call (store-then-load must not leak across cases).""" + engine = wasmtime.Engine() + module = wasmtime.Module(engine, WAT.read_text()) + store = wasmtime.Store(engine) + inst = wasmtime.Instance(store, module, []) + return inst.exports(store)[fn](store, *args) & 0xFFFFFFFF + + +def main(): + obj = Path(tempfile.mkdtemp(prefix="mem406_")) / "mem406.o" + proc = subprocess.run( + [ + SYNTH, + "compile", + str(WAT), + "-o", + str(obj), + "--target", + "cortex-m3", + "--all-exports", + "--relocatable", + ], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + print(proc.stderr.strip()) + print("FAIL: compile declined — multi-memory not lowered (#406)") + return 1 + + e = ELFFile(open(obj, "rb")) + secname_by_idx = {i: s.name for i, s in enumerate(e.iter_sections())} + text = bytearray(e.get_section_by_name(".text").data()) + + # Structural pins: the per-memory region + its base symbol. + mem1 = e.get_section_by_name(MEM1_SECTION) + if mem1 is None: + print(f"FAIL: object lacks {MEM1_SECTION} — memory 1 has no region (#406)") + return 1 + if mem1["sh_size"] != 3 * PAGE: + print(f"FAIL: {MEM1_SECTION} is {mem1['sh_size']} B, want {3 * PAGE} (3 pages)") + return 1 + if mem1["sh_type"] != "SHT_PROGBITS": + print(f"FAIL: {MEM1_SECTION} is {mem1['sh_type']} — init segment dropped (#406)") + return 1 + mem1_image = bytes(mem1.data()) + if mem1_image[16:24] != b"\xaa\xbb\xcc\xdd\x11\x22\x33\x44": + print("FAIL: memory 1's init segment bytes not placed at offset 16 (#406)") + return 1 + + symtab = [s for s in e.iter_sections() if s["sh_type"] == "SHT_SYMTAB"][0] + syms = {} + for s in symtab.iter_symbols(): + syms[s.name] = (s["st_shndx"], s["st_value"]) + if "__synth_wasm_data_1" not in syms or not isinstance( + syms["__synth_wasm_data_1"][0], int + ): + print("FAIL: __synth_wasm_data_1 is not a DEFINED symbol (#406)") + return 1 + + # Patch every ABS32 literal-pool word: memory 1's words to MEM1_BASE (with + # an in-range pin), anything else to its own section base (none expected on + # this plain-relocatable fixture, but stay general). + sec_bases = {MEM1_SECTION: MEM1_BASE, ".text": TEXT_BASE} + rel = e.get_section_by_name(".rel.text") + n_mem1_relocs = 0 + for r in rel.iter_relocations() if rel is not None else []: + if r["r_info_type"] != ABS32: + continue + sym = symtab.get_symbol(r["r_info_sym"]) + shndx, val = syms[sym.name] + secname = secname_by_idx[shndx] + (add,) = struct.unpack_from(" 3 * PAGE: + print( + f"FAIL: reloc {sym.name}+{add:#x} targets past memory 1's " + f"3-page region (#406)" + ) + return 1 + if secname not in sec_bases: + print(f"FAIL: unexpected ABS32 reloc into section {secname}") + return 1 + struct.pack_into( + " Date: Wed, 15 Jul 2026 12:38:19 +0200 Subject: [PATCH 4/6] test(#406): multi-memory capability/decline matrix (13 cases) GREEN: 2- and 3-memory ARM --relocatable objects (per-memory NOBITS/PROGBITS regions, defined __synth_wasm_data_, init bytes placed, no _0 alias). REFUSED loudly: no --relocatable, self-contained --cortex-m, --native-pointer-abi, --shadow-stack-size, riscv, aarch64, cross-memory memory.copy, non-default memory.fill, i64 access on memory k, non-const segment offset, overflowing segment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- crates/synth-cli/tests/multi_memory_406.rs | 321 +++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 crates/synth-cli/tests/multi_memory_406.rs diff --git a/crates/synth-cli/tests/multi_memory_406.rs b/crates/synth-cli/tests/multi_memory_406.rs new file mode 100644 index 00000000..aa6f59ee --- /dev/null +++ b/crates/synth-cli/tests/multi_memory_406.rs @@ -0,0 +1,321 @@ +//! #406 (VCR-MEM-002 phase 1) — multi-memory capability matrix. +//! +//! N wasm linear memories lower to N DISTINCT native base regions on the ARM +//! `--relocatable` path: memory 0 keeps the runtime R11 base (single-memory +//! lowering untouched — the frozen byte gate pins that), memory k > 0 is +//! addressed via its own `__synth_wasm_data_` symbol at the base of a +//! per-memory `.synth.wasm_mem_` reservation section. +//! +//! Everything OUTSIDE that lane must decline LOUDLY (typed, precise), never +//! silently alias every memory onto one base (the pre-#406 bug): +//! +//! | shape | verdict | +//! |----------------------------------------|--------------------------------| +//! | 2 memories, ARM `--relocatable` | GREEN (execution differential) | +//! | 3 memories, ARM `--relocatable` | GREEN (generic over K) | +//! | multi-memory, no `--relocatable` | refuse (one R11 base) | +//! | multi-memory, self-contained --cortex-m| refuse (same) | +//! | multi-memory × `--native-pointer-abi` | refuse (static region is mem-0)| +//! | multi-memory × `--shadow-stack-size` | refuse (shrinks mem-0 geometry)| +//! | multi-memory on riscv / aarch64 | refuse (no per-memory base) | +//! | cross-/non-default memory.copy/fill | loud-skip naming the op | +//! | i64/f32 access on memory k > 0 | loud-skip (i32 family only) | +//! | non-const data-segment offset on mem k | refuse at decode | +//! +//! Execution ground truth: `scripts/repro/multi_memory_406_differential.py` +//! (unicorn two-region mapping vs wasmtime multi-memory). + +use std::path::PathBuf; +use std::process::Command; + +use object::{Object, ObjectSection, ObjectSymbol, SectionKind}; + +fn synth() -> &'static str { + env!("CARGO_BIN_EXE_synth") +} + +/// The differential's fixture — 2 memories, init data on memory 1. +fn two_mem_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("scripts/repro/mem406_multi_memory.wat") +} + +/// Write an inline wat to a temp file and return its path. +fn wat_file(name: &str, wat: &str) -> PathBuf { + let dir = std::env::temp_dir().join("synth_mem406_tests"); + std::fs::create_dir_all(&dir).expect("mkdir"); + let p = dir.join(name); + std::fs::write(&p, wat).expect("write wat"); + p +} + +fn compile(input: &PathBuf, extra: &[&str]) -> std::process::Output { + let out = std::env::temp_dir() + .join("synth_mem406_tests") + .join(format!( + "{}_{}.o", + input.file_stem().unwrap().to_str().unwrap(), + extra.join("").replace(['-', '/', ' '], "") + )); + let mut args = vec![ + "compile", + input.to_str().unwrap(), + "--all-exports", + "-o", + out.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + Command::new(synth()).args(&args).output().expect("run synth") +} + +fn stderr(out: &std::process::Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +/// A refusal must be loud AND precise: nonzero exit + names multi-memory/#406. +fn assert_refused(out: &std::process::Output, must_mention: &[&str], ctx: &str) { + assert!( + !out.status.success(), + "{ctx}: expected a loud refusal, got success.\nstderr: {}", + stderr(out) + ); + let err = stderr(out); + for needle in must_mention { + assert!( + err.contains(needle), + "{ctx}: refusal does not mention '{needle}'.\nstderr: {err}" + ); + } +} + +// --------------------------------------------------------------------------- +// GREEN lane +// --------------------------------------------------------------------------- + +/// 2 memories on ARM --relocatable: per-memory region + symbol, init data +/// placed. (Execution equivalence is the python differential's job.) +#[test] +fn two_memories_relocatable_green() { + let out = compile(&two_mem_fixture(), &["--relocatable", "--target", "cortex-m3"]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + let bytes = std::fs::read( + std::env::temp_dir().join("synth_mem406_tests/mem406_multi_memory_relocatabletargetcortexm3.o"), + ) + .expect("read object"); + let obj = object::File::parse(&*bytes).expect("parse ELF"); + + // Memory 1's region: PROGBITS (has an init segment), exactly 3 pages, + // segment bytes placed at offset 16. + let mem1 = obj + .section_by_name(".synth.wasm_mem_1") + .expect("memory 1 reservation section"); + assert_eq!(mem1.size(), 3 * 65536, "3 declared pages"); + let data = mem1.data().expect("progbits data"); + assert_eq!( + &data[16..24], + b"\xaa\xbb\xcc\xdd\x11\x22\x33\x44", + "init segment placed at its offset" + ); + + // Its base symbol is DEFINED and points at that section. + let sym = obj + .symbols() + .find(|s| s.name() == Ok("__synth_wasm_data_1")) + .expect("__synth_wasm_data_1 defined"); + assert!(!sym.is_undefined(), "must be a defined region base"); + assert_eq!(sym.address(), 0, "base of the section"); + + // Memory 0 keeps the historical addressing: NO __synth_wasm_data_0 alias. + assert!( + !obj.symbols().any(|s| s.name() == Ok("__synth_wasm_data_0")), + "memory 0 keeps the R11/legacy contract — no _0 symbol" + ); +} + +/// 3 memories: generic over K — memory 1 (pure zero-init) ships NOBITS, +/// memory 2 (init segment) ships PROGBITS, each under its own symbol. +#[test] +fn three_memories_relocatable_green() { + let wat = r#"(module + (memory $a 1 1) + (memory $b 2 2) + (memory $c 1 1) + (data (memory $c) (i32.const 8) "\01\02\03\04") + (func (export "xfer") (param $p i32) (result i32) + (i32.store $b (local.get $p) (i32.load $c offset=8 (local.get $p))) + (i32.load $b (local.get $p))))"#; + let f = wat_file("three_mem.wat", wat); + let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + let bytes = + std::fs::read(std::env::temp_dir().join("synth_mem406_tests/three_mem_relocatabletargetcortexm3.o")) + .expect("read object"); + let obj = object::File::parse(&*bytes).expect("parse ELF"); + + let mem1 = obj.section_by_name(".synth.wasm_mem_1").expect("mem 1 region"); + assert_eq!(mem1.size(), 2 * 65536); + assert_eq!( + mem1.kind(), + SectionKind::UninitializedData, + "zero-init memory ships NOBITS (no flash cost)" + ); + let mem2 = obj.section_by_name(".synth.wasm_mem_2").expect("mem 2 region"); + assert_eq!(mem2.size(), 65536); + assert_eq!(&mem2.data().expect("progbits")[8..12], b"\x01\x02\x03\x04"); + + for name in ["__synth_wasm_data_1", "__synth_wasm_data_2"] { + assert!( + obj.symbols().any(|s| s.name() == Ok(name) && !s.is_undefined()), + "{name} must be defined" + ); + } +} + +// --------------------------------------------------------------------------- +// Decline matrix +// --------------------------------------------------------------------------- + +/// No --relocatable ⇒ one runtime base (R11) ⇒ refuse the whole module. +#[test] +fn multi_memory_without_relocatable_refuses() { + let out = compile(&two_mem_fixture(), &["--target", "cortex-m3"]); + assert_refused(&out, &["multi-memory", "#406", "--relocatable"], "plain object path"); +} + +/// Self-contained --cortex-m image: same single-base refusal. +#[test] +fn multi_memory_self_contained_cortex_m_refuses() { + let out = compile(&two_mem_fixture(), &["--cortex-m"]); + assert_refused(&out, &["multi-memory", "#406"], "self-contained --cortex-m"); +} + +/// --native-pointer-abi: the static-region classification is memory-0-only. +#[test] +fn multi_memory_native_pointer_abi_refuses() { + let out = compile( + &two_mem_fixture(), + &["--relocatable", "--native-pointer-abi", "--target", "cortex-m3"], + ); + assert_refused( + &out, + &["multi-memory", "#406", "--native-pointer-abi"], + "native-pointer ABI", + ); +} + +/// --shadow-stack-size shrinks MEMORY 0's reservation geometry — undefined +/// for a multi-memory module in phase 1. +#[test] +fn multi_memory_shadow_stack_refuses() { + let out = compile( + &two_mem_fixture(), + &[ + "--relocatable", + "--native-pointer-abi", + "--shadow-stack-size", + "2048", + "--target", + "cortex-m3", + ], + ); + // The native-pointer-abi gate may fire first — either way it must be a + // loud multi-memory refusal, and the flag combo must never compile. + assert_refused(&out, &["multi-memory", "#406"], "--shadow-stack-size combo"); +} + +/// riscv / aarch64: no per-memory base lowering — refuse the module. +#[test] +fn multi_memory_riscv_refuses() { + let out = compile(&two_mem_fixture(), &["-b", "riscv", "--relocatable"]); + assert_refused(&out, &["multi-memory", "#406", "riscv"], "riscv backend"); +} + +#[test] +fn multi_memory_aarch64_refuses() { + let out = compile(&two_mem_fixture(), &["-b", "aarch64", "--relocatable"]); + assert_refused(&out, &["multi-memory", "#406", "aarch64"], "aarch64 backend"); +} + +/// Cross-memory memory.copy has no phase-1 lowering: the function loud-skips +/// naming the op, and a module with only that export fails (never a silent +/// memory-0 copy). +#[test] +fn cross_memory_copy_loud_skips() { + let wat = r#"(module + (memory $a 1 1) + (memory $b 1 1) + (func (export "xcopy") (param $d i32) (param $s i32) (param $n i32) + (memory.copy $b $a (local.get $d) (local.get $s) (local.get $n))))"#; + let f = wat_file("cross_copy.wat", wat); + let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]); + assert_refused(&out, &["memory.copy", "#406"], "cross-memory memory.copy"); +} + +/// memory.fill on a non-default memory: same loud-skip contract. +#[test] +fn non_default_memory_fill_loud_skips() { + let wat = r#"(module + (memory $a 1 1) + (memory $b 1 1) + (func (export "fill1") (param $d i32) (param $v i32) (param $n i32) + (memory.fill $b (local.get $d) (local.get $v) (local.get $n))))"#; + let f = wat_file("fill1.wat", wat); + let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]); + assert_refused(&out, &["memory.fill", "#406"], "non-default memory.fill"); +} + +/// Phase-1 scope is the i32 access family: an i64 access on memory k > 0 +/// loud-skips its function (never aliases or truncates). +#[test] +fn wide_access_on_non_default_memory_loud_skips() { + let wat = r#"(module + (memory $a 1 1) + (memory $b 1 1) + (func (export "w") (param $p i32) (result i64) + (i64.load $b (local.get $p))))"#; + let f = wat_file("wide1.wat", wat); + let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]); + assert_refused(&out, &["#406"], "i64 access on memory 1"); + assert!( + stderr(&out).contains("i32 load/store family"), + "must name the phase-1 scope.\nstderr: {}", + stderr(&out) + ); +} + +/// An active data segment on memory k > 0 with a NON-constant offset cannot +/// be placed at compile time — refuse at decode, never ship memory k +/// uninitialized. +#[test] +fn non_const_segment_offset_on_memory_k_refuses() { + let wat = r#"(module + (import "env" "off" (global $off i32)) + (memory $a 1 1) + (memory $b 1 1) + (data (memory $b) (global.get $off) "\aa\bb") + (func (export "f") (param $p i32) (result i32) + (i32.load $b (local.get $p))))"#; + let f = wat_file("nonconst_seg.wat", wat); + let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]); + assert_refused( + &out, + &["multi-memory", "#406", "non-constant offset"], + "non-const segment offset", + ); +} + +/// A segment overflowing memory k's declared size would trap at +/// instantiation — refuse rather than truncate. +#[test] +fn segment_overflowing_memory_k_refuses() { + let wat = r#"(module + (memory $a 1 1) + (memory $b 1 1) + (data (memory $b) (i32.const 65532) "\01\02\03\04\05\06\07\08") + (func (export "f") (param $p i32) (result i32) + (i32.load $b (local.get $p))))"#; + let f = wat_file("overflow_seg.wat", wat); + let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]); + assert_refused(&out, &["multi-memory", "#406", "overflows"], "overflowing segment"); +} From 4ad7832493b3a023e964c31de68ab970d9ec4fa4 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 15 Jul 2026 12:38:54 +0200 Subject: [PATCH 5/6] ci(#406): wire the multi-memory distinct-regions oracle Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3596e0e0..8ffc468c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -415,6 +415,22 @@ jobs: # 32-bit T3 conditional branch. RED on v0.42.0 (5/6 cases) -> GREEN. - name: Run wide-B.W branch-target oracle (#740, cortex-m4) run: SYNTH=./target/debug/synth python scripts/repro/brif_outer_740_differential.py + # #406 (VCR-MEM-002 phase 1): N wasm memories = N DISTINCT native base + # regions. Memory 0 keeps the runtime R11 base; memory k > 0 is + # addressed via its own __synth_wasm_data_ symbol at the base of the + # object's .synth.wasm_mem_ reservation section. The oracle maps the + # two memories at DISTINCT unicorn bases (far apart, so residual + # aliasing faults or mismatches — never accidentally correct) and checks + # store-both aliasing discriminators, memory-1 init-data peeks (pre-#406 + # those segments were silently DROPPED), sub-word round-trips, and + # per-memory size/grow against wasmtime multi-memory. RED on v0.42.0 + # (the object has no per-memory region — every memory aliased R11); + # GREEN with the per-memory lowering. The decline matrix (self-contained, + # native-pointer-abi, shadow-stack, riscv/aarch64, cross-memory copy, + # wide accesses, non-const segment offsets) is the cargo test + # crates/synth-cli/tests/multi_memory_406.rs. + - name: Run multi-memory distinct-regions oracle (#406, cortex-m3) + run: SYNTH=./target/debug/synth python scripts/repro/multi_memory_406_differential.py fact-spec-oracle: name: fact-spec elision oracle (#494 phases 2 + 2b + 3+) From dad0261afdebfc5bf6ef4d7b26a65bb43d0031de Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 15 Jul 2026 12:44:37 +0200 Subject: [PATCH 6/6] chore(#406): cargo fmt + clippy (Path over PathBuf in test helper) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- crates/synth-cli/src/main.rs | 4 +- crates/synth-cli/tests/multi_memory_406.rs | 58 +++++-- crates/synth-core/src/wasm_op.rs | 142 ++++++++++++++---- .../src/instruction_selector.rs | 46 +++--- .../synth-synthesis/src/optimizer_bridge.rs | 7 +- 5 files changed, 186 insertions(+), 71 deletions(-) diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index 7455e98f..d03c9ad5 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -2422,8 +2422,8 @@ fn compile_all_exports( all_type_ret_f64, // GI-FPU-002 phase 2 (#719/#369): per-type returns-f64 (call_indirect) all_wsc_facts, // VCR-PERF-002 Phase 1 (#494): loom wsc.facts premises all_extra_memory_segments, // #406: (mem_idx>0, offset, bytes) init segments on non-default memories - multi_memory_decline, // #406: decode-level reason multi-memory must decline (if any) - all_call_indirect_guards, // #642: table size + closed-world type verdicts + multi_memory_decline, // #406: decode-level reason multi-memory must decline (if any) + all_call_indirect_guards, // #642: table size + closed-world type verdicts ) = if path.extension().is_some_and(|ext| ext == "wast") { info!("Parsing WAST (extracting all modules)..."); let contents = String::from_utf8(file_bytes).context("WAST file is not valid UTF-8")?; diff --git a/crates/synth-cli/tests/multi_memory_406.rs b/crates/synth-cli/tests/multi_memory_406.rs index aa6f59ee..37a6e6d6 100644 --- a/crates/synth-cli/tests/multi_memory_406.rs +++ b/crates/synth-cli/tests/multi_memory_406.rs @@ -50,7 +50,7 @@ fn wat_file(name: &str, wat: &str) -> PathBuf { p } -fn compile(input: &PathBuf, extra: &[&str]) -> std::process::Output { +fn compile(input: &std::path::Path, extra: &[&str]) -> std::process::Output { let out = std::env::temp_dir() .join("synth_mem406_tests") .join(format!( @@ -66,7 +66,10 @@ fn compile(input: &PathBuf, extra: &[&str]) -> std::process::Output { out.to_str().unwrap(), ]; args.extend_from_slice(extra); - Command::new(synth()).args(&args).output().expect("run synth") + Command::new(synth()) + .args(&args) + .output() + .expect("run synth") } fn stderr(out: &std::process::Output) -> String { @@ -97,10 +100,14 @@ fn assert_refused(out: &std::process::Output, must_mention: &[&str], ctx: &str) /// placed. (Execution equivalence is the python differential's job.) #[test] fn two_memories_relocatable_green() { - let out = compile(&two_mem_fixture(), &["--relocatable", "--target", "cortex-m3"]); + let out = compile( + &two_mem_fixture(), + &["--relocatable", "--target", "cortex-m3"], + ); assert!(out.status.success(), "stderr: {}", stderr(&out)); let bytes = std::fs::read( - std::env::temp_dir().join("synth_mem406_tests/mem406_multi_memory_relocatabletargetcortexm3.o"), + std::env::temp_dir() + .join("synth_mem406_tests/mem406_multi_memory_relocatabletargetcortexm3.o"), ) .expect("read object"); let obj = object::File::parse(&*bytes).expect("parse ELF"); @@ -148,25 +155,31 @@ fn three_memories_relocatable_green() { let f = wat_file("three_mem.wat", wat); let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]); assert!(out.status.success(), "stderr: {}", stderr(&out)); - let bytes = - std::fs::read(std::env::temp_dir().join("synth_mem406_tests/three_mem_relocatabletargetcortexm3.o")) - .expect("read object"); + let bytes = std::fs::read( + std::env::temp_dir().join("synth_mem406_tests/three_mem_relocatabletargetcortexm3.o"), + ) + .expect("read object"); let obj = object::File::parse(&*bytes).expect("parse ELF"); - let mem1 = obj.section_by_name(".synth.wasm_mem_1").expect("mem 1 region"); + let mem1 = obj + .section_by_name(".synth.wasm_mem_1") + .expect("mem 1 region"); assert_eq!(mem1.size(), 2 * 65536); assert_eq!( mem1.kind(), SectionKind::UninitializedData, "zero-init memory ships NOBITS (no flash cost)" ); - let mem2 = obj.section_by_name(".synth.wasm_mem_2").expect("mem 2 region"); + let mem2 = obj + .section_by_name(".synth.wasm_mem_2") + .expect("mem 2 region"); assert_eq!(mem2.size(), 65536); assert_eq!(&mem2.data().expect("progbits")[8..12], b"\x01\x02\x03\x04"); for name in ["__synth_wasm_data_1", "__synth_wasm_data_2"] { assert!( - obj.symbols().any(|s| s.name() == Ok(name) && !s.is_undefined()), + obj.symbols() + .any(|s| s.name() == Ok(name) && !s.is_undefined()), "{name} must be defined" ); } @@ -180,7 +193,11 @@ fn three_memories_relocatable_green() { #[test] fn multi_memory_without_relocatable_refuses() { let out = compile(&two_mem_fixture(), &["--target", "cortex-m3"]); - assert_refused(&out, &["multi-memory", "#406", "--relocatable"], "plain object path"); + assert_refused( + &out, + &["multi-memory", "#406", "--relocatable"], + "plain object path", + ); } /// Self-contained --cortex-m image: same single-base refusal. @@ -195,7 +212,12 @@ fn multi_memory_self_contained_cortex_m_refuses() { fn multi_memory_native_pointer_abi_refuses() { let out = compile( &two_mem_fixture(), - &["--relocatable", "--native-pointer-abi", "--target", "cortex-m3"], + &[ + "--relocatable", + "--native-pointer-abi", + "--target", + "cortex-m3", + ], ); assert_refused( &out, @@ -234,7 +256,11 @@ fn multi_memory_riscv_refuses() { #[test] fn multi_memory_aarch64_refuses() { let out = compile(&two_mem_fixture(), &["-b", "aarch64", "--relocatable"]); - assert_refused(&out, &["multi-memory", "#406", "aarch64"], "aarch64 backend"); + assert_refused( + &out, + &["multi-memory", "#406", "aarch64"], + "aarch64 backend", + ); } /// Cross-memory memory.copy has no phase-1 lowering: the function loud-skips @@ -317,5 +343,9 @@ fn segment_overflowing_memory_k_refuses() { (i32.load $b (local.get $p))))"#; let f = wat_file("overflow_seg.wat", wat); let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]); - assert_refused(&out, &["multi-memory", "#406", "overflows"], "overflowing segment"); + assert_refused( + &out, + &["multi-memory", "#406", "overflows"], + "overflowing segment", + ); } diff --git a/crates/synth-core/src/wasm_op.rs b/crates/synth-core/src/wasm_op.rs index c0e12da2..47db142c 100644 --- a/crates/synth-core/src/wasm_op.rs +++ b/crates/synth-core/src/wasm_op.rs @@ -53,28 +53,58 @@ pub enum WasmOp { I32Const(i32), // Memory - I32Load { offset: u32, align: u32 }, - I32Store { offset: u32, align: u32 }, + I32Load { + offset: u32, + align: u32, + }, + I32Store { + offset: u32, + align: u32, + }, // Sub-word loads (i32) - I32Load8S { offset: u32, align: u32 }, // byte load, sign-extend to i32 - I32Load8U { offset: u32, align: u32 }, // byte load, zero-extend to i32 - I32Load16S { offset: u32, align: u32 }, // halfword load, sign-extend to i32 - I32Load16U { offset: u32, align: u32 }, // halfword load, zero-extend to i32 + I32Load8S { + offset: u32, + align: u32, + }, // byte load, sign-extend to i32 + I32Load8U { + offset: u32, + align: u32, + }, // byte load, zero-extend to i32 + I32Load16S { + offset: u32, + align: u32, + }, // halfword load, sign-extend to i32 + I32Load16U { + offset: u32, + align: u32, + }, // halfword load, zero-extend to i32 // Sub-word stores (i32) - I32Store8 { offset: u32, align: u32 }, // store low byte - I32Store16 { offset: u32, align: u32 }, // store low halfword + I32Store8 { + offset: u32, + align: u32, + }, // store low byte + I32Store16 { + offset: u32, + align: u32, + }, // store low halfword // Control flow Block, Loop, Br(u32), // Branch to label BrIf(u32), // Conditional branch - BrTable { targets: Vec, default: u32 }, + BrTable { + targets: Vec, + default: u32, + }, Return, Call(u32), - CallIndirect { type_index: u32, table_index: u32 }, + CallIndirect { + type_index: u32, + table_index: u32, + }, LocalGet(u32), LocalSet(u32), LocalTee(u32), @@ -106,7 +136,10 @@ pub enum WasmOp { /// `memory.size`/`memory.grow` are NOT wrapped — their variants already /// carry the memory index. Invariant (decoder-enforced): `memory > 0` and /// `op` is never itself a `MultiMemory`. - MultiMemory { memory: u32, op: Box }, + MultiMemory { + memory: u32, + op: Box, + }, // More ops Drop, @@ -158,21 +191,54 @@ pub enum WasmOp { // i64 Constants and Memory I64Const(i64), - I64Load { offset: u32, align: u32 }, - I64Store { offset: u32, align: u32 }, + I64Load { + offset: u32, + align: u32, + }, + I64Store { + offset: u32, + align: u32, + }, // Sub-word loads (i64) — load sub-word, extend to i64 - I64Load8S { offset: u32, align: u32 }, - I64Load8U { offset: u32, align: u32 }, - I64Load16S { offset: u32, align: u32 }, - I64Load16U { offset: u32, align: u32 }, - I64Load32S { offset: u32, align: u32 }, - I64Load32U { offset: u32, align: u32 }, + I64Load8S { + offset: u32, + align: u32, + }, + I64Load8U { + offset: u32, + align: u32, + }, + I64Load16S { + offset: u32, + align: u32, + }, + I64Load16U { + offset: u32, + align: u32, + }, + I64Load32S { + offset: u32, + align: u32, + }, + I64Load32U { + offset: u32, + align: u32, + }, // Sub-word stores (i64) — store low N bits - I64Store8 { offset: u32, align: u32 }, - I64Store16 { offset: u32, align: u32 }, - I64Store32 { offset: u32, align: u32 }, + I64Store8 { + offset: u32, + align: u32, + }, + I64Store16 { + offset: u32, + align: u32, + }, + I64Store32 { + offset: u32, + align: u32, + }, // Conversion operations I64ExtendI32S, // Sign-extend i32 to i64 @@ -216,8 +282,14 @@ pub enum WasmOp { // f32 Constants and Memory F32Const(f32), - F32Load { offset: u32, align: u32 }, - F32Store { offset: u32, align: u32 }, + F32Load { + offset: u32, + align: u32, + }, + F32Store { + offset: u32, + align: u32, + }, // f32 Conversions F32ConvertI32S, // Convert signed i32 to f32 @@ -262,8 +334,14 @@ pub enum WasmOp { // f64 Constants and Memory F64Const(f64), - F64Load { offset: u32, align: u32 }, - F64Store { offset: u32, align: u32 }, + F64Load { + offset: u32, + align: u32, + }, + F64Store { + offset: u32, + align: u32, + }, // f64 Conversions F64ConvertI32S, // Convert signed i32 to f64 @@ -284,9 +362,15 @@ pub enum WasmOp { // Targets ARM Cortex-M55 Helium MVE (M-Profile Vector Extension) // v128 Constants and Memory - V128Const([u8; 16]), // 128-bit constant - V128Load { offset: u32, align: u32 }, // v128.load - V128Store { offset: u32, align: u32 }, // v128.store + V128Const([u8; 16]), // 128-bit constant + V128Load { + offset: u32, + align: u32, + }, // v128.load + V128Store { + offset: u32, + align: u32, + }, // v128.store // v128 Bitwise operations V128And, // v128.and diff --git a/crates/synth-synthesis/src/instruction_selector.rs b/crates/synth-synthesis/src/instruction_selector.rs index c2a86abf..c109084d 100644 --- a/crates/synth-synthesis/src/instruction_selector.rs +++ b/crates/synth-synthesis/src/instruction_selector.rs @@ -3488,13 +3488,16 @@ impl InstructionSelector { --native-pointer-abi or keep the module single-memory" ))); } - self.memory_pages.get(memory as usize).copied().ok_or_else(|| { - synth_core::Error::synthesis(format!( - "multi-memory: op targets memory {memory} but the module context \ + self.memory_pages + .get(memory as usize) + .copied() + .ok_or_else(|| { + synth_core::Error::synthesis(format!( + "multi-memory: op targets memory {memory} but the module context \ declares {} memories (#406)", - self.memory_pages.len() - )) - }) + self.memory_pages.len() + )) + }) } /// #237: under the native-pointer ABI, a const effective address `addr` @@ -10994,13 +10997,7 @@ impl InstructionSelector { &[live_params.as_slice(), &[addr, dst]].concat(), idx, )?; - Self::emit_sym_addr( - &mut instructions, - base, - &sym, - *offset as i32, - idx, - ); + Self::emit_sym_addr(&mut instructions, base, &sym, *offset as i32, idx); instructions.push(ArmInstruction { op: ArmOp::Add { rd: base, @@ -11053,13 +11050,7 @@ impl InstructionSelector { &[live_params.as_slice(), &[addr, value]].concat(), idx, )?; - Self::emit_sym_addr( - &mut instructions, - base, - &sym, - *offset as i32, - idx, - ); + Self::emit_sym_addr(&mut instructions, base, &sym, *offset as i32, idx); instructions.push(ArmInstruction { op: ArmOp::Add { rd: base, @@ -11070,9 +11061,18 @@ impl InstructionSelector { }); let mem = MemAddr::imm(base, 0); let store_op = match inner_op.as_ref() { - I32Store { .. } => ArmOp::Str { rd: value, addr: mem }, - I32Store8 { .. } => ArmOp::Strb { rd: value, addr: mem }, - I32Store16 { .. } => ArmOp::Strh { rd: value, addr: mem }, + I32Store { .. } => ArmOp::Str { + rd: value, + addr: mem, + }, + I32Store8 { .. } => ArmOp::Strb { + rd: value, + addr: mem, + }, + I32Store16 { .. } => ArmOp::Strh { + rd: value, + addr: mem, + }, _ => unreachable!(), }; instructions.push(ArmInstruction { diff --git a/crates/synth-synthesis/src/optimizer_bridge.rs b/crates/synth-synthesis/src/optimizer_bridge.rs index 662bc857..deb34783 100644 --- a/crates/synth-synthesis/src/optimizer_bridge.rs +++ b/crates/synth-synthesis/src/optimizer_bridge.rs @@ -2771,9 +2771,10 @@ impl OptimizerBridge { } // #406: same for `memory.size`/`memory.grow` on a non-default memory — // the optimized path's lowering reads R10 / emits -1 for MEMORY 0. - if let Some(ms_op) = wasm_ops.iter().find( - |op| matches!(op, WasmOp::MemorySize(k) | WasmOp::MemoryGrow(k) if *k != 0), - ) { + if let Some(ms_op) = wasm_ops + .iter() + .find(|op| matches!(op, WasmOp::MemorySize(k) | WasmOp::MemoryGrow(k) if *k != 0)) + { return Err(Error::UnsupportedInstruction(format!( "optimized lowering path does not support {ms_op:?} on a \ non-default memory (R10 is memory 0's size register) — issue #406"