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)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 12:05:27 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 476da9b2f0
commit 8d31e9e91e
8 changed files with 262 additions and 16 deletions
@@ -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);
})
}
+15 -6
View File
@@ -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);
+28 -2
View File
@@ -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<dyn FnOnce(&mut App) + 'a>;
/// The one body behind [`run`] and [`run_with`], not generic, so a launcher compiles none of it.
fn launch(build: BuildId, extend: Option<Extension<'_>>) -> AppExit {
// `WOW_HOVER_LOG_REPORT=<csv>` 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-<unix>.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);
@@ -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<String> {
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"
);
}
+37 -5
View File
@@ -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"
);
}
}
+1
View File
@@ -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()
})
}
+1
View File
@@ -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()
})
}
+4 -3
View File
@@ -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.