From 8d31e9e91ea2926f857cacb909a728aceb18bd28 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:05:27 +0700 Subject: [PATCH] A crate on top of benilla adds its own Bevy plugins through `benilla_app::run_with`, and a client started through it reads `extended` in its build line (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crate built on top of benilla now adds its own Bevy plugins through `benilla_app::run_with(build, extend)`: `extend` gets the client fully built, after the game plugins, the asset loaders and the `ExtractSchedule` executor flip, and before the probe fleet, which sees what it adds. `run` is `run_with` with nothing added, so the stock client is unchanged. - A client started through `run_with` carries `extended` in its build id (`BuildId::summary`), so the startup banner, the crash report and the debug panel's footer all say so, and a report from an extended client reads apart from stock. The entry point sets the flag; a launcher leaves it to `..Default::default()`. - `crates/benilla-app/examples/extended_launcher.rs` is a launcher with a plugin of its own, compiled by the gates. Its header is the shape a crate of its own copies: the manifest (benilla-app without default features and `dev` forwarded, the workspace's bevy, and the root's `[patch]` and `[profile.dev]`, which cargo reads from the root manifest only), the build script's stamp, and a `Cargo.lock` started from benilla's. - `tests/launcher_shape.rs` holds that documented manifest to the root `Cargo.toml` and the `benilla` launcher's, so a patch or profile added later cannot leave the documented shape building a client that is not benilla's. - `docs/CONTRIBUTING.md`: a feature 1.12.1 does not have lives in its own crate on top of benilla, started through `run_with`. Fixes #483 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../benilla-app/examples/extended_launcher.rs | 82 ++++++++++++++++ crates/benilla-app/src/crash.rs | 21 +++-- crates/benilla-app/src/lib.rs | 30 +++++- crates/benilla-app/tests/launcher_shape.rs | 94 +++++++++++++++++++ crates/benilla-world/src/build_id.rs | 42 ++++++++- crates/benilla-worldview/src/main.rs | 1 + crates/benilla/src/main.rs | 1 + docs/CONTRIBUTING.md | 7 +- 8 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 crates/benilla-app/examples/extended_launcher.rs create mode 100644 crates/benilla-app/tests/launcher_shape.rs diff --git a/crates/benilla-app/examples/extended_launcher.rs b/crates/benilla-app/examples/extended_launcher.rs new file mode 100644 index 00000000..2964e55b --- /dev/null +++ b/crates/benilla-app/examples/extended_launcher.rs @@ -0,0 +1,82 @@ +//! `extended_launcher`: the client with a plugin of its own on top, started through +//! [`benilla_app::run_with`] as a crate built on benilla starts it. A feature 1.12.1 does not have +//! lives in a crate like this one, never in benilla. +//! +//! ```text +//! cargo run -p benilla-app --example extended_launcher +//! ``` +//! +//! The banner reads `benilla build … · extended`, and the plugin logs one line at `Startup`. +//! +//! A crate of its own copies the `benilla` launcher (`crates/benilla`): a build script that stamps +//! its commit, a `main` like this one that reads the stamp back as `crates/benilla/src/main.rs` +//! does, and a manifest that takes benilla as the workspace does: +//! +//! ```toml +//! [features] +//! default = ["dev"] +//! # The instruments; the player build is `--no-default-features`. +//! dev = ["benilla-app/dev"] +//! +//! [dependencies] +//! # `default-features = false`, or `--no-default-features` still builds benilla-app with `dev`. +//! benilla-app = { git = "https://github.com/samwhosung/benilla", default-features = false } +//! # benilla's version, adding no features: bevy's defaults bring its audio stack back. +//! bevy = { version = "0.18.1", default-features = false } +//! +//! [build-dependencies] +//! benilla-buildstamp = { git = "https://github.com/samwhosung/benilla" } +//! +//! # Cargo reads `[patch]` and `[profile]` from the root manifest only, so benilla's are repeated: +//! # without the patches the addon Lua and the mixer are not benilla's. +//! [patch.crates-io] +//! lua-src = { git = "https://github.com/samwhosung/benilla" } +//! kira = { git = "https://github.com/samwhosung/benilla" } +//! +//! [profile.dev] +//! opt-level = 1 +//! +//! [profile.dev.package."*"] +//! opt-level = 3 +//! ``` +//! +//! ```text +//! // build.rs +//! fn main() { +//! benilla_buildstamp::emit(); +//! } +//! ``` +//! +//! Its `Cargo.lock` starts as a copy of benilla's: cargo reads only the root's lockfile too, and +//! without it resolves every crate to its newest compatible version, not the one benilla is built +//! with. A dev build looks for the `WoW` link and `benilla-config/` at benilla's repo root, which +//! for a git dependency is cargo's checkout, so `WOW_DATA` and `BENILLA_HOME` name them instead. + +use benilla_app::{AppExit, BuildId}; +use bevy::prelude::*; + +/// The crate's own plugin: one line at `Startup`, to show it runs inside the client. +struct HelloPlugin; + +impl Plugin for HelloPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, || info!("a plugin on top of benilla is running")); + } +} + +fn main() -> AppExit { + // A launcher of its own passes the stamp its build script made; an example has no build + // script, so this names the version and profile alone. + let build = BuildId { + version: env!("CARGO_PKG_VERSION"), + profile: if cfg!(debug_assertions) { + "debug" + } else { + "release" + }, + ..Default::default() + }; + benilla_app::run_with(build, |app| { + app.add_plugins(HelloPlugin); + }) +} diff --git a/crates/benilla-app/src/crash.rs b/crates/benilla-app/src/crash.rs index eb17c7ff..6a237060 100644 --- a/crates/benilla-app/src/crash.rs +++ b/crates/benilla-app/src/crash.rs @@ -16,8 +16,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::BuildId; -/// Chains the crash-report hook after the current one. Called once from [`crate::run`], before -/// the `App` exists, so a panic while plugins build is covered. +/// Chains the crash-report hook after the current one. Called once as the client launches +/// ([`crate::run`], [`crate::run_with`]), before the `App` exists, so a panic while plugins build +/// is covered. pub(crate) fn install(build: BuildId) { let started = Instant::now(); let previous = std::panic::take_hook(); @@ -139,12 +140,13 @@ mod tests { short: "0123456", date: "2026-09-16", profile: "debug", + extended: false, }; #[test] fn a_report_carries_the_build_the_place_the_payload_and_the_log() { let log = vec!["+ 1.000s INFO benilla_app::net: entered world".to_owned()]; - let text = render(&Report { + let mut report = Report { build: BUILD, unix: 1_800_000_000, uptime: Duration::from_millis(12_345), @@ -153,10 +155,10 @@ mod tests { payload: "index out of bounds: the len is 3 but the index is 7", backtrace: " 0: benilla_app::net::apply::apply_net_updates", log: &log, - }); + }; + let text = render(&report); for needle in [ - "sha 0123456789abcdef0123456789abcdef01234567", - "0.2.0+3 · 0123456 · 2026-09-16 · debug", + "build: 0.2.0+3 · 0123456 · 2026-09-16 · debug (sha 0123456789abcdef0123456789abcdef01234567)", "12.3 s after launch", "thread: main", "at: crates/benilla-app/src/net/apply.rs:100:5", @@ -167,6 +169,13 @@ mod tests { ] { assert!(text.contains(needle), "missing {needle:?} in:\n{text}"); } + // A client a crate on top of benilla extended says so in the build line. + report.build.extended = true; + let extended = render(&report); + assert!( + extended.contains("build: 0.2.0+3 · 0123456 · 2026-09-16 · debug · extended (sha "), + "{extended}" + ); let dir = std::env::temp_dir().join(format!("benilla-crash-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 3a02b5e5..23155d3d 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -190,8 +190,8 @@ mod world_state_ui; use bevy::prelude::*; -// The `benilla` launcher shim stamps the build id at compile time and hands it to [`run`]; -// re-exported so the shim needs no bevy dependency of its own. +// A launcher stamps the build id at compile time and hands it to [`run`] or [`run_with`]; +// re-exported so the `benilla` shim needs no bevy dependency of its own. pub use benilla_world::build_id::BuildId; /// The world viewer's entry point, the engine with no game attached, called by the /// `benilla-worldview` shim. @@ -201,6 +201,21 @@ pub use bevy::app::AppExit; /// Builds and runs the client app. `build` is the launcher's compile-time git stamp, passed in as /// data so the sha lives in the shim's fingerprint and a commit does not recompile this crate. pub fn run(build: BuildId) -> AppExit { + launch(build, None) +} + +/// [`run`], plus `extend` adding a crate's own plugins: how a feature 1.12.1 lacks is built on top +/// of benilla (`examples/extended_launcher.rs`), the build marked `extended` wherever it is named. +/// `extend` gets the client fully built and runs before the probe fleet, which sees what it adds. +pub fn run_with(build: BuildId, extend: impl FnOnce(&mut App)) -> AppExit { + launch(build, Some(Box::new(extend))) +} + +/// What a crate on top of benilla adds to the built app ([`run_with`]). +type Extension<'a> = Box; + +/// The one body behind [`run`] and [`run_with`], not generic, so a launcher compiles none of it. +fn launch(build: BuildId, extend: Option>) -> AppExit { // `WOW_HOVER_LOG_REPORT=` and `WOW_CAPTURE=list` print and exit before any setup. if let Ok(path) = std::env::var("WOW_HOVER_LOG_REPORT") { dev::report_recorded_hover_log(&path); @@ -216,6 +231,12 @@ pub fn run(build: BuildId) -> AppExit { return AppExit::Success; } + // Every line that names the build says whether a crate on top extended it. + let build = BuildId { + extended: extend.is_some(), + ..build + }; + // From here a panic leaves `benilla-config/Diagnostics/crash-.txt`; armed before the // `App` exists, so a panic while plugins build is reported too. crash::install(build); @@ -404,6 +425,11 @@ pub fn run(build: BuildId) -> AppExit { } } + // A crate on top of benilla adds its plugins here ([`run_with`]). + if let Some(extend) = extend { + extend(&mut app); + } + // The probe fleet, last so it observes the fully-built app; compiled out by // `--no-default-features`. app.add_plugins(dev::DevProbesPlugin); diff --git a/crates/benilla-app/tests/launcher_shape.rs b/crates/benilla-app/tests/launcher_shape.rs new file mode 100644 index 00000000..d51fb07c --- /dev/null +++ b/crates/benilla-app/tests/launcher_shape.rs @@ -0,0 +1,94 @@ +//! The launcher `examples/extended_launcher.rs` documents for a crate on top of benilla stays in +//! step with the workspace: cargo reads `[patch]` and `[profile]` from the root manifest only, so +//! the header repeats the root's, a missing patch builds a client that is silently not benilla's, +//! and a bevy other than the workspace's is a second bevy. The rest mirrors the `benilla` launcher. + +use std::path::Path; + +/// A manifest by its path from this crate's root. +fn manifest(rel: &str) -> toml::Table { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(rel); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) + .parse() + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +/// The example header's ```toml block, parsed. +fn documented() -> toml::Table { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/extended_launcher.rs"); + let text = std::fs::read_to_string(&path).unwrap(); + let block: Vec<&str> = text + .lines() + .map(|l| { + l.strip_prefix("//!") + .map(|l| l.strip_prefix(' ').unwrap_or(l)) + }) + .skip_while(|l| *l != Some("```toml")) + .skip(1) + .take_while(|l| *l != Some("```")) + .map(|l| l.expect("the ```toml block ends inside the header")) + .collect(); + assert!(!block.is_empty(), "no ```toml block in {}", path.display()); + block + .join("\n") + .parse() + .expect("the documented manifest parses") +} + +/// `t[k1][k2]…`, or a panic naming the path. +fn at<'a>(t: &'a toml::Table, keys: &[&str]) -> &'a toml::Value { + let (first, rest) = keys.split_first().unwrap(); + let mut v = t.get(*first).unwrap_or_else(|| panic!("no `{first}`")); + for k in rest { + v = v + .get(*k) + .unwrap_or_else(|| panic!("no `{}`", keys.join("."))); + } + v +} + +#[test] +fn the_documented_launcher_repeats_the_root_patches_profile_and_bevy() { + let doc = documented(); + let root = manifest("../../Cargo.toml"); + let patched = |t: &toml::Table| -> Vec { + let table = at(t, &["patch", "crates-io"]).as_table().unwrap(); + table.keys().cloned().collect() + }; + assert_eq!(patched(&doc), patched(&root), "[patch.crates-io]"); + for keys in [ + &["profile", "dev", "opt-level"][..], + &["profile", "dev", "package", "*", "opt-level"], + ] { + assert_eq!(at(&doc, keys), at(&root, keys), "{}", keys.join(".")); + } + assert_eq!( + at(&doc, &["dependencies", "bevy", "version"]), + at(&root, &["workspace", "dependencies", "bevy", "version"]), + "bevy's version" + ); + assert_eq!( + at(&doc, &["dependencies", "bevy", "default-features"]).as_bool(), + Some(false), + "bevy adds no features" + ); +} + +#[test] +fn the_documented_launcher_takes_benilla_app_as_the_benilla_launcher_does() { + let doc = documented(); + let shim = manifest("../benilla/Cargo.toml"); + for keys in [ + &["features", "dev"][..], + &["dependencies", "benilla-app", "default-features"], + ] { + assert_eq!(at(&doc, keys), at(&shim, keys), "{}", keys.join(".")); + } + assert!( + at(&doc, &["build-dependencies"]) + .get("benilla-buildstamp") + .is_some(), + "the build script's stamp" + ); +} diff --git a/crates/benilla-world/src/build_id.rs b/crates/benilla-world/src/build_id.rs index 03857dd4..d629ac33 100644 --- a/crates/benilla-world/src/build_id.rs +++ b/crates/benilla-world/src/build_id.rs @@ -1,13 +1,13 @@ //! Which build is running: the release and the commit the binary was built from, stamped at //! compile time by the launcher shims' build script (`benilla-buildstamp`) and passed to `run` as -//! the [`BuildId`] resource. It shows in the startup log line ([`banner`]), in crash reports and in -//! the debug panel's footer. +//! the [`BuildId`] resource, and whether a crate on top of benilla extended it. It shows in the +//! startup log line ([`banner`]), in crash reports and in the debug panel's footer. use bevy::prelude::*; /// The launcher shim's compile-time stamp. The git fields are empty when the build had no /// checkout or no `git` on `PATH`, which [`BuildId::summary`] reports as an unknown commit. -#[derive(Resource, Clone, Copy)] +#[derive(Resource, Clone, Copy, Default)] pub struct BuildId { /// The workspace version from `Cargo.toml`, which every release bumps. pub version: &'static str, @@ -22,6 +22,9 @@ pub struct BuildId { pub date: &'static str, /// The cargo profile directory: `debug`, `release` or `ship`. pub profile: &'static str, + /// Whether a crate on top of benilla added plugins through `benilla_app::run_with`. The entry + /// point sets it, so a launcher leaves it to `..Default::default()`. + pub extended: bool, } impl BuildId { @@ -35,9 +38,10 @@ impl BuildId { } } - /// The one-line build id: release, short sha, commit date and profile. + /// The one-line build id: release, short sha, commit date and profile, then `extended` when a + /// crate on top added plugins, so a report from that client reads apart from stock. pub fn summary(&self) -> String { - if self.short.is_empty() { + let stamp = if self.short.is_empty() { format!( "{} · unknown commit (built without a git checkout) · {}", self.release(), @@ -51,6 +55,11 @@ impl BuildId { self.date, self.profile ) + }; + if self.extended { + stamp + " · extended" + } else { + stamp } } } @@ -82,6 +91,7 @@ mod tests { short, date: "2026-09-27", profile: "release", + extended: false, } } @@ -127,4 +137,26 @@ mod tests { "0.2.0 · unknown commit (built without a git checkout) · release" ); } + + #[test] + fn an_extended_client_says_so_and_stock_reads_as_before() { + let stock = build("0.2.0", "v0.2.0-12-gb17be27", "b17be27"); + assert_eq!(stock.summary(), "0.2.0+12 · b17be27 · 2026-09-27 · release"); + assert_eq!( + BuildId { + extended: true, + ..stock + } + .summary(), + "0.2.0+12 · b17be27 · 2026-09-27 · release · extended" + ); + assert_eq!( + BuildId { + extended: true, + ..build("0.2.0", "", "") + } + .summary(), + "0.2.0 · unknown commit (built without a git checkout) · release · extended" + ); + } } diff --git a/crates/benilla-worldview/src/main.rs b/crates/benilla-worldview/src/main.rs index 07ef880b..ef17087f 100644 --- a/crates/benilla-worldview/src/main.rs +++ b/crates/benilla-worldview/src/main.rs @@ -12,5 +12,6 @@ fn main() -> benilla_world::AppExit { short: env!("BENILLA_GIT_SHORT"), date: env!("BENILLA_GIT_DATE"), profile: env!("BENILLA_PROFILE"), + ..Default::default() }) } diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index c9631222..f034c886 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -11,5 +11,6 @@ fn main() -> benilla_app::AppExit { short: env!("BENILLA_GIT_SHORT"), date: env!("BENILLA_GIT_DATE"), profile: env!("BENILLA_PROFILE"), + ..Default::default() }) } diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1b67ea2b..9ed98175 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -23,9 +23,10 @@ taken. ## What does not -- Features 1.12.1 does not have, and behaviour changed because it seems better. A deviation - from the reference is the maintainer's call and is recorded where it lives; a pull request is - not the place to propose one. +- Features 1.12.1 does not have, and behaviour changed because it seems better. Such a feature + lives in its own crate on top of benilla, started through `benilla_app::run_with` as in + `crates/benilla-app/examples/extended_launcher.rs`. A deviation from the reference is the + maintainer's call and is recorded where it lives; a pull request is not the place to propose one. - Anything from a WoW install: art, models, sounds, maps, data. The one exception is interface code (FrameXML and GlueXML), and only through the migration recipe in `docs/METHOD.md`. - Big or mixed changes. One change per pull request, small enough to read in one sitting.