WASM plugins, scripting, i18n, GUI pan/zoom, cargo-dist CI/CD

- WASM plugin host (wasmtime 46, 17 host fns) + example noisemap plugin
- Rhai scripting engine (12 API fns, `terrafier run` cmd)
- i18n: en/ru translations, auto-detect, t! macro
- GUI: pan (click-drag) + zoom (scroll wheel) with viewport transform
- cargo-dist 0.31: MSI/pkg/shell installers, both binaries
- CI: .github/workflows/ci.yml + release.yml
- Security: wasmtime 30→46, bincode 1.3→2.0, quick-xml CVE docs
- Code review: SAFETY comments, unwrap→match cleanup
- Fixes: clippy approx_constant, needless_borrows, rustfmt
This commit is contained in:
loki5512344 2026-07-04 11:14:47 +02:00
parent bdd4564240
commit 18613260a8
100 changed files with 4262 additions and 585 deletions

View file

@ -0,0 +1,17 @@
[package]
name = "terrafier-wasm-host"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "WASM plugin host for Terrafier using wasmtime"
[dependencies]
wasmtime = "46"
terrafier-core = { path = "../../core" }
anyhow.workspace = true
thiserror.workspace = true
log.workspace = true
[dev-dependencies]
tempfile = "3"
wat = "1"

View file

@ -0,0 +1,12 @@
use terrafier_core::model::tile::Tile;
pub struct WorldContext {
pub tile: Tile,
pub seed: u64,
pub tile_x: i32,
pub tile_z: i32,
pub hm_offset: usize,
pub ter_offset: usize,
pub wat_offset: usize,
pub error: Option<String>,
}

View file

@ -0,0 +1,13 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum WasmHostError {
#[error("Failed to load WASM module: {0}")]
LoadError(#[from] anyhow::Error),
#[error("WASM plugin does not export required function: {0}")]
MissingExport(String),
#[error("WASM plugin returned error: {0}")]
PluginError(String),
#[error("Memory access error: {0}")]
MemoryError(String),
}

View file

@ -0,0 +1,195 @@
use wasmtime::{Caller, Linker};
use crate::context::WorldContext;
const TILE_SIZE: usize = 128;
const HEIGHTMAP_BYTES: usize = TILE_SIZE * TILE_SIZE * 2;
const TERRAIN_BYTES: usize = TILE_SIZE * TILE_SIZE;
const WATER_BYTES: usize = TILE_SIZE * TILE_SIZE;
const TOTAL_BUFFER: usize = HEIGHTMAP_BYTES + TERRAIN_BYTES + WATER_BYTES;
pub fn register_host_functions(linker: &mut Linker<WorldContext>) -> anyhow::Result<()> {
linker.func_wrap("env", "host_log", host_log)?;
linker.func_wrap("env", "host_get_seed", host_get_seed)?;
linker.func_wrap("env", "host_tile_size", host_tile_size)?;
linker.func_wrap("env", "host_get_height", host_get_height)?;
linker.func_wrap("env", "host_set_height", host_set_height)?;
linker.func_wrap("env", "host_get_terrain", host_get_terrain)?;
linker.func_wrap("env", "host_set_terrain", host_set_terrain)?;
linker.func_wrap("env", "host_get_water", host_get_water)?;
linker.func_wrap("env", "host_set_water", host_set_water)?;
linker.func_wrap("env", "host_alloc_buffers", host_alloc_buffers)?;
linker.func_wrap("env", "host_load_heightmap", host_load_heightmap)?;
linker.func_wrap("env", "host_load_terrain", host_load_terrain)?;
linker.func_wrap("env", "host_load_water", host_load_water)?;
linker.func_wrap("env", "host_store_heightmap", host_store_heightmap)?;
linker.func_wrap("env", "host_store_terrain", host_store_terrain)?;
linker.func_wrap("env", "host_store_water", host_store_water)?;
Ok(())
}
fn host_log(mut caller: Caller<'_, WorldContext>, level: i32, ptr: i32, len: i32) {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return;
};
let mut buf = vec![0u8; len as usize];
let _ = mem.read(&caller, ptr as usize, &mut buf);
let msg = String::from_utf8_lossy(&buf);
match level {
0 => log::error!("[wasm] {}", msg),
1 => log::warn!("[wasm] {}", msg),
2 => log::info!("[wasm] {}", msg),
_ => log::debug!("[wasm] {}", msg),
}
}
fn host_get_seed(caller: Caller<'_, WorldContext>) -> i64 {
let (lo, hi) = (
(caller.data().seed & 0xFFFF_FFFF) as i64,
((caller.data().seed >> 32) & 0xFFFF_FFFF) as i64,
);
lo | (hi << 32)
}
fn host_tile_size(_caller: Caller<'_, WorldContext>) -> i32 {
TILE_SIZE as i32
}
fn host_get_height(caller: Caller<'_, WorldContext>, x: i32, z: i32) -> i32 {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return 0;
}
caller.data().tile.get_height(x as usize, z as usize) as i32
}
fn host_set_height(mut caller: Caller<'_, WorldContext>, x: i32, z: i32, val: i32) {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return;
}
caller
.data_mut()
.tile
.set_height(x as usize, z as usize, val as i16);
}
fn host_get_terrain(caller: Caller<'_, WorldContext>, x: i32, z: i32) -> i32 {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return 0;
}
caller.data().tile.get_terrain(x as usize, z as usize) as i32
}
fn host_set_terrain(mut caller: Caller<'_, WorldContext>, x: i32, z: i32, val: i32) {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return;
}
caller
.data_mut()
.tile
.set_terrain(x as usize, z as usize, val as u8);
}
fn host_get_water(caller: Caller<'_, WorldContext>, x: i32, z: i32) -> i32 {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return 0;
}
caller.data().tile.water_level[(z as usize) * TILE_SIZE + (x as usize)] as i32
}
fn host_set_water(mut caller: Caller<'_, WorldContext>, x: i32, z: i32, val: i32) {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return;
}
caller.data_mut().tile.water_level[(z as usize) * TILE_SIZE + (x as usize)] = val as u8;
}
fn host_alloc_buffers(mut caller: Caller<'_, WorldContext>) -> i32 {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return -1;
};
let needed_bytes = TOTAL_BUFFER;
let current_size = mem.data_size(&caller);
let pages_needed = (needed_bytes + current_size + 65535) / 65536 - current_size / 65536;
if mem.grow(&mut caller, pages_needed as u64).is_err() {
return -1;
}
let hm_offset = current_size;
let ter_offset = hm_offset + HEIGHTMAP_BYTES;
let wat_offset = ter_offset + TERRAIN_BYTES;
let ctx = caller.data_mut();
ctx.hm_offset = hm_offset;
ctx.ter_offset = ter_offset;
ctx.wat_offset = wat_offset;
0
}
fn host_load_heightmap(mut caller: Caller<'_, WorldContext>) -> i32 {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return -1;
};
let offset = caller.data().hm_offset;
let bytes: Vec<u8> = caller
.data()
.tile
.heightmap
.iter()
.flat_map(|h| h.to_le_bytes())
.collect();
let _ = mem.write(&mut caller, offset, &bytes);
offset as i32
}
fn host_load_terrain(mut caller: Caller<'_, WorldContext>) -> i32 {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return -1;
};
let offset = caller.data().ter_offset;
let terrain = caller.data().tile.terrain;
let _ = mem.write(&mut caller, offset, &terrain);
offset as i32
}
fn host_load_water(mut caller: Caller<'_, WorldContext>) -> i32 {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return -1;
};
let offset = caller.data().wat_offset;
let water = caller.data().tile.water_level;
let _ = mem.write(&mut caller, offset, &water);
offset as i32
}
fn host_store_heightmap(mut caller: Caller<'_, WorldContext>) {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return;
};
let offset = caller.data().hm_offset;
let mut buf = vec![0u8; HEIGHTMAP_BYTES];
let _ = mem.read(&caller, offset, &mut buf);
let ctx = caller.data_mut();
for (i, chunk) in buf.chunks_exact(2).enumerate() {
ctx.tile.heightmap[i] = i16::from_le_bytes([chunk[0], chunk[1]]);
}
}
fn host_store_terrain(mut caller: Caller<'_, WorldContext>) {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return;
};
let offset = caller.data().ter_offset;
let mut buf = [0u8; TERRAIN_BYTES];
let _ = mem.read(&caller, offset, &mut buf);
caller.data_mut().tile.terrain = buf;
}
fn host_store_water(mut caller: Caller<'_, WorldContext>) {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return;
};
let offset = caller.data().wat_offset;
let mut buf = [0u8; WATER_BYTES];
let _ = mem.read(&caller, offset, &mut buf);
caller.data_mut().tile.water_level = buf;
}

