diff --git a/Cargo.lock b/Cargo.lock
index 52561c16..20c3d1dd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4840,6 +4840,10 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+[[package]]
+name = "skate-audio"
+version = "0.1.0"
+
[[package]]
name = "skate-core"
version = "0.1.0"
@@ -4878,6 +4882,7 @@ dependencies = [
"sdl3",
"serde",
"serde_json",
+ "skate-audio",
"skate-core",
"skate-data",
"skate-dynamics",
diff --git a/Cargo.toml b/Cargo.toml
index e8a82a77..c9b8ba01 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["crates/skate-core", "crates/skate-data", "crates/skate-game", "crates/skate-dynamics", "crates/skate-mods", "crates/skate-net", "crates/skate-steam-relay"]
+members = ["crates/skate-core", "crates/skate-audio", "crates/skate-data", "crates/skate-game", "crates/skate-dynamics", "crates/skate-mods", "crates/skate-net", "crates/skate-steam-relay"]
resolver = "3"
[workspace.package]
diff --git a/crates/skate-audio/Cargo.toml b/crates/skate-audio/Cargo.toml
new file mode 100644
index 00000000..347e24ab
--- /dev/null
+++ b/crates/skate-audio/Cargo.toml
@@ -0,0 +1,6 @@
+[package]
+name = "skate-audio"
+version.workspace = true
+edition.workspace = true
+publish.workspace = true
+description = "Native port of Skate 3's AEMS patch-program evaluator and RenderWare-style voice graph (engine independent)"
diff --git a/crates/skate-audio/examples/aems_list.rs b/crates/skate-audio/examples/aems_list.rs
new file mode 100644
index 00000000..47bca200
--- /dev/null
+++ b/crates/skate-audio/examples/aems_list.rs
@@ -0,0 +1,51 @@
+//! List every bank's modules: bound class, capacity, payload words, players and the opcodes used.
+//!
+//! usage: aems_list
[name filter]
+use std::path::Path;
+
+use skate_audio::be::u8_at;
+use skate_audio::eval::Evaluator;
+use skate_audio::eval::symbols::SymRef;
+use skate_audio::formats::{Bank, Project};
+
+fn main() {
+ let args: Vec = std::env::args().collect();
+ let dir = Path::new(&args[1]);
+ let filter = args.get(2).map(|s| s.to_ascii_lowercase());
+ let mut eval = Evaluator::new();
+ for name in std::fs::read_to_string(dir.join("csi_order.txt")).expect("csi_order.txt").lines() {
+ eval.install_project(&Project::parse(name, &std::fs::read(dir.join(name)).unwrap()).unwrap());
+ }
+ let mut names: Vec = std::fs::read_dir(dir)
+ .unwrap()
+ .filter_map(|e| e.ok()?.file_name().into_string().ok())
+ .filter(|n| n.to_ascii_lowercase().ends_with(".abk"))
+ .filter(|n| filter.as_ref().is_none_or(|f| n.to_ascii_lowercase().contains(f)))
+ .collect();
+ names.sort();
+ for name in names {
+ let bank = Bank::parse(&name, std::fs::read(dir.join(&name)).unwrap()).unwrap();
+ for (i, m) in bank.modules.iter().enumerate() {
+ let class = bank
+ .exports
+ .iter()
+ .find(|e| e.handle_offset == m.offset + 4)
+ .and_then(|e| eval.registry.lookup(e.kind, e.project, e.name_id, &e.name))
+ .and_then(|s| match s {
+ SymRef::Class(c) => Some(eval.registry.classes[c].name.clone()),
+ _ => None,
+ })
+ .unwrap_or_else(|| "?".into());
+ let payload = m.class_data_state.map(|o| u8_at(m.template(&bank.data), o as usize + 16));
+ let mut ops: Vec = m.program.iter().map(|o| o.opcode).collect();
+ ops.sort_unstable();
+ ops.dedup();
+ println!(
+ "{name} #{i} class={class} capacity={} payload={payload:?} players={} samples={} ops={ops:?}",
+ m.max_instances,
+ m.num_players,
+ bank.samples.len()
+ );
+ }
+ }
+}
diff --git a/crates/skate-audio/examples/aems_render.rs b/crates/skate-audio/examples/aems_render.rs
new file mode 100644
index 00000000..c8700c9a
--- /dev/null
+++ b/crates/skate-audio/examples/aems_render.rs
@@ -0,0 +1,129 @@
+//! Headless end-to-end render: install the projects, load banks with their decoded WAVs, post one
+//! message and render through the voice graph and the output stage's stereo fold. Prints per-second
+//! RMS / peak (dBFS), voice counts and the render speed; optionally writes a 48 kHz stereo WAV.
+//!
+//! usage: aems_render