Repository: bytecodealliance/wasmtime
Pull request #9240 (2024-09-13 15:25:10)

Wasmtime: Allow compiling Wasm modules with Pulley
This does not yet add support for running Wasm modules compiled with Pulley, but it does allow compiling them via `wasmtime compile`. This is a first step towards integrating Pulley into Wasmtime itself.

This is also enough to get basic `tests/disas` tests working with Pulley.

<!--
Please make sure you include the following information:

- If this work has been discussed elsewhere, please include a link to that
  conversation. If it was discussed in an issue, just mention "issue #...".

- Explain why this change is needed. If the details are in an issue already,
  this can be brief.

Our development process is documented in the Wasmtime book:
https://docs.wasmtime.dev/contributing-development-process.html

Please ensure all communication follows the code of conduct:
https://github.com/bytecodealliance/wasmtime/blob/main/CODE_OF_CONDUCT.md
-->



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index dce273cfb..06e58f29f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3496,6 +3496,7 @@ dependencies = [
  "postcard",
  "proptest",
  "psm",
+ "pulley-interpreter",
  "rand",
  "rayon",
  "rustix 0.38.31",
@@ -3637,6 +3638,7 @@ dependencies = [
  "num_cpus",
  "object",
  "once_cell",
+ "pulley-interpreter",
  "rayon",
  "rustix 0.38.31",
  "serde",
diff --git a/Cargo.toml b/Cargo.toml
index 32eca81f4..57ac9429d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -85,7 +85,7 @@ rustix = { workspace = true, features = ["mm", "param", "process"] }
 
 [dev-dependencies]
 # depend again on wasmtime to activate its default features for tests
-wasmtime = { workspace = true, features = ['default', 'winch', 'all-arch', 'call-hook', 'memory-protection-keys'] }
+wasmtime = { workspace = true, features = ['default', 'winch', 'pulley', 'all-arch', 'call-hook', 'memory-protection-keys'] }
 env_logger = { workspace = true }
 log = { workspace = true }
 filecheck = { workspace = true }
@@ -119,6 +119,7 @@ libtest-mimic = { workspace = true }
 capstone = { workspace = true }
 object = { workspace = true, features = ['std'] }
 wasmtime-test-macros = { path = "crates/test-macros" }
+pulley-interpreter = { workspace = true, features = ["disas"] }
 
 [target.'cfg(windows)'.dev-dependencies]
 windows-sys = { workspace = true, features = ["Win32_System_Memory"] }
@@ -397,6 +398,7 @@ default = [
 # These features are off-by-default but may optionally be enabled.
 all-arch = ["wasmtime/all-arch"]
 winch = ["wasmtime/winch"]
+pulley = ["wasmtime/pulley"]
 wmemcheck = ["wasmtime/wmemcheck"]
 memory-protection-keys = ["wasmtime-cli-flags/memory-protection-keys"]
 
diff --git a/crates/cranelift/Cargo.toml b/crates/cranelift/Cargo.toml
index 0d7caa626..8cf85ff2d 100644
--- a/crates/cranelift/Cargo.toml
+++ b/crates/cranelift/Cargo.toml
@@ -36,6 +36,7 @@ wasmtime-versioned-export-macros = { workspace = true }
 [features]
 all-arch = ["cranelift-codegen/all-arch"]
 host-arch = ["cranelift-codegen/host-arch"]
+pulley = ["cranelift-codegen/pulley"]
 component-model = ["wasmtime-environ/component-model"]
 incremental-cache = ["cranelift-codegen/incremental-cache"]
 wmemcheck = ["wasmtime-environ/wmemcheck"]
diff --git a/crates/environ/src/compile/mod.rs b/crates/environ/src/compile/mod.rs
index 0af013a9f..59ef62e0b 100644
--- a/crates/environ/src/compile/mod.rs
+++ b/crates/environ/src/compile/mod.rs
@@ -293,6 +293,10 @@ pub trait Compiler: Send + Sync {
                 Aarch64(_) => Architecture::Aarch64,
                 S390x => Architecture::S390x,
                 Riscv64(_) => Architecture::Riscv64,
+                // XXX: the `object` crate won't successfully build an object
+                // with relocations and such if it doesn't know the
+                // architecture, so just pretend we are riscv64. Yolo!
+                Pulley32 | Pulley64 => Architecture::Riscv64,
                 architecture => {
                     anyhow::bail!("target architecture {:?} is unsupported", architecture,);
                 }
diff --git a/crates/environ/src/compile/module_artifacts.rs b/crates/environ/src/compile/module_artifacts.rs
index 8449c456d..6d071dc1b 100644
--- a/crates/environ/src/compile/module_artifacts.rs
+++ b/crates/environ/src/compile/module_artifacts.rs
@@ -28,6 +28,9 @@ pub struct ObjectBuilder<'a> {
     /// will go.
     data: SectionId,
 
+    /// The target triple for this compilation.
+    triple: target_lexicon::Triple,
+
     /// The section identifier for function name information, or otherwise where
     /// the `name` custom section of wasm is copied into.
     ///
@@ -43,7 +46,11 @@ pub struct ObjectBuilder<'a> {
 
 impl<'a> ObjectBuilder<'a> {
     /// Creates a new builder for the `obj` specified.
-    pub fn new(mut obj: Object<'a>, tunables: &'a Tunables) -> ObjectBuilder<'a> {
+    pub fn new(
+        mut obj: Object<'a>,
+        tunables: &'a Tunables,
+        triple: target_lexicon::Triple,
+    ) -> ObjectBuilder<'a> {
         let data = obj.add_section(
             obj.segment_name(StandardSegment::Data).to_vec(),
             obj::ELF_WASM_DATA.as_bytes().to_vec(),
@@ -53,6 +60,7 @@ impl<'a> ObjectBuilder<'a> {
             obj,
             tunables,
             data,
+            triple,
             names: None,
             dwarf: None,
         }
@@ -217,6 +225,12 @@ impl<'a> ObjectBuilder<'a> {
             self.push_debuginfo(&mut dwarf, &debuginfo);
         }
 
+        let is_pulley = matches!(
+            self.triple.architecture,
+            target_lexicon::Architecture::Pulley32 | target_lexicon::Architecture::Pulley64
+        );
+        assert!(!is_pulley || wasm_to_array_trampolines.is_empty());
+
         Ok(CompiledModuleInfo {
             module,
             funcs,
@@ -227,6 +241,7 @@ impl<'a> ObjectBuilder<'a> {
                 has_unparsed_debuginfo,
                 code_section_offset: debuginfo.wasm_file.code_section_offset,
                 has_wasm_debuginfo: self.tunables.parse_wasm_debuginfo,
+                is_pulley,
                 dwarf,
             },
         })
diff --git a/crates/environ/src/module_artifacts.rs b/crates/environ/src/module_artifacts.rs
index 8610029e6..b479521cc 100644
--- a/crates/environ/src/module_artifacts.rs
+++ b/crates/environ/src/module_artifacts.rs
@@ -109,6 +109,10 @@ pub struct Metadata {
     /// weren't found in the original wasm module itself.
     pub has_wasm_debuginfo: bool,
 
+    /// Whether this artifact contains Pulley bytecode (instead of machine code)
+    /// or not.
+    pub is_pulley: bool,
+
     /// Dwarf sections and the offsets at which they're stored in the
     /// ELF_WASMTIME_DWARF
     pub dwarf: Vec<(u8, Range<u64>)>,
diff --git a/crates/wasmtime/Cargo.toml b/crates/wasmtime/Cargo.toml
index 7eff216cb..bd10631b4 100644
--- a/crates/wasmtime/Cargo.toml
+++ b/crates/wasmtime/Cargo.toml
@@ -30,6 +30,7 @@ wasmtime-component-util = { workspace = true, optional = true }
 wasmtime-slab = { workspace = true, optional = true }
 wasmtime-versioned-export-macros = { workspace = true }
 wasmtime-wmemcheck = { workspace = true, optional = true }
+pulley-interpreter = { workspace = true, optional = true }
 target-lexicon = { workspace = true }
 wasmparser = { workspace = true }
 wasm-encoder = { workspace = true, optional = true }
@@ -134,11 +135,18 @@ default = [
 # precompiled WebAssembly modules.
 cranelift = ["dep:wasmtime-cranelift", "std"]
 
-# Enables support for winch, the WebAssembly baseline compiler. The Winch compiler
+# Enables support for Winch, the WebAssembly baseline compiler. The Winch compiler
 # strategy in `Config` will be available. It is currently in active development
 # and shouldn't be used in production applications.
 winch = ["dep:wasmtime-winch", "std"]
 
+# Enables support for Pulley, the WebAssembly interpreter. When paired with the
+# `cranelift` feature, the compiler backends for the `pulley32` and `pulley64`
+# targets will be available. When paired with the `runtime` feature, the Pulley
+# interpreter will be built into the runtime and you can interpret WebAssembly
+# modules that have been compiled to Pulley bytecode.
+pulley = ["dep:pulley-interpreter", "wasmtime-cranelift?/pulley"]
+
 # Enables support for incremental compilation cache to be enabled in `Config`.
 incremental-cache = ["wasmtime-cranelift?/incremental-cache", "std"]
 
@@ -175,7 +183,7 @@ pooling-allocator = ["runtime", "std"]
 # Enables support for all architectures in Cranelift, allowing
 # cross-compilation using the `wasmtime` crate's API, notably the
 # `Engine::precompile_module` function.
-all-arch = ["wasmtime-cranelift?/all-arch", "wasmtime-winch?/all-arch"]
+all-arch = ["wasmtime-cranelift?/all-arch", "wasmtime-winch?/all-arch", "pulley"]
 
 # Enables in-progress support for the component model. Note that this feature is
 # in-progress, buggy, and incomplete. This is primarily here for internal
@@ -221,7 +229,8 @@ runtime = [
   "dep:windows-sys",
   "dep:psm",
   "dep:rustix",
-  "rustix/mm"
+  "rustix/mm",
+  "pulley-interpreter?/interp",
 ]
 
 # Enable support for garbage collection-related things.
diff --git a/crates/wasmtime/src/compile.rs b/crates/wasmtime/src/compile.rs
index 35877008e..0469b4e09 100644
--- a/crates/wasmtime/src/compile.rs
+++ b/crates/wasmtime/src/compile.rs
@@ -78,7 +78,8 @@ pub(crate) fn build_artifacts<T: FinishedObject>(
         .context("failed to parse WebAssembly module")?;
     let functions = mem::take(&mut translation.function_body_inputs);
 
-    let compile_inputs = CompileInputs::for_module(&types, &translation, functions);
+    let compile_inputs =
+        CompileInputs::for_module(engine.compiler().triple(), &types, &translation, functions);
     let unlinked_compile_outputs = compile_inputs.compile(engine)?;
     let (compiled_funcs, function_indices) = unlinked_compile_outputs.pre_link();
 
@@ -144,6 +145,7 @@ pub(crate) fn build_component_artifacts<T: FinishedObject>(
             .context("failed to parse WebAssembly module")?;
 
     let compile_inputs = CompileInputs::for_component(
+        engine.compiler().triple(),
         &types,
         &component,
         module_translations.iter_mut().map(|(i, translation)| {
@@ -320,6 +322,9 @@ struct CompileOutput {
 /// The collection of things we need to compile for a Wasm module or component.
 #[derive(Default)]
 struct CompileInputs<'a> {
+    // Whether or not we need to compile wasm-to-native trampolines.
+    need_wasm_to_array_trampolines: bool,
+
     inputs: Vec<CompileInput<'a>>,
 }
 
@@ -330,13 +335,20 @@ impl<'a> CompileInputs<'a> {
 
     /// Create the `CompileInputs` for a core Wasm module.
     fn for_module(
+        triple: &target_lexicon::Triple,
         types: &'a ModuleTypesBuilder,
         translation: &'a ModuleTranslation<'a>,
         functions: PrimaryMap<DefinedFuncIndex, FunctionBodyData<'a>>,
     ) -> Self {
-        let mut ret = Self::default();
-        let module_index = StaticModuleIndex::from_u32(0);
+        let mut ret = CompileInputs {
+            need_wasm_to_array_trampolines: !matches!(
+                triple.architecture,
+                target_lexicon::Architecture::Pulley32 | target_lexicon::Architecture::Pulley64
+            ),
+            inputs: vec![],
+        };
 
+        let module_index = StaticModuleIndex::from_u32(0);
         ret.collect_inputs_in_translations(types, [(module_index, translation, functions)]);
 
         ret
@@ -345,6 +357,7 @@ impl<'a> CompileInputs<'a> {
     /// Create a `CompileInputs` for a component.
     #[cfg(feature = "component-model")]
     fn for_component(
+        triple: &target_lexicon::Triple,
         types: &'a wasmtime_environ::component::ComponentTypesBuilder,
         component: &'a wasmtime_environ::component::ComponentTranslation,
         module_translations: impl IntoIterator<
@@ -355,7 +368,13 @@ impl<'a> CompileInputs<'a> {
             ),
         >,
     ) -> Self {
-        let mut ret = CompileInputs::default();
+        let mut ret = CompileInputs {
+            need_wasm_to_array_trampolines: !matches!(
+                triple.architecture,
+                target_lexicon::Architecture::Pulley32 | target_lexicon::Architecture::Pulley64
+            ),
+            inputs: vec![],
+        };
 
         ret.collect_inputs_in_translations(types.module_types_builder(), module_translations);
 
@@ -495,25 +514,28 @@ impl<'a> CompileInputs<'a> {
             }
         }
 
-        let mut trampoline_types_seen = HashSet::new();
-        for (_func_type_index, trampoline_type_index) in types.trampoline_types() {
-            let is_new = trampoline_types_seen.insert(trampoline_type_index);
-            if !is_new {
-                continue;
+        if self.need_wasm_to_array_trampolines {
+            let mut trampoline_types_seen = HashSet::new();
+            for (_func_type_index, trampoline_type_index) in types.trampoline_types() {
+                let is_new = trampoline_types_seen.insert(trampoline_type_index);
+                if !is_new {
+                    continue;
+                }
+                let trampoline_func_ty = types[trampoline_type_index].unwrap_func();
+                self.push_input(move |compiler| {
+                    let trampoline =
+                        compiler.compile_wasm_to_array_trampoline(trampoline_func_ty)?;
+                    Ok(CompileOutput {
+                        key: CompileKey::wasm_to_array_trampoline(trampoline_type_index),
+                        symbol: format!(
+                            "signatures[{}]::wasm_to_array_trampoline",
+                            trampoline_type_index.as_u32()
+                        ),
+                        function: CompiledFunction::Function(trampoline),
+                        info: None,
+                    })
+                });
             }
-            let trampoline_func_ty = types[trampoline_type_index].unwrap_func();
-            self.push_input(move |compiler| {
-                let trampoline = compiler.compile_wasm_to_array_trampoline(trampoline_func_ty)?;
-                Ok(CompileOutput {
-                    key: CompileKey::wasm_to_array_trampoline(trampoline_type_index),
-                    symbol: format!(
-                        "signatures[{}]::wasm_to_array_trampoline",
-                        trampoline_type_index.as_u32()
-                    ),
-                    function: CompiledFunction::Function(trampoline),
-                    info: None,
-                })
-            });
         }
     }
 
@@ -712,7 +734,8 @@ impl FunctionIndices {
             )?;
         }
 
-        let mut obj = wasmtime_environ::ObjectBuilder::new(obj, tunables);
+        let mut obj =
+            wasmtime_environ::ObjectBuilder::new(obj, tunables, compiler.triple().clone());
         let mut artifacts = Artifacts::default();
 
         // Remove this as it's not needed by anything below and we'll debug
@@ -804,23 +827,29 @@ impl FunctionIndices {
                         })
                         .collect();
 
-                let unique_and_sorted_trampoline_sigs = translation
-                    .module
-                    .types
-                    .iter()
-                    .map(|(_, ty)| *ty)
-                    .filter(|idx| types[*idx].is_func())
-                    .map(|idx| types.trampoline_type(idx))
-                    .collect::<BTreeSet<_>>();
-                let wasm_to_array_trampolines = unique_and_sorted_trampoline_sigs
-                    .iter()
-                    .map(|idx| {
-                        let trampoline = types.trampoline_type(*idx);
-                        let key = CompileKey::wasm_to_array_trampoline(trampoline);
-                        let compiled = wasm_to_array_trampolines[&key];
-                        (*idx, symbol_ids_and_locs[compiled.unwrap_function()].1)
-                    })
-                    .collect();
+                let wasm_to_array_trampolines = match engine.compiler().triple().architecture {
+                    target_lexicon::Architecture::Pulley32
+                    | target_lexicon::Architecture::Pulley64 => vec![],
+                    _ => {
+                        let unique_and_sorted_trampoline_sigs = translation
+                            .module
+                            .types
+                            .iter()
+                            .map(|(_, ty)| *ty)
+                            .filter(|idx| types[*idx].is_func())
+                            .map(|idx| types.trampoline_type(idx))
+                            .collect::<BTreeSet<_>>();
+                        unique_and_sorted_trampoline_sigs
+                            .iter()
+                            .map(|idx| {
+                                let trampoline = types.trampoline_type(*idx);
+                                let key = CompileKey::wasm_to_array_trampoline(trampoline);
+                                let compiled = wasm_to_array_trampolines[&key];
+                                (*idx, symbol_ids_and_locs[compiled.unwrap_function()].1)
+                            })
+                            .collect()
+                    }
+                };
 
                 obj.append(translation, funcs, wasm_to_array_trampolines)
             })
diff --git a/crates/wasmtime/src/runtime/vm/memory.rs b/crates/wasmtime/src/runtime/vm/memory.rs
index 40bf4e5a9..09972c2a9 100644
--- a/crates/wasmtime/src/runtime/vm/memory.rs
+++ b/crates/wasmtime/src/runtime/vm/memory.rs
@@ -49,9 +49,7 @@ impl RuntimeMemoryCreator for DefaultMemoryCreator {
     }
 }
 
-/// A linear memory's backing storage.
-///
-/// This does not a full Wasm linear memory, as it may
+/// A linear memory and its backing storage.
 pub trait RuntimeLinearMemory: Send + Sync {
     /// Returns the log2 of this memory's page size, in bytes.
     fn page_size_log2(&self) -> u8;


Test patch:
diff --git a/tests/disas.rs b/tests/disas.rs
index 1a07e8d69..70f4acbe7 100644
--- a/tests/disas.rs
+++ b/tests/disas.rs
@@ -45,6 +45,7 @@ use cranelift_codegen::ir::{Function, UserExternalName, UserFuncName};
 use cranelift_codegen::isa::{lookup_by_name, TargetIsa};
 use cranelift_codegen::settings::{Configurable, Flags, SetError};
 use libtest_mimic::{Arguments, Trial};
+use pulley_interpreter::decode::OpVisitor;
 use serde::de::DeserializeOwned;
 use serde_derive::Deserialize;
 use similar::TextDiff;
@@ -60,6 +61,8 @@ fn main() -> Result<()> {
         return Ok(());
     }
 
+    let _ = env_logger::try_init();
+
     let mut tests = Vec::new();
     find_tests("./tests/disas".as_ref(), &mut tests)?;
 
@@ -311,17 +314,130 @@ fn assert_output(
                 }
             }
         }
-        CompileOutput::Elf(bytes) => {
-            let disas = isa.to_capstone()?;
-            disas_elf(&disas, &mut actual, &bytes)?;
-        }
+        CompileOutput::Elf(bytes) => match isa.to_capstone() {
+            Ok(disas) => disas_insts(&mut actual, &bytes, |bytes, addr| {
+                Ok(disas
+                    .disasm_all(bytes, addr)?
+                    .into_iter()
+                    .map(|inst| {
+                        use capstone::InsnGroupType::{CS_GRP_JUMP, CS_GRP_RET};
+
+                        let detail = disas.insn_detail(&inst).ok();
+                        let detail = detail.as_ref();
+                        let is_jump = detail
+                            .map(|d| {
+                                d.groups()
+                                    .iter()
+                                    .find(|g| g.0 as u32 == CS_GRP_JUMP)
+                                    .is_some()
+                            })
+                            .unwrap_or(false);
+
+                        let is_return = detail
+                            .map(|d| {
+                                d.groups()
+                                    .iter()
+                                    .find(|g| g.0 as u32 == CS_GRP_RET)
+                                    .is_some()
+                            })
+                            .unwrap_or(false);
+
+                        let disassembly = match (inst.mnemonic(), inst.op_str()) {
+                            (Some(i), Some(o)) => {
+                                if o.is_empty() {
+                                    format!("{i}")
+                                } else {
+                                    format!("{i:7} {o}")
+                                }
+                            }
+                            (Some(i), None) => format!("{i}"),
+                            _ => unreachable!(),
+                        };
+
+                        let address = inst.address();
+                        DisasInst {
+                            address,
+                            is_jump,
+                            is_return,
+                            disassembly,
+                        }
+                    })
+                    .collect::<Vec<_>>())
+            })?,
+            Err(_) => {
+                assert!(matches!(
+                    isa.triple().architecture,
+                    target_lexicon::Architecture::Pulley32 | target_lexicon::Architecture::Pulley64
+                ));
+                disas_insts(&mut actual, &bytes, |bytes, _addr| {
+                    let mut result = vec![];
+
+                    let mut disas = pulley_interpreter::disas::Disassembler::new(bytes);
+                    disas.offsets(false);
+                    disas.hexdump(false);
+                    let mut decoder = pulley_interpreter::decode::Decoder::new();
+
+                    loop {
+                        let addr = disas.bytecode().position();
+
+                        match decoder.decode_one(&mut disas) {
+                            // If we got EOF at the initial position, then we're done disassembling.
+                            Err(pulley_interpreter::decode::DecodingError::UnexpectedEof {
+                                position,
+                            }) if position == addr => break,
+
+                            // Otherwise, propagate the error.
+                            Err(e) => {
+                                return Err(anyhow::Error::from(e))
+                                    .context("failed to disassembly pulley bytecode");
+                            }
+
+                            Ok(()) => {
+                                let disassembly = disas
+                                    .disas()
+                                    .lines()
+                                    .map(|l| l.trim().to_string())
+                                    .filter(|l| !l.is_empty())
+                                    .next_back()
+                                    .unwrap();
+                                let address = u64::try_from(addr).unwrap();
+                                let is_jump =
+                                    disassembly.contains("jump") || disassembly.contains("br_if");
+                                let is_return = disassembly == "ret";
+                                result.push(DisasInst {
+                                    address,
+                                    is_jump,
+                                    is_return,
+                                    disassembly,
+                                });
+                            }
+                        }
+                    }
+
+                    Ok(result)
+                })?
+            }
+        },
     }
     let actual = actual.trim();
     assert_or_bless_output(path, wat, actual)
 }
 
-fn disas_elf(disas: &capstone::Capstone, result: &mut String, elf: &[u8]) -> Result<()> {
-    use capstone::InsnGroupType::{CS_GRP_JUMP, CS_GRP_RET};
+struct DisasInst {
+    address: u64,
+    is_jump: bool,
+    is_return: bool,
+    disassembly: String,
+}
+
+fn disas_insts<I>(
+    result: &mut String,
+    elf: &[u8],
+    disas: impl Fn(&[u8], u64) -> Result<I>,
+) -> Result<()>
+where
+    I: IntoIterator<Item = DisasInst>,
+{
     use object::{Endianness, Object, ObjectSection, ObjectSymbol};
 
     let elf = object::read::elf::ElfFile64::<Endianness>::parse(elf)?;
@@ -354,50 +470,27 @@ fn disas_elf(disas: &capstone::Capstone, result: &mut String, elf: &[u8]) -> Res
         let mut prev_jump = false;
         let mut write_offsets = false;
 
-        for inst in disas.disasm_all(bytes, sym.address())?.iter() {
-            let detail = disas.insn_detail(&inst).ok();
-            let detail = detail.as_ref();
-            let is_jump = detail
-                .map(|d| {
-                    d.groups()
-                        .iter()
-                        .find(|g| g.0 as u32 == CS_GRP_JUMP)
-                        .is_some()
-                })
-                .unwrap_or(false);
-
+        for DisasInst {
+            address,
+            is_jump,
+            is_return,
+            disassembly: disas,
+        } in disas(bytes, sym.address())?.into_iter()
+        {
             if write_offsets || (prev_jump && !is_jump) {
-                write!(result, "{:>4x}: ", inst.address())?;
+                write!(result, "{address:>4x}: ")?;
             } else {
                 write!(result, "      ")?;
             }
 
-            match (inst.mnemonic(), inst.op_str()) {
-                (Some(i), Some(o)) => {
-                    if o.is_empty() {
-                        writeln!(result, "{i}")?;
-                    } else {
-                        writeln!(result, "{i:7} {o}")?;
-                    }
-                }
-                (Some(i), None) => writeln!(result, "{i}")?,
-                _ => unreachable!(),
-            }
+            writeln!(result, "{disas}")?;
 
             prev_jump = is_jump;
 
             // Flip write_offsets to true once we've seen a `ret`, as
             // instructions that follow the return are often related to trap
             // tables.
-            write_offsets = write_offsets
-                || detail
-                    .map(|d| {
-                        d.groups()
-                            .iter()
-                            .find(|g| g.0 as u32 == CS_GRP_RET)
-                            .is_some()
-                    })
-                    .unwrap_or(false);
+            write_offsets |= is_return;
         }
     }
     Ok(())
diff --git a/tests/disas/pulley/i32_add/const.wat b/tests/disas/pulley/i32_add/const.wat
new file mode 100644
index 000000000..f0e23cd22
--- /dev/null
+++ b/tests/disas/pulley/i32_add/const.wat
@@ -0,0 +1,23 @@
+;;! target = "pulley64"
+;;! test = "compile"
+
+(module
+    (func (result i32)
+        i32.const 10
+        i32.const 20
+        i32.add
+    )
+)
+
+;; wasm[0]::function[0]:
+;;       xconst8 spilltmp0, -16
+;;       xadd32 sp, sp, spilltmp0
+;;       store64_offset8 sp, 8, lr
+;;       store64 sp, fp
+;;       xmov fp, sp
+;;       xconst8 x0, 30
+;;       load64_offset8 lr, sp, 8
+;;       load64 fp, sp
+;;       xconst8 spilltmp0, 16
+;;       xadd32 sp, sp, spilltmp0
+;;       ret