View file

@ -0,0 +1,43 @@
mod context;
mod error;
mod host;
mod plugin;
use std::path::Path;
use terrafier_core::plugins::PluginRegistry;
pub use context::WorldContext;
pub use error::WasmHostError;
pub use plugin::{WasmPlugin, WasmTileSourcePlugin};
/// Load all .wasm plugins from a directory into the registry.
pub fn load_wasm_dir(
registry: &mut PluginRegistry,
dir: &Path,
) -> Result<usize, Box<dyn std::error::Error>> {
let mut count = 0;
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "wasm").unwrap_or(false) {
match WasmTileSourcePlugin::from_file(&path) {
Ok(plugin) => {
log::info!(
"Loaded WASM plugin: {} v{} ({})",
plugin.name(),
plugin.version(),
path.display()
);
registry.sources.push(Box::new(plugin));
count += 1;
}
Err(e) => {
log::error!("Failed to load WASM plugin {}: {}", path.display(), e);
}
}
}
}
}
Ok(count)
}

View file

@ -0,0 +1,187 @@
use std::path::Path;
use terrafier_core::model::tile::Tile;
use terrafier_core::plugins::TileSourcePlugin;
use wasmtime::*;
use crate::context::WorldContext;
use crate::error::WasmHostError;
use crate::host::register_host_functions;
pub trait WasmPlugin {
fn name(&self) -> &str;
fn version(&self) -> &str;
fn plugin_type(&self) -> u32;
}
pub struct WasmTileSourcePlugin {
engine: Engine,
module: Module,
name: String,
version: String,
}
impl WasmTileSourcePlugin {
pub fn from_file(path: &Path) -> Result<Self, WasmHostError> {
let engine = Engine::default();
let wasm_bytes = std::fs::read(path).map_err(|e| {
WasmHostError::LoadError(anyhow::anyhow!("Failed to read .wasm file: {}", e))
})?;
let module = Module::from_binary(&engine, &wasm_bytes)
.map_err(|e| WasmHostError::LoadError(anyhow::anyhow!("Invalid WASM module: {}", e)))?;
let mut linker = Linker::new(&engine);
register_host_functions(&mut linker).map_err(|e| {
WasmHostError::LoadError(anyhow::anyhow!("Failed to register host functions: {}", e))
})?;
let dummy_ctx = WorldContext {
tile: Tile::new(0, 0, -64, 320),
seed: 0,
tile_x: 0,
tile_z: 0,
hm_offset: 0,
ter_offset: 0,
wat_offset: 0,
error: None,
};
let mut store = Store::new(&engine, dummy_ctx);
let instance = linker.instantiate(&mut store, &module).map_err(|e| {
WasmHostError::LoadError(anyhow::anyhow!("Failed to instantiate module: {}", e))
})?;
let memory = instance
.get_memory(&mut store, "memory")
.ok_or_else(|| WasmHostError::MissingExport("memory".into()))?;
let name = Self::read_string(&mut store, &instance, &memory, "name_ptr", "name_len")?;
let version =
Self::read_string(&mut store, &instance, &memory, "version_ptr", "version_len")?;
Ok(Self {
engine,
module,
name,
version,
})
}
fn read_string(
store: &mut Store<WorldContext>,
instance: &Instance,
memory: &Memory,
ptr_func: &str,
len_func: &str,
) -> Result<String, WasmHostError> {
let ptr = instance
.get_typed_func::<(), i32>(&mut *store, ptr_func)
.map_err(|_| WasmHostError::MissingExport(ptr_func.into()))?
.call(&mut *store, ())
.map_err(|e| WasmHostError::PluginError(e.to_string()))?;
let len = instance
.get_typed_func::<(), i32>(&mut *store, len_func)
.map_err(|_| WasmHostError::MissingExport(len_func.into()))?
.call(&mut *store, ())
.map_err(|e| WasmHostError::PluginError(e.to_string()))?;
let mut buf = vec![0u8; len as usize];
memory
.read(&*store, ptr as usize, &mut buf)
.map_err(|e| WasmHostError::MemoryError(e.to_string()))?;
Ok(String::from_utf8_lossy(&buf).to_string())
}
fn make_instance(
&self,
tile: &Tile,
seed: u64,
tile_x: i32,
tile_z: i32,
) -> Result<(Store<WorldContext>, Instance), WasmHostError> {
let mut linker = Linker::new(&self.engine);
register_host_functions(&mut linker).map_err(|e| WasmHostError::LoadError(e.into()))?;
let ctx = WorldContext {
tile: tile.clone(),
seed,
tile_x,
tile_z,
hm_offset: 0,
ter_offset: 0,
wat_offset: 0,
error: None,
};
let mut store = Store::new(&self.engine, ctx);
let instance = linker
.instantiate(&mut store, &self.module)
.map_err(|e| WasmHostError::LoadError(e.into()))?;
Ok((store, instance))
}
}
impl WasmPlugin for WasmTileSourcePlugin {
fn name(&self) -> &str {
&self.name
}
fn version(&self) -> &str {
&self.version
}
fn plugin_type(&self) -> u32 {
3
}
}
impl TileSourcePlugin for WasmTileSourcePlugin {
fn name(&self) -> &'static str {
Box::leak(self.name.clone().into_boxed_str())
}
fn version(&self) -> &'static str {
Box::leak(self.version.clone().into_boxed_str())
}
fn generate_tile(&self, tile: &mut Tile, seed: u64) {
let tx = tile.x;
let tz = tile.z;
let tile_clone = tile.clone();
let (mut store, instance) = match self.make_instance(&tile_clone, seed, tx, tz) {
Ok(v) => v,
Err(e) => {
log::error!(
"Failed to create WASM instance for plugin '{}': {}",
self.name,
e
);
return;
}
};
let Ok(gen_func) =
instance.get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "generate_tile")
else {
log::error!(
"WASM plugin '{}' does not export 'generate_tile'",
self.name
);
return;
};
let seed_lo = (seed & 0xFFFF_FFFF) as i32;
let seed_hi = ((seed >> 32) & 0xFFFF_FFFF) as i32;
match gen_func.call(&mut store, (tx, tz, seed_lo, seed_hi)) {
Ok(0) => {
*tile = store.data().tile.clone();
}
Ok(code) => {
log::error!(
"WASM plugin '{}' generate_tile returned error code: {}",
self.name,
code
);
}
Err(e) => {
log::error!("WASM plugin '{}' generate_tile trap: {}", self.name, e);
}
}
}
}

