815 lines
27 KiB
Rust
815 lines
27 KiB
Rust
//! Local mod discovery, dependency ordering and transactional script callbacks.
|
|
use crate::{
|
|
assets::{Catalog, contained},
|
|
content::{API_VERSION, ContentPack, Registry},
|
|
simulation::LegacyRng,
|
|
};
|
|
use anyhow::{Context, Result, ensure};
|
|
use rhai::{
|
|
AST, Dynamic, Engine, Scope,
|
|
packages::{
|
|
ArithmeticPackage, BasicArrayPackage, BasicFnPackage, BasicIteratorPackage,
|
|
BasicMapPackage, BasicMathPackage, BasicStringPackage, BitFieldPackage, LogicPackage,
|
|
MoreStringPackage, Package,
|
|
},
|
|
};
|
|
use schemars::JsonSchema;
|
|
use semver::{Version, VersionReq};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
};
|
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Manifest {
|
|
/// Unique lowercase namespace; `base` is reserved for the original game.
|
|
pub id: String,
|
|
/// Display name shown in the in-game manager.
|
|
pub name: String,
|
|
/// Semantic package version, saved together with a SHA-256 content hash.
|
|
pub version: String,
|
|
/// Exact supported content/script API major version.
|
|
pub api_version: u32,
|
|
#[serde(default)]
|
|
pub description: String,
|
|
/// Required, explicitly enabled mod IDs mapped to semantic version requirements.
|
|
#[serde(default)]
|
|
pub dependencies: BTreeMap<String, String>,
|
|
/// IDs which cannot be enabled at the same time as this package.
|
|
#[serde(default)]
|
|
pub conflicts: Vec<String>,
|
|
/// Relative path to the JSON content pack. Defaults to `content.json`.
|
|
#[serde(default = "content_file")]
|
|
pub content: String,
|
|
/// Relative paths to every Rhai source referenced by this content pack.
|
|
#[serde(default)]
|
|
pub scripts: Vec<String>,
|
|
}
|
|
fn content_file() -> String {
|
|
"content.json".into()
|
|
}
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct ModPin {
|
|
pub id: String,
|
|
pub version: String,
|
|
pub hash: String,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct InstalledMod {
|
|
pub root: PathBuf,
|
|
pub manifest: Manifest,
|
|
pub hash: String,
|
|
}
|
|
#[derive(Debug, Default)]
|
|
pub struct Discovery {
|
|
pub mods: BTreeMap<String, InstalledMod>,
|
|
pub errors: Vec<String>,
|
|
/// Directory inventoried by this snapshot, used to reject new duplicate packages.
|
|
pub root: Option<PathBuf>,
|
|
}
|
|
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
|
const MAX_PACKAGE_BYTES: u64 = 256 * 1024 * 1024;
|
|
const MAX_FILES: usize = 10000;
|
|
const MAX_DEPTH: usize = 16;
|
|
|
|
fn namespace(id: &str) -> bool {
|
|
!id.is_empty()
|
|
&& id.len() <= 64
|
|
&& id != "base"
|
|
&& id
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c))
|
|
}
|
|
fn relative_path(value: &str) -> Result<()> {
|
|
let path = Path::new(value);
|
|
ensure!(
|
|
!value.is_empty()
|
|
&& value.len() <= 512
|
|
&& !path.is_absolute()
|
|
&& path
|
|
.components()
|
|
.all(|c| matches!(c, std::path::Component::Normal(_))),
|
|
"package paths must be relative and may not contain parent traversal: {value}"
|
|
);
|
|
Ok(())
|
|
}
|
|
fn read_package_file(root: &Path, relative: &Path) -> Result<Vec<u8>> {
|
|
let path = contained(root, relative)?;
|
|
let metadata = fs::symlink_metadata(&path)?;
|
|
ensure!(metadata.is_file(), "package entry must be a regular file");
|
|
ensure!(metadata.len() <= MAX_FILE_BYTES, "mod file exceeds 64 MiB");
|
|
let data = fs::read(path)?;
|
|
ensure!(
|
|
data.len() as u64 <= MAX_FILE_BYTES,
|
|
"mod file exceeds 64 MiB"
|
|
);
|
|
Ok(data)
|
|
}
|
|
fn load_package(path: &Path) -> Result<InstalledMod> {
|
|
ensure!(
|
|
fs::symlink_metadata(path)?.is_dir(),
|
|
"mod folder must be a real directory"
|
|
);
|
|
let root = path.canonicalize()?;
|
|
let mut files = Vec::new();
|
|
let mut bytes = 0;
|
|
walk(&root, &root, &mut files, &mut bytes, 0)?;
|
|
files.sort();
|
|
let manifest_data = read_package_file(&root, Path::new("mod.toml"))?;
|
|
ensure!(
|
|
manifest_data.len() <= 64 * 1024,
|
|
"mod manifest exceeds 64 KiB"
|
|
);
|
|
let manifest: Manifest = toml::from_str(std::str::from_utf8(&manifest_data)?)?;
|
|
ensure!(namespace(&manifest.id), "invalid/reserved mod namespace");
|
|
ensure!(
|
|
!manifest.name.trim().is_empty() && manifest.name.len() <= 128,
|
|
"invalid mod display name"
|
|
);
|
|
ensure!(
|
|
manifest.description.len() <= 4096,
|
|
"mod description exceeds 4 KiB"
|
|
);
|
|
Version::parse(&manifest.version).context("invalid package version")?;
|
|
ensure!(
|
|
manifest.dependencies.len() <= 128 && manifest.conflicts.len() <= 128,
|
|
"too many dependencies/conflicts"
|
|
);
|
|
for (dependency, version) in &manifest.dependencies {
|
|
ensure!(
|
|
namespace(dependency) && dependency != &manifest.id,
|
|
"invalid/self dependency {dependency}"
|
|
);
|
|
VersionReq::parse(version)
|
|
.with_context(|| format!("invalid version requirement for {dependency}"))?;
|
|
}
|
|
let conflicts: BTreeSet<_> = manifest.conflicts.iter().collect();
|
|
ensure!(
|
|
conflicts.len() == manifest.conflicts.len(),
|
|
"duplicate conflict declaration"
|
|
);
|
|
for conflict in &manifest.conflicts {
|
|
ensure!(
|
|
namespace(conflict) && conflict != &manifest.id,
|
|
"invalid/self conflict {conflict}"
|
|
);
|
|
}
|
|
relative_path(&manifest.content)?;
|
|
contained(&root, Path::new(&manifest.content))?;
|
|
ensure!(
|
|
manifest.scripts.len() <= 64,
|
|
"too many scripts (maximum 64)"
|
|
);
|
|
let scripts: BTreeSet<_> = manifest.scripts.iter().collect();
|
|
ensure!(
|
|
scripts.len() == manifest.scripts.len(),
|
|
"duplicate script declaration"
|
|
);
|
|
for script in &manifest.scripts {
|
|
relative_path(script)?;
|
|
contained(&root, Path::new(script))?;
|
|
}
|
|
let mut hash = Sha256::new();
|
|
hash.update(b"raptor-mod-package-v1\0");
|
|
for relative in files {
|
|
let name = relative
|
|
.to_str()
|
|
.context("package filenames must be UTF-8")?;
|
|
let data = read_package_file(&root, &relative)?;
|
|
hash.update((name.len() as u64).to_le_bytes());
|
|
hash.update(name.as_bytes());
|
|
hash.update((data.len() as u64).to_le_bytes());
|
|
hash.update(data);
|
|
}
|
|
Ok(InstalledMod {
|
|
root,
|
|
manifest,
|
|
hash: format!("{:x}", hash.finalize()),
|
|
})
|
|
}
|
|
pub fn discover(root: &Path) -> Discovery {
|
|
let mut out = Discovery::default();
|
|
out.root = Some(root.canonicalize().unwrap_or_else(|_| root.to_path_buf()));
|
|
if !root.exists() {
|
|
return out;
|
|
}
|
|
let entries = match fs::read_dir(root) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
out.errors.push(e.to_string());
|
|
return out;
|
|
}
|
|
};
|
|
let mut entries: Vec<_> = entries.collect();
|
|
entries.sort_by_key(|e| e.as_ref().ok().map(|e| e.path()));
|
|
let mut ambiguous = BTreeSet::new();
|
|
for entry in entries {
|
|
let e = match entry {
|
|
Ok(e) => e,
|
|
Err(err) => {
|
|
out.errors.push(format!("{}: {err}", root.display()));
|
|
continue;
|
|
}
|
|
};
|
|
let kind = match e.file_type() {
|
|
Ok(kind) => kind,
|
|
Err(err) => {
|
|
out.errors.push(format!("{}: {err}", e.path().display()));
|
|
continue;
|
|
}
|
|
};
|
|
if kind.is_symlink() {
|
|
out.errors.push(format!(
|
|
"{}: mod folders may not be symlinks",
|
|
e.path().display()
|
|
));
|
|
continue;
|
|
}
|
|
if !kind.is_dir() {
|
|
continue;
|
|
}
|
|
match load_package(&e.path()) {
|
|
Ok(m) => {
|
|
let id = m.manifest.id.clone();
|
|
if ambiguous.contains(&id) || out.mods.contains_key(&id) {
|
|
out.errors.push(format!("duplicate installed mod {id}"));
|
|
ambiguous.insert(id.clone());
|
|
out.mods.remove(&id);
|
|
} else {
|
|
out.mods.insert(id, m);
|
|
}
|
|
}
|
|
Err(err) => out.errors.push(format!("{}: {err:#}", e.path().display())),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
fn walk(
|
|
root: &Path,
|
|
current: &Path,
|
|
files: &mut Vec<PathBuf>,
|
|
bytes: &mut u64,
|
|
depth: usize,
|
|
) -> Result<()> {
|
|
ensure!(
|
|
depth <= MAX_DEPTH,
|
|
"mod package directory depth exceeds {MAX_DEPTH}"
|
|
);
|
|
for e in fs::read_dir(current)? {
|
|
let e = e?;
|
|
let relative = e.path().strip_prefix(root)?.to_path_buf();
|
|
ensure!(
|
|
!e.file_type()?.is_symlink(),
|
|
"mod packages may not contain symlinks"
|
|
);
|
|
if e.file_type()?.is_dir() {
|
|
walk(root, &e.path(), files, bytes, depth + 1)?
|
|
} else {
|
|
ensure!(
|
|
e.file_type()?.is_file(),
|
|
"mod entries must be regular files"
|
|
);
|
|
ensure!(
|
|
e.metadata()?.len() <= MAX_FILE_BYTES,
|
|
"mod file exceeds 64 MiB"
|
|
);
|
|
*bytes = bytes
|
|
.checked_add(e.metadata()?.len())
|
|
.context("package size overflow")?;
|
|
ensure!(*bytes <= MAX_PACKAGE_BYTES, "mod package exceeds 256 MiB");
|
|
files.push(relative);
|
|
ensure!(files.len() <= MAX_FILES, "mod package has too many files");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn order(discovery: &Discovery, enabled: &[String]) -> Result<Vec<String>> {
|
|
let selected: BTreeSet<_> = enabled.iter().cloned().collect();
|
|
ensure!(
|
|
selected.len() == enabled.len(),
|
|
"duplicate mod in load order"
|
|
);
|
|
let mut result = Vec::new();
|
|
let mut visiting = BTreeSet::new();
|
|
let mut done = BTreeSet::new();
|
|
fn visit(
|
|
id: &str,
|
|
d: &Discovery,
|
|
selected: &BTreeSet<String>,
|
|
visiting: &mut BTreeSet<String>,
|
|
done: &mut BTreeSet<String>,
|
|
result: &mut Vec<String>,
|
|
) -> Result<()> {
|
|
if done.contains(id) {
|
|
return Ok(());
|
|
}
|
|
ensure!(visiting.insert(id.into()), "cyclic mod dependency at {id}");
|
|
let m = d
|
|
.mods
|
|
.get(id)
|
|
.with_context(|| format!("mod {id} is not installed"))?;
|
|
ensure!(
|
|
m.manifest.api_version == API_VERSION,
|
|
"{id}: requires API {}, game provides {API_VERSION}",
|
|
m.manifest.api_version
|
|
);
|
|
for (dep, requirement) in &m.manifest.dependencies {
|
|
ensure!(selected.contains(dep), "{id} requires enabled mod {dep}");
|
|
let v = &d
|
|
.mods
|
|
.get(dep)
|
|
.with_context(|| format!("missing dependency {dep}"))?
|
|
.manifest
|
|
.version;
|
|
ensure!(
|
|
VersionReq::parse(requirement)?.matches(&Version::parse(v)?),
|
|
"{id} requires {dep} {requirement}, installed {v}"
|
|
);
|
|
visit(dep, d, selected, visiting, done, result)?;
|
|
}
|
|
for conflict in &m.manifest.conflicts {
|
|
ensure!(
|
|
!selected.contains(conflict),
|
|
"{id} conflicts with {conflict}"
|
|
);
|
|
}
|
|
visiting.remove(id);
|
|
done.insert(id.into());
|
|
result.push(id.into());
|
|
Ok(())
|
|
}
|
|
for id in enabled {
|
|
visit(
|
|
id,
|
|
discovery,
|
|
&selected,
|
|
&mut visiting,
|
|
&mut done,
|
|
&mut result,
|
|
)?;
|
|
}
|
|
Ok(result)
|
|
}
|
|
pub struct ActiveMods {
|
|
pub registry: Registry,
|
|
pub pins: Vec<ModPin>,
|
|
pub scripts: ScriptRuntime,
|
|
}
|
|
impl ActiveMods {
|
|
pub fn build(
|
|
base: &Registry,
|
|
discovery: &Discovery,
|
|
enabled: &[String],
|
|
catalog: Option<&Catalog>,
|
|
) -> Result<Self> {
|
|
let mut registry = base.clone();
|
|
let mut pins = Vec::new();
|
|
let mut scripts = ScriptRuntime::new();
|
|
let ordered = order(discovery, enabled)?;
|
|
let refreshed = discovery.root.as_ref().map(|root| discover(root));
|
|
for id in &ordered {
|
|
let previous = &discovery.mods[id];
|
|
if let Some(refreshed) = &refreshed {
|
|
ensure!(
|
|
refreshed.mods.contains_key(id),
|
|
"{id}: package removed, invalid or duplicated since discovery; rescan the Mods screen ({})",
|
|
refreshed.errors.join("; ")
|
|
);
|
|
}
|
|
let current = load_package(&previous.root).with_context(|| {
|
|
format!("{id}: package changed or was removed; rescan the Mods screen")
|
|
})?;
|
|
ensure!(
|
|
current.manifest.id == *id && current.hash == previous.hash,
|
|
"{id}: package changed since discovery; rescan the Mods screen before applying"
|
|
);
|
|
}
|
|
for id in &ordered {
|
|
let m = &discovery.mods[id];
|
|
let content: ContentPack = serde_json::from_slice(&read_package_file(
|
|
&m.root,
|
|
Path::new(&m.manifest.content),
|
|
)?)
|
|
.with_context(|| format!("{id}: invalid content"))?;
|
|
registry.asset_roots.insert(id.clone(), m.root.clone());
|
|
registry.apply(content, &id)?;
|
|
for path in &m.manifest.scripts {
|
|
let source = read_package_file(&m.root, Path::new(path))?;
|
|
let source = std::str::from_utf8(&source)
|
|
.with_context(|| format!("{id}/{path}: source must be UTF-8"))?;
|
|
scripts.add(&id, path, &source)?;
|
|
}
|
|
pins.push(ModPin {
|
|
id: id.clone(),
|
|
version: m.manifest.version.clone(),
|
|
hash: m.hash.clone(),
|
|
});
|
|
}
|
|
registry.validate()?;
|
|
let validate_art = |owner: &str, art: &crate::content::Art| -> Result<()> {
|
|
for png in art.png.iter().chain(art.hd_png.iter()) {
|
|
let root = registry
|
|
.asset_roots
|
|
.get(owner)
|
|
.context("missing PNG root")?;
|
|
let path = contained(root, Path::new(png))?;
|
|
let palette = catalog
|
|
.map(|cat| cat.palette())
|
|
.transpose()?
|
|
.unwrap_or([[0; 3]; 256]);
|
|
let sprite = crate::assets::Sprite::png(&path, &palette)?;
|
|
ensure!(
|
|
sprite.width % art.frames as usize == 0,
|
|
"{owner}/{png}: PNG strip width must divide evenly into frames"
|
|
);
|
|
ensure!(
|
|
sprite.width / art.frames as usize * art.logical_height as usize
|
|
== sprite.height * art.logical_width as usize,
|
|
"{owner}/{png}: artwork canvas aspect differs from logical canvas"
|
|
);
|
|
if art.hd_png.as_ref() == Some(png) {
|
|
ensure!(
|
|
sprite.width / art.frames as usize >= 2 * art.logical_width as usize
|
|
&& sprite.height >= 2 * art.logical_height as usize,
|
|
"{owner}/{png}: HD artwork needs at least twice the logical resolution"
|
|
);
|
|
}
|
|
}
|
|
if let (Some(id), Some(cat)) = (art.asset, catalog) {
|
|
for frame in 0..art.frames {
|
|
cat.sprite(id.checked_add(frame).context("asset ID overflow")?)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
};
|
|
for (id, owner) in ®istry.owners {
|
|
if owner == "base" {
|
|
continue;
|
|
}
|
|
if let Some(art) = registry.art(id) {
|
|
validate_art(owner, art)?;
|
|
}
|
|
if let Some(sound) = registry.sounds.get(id) {
|
|
let root = registry
|
|
.asset_roots
|
|
.get(owner)
|
|
.context("missing sound root")?;
|
|
let bytes = read_package_file(root, Path::new(&sound.file))?;
|
|
crate::sound::decode(&bytes)
|
|
.with_context(|| format!("{owner}/{}: invalid sound", sound.file))?;
|
|
}
|
|
if let Some(m) = registry.missions.get(id) {
|
|
for tile in &m.tiles {
|
|
validate_art(owner, &tile.art)?;
|
|
if let Some(a) = &tile.destroyed {
|
|
validate_art(owner, a)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let validate_script = |owner: &str, path: &Option<String>| -> Result<()> {
|
|
if let Some(path) = path {
|
|
ensure!(
|
|
scripts.has(owner, path),
|
|
"{owner}: script {path} must appear in manifest.scripts"
|
|
);
|
|
}
|
|
Ok(())
|
|
};
|
|
for (id, owner) in ®istry.owners {
|
|
if let Some(w) = registry.weapons.get(id) {
|
|
validate_script(owner, &w.script)?;
|
|
}
|
|
if let Some(e) = registry.enemies.get(id) {
|
|
validate_script(owner, &e.script)?;
|
|
}
|
|
if let Some(i) = registry.items.get(id) {
|
|
validate_script(owner, &i.script)?;
|
|
}
|
|
if let Some(m) = registry.missions.get(id) {
|
|
validate_script(owner, &m.script)?;
|
|
}
|
|
}
|
|
for id in &ordered {
|
|
ensure!(
|
|
load_package(&discovery.mods[id].root)?.hash == discovery.mods[id].hash,
|
|
"{id}: package changed while loading; rescan the Mods screen before applying"
|
|
);
|
|
}
|
|
Ok(Self {
|
|
registry,
|
|
pins,
|
|
scripts,
|
|
})
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum Command {
|
|
/// Add an enemy at an integer position in the original logical canvas.
|
|
Spawn {
|
|
enemy: String,
|
|
x: i32,
|
|
y: i32,
|
|
},
|
|
Projectile {
|
|
weapon: String,
|
|
x: i32,
|
|
y: i32,
|
|
vx: i32,
|
|
vy: i32,
|
|
#[serde(default)]
|
|
hostile: bool,
|
|
},
|
|
Damage {
|
|
/// Enemy entity ID; zero addresses the player.
|
|
entity: u64,
|
|
amount: i32,
|
|
},
|
|
Despawn {
|
|
entity: u64,
|
|
},
|
|
Move {
|
|
/// Existing enemy entity ID.
|
|
entity: u64,
|
|
x: i32,
|
|
y: i32,
|
|
},
|
|
Velocity {
|
|
entity: u64,
|
|
vx: i32,
|
|
vy: i32,
|
|
},
|
|
GiveItem {
|
|
item: String,
|
|
count: u32,
|
|
},
|
|
ConsumeItem {
|
|
item: String,
|
|
count: u32,
|
|
},
|
|
Credits {
|
|
amount: i32,
|
|
},
|
|
Shield {
|
|
amount: i32,
|
|
},
|
|
Effect {
|
|
x: i32,
|
|
y: i32,
|
|
},
|
|
Sound {
|
|
asset: u32,
|
|
},
|
|
PlaySound {
|
|
sound: String,
|
|
},
|
|
Objective {
|
|
/// Stable namespaced objective key, at most 128 bytes.
|
|
key: String,
|
|
value: i32,
|
|
},
|
|
Complete,
|
|
Fail,
|
|
}
|
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HookResult {
|
|
/// Transactional commands, applied in order only when the callback succeeds.
|
|
#[serde(default)]
|
|
pub commands: Vec<Command>,
|
|
/// JSON-serializable state shared by callbacks in this script; maximum 64 KiB.
|
|
#[serde(default)]
|
|
pub state: serde_json::Value,
|
|
}
|
|
pub struct ScriptRuntime {
|
|
engine: Engine,
|
|
scripts: BTreeMap<(String, String), AST>,
|
|
pub states: BTreeMap<String, serde_json::Value>,
|
|
pub rng: BTreeMap<String, LegacyRng>,
|
|
budget: std::sync::Arc<std::sync::atomic::AtomicI64>,
|
|
}
|
|
impl ScriptRuntime {
|
|
pub fn new() -> Self {
|
|
// Rhai's default StandardPackage includes wall-clock timestamps and native sleep.
|
|
// Use an explicit package allowlist so timing cannot bypass operation limits,
|
|
// including through dynamically created function pointers.
|
|
let mut engine = Engine::new_raw();
|
|
ArithmeticPackage::new().register_into_engine(&mut engine);
|
|
BasicStringPackage::new().register_into_engine(&mut engine);
|
|
BasicIteratorPackage::new().register_into_engine(&mut engine);
|
|
BasicFnPackage::new().register_into_engine(&mut engine);
|
|
BitFieldPackage::new().register_into_engine(&mut engine);
|
|
LogicPackage::new().register_into_engine(&mut engine);
|
|
BasicMathPackage::new().register_into_engine(&mut engine);
|
|
BasicArrayPackage::new().register_into_engine(&mut engine);
|
|
BasicMapPackage::new().register_into_engine(&mut engine);
|
|
MoreStringPackage::new().register_into_engine(&mut engine);
|
|
engine
|
|
.set_max_operations(100000)
|
|
.set_max_call_levels(32)
|
|
.set_max_expr_depths(64, 32)
|
|
.set_max_array_size(4096)
|
|
.set_max_map_size(1024)
|
|
.set_max_string_size(16384)
|
|
.set_max_modules(0);
|
|
engine.disable_symbol("eval");
|
|
engine.disable_symbol("import");
|
|
engine.on_print(|_| {});
|
|
engine.on_debug(|_, _, _| {});
|
|
let budget = std::sync::Arc::new(std::sync::atomic::AtomicI64::new(500000));
|
|
let counter = budget.clone();
|
|
engine.on_progress(move |_| {
|
|
if counter.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) <= 0 {
|
|
Some(Dynamic::UNIT)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
Self {
|
|
engine,
|
|
scripts: BTreeMap::new(),
|
|
states: BTreeMap::new(),
|
|
rng: BTreeMap::new(),
|
|
budget,
|
|
}
|
|
}
|
|
pub fn begin_tick(&self) {
|
|
self.budget
|
|
.store(500000, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
pub fn add(&mut self, owner: &str, path: &str, source: &str) -> Result<()> {
|
|
ensure!(
|
|
source.len() <= 1024 * 1024,
|
|
"{owner}/{path}: script source exceeds 1 MiB"
|
|
);
|
|
let mut ast = self
|
|
.engine
|
|
.compile(source)
|
|
.map_err(|e| anyhow::anyhow!("{owner}/{path}: {e}"))?;
|
|
for function in ast.iter_functions() {
|
|
if [
|
|
"on_spawn",
|
|
"on_tick",
|
|
"on_fire",
|
|
"on_hit",
|
|
"on_death",
|
|
"on_pickup",
|
|
"on_use",
|
|
"on_mission_start",
|
|
"on_mission_end",
|
|
]
|
|
.contains(&function.name)
|
|
{
|
|
ensure!(
|
|
function.params.len() == 3,
|
|
"{owner}/{path}::{}: callbacks require (event, state, random)",
|
|
function.name
|
|
);
|
|
}
|
|
}
|
|
ast.set_source(format!("{owner}/{path}"));
|
|
self.scripts.insert((owner.into(), path.into()), ast);
|
|
Ok(())
|
|
}
|
|
pub fn has(&self, owner: &str, path: &str) -> bool {
|
|
self.scripts.contains_key(&(owner.into(), path.into()))
|
|
}
|
|
pub fn invoke(
|
|
&mut self,
|
|
owner: &str,
|
|
path: &str,
|
|
hook: &str,
|
|
event: &serde_json::Value,
|
|
seed: i32,
|
|
) -> Result<Vec<Command>> {
|
|
let ast = self
|
|
.scripts
|
|
.get(&(owner.into(), path.into()))
|
|
.with_context(|| format!("{owner}/{path}::{hook}: script is not loaded"))?;
|
|
if !ast
|
|
.iter_functions()
|
|
.any(|f| f.name == hook && f.params.len() == 3)
|
|
{
|
|
return Ok(vec![]);
|
|
}
|
|
let key = format!("{owner}/{path}");
|
|
let mut rng = *self.rng.get(&key).unwrap_or(&LegacyRng::new(seed));
|
|
let random: Vec<i32> = (0..32).map(|_| rng.next_value()).collect();
|
|
let state = self
|
|
.states
|
|
.get(&key)
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!({}));
|
|
let event = rhai::serde::to_dynamic(event.clone())?;
|
|
let state = rhai::serde::to_dynamic(state)?;
|
|
let random = rhai::serde::to_dynamic(random)?;
|
|
let value = self
|
|
.engine
|
|
.call_fn::<Dynamic>(&mut Scope::new(), ast, hook, (event, state, random))
|
|
.map_err(|e| anyhow::anyhow!("{owner}/{path}::{hook}: {e}"))?;
|
|
let result: HookResult = rhai::serde::from_dynamic(&value)
|
|
.map_err(|e| anyhow::anyhow!("{owner}/{path}::{hook}: invalid result: {e}"))?;
|
|
ensure!(
|
|
result.commands.len() <= 256,
|
|
"{owner}/{path}::{hook}: callback emitted too many commands"
|
|
);
|
|
ensure!(
|
|
serde_json::to_vec(&result.state)?.len() <= 65536,
|
|
"{owner}/{path}::{hook}: callback state exceeds 64 KiB"
|
|
);
|
|
// World command validation is performed by the caller before committing these values.
|
|
self.states.insert(key.clone(), result.state);
|
|
self.rng.insert(key, rng);
|
|
Ok(result.commands)
|
|
}
|
|
pub fn checkpoints(
|
|
&self,
|
|
) -> (
|
|
BTreeMap<String, serde_json::Value>,
|
|
BTreeMap<String, LegacyRng>,
|
|
) {
|
|
(self.states.clone(), self.rng.clone())
|
|
}
|
|
pub fn restore(
|
|
&mut self,
|
|
c: (
|
|
BTreeMap<String, serde_json::Value>,
|
|
BTreeMap<String, LegacyRng>,
|
|
),
|
|
) {
|
|
self.states = c.0;
|
|
self.rng = c.1;
|
|
}
|
|
}
|
|
impl Default for ScriptRuntime {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
pub fn write_schemas(root: &Path) -> Result<()> {
|
|
fs::create_dir_all(root)?;
|
|
macro_rules! schema {
|
|
($name:expr,$ty:ty) => {
|
|
crate::persistence::atomic_write(
|
|
&root.join($name),
|
|
&serde_json::to_vec_pretty(&schemars::schema_for!($ty))?,
|
|
)?;
|
|
};
|
|
}
|
|
schema!("mod-v1.json", Manifest);
|
|
schema!("content-v1.json", ContentPack);
|
|
schema!("hook-v1.json", HookResult);
|
|
schema!("weapon-projectile-v1.json", crate::content::WeaponDef);
|
|
schema!("item-v1.json", crate::content::ItemDef);
|
|
schema!("enemy-v1.json", crate::content::EnemyDef);
|
|
schema!("mission-v1.json", crate::content::MissionDef);
|
|
schema!("campaign-v1.json", crate::content::CampaignDef);
|
|
schema!("sound-v1.json", crate::content::SoundDef);
|
|
Ok(())
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn bounded_script_and_state_rollback() {
|
|
let mut rt = ScriptRuntime::new();
|
|
rt.add("test", "bad.rhai", "fn on_tick(e,s,r) { while true {} }")
|
|
.unwrap();
|
|
assert!(
|
|
rt.invoke("test", "bad.rhai", "on_tick", &serde_json::json!({}), 1)
|
|
.is_err()
|
|
);
|
|
assert!(rt.states.is_empty());
|
|
}
|
|
#[test]
|
|
fn script_can_emit_commands() {
|
|
let mut rt = ScriptRuntime::new();
|
|
rt.add("test","good.rhai","fn on_fire(e,s,r) { #{ commands: [#{ op: \"credits\", amount: 7 }], state: #{ calls: 1 } } }").unwrap();
|
|
assert_eq!(
|
|
rt.invoke("test", "good.rhai", "on_fire", &serde_json::json!({}), 1)
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
assert_eq!(rt.states["test/good.rhai"]["calls"], 1);
|
|
}
|
|
#[test]
|
|
fn io_is_not_exposed() {
|
|
let mut rt = ScriptRuntime::new();
|
|
rt.add(
|
|
"test",
|
|
"io.rhai",
|
|
"fn on_tick(e,s,r){ read_file(\"/etc/passwd\") }",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
rt.invoke("test", "io.rhai", "on_tick", &serde_json::json!({}), 1)
|
|
.is_err()
|
|
);
|
|
}
|
|
}
|