diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8966854c2..32ad79497 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,12 +61,13 @@ jobs: if: steps.rust.outputs.required != 'false' run: | cargo +stable fmt --all -- --check - cargo +stable clippy --workspace --all-targets -- -D warnings + # With the game's code mods (on in every release build, off by default). + cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings # The server build (released as horizon-engine): the game without sound or controllers. cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings - name: Test Rust core and voxel kernel correctness if: steps.rust.outputs.required != 'false' - run: cargo +stable test --workspace + run: cargo +stable test --workspace --features horizon-client/mods,horizon-mods/pack parity: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository uses: ./.github/workflows/parity.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b19ba9ce5..bb3be5480 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,8 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev - name: Build the client, horizon-ctl, horizon-prepare and horizon-server - run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server + # `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds). + run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server - name: Every program the release folder takes was built run: | for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do @@ -250,8 +251,9 @@ jobs: run: | set -euo pipefail # After the client is packaged: target/release/horizon-client is rebuilt without the audio and - # controllers features, so it needs neither libasound nor libudev (minimal server images lack them). - cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client + # controllers features, so it needs neither libasound nor libudev (minimal server images lack them), + # and with code mods like the client. + cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}" folder="dist/${{ matrix.server_dir }}" python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare" diff --git a/Cargo.lock b/Cargo.lock index 2f832e248..1a1b288c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1493,7 +1493,6 @@ dependencies = [ name = "horizon-mods" version = "0.1.0" dependencies = [ - "horizon-mods", "log", "serde", "serde_json", diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index cd5d86fe7..94ba949d0 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -18,7 +18,8 @@ horizon-protocol = { path = "../horizon-protocol" } horizon-server-files = { path = "../horizon-server-files" } horizon-friends = { path = "../horizon-friends" } # Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md). -horizon-mods = { path = "../horizon-mods" } +# Their runtime is the `mods` feature. +horizon-mods = { path = "../horizon-mods", default-features = false } wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] } winit = "0.30.13" pollster = "0.4" @@ -78,6 +79,9 @@ default = ["audio", "controllers"] audio = ["dep:cpal"] # Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad. controllers = ["dep:gilrs", "dep:horizon-dualsense"] +# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build +# (release.yml); the tests and probes don't need it. Content mods load without it. +mods = ["horizon-mods/runtime"] # Records the call sites of mob random draws (`mob_ai_probe` traces). rng-trace = ["horizon-core/rng-trace"] diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 20aeeb7fd..3d10d57b6 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -454,7 +454,7 @@ impl Game<'_> { return; } let focused = self.screen == Screen::Chat; - let heard = self.mods.mods.is_running().then(|| text.plain()); + let heard = self.mods.running().then(|| text.plain()); self.ui.chat.add( &self.gui.atlas, text, @@ -583,7 +583,7 @@ impl Game<'_> { &[Text::literal(PLAYER_NAME), Text::literal(message)], ); let focused = self.screen == Screen::Chat; - let heard = self.mods.mods.is_running().then(|| line.plain()); + let heard = self.mods.running().then(|| line.plain()); self.ui .chat .add(&self.gui.atlas, line, None, self.ticks, focused); diff --git a/crates/horizon-client/src/game/extras.rs b/crates/horizon-client/src/game/extras.rs index c16c54447..cee173fda 100644 --- a/crates/horizon-client/src/game/extras.rs +++ b/crates/horizon-client/src/game/extras.rs @@ -237,7 +237,7 @@ impl Game<'_> { /// the caller). The top left corner gives way to the debug screen. pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) { let e = &self.settings.extras; - let mod_panels = self.mods.mods.panels().next().is_some(); + let mod_panels = self.mods.has_panels(); if !e.fps && !e.keystrokes && !e.minimap && !mod_panels { return; } diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 01787ec02..572f9fe2b 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -2,43 +2,76 @@ //! their tick, frame, chat and command hooks, their HUD panels and what //! they print. With no mod on, `horizon_mods::Mods` holds no engine and //! every hook here returns after one check. -use super::{Game, Screen}; +//! +//! Code mods run only in builds with the `mods` feature (release builds): +//! without it the game links no wasmtime, and every hook does nothing. +//! Content mods (`content_root`) load in every build. +use super::Game; use horizon_client::{ command::{Dispatcher, vanilla}, - extras::Corner, - extras_hud::{self, Corners}, + extras_hud::Corners, hud::HudBatch, - mod_list::{self, Installed}, - text::{self, Text}, + mod_list, }; -use horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status}; -use std::{ - path::PathBuf, - time::{Duration, Instant}, +use std::{path::PathBuf, time::Duration}; +#[cfg(feature = "mods")] +use { + super::Screen, + horizon_client::{ + extras::Corner, + extras_hud, + mod_list::Installed, + text::{self, Text}, + }, + horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status}, + std::time::Instant, }; #[derive(Default)] pub(super) struct ModsState { - pub(super) mods: Mods, + #[cfg(feature = "mods")] + mods: Mods, /// `--dev-mod PATH`: always on, reloaded when rebuilt. pub(super) dev: Vec, /// When dev mods were last checked for a rebuild. + #[cfg(feature = "mods")] checked: Option, /// The chat's command tree while a running mod owns commands. + #[cfg(feature = "mods")] tree: Option, } impl ModsState { /// The chat's command tree: vanilla's, plus the running mods' commands. pub(super) fn dispatcher(&self) -> &Dispatcher { - self.tree - .as_ref() - .unwrap_or_else(|| vanilla::game_dispatcher()) + #[cfg(feature = "mods")] + if let Some(tree) = &self.tree { + return tree; + } + vanilla::game_dispatcher() + } + /// Whether a mod runs: the chat keeps its lines for them. + pub(super) fn running(&self) -> bool { + #[cfg(feature = "mods")] + if self.mods.is_running() { + return true; + } + false + } + /// Whether a mod draws a HUD panel. + pub(super) fn has_panels(&self) -> bool { + #[cfg(feature = "mods")] + if self.mods.panels().next().is_some() { + return true; + } + false } } /// How often dev mods are checked for a rebuild. +#[cfg(feature = "mods")] const RELOAD_CHECK: Duration = Duration::from_secs(1); /// A vanilla command name: mods may not take it. +#[cfg(feature = "mods")] fn vanilla_command(name: &str) -> bool { let d = vanilla::game_dispatcher(); d.nodes[0] @@ -112,6 +145,30 @@ pub(super) fn content_root( } } +/// A build without the `mods` feature: no mod runs. +#[cfg(not(feature = "mods"))] +impl Game<'_> { + /// Says why the installed and dev mods don't run. + pub(super) fn start_mods(&mut self) { + let dir = Self::options_path().with_file_name(mod_list::DIR); + let found = horizon_mods::find(&dir).len() + self.mods.dev.len(); + if found > 0 { + log::warn!( + "Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)" + ); + } + } + pub(super) fn sync_mods(&mut self) {} + pub(super) fn tick_mods(&mut self) {} + pub(super) fn mods_frame(&mut self, _elapsed: Duration) {} + pub(super) fn mod_command(&mut self, _command: &str) -> bool { + false + } + pub(super) fn mods_chat(&mut self, _line: &str) {} + pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {} +} + +#[cfg(feature = "mods")] impl Game<'_> { /// Reads `horizon-mods/` and the dev mods, and starts the ones turned on. pub(super) fn start_mods(&mut self) { diff --git a/crates/horizon-mods/Cargo.toml b/crates/horizon-mods/Cargo.toml index 8db24ac35..013d38a99 100644 --- a/crates/horizon-mods/Cargo.toml +++ b/crates/horizon-mods/Cargo.toml @@ -19,13 +19,16 @@ wit-component = { version = "0.239", optional = true } wit-parser = { version = "0.239", optional = true } [features] -default = ["runtime"] -# Loads and runs mods; without it only manifests and packaging (cargo horizon-mod). +# 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"] pack = ["dep:wit-component", "dep:wit-parser"] -[dev-dependencies] -horizon-mods = { path = ".", features = ["pack"] } +# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`. +[[test]] +name = "sandbox" +required-features = ["runtime", "pack"] [lints] workspace = true diff --git a/crates/horizon-mods/src/lib.rs b/crates/horizon-mods/src/lib.rs index ec9ebe67d..5060bf747 100644 --- a/crates/horizon-mods/src/lib.rs +++ b/crates/horizon-mods/src/lib.rs @@ -11,6 +11,6 @@ pub mod manifest; mod runtime; pub use content::Content; -pub use manifest::{Capability, Manifest, Package, Side}; +pub use manifest::{Capability, Manifest, Package, Side, find}; #[cfg(feature = "runtime")] pub use runtime::*; diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 44cdbd418..a71210883 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -1,6 +1,10 @@ //! `horizon-mod.toml`, a mod's manifest, and the `.hmod` file around it. use serde::Deserialize; -use std::{fmt, io::Read, path::Path}; +use std::{ + fmt, + io::Read, + path::{Path, PathBuf}, +}; /// The manifest's file name, beside `mod.wasm`. pub const MANIFEST: &str = "horizon-mod.toml"; @@ -282,6 +286,20 @@ impl Package { } } +/// The `.hmod` files and mod folders in `dir`, by name. +pub fn find(dir: &Path) -> Vec { + let Ok(read) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut found: Vec = read + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "hmod") || p.join(MANIFEST).is_file()) + .collect(); + found.sort(); + found +} + /// Turns a `wasm32-unknown-unknown` cdylib built with the SDK into the /// component the game loads. #[cfg(feature = "pack")] diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 975cd1461..9dd2eb54e 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -131,23 +131,6 @@ pub struct Mods { out: Vec, } -/// The `.hmod` files and mod folders in `dir`, by name. -pub fn find(dir: &Path) -> Vec { - let Ok(read) = std::fs::read_dir(dir) else { - return Vec::new(); - }; - let mut found: Vec = read - .flatten() - .map(|e| e.path()) - .filter(|p| { - p.extension().is_some_and(|e| e == "hmod") - || p.join(crate::manifest::MANIFEST).is_file() - }) - .collect(); - found.sort(); - found -} - impl Mods { /// Reads every mod at `paths` and starts those `enabled` says are on. /// A command that `reserved` (vanilla's commands) or an earlier mod diff --git a/docs/mods.md b/docs/mods.md index 4b356d336..4521d312d 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -137,10 +137,17 @@ proposal, no profiling. Its cranelift backend generates native code for x86-64 and ARM64 on Windows, macOS and Linux, which is why one `.hmod` serves them all. +The game links it only with its `mods` feature: every release build turns it +on (`release.yml`), and a build from source needs +`cargo build --release -p horizon-client --features mods` to run code mods. +Without it (the tests and probes) wasmtime and cranelift aren't built, a +quarter of a clean build; content mods still load, and the game logs why the +code mods don't start. + ### Performance Measured on the Mac mini (M4 Pro), release build, `cargo test -p -horizon-mods --release --test sandbox -- --ignored --nocapture`: +horizon-mods --features runtime,pack --release --test sandbox -- --ignored --nocapture`: | | per call | |---|---| @@ -180,7 +187,8 @@ chat (`Mod Test Guest stopped: it took too long (an endless loop?)`), marks it on the Mods page and carries on; the other mods keep running. Host functions clip what a mod hands back (chat lines per call, HUD lines and lengths). Tests: `crates/horizon-mods/tests/sandbox.rs` (an endless loop, a panic and a 1 GiB -allocation, next to a healthy mod). +allocation, next to a healthy mod; `cargo test -p horizon-mods --features +runtime,pack`). ## Playing with mods diff --git a/tests/test_pr_ready.py b/tests/test_pr_ready.py index c10e7b2ac..8268d5ab7 100644 --- a/tests/test_pr_ready.py +++ b/tests/test_pr_ready.py @@ -9,6 +9,7 @@ PACKAGES = {"horizon-core": "crates/horizon-core", "horizon-client": "crates/hor "horizon-theme": "crates/horizon-theme", "xtask": "xtask"} LIBRARIES = set(PACKAGES) - {"xtask"} DEPENDENCIES = {"horizon-client": {"horizon-core"}} +MODS = " --features horizon-mods/runtime,horizon-mods/pack,horizon-client/mods" def args(host="local", quick=False): @@ -58,9 +59,9 @@ class PrReadyTests(unittest.TestCase): steps = pr_ready.plan(args(quick=True), "origin/main", {path}, PACKAGES, LIBRARIES, DEPENDENCIES) by_name = {step.name: step for step in steps} self.assertEqual(by_name["clippy"].command, - "cargo clippy --workspace --all-targets --locked -- -D warnings") + "cargo clippy --workspace" + MODS + " --all-targets --locked -- -D warnings") self.assertEqual(by_name["tests: workspace"].command, - "cargo test --locked --workspace --tests") + "cargo test --locked --workspace" + MODS + " --tests") self.assertEqual(by_name["doc tests"].command, "cargo test --locked --workspace --doc") def test_non_rust_changes_skip_workspace_checks(self): diff --git a/tools/pr_ready.py b/tools/pr_ready.py index e3e317ef8..a944e2116 100755 --- a/tools/pr_ready.py +++ b/tools/pr_ready.py @@ -274,6 +274,16 @@ def parity_summary(output): return text +def mod_features(crates): + """Code mods are off by default (docs/mods.md): on wherever horizon-mods is checked, as in CI.""" + if crates is not None and "horizon-mods" not in crates: + return "" + features = ["horizon-mods/runtime", "horizon-mods/pack"] + if crates is None or "horizon-client" in crates: + features.append("horizon-client/mods") + return f" --features {','.join(features)}" + + def plan(args, base, changed, crates_of, libraries, dependencies): """The checks for these changed files, in the order they run.""" rust = any(workspace_rust(path) for path in changed) @@ -297,10 +307,10 @@ def plan(args, base, changed, crates_of, libraries, dependencies): Step("Python tests", "python3 -m unittest discover -s tests", where="here", skip=None if python else "no Python change", summary=unittest_passed), Step("rustfmt", "cargo fmt --all -- --check", skip=None if rust else no_rust), - Step("clippy", f"cargo clippy {clippy_selection} --all-targets --locked -- -D warnings", + Step("clippy", f"cargo clippy {clippy_selection}{mod_features(clippy_crates)} --all-targets --locked -- -D warnings", skip=None if rust else no_rust), Step("tests" + (": workspace" if crates is None else f": {', '.join(crates)}" if crates else ""), - f"cargo test --locked {selection} --tests", + f"cargo test --locked {selection}{mod_features(crates)} --tests", skip=test_skip, summary=tests_passed), Step("doc tests", f"cargo test --locked {doc_selection} --doc", skip=test_skip or (None if doc_crates else "no library touched"), summary=tests_passed),