View file

@ -0,0 +1,101 @@
use terrafier_core::model::tile::Tile;
use terrafier_core::plugins::{PluginRegistry, TileSourcePlugin};
use terrafier_wasm_host::{WasmTileSourcePlugin, load_wasm_dir};
const MINIMAL_PLUGIN_WAT: &str = r#"
(module
(import "env" "host_set_height" (func $host_set_height (param i32 i32 i32)))
(import "env" "host_set_terrain" (func $host_set_terrain (param i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 1024) "test_plugin")
(data (i32.const 1040) "1.0.0")
(func (export "name_ptr") (result i32) i32.const 1024)
(func (export "name_len") (result i32) i32.const 11)
(func (export "version_ptr") (result i32) i32.const 1040)
(func (export "version_len") (result i32) i32.const 5)
(func (export "plugin_type") (result i32) i32.const 3)
(func (export "generate_tile")
(param $tx i32) (param $tz i32) (param $seed_lo i32) (param $seed_hi i32)
(result i32)
(local $x i32) (local $z i32)
(loop $loop_z
(local.set $x (i32.const 0))
(loop $loop_x
(call $host_set_height (local.get $x) (local.get $z)
(i32.add (local.get $x) (local.get $z)))
(call $host_set_terrain (local.get $x) (local.get $z) (i32.const 1))
(br_if $loop_x
(i32.lt_u
(local.tee $x (i32.add (local.get $x) (i32.const 1)))
(i32.const 128)))
)
(br_if $loop_z
(i32.lt_u
(local.tee $z (i32.add (local.get $z) (i32.const 1)))
(i32.const 128)))
)
(i32.const 0)
)
)
"#;
#[test]
fn test_load_plugin_from_file() {
let dir = tempfile::tempdir().unwrap();
let wasm_path = dir.path().join("test_plugin.wasm");
let wasm_bytes = wat::parse_str(MINIMAL_PLUGIN_WAT).expect("Failed to parse WAT");
std::fs::write(&wasm_path, &wasm_bytes).unwrap();
let plugin = WasmTileSourcePlugin::from_file(&wasm_path).expect("Failed to load WASM plugin");
assert_eq!(plugin.name(), "test_plugin");
assert_eq!(plugin.version(), "1.0.0");
}
#[test]
fn test_generate_tile() {
let dir = tempfile::tempdir().unwrap();
let wasm_path = dir.path().join("test_plugin.wasm");
let wasm_bytes = wat::parse_str(MINIMAL_PLUGIN_WAT).expect("Failed to parse WAT");
std::fs::write(&wasm_path, &wasm_bytes).unwrap();
let plugin = WasmTileSourcePlugin::from_file(&wasm_path).unwrap();
let mut tile = Tile::new(5, 3, -64, 320);
plugin.generate_tile(&mut tile, 42);
assert_eq!(tile.x, 5);
assert_eq!(tile.z, 3);
// Plugin sets height = x + z, terrain = 1
for z in 0..128 {
for x in 0..128 {
assert_eq!(tile.get_height(x, z), (x + z) as i16);
assert_eq!(tile.get_terrain(x, z), 1);
}
}
}
#[test]
fn test_load_wasm_dir() {
let dir = tempfile::tempdir().unwrap();
// Create two .wasm files
let wasm_bytes = wat::parse_str(MINIMAL_PLUGIN_WAT).expect("Failed to parse WAT");
std::fs::write(dir.path().join("plugin_a.wasm"), &wasm_bytes).unwrap();
std::fs::write(dir.path().join("plugin_b.wasm"), &wasm_bytes).unwrap();
// Non-wasm file should be ignored
std::fs::write(dir.path().join("readme.txt"), b"ignore me").unwrap();
let mut registry = PluginRegistry::new();
assert_eq!(registry.sources.len(), 0);
let count = load_wasm_dir(&mut registry, dir.path()).unwrap();
assert_eq!(count, 2);
assert_eq!(registry.sources.len(), 2);
}