From ccff83262c3920c73b1af26ae0049cedba243b6c Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 20 Jun 2026 18:43:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(dwarf):=20step=201=20=E2=80=94=20decoder?= =?UTF-8?q?=20records=20per-op=20wasm=20byte=20offset=20(VCR-DBG-001,=20#3?= =?UTF-8?q?94,=20#242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the DWARF Tier-1 .debug_line feature loop (→ v0.12.0). The crux, found by the de-risk spike: synth's source_line is the wasm OP INDEX, not the byte-offset DWARF-for-wasm .debug_line keys on, and the decoder discarded the offsets wasmparser exposes. This captures them. decode_function_body now iterates via OperatorsReader::into_iter_with_offsets() and records a parallel `op_offsets: Vec` on FunctionOps — the module-relative wasm byte offset of each emitted op, index-aligned with `ops`. This is the bridge the DWARF emitter will compose with source_line (ARM-addr → op-index) and the input wasm's .debug_line (wasm-offset → source). PURELY ADDITIVE / FROZEN-SAFE: no codegen path reads op_offsets, so emitted code is unchanged. VERIFIED: control_step, flight_seam, flight_seam_flat all produce BYTE-IDENTICAL ELFs (full file, not just .text) between main and this change. No new dependency (wasmparser already in tree) ⇒ no Cargo.lock / MODULE.bazel change. New test pins the invariants (offsets parallel to ops, strictly increasing, module-relative). 25 decoder tests + fmt + clippy clean. Next steps: (2) read wasm .debug_line via gimli; (3) compose; (4) emit; (5) oracle → v0.12.0. Co-Authored-By: Claude Opus 4.8 --- crates/synth-cli/src/main.rs | 1 + crates/synth-core/src/wasm_decoder.rs | 71 ++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index 1064816a..33606ff9 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -4040,6 +4040,7 @@ mod tests { index, export_name: export.map(String::from), ops, + op_offsets: Vec::new(), unsupported: None, } } diff --git a/crates/synth-core/src/wasm_decoder.rs b/crates/synth-core/src/wasm_decoder.rs index 23d96e88..9a950f68 100644 --- a/crates/synth-core/src/wasm_decoder.rs +++ b/crates/synth-core/src/wasm_decoder.rs @@ -335,7 +335,7 @@ pub fn decode_wasm_module(wasm_bytes: &[u8]) -> Result { } } Payload::CodeSectionEntry(body) => { - let (ops, unsupported) = decode_function_body(&body)?; + let (ops, op_offsets, unsupported) = decode_function_body(&body)?; let actual_index = num_imported_funcs + func_index; let export_name = export_names.get(&actual_index).cloned(); @@ -343,6 +343,7 @@ pub fn decode_wasm_module(wasm_bytes: &[u8]) -> Result { index: actual_index, export_name, ops, + op_offsets, unsupported, }); func_index += 1; @@ -397,7 +398,7 @@ pub fn decode_wasm_functions(wasm_bytes: &[u8]) -> Result> { } } Payload::CodeSectionEntry(body) => { - let (ops, unsupported) = decode_function_body(&body)?; + let (ops, op_offsets, unsupported) = decode_function_body(&body)?; let actual_index = num_imported_funcs + func_index; let export_name = export_names.get(&actual_index).cloned(); @@ -405,6 +406,7 @@ pub fn decode_wasm_functions(wasm_bytes: &[u8]) -> Result> { index: actual_index, export_name, ops, + op_offsets, unsupported, }); func_index += 1; @@ -425,6 +427,14 @@ pub struct FunctionOps { pub export_name: Option, /// The WASM operations in this function body pub ops: Vec, + /// VCR-DBG-001 step 1 (#394): module-relative wasm byte offset of each op in + /// `ops` (same index → same op). This is the address space DWARF-for-wasm + /// `.debug_line` keys on, so it is the bridge from synth's op-index + /// `source_line` to the input wasm's DWARF (wasm-offset → source). PURELY + /// ADDITIVE metadata: no codegen path reads it, so emitted `.text` is + /// unchanged and the frozen fixtures stay bit-identical. Empty until consumed + /// by the DWARF emitter (Tier 1). + pub op_offsets: Vec, /// `Some(reason)` when the body contained a value-affecting operator the /// decoder cannot lower (e.g. scalar f32/f64 — #369, bulk-memory /// memory.copy/fill). Such an op would otherwise be silently *dropped* @@ -442,16 +452,24 @@ pub struct FunctionOps { /// Returns the ops plus `Some(reason)` if any operator was a value-affecting /// op the decoder cannot lower (so the function must be loud-skipped, #369 — /// not silently miscompiled by dropping the op). -fn decode_function_body(body: &wasmparser::FunctionBody) -> Result<(Vec, Option)> { +fn decode_function_body( + body: &wasmparser::FunctionBody, +) -> Result<(Vec, Vec, Option)> { let mut ops = Vec::new(); + // VCR-DBG-001 step 1: parallel to `ops` — the module-relative wasm byte + // offset of each emitted op (the DWARF-for-wasm address space). Captured via + // the offset-aware reader; pushed only when an op is pushed, so indices stay + // aligned with `ops`. Additive metadata, no codegen consumer ⇒ frozen-safe. + let mut op_offsets = Vec::new(); let mut unsupported: Option = None; let ops_reader = body.get_operators_reader()?; - for op_result in ops_reader { - let op = op_result.context("Failed to read operator")?; + for item in ops_reader.into_iter_with_offsets() { + let (op, offset) = item.context("Failed to read operator")?; if let Some(wasm_op) = convert_operator(&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/Unreachable) — record it so the @@ -460,7 +478,7 @@ fn decode_function_body(body: &wasmparser::FunctionBody) -> Result<(Vec, } } - Ok((ops, unsupported)) + Ok((ops, op_offsets, unsupported)) } /// Operators that `convert_operator` returns `None` for *on purpose* — they @@ -1504,6 +1522,47 @@ mod tests { assert!(ops.contains(&WasmOp::I32x4Mul)); } + /// VCR-DBG-001 step 1 (#394): the decoder records a module-relative wasm byte + /// offset per emitted op — the DWARF-for-wasm address space that bridges + /// synth's op-index `source_line` to the input wasm's `.debug_line`. Purely + /// additive metadata (no codegen consumer ⇒ frozen fixtures byte-identical, + /// verified separately); this test pins the structural invariants. + #[test] + fn test_decode_records_aligned_increasing_op_offsets_dbg001() { + let wat = r#" + (module + (func (export "f") (param i32 i32) (result i32) + local.get 0 + local.get 1 + i32.add + i32.const 7 + i32.mul)) + "#; + let wasm = wat::parse_str(wat).expect("parse WAT"); + let functions = decode_wasm_functions(&wasm).expect("decode"); + let f = &functions[0]; + + // One offset per emitted op, index-aligned with `ops`. + assert_eq!( + f.op_offsets.len(), + f.ops.len(), + "op_offsets must be parallel to ops" + ); + assert!(!f.op_offsets.is_empty()); + + // Byte offsets are strictly increasing through the body (each op consumes + // at least one byte) and module-relative (well past the header). + assert!( + f.op_offsets.windows(2).all(|w| w[1] > w[0]), + "wasm byte offsets must strictly increase: {:?}", + f.op_offsets + ); + assert!( + f.op_offsets[0] >= 8, + "module-relative offset is past the 8-byte wasm header" + ); + } + /// #237: the decoder captures a global's `i32.const` initializer + mutability, /// so the native-pointer ABI can recognize the stack-pointer global. #[test]