mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
313b777209 | ||
|
|
8b99708236 | ||
|
|
47f424cdcf | ||
|
|
d534c9ab1d |
No files matched your search
Generated
+7
@@ -3888,6 +3888,13 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vignette"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -34,6 +34,7 @@ members = [
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"crates/horizon-mod-sdk/examples/vignette",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/far-view",
|
||||
|
||||
@@ -197,9 +197,18 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
let mut reader = content.reader()?;
|
||||
let mut files = Vec::with_capacity(content.files.len());
|
||||
for name in &content.files {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
files.push((name.clone(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
// Its shaders, which content leaves out (`Package::read` checks them).
|
||||
for name in &manifest.shaders {
|
||||
let file = manifest.shader_path(name);
|
||||
let bytes = std::fs::read(root.join(&file)).map_err(|e| format!("{file}: {e}"))?;
|
||||
files.push((file, bytes));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files
|
||||
.iter()
|
||||
.map(|(n, b)| (n.as_str(), b.as_slice()))
|
||||
.collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
|
||||
@@ -115,6 +115,8 @@ mod mod_input;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_recipes;
|
||||
mod mod_render;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_shaders;
|
||||
mod mods;
|
||||
mod mount_menu;
|
||||
mod mounts;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Capability `render` (docs/mods.md): the shapes mods keep on the HUD,
|
||||
//! drawn by the game's own HUD renderer, and their camera shakes. Mods
|
||||
//! never supply shader code. With no mod drawing, each hook here returns
|
||||
//! drawn by the game's own HUD renderer, and their camera shakes (their own
|
||||
//! shaders: `mod_shaders.rs`). With no mod drawing, each hook here returns
|
||||
//! after one check.
|
||||
use super::Game;
|
||||
use horizon_client::hud::HudBatch;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Mods' own shaders (docs/mods.md, "Shaders from a mod"): the passes mods register with
|
||||
//! `render.register-shader`, handed to the renderer when they change and their `params`
|
||||
//! every frame. The renderer times each on the GPU; a pass that keeps going over its budget is
|
||||
//! removed and the player told, as a mod out of fuel is stopped. With no mod shader, this is
|
||||
//! one comparison of two empty lists a frame.
|
||||
use super::Game;
|
||||
use horizon_client::gfx::{ModPass, PASS_BUDGET_MS};
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn sync_mod_shaders(&mut self) {
|
||||
let Some(renderer) = &mut self.renderer else {
|
||||
return;
|
||||
};
|
||||
let mods = &mut self.mods.mods;
|
||||
let keys: Vec<(usize, u32)> = mods.shaders().map(|(i, _, s)| (i, s.handle)).collect();
|
||||
if keys != self.mods.shaders {
|
||||
let passes = mods
|
||||
.shaders()
|
||||
.map(|(_, id, s)| ModPass {
|
||||
label: format!("{id}/{}", s.name),
|
||||
stage: (*s.stage).clone(),
|
||||
})
|
||||
.collect();
|
||||
if let Err((i, e)) = renderer.set_mod_shaders(passes) {
|
||||
// The others are rebuilt without it next frame.
|
||||
let (owner, handle) = keys[i];
|
||||
mods.drop_shader(owner, handle, &format!("the GPU refused it: {e}"));
|
||||
self.mods.shaders.clear();
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
self.mods.shaders = keys;
|
||||
}
|
||||
for (i, (_, _, s)) in mods.shaders().enumerate() {
|
||||
renderer.set_mod_shader_params(i, &s.values);
|
||||
}
|
||||
let over = renderer.mod_shaders_over_budget();
|
||||
for &(i, ms) in &over {
|
||||
let (owner, handle) = self.mods.shaders[i];
|
||||
let why =
|
||||
format!("it took {ms:.1} ms of GPU time a frame, over its {PASS_BUDGET_MS} ms");
|
||||
mods.drop_shader(owner, handle, &why);
|
||||
}
|
||||
if !over.is_empty() {
|
||||
self.show_mod_output();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ pub(super) struct ModsState {
|
||||
/// Capability `render`'s camera shakes.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) shake: super::mod_render::Shake,
|
||||
/// The mods' shaders the renderer draws, as (mod, handle), in order.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) shaders: Vec<(usize, u32)>,
|
||||
/// `--dev-mod PATH`: always on, reloaded when rebuilt.
|
||||
pub(super) dev: Vec<PathBuf>,
|
||||
/// When dev mods were last checked for a rebuild.
|
||||
@@ -304,7 +307,9 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
|
||||
// A dedicated server's owner sets it in server.toml (`[mods]`).
|
||||
let quota = horizon_mods::StorageQuota::from_env();
|
||||
let mods = Mods::load_for(self.mod_runs(), quota, &paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
@@ -352,7 +357,10 @@ impl Game<'_> {
|
||||
};
|
||||
};
|
||||
// What it may do first: the row cuts long descriptions.
|
||||
let may: Vec<&str> = m.capabilities.iter().map(|c| c.describe()).collect();
|
||||
let mut may: Vec<&str> = m.capabilities.iter().map(|c| c.describe()).collect();
|
||||
if !m.shaders.is_empty() {
|
||||
may.push("use custom shaders");
|
||||
}
|
||||
let may = if may.is_empty() {
|
||||
"Asks for nothing.".to_owned()
|
||||
} else {
|
||||
@@ -627,6 +635,8 @@ impl Game<'_> {
|
||||
/// Once per frame: the frame hook, and dev mods' rebuilds.
|
||||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||||
self.reload_dev_mods();
|
||||
// Before the check below: mods that stopped take their passes with them.
|
||||
self.sync_mod_shaders();
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
@@ -701,6 +711,12 @@ impl Game<'_> {
|
||||
}
|
||||
Text::literal(line).with_color(text::RED)
|
||||
}
|
||||
Output::ShaderStopped {
|
||||
name,
|
||||
shader,
|
||||
reason,
|
||||
} => Text::literal(format!("Mod {name}'s shader {shader} stopped: {reason}"))
|
||||
.with_color(text::RED),
|
||||
server @ (Output::Tell { .. }
|
||||
| Output::Broadcast { .. }
|
||||
| Output::Reply { .. }
|
||||
|
||||
@@ -42,6 +42,7 @@ pub use entity_batch::{
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
|
||||
};
|
||||
pub use mod_post::{ModPass, PASS_BUDGET_MS};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -74,6 +75,7 @@ mod flat;
|
||||
mod hdr;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod mod_post;
|
||||
mod native_material;
|
||||
mod native_pack;
|
||||
mod native_post;
|
||||
@@ -541,6 +543,8 @@ pub struct Renderer {
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
native: Option<native_pack::NativePack>,
|
||||
/// Mods' post passes (`render.register-shader`), before a shader pack's.
|
||||
mod_post: Option<mod_post::ModPost>,
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
pass_timing: bool,
|
||||
/// The entity vertices Horizon draws itself while a shader pack draws the others.
|
||||
@@ -2644,6 +2648,7 @@ impl Renderer {
|
||||
hdr_output,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
mod_post: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
pack_entity_rest: Vec::new(),
|
||||
cloud_bindings,
|
||||
@@ -3316,6 +3321,33 @@ impl Renderer {
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
/// Draws `passes`, mods' post passes, from now on (none: no pass), in order. On an error
|
||||
/// (the pass wgpu refused, by index) none run.
|
||||
pub fn set_mod_shaders(
|
||||
&mut self,
|
||||
passes: Vec<ModPass>,
|
||||
) -> std::result::Result<(), (usize, String)> {
|
||||
self.mod_post = None;
|
||||
if !passes.is_empty() {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let post = mod_post::ModPost::new(&self.device, &self.queue, passes, format)?;
|
||||
self.mod_post = Some(post);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The mod pass `i`'s `params`, from the next frame.
|
||||
pub fn set_mod_shader_params(&self, i: usize, values: &[f32]) {
|
||||
if let Some(m) = &self.mod_post {
|
||||
m.post.set_params(&self.queue, i, values);
|
||||
}
|
||||
}
|
||||
/// The mod passes that keep going over their GPU budget (`PASS_BUDGET_MS`): their index
|
||||
/// and median time in milliseconds.
|
||||
pub fn mod_shaders_over_budget(&mut self) -> Vec<(usize, f32)> {
|
||||
self.mod_post
|
||||
.as_mut()
|
||||
.map_or_else(Vec::new, mod_post::ModPost::over_budget)
|
||||
}
|
||||
/// What a shader pack's gbuffers programs read from Horizon's geometry: the sky's, and
|
||||
/// terrain's in the pack's block atlas (made here when missing; none when it does not fit).
|
||||
pub fn pack_inputs(&mut self) -> horizon_shaders::GameInputs {
|
||||
@@ -4446,6 +4478,8 @@ impl Renderer {
|
||||
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
|
||||
// One of Horizon's own packs reads the same world target.
|
||||
let native_post = !frame.title && self.native.as_ref().is_some_and(|n| n.post.is_some());
|
||||
// Mods' passes read it too, before the pack's (never with a `shaders/` pack's post).
|
||||
let mod_post = !frame.title && !pack_post && self.mod_post.is_some();
|
||||
if let Some(timer) = Self::pass_timer(&mut self.pack_post, &mut self.native)
|
||||
&& timer.is_some() != self.pass_timing
|
||||
{
|
||||
@@ -4454,7 +4488,7 @@ impl Renderer {
|
||||
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
let pack_reads_world = pack_post || native_post || mod_post;
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -4537,6 +4571,13 @@ impl Renderer {
|
||||
native.sky.is_some() && t.recording() && sky_visible && frame.skybox;
|
||||
}
|
||||
}
|
||||
if mod_post && let Some(mods) = &mut self.mod_post {
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
mods.frame.write(&self.queue, frame, size);
|
||||
if let Some(t) = &mut mods.timing {
|
||||
t.begin(&self.device, &self.queue);
|
||||
}
|
||||
}
|
||||
if native_sky_pass
|
||||
&& let Some(native) = &mut self.native
|
||||
&& let Some(sky) = &native.sky
|
||||
@@ -5011,19 +5052,40 @@ impl Renderer {
|
||||
);
|
||||
self.pack_post = Some((dimension, Some(post)));
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& native_post
|
||||
&& let Some(native) = &mut self.native
|
||||
&& let Some(post) = &mut native.post
|
||||
&& (native_post || mod_post)
|
||||
{
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
post.run(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
(color, depth),
|
||||
&view_target,
|
||||
size,
|
||||
native.timing.as_mut(),
|
||||
);
|
||||
// Mods' passes first: onto the screen, or into the picture the pack's passes read.
|
||||
let mut color = color.clone();
|
||||
if mod_post && let Some(mods) = &mut self.mod_post {
|
||||
let out = match native_post {
|
||||
true => mods.output(&self.device, size),
|
||||
false => view_target.clone(),
|
||||
};
|
||||
let timing = mods.timing.as_mut();
|
||||
mods.post.run(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
(&color, depth),
|
||||
&out,
|
||||
size,
|
||||
timing,
|
||||
);
|
||||
color = out;
|
||||
}
|
||||
if native_post
|
||||
&& let Some(native) = &mut self.native
|
||||
&& let Some(post) = &mut native.post
|
||||
{
|
||||
post.run(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
(&color, depth),
|
||||
&view_target,
|
||||
size,
|
||||
native.timing.as_mut(),
|
||||
);
|
||||
}
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
}
|
||||
@@ -5211,6 +5273,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some(timer) = self.mod_post.as_mut().and_then(|m| m.timing.as_mut()) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Mods' post passes (docs/mods.md, "Shaders from a mod"): the shaders mods register as
|
||||
//! `post-after-translucent`, run over the world's picture before a shader pack's own post
|
||||
//! passes and under the hand and HUD. They are built as a pack's post stage is
|
||||
//! (`NativePost`), each with a `params` uniform the mod sets every frame, and timed on the GPU
|
||||
//! all the time: a pass that keeps going over [`PASS_BUDGET_MS`] is reported for the game to
|
||||
//! remove.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use horizon_shader_api::check::CheckedStage;
|
||||
|
||||
use super::native_post::{NativeFrame, NativePost};
|
||||
use super::pack_timing::PassTiming;
|
||||
|
||||
/// GPU time one mod pass may take a frame, in milliseconds (the median of its timed frames).
|
||||
pub const PASS_BUDGET_MS: f32 = 2.0;
|
||||
/// How long a pass may stay over budget before it is removed.
|
||||
const OVER_FOR: Duration = Duration::from_secs(5);
|
||||
|
||||
/// One mod's pass: its label (`<mod>/<shader>`) and its checked stage.
|
||||
pub struct ModPass {
|
||||
pub label: String,
|
||||
pub stage: CheckedStage,
|
||||
}
|
||||
|
||||
pub struct ModPost {
|
||||
pub post: NativePost,
|
||||
pub frame: NativeFrame,
|
||||
pub timing: Option<PassTiming>,
|
||||
budget: Budget,
|
||||
/// The passes' output when a shader pack's post passes read it after them, of the
|
||||
/// world's size.
|
||||
out: Option<([u32; 2], wgpu::TextureView)>,
|
||||
format: wgpu::TextureFormat,
|
||||
}
|
||||
|
||||
impl ModPost {
|
||||
/// Pipelines for `passes`, in order, drawing onto a screen of `format`. Errors name the
|
||||
/// pass wgpu refused, by its index.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
passes: Vec<ModPass>,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Result<Self, (usize, String)> {
|
||||
let frame = NativeFrame::new(device);
|
||||
let labels: Vec<String> = passes.iter().map(|p| p.label.clone()).collect();
|
||||
let stages = passes
|
||||
.into_iter()
|
||||
.map(|p| CheckedStage {
|
||||
label: p.label,
|
||||
..p.stage
|
||||
})
|
||||
.collect();
|
||||
let post = NativePost::for_mods(device, stages, format, &frame)?;
|
||||
Ok(Self {
|
||||
post,
|
||||
frame,
|
||||
timing: PassTiming::new(device, queue),
|
||||
budget: Budget::new(labels),
|
||||
out: None,
|
||||
format,
|
||||
})
|
||||
}
|
||||
|
||||
/// The target the passes write when a shader pack's post passes follow them.
|
||||
pub fn output(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::TextureView {
|
||||
if self.out.as_ref().is_none_or(|o| o.0 != size) {
|
||||
let view = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Mod post output"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
self.out = Some((size, view));
|
||||
}
|
||||
self.out.as_ref().expect("made above").1.clone()
|
||||
}
|
||||
|
||||
/// The passes over budget for too long: their index and median GPU time in milliseconds.
|
||||
pub fn over_budget(&mut self) -> Vec<(usize, f32)> {
|
||||
let Some(timing) = &self.timing else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.budget.check(&timing.medians(), Instant::now())
|
||||
}
|
||||
}
|
||||
|
||||
/// Which passes keep going over [`PASS_BUDGET_MS`]: one over it in every check for
|
||||
/// [`OVER_FOR`] is reported once.
|
||||
struct Budget {
|
||||
/// Each pass's label and since when it has been over.
|
||||
passes: Vec<(String, Option<Instant>)>,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn new(labels: Vec<String>) -> Self {
|
||||
Self {
|
||||
passes: labels.into_iter().map(|l| (l, None)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The passes, by index, whose median in `medians` has stayed over the budget for
|
||||
/// [`OVER_FOR`] by `now`, with that median.
|
||||
fn check(&mut self, medians: &[(String, f32)], now: Instant) -> Vec<(usize, f32)> {
|
||||
let mut over = Vec::new();
|
||||
for (i, (label, since)) in self.passes.iter_mut().enumerate() {
|
||||
let Some(&(_, ms)) = medians.iter().find(|(l, _)| l == label) else {
|
||||
continue;
|
||||
};
|
||||
if ms <= PASS_BUDGET_MS {
|
||||
*since = None;
|
||||
continue;
|
||||
}
|
||||
let start = *since.get_or_insert(now);
|
||||
if now - start >= OVER_FOR {
|
||||
over.push((i, ms));
|
||||
// Reported once: the game removes it and rebuilds the passes.
|
||||
*since = Some(now + Duration::from_secs(3600));
|
||||
}
|
||||
}
|
||||
over
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pass_that_keeps_going_over_budget_is_dropped() {
|
||||
let mut budget = Budget::new(vec!["slow/blur".into(), "fast/tint".into()]);
|
||||
let t0 = Instant::now();
|
||||
let at = |s: f32| t0 + Duration::from_secs_f32(s);
|
||||
let medians = |slow: f32| {
|
||||
vec![
|
||||
("slow/blur".to_owned(), slow),
|
||||
("fast/tint".to_owned(), 0.1),
|
||||
]
|
||||
};
|
||||
assert!(budget.check(&medians(3.5), at(0.0)).is_empty());
|
||||
assert!(budget.check(&medians(3.5), at(4.0)).is_empty());
|
||||
// A dip under the budget starts the count again.
|
||||
assert!(budget.check(&medians(1.5), at(4.5)).is_empty());
|
||||
assert!(budget.check(&medians(3.5), at(5.0)).is_empty());
|
||||
assert!(budget.check(&medians(3.5), at(9.0)).is_empty());
|
||||
assert_eq!(budget.check(&medians(3.5), at(10.0)), [(0, 3.5)]);
|
||||
// Reported once.
|
||||
assert!(budget.check(&medians(3.5), at(11.0)).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,16 @@
|
||||
//! colour and depth, the previous pass's output and two samplers. Passes before the last write
|
||||
//! half-float targets of the world's size, so values over 1 carry to the next; the last draws
|
||||
//! onto the screen. Every stage of a pack reads the same `frame`, written once a frame
|
||||
//! (`NativeFrame`).
|
||||
//! (`NativeFrame`). Mods' post passes are built the same way, each with its own `params`
|
||||
//! uniform at binding 6 (`gfx/mod_post.rs`).
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
|
||||
use horizon_shader_api::{
|
||||
Frame as FrameInputs,
|
||||
check::CheckedStage,
|
||||
mods::{PARAMS_BINDING, PARAMS_SIZE},
|
||||
stages::GROUP,
|
||||
};
|
||||
|
||||
use super::Frame;
|
||||
|
||||
@@ -18,6 +24,8 @@ type WorldViews = (wgpu::TextureView, wgpu::TextureView);
|
||||
|
||||
pub struct NativePost {
|
||||
passes: Vec<(String, wgpu::RenderPipeline)>,
|
||||
/// Each pass's `params` uniform (a mod's pass); empty for a pack's.
|
||||
params: Vec<wgpu::Buffer>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
frame: wgpu::Buffer,
|
||||
linear: wgpu::Sampler,
|
||||
@@ -141,17 +149,52 @@ impl NativePost {
|
||||
format: wgpu::TextureFormat,
|
||||
frame: &NativeFrame,
|
||||
) -> Result<Self, String> {
|
||||
Self::build(device, stages, constants, format, frame, false).map_err(|e| e.1)
|
||||
}
|
||||
|
||||
/// Pipelines for mods' post passes (`horizon_shader_api::mods`), each reading its own
|
||||
/// `params`. Errors name the pass wgpu refused, by its index.
|
||||
pub fn for_mods(
|
||||
device: &wgpu::Device,
|
||||
stages: Vec<CheckedStage>,
|
||||
format: wgpu::TextureFormat,
|
||||
frame: &NativeFrame,
|
||||
) -> Result<Self, (usize, String)> {
|
||||
Self::build(device, stages, &[], format, frame, true)
|
||||
}
|
||||
|
||||
fn build(
|
||||
device: &wgpu::Device,
|
||||
stages: Vec<CheckedStage>,
|
||||
constants: &[(String, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
frame: &NativeFrame,
|
||||
params: bool,
|
||||
) -> Result<Self, (usize, String)> {
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut entries = vec![
|
||||
frame_entry(wgpu::ShaderStages::FRAGMENT),
|
||||
texture_entry(1, float),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(3, float),
|
||||
sampler_entry(4),
|
||||
sampler_entry(5),
|
||||
];
|
||||
if params {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: PARAMS_BINDING,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(PARAMS_SIZE),
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
entries: &[
|
||||
frame_entry(wgpu::ShaderStages::FRAGMENT),
|
||||
texture_entry(1, float),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(3, float),
|
||||
sampler_entry(4),
|
||||
sampler_entry(5),
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[GROUP as usize] = Some(&layout);
|
||||
@@ -199,10 +242,20 @@ impl NativePost {
|
||||
cache: None,
|
||||
});
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("{}: {e}", stage.file));
|
||||
return Err((i, format!("{}: {e}", stage.file)));
|
||||
}
|
||||
passes.push((stage.label, pipeline));
|
||||
}
|
||||
let params = (0..if params { count } else { 0 })
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Mod shader params"),
|
||||
size: PARAMS_SIZE,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let sampler = |label, filter| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(label),
|
||||
@@ -213,6 +266,7 @@ impl NativePost {
|
||||
};
|
||||
Ok(Self {
|
||||
passes,
|
||||
params,
|
||||
layout,
|
||||
frame: frame.buffer.clone(),
|
||||
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
|
||||
@@ -228,6 +282,17 @@ impl NativePost {
|
||||
self.passes.iter().map(|(l, _)| l.as_str()).collect()
|
||||
}
|
||||
|
||||
/// A mod's pass `i` reads `values` as its `params` from the next submission (the rest 0).
|
||||
pub fn set_params(&self, queue: &wgpu::Queue, i: usize, values: &[f32]) {
|
||||
if let Some(buffer) = self.params.get(i) {
|
||||
let mut all = [0f32; PARAMS_SIZE as usize / 4];
|
||||
for (to, v) in all.iter_mut().zip(values) {
|
||||
*to = *v;
|
||||
}
|
||||
queue.write_buffer(buffer, 0, bytemuck::cast_slice(&all));
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -277,35 +342,42 @@ impl NativePost {
|
||||
(Some((_, t)), 1..) => &t[(i - 1) % 2],
|
||||
_ => world.0,
|
||||
};
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
];
|
||||
if let Some(params) = self.params.get(i) {
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: PARAMS_BINDING,
|
||||
resource: params.as_entire_binding(),
|
||||
});
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
],
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -482,13 +554,53 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
|
||||
let pixel = draw(&device, &queue, &mut post);
|
||||
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
|
||||
assert!(
|
||||
near(pixel[0], 204) && near(pixel[1], 102) && near(pixel[2], 51) && near(pixel[3], 128),
|
||||
"{pixel:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A mod's pass reads the numbers it set (`params`), the rest 0.
|
||||
#[test]
|
||||
fn a_mods_pass_reads_its_params() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let wgsl = "fn post(input: PostInput) -> vec4<f32> {\n\
|
||||
return vec4<f32>(input.color.rgb * params.gain + params.lift, 1.0 - params.unset);\n}\n";
|
||||
let params = ["gain", "lift", "unset"].map(String::from);
|
||||
let stage = horizon_shader_api::mods::check(
|
||||
horizon_shader_api::StageKind::Post,
|
||||
"m.wgsl",
|
||||
wgsl,
|
||||
¶ms,
|
||||
)
|
||||
.unwrap();
|
||||
let frame = NativeFrame::new(&device);
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let mut post = NativePost::for_mods(&device, vec![stage], format, &frame).unwrap();
|
||||
post.set_params(&queue, 0, &[0.5, 0.1]);
|
||||
let pixel = draw(&device, &queue, &mut post);
|
||||
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
|
||||
// (0.8, 0.4, 0.2) * 0.5 + 0.1, alpha 1 - 0.
|
||||
assert!(
|
||||
near(pixel[0], 128) && near(pixel[1], 77) && near(pixel[2], 51) && pixel[3] == 255,
|
||||
"{pixel:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs `post` over a 4 × 4 world of colour (0.8, 0.4, 0.2) and depth 0.5; the top left
|
||||
/// pixel of what it draws.
|
||||
fn draw(device: &wgpu::Device, queue: &wgpu::Queue, post: &mut NativePost) -> [u8; 4] {
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage).create_view(&Default::default());
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
|
||||
let color = texture(device, format, usage).create_view(&Default::default());
|
||||
let depth = texture(device, wgpu::TextureFormat::Depth32Float, usage)
|
||||
.create_view(&Default::default());
|
||||
let screen = texture(
|
||||
&device,
|
||||
device,
|
||||
format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
@@ -520,7 +632,7 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
let view = screen.create_view(&Default::default());
|
||||
post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
|
||||
post.run(device, &mut encoder, (&color, &depth), &view, [4, 4], None);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
|
||||
@@ -545,10 +657,6 @@ mod tests {
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
|
||||
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
|
||||
assert!(
|
||||
near(pixel[0], 204) && near(pixel[1], 102) && near(pixel[2], 51) && near(pixel[3], 128),
|
||||
"{pixel:?}"
|
||||
);
|
||||
[pixel[0], pixel[1], pixel[2], pixel[3]]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "vignette"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Example Horizon mod: a post pass of its own, a vignette that reddens as the player's health falls"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../.." }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,12 @@
|
||||
// The vignette's post pass (docs/mods.md, "Shaders from a mod"): the picture darkens toward its
|
||||
// edges by `params.strength` (0 to 1), and the edges tighten and pulse red as
|
||||
// `params.low_health` (0 full health to 1 none) rises.
|
||||
fn post(input: PostInput) -> vec4<f32> {
|
||||
let aspect = frame.resolution.x / max(frame.resolution.y, 1.0);
|
||||
let from_centre = length((input.uv - 0.5) * vec2<f32>(aspect, 1.0));
|
||||
let inner = mix(0.55, 0.3, params.low_health);
|
||||
let edge = smoothstep(inner, inner + 0.6, from_centre) * params.strength;
|
||||
let pulse = 0.75 + 0.25 * sin(frame.time * 5.0);
|
||||
let red = vec3<f32>(0.55, 0.02, 0.0) * edge * params.low_health * pulse;
|
||||
return vec4<f32>(input.color.rgb * (1.0 - edge) + red, input.color.a);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
id = "vignette"
|
||||
name = "Vignette"
|
||||
version = "0.1.0"
|
||||
description = "Darkens the edges of the world's picture, and reddens them as your health falls. /vignette <0 to 1> sets how strong; 0 turns it off."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["game-read", "render"]
|
||||
experimental = ["render"]
|
||||
shaders = ["vignette"]
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Example Horizon mod (`docs/mods.md`, "Shaders from a mod"): a post pass of
|
||||
//! its own. `assets/vignette/shaders/vignette.wgsl` darkens the edges of the
|
||||
//! world's picture and reddens them as the player's health falls; the mod
|
||||
//! sets its two numbers every tick. `/vignette <0 to 1>` sets how strong it
|
||||
//! is, and 0 removes the pass, so it costs nothing while off.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, game,
|
||||
render::{self, ShaderKind},
|
||||
};
|
||||
|
||||
struct Vignette {
|
||||
/// The registered pass, while it is on.
|
||||
shader: Option<u32>,
|
||||
strength: f32,
|
||||
/// The values last sent, so an unchanged frame costs no call.
|
||||
sent: [f32; 2],
|
||||
}
|
||||
|
||||
/// Registers the pass: its numbers are `params.strength` and `params.low_health`.
|
||||
fn register() -> Result<u32, String> {
|
||||
let params = ["strength".to_owned(), "low_health".to_owned()];
|
||||
render::register_shader("vignette", ShaderKind::PostAfterTranslucent, ¶ms)
|
||||
}
|
||||
|
||||
impl Mod for Vignette {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
// A shader the game refuses stops only the pass: the mod runs on.
|
||||
let state = Vignette {
|
||||
shader: register().inspect_err(|e| horizon_mod_sdk::warn(e)).ok(),
|
||||
strength: 0.6,
|
||||
sent: [-1.0; 2],
|
||||
};
|
||||
Ok((state, Registration::new().command("vignette").on_tick()))
|
||||
}
|
||||
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
let Some(shader) = self.shader else {
|
||||
return;
|
||||
};
|
||||
let low = game::get_player().map_or(0.0, |p| (1.0 - p.health / 20.0).clamp(0.0, 1.0));
|
||||
let values = [self.strength, low];
|
||||
if values != self.sent {
|
||||
render::set_shader_params(shader, &values);
|
||||
self.sent = values;
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
let strength: f32 = args
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|s: &f32| (0.0..=1.0).contains(s))
|
||||
.ok_or("Give a strength from 0 to 1")?;
|
||||
self.strength = strength;
|
||||
match (self.shader, strength > 0.0) {
|
||||
(Some(shader), false) => {
|
||||
render::remove_shader(shader);
|
||||
self.shader = None;
|
||||
}
|
||||
(None, true) => {
|
||||
self.shader = Some(register()?);
|
||||
self.sent = [-1.0; 2];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Some(format!("Vignette at {strength}")))
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Vignette);
|
||||
@@ -0,0 +1,29 @@
|
||||
# The pass is registered at start, follows the player's health each tick and goes away at
|
||||
# strength 0. (A step's ticks run before its command.)
|
||||
|
||||
[[step]]
|
||||
ticks = 1
|
||||
expect.shaders = [{ name = "vignette", kind = "post-after-translucent", params = [0.6, 0.0] }]
|
||||
|
||||
[[step]]
|
||||
player = { health = 5.0 }
|
||||
ticks = 1
|
||||
expect.shaders = [{ name = "vignette", params = [0.6, 0.75] }]
|
||||
|
||||
[[step]]
|
||||
command = "vignette 0"
|
||||
expect.feedback = "Vignette at 0"
|
||||
expect.shaders = []
|
||||
|
||||
[[step]]
|
||||
command = "vignette 1"
|
||||
expect.feedback = "Vignette at 1"
|
||||
expect.shaders = [{ name = "vignette", params = [0.0, 0.0] }]
|
||||
|
||||
[[step]]
|
||||
ticks = 1
|
||||
expect.shaders = [{ name = "vignette", params = [1.0, 0.75] }]
|
||||
|
||||
[[step]]
|
||||
command = "vignette 2"
|
||||
expect.error = "Give a strength from 0 to 1"
|
||||
@@ -429,6 +429,43 @@ interface render {
|
||||
/// Distance setting, when on, wins. One far terrain shows at a time: the
|
||||
/// first loaded mod's.
|
||||
set-far-terrain: func(far-terrain: option<far-terrain>);
|
||||
/// Where a mod's shader runs: hook points the game owns, as shader packs'
|
||||
/// stages are (docs/api/shaders.md). A mod never builds a pipeline or
|
||||
/// binds resources of its own.
|
||||
enum shader-kind {
|
||||
/// A full-screen pass over the world's picture once the world is
|
||||
/// drawn (terrain, translucent blocks, entities, particles, weather),
|
||||
/// under the hand and the HUD: where shader packs' post passes run,
|
||||
/// before them. Its WGSL defines `fn post(input: PostInput) ->
|
||||
/// vec4<f32>` and reads the post stage's resources.
|
||||
post-after-translucent,
|
||||
/// A full-screen pass after opaque terrain, before translucent
|
||||
/// blocks. Not run yet: `register-shader` refuses it.
|
||||
post-after-terrain,
|
||||
/// A full-screen pass over the world and the hand, before the HUD.
|
||||
/// Not run yet.
|
||||
post-before-hud,
|
||||
/// The surface of the mod's own blocks, items and entities. Not run
|
||||
/// yet.
|
||||
material,
|
||||
/// The colour of the mod's own HUD shapes. Not run yet.
|
||||
hud-fill,
|
||||
}
|
||||
/// Registers the shader `name` of the mod's manifest
|
||||
/// (`assets/<id>/shaders/<name>.wgsl` in its `.hmod`) as a `kind` pass,
|
||||
/// reading the numbers `params` (up to 16, `[a-z][a-z0-9_]*`) as
|
||||
/// `params.<name>`, all 0 until `set-shader-params`. The game checks the
|
||||
/// WGSL first (naga: its signature, no resources but the stage's, a size
|
||||
/// and compile-time cap) and errs with the file, line and reason, also in
|
||||
/// the game log. Returns the shader's handle. At most 8 per mod; a pass
|
||||
/// that keeps going over its GPU budget (2 ms a frame) is removed, and
|
||||
/// the player is told.
|
||||
register-shader: func(name: string, kind: shader-kind, params: list<string>) -> result<u32, string>;
|
||||
/// The shader's `params`, in the order `register-shader` named them
|
||||
/// (extra values are dropped, missing ones are 0), from the next frame on.
|
||||
set-shader-params: func(shader: u32, values: list<f32>);
|
||||
/// Stops drawing the shader; its handle is no longer valid.
|
||||
remove-shader: func(shader: u32);
|
||||
}
|
||||
|
||||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||||
|
||||
@@ -22,7 +22,8 @@ wasmparser = { version = "0.254", optional = true }
|
||||
# The registry index's checks (feature `index`): SHA-256, and minisign signatures (no dependencies of its own).
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
minisign-verify = { version = "0.3", optional = true }
|
||||
# A shader pack's check (kind `shader`): the one the game runs before it loads a pack.
|
||||
# A shader pack's check (kind `shader`): the one the game runs before it loads a pack; and the
|
||||
# check of a mod's own shaders (`render.register-shader`).
|
||||
horizon-shader-api = { path = "../horizon-shader-api", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -34,7 +35,7 @@ wit-parser = "0.254"
|
||||
# Loads and runs mods (the game's `mods` feature); without it only manifests,
|
||||
# content and packaging. Off by default so builds that don't run mods (the
|
||||
# game's tests and probes) skip wasmtime and cranelift.
|
||||
runtime = ["dep:wasmtime", "dep:wasmparser"]
|
||||
runtime = ["dep:wasmtime", "dep:wasmparser", "dep:horizon-shader-api"]
|
||||
pack = ["dep:wit-component", "dep:wit-parser"]
|
||||
# The mod registry's index (docs/api/mod-index.md): the launcher, the registry server and its CI.
|
||||
index = ["dep:sha2", "dep:minisign-verify", "dep:horizon-shader-api", "dep:wasmparser"]
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
pub const LEVEL: u32 = 7;
|
||||
pub const LEVEL: u32 = 8;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
pub const FUNCTIONS: [(&str, u32); 46] = [
|
||||
pub const FUNCTIONS: [(&str, u32); 49] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -60,6 +60,9 @@ pub const FUNCTIONS: [(&str, u32); 46] = [
|
||||
("recipes.place", 6),
|
||||
("recipes.give", 6),
|
||||
("render.set-far-terrain", 7),
|
||||
("render.register-shader", 8),
|
||||
("render.set-shader-params", 8),
|
||||
("render.remove-shader", 8),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
|
||||
@@ -208,7 +208,13 @@ impl Content {
|
||||
};
|
||||
let mut files = Vec::with_capacity(sized.len());
|
||||
let mut total = 0u64;
|
||||
for (name, size) in sized {
|
||||
// A mod's shaders are its code's (capability `render`, `Package::shaders`), not content.
|
||||
let shaders: Vec<String> = manifest
|
||||
.shaders
|
||||
.iter()
|
||||
.map(|n| manifest.shader_path(n))
|
||||
.collect();
|
||||
for (name, size) in sized.into_iter().filter(|(n, _)| !shaders.contains(n)) {
|
||||
check_path(&name)?;
|
||||
if size > MAX_FILE {
|
||||
return Err(format!("{name} is over {} MiB", MAX_FILE >> 20));
|
||||
|
||||
@@ -553,7 +553,10 @@ impl Facts {
|
||||
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
|
||||
match kind {
|
||||
Kind::Mod => {
|
||||
let Package { manifest: m, wasm } = Package::read(path)?;
|
||||
// Its shaders are checked when its code registers them (the trial run).
|
||||
let Package {
|
||||
manifest: m, wasm, ..
|
||||
} = Package::read(path)?;
|
||||
crate::Content::read(path, &m)?;
|
||||
let api_level = match wasm {
|
||||
Some(wasm) => {
|
||||
|
||||
@@ -28,6 +28,10 @@ const RESERVED_NAMESPACES: [&str; 3] = ["minecraft", "c", "horizon"];
|
||||
pub const STORAGE_DIR: &str = ".horizon-mod-data";
|
||||
/// The biggest `mod.wasm` a mod may ship.
|
||||
const MAX_WASM: u64 = 32 << 20;
|
||||
/// Shaders a mod may declare, and the bytes of each
|
||||
/// (`horizon_shader_api::mods::MAX_SOURCE`).
|
||||
const MAX_SHADERS: usize = 16;
|
||||
const MAX_SHADER: u64 = 64 << 10;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -58,6 +62,10 @@ pub struct Manifest {
|
||||
/// accepted here, so the manifest keeps loading once it is promoted.
|
||||
#[serde(default)]
|
||||
pub experimental: Vec<Capability>,
|
||||
/// The WGSL shaders the mod ships, by name: `assets/<id>/shaders/<name>.wgsl`
|
||||
/// in its `.hmod`, registered from its code with capability `render`.
|
||||
#[serde(default)]
|
||||
pub shaders: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
@@ -265,6 +273,31 @@ impl Manifest {
|
||||
add experimental = [\"{c}\"] to use it"
|
||||
));
|
||||
}
|
||||
if !m.shaders.is_empty() && !m.capabilities.contains(&Capability::Render) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: shaders are drawn with capability render"
|
||||
));
|
||||
}
|
||||
if m.shaders.len() > MAX_SHADERS {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: {} shaders; a mod may ship {MAX_SHADERS}",
|
||||
m.shaders.len()
|
||||
));
|
||||
}
|
||||
for (i, name) in m.shaders.iter().enumerate() {
|
||||
let ok = (1..=64).contains(&name.len())
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
|
||||
if !ok {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: shader {name:?} is not [a-z0-9_]{{1,64}}"
|
||||
));
|
||||
}
|
||||
if m.shaders[..i].contains(name) {
|
||||
return Err(format!("{MANIFEST}: shader {name} is listed twice"));
|
||||
}
|
||||
}
|
||||
if !m.namespaces.is_empty() && !m.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: namespaces are for content, which needs capability registries"
|
||||
@@ -296,6 +329,10 @@ impl Manifest {
|
||||
pub fn owned_namespaces(&self) -> impl Iterator<Item = &str> {
|
||||
std::iter::once(self.id.as_str()).chain(self.namespaces.iter().map(String::as_str))
|
||||
}
|
||||
/// Where the shader `name` is in the mod's `.hmod` or folder.
|
||||
pub fn shader_path(&self, name: &str) -> String {
|
||||
format!("assets/{}/shaders/{name}.wgsl", self.id)
|
||||
}
|
||||
/// Checks that the component's imports need only declared capabilities.
|
||||
pub fn check_imports<'a>(
|
||||
&self,
|
||||
@@ -312,11 +349,13 @@ impl Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod as found on disk: its manifest and component bytes.
|
||||
/// A mod as found on disk: its manifest, component bytes and shaders.
|
||||
pub struct Package {
|
||||
pub manifest: Manifest,
|
||||
/// None for a content-only mod (capability `registries`, no code).
|
||||
pub wasm: Option<Vec<u8>>,
|
||||
/// The WGSL of each shader the manifest declares, by name.
|
||||
pub shaders: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
impl Package {
|
||||
/// A `.hmod` (a zip holding `horizon-mod.toml`, `mod.wasm` and, for
|
||||
@@ -353,12 +392,17 @@ impl Package {
|
||||
return Err(format!("{WASM} is over {} MiB", MAX_WASM >> 20));
|
||||
}
|
||||
let manifest = Manifest::parse(&toml)?;
|
||||
let shaders = read_shaders(path, &manifest)?;
|
||||
if wasm.is_none() && !manifest.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"{WASM}: missing (only a content mod, with capability registries, may have no code)"
|
||||
));
|
||||
}
|
||||
Ok(Package { manifest, wasm })
|
||||
Ok(Package {
|
||||
manifest,
|
||||
wasm,
|
||||
shaders,
|
||||
})
|
||||
}
|
||||
/// The `.hmod` bytes for `manifest_text` and a component.
|
||||
pub fn write(manifest_text: &str, wasm: &[u8]) -> Result<Vec<u8>, String> {
|
||||
@@ -392,6 +436,46 @@ impl Package {
|
||||
}
|
||||
}
|
||||
|
||||
/// The WGSL of the shaders `m` declares, from the mod at `path` (a `.hmod` or a folder).
|
||||
fn read_shaders(
|
||||
path: &Path,
|
||||
m: &Manifest,
|
||||
) -> Result<std::collections::BTreeMap<String, String>, String> {
|
||||
let mut shaders = std::collections::BTreeMap::new();
|
||||
if m.shaders.is_empty() {
|
||||
return Ok(shaders);
|
||||
}
|
||||
let mut zip = match path.is_dir() {
|
||||
true => None,
|
||||
false => {
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
Some(zip::ZipArchive::new(file).map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
for name in &m.shaders {
|
||||
let file = m.shader_path(name);
|
||||
let missing = || format!("{file}: missing (shader {name} of {MANIFEST})");
|
||||
let mut bytes = Vec::new();
|
||||
match &mut zip {
|
||||
None => {
|
||||
let f = std::fs::File::open(path.join(&file)).map_err(|_| missing())?;
|
||||
f.take(MAX_SHADER + 1).read_to_end(&mut bytes)
|
||||
}
|
||||
Some(zip) => {
|
||||
let f = zip.by_name(&file).map_err(|_| missing())?;
|
||||
f.take(MAX_SHADER + 1).read_to_end(&mut bytes)
|
||||
}
|
||||
}
|
||||
.map_err(|e| format!("{file}: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_SHADER {
|
||||
return Err(format!("{file} is over {} KiB", MAX_SHADER >> 10));
|
||||
}
|
||||
let text = String::from_utf8(bytes).map_err(|_| format!("{file}: not UTF-8"))?;
|
||||
shaders.insert(name.clone(), text);
|
||||
}
|
||||
Ok(shaders)
|
||||
}
|
||||
|
||||
/// The `.hmod` files and mod folders in `dir`, by name.
|
||||
pub fn find(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(read) = std::fs::read_dir(dir) else {
|
||||
@@ -530,6 +614,41 @@ capabilities = ["game-read", "hud"]
|
||||
assert!(Manifest::parse(&code).unwrap_err().contains("registries"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaders_ship_in_the_package() {
|
||||
let manifest = "id = \"glow\"\nname = \"Glow\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
capabilities = [\"render\"]\nexperimental = [\"render\"]\nshaders = [\"edge\"]\n";
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mods-shaders-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("glow.hmod");
|
||||
let wgsl = b"fn post(input: PostInput) -> vec4<f32> { return input.color; }\n";
|
||||
let files = [("assets/glow/shaders/edge.wgsl", &wgsl[..])];
|
||||
std::fs::write(
|
||||
&path,
|
||||
Package::write_files(manifest, Some(b"\0asm"), &files).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = Package::read(&path).unwrap();
|
||||
assert_eq!(p.shaders["edge"].as_bytes(), wgsl);
|
||||
// Shaders aren't content: no capability registries needed.
|
||||
assert!(crate::Content::read(&path, &p.manifest).unwrap().is_empty());
|
||||
std::fs::write(&path, Package::write(manifest, b"\0asm").unwrap()).unwrap();
|
||||
let missing = Package::read(&path).err().unwrap();
|
||||
assert!(
|
||||
missing.contains("assets/glow/shaders/edge.wgsl: missing"),
|
||||
"{missing}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let no_render = manifest.replace("[\"render\"]", "[]");
|
||||
assert!(
|
||||
Manifest::parse(&no_render)
|
||||
.unwrap_err()
|
||||
.contains("capability render")
|
||||
);
|
||||
let bad = manifest.replace("[\"edge\"]", "[\"Edge\"]");
|
||||
assert!(Manifest::parse(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmod_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mods-{}", std::process::id()));
|
||||
|
||||
@@ -79,8 +79,8 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I
|
||||
/// What capability `render` asks the game to draw (WIT `interface render`).
|
||||
pub use render_bindings::horizon::mods::render::{
|
||||
Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
|
||||
Minimap as ModMinimap, Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape,
|
||||
TextShape, Zoom as ModZoom,
|
||||
Minimap as ModMinimap, Point as RenderPoint, QuadShape, Rect as RenderRect, ShaderKind, Shape,
|
||||
SpriteShape, TextShape, Zoom as ModZoom,
|
||||
};
|
||||
use render_bindings::{RenderHost, horizon::mods::render};
|
||||
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
|
||||
@@ -121,18 +121,98 @@ const QUIET_PER_CALL: usize = 256;
|
||||
pub const RAYCAST_MAX: f32 = 1024.0;
|
||||
const HUD_LINES: usize = 16;
|
||||
const LINE_CHARS: usize = 256;
|
||||
/// Total bytes a mod's `storage` may hold on disk, across all its keys.
|
||||
const STORAGE_QUOTA: u64 = 4 << 20;
|
||||
/// Distinct keys a mod's `storage` may hold: `STORAGE_QUOTA` alone doesn't
|
||||
/// bound a flood of zero- or near-zero-byte files (each still a file name,
|
||||
/// a directory entry and an inode).
|
||||
const STORAGE_MAX_KEYS: usize = 64;
|
||||
/// What capability `storage` may hold for one mod: its saved state, not its
|
||||
/// resources (those are the `.hmod`'s `assets/` and `data/`). Server owners
|
||||
/// change it in `server.toml` (`[mods]`), which sets [`StorageQuota::ENV`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct StorageQuota {
|
||||
/// Bytes on disk, across all the mod's keys.
|
||||
pub bytes: u64,
|
||||
/// Distinct keys: `bytes` alone doesn't bound a flood of zero- or
|
||||
/// near-zero-byte files (each still a file name, a directory entry and
|
||||
/// an inode).
|
||||
pub files: usize,
|
||||
}
|
||||
impl Default for StorageQuota {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bytes: 16 << 20,
|
||||
files: 256,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl StorageQuota {
|
||||
/// The variable a game reads its quota from: `<MiB>,<files>`.
|
||||
pub const ENV: &str = "HORIZON_MOD_STORAGE";
|
||||
/// The most an owner may set: 1 GiB and 65,536 keys per mod.
|
||||
const MAX_MIB: u64 = 1024;
|
||||
const MAX_FILES: usize = 65_536;
|
||||
|
||||
/// `<MiB>,<files>`, as [`StorageQuota::ENV`] holds it.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let bad = || format!("{text:?} is not <MiB>,<files>, like 16,256");
|
||||
let (mib, files) = text.split_once(',').ok_or_else(bad)?;
|
||||
let mib: u64 = mib.trim().parse().map_err(|_| bad())?;
|
||||
let files: usize = files.trim().parse().map_err(|_| bad())?;
|
||||
if !(1..=Self::MAX_MIB).contains(&mib) || !(1..=Self::MAX_FILES).contains(&files) {
|
||||
return Err(format!(
|
||||
"{text:?}: 1 to {} MiB and 1 to {} files",
|
||||
Self::MAX_MIB,
|
||||
Self::MAX_FILES
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
bytes: mib << 20,
|
||||
files,
|
||||
})
|
||||
}
|
||||
/// The quota [`StorageQuota::ENV`] sets, else the default; a malformed
|
||||
/// value is logged and the default kept.
|
||||
pub fn from_env() -> Self {
|
||||
match std::env::var(Self::ENV) {
|
||||
Ok(text) => Self::parse(&text).unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: {}: {e}; mods keep the default storage", Self::ENV);
|
||||
Self::default()
|
||||
}),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Bytes `storage::set` may write across one hook call: fuel bounds a
|
||||
/// mod's CPU time per call, not the time a synchronous disk write takes, so
|
||||
/// a cheap-in-fuel loop replacing one key with large payloads needs its own
|
||||
/// cap, separate from the on-disk quota it would otherwise stay under.
|
||||
const STORAGE_WRITE_BUDGET: u64 = 1 << 20;
|
||||
|
||||
/// Shaders one mod may have registered at once.
|
||||
const SHADERS_PER_MOD: usize = 8;
|
||||
|
||||
/// A shader a mod registered (`render.register-shader`), checked and ready
|
||||
/// for the renderer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModShader {
|
||||
pub handle: u32,
|
||||
/// Its name in the manifest's `shaders`.
|
||||
pub name: String,
|
||||
pub kind: ShaderKind,
|
||||
/// The names of its `params`, in order.
|
||||
pub params: Vec<String>,
|
||||
/// Their values (`render.set-shader-params`), one per name.
|
||||
pub values: Vec<f32>,
|
||||
pub stage: Arc<horizon_shader_api::check::CheckedStage>,
|
||||
}
|
||||
|
||||
/// The kind's name in the WIT contract.
|
||||
pub fn shader_kind_name(kind: ShaderKind) -> &'static str {
|
||||
match kind {
|
||||
ShaderKind::PostAfterTranslucent => "post-after-translucent",
|
||||
ShaderKind::PostAfterTerrain => "post-after-terrain",
|
||||
ShaderKind::PostBeforeHud => "post-before-hud",
|
||||
ShaderKind::Material => "material",
|
||||
ShaderKind::HudFill => "hud-fill",
|
||||
}
|
||||
}
|
||||
|
||||
/// What mods may read of the game, copied once per tick by the game.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Snapshot {
|
||||
@@ -210,6 +290,12 @@ pub enum Output {
|
||||
QuietCommands { mod_id: String, lines: Vec<String> },
|
||||
/// The mod was turned off: it trapped, overran or failed to start.
|
||||
Stopped { name: String, reason: String },
|
||||
/// The game removed one of the mod's shaders (`Mods::drop_shader`).
|
||||
ShaderStopped {
|
||||
name: String,
|
||||
shader: String,
|
||||
reason: String,
|
||||
},
|
||||
/// `players.tell`: a line after the mod's name, to the player with this id.
|
||||
Tell {
|
||||
name: String,
|
||||
@@ -416,10 +502,16 @@ struct State {
|
||||
minimap: Option<ModMinimap>,
|
||||
/// Capability `render`'s far terrain, when the mod draws one.
|
||||
far_terrain: Option<ModFarTerrain>,
|
||||
/// The WGSL of the shaders its manifest declares, by name.
|
||||
shader_sources: std::collections::BTreeMap<String, String>,
|
||||
/// Its registered shaders, in the order it registered them.
|
||||
shaders: Vec<ModShader>,
|
||||
next_shader: u32,
|
||||
input: InputState,
|
||||
browser: BrowserState,
|
||||
/// Capability `storage`'s folder; made on first `set`.
|
||||
data_dir: PathBuf,
|
||||
quota: StorageQuota,
|
||||
/// Bytes `storage::set` may still write before this hook call ends
|
||||
/// (reset per call in [`Mods::call`]): fuel bounds a mod's CPU time but
|
||||
/// not disk I/O time, so a loop of small-fuel, large-payload `set`s
|
||||
@@ -441,6 +533,8 @@ pub struct Mods {
|
||||
/// Moves on whenever a mod starts or stops: the game resends the server
|
||||
/// mods' commands to players when it changes.
|
||||
generation: u64,
|
||||
/// What each mod's `storage` may hold.
|
||||
quota: StorageQuota,
|
||||
}
|
||||
|
||||
impl Mods {
|
||||
@@ -479,17 +573,28 @@ impl Mods {
|
||||
enabled: impl Fn(&str) -> bool,
|
||||
reserved: impl Fn(&str) -> bool,
|
||||
) -> Mods {
|
||||
Mods::load_for(Runs::Both, paths, enabled, reserved)
|
||||
Mods::load_for(
|
||||
Runs::Both,
|
||||
StorageQuota::default(),
|
||||
paths,
|
||||
enabled,
|
||||
reserved,
|
||||
)
|
||||
}
|
||||
/// [`Mods::load`], starting only the mods `runs` says this game runs; the
|
||||
/// others that are on are [`Status::Elsewhere`].
|
||||
/// [`Mods::load`], starting only the mods `runs` says this game runs (the
|
||||
/// others that are on are [`Status::Elsewhere`]), each with `quota` of
|
||||
/// storage.
|
||||
pub fn load_for(
|
||||
runs: Runs,
|
||||
quota: StorageQuota,
|
||||
paths: &[PathBuf],
|
||||
enabled: impl Fn(&str) -> bool,
|
||||
reserved: impl Fn(&str) -> bool,
|
||||
) -> Mods {
|
||||
let mut mods = Mods::default();
|
||||
let mut mods = Mods {
|
||||
quota,
|
||||
..Mods::default()
|
||||
};
|
||||
for path in paths {
|
||||
let modified = modified(path);
|
||||
let mut entry = Entry {
|
||||
@@ -587,6 +692,7 @@ impl Mods {
|
||||
.collect();
|
||||
let snapshot = self.snapshot.clone();
|
||||
let world = self.world.clone();
|
||||
let quota = self.quota;
|
||||
let (engine, linker) = self.engine()?;
|
||||
let component =
|
||||
Component::new(engine, wasm).map_err(|e| format!("not a component: {e}"))?;
|
||||
@@ -619,9 +725,13 @@ impl Mods {
|
||||
zoom: None,
|
||||
minimap: None,
|
||||
far_terrain: None,
|
||||
shader_sources: package.shaders.clone(),
|
||||
shaders: Vec::new(),
|
||||
next_shader: 0,
|
||||
input: InputState::default(),
|
||||
browser: BrowserState::default(),
|
||||
data_dir,
|
||||
quota,
|
||||
storage_budget: STORAGE_WRITE_BUDGET,
|
||||
limits: StoreLimitsBuilder::new()
|
||||
.memory_size(MEMORY)
|
||||
@@ -1254,6 +1364,34 @@ impl Mods {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// The running mods' shaders, in load and registration order: the mod's
|
||||
/// index in [`Mods::entries`], its id and the shader.
|
||||
pub fn shaders(&self) -> impl Iterator<Item = (usize, &str, &ModShader)> {
|
||||
self.entries.iter().enumerate().flat_map(|(i, e)| {
|
||||
let id = e.manifest.as_ref().map_or("", |m| m.id.as_str());
|
||||
e.run
|
||||
.iter()
|
||||
.flat_map(move |r| r.store.data().shaders.iter().map(move |s| (i, id, s)))
|
||||
})
|
||||
}
|
||||
/// Removes the shader `handle` of mod `owner` (it kept going over its GPU
|
||||
/// budget, or the GPU refused it), telling the player why; the mod runs on.
|
||||
pub fn drop_shader(&mut self, owner: usize, handle: u32, reason: &str) {
|
||||
let Some(run) = self.entries.get_mut(owner).and_then(|e| e.run.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let data = run.store.data_mut();
|
||||
let Some(i) = data.shaders.iter().position(|s| s.handle == handle) else {
|
||||
return;
|
||||
};
|
||||
let shader = data.shaders.remove(i);
|
||||
log::warn!("[mod {}] shader {} stopped: {reason}", data.id, shader.name);
|
||||
self.out.push(Output::ShaderStopped {
|
||||
name: data.name.clone(),
|
||||
shader: shader.name,
|
||||
reason: reason.to_owned(),
|
||||
});
|
||||
}
|
||||
/// What mods asked to show since the last call.
|
||||
pub fn drain(&mut self) -> Vec<Output> {
|
||||
std::mem::take(&mut self.out)
|
||||
@@ -1773,7 +1911,80 @@ fn clip_shapes(shapes: Vec<Shape>) -> Vec<Shape> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn register_shader_checked(
|
||||
&mut self,
|
||||
name: &str,
|
||||
kind: ShaderKind,
|
||||
params: Vec<String>,
|
||||
) -> Result<u32, String> {
|
||||
let source = self.shader_sources.get(name).ok_or_else(|| {
|
||||
format!(
|
||||
"no shader {name} in {}'s shaders",
|
||||
crate::manifest::MANIFEST
|
||||
)
|
||||
})?;
|
||||
if kind != ShaderKind::PostAfterTranslucent {
|
||||
return Err(format!(
|
||||
"this Horizon runs post-after-translucent shaders, not {} yet",
|
||||
shader_kind_name(kind)
|
||||
));
|
||||
}
|
||||
if self.shaders.iter().any(|s| s.name == name) {
|
||||
return Err(format!("shader {name} is already registered"));
|
||||
}
|
||||
if self.shaders.len() >= SHADERS_PER_MOD {
|
||||
return Err(format!("a mod may register {SHADERS_PER_MOD} shaders"));
|
||||
}
|
||||
let file = format!("assets/{}/shaders/{name}.wgsl", self.id);
|
||||
let stage = horizon_shader_api::mods::check(
|
||||
horizon_shader_api::StageKind::Post,
|
||||
&file,
|
||||
source,
|
||||
¶ms,
|
||||
)
|
||||
.map_err(|problems| {
|
||||
let p: Vec<String> = problems.iter().map(ToString::to_string).collect();
|
||||
p.join("; ")
|
||||
})?;
|
||||
self.next_shader += 1;
|
||||
self.shaders.push(ModShader {
|
||||
handle: self.next_shader,
|
||||
name: name.to_owned(),
|
||||
kind,
|
||||
values: vec![0.0; params.len()],
|
||||
params,
|
||||
stage: Arc::new(stage),
|
||||
});
|
||||
Ok(self.next_shader)
|
||||
}
|
||||
}
|
||||
|
||||
impl render::Host for State {
|
||||
fn register_shader(
|
||||
&mut self,
|
||||
name: String,
|
||||
kind: ShaderKind,
|
||||
params: Vec<String>,
|
||||
) -> Result<u32, String> {
|
||||
let result = self.register_shader_checked(&name, kind, params);
|
||||
// In the mod's log as well: a shader refused stops only itself.
|
||||
if let Err(e) = &result {
|
||||
log::warn!("[mod {}] shader {name} refused: {e}", self.id);
|
||||
}
|
||||
result
|
||||
}
|
||||
fn set_shader_params(&mut self, shader: u32, values: Vec<f32>) {
|
||||
if let Some(s) = self.shaders.iter_mut().find(|s| s.handle == shader) {
|
||||
let mut values = values.into_iter();
|
||||
for v in &mut s.values {
|
||||
*v = values.next().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn remove_shader(&mut self, shader: u32) {
|
||||
self.shaders.retain(|s| s.handle != shader);
|
||||
}
|
||||
fn set_hud(&mut self, shapes: Vec<Shape>) {
|
||||
self.shapes = clip_shapes(shapes);
|
||||
}
|
||||
@@ -1934,18 +2145,19 @@ impl storage::Host for State {
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy() != key)
|
||||
.collect();
|
||||
if existing.len() >= STORAGE_MAX_KEYS {
|
||||
return Err(format!("would hold over {STORAGE_MAX_KEYS} keys"));
|
||||
let quota = self.quota;
|
||||
if existing.len() >= quota.files {
|
||||
return Err(format!("would hold over {} keys", quota.files));
|
||||
}
|
||||
let used: u64 = existing
|
||||
.iter()
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.map(|m| m.len())
|
||||
.sum();
|
||||
if used + len > STORAGE_QUOTA {
|
||||
if used + len > quota.bytes {
|
||||
return Err(format!(
|
||||
"would use over {} MiB of storage",
|
||||
STORAGE_QUOTA >> 20
|
||||
quota.bytes >> 20
|
||||
));
|
||||
}
|
||||
std::fs::create_dir_all(&self.data_dir).map_err(|e| e.to_string())?;
|
||||
@@ -1969,3 +2181,18 @@ fn storage_key_ok(key: &str) -> bool {
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.-".contains(&b));
|
||||
chars_ok && key != "." && key != ".."
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::StorageQuota;
|
||||
|
||||
#[test]
|
||||
fn storage_quota_reads_mib_and_files() {
|
||||
let quota = StorageQuota::parse("64, 1024").unwrap();
|
||||
assert_eq!((quota.bytes, quota.files), (64 << 20, 1024));
|
||||
assert_eq!(StorageQuota::default().bytes, 16 << 20);
|
||||
for bad in ["16", "16,", "x,256", "0,256", "16,0", "2048,256"] {
|
||||
assert!(StorageQuota::parse(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,18 @@ pub struct Expect {
|
||||
/// The mod is turned off during this step, for a reason holding this text.
|
||||
/// Without it, a mod that stops fails the test.
|
||||
stopped: Option<String>,
|
||||
/// The shaders it has registered after the step, in order (capability
|
||||
/// `render`): each passed the game's check.
|
||||
shaders: Option<Vec<ShaderExpect>>,
|
||||
}
|
||||
|
||||
/// A registered shader: its name, and its kind and params' values when given.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ShaderExpect {
|
||||
name: String,
|
||||
kind: Option<String>,
|
||||
params: Option<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl Script {
|
||||
@@ -701,6 +713,26 @@ impl Step {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(want) = &e.shaders {
|
||||
let got: Vec<_> = game.mods.shaders().map(|(_, _, s)| s).collect();
|
||||
let names = |n: Vec<&str>| n.join(", ");
|
||||
if want.len() != got.len() || want.iter().zip(&got).any(|(w, g)| w.name != g.name) {
|
||||
return Err(format!(
|
||||
"shaders\n expected: [{}]\n got: [{}]",
|
||||
names(want.iter().map(|w| w.name.as_str()).collect()),
|
||||
names(got.iter().map(|g| g.name.as_str()).collect())
|
||||
));
|
||||
}
|
||||
for (w, g) in want.iter().zip(&got) {
|
||||
if let Some(kind) = &w.kind {
|
||||
let what = format!("shader {}'s kind", w.name);
|
||||
same(&what, &kind.as_str(), &crate::shader_kind_name(g.kind))?;
|
||||
}
|
||||
if let Some(params) = &w.params {
|
||||
same(&format!("shader {}'s params", w.name), params, &g.values)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(want) = &e.corner {
|
||||
let got = game.panel().map(|p| corner_name(p.corner).to_string());
|
||||
same("corner", &Some(want.clone()), &got)?;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
|
||||
use horizon_mods::{
|
||||
BlockPos, Clock, Corner, GameMode, HeldItem, Hit, Mods, Output, Package, Player, Runs,
|
||||
ServerPlayer, Shape, Snapshot, Status,
|
||||
ServerPlayer, Shape, Snapshot, Status, StorageQuota,
|
||||
manifest::componentize,
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
@@ -30,6 +30,11 @@ capabilities = ["game-read", "chat", "command", "ui", "render", "input", "recipe
|
||||
experimental = ["ui", "render", "input", "recipes"]
|
||||
"#;
|
||||
|
||||
/// The vignette example's shader, in its folder and in its `.hmod`.
|
||||
const VIGNETTE_WGSL: &str =
|
||||
"crates/horizon-mod-sdk/examples/vignette/assets/vignette/shaders/vignette.wgsl";
|
||||
const VIGNETTE_SHADER: &str = "assets/vignette/shaders/vignette.wgsl";
|
||||
|
||||
/// Builds the mods once and writes their `.hmod` files; returns the folder.
|
||||
fn mods_dir() -> &'static Path {
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
@@ -49,6 +54,8 @@ fn mods_dir() -> &'static Path {
|
||||
"-p",
|
||||
"beacons",
|
||||
"-p",
|
||||
"vignette",
|
||||
"-p",
|
||||
"horizon-mods-test-guest",
|
||||
"-p",
|
||||
"horizon-mods-test-ui-guest",
|
||||
@@ -81,6 +88,16 @@ fn mods_dir() -> &'static Path {
|
||||
let hmod = Package::write(&manifest, &componentize(&core).unwrap()).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.hmod")), hmod).unwrap();
|
||||
}
|
||||
// The vignette ships its shader.
|
||||
let core = std::fs::read(built.join("vignette.wasm")).unwrap();
|
||||
let wgsl = std::fs::read(root.join(VIGNETTE_WGSL)).unwrap();
|
||||
let hmod = Package::write_files(
|
||||
&example("vignette"),
|
||||
Some(&componentize(&core).unwrap()),
|
||||
&[(VIGNETTE_SHADER, &wgsl)],
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("vignette.hmod"), hmod).unwrap();
|
||||
dir
|
||||
})
|
||||
}
|
||||
@@ -256,7 +273,9 @@ experimental = ["ui", "render", "input", "recipes"]
|
||||
};
|
||||
assert_eq!(level("coords_hud.hmod"), 0);
|
||||
// The UI guest draws far terrain (`render.set-far-terrain`).
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL);
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 7);
|
||||
// The vignette registers a shader.
|
||||
assert_eq!(level("vignette.hmod"), LEVEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -365,7 +384,7 @@ fn storage_write_budget_limits_one_call() {
|
||||
let path = dir.join("guest.hmod");
|
||||
std::fs::copy(mods_dir().join("horizon_mods_test_guest.hmod"), &path).unwrap();
|
||||
let mut mods = Mods::load(std::slice::from_ref(&path), |_| true, |_| false);
|
||||
// Under the 4 MiB on-disk quota, but over the 1 MiB a single call may
|
||||
// Under the 16 MiB on-disk quota, but over the 1 MiB a single call may
|
||||
// write: a loop replacing one key can never bypass the quota by
|
||||
// writing a lot in one call, only across many.
|
||||
let over_budget = "a".repeat((1 << 20) + 1024);
|
||||
@@ -383,7 +402,9 @@ fn storage_quota_is_enforced() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("guest.hmod");
|
||||
std::fs::copy(mods_dir().join("horizon_mods_test_guest.hmod"), &path).unwrap();
|
||||
let mut mods = Mods::load(std::slice::from_ref(&path), |_| true, |_| false);
|
||||
// A server owner's quota (`server.toml`'s `[mods]`): 4 MiB.
|
||||
let quota = StorageQuota::parse("4,256").unwrap();
|
||||
let mut mods = Mods::load_for(Runs::Both, quota, &[path], |_| true, |_| false);
|
||||
// Each call is under the per-call write budget; distinct keys so the
|
||||
// quota (not the fixed "note" key being overwritten) is what's tested.
|
||||
let chunk = "a".repeat(900 << 10);
|
||||
@@ -412,7 +433,8 @@ fn storage_key_count_is_bounded() {
|
||||
let path = dir.join("guest.hmod");
|
||||
std::fs::copy(mods_dir().join("horizon_mods_test_guest.hmod"), &path).unwrap();
|
||||
let mut mods = Mods::load(std::slice::from_ref(&path), |_| true, |_| false);
|
||||
for i in 0..64 {
|
||||
// 256 keys by default (`StorageQuota`).
|
||||
for i in 0..256 {
|
||||
assert!(mods.command(&format!("save_key k{i} x")));
|
||||
assert_eq!(
|
||||
mods.drain(),
|
||||
@@ -423,9 +445,9 @@ fn storage_key_count_is_bounded() {
|
||||
"key {i}"
|
||||
);
|
||||
}
|
||||
assert!(mods.command("save_key k64 x"));
|
||||
assert!(mods.command("save_key k256 x"));
|
||||
let refused = Output::Feedback {
|
||||
text: "would hold over 64 keys".into(),
|
||||
text: "would hold over 256 keys".into(),
|
||||
error: true,
|
||||
};
|
||||
assert_eq!(mods.drain(), [refused]);
|
||||
@@ -888,6 +910,7 @@ fn the_examples_test_scripts_pass() {
|
||||
("coords-hud", "coords_hud"),
|
||||
("welcome", "welcome"),
|
||||
("beacons", "beacons"),
|
||||
("vignette", "vignette"),
|
||||
];
|
||||
for (example, hmod) in examples_and_files {
|
||||
for entry in std::fs::read_dir(examples.join(example).join("tests")).unwrap() {
|
||||
@@ -900,7 +923,67 @@ fn the_examples_test_scripts_pass() {
|
||||
ran += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(ran, 5);
|
||||
assert_eq!(ran, 6);
|
||||
}
|
||||
|
||||
/// A shader the game's check refuses stops only that shader: the mod gets the
|
||||
/// error (file, line, reason) and runs on without it.
|
||||
#[test]
|
||||
fn a_refused_shader_stops_only_itself() {
|
||||
let dir = mods_dir().join("bad_shader");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("vignette.hmod");
|
||||
let good = Package::read(&mods_dir().join("vignette.hmod")).unwrap();
|
||||
let manifest = std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-mod-sdk/examples/vignette/horizon-mod.toml"),
|
||||
)
|
||||
.unwrap();
|
||||
let bad = good.shaders["vignette"].replace("params.low_health", "params.missing");
|
||||
let hmod = Package::write_files(
|
||||
&manifest,
|
||||
good.wasm.as_deref(),
|
||||
&[(VIGNETTE_SHADER, bad.as_bytes())],
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&path, hmod).unwrap();
|
||||
let mut mods = Mods::load(std::slice::from_ref(&path), |_| true, |_| false);
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
assert_eq!(mods.shaders().count(), 0);
|
||||
// Its command still runs; registering again meets the same check, whose
|
||||
// error says where the WGSL is wrong.
|
||||
assert!(mods.command("vignette 0.5"));
|
||||
let refused = "assets/vignette/shaders/vignette.wgsl:7:39: invalid field accessor `missing`";
|
||||
assert!(
|
||||
matches!(&mods.drain()[..], [Output::Feedback { text, error: true }] if text.starts_with(refused))
|
||||
);
|
||||
assert_eq!(mods.shaders().count(), 0);
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
}
|
||||
|
||||
/// The game removes a pass over its GPU budget: the mod is told nothing, the
|
||||
/// player is, and the mod runs on.
|
||||
#[test]
|
||||
fn a_dropped_shader_tells_the_player() {
|
||||
let mut mods = load("vignette.hmod");
|
||||
let (owner, _, shader) = mods
|
||||
.shaders()
|
||||
.next()
|
||||
.map(|(o, i, s)| (o, i.to_owned(), s.clone()))
|
||||
.unwrap();
|
||||
assert_eq!(shader.params, ["strength", "low_health"]);
|
||||
mods.drop_shader(owner, shader.handle, "it took 3.1 ms of GPU time a frame");
|
||||
assert_eq!(mods.shaders().count(), 0);
|
||||
assert_eq!(
|
||||
mods.drain(),
|
||||
[Output::ShaderStopped {
|
||||
name: "Vignette".into(),
|
||||
shader: "vignette".into(),
|
||||
reason: "it took 3.1 ms of GPU time a frame".into(),
|
||||
}]
|
||||
);
|
||||
assert!(mods.is_running());
|
||||
}
|
||||
|
||||
/// Capability `world` without a world lent (the registry's trial run):
|
||||
@@ -1008,11 +1091,23 @@ fn a_server_mod_runs_only_where_the_world_does() {
|
||||
let path = dir.join("welcome.hmod");
|
||||
std::fs::copy(mods_dir().join("welcome.hmod"), &path).unwrap();
|
||||
let paths = std::slice::from_ref(&path);
|
||||
let window = Mods::load_for(Runs::Client, paths, |_| true, |_| false);
|
||||
let window = Mods::load_for(
|
||||
Runs::Client,
|
||||
StorageQuota::default(),
|
||||
paths,
|
||||
|_| true,
|
||||
|_| false,
|
||||
);
|
||||
assert_eq!(window.entries()[0].status, Status::Elsewhere);
|
||||
assert!(!window.is_running());
|
||||
|
||||
let mut mods = Mods::load_for(Runs::Server, paths, |_| true, |c| c == "give");
|
||||
let mut mods = Mods::load_for(
|
||||
Runs::Server,
|
||||
StorageQuota::default(),
|
||||
paths,
|
||||
|_| true,
|
||||
|c| c == "give",
|
||||
);
|
||||
assert!(mods.runs_server());
|
||||
let commands: Vec<_> = mods.server_commands().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(commands, ["online", "mined"]);
|
||||
|
||||
@@ -55,6 +55,12 @@ keep = 1 # other releases kept by updates, besides the one i
|
||||
[panel]
|
||||
bind = "127.0.0.1:8130" # the web panel: this computer only, or a tailnet address (100.x); "" turns it off
|
||||
allow_public = false # true lets bind be any address; never put the panel on the open internet
|
||||
|
||||
# Horizon mods (docs/mods.md): what each server mod may save with capability storage. Its textures,
|
||||
# models and data ship in its .hmod, not here.
|
||||
[mods]
|
||||
storage_mib = 16 # megabytes each mod may keep, 1 to 1024
|
||||
storage_files = 256 # files (keys) each mod may keep, 1 to 65536
|
||||
"##;
|
||||
|
||||
/// Each typed setting: its section, its name, and the vanilla key it writes.
|
||||
@@ -92,6 +98,8 @@ pub struct Config {
|
||||
pub updates: Updates,
|
||||
#[serde(default)]
|
||||
pub panel: Panel,
|
||||
#[serde(default)]
|
||||
pub mods: Mods,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -128,6 +136,31 @@ pub struct Engine {
|
||||
pub restart_on_crash: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Mods {
|
||||
pub storage_mib: u64,
|
||||
pub storage_files: u64,
|
||||
}
|
||||
|
||||
impl Default for Mods {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_mib: 16,
|
||||
storage_files: 256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mods {
|
||||
/// What the game reads its mods' storage quota from (`HORIZON_MOD_STORAGE`, `<MiB>,<files>`).
|
||||
pub const ENV: &str = "HORIZON_MOD_STORAGE";
|
||||
|
||||
pub fn storage_env(&self) -> String {
|
||||
format!("{},{}", self.storage_mib, self.storage_files)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Panel {
|
||||
@@ -295,6 +328,13 @@ impl Config {
|
||||
));
|
||||
}
|
||||
crate::backup::interval(&self.backups.every)?;
|
||||
let m = &self.mods;
|
||||
if !(1..=1024).contains(&m.storage_mib) || !(1..=65_536).contains(&m.storage_files) {
|
||||
return Err(format!(
|
||||
"mods.storage_mib is {} and mods.storage_files {}: use 1 to 1024 megabytes and 1 to 65536 files",
|
||||
m.storage_mib, m.storage_files
|
||||
));
|
||||
}
|
||||
self.panel.address()?;
|
||||
if self.server.port == 0 {
|
||||
return Err("server.port must be a port number from 1 to 65535".into());
|
||||
|
||||
@@ -154,7 +154,12 @@ pub fn resources(config: &Config, dir: &Path, program: Option<&Path>) -> Result<
|
||||
|
||||
/// Starts the engine in the server folder, with its console and log piped to the supervisor and its status
|
||||
/// on the control endpoint.
|
||||
pub fn spawn(program: &Path, resources: &Path, dir: &Path) -> std::io::Result<Child> {
|
||||
pub fn spawn(
|
||||
program: &Path,
|
||||
resources: &Path,
|
||||
dir: &Path,
|
||||
mods: &crate::config::Mods,
|
||||
) -> std::io::Result<Child> {
|
||||
// The game runs in the server folder, so a folder given relative to ours (`--dir srv`) would put its
|
||||
// control endpoint in `srv/srv/.horizon`, which doesn't exist.
|
||||
let dir = std::path::absolute(dir)?;
|
||||
@@ -168,6 +173,8 @@ pub fn spawn(program: &Path, resources: &Path, dir: &Path) -> std::io::Result<Ch
|
||||
.args(["--dedicated", "--nogui"])
|
||||
.current_dir(&dir)
|
||||
.env("HORIZON_NO_AUDIO", "1")
|
||||
// An environment variable, not a flag, so a game from before it still starts.
|
||||
.env(crate::config::Mods::ENV, mods.storage_env())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
@@ -322,6 +329,7 @@ mod tests {
|
||||
&std::path::absolute(&game).unwrap(),
|
||||
&root.join("res"),
|
||||
&server,
|
||||
&Default::default(),
|
||||
) {
|
||||
Err(e) if e.raw_os_error() == Some(26) && tries < 50 => {
|
||||
tries += 1;
|
||||
|
||||
@@ -146,6 +146,8 @@ fn range(section: &str, field: &str) -> Option<std::ops::RangeInclusive<i64>> {
|
||||
("server", "view_distance" | "simulation_distance") => Some(2..=32),
|
||||
("server", "max_players") => Some(1..=1000),
|
||||
("backups", "keep") => Some(1..=1000),
|
||||
("mods", "storage_mib") => Some(1..=1024),
|
||||
("mods", "storage_files") => Some(1..=65_536),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ pub fn run(dir: &Path) -> Result<(), String> {
|
||||
),
|
||||
);
|
||||
shared.set_phase(Phase::Starting);
|
||||
let mut child = engine::spawn(&program, &resources, dir)
|
||||
let mut child = engine::spawn(&program, &resources, dir, &config.mods)
|
||||
.map_err(|e| format!("{}: {e}", program.display()))?;
|
||||
{
|
||||
let mut state = shared.state.lock().unwrap();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! HLSL with its options at their defaults, minimums and maximums.
|
||||
use crate::manifest::{OptionKind, PackOption};
|
||||
use crate::package::Pack;
|
||||
use crate::stages::{Composed, StageKind, compose};
|
||||
use crate::stages::{Composed, Source, StageKind, compose_source};
|
||||
use naga::valid::{Capabilities, ModuleInfo, ValidationFlags, Validator};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
@@ -52,7 +52,7 @@ fn problem(file: &str, message: impl Into<String>) -> Problem {
|
||||
}
|
||||
|
||||
/// One stage that passed, ready for the renderer.
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CheckedStage {
|
||||
pub kind: StageKind,
|
||||
pub file: String,
|
||||
@@ -123,19 +123,42 @@ pub fn check(pack: &Pack) -> Result<Report, Vec<Problem>> {
|
||||
|
||||
/// Builds and checks one stage of `pack` from its file.
|
||||
pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result<CheckedStage, Vec<Problem>> {
|
||||
let shared: Vec<(&str, &str)> = pack
|
||||
.manifest
|
||||
.shared
|
||||
.iter()
|
||||
.filter(|s| *s != file)
|
||||
.map(|s| (s.as_str(), pack.source(s).unwrap_or_default()))
|
||||
.collect();
|
||||
let source = Source {
|
||||
kind,
|
||||
prelude: kind.prelude(),
|
||||
shared: &shared,
|
||||
file,
|
||||
text: pack.source(file).unwrap_or_default(),
|
||||
};
|
||||
check_source(&source, &pack.manifest.options, MAX_EXPRESSIONS)
|
||||
}
|
||||
|
||||
/// Builds and checks the stage `source`, with the pack's `options` and at most
|
||||
/// `max_expressions` naga expressions.
|
||||
pub(crate) fn check_source(
|
||||
source: &Source,
|
||||
options: &[PackOption],
|
||||
max_expressions: usize,
|
||||
) -> Result<CheckedStage, Vec<Problem>> {
|
||||
let (kind, file) = (source.kind, source.file);
|
||||
// First without the entry points, so a missing or mistyped stage function
|
||||
// is reported as such rather than as a call that doesn't resolve.
|
||||
let mut own = compose(pack, kind, file);
|
||||
let entries = own.parts.pop().expect("entries are the last part");
|
||||
own.text.truncate(entries.1.start);
|
||||
let own = compose_source(source, false);
|
||||
let module = parse(&own)?;
|
||||
let mut problems = signature(&module, kind, file);
|
||||
problems.extend(api_rules(&module, kind, &own));
|
||||
problems.extend(options(&module, &pack.manifest.options, file));
|
||||
problems.extend(api_rules(&module, &source.prelude, &own));
|
||||
problems.extend(self::options(&module, options, file));
|
||||
if !problems.is_empty() {
|
||||
return Err(problems);
|
||||
}
|
||||
let composed = compose(pack, kind, file);
|
||||
let composed = compose_source(source, true);
|
||||
let module = parse(&composed)?;
|
||||
let info = Validator::new(ValidationFlags::all(), Capabilities::default())
|
||||
.validate(&module)
|
||||
@@ -151,10 +174,10 @@ pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result<CheckedSt
|
||||
})?;
|
||||
let (expressions, texture_reads) = cost(&module);
|
||||
let mut problems = Vec::new();
|
||||
if expressions > MAX_EXPRESSIONS {
|
||||
if expressions > max_expressions {
|
||||
problems.push(problem(
|
||||
file,
|
||||
format!("{expressions} expressions; a stage may hold {MAX_EXPRESSIONS}"),
|
||||
format!("{expressions} expressions; a stage may hold {max_expressions}"),
|
||||
));
|
||||
}
|
||||
if texture_reads > MAX_TEXTURE_READS {
|
||||
@@ -163,7 +186,7 @@ pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result<CheckedSt
|
||||
format!("{texture_reads} texture reads; a stage may write {MAX_TEXTURE_READS}"),
|
||||
));
|
||||
}
|
||||
problems.extend(translate(&module, &info, &pack.manifest.options, file));
|
||||
problems.extend(translate(&module, &info, options, file));
|
||||
if !problems.is_empty() {
|
||||
return Err(problems);
|
||||
}
|
||||
@@ -279,8 +302,8 @@ fn names(module: &naga::Module) -> HashSet<String> {
|
||||
|
||||
/// A pack's files declare no bindings, no entry points and no `horizon_`
|
||||
/// names: what the prelude doesn't define is the pack's.
|
||||
fn api_rules(module: &naga::Module, kind: StageKind, own: &Composed) -> Vec<Problem> {
|
||||
let prelude = naga::front::wgsl::parse_str(&kind.prelude()).expect("the prelude parses");
|
||||
fn api_rules(module: &naga::Module, prelude: &str, own: &Composed) -> Vec<Problem> {
|
||||
let prelude = naga::front::wgsl::parse_str(prelude).expect("the prelude parses");
|
||||
let ours = names(&prelude);
|
||||
let file = &own.parts[own.parts.len() - 1].0;
|
||||
let mut problems = Vec::new();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod check;
|
||||
pub mod inputs;
|
||||
pub mod manifest;
|
||||
pub mod mods;
|
||||
pub mod package;
|
||||
pub mod reference;
|
||||
pub mod stages;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Shaders a Horizon mod ships in its `.hmod` and registers from its code
|
||||
//! (docs/mods.md, "Shaders from a mod"): a post stage's WGSL, written as a
|
||||
//! pack's is, plus `params`, a uniform of the mod's own numbers that it sets
|
||||
//! every frame. A mod's shader is checked as a pack's stage is, with tighter
|
||||
//! limits, since a mod registers it while the game runs.
|
||||
use crate::check::{CheckedStage, Problem, check_source};
|
||||
use crate::stages::{GROUP, Source, StageKind};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Bytes of one mod shader's WGSL.
|
||||
pub const MAX_SOURCE: usize = 64 << 10;
|
||||
/// Numbers a mod shader may read from `params`.
|
||||
pub const MAX_PARAMS: usize = 16;
|
||||
/// naga expressions a mod shader may hold: a fifth of a pack stage's.
|
||||
pub const MAX_EXPRESSIONS: usize = 4_000;
|
||||
/// How long its check (parse, validation, translation for Vulkan, Metal and
|
||||
/// DirectX 12) may take.
|
||||
pub const COMPILE_BUDGET: Duration = Duration::from_millis(250);
|
||||
/// `params`' binding in group 3, after the post stage's resources.
|
||||
pub const PARAMS_BINDING: u32 = 6;
|
||||
/// `params`' size in bytes: [`MAX_PARAMS`] `f32`s, however many the mod names.
|
||||
pub const PARAMS_SIZE: u64 = MAX_PARAMS as u64 * 4;
|
||||
|
||||
fn problem(file: &str, message: String) -> Vec<Problem> {
|
||||
vec![Problem {
|
||||
file: file.to_owned(),
|
||||
line: 0,
|
||||
column: 0,
|
||||
message,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Checks the mod shader `file`, whose WGSL is `text`, as a stage of `kind`
|
||||
/// reading the numbers `params` (by name, in order) from `params`.
|
||||
pub fn check(
|
||||
kind: StageKind,
|
||||
file: &str,
|
||||
text: &str,
|
||||
params: &[String],
|
||||
) -> Result<CheckedStage, Vec<Problem>> {
|
||||
check_within(kind, file, text, params, COMPILE_BUDGET)
|
||||
}
|
||||
|
||||
fn check_within(
|
||||
kind: StageKind,
|
||||
file: &str,
|
||||
text: &str,
|
||||
params: &[String],
|
||||
budget: Duration,
|
||||
) -> Result<CheckedStage, Vec<Problem>> {
|
||||
let start = Instant::now();
|
||||
if kind != StageKind::Post {
|
||||
return Err(problem(
|
||||
file,
|
||||
format!("a mod's shader is a post stage, not {}", kind.name()),
|
||||
));
|
||||
}
|
||||
if text.len() > MAX_SOURCE {
|
||||
return Err(problem(file, format!("is over {} KiB", MAX_SOURCE >> 10)));
|
||||
}
|
||||
let params = params_struct(params).map_err(|m| problem(file, m))?;
|
||||
let source = Source {
|
||||
kind,
|
||||
prelude: format!(
|
||||
"{}{params}@group({GROUP}) @binding({PARAMS_BINDING}) var<uniform> params: Params;\n",
|
||||
kind.prelude()
|
||||
),
|
||||
shared: &[],
|
||||
file,
|
||||
text,
|
||||
};
|
||||
let stage = check_source(&source, &[], MAX_EXPRESSIONS)?;
|
||||
let took = start.elapsed();
|
||||
if took > budget {
|
||||
return Err(problem(
|
||||
file,
|
||||
format!(
|
||||
"took {} ms to check; a mod's shader may take {}",
|
||||
took.as_millis(),
|
||||
budget.as_millis()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(stage)
|
||||
}
|
||||
|
||||
/// `struct Params`: an `f32` per name, padded to [`MAX_PARAMS`].
|
||||
fn params_struct(names: &[String]) -> Result<String, String> {
|
||||
if names.len() > MAX_PARAMS {
|
||||
return Err(format!(
|
||||
"{} params; a mod's shader may have {MAX_PARAMS}",
|
||||
names.len()
|
||||
));
|
||||
}
|
||||
let mut out = String::from("struct Params {\n");
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let ok = (1..=32).contains(&name.len())
|
||||
&& name.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
|
||||
if !ok || name.starts_with("horizon_") {
|
||||
return Err(format!(
|
||||
"param {name:?} is not [a-z][a-z0-9_]{{0,31}} (and not horizon_...)"
|
||||
));
|
||||
}
|
||||
if names[..i].contains(name) {
|
||||
return Err(format!("param {name} is named twice"));
|
||||
}
|
||||
out += &format!(" {name}: f32,\n");
|
||||
}
|
||||
for i in names.len()..MAX_PARAMS {
|
||||
out += &format!(" horizon_pad{i}: f32,\n");
|
||||
}
|
||||
out += "}\n";
|
||||
naga::front::wgsl::parse_str(&out)
|
||||
.map_err(|_| format!("a param's name is a WGSL keyword: {}", names.join(", ")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VIGNETTE: &str = "fn post(input: PostInput) -> vec4<f32> {
|
||||
let d = distance(input.uv, vec2<f32>(0.5));
|
||||
let shade = 1.0 - params.strength * smoothstep(0.3, 0.8, d);
|
||||
return vec4<f32>(input.color.rgb * shade, input.color.a);
|
||||
}
|
||||
";
|
||||
|
||||
fn names(n: &[&str]) -> Vec<String> {
|
||||
n.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn message(r: Result<CheckedStage, Vec<Problem>>) -> String {
|
||||
r.expect_err("refused")
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_post_pass_reads_its_params() {
|
||||
let stage = check(StageKind::Post, "v.wgsl", VIGNETTE, &names(&["strength"])).unwrap();
|
||||
assert_eq!(stage.kind, StageKind::Post);
|
||||
assert!(stage.module.entry_points.len() == 2);
|
||||
// An unknown param is a WGSL error, with its line.
|
||||
let e = message(check(
|
||||
StageKind::Post,
|
||||
"v.wgsl",
|
||||
VIGNETTE,
|
||||
&names(&["radius"]),
|
||||
));
|
||||
assert!(e.starts_with("v.wgsl:3:"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_wgsl_is_refused_where_it_is_wrong() {
|
||||
let e = message(check(
|
||||
StageKind::Post,
|
||||
"v.wgsl",
|
||||
"fn post(input: PostInput) -> vec4<f32> { return input.colour; }\n",
|
||||
&[],
|
||||
));
|
||||
assert!(e.starts_with("v.wgsl:1:"), "{e}");
|
||||
let e = message(check(StageKind::Post, "v.wgsl", "fn main() {}\n", &[]));
|
||||
assert!(e.contains("defines no `fn post"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindings_of_its_own_are_refused() {
|
||||
// A storage buffer it could write, or any other resource: the stage
|
||||
// reads only the prelude's.
|
||||
let e = message(check(
|
||||
StageKind::Post,
|
||||
"v.wgsl",
|
||||
"@group(3) @binding(9) var<storage, read_write> out: array<f32>;\n\
|
||||
fn post(input: PostInput) -> vec4<f32> { out[0] = 1.0; return input.color; }\n",
|
||||
&[],
|
||||
));
|
||||
assert!(e.contains("declares the binding `out`"), "{e}");
|
||||
let e = message(check(
|
||||
StageKind::Post,
|
||||
"v.wgsl",
|
||||
"@group(0) @binding(0) var other: texture_2d<f32>;\n\
|
||||
fn post(input: PostInput) -> vec4<f32> { return input.color; }\n",
|
||||
&[],
|
||||
));
|
||||
assert!(e.contains("declares the binding `other`"), "{e}");
|
||||
let e = message(check(
|
||||
StageKind::Post,
|
||||
"v.wgsl",
|
||||
"@fragment fn horizon_fs(input: PostInput) -> @location(0) vec4<f32> { return input.color; }\n",
|
||||
&[],
|
||||
));
|
||||
assert!(e.contains("entry point"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_hold() {
|
||||
let big = format!("{VIGNETTE}// {}\n", "x".repeat(MAX_SOURCE));
|
||||
assert!(
|
||||
message(check(
|
||||
StageKind::Post,
|
||||
"v.wgsl",
|
||||
&big,
|
||||
&names(&["strength"])
|
||||
))
|
||||
.contains("KiB")
|
||||
);
|
||||
let many: Vec<String> = (0..=MAX_PARAMS).map(|i| format!("p{i}")).collect();
|
||||
assert!(message(check(StageKind::Post, "v.wgsl", VIGNETTE, &many)).contains("params"));
|
||||
for bad in [&["Strength"][..], &["horizon_x"], &["a", "a"], &["loop"]] {
|
||||
assert!(
|
||||
check(StageKind::Post, "v.wgsl", VIGNETTE, &names(bad)).is_err(),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
// Under the size cap, over the expression cap.
|
||||
let steps: String = (0..1500).map(|_| " c = c * 0.5 + 0.1;\n").collect();
|
||||
let long = format!(
|
||||
"fn post(input: PostInput) -> vec4<f32> {{\n var c = input.color;\n{steps} return c;\n}}\n"
|
||||
);
|
||||
assert!(long.len() < MAX_SOURCE);
|
||||
let e = message(check(StageKind::Post, "v.wgsl", &long, &[]));
|
||||
assert!(e.contains("expressions; a stage may hold 4000"), "{e}");
|
||||
let e = message(check(StageKind::Sky, "v.wgsl", VIGNETTE, &[]));
|
||||
assert!(e.contains("post stage"), "{e}");
|
||||
// The compile-time budget: none at all refuses even this one.
|
||||
let e = message(check_within(
|
||||
StageKind::Post,
|
||||
"v.wgsl",
|
||||
VIGNETTE,
|
||||
&names(&["strength"]),
|
||||
Duration::ZERO,
|
||||
));
|
||||
assert!(e.contains("ms to check"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -238,9 +238,43 @@ impl Composed {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a stage is built from: a pack's file (with the pack's shared files), or a mod's
|
||||
/// shader (`crate::mods`).
|
||||
pub struct Source<'a> {
|
||||
pub kind: StageKind,
|
||||
/// What its files may use: the stage's prelude, and a mod's `params`.
|
||||
pub prelude: String,
|
||||
/// WGSL put before the file, in order: (file, text).
|
||||
pub shared: &'a [(&'a str, &'a str)],
|
||||
pub file: &'a str,
|
||||
pub text: &'a str,
|
||||
}
|
||||
|
||||
/// The WGSL of the stage `kind` whose own file is `file`: the prelude, the
|
||||
/// pack's shared files in order, the file, then the entry points.
|
||||
pub fn compose(pack: &Pack, kind: StageKind, file: &str) -> Composed {
|
||||
let shared: Vec<(&str, &str)> = pack
|
||||
.manifest
|
||||
.shared
|
||||
.iter()
|
||||
.filter(|s| *s != file)
|
||||
.map(|s| (s.as_str(), pack.source(s).unwrap_or_default()))
|
||||
.collect();
|
||||
compose_source(
|
||||
&Source {
|
||||
kind,
|
||||
prelude: kind.prelude(),
|
||||
shared: &shared,
|
||||
file,
|
||||
text: pack.source(file).unwrap_or_default(),
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// The WGSL of `source`: the prelude, the shared files, the file and, when
|
||||
/// `entries`, the stage's entry points.
|
||||
pub fn compose_source(source: &Source, entries: bool) -> Composed {
|
||||
let mut composed = Composed {
|
||||
text: String::new(),
|
||||
parts: Vec::new(),
|
||||
@@ -255,11 +289,13 @@ pub fn compose(pack: &Pack, kind: StageKind, file: &str) -> Composed {
|
||||
.parts
|
||||
.push((name.to_owned(), start..composed.text.len()));
|
||||
};
|
||||
push("<prelude>", &kind.prelude());
|
||||
for shared in pack.manifest.shared.iter().filter(|s| *s != file) {
|
||||
push(shared, pack.source(shared).unwrap_or_default());
|
||||
push("<prelude>", &source.prelude);
|
||||
for (file, text) in source.shared {
|
||||
push(file, text);
|
||||
}
|
||||
push(source.file, source.text);
|
||||
if entries {
|
||||
push("<entries>", source.kind.entries());
|
||||
}
|
||||
push(file, pack.source(file).unwrap_or_default());
|
||||
push("<entries>", kind.entries());
|
||||
composed
|
||||
}
|
||||
@@ -442,6 +442,18 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `distance` | `u32` | How far the distant terrain reaches, in chunks: 64 to 1024. On integrated graphics other than Apple's the game caps it lower. |
|
||||
| `reduced` | `bool` | Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms less GPU time per frame on integrated graphics. |
|
||||
|
||||
### enum `shader-kind`
|
||||
|
||||
Where a mod's shader runs: hook points the game owns, as shader packs' stages are (docs/api/shaders.md). A mod never builds a pipeline or binds resources of its own.
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `post-after-translucent` | A full-screen pass over the world's picture once the world is drawn (terrain, translucent blocks, entities, particles, weather), under the hand and the HUD: where shader packs' post passes run, before them. Its WGSL defines `fn post(input: PostInput) -> vec4<f32>` and reads the post stage's resources. |
|
||||
| `post-after-terrain` | A full-screen pass after opaque terrain, before translucent blocks. Not run yet: `register-shader` refuses it. |
|
||||
| `post-before-hud` | A full-screen pass over the world and the hand, before the HUD. Not run yet. |
|
||||
| `material` | The surface of the mod's own blocks, items and entities. Not run yet. |
|
||||
| `hud-fill` | The colour of the mod's own HUD shapes. Not run yet. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
|
||||
@@ -454,6 +466,9 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `set-zoom` | `func(zoom: zoom)` | Zooms the camera until the next call (`factor` 1 ends it), only while playing: a screen opening or the spyglass's own view (which wins) leave the camera vanilla's. With several mods, the strongest zoom counts. |
|
||||
| `set-minimap` | `func(minimap: option<minimap>)` | Shows a map of the world around the player in `corner`, stacked with the game's overlays and the mods' panels, until the next call (none hides it). It looks like a map item: vanilla's map colours with their height shading and water depth, and the player's arrow. Underground, under a roof and in the Nether it maps the floor around the player's height. The game keeps the map's tiles and refreshes them within its own frame budget; hidden, they are dropped. One map shows at a time: the first loaded mod's. |
|
||||
| `set-far-terrain` | `func(far-terrain: option<far-terrain>)` | Draws the world beyond the render distance as distant terrain, out to `distance` chunks, until the next call (none ends it). The game builds it on its own workers, from the chunks already loaded or saved and, where nobody has been, from the world's generator; the simulation distance doesn't change. Only worlds whose generator the game has (single player and a LAN world you host) show it. The player's own LOD Distance setting, when on, wins. One far terrain shows at a time: the first loaded mod's. |
|
||||
| `register-shader` | `func(name: string, kind: shader-kind, params: list<string>) -> result<u32, string>` | Registers the shader `name` of the mod's manifest (`assets/<id>/shaders/<name>.wgsl` in its `.hmod`) as a `kind` pass, reading the numbers `params` (up to 16, `[a-z][a-z0-9_]*`) as `params.<name>`, all 0 until `set-shader-params`. The game checks the WGSL first (naga: its signature, no resources but the stage's, a size and compile-time cap) and errs with the file, line and reason, also in the game log. Returns the shader's handle. At most 8 per mod; a pass that keeps going over its GPU budget (2 ms a frame) is removed, and the player is told. |
|
||||
| `set-shader-params` | `func(shader: u32, values: list<f32>)` | The shader's `params`, in the order `register-shader` named them (extra values are dropped, missing ones are 0), from the next frame on. |
|
||||
| `remove-shader` | `func(shader: u32)` | Stops drawing the shader; its handle is no longer valid. |
|
||||
|
||||
## `events`
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ The step-by-step guide, from installing the tools to publishing on the registry,
|
||||
|
||||
[mods-reference.md](mods-reference.md), generated from `crates/horizon-mod-sdk/wit/horizon.wit`: every capability,
|
||||
interface, type, function and hook, with the limits the guide lists (16 HUD lines of 256 characters, 8 chat lines per
|
||||
call, 4 MiB of storage, fuel per call).
|
||||
call, 16 MiB and 256 files of storage, fuel per call).
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -118,6 +118,15 @@ Any other server.properties key, spelled as vanilla spells it, for example: spaw
|
||||
| `bind` | `"127.0.0.1:8130"` | | the web panel: this computer only, or a tailnet address (100.x); "" turns it off |
|
||||
| `allow_public` | `false` | | true lets bind be any address; never put the panel on the open internet |
|
||||
|
||||
### `[mods]`
|
||||
|
||||
Horizon mods (docs/mods.md): what each server mod may save with capability storage. Its textures, models and data ship in its .hmod, not here.
|
||||
|
||||
| Key | Default | In `server.properties` | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `storage_mib` | `16` | | megabytes each mod may keep, 1 to 1024 |
|
||||
| `storage_files` | `256` | | files (keys) each mod may keep, 1 to 65536 |
|
||||
|
||||
## Control API (`/api/v1`)
|
||||
|
||||
HTTP and JSON on 127.0.0.1. A running server writes its address and a random token to `.horizon/api.json`, readable only by its own user; every request sends `Authorization: Bearer <token>` and a `Host` that is that address. Errors answer `{"error": "..."}` with 400, 401, 403, 404, 405 or 409. The web panel uses the same routes with its session.
|
||||
|
||||
@@ -77,6 +77,14 @@ value). The pack holds only the manifest, the `.wgsl` files it names (each used,
|
||||
`README.md`, `LICENSE` (`.md`, `.txt`) and `icon.png`, under plain paths, 8 MiB in all. A `.hshader` is built
|
||||
the same way every time (files in path order, fixed dates), so a pack's SHA-256 depends only on its files.
|
||||
|
||||
## Shaders from a mod
|
||||
|
||||
A Horizon mod with capability `render` may ship post passes of its own ([mods.md](../mods.md#shaders-from-a-mod)):
|
||||
`assets/<id>/shaders/<name>.wgsl` in its `.hmod`, the same post stage as a pack's (`fn post(input: PostInput) ->
|
||||
vec4<f32>`, the same prelude, inputs and helpers), plus `params`, a uniform of up to 16 `f32`s the mod names when it
|
||||
registers the shader and sets every frame. `horizon_shader_api::mods::check` builds and checks it as a pack's stage
|
||||
is, with tighter limits: 64 KiB of WGSL, 4,000 naga expressions, no options, and 250 ms to check.
|
||||
|
||||
## Guide, reference and examples
|
||||
|
||||
- [shaders-reference.md](shaders-reference.md): every stage's signature, input and output structs, resources and
|
||||
@@ -96,6 +104,8 @@ stages.
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-10-27: mods' post passes (`horizon_shader_api::mods`): the post stage with a mod's `params`.
|
||||
|
||||
- 2026-10-26: the material stage in the game: terrain's surfaces through the pack's `material`.
|
||||
- 2026-10-26: the sky stage in the game: the pack's `sky` gets vanilla's colour along each ray.
|
||||
- 2026-10-26: the post stage in the game (`HORIZON_SHADER_PACK`, hot reload with the last good pack kept).
|
||||
|
||||
@@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says
|
||||
|
||||
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
|
||||
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`), level 6 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`, level 7 `render.set-far-terrain`. A mod needs the level of
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`), level 6 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`, level 7 `render.set-far-terrain`, level 8 `render.register-shader`, `set-shader-params` and `remove-shader`. A mod needs the level of
|
||||
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
|
||||
never declared:
|
||||
|
||||
|
||||
@@ -180,6 +180,10 @@ keep = 12 # automatic backups kept; backups made by hand are
|
||||
[panel]
|
||||
bind = "127.0.0.1:8130" # this computer only, or a tailnet address (100.x); "" turns it off
|
||||
allow_public = false
|
||||
|
||||
[mods]
|
||||
storage_mib = 16 # what each server mod may save (capability storage), 1 to 1024
|
||||
storage_files = 256 # files each server mod may save, 1 to 65536
|
||||
```
|
||||
|
||||
The defaults are vanilla 26.3's. Unknown keys and values vanilla would refuse are errors, so a typo stops the
|
||||
|
||||
+84
-2
@@ -86,10 +86,10 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| `game` | `game-read` | `get-player()`: x y z, yaw, pitch, health, food, dimension; `get-clock()`: game time, day time; `raycast(distance)`: the first block along the view (fluids pass), up to 1024 blocks and across the distant terrain, with the point hit and the block's id |
|
||||
| `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) |
|
||||
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
|
||||
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
|
||||
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: the mod's saved state (settings, progress), in a per-mod folder of up to 256 files and 16 MiB, at most 1 MiB written per call; a server owner changes both in `server.toml` (`[mods]`). Not for resources: [where a mod's files go](#where-a-mods-files-go) |
|
||||
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) |
|
||||
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` |
|
||||
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's); `set-far-terrain(far-terrain)`: the world past the render distance as distant terrain, out to 64 to 1024 chunks, built by the game's own workers (the player's LOD Distance, when on, wins; the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
|
||||
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's); `set-far-terrain(far-terrain)`: the world past the render distance as distant terrain, out to 64 to 1024 chunks, built by the game's own workers (the player's LOD Distance, when on, wins; the first loaded mod's). The game's own HUD renderer draws them. `register-shader(name, kind, params)`, `set-shader-params(shader, values)`, `remove-shader(shader)`: post passes of the mod's own WGSL over the world's picture ([shaders from a mod](#shaders-from-a-mod)). **Experimental:** `experimental = ["render"]` |
|
||||
| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key.<mod>.<name>:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` |
|
||||
| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds; `place(book-id, all)`: the recipe book's click on a recipe, in the open inventory's or crafting table's grid (refused for a recipe the player's book doesn't hold, as vanilla's server refuses it); `give(stack)`: in creative mode, the stack into the player's inventory, as from the creative inventory. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` |
|
||||
|
||||
@@ -101,12 +101,69 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| `command(name, args) -> result<option<string>, string>` | `/name args` typed in chat, for a command the mod registered |
|
||||
| `chat(text)` | each chat line the player receives, as plain text |
|
||||
|
||||
### Where a mod's files go
|
||||
|
||||
A mod ships everything it *is* in its `.hmod`, and keeps only what it *saves* in `storage`:
|
||||
|
||||
| What | Where | Why |
|
||||
|---|---|---|
|
||||
| Textures, models, sounds, language files, block states | `assets/<id>/...` in the `.hmod` | the game loads them with the mod, as a resource pack's files; no quota but the package's (16 MiB a file, 512 MiB in all) |
|
||||
| Recipes, loot tables, tags, blocks and items | `data/<id>/...` in the `.hmod` | read before the world loads ([content mods](#content-mods-api-02)) |
|
||||
| WGSL shaders | `assets/<id>/shaders/<name>.wgsl` in the `.hmod` | capability `render` ([shaders from a mod](#shaders-from-a-mod)); no `registries` needed |
|
||||
| Settings, progress, a cache the mod rebuilds | `storage` | the only part that changes while playing: 16 MiB and 256 files per mod |
|
||||
|
||||
`assets/` and `data/` need capability `registries` and `api = "0.2"`, with code or without. A gun mod's model,
|
||||
textures and sounds are package assets; its `storage` holds, say, the player's chosen sight.
|
||||
|
||||
Commands: vanilla's command names win. A mod asking for `/give` does not get
|
||||
it (logged), and two mods cannot share a name (the first one loaded keeps it).
|
||||
The chat box colours and Tab-completes a mod's commands like vanilla's, for
|
||||
every player whatever their permission level, and takes any text after the
|
||||
name (the mod reads it as `args`; API 0.1 has no way to declare argument types).
|
||||
|
||||
### Shaders from a mod
|
||||
|
||||
A mod with capability `render` may draw full-screen passes of its own over the world, written in WGSL as a
|
||||
[Horizon shader pack](api/shaders.md)'s post stage is. It ships each one in its `.hmod` and declares it in its
|
||||
manifest:
|
||||
|
||||
```toml
|
||||
capabilities = ["game-read", "render"]
|
||||
experimental = ["render"]
|
||||
shaders = ["vignette"] # assets/vignette/shaders/vignette.wgsl
|
||||
```
|
||||
|
||||
Its code registers it, then sets its numbers whenever they change (each frame at most):
|
||||
|
||||
```rust
|
||||
let params = ["strength".to_owned(), "low_health".to_owned()];
|
||||
let shader = render::register_shader("vignette", ShaderKind::PostAfterTranslucent, ¶ms)?;
|
||||
render::set_shader_params(shader, &[0.6, low_health]);
|
||||
```
|
||||
|
||||
The WGSL defines `fn post(input: PostInput) -> vec4<f32>` and reads what a pack's post stage reads (`frame`, the
|
||||
world's colour and depth, the helpers; [shaders-reference.md](api/shaders-reference.md)), plus `params.<name>`, the
|
||||
mod's numbers (up to 16 `f32`s, 0 until set). The example is
|
||||
[`vignette`](../crates/horizon-mod-sdk/examples/vignette/src/lib.rs).
|
||||
|
||||
- **Hook points, not pipelines.** `kind` is a point of the game's frame. Today the game runs
|
||||
`post-after-translucent`: over the world once it is drawn (terrain, translucent blocks, entities, particles,
|
||||
weather), under the hand and the HUD, before a shader pack's own post passes, in the mods' load order and then the
|
||||
order each registered them. `post-after-terrain`, `post-before-hud`, `material` (the mod's own blocks, items and
|
||||
entities) and `hud-fill` (its HUD shapes) are in the contract and refused with an error until the game runs them.
|
||||
A mod never builds a pipeline, binds a resource of its own or touches another mod's or vanilla's materials.
|
||||
- **Checked when registered.** `register-shader` builds the stage as the game will and checks it with naga: the
|
||||
signature, no bindings or entry points of its own (so no storage it could write), translation for Vulkan, Metal
|
||||
and DirectX 12, at most 64 KiB of WGSL, 4,000 expressions and 250 ms to check. A shader that fails is refused with
|
||||
its file, line and reason, in the mod's log too; only that shader stops, the mod runs on.
|
||||
- **A GPU budget.** Each pass is timed on the GPU; one that stays over 2 ms a frame for 5 seconds is removed and
|
||||
the player is told in chat ("Mod Vignette's shader vignette stopped: it took 3.1 ms of GPU time a frame"), as a
|
||||
mod that runs out of fuel is stopped. On a GPU without timestamp queries no pass is timed or removed.
|
||||
- **Off until the player turns the mod on**, like every mod; its row in Options > Mods says it may "use custom
|
||||
shaders". At most 8 registered at once per mod; `remove-shader` stops one.
|
||||
- `cargo horizon-mod test` scripts check them: `expect.shaders = [{ name = "vignette", kind =
|
||||
"post-after-translucent", params = [0.6, 0.0] }]` (the shaders registered after the step, in order).
|
||||
|
||||
### Planned interfaces
|
||||
|
||||
Each is a new capability, added in a 0.x minor version so older mods keep
|
||||
@@ -122,9 +179,32 @@ loading. Ordered by what the first native ports need:
|
||||
| `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) |
|
||||
| `server` | world events (entity spawn/death), game rules, world read/write, typed server commands | after [server mods 0.3](#server-mods-api-03-experimental)'s players, chat, blocks and commands |
|
||||
| `net` | a mod's own client↔server channel (custom payloads) | only between the two halves of the same mod |
|
||||
| `packets` | server mods: a read-only, typed view of the play packets the server receives | design below; not built |
|
||||
|
||||
There is no file, socket, process or raw-input interface, on purpose.
|
||||
|
||||
### No sockets: what to use instead
|
||||
|
||||
A mod never opens a socket, not even to the game's own connection: it could talk to anything on the player's
|
||||
network, and the sandbox could no longer say what a mod does. What a mod usually wants a socket for is covered
|
||||
by:
|
||||
|
||||
- **`net`** (planned): the mod's own channel between its client and server halves, as custom payloads on the
|
||||
game's connection. The server half decides what the client half may do.
|
||||
- **Typed events**: the hooks above (`tick`, `chat`, `command`; a server mod's joins, chat, commands, blocks
|
||||
broken and placed). A missing event is a new hook, added like the others.
|
||||
|
||||
**`packets` (design; ask before building it).** If a real mod needs more than those, the next step is a
|
||||
read-only capability for **server mods** first:
|
||||
|
||||
- A typed view of the play packets the server *receives* (movement, use and attack, container clicks, held
|
||||
item), delivered as a hook after the server handled them. The mod can't change, drop or send a packet.
|
||||
- Never chat, commands, signatures, keys, login or configuration packets: what a player types or proves about
|
||||
their account stays out of reach.
|
||||
- Opt-in: `capabilities = ["packets"]`, listed on the mod's page ("May see what players do: movement, clicks,
|
||||
items used"); off for client mods, which see only their own player through `game` and `input`.
|
||||
- Rate-capped like `world` (a fixed number per tick, the oldest dropped) and free when no mod has it.
|
||||
|
||||
## Choosing the runtime
|
||||
|
||||
| Option | Sandbox | One file for all OSes | Typed API | Cost | Verdict |
|
||||
@@ -310,6 +390,7 @@ checked:
|
||||
| `chat = ["..."]` | every chat line it showed, in order (`[]`: none) |
|
||||
| `feedback = "..."`, `error = "..."` | the command's answer, shown plain or in red |
|
||||
| `panel = ["..."]`, `corner = "top-left"` | its HUD panel's lines (`[]`: no panel) and corner |
|
||||
| `shaders = [{ name = "vignette", kind = "...", params = [0.6] }]` | the shaders it has registered, in order (`[]`: none); `kind` and `params` are checked when given |
|
||||
| `told = ["Alex: Welcome!"]`, `broadcast = ["..."]` | server mods: every `players.tell` (as `name: text`) and `players.broadcast`, in order |
|
||||
| `blocks = [{ x = 2, y = 64, z = 0, state = "minecraft:glowstone" }]` | server mods with `world`: those blocks of the Overworld after the step, the mod's `set-blocks` made (the script checks a state's shape; the game checks its name) |
|
||||
| `stopped = "panic"` | the mod was turned off, for a reason holding this text; without it, a mod that traps, panics or overruns fails the test |
|
||||
@@ -367,6 +448,7 @@ api = "0.1" # the API the SDK targets
|
||||
side = "client" # client | server | both
|
||||
capabilities = ["game-read", "hud", "chat"]
|
||||
experimental = [] # experimental capabilities it uses, e.g. ["ui"]
|
||||
shaders = [] # its WGSL shaders, assets/<id>/shaders/<name>.wgsl (capability render)
|
||||
```
|
||||
|
||||
Unknown keys are refused, so a typo is an error, not a silently ignored
|
||||
|
||||
Reference in new issue
Block a user