Files
benilla-octo/crates/benilla-world/src/worldview.rs
T
SamandClaude Fable 5 6b980b9efb the player build: one binary that carries its content
- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module
- the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder
- the glue screens get a capture shutter, and the engine stops depending on the protocol crate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:18:02 +07:00

405 lines
17 KiB
Rust

//! `benilla-worldview` — **the engine with no game attached**, and the wall that keeps it that way.
//!
//! Decision 1160. `benilla-app` is being split: the world renderer becomes `benilla-world`, and the
//! game stands on it. A crate boundary alone cannot hold that line — in Bevy two systems couple
//! with no symbol crossing between them (a `Res<player::Player>` read needs that resource to exist
//! at *runtime*; move the type somewhere neutral and the crate graph is satisfied while the
//! coupling is fully intact). So the enforcer is a **second binary**: this one. It boots the engine
//! plugin set, spawns a free-fly camera, and flies over Elwynn — with no server, no login, no UI,
//! no player. Wire a game concept back into the engine and this stops working *that day, loudly*.
//!
//! It is also the world editor's first milestone: a window that loads a map and lets you fly
//! around is where `benilla-editor` starts.
//!
//! ## Reading this file
//!
//! The plugin list below is **the proposed cut line**, written down. Everything registered here is
//! claimed for `benilla-world`; everything in [`crate::run`] and not here is claimed for the game.
//! The `STUB` block is the spike's finding: the gameplay resources the engine reads *as data* and
//! which a stub therefore satisfies. Each stub is a line item on the work order — the engine should
//! end up not needing it (decision 1160, "the nine wires"), and until it doesn't, the stub says so
//! out loud rather than the coupling hiding inside a working game.
use bevy::camera::{PerspectiveProjection, Projection};
use bevy::core_pipeline::tonemapping::Tonemapping;
use bevy::input::mouse::AccumulatedMouseMotion;
use bevy::prelude::*;
use bevy::render::view::Hdr;
use bevy::window::{CursorGrabMode, PrimaryWindow};
use benilla_assets::coords::wow_to_bevy;
use crate::boot;
use crate::build_id::BuildId;
use crate::lighting::fog_range;
use crate::terrain_stream::SPAWN_XY;
use crate::thread_qos;
use benilla_assets::RenderConfig;
/// Where the viewer opens, in WoW world coords. Northshire, Elwynn — the same anchor the client
/// boots on ([`crate::terrain_stream::SPAWN_XY`]), so the two binaries stream the same tiles and a difference
/// between them is a difference in the *engine*, not in where they are standing.
const VIEW_START: (f32, f32) = SPAWN_XY;
/// Height above the spawn point the camera opens at, in yards.
const VIEW_START_HEIGHT: f32 = 60.0;
/// Near plane, in yards — the client's ([`crate::view::CAM_NEAR`]) value, kept in step by hand
/// until the camera itself moves engine-side (decision 1160, stage zero).
const NEAR: f32 = 0.1;
/// Vertical FOV in radians — vanilla's 90° horizontal at 4:3.
const FOVY: f32 = 1.221_730_5;
/// Build and run the world viewer. `build` is the launcher shim's compile-time git stamp, exactly
/// as for [`crate::run`].
pub fn run(build: BuildId) -> AppExit {
let mut app = App::new();
app.insert_resource(build);
// **The survey switch** (`WOW_WORLDVIEW_SURVEY=1`) — the split's instrument, and the reason the
// spike is one run instead of one rebuild per finding. Bevy's default error handler panics on
// the first system whose parameters don't validate, so a viewer missing N gameplay resources
// reports exactly one of them; downgraded to `warn`, a single run names all N and the log IS
// the work order (decision 1160). Off by default, because for the finished enforcer the panic
// is the point: wire a game concept back into the engine and this binary must stop, loudly.
if std::env::var("WOW_WORLDVIEW_SURVEY").as_deref() == Ok("1") {
warn!("worldview: SURVEY mode — unmet dependencies are warnings, not panics");
app.set_error_handler(bevy::ecs::error::warn);
}
// **The check** (`WOW_WORLDVIEW_CHECK[=seconds]`) — the survey, made into a gate. It collects
// every distinct fault instead of dying on the first, runs for a bounded time, prints the set
// and exits non-zero if it is non-empty. `scripts/gates.sh` runs this on every commit.
//
// This exists because the enforcer rotted. It is a binary whose entire purpose is to fail
// loudly the day a game concept is wired back into the engine — and it had been failing on
// frame one, unnoticed, because nothing ran it (decision 1164). A tripwire nobody trips is not
// a tripwire. The wall test measures the doorway on every `cargo test`; this measures the
// other half, the coupling that crosses no symbol at all.
let check = check_seconds();
if let Some(secs) = check {
app.set_error_handler(record_fault);
app.insert_resource(CheckDeadline(secs))
.add_systems(Update, end_check);
}
// The `mpq://` asset source must be registered BEFORE `AssetPlugin` (inside `DefaultPlugins`)
// builds — same order, same reason, same install resolver (1175) as the client's boot.
match benilla_formats::wow_data() {
Some(data_dir) => {
if let Err(e) = benilla_assets::register_mpq_source(&mut app, &data_dir) {
eprintln!("benilla-assets: mpq:// source unavailable ({e:#})");
}
}
None => eprintln!(
"benilla-worldview: no WoW install found — looked in {:?}",
benilla_formats::candidates()
),
}
let background = crate::bgwin::background_run();
app.add_plugins(boot::tuned_default_plugins(Window {
title: "benilla worldview".into(),
resolution: std::env::var("WOW_WIN")
.ok()
.and_then(|v| {
let (w, h) = v.split_once('x')?;
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
})
// Small + cornered for a run nothing photographs (decision 1148, the client's own
// rule): the check reads the error log, not the framebuffer.
.unwrap_or(if crate::bgwin::no_pixel_run() {
UVec2::new(640, 360)
} else {
UVec2::new(1600, 900)
})
.into(),
present_mode: if std::env::var("WOW_NOVSYNC").as_deref() == Ok("1") {
bevy::window::PresentMode::AutoNoVsync
} else {
bevy::window::PresentMode::default()
},
// Same rule as the client (decision 0703): an instrumented run never fights the
// director's screen.
focused: !background,
window_level: if background {
bevy::window::WindowLevel::AlwaysOnBottom
} else {
bevy::window::WindowLevel::Normal
},
..default()
}))
.add_plugins(thread_qos::ThreadQosPlugin)
.add_plugins(crate::bgwin::BgWinPlugin);
// **The cut line**, and the whole of it: everything `benilla-world` will own, in one name
// (decision 1164, `crate::world_plugins`). This binary and the client add the identical group,
// so a divergence between them is no longer possible to write by accident — which is what the
// hand-kept twin list here was for.
app.add_plugins(crate::world_plugins::WorldPlugins);
stubs(&mut app);
app.add_plugins(plugin);
// Registered AFTER `AssetPlugin` (they go into the live `AssetServer`) — the client's order.
benilla_assets::register_asset_loaders(&mut app);
let exit = app.run();
match check {
Some(_) => report_check(exit),
None => exit,
}
}
/// `WOW_WORLDVIEW_CHECK` — unset is off; bare (or unparseable) is [`CHECK_SECS_DEFAULT`]; a number
/// is that many seconds. Long enough that the asset chain opens and the first tiles stream, which
/// is when the streaming half of the engine first runs.
fn check_seconds() -> Option<f32> {
let v = std::env::var("WOW_WORLDVIEW_CHECK").ok()?;
Some(v.trim().parse().unwrap_or(CHECK_SECS_DEFAULT))
}
/// How long the check runs when it is not given a number. Ten seconds is past the asset chain,
/// past `Startup`, and into streamed tiles on a warm cache — every system in the engine set has
/// been offered to the executor by then, which is what validates its parameters.
const CHECK_SECS_DEFAULT: f32 = 10.0;
/// Wall-clock seconds the check runs for.
#[derive(Resource)]
struct CheckDeadline(f32);
/// Every distinct fault the check saw, by the ECS construct that raised it. A `static` because
/// Bevy's error handler is a bare `fn` pointer and cannot capture.
static FAULTS: std::sync::Mutex<std::collections::BTreeSet<String>> =
std::sync::Mutex::new(std::collections::BTreeSet::new());
/// The check's error handler: record, warn, carry on — so one run names every fault rather than
/// the first one.
fn record_fault(error: bevy::ecs::error::BevyError, ctx: bevy::ecs::error::ErrorContext) {
let entry = format!("{} `{}`: {error}", ctx.kind(), ctx.name());
warn!("worldview: {entry}");
FAULTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(entry);
}
fn end_check(
time: Res<Time<bevy::time::Real>>,
deadline: Res<CheckDeadline>,
mut exit: MessageWriter<AppExit>,
) {
if time.elapsed_secs() >= deadline.0 {
exit.write(AppExit::Success);
}
}
/// Print the check's verdict and turn it into a process exit code — the whole point of the mode.
fn report_check(exit: AppExit) -> AppExit {
let faults = FAULTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if faults.is_empty() {
println!("WORLDVIEW_CHECK ok — the engine booted and ran with no game attached");
return exit;
}
for f in faults.iter() {
println!("WORLDVIEW_CHECK fault {f}");
}
println!(
"WORLDVIEW_CHECK {} fault(s) — a game concept is wired into the engine, or an engine \
fact is parked on the game side. Decision 1160.",
faults.len()
);
AppExit::error()
}
/// **What the engine still needs told, and nothing more.**
///
/// This used to be five stubs — the 1160 spike's whole finding: a fake `Player` mirrored from the
/// camera every frame, the game's session enum asserted, `ServerTime`, the loading screen, the
/// UI's pointer bool. Every one of them was the engine reaching across the line for a fact, and
/// each retired by the edit its record named rather than by growing a nicer stub:
///
/// - "where is the viewer" → `terrain_stream::ViewFocus` + `view::Viewer`, which the engine
/// defaults, so a program with no avatar follows its camera.
/// - "what time is it" → `lighting::WorldTime`, defaulting to noon.
/// - "is the cover up" → `view::Viewer::world_covered`, defaulting to no.
/// - "is the pointer over the UI" → the engine stopped asking; `cursor` and `bindings` own it.
///
/// What is left is one bit, and it is configuration rather than a stub: **is there a world**.
///
/// ## What this function CANNOT prove — read this before trusting it
///
/// A stub existed here because something *panicked*. Coupling that never panics is invisible to
/// this binary: an ordering edge onto an unregistered system is silently dropped by Bevy, an
/// `Option<Res<…>>` read just sees `None`, and a query filtered on a component nobody spawns
/// simply matches nothing. All three classes were found by *static* sweeps, not by running this —
/// the panic list is a floor on the work order, never a ceiling (decision 1163).
fn stubs(app: &mut App) {
// ── The world-existence gate ──────────────────────────────────────────────────────────────
// The viewer's world is permanently live. This used to assert the *game's* session state
// (`char_select::ClientState::InWorld`) because the engine read it directly; since 1160's
// wire (b) the engine owns a one-bit `WorldLive` that whatever composes it writes, so the
// viewer just says yes.
app.insert_resource(crate::schedule::WorldLive(true));
}
/// The viewer itself: the free-fly camera and its controller.
fn plugin(app: &mut App) {
app.add_systems(
Startup,
spawn_view_camera.after(benilla_assets::AssetSet::Open),
)
.add_systems(Update, fly.in_set(crate::schedule::WorldStage::Input));
// `WOW_WORLDVIEW_SHOT=<png>` (at `WOW_WORLDVIEW_SHOT_AT` seconds, default 20) writes one frame
// and exits. The client's own live shot (`capture::LiveShotPlugin`) can't serve here: its
// subject gate reads `SelfPlayer`, the name cache and the net writer, none of which the engine
// has. This is the viewer's own — deliberately gateless, because a viewer has no subject to be
// aimed at, only a scene. Post-split it is how `benilla-world` gets a visual regression
// baseline of its own, with no game in the frame to move underneath it.
if std::env::var("WOW_WORLDVIEW_SHOT").is_ok() {
app.add_systems(Update, shoot_and_exit);
}
}
/// The one-shot frame writer behind `WOW_WORLDVIEW_SHOT` — fires once, then gives the screenshot
/// observer a couple of seconds to reach the disk before exiting (the write is asynchronous; an
/// immediate exit is how a "clean run" ends with no PNG, decision 0743).
fn shoot_and_exit(
time: Res<Time>,
mut commands: Commands,
mut fired_at: Local<Option<f32>>,
mut exit: MessageWriter<AppExit>,
) {
let at = std::env::var("WOW_WORLDVIEW_SHOT_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20.0);
match *fired_at {
None if time.elapsed_secs() >= at => {
let path = std::env::var("WOW_WORLDVIEW_SHOT").unwrap_or_default();
info!("worldview: writing {path}");
commands
.spawn(bevy::render::view::screenshot::Screenshot::primary_window())
.observe(bevy::render::view::screenshot::save_to_disk(path));
*fired_at = Some(time.elapsed_secs());
}
Some(t) if time.elapsed_secs() >= t + 2.0 => {
exit.write(AppExit::Success);
}
_ => {}
}
}
/// The free-fly camera. The client's own `FlyCam` is gameplay-side today and comes over in stage
/// zero (decision 1160); until it does, this is the viewer's own — deliberately the *minimum* that
/// proves the engine renders, not a second implementation to keep in step.
#[derive(Component)]
struct ViewCam {
yaw: f32,
pitch: f32,
speed: f32,
}
fn spawn_view_camera(mut commands: Commands, config: Option<Res<RenderConfig>>) {
let start = wow_to_bevy([VIEW_START.0, VIEW_START.1, 100.0]);
let far = config
.map(|c| (fog_range(c.tile_radius).1 + 800.0).max(3000.0))
.unwrap_or(3000.0);
commands.spawn((
Camera3d::default(),
crate::view::WorldCamera,
match std::env::var("WOW_MSAA").ok().as_deref() {
Some("off") | Some("0") | Some("1") => bevy::render::view::Msaa::Off,
Some("2") => bevy::render::view::Msaa::Sample2,
Some("8") => bevy::render::view::Msaa::Sample8,
_ => bevy::render::view::Msaa::Sample4,
},
Projection::from(PerspectiveProjection {
far,
near: NEAR,
fov: FOVY,
..default()
}),
Hdr,
Tonemapping::None,
crate::ffx_glow::FfxGlow::WORLD,
Transform::from_translation(start + Vec3::new(0.0, VIEW_START_HEIGHT, VIEW_START_HEIGHT))
.looking_at(start, Vec3::Y),
ViewCam {
yaw: 0.0,
pitch: -0.5,
speed: 100.0,
},
));
}
/// WASD + Space/C fly, right-drag look, Ctrl boost, scroll to change speed. The editor's
/// navigation, not the game's.
fn fly(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
motion: Res<AccumulatedMouseMotion>,
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
mut window: Query<&mut bevy::window::CursorOptions, With<PrimaryWindow>>,
mut cam: Query<(&mut Transform, &mut ViewCam)>,
) {
let Ok((mut xf, mut cam)) = cam.single_mut() else {
return;
};
let looking = buttons.pressed(MouseButton::Right) || buttons.pressed(MouseButton::Left);
if let Ok(mut cursor) = window.single_mut() {
let want = if looking {
CursorGrabMode::Locked
} else {
CursorGrabMode::None
};
if cursor.grab_mode != want {
cursor.grab_mode = want;
cursor.visible = !looking;
}
}
if looking {
const LOOK: f32 = 0.003;
cam.yaw -= motion.delta.x * LOOK;
cam.pitch = (cam.pitch - motion.delta.y * LOOK).clamp(-1.54, 1.54);
}
xf.rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
for ev in wheel.read() {
cam.speed = (cam.speed * (1.0 + ev.y * 0.1)).clamp(1.0, 2000.0);
}
let mut dir = Vec3::ZERO;
if keys.pressed(KeyCode::KeyW) {
dir += *xf.forward();
}
if keys.pressed(KeyCode::KeyS) {
dir += *xf.back();
}
if keys.pressed(KeyCode::KeyA) {
dir += *xf.left();
}
if keys.pressed(KeyCode::KeyD) {
dir += *xf.right();
}
if keys.pressed(KeyCode::Space) {
dir += Vec3::Y;
}
if keys.pressed(KeyCode::KeyC) {
dir -= Vec3::Y;
}
if dir != Vec3::ZERO {
let boost = if keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight) {
5.0
} else {
1.0
};
xf.translation += dir.normalize() * cam.speed * boost * time.delta_secs();
}
}