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:
parent
bdd4564240
commit
18613260a8
100 changed files with 4262 additions and 585 deletions
|
|
@ -162,6 +162,4 @@ impl Region {
|
|||
pub fn chunk_count(&self) -> usize {
|
||||
self.chunks.len()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
10
crates/i18n/Cargo.toml
Normal file
10
crates/i18n/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "terrafier-i18n"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Internationalization for Terrafier"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
8
crates/i18n/src/lang.rs
Normal file
8
crates/i18n/src/lang.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum Language {
|
||||
English = 0,
|
||||
Russian = 1,
|
||||
}
|
||||
116
crates/i18n/src/lib.rs
Normal file
116
crates/i18n/src/lib.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
mod lang;
|
||||
pub use lang::Language;
|
||||
|
||||
// Language code: 0 = English, 1 = Russian
|
||||
static CURRENT_LANG: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
// Embedded translations
|
||||
type TransMap = HashMap<String, String>;
|
||||
|
||||
static EN: LazyLock<TransMap> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("translations/en.json"))
|
||||
.expect("Failed to parse English translations")
|
||||
});
|
||||
|
||||
static RU: LazyLock<TransMap> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("translations/ru.json"))
|
||||
.expect("Failed to parse Russian translations")
|
||||
});
|
||||
|
||||
pub fn set_language(lang: Language) {
|
||||
CURRENT_LANG.store(lang as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn language() -> Language {
|
||||
match CURRENT_LANG.load(Ordering::Relaxed) {
|
||||
1 => Language::Russian,
|
||||
_ => Language::English,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a translation for the given key.
|
||||
pub fn translate(key: &str) -> &str {
|
||||
let map = match CURRENT_LANG.load(Ordering::Relaxed) {
|
||||
1 => &*RU,
|
||||
_ => &*EN,
|
||||
};
|
||||
map.get(key).map(String::as_str).unwrap_or(key)
|
||||
}
|
||||
|
||||
/// Get a translation with variable substitution.
|
||||
/// Variables in the string are `{name}` placeholders.
|
||||
pub fn translate_with(key: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut s = translate(key).to_string();
|
||||
for (k, v) in vars {
|
||||
s = s.replace(&format!("{{{}}}", k), v);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Macro for simple translation lookup.
|
||||
#[macro_export]
|
||||
macro_rules! t {
|
||||
($key:expr) => {
|
||||
$crate::translate($key)
|
||||
};
|
||||
($key:expr, $($var:ident = $val:expr),+ $(,)?) => {{
|
||||
let vars: &[(&str, &str)] = &[$((stringify!($var), &$val as &dyn std::fmt::Display as &str)),+];
|
||||
// This won't work with runtime format. Use translate_with instead.
|
||||
$crate::translate_with($key, &[$((stringify!($var), &$val.as_ref() as &str)),+])
|
||||
}};
|
||||
}
|
||||
|
||||
/// Detect system language from environment variables (LANG, LANGUAGE, LC_ALL).
|
||||
pub fn detect_language() -> Language {
|
||||
for var in &["LANG", "LANGUAGE", "LC_ALL"] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
let lower = val.to_lowercase();
|
||||
if lower.starts_with("ru") || lower.contains("ru_ru") {
|
||||
return Language::Russian;
|
||||
}
|
||||
}
|
||||
}
|
||||
Language::English
|
||||
}
|
||||
|
||||
/// Initialize i18n: detect language and set it.
|
||||
pub fn init() {
|
||||
let lang = detect_language();
|
||||
set_language(lang);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_language_is_english() {
|
||||
assert_eq!(translate("cli.new.creating"), "Creating new world...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_russian_language() {
|
||||
set_language(Language::Russian);
|
||||
assert_eq!(translate("cli.new.creating"), "Создание нового мира...");
|
||||
set_language(Language::English);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_key_returns_key() {
|
||||
assert_eq!(translate("nonexistent.key"), "nonexistent.key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_with_vars() {
|
||||
let result = translate_with("cli.new.created", &[
|
||||
("name", "test"),
|
||||
("tiles", "9"),
|
||||
]);
|
||||
assert!(result.contains("test"));
|
||||
assert!(result.contains("9"));
|
||||
}
|
||||
}
|
||||
30
crates/i18n/src/translations/en.json
Normal file
30
crates/i18n/src/translations/en.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"cli.new.creating": "Creating new world...",
|
||||
"cli.new.created": "World \"{name}\" created ({tiles} tiles)",
|
||||
"cli.export.exporting": "Exporting world to \"{path}\"...",
|
||||
"cli.export.complete": "Export complete: {regions} regions, {chunks} chunks",
|
||||
"cli.export.empty": "No chunks to export",
|
||||
"cli.import.importing": "Importing Minecraft save from \"{path}\"...",
|
||||
"cli.import.complete": "Import complete: {tiles} tiles, {dimensions} dimensions",
|
||||
"cli.import.no_regions": "No region files found in \"{path}\"",
|
||||
"cli.render.rendering": "Rendering world preview...",
|
||||
"cli.render.saved": "Preview saved to \"{path}\"",
|
||||
"cli.info.title": "World: {name}",
|
||||
"cli.info.seed": "Seed: {seed}",
|
||||
"cli.info.platform": "Platform: {platform}",
|
||||
"cli.info.tiles": "Tiles: {tiles}",
|
||||
"cli.info.dimensions": "Dimensions: {dimensions}",
|
||||
"cli.validate.ok": "World file is valid",
|
||||
"cli.validate.fail": "World file is invalid: {error}",
|
||||
"cli.error.not_found": "File not found: {path}",
|
||||
"cli.error.io": "I/O error: {error}",
|
||||
"cli.error.format": "Unsupported format: {format}",
|
||||
"cli.dry_run": "[DRY RUN] Would {action}",
|
||||
"cli.status.ok": "Done",
|
||||
"cli.status.error": "Error",
|
||||
"ops.smooth.applying": "Smoothing terrain...",
|
||||
"ops.erode.applying": "Eroding terrain...",
|
||||
"ops.raise.applying": "Raising terrain...",
|
||||
"ops.lower.applying": "Lowering terrain...",
|
||||
"ops.flatten.applying": "Flattening terrain..."
|
||||
}
|
||||
30
crates/i18n/src/translations/ru.json
Normal file
30
crates/i18n/src/translations/ru.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"cli.new.creating": "Создание нового мира...",
|
||||
"cli.new.created": "Мир \"{name}\" создан ({tiles} тайлов)",
|
||||
"cli.export.exporting": "Экспорт мира в \"{path}\"...",
|
||||
"cli.export.complete": "Экспорт завершён: {regions} регионов, {chunks} чанков",
|
||||
"cli.export.empty": "Нет чанков для экспорта",
|
||||
"cli.import.importing": "Импорт сохранения Minecraft из \"{path}\"...",
|
||||
"cli.import.complete": "Импорт завершён: {tiles} тайлов, {dimensions} измерений",
|
||||
"cli.import.no_regions": "Файлы регионов не найдены в \"{path}\"",
|
||||
"cli.render.rendering": "Рендер превью мира...",
|
||||
"cli.render.saved": "Превью сохранено в \"{path}\"",
|
||||
"cli.info.title": "Мир: {name}",
|
||||
"cli.info.seed": "Сид: {seed}",
|
||||
"cli.info.platform": "Платформа: {platform}",
|
||||
"cli.info.tiles": "Тайлов: {tiles}",
|
||||
"cli.info.dimensions": "Измерений: {dimensions}",
|
||||
"cli.validate.ok": "Файл мира корректен",
|
||||
"cli.validate.fail": "Файл мира повреждён: {error}",
|
||||
"cli.error.not_found": "Файл не найден: {path}",
|
||||
"cli.error.io": "Ошибка ввода-вывода: {error}",
|
||||
"cli.error.format": "Неподдерживаемый формат: {format}",
|
||||
"cli.dry_run": "[DRY RUN] Будет выполнено: {action}",
|
||||
"cli.status.ok": "Готово",
|
||||
"cli.status.error": "Ошибка",
|
||||
"ops.smooth.applying": "Сглаживание рельефа...",
|
||||
"ops.erode.applying": "Эрозия рельефа...",
|
||||
"ops.raise.applying": "Поднятие рельефа...",
|
||||
"ops.lower.applying": "Опускание рельефа...",
|
||||
"ops.flatten.applying": "Выравнивание рельефа..."
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
pub mod read;
|
||||
pub mod write;
|
||||
|
||||
pub use read::reader;
|
||||
pub use read::read_tag;
|
||||
pub use write::writer;
|
||||
pub use read::reader;
|
||||
pub use write::write_tag;
|
||||
pub use write::writer;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
pub mod reader;
|
||||
pub mod read_tag;
|
||||
pub mod reader;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
use crate::tag::Tag;
|
||||
use super::reader::{NbtReader, ReadError, Result};
|
||||
use crate::tag::Tag;
|
||||
|
||||
impl<R: Read> NbtReader<R> {
|
||||
pub(crate) fn read_tag_payload(&mut self, tag_type: u8) -> Result<Tag> {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
pub mod writer;
|
||||
pub mod write_tag;
|
||||
pub mod writer;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::io::Write;
|
||||
|
||||
use super::writer::{NbtWriter, Result, WriteError};
|
||||
use crate::tag::Tag;
|
||||
use super::writer::{NbtWriter, WriteError, Result};
|
||||
|
||||
impl<W: Write> NbtWriter<W> {
|
||||
pub(crate) fn write_tag(&mut self, tag: &Tag, name: Option<&str>) -> Result<()> {
|
||||
|
|
|
|||
11
crates/scripting/Cargo.toml
Normal file
11
crates/scripting/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "terrafier-scripting"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Rhai scripting engine for Terrafier"
|
||||
|
||||
[dependencies]
|
||||
terrafier-core = { path = "../../core" }
|
||||
rhai = "1"
|
||||
anyhow.workspace = true
|
||||
148
crates/scripting/src/lib.rs
Normal file
148
crates/scripting/src/lib.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use std::path::Path;
|
||||
|
||||
use rhai::{Dynamic, Engine, EvalAltResult};
|
||||
|
||||
use terrafier_core::World;
|
||||
use terrafier_core::ops::heightmap::{FlatHeightMap, HeightMapSource, NoiseHeightMap};
|
||||
use terrafier_core::ops::layers::generate_all_layers;
|
||||
|
||||
pub fn run_script(path: &Path) -> anyhow::Result<()> {
|
||||
let script = std::fs::read_to_string(path)?;
|
||||
let mut engine = ScriptEngine::new();
|
||||
engine
|
||||
.run(&script)
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Script error: {}", e))
|
||||
}
|
||||
|
||||
pub struct ScriptEngine {
|
||||
engine: Engine,
|
||||
}
|
||||
|
||||
fn new_world_fn(name: &str, seed: i64) -> World {
|
||||
World::new(name, seed as u64)
|
||||
}
|
||||
|
||||
fn save_world_fn(world: &mut World, path: &str) -> Result<(), Box<EvalAltResult>> {
|
||||
terrafier_core::io::save_binary(world, Path::new(path)).map_err(|e| e.to_string().into())
|
||||
}
|
||||
|
||||
fn info_fn(world: &mut World) -> String {
|
||||
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
|
||||
let dim_names: Vec<&str> = world.dimensions.iter().map(|d| d.name.as_str()).collect();
|
||||
format!(
|
||||
"World: {} seed: {} dimensions: [{}] tiles: {}",
|
||||
world.name,
|
||||
world.seed,
|
||||
dim_names.join(", "),
|
||||
tile_count
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_heightmap_flat_fn(
|
||||
world: &mut World,
|
||||
height: i64,
|
||||
dim_name: &str,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
let dim = world
|
||||
.dimensions
|
||||
.iter_mut()
|
||||
.find(|d| d.name == dim_name)
|
||||
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
|
||||
let flat = FlatHeightMap {
|
||||
height: height as i16,
|
||||
};
|
||||
for (_, tile) in &mut dim.tiles {
|
||||
flat.generate(tile, world.seed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_heightmap_noise_fn(
|
||||
world: &mut World,
|
||||
seed: i64,
|
||||
amplitude: f64,
|
||||
_octaves: i64,
|
||||
dim_name: &str,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
let dim = world
|
||||
.dimensions
|
||||
.iter_mut()
|
||||
.find(|d| d.name == dim_name)
|
||||
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
|
||||
let noise = NoiseHeightMap {
|
||||
base_height: 63.0,
|
||||
amplitude,
|
||||
frequency: 0.01,
|
||||
scale_x: 1.0,
|
||||
scale_z: 1.0,
|
||||
};
|
||||
for (_, tile) in &mut dim.tiles {
|
||||
noise.generate(tile, seed as u64);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_smooth_fn(
|
||||
world: &mut World,
|
||||
_iterations: i64,
|
||||
_strength: f64,
|
||||
dim_name: &str,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
world
|
||||
.dimensions
|
||||
.iter_mut()
|
||||
.find(|d| d.name == dim_name)
|
||||
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_erode_fn(
|
||||
world: &mut World,
|
||||
_iterations: i64,
|
||||
_strength: f64,
|
||||
dim_name: &str,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
world
|
||||
.dimensions
|
||||
.iter_mut()
|
||||
.find(|d| d.name == dim_name)
|
||||
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_layers_fn(world: &mut World, dim_name: &str) -> Result<(), Box<EvalAltResult>> {
|
||||
let dim = world
|
||||
.dimensions
|
||||
.iter_mut()
|
||||
.find(|d| d.name == dim_name)
|
||||
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
|
||||
for (_, tile) in &mut dim.tiles {
|
||||
generate_all_layers(tile, world.seed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ScriptEngine {
|
||||
pub fn new() -> Self {
|
||||
let mut engine = Engine::new();
|
||||
|
||||
engine.register_type::<World>();
|
||||
|
||||
engine.register_fn("new_world", new_world_fn);
|
||||
engine.register_fn("save_world", save_world_fn);
|
||||
engine.register_fn("info", info_fn);
|
||||
engine.register_fn("generate_heightmap_flat", generate_heightmap_flat_fn);
|
||||
engine.register_fn("generate_heightmap_noise", generate_heightmap_noise_fn);
|
||||
engine.register_fn("apply_smooth", apply_smooth_fn);
|
||||
engine.register_fn("apply_erode", apply_erode_fn);
|
||||
engine.register_fn("generate_layers", generate_layers_fn);
|
||||
|
||||
Self { engine }
|
||||
}
|
||||
|
||||
pub fn run(&mut self, script: &str) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let ast = self.engine.compile(script)?;
|
||||
self.engine.eval_ast(&ast)
|
||||
}
|
||||
}
|
||||
56
crates/scripting/tests/integration_test.rs
Normal file
56
crates/scripting/tests/integration_test.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use std::path::Path;
|
||||
|
||||
use terrafier_core::World;
|
||||
use terrafier_scripting::ScriptEngine;
|
||||
|
||||
#[test]
|
||||
fn test_new_world() {
|
||||
let mut engine = ScriptEngine::new();
|
||||
let result = engine
|
||||
.run(
|
||||
r#"
|
||||
let world = new_world("test_world", 12345);
|
||||
let i = info(world);
|
||||
i.contains("test_world") && i.contains("12345")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.as_bool().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_flat_heightmap() {
|
||||
let mut engine = ScriptEngine::new();
|
||||
let _ = engine
|
||||
.run(
|
||||
r#"
|
||||
let world = new_world("flat_world", 42);
|
||||
generate_heightmap_flat(world, 64, "overworld");
|
||||
save_world(world, "/tmp/test_flat.tfw");
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let loaded: World = terrafier_core::io::load_binary(Path::new("/tmp/test_flat.tfw")).unwrap();
|
||||
if let Some(dim) = loaded.dimensions.first() {
|
||||
for (_, tile) in &dim.tiles {
|
||||
for z in 0..128 {
|
||||
for x in 0..128 {
|
||||
assert_eq!(tile.get_height(x, z), 64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file("/tmp/test_flat.tfw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_script_error_reporting() {
|
||||
let mut engine = ScriptEngine::new();
|
||||
let result = engine.run(
|
||||
r#"
|
||||
let world = new_world("bad", 1);
|
||||
generate_heightmap_flat(world, 64, "nonexistent");
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
17
crates/wasm-host/Cargo.toml
Normal file
17
crates/wasm-host/Cargo.toml
Normal 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"
|
||||
12
crates/wasm-host/src/context.rs
Normal file
12
crates/wasm-host/src/context.rs
Normal 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>,
|
||||
}
|
||||
13
crates/wasm-host/src/error.rs
Normal file
13
crates/wasm-host/src/error.rs
Normal 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),
|
||||
}
|
||||
195
crates/wasm-host/src/host.rs
Normal file
195
crates/wasm-host/src/host.rs
Normal 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;
|
||||
}
|
||||
43
crates/wasm-host/src/lib.rs
Normal file
43
crates/wasm-host/src/lib.rs
Normal 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)
|
||||
}
|
||||
187
crates/wasm-host/src/plugin.rs
Normal file
187
crates/wasm-host/src/plugin.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
crates/wasm-host/tests/integration_test.rs
Normal file
101
crates/wasm-host/tests/integration_test.rs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue