From b15e5e893ed96733ebcdb1e8a1e7e41969251f59 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:58:39 +0200 Subject: [PATCH] Code mods are the client's mods feature: tests and probes skip wasmtime horizon-mods' runtime is no longer a default feature, and its sandbox test requires runtime and pack instead of a dev-dependency on itself (that dev-dependency turned the runtime on in every workspace build with examples, the parity probe build included). The client gets a mods feature that turns the runtime on; without it game/mods.rs keeps content mods and logs why code mods don't start, and every hook does nothing. Release builds (client and server) pass the feature, CI's clippy and tests pass it (and pack, for the sandbox test), and pr_ready does where horizon-mods is checked. horizon_mods::find moves to manifest.rs: content mods use it without the runtime. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +- .github/workflows/release.yml | 8 ++- Cargo.lock | 1 - crates/horizon-client/Cargo.toml | 6 +- crates/horizon-client/src/game/chat.rs | 4 +- crates/horizon-client/src/game/extras.rs | 2 +- crates/horizon-client/src/game/mods.rs | 83 ++++++++++++++++++++---- crates/horizon-mods/Cargo.toml | 11 ++-- crates/horizon-mods/src/lib.rs | 2 +- crates/horizon-mods/src/manifest.rs | 20 +++++- crates/horizon-mods/src/runtime.rs | 17 ----- docs/mods.md | 12 +++- tests/test_pr_ready.py | 5 +- tools/pr_ready.py | 14 +++- 14 files changed, 138 insertions(+), 52 deletions(-) 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),