benilla-world: the engine becomes its own crate

- the world engine splits from the game into a benilla-world crate — 112 files, and the wall is ratcheted to zero names of the game inside the engine
- the engine's public doorway is counted and gated at 32 items, and the render materials, shaders and asset cache move down to benilla-assets
- the game's files leave the engine's asset root — game:// is its own space — and a spike binary boots the engine with no game attached
- weather rides along: snowfall emission is denominated in the reference's frame cap, and a long teleport retires the stranded precipitation field instead of leaving it behind

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:13:44 +07:00
co-authored by Claude Fable 5
parent 6c6a0c9b88
commit bdef9cd790
316 changed files with 9840 additions and 6121 deletions
Generated
+31
View File
@@ -572,6 +572,7 @@ name = "benilla"
version = "0.1.0"
dependencies = [
"benilla-app",
"benilla-buildstamp",
]
[[package]]
@@ -593,6 +594,7 @@ dependencies = [
"benilla-protocol",
"benilla-ui",
"benilla-wmo",
"benilla-world",
"bevy",
"bevy_egui",
"bytemuck",
@@ -636,6 +638,10 @@ dependencies = [
"texpresso",
]
[[package]]
name = "benilla-buildstamp"
version = "0.1.0"
[[package]]
name = "benilla-bytes"
version = "0.1.0"
@@ -729,6 +735,31 @@ dependencies = [
"benilla-bytes",
]
[[package]]
name = "benilla-world"
version = "0.1.0"
dependencies = [
"avian3d",
"benilla-assets",
"benilla-formats",
"benilla-protocol",
"benilla-wmo",
"bevy",
"bytemuck",
"image",
"libc",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
[[package]]
name = "benilla-worldview"
version = "0.1.0"
dependencies = [
"benilla-buildstamp",
"benilla-world",
]
[[package]]
name = "bevy"
version = "0.18.1"
+1
View File
@@ -50,6 +50,7 @@ crossbeam-channel = "0.5"
benilla-assets = { path = "../benilla-assets" }
benilla-formats = { path = "../benilla-formats" }
benilla-protocol = { path = "../benilla-protocol" }
benilla-world = { path = "../benilla-world" }
# The CVar config diff (`benilla/config.toml`, decision 0954): serde derive on the persisted
# shape, toml as its format. (serde_json left with the era manifest — 0978.)
serde = { version = "1", features = ["derive"] }
+13 -15
View File
@@ -23,7 +23,7 @@
//! ownerless zone** (nil is structural failure only); `factionName` = FactionGroup.dbc's
//! localized Name for the zone's mask bit; pvpType is never "arena"; realm type never enters.
//! - **The indoor bit** is the player's faces-only down-ray verdict
//! ([`crate::wmo_portal::CurrentAreaInterior`] — wow-re `zonetext-indoor-bit.md`, the CGLight
//! ([`benilla_world::wmo_portal::CurrentAreaInterior`] — wow-re `zonetext-indoor-bit.md`, the CGLight
//! node's `+0x90` bit 0): indoors ⇔ the nearest surface below is a WMO face whose group lacks
//! MOGP `0x8` EXTERIOR. The abbey yard is terrain-below ⇒ outdoors; the flip is the doorway.
//! - **The indoor naming** (`0x67e670` (d), byte-pinned by (d-ii)): while indoors and the hit
@@ -55,11 +55,9 @@ use bevy::prelude::*;
use benilla_formats::AreaTableCatalog;
use benilla_ui::script::UiScript;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::net::{ObjectStore, SelfPlayer};
use crate::target::Factions;
use crate::terrain_stream::CurrentArea;
use crate::wmo_portal::CurrentAreaInterior;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
/// The shared `AreaTable.dbc` catalog: id → (name, parent zone, flags, faction mask). Loaded once
/// at Startup; absent if the DBC failed to read (consumers take `Option` and go quiet).
@@ -132,9 +130,7 @@ fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> {
#[allow(clippy::too_many_arguments)]
fn feed_zone_events(
script: Option<NonSendMut<UiScript>>,
area: Res<CurrentArea>,
interior: Res<CurrentAreaInterior>,
wmo_areas: Option<Res<crate::sound::interior::WmoAreas>>,
world: benilla_world::world_point::WorldPoint,
areas: Option<Res<AreaTableRes>>,
factions: Option<Res<Factions>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
@@ -143,7 +139,7 @@ fn feed_zone_events(
let (Some(mut script), Some(areas)) = (script, areas) else {
return;
};
let Some(leaf) = area.0 else { return };
let Some(leaf) = world.area() else { return };
let Some(leaf_row) = areas.0.get(leaf) else {
// An id the catalog doesn't know stays unpublished (keep the last real state, like the
// resolver itself does for areaId 0).
@@ -157,7 +153,7 @@ fn feed_zone_events(
leaf_row.zone_id
};
let real_zone_text = areas.0.name(zone).unwrap_or_default().to_string();
let indoor = interior.0.is_some();
let indoor = world.area_interior().is_some();
// The outdoor texts — the locals `0x67e670` starts from.
let mut zone_text = real_zone_text.clone();
@@ -171,7 +167,7 @@ fn feed_zone_events(
// holds; else the default-row name (query A) may override the zone slot and the group-row
// name (query B) re-populates the subzone — both EXACT-key lookups (no name-set retry).
let mut wmo_group = 0u32;
if let Some((k, cat)) = interior.0.zip(wmo_areas.as_ref()) {
if let Some((k, group, default)) = world.area_interior_rows() {
// The dedup key: the hit GROUP's identity (the client's `[groupRec+0x7c]` group index,
// nonzero-gated — (d-ii)). We key the MOGP uniqueID scoped by WMO id: it changes exactly
// when the hit group does, and — a named divergence — never aliases across two adjacent
@@ -184,8 +180,6 @@ fn feed_zone_events(
return; // the dedup: same WMO group ⇒ the whole updater is skipped
}
cache.wmo_id = k.wmo_id;
let group = cat.0.group_row(k.wmo_id, k.name_set, k.group_area_id);
let default = cat.0.default_row(k.wmo_id, k.name_set);
// Query A (the whole-WMO −1 row): override the zone name + null the subzone — only when
// the resolved name is non-empty and differs from the current subzone (the abbey skip).
// An EXISTING-but-unnamed row falls back to an AreaTable name — the row's own
@@ -193,7 +187,7 @@ fn feed_zone_events(
// "Ironforge"; the client's area-0 arm reads the raw terrain areaId at the node — the
// current leaf equals it in that arm, since an area-0 row also never overrode the leaf).
// A MISSING row never overrides (the client's `""` sentinel bypass).
let a_name = default.map(|d| {
let a_name = default.as_ref().map(|d| {
if !d.name.is_empty() {
d.name.clone()
} else if d.area_table_id != 0 {
@@ -213,7 +207,11 @@ fn feed_zone_events(
}
}
// Query B (the hit group's own row): re-populate the subzone (empty/missing = leave it).
if let Some(b) = group.map(|r| r.name.as_str()).filter(|n| !n.is_empty()) {
if let Some(b) = group
.as_ref()
.map(|r| r.name.as_str())
.filter(|n| !n.is_empty())
{
subzone_text = b.to_string();
}
}
@@ -308,7 +306,7 @@ impl Plugin for AreaPlugin {
// spurious big splash.
feed_zone_events
.after(crate::ui_script::UiInput)
.after(crate::terrain_stream::AreaAuthoritySet),
.after(benilla_world::terrain_stream::AreaAuthoritySet),
);
}
}
+3 -3
View File
@@ -50,11 +50,11 @@ use benilla_assets::coords::bevy_to_wow;
use benilla_formats::AreaTriggerCatalog;
use bevy::prelude::*;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::net::{ClientCommand, NetCommands};
use crate::player::Player;
use crate::schedule::WorldStage;
use crate::world_map::CurrentMap;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use benilla_world::world_map::CurrentMap;
/// The `AreaTrigger.dbc` catalog, bucketed by map. Absent when the client data didn't load — the
/// check then does nothing, like every other data-driven system here.
-589
View File
@@ -1,589 +0,0 @@
//! **Within-map art residency** — the distance half of decision 0729's map-scope teardown.
//!
//! 0729 gave the world-art dedup caches a *reset point*: a cross-map transition clears them, and the
//! loading screen pays for the rebuild. B131 is the case that argument does not reach — a long flight
//! **inside** one map never fires that transition, so nothing ever evicts and `mats`/`images` ratchet
//! for as long as you stay on a continent (decision 0785's open half: 26.8 k materials / 2.8 k images
//! after ten minutes here; 3.2 → 4.9 GiB of VRAM on the reporter's tour).
//!
//! This module turns clear-on-*transition* into clear-on-*distance*: every cache entry remembers the
//! view focus it was last used from, and a sweep drops what was last wanted more than
//! [`DEFAULT_RADIUS_YD`] away. So residency is bounded by *where you are*, not by how long the process
//! has run, and the map-change clear stays exactly as it was — the hard reset on top of the soft one.
//!
//! **Distance decides what expires; a dwell floor decides how soon.** [`MIN_DWELL_SECS`] exists
//! because the radius alone is sized for gameplay speed — the detached free-fly camera crosses it in
//! five seconds and would lose a city's dedup only to rebuild it on the way back (found on the
//! director's first real run; see the constant).
//!
//! **Distance, not a TTL**, and the difference is safety rather than taste: a time-expired entry can
//! be one the streamer is about to ask for again from ten yards away, and dropping it costs a
//! duplicate; a distance-expired entry is one whose tiles unloaded long ago. The radius is therefore
//! floored at the streamer's own reach ([`radius_floor`]) so eviction can never outrun the thing that
//! placed the art.
//!
//! **Two properties worth knowing before reading the code**, both of which buy the design its
//! smallness:
//!
//! 1. **Stamping needs no plumbing.** A *use* (a [`SpatialCache::fetch`] hit or an insert) clears the
//! stamp to `None` — "used since the last sweep" — and the *sweep* is what writes today's focus
//! onto every `None`. So no call site has to know where the camera is, and a cache nothing sweeps
//! (the `Local<MaterialCache>`s in the glue booth and the portrait bake) simply never expires.
//! 2. **Nothing needs ordering.** The stamp is at worst one sweep interval stale (≈25 yd at flight
//! speed, against a 2.6 km radius) and `due` being read a frame before it is set only moves the
//! sweep one frame. There are no `.before`/`.after` constraints anywhere in this module.
//!
//! The failure mode if the radius were ever too small: evicting the dedup for art that is still
//! drawn. That is not a visual bug and not a leak — the drawn entity holds its own handles, and the
//! next spawn of that art re-creates one duplicate material which becomes the new cache entry. It
//! costs a lost batch, and it self-heals. That asymmetry (too-eager = mild and transient, too-lazy =
//! the bug we are fixing) is why the floor is a clamp and not a warning.
use std::collections::HashMap;
use std::hash::Hash;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use bevy::time::Real;
use benilla_formats::TILE_SIZE;
/// Wall seconds between sweeps ([`Real`], not the virtual clock — this is a housekeeping cadence,
/// and decision 0789's reading applies: "what time is it" is not "how much world time to advance").
/// One `retain` over ~30 k entries a second is not measurable; the interval exists so it is not run
/// per frame, and it doubles as the stamp's resolution.
const SWEEP_SECS: f32 = 1.0;
/// Default eviction radius in yards — five ADT tiles (2667 yd). Chosen as the smallest round
/// multiple of the tile grid comfortably clear of [`radius_floor`] at the default
/// `$WOW_TILE_RADIUS` (2 ⇒ 2419 yd): far enough that art still on screen is never evicted, close
/// enough that leaving one city for another recovers it (Stormwind → Booty Bay is ~5500 yd,
/// Ironforge → Stormwind ~4250).
const DEFAULT_RADIUS_YD: f32 = 5.0 * TILE_SIZE;
/// The **dwell floor**: nothing is dropped within this many wall seconds of its last use, however far
/// away you have gone. Distance decides *what* expires; this decides how soon it is allowed to.
///
/// Found by the director's first real run, and it is the case the distance rule alone gets wrong. The
/// detached free-fly camera (`F`, `FlyCam::speed` 40–100 yd/s, ×5 on Ctrl) travels at up to 500 yd/s —
/// so it crosses the 2667 yd radius in **five seconds**, and a sweep then drops a whole city's dedup
/// that the same camera re-creates on the way back. Their 48-second leg evicted 25 570 entries and
/// rebuilt `pmat` 3086 → 15354 doing exactly that: the eviction was *correct* by the radius and
/// useless in effect, because nothing had finished being wanted.
///
/// 30 s covers a 15 000 yd round trip at boosted fly speed. Under gameplay speed it never binds at
/// all (a gryphon needs ~107 s to cover the radius), so B131's own case is unchanged — this only
/// stops the thrash at speeds the radius was never sized for. Residency stays bounded either way:
/// what you hold is at most the art of the last 30 seconds of travel, which does not grow with how
/// long the process has run.
const MIN_DWELL_SECS: f32 = 30.0;
/// The smallest radius that cannot evict art the streamer is still holding: the far **corner** of
/// the `(2r+1)²` tile block it keeps resident, plus a tile of margin.
///
/// A stamp records where *the viewer* was when the art was last wanted, not where the art is — so an
/// entry can be up to a streaming radius "behind" the art it belongs to. Flooring the radius at the
/// block's circumscribed radius means you have to leave the art's neighbourhood entirely before its
/// dedup expires.
fn radius_floor(tile_radius: u32) -> f32 {
(tile_radius as f32 + 0.5) * TILE_SIZE * std::f32::consts::SQRT_2 + TILE_SIZE
}
/// Resolve `$WOW_ART_RADIUS` (yards; `0` or negative ⇒ eviction off, the A/B leg that reproduces the
/// unbounded behaviour on a fixed build) against the streamer's reach. An explicitly-requested radius
/// below [`radius_floor`] is raised **and** warned about, rather than silently obeyed: a knob that
/// quietly means something else than it says is how decision 0789 happened.
fn radius_from_env(tile_radius: u32) -> f32 {
let floor = radius_floor(tile_radius);
match std::env::var("WOW_ART_RADIUS")
.ok()
.and_then(|s| s.parse::<f32>().ok())
{
Some(r) if r <= 0.0 => 0.0,
Some(r) if r < floor => {
warn!(
"art-scope: WOW_ART_RADIUS={r} is inside the streamer's own reach ({floor:.0} yd \
at tile radius {tile_radius}) — using {floor:.0}"
);
floor
}
Some(r) => r,
None => DEFAULT_RADIUS_YD.max(floor),
}
}
/// Horizontal (tile-plane) distance between two wow-space points. Height is deliberately ignored:
/// residency is a 2D question — the streamer's window is a tile square, and art 200 yd below a
/// gryphon is in front of you, not away from you.
fn plan_dist(a: [f32; 3], b: [f32; 3]) -> f32 {
let (dx, dy) = (a[0] - b[0], a[1] - b[1]);
(dx * dx + dy * dy).sqrt()
}
/// A dedup cache whose entries remember **where they were last used from**, so a sweep can drop the
/// art of a place you have left. A drop-in for the `HashMap<K, Handle<_>>` these caches used to be:
/// the only difference at a call site is that a lookup is [`Self::fetch`] (it counts as a use).
///
/// `None` in the stamp means "used since the last sweep" — see the module docs for why that inverted
/// spelling is what removes all the plumbing.
pub(crate) struct SpatialCache<K, V> {
map: HashMap<K, (V, Option<Stamp>)>,
}
/// Where and when an entry was last wanted: the view focus at the first sweep after its last use.
/// Both halves are load-bearing — `at` decides whether it is far enough to expire, `t` whether it has
/// been idle long enough to be allowed to ([`MIN_DWELL_SECS`]).
#[derive(Clone, Copy)]
struct Stamp {
at: [f32; 3],
t: f32,
}
impl<K, V> Default for SpatialCache<K, V> {
fn default() -> Self {
Self {
map: HashMap::new(),
}
}
}
impl<K: Eq + Hash, V> SpatialCache<K, V> {
/// Live entry count — what the census reports and the map-change evictors log.
pub(crate) fn len(&self) -> usize {
self.map.len()
}
/// Drop everything (the map-scope teardown, decision 0729 — still the hard reset).
pub(crate) fn clear(&mut self) {
self.map.clear();
}
/// Fetch a deduped value, **counting the hit as a use** so it restarts its distance grace.
/// Clones out rather than lending a reference: every caller wants an owned `Handle` anyway, and
/// ending the borrow at the call is what lets the miss path insert in the same expression.
pub(crate) fn fetch(&mut self, key: &K) -> Option<V>
where
V: Clone,
{
let slot = self.map.get_mut(key)?;
slot.1 = None;
Some(slot.0.clone())
}
/// Install a freshly-built value, counted as a use.
pub(crate) fn insert(&mut self, key: K, value: V) {
self.map.insert(key, (value, None));
}
/// Get-or-build in place, counted as a use — for the caches whose value is too big to clone out
/// ([`crate::clutter::ClutterGeometry`]'s decoded submeshes).
pub(crate) fn or_insert_with(&mut self, key: K, build: impl FnOnce() -> V) -> &mut V {
let slot = self.map.entry(key).or_insert_with(|| (build(), None));
slot.1 = None;
&mut slot.0
}
/// Stamp everything used since the last sweep at `focus`/`now`, then drop every entry that is
/// **both** more than `radius` yards away **and** idle for at least [`MIN_DWELL_SECS`]. Returns
/// how many were dropped.
///
/// The two conditions are not redundant. Distance alone expires art the moment a fast camera
/// pulls away from it, which is how the director's free-fly leg lost a city's dedup and rebuilt
/// it seconds later; the dwell floor is what makes "not immediate" true at any speed, not just at
/// gameplay speed.
pub(crate) fn scope(&mut self, focus: [f32; 3], radius: f32, now: f32) -> usize {
let before = self.map.len();
self.map.retain(|_, (_, stamp)| match *stamp {
None => {
*stamp = Some(Stamp { at: focus, t: now });
true
}
Some(s) => plan_dist(s.at, focus) <= radius || now - s.t < MIN_DWELL_SECS,
});
before - self.map.len()
}
}
/// Which cache a census row is about. Fixed set, in journal-column order — the CSV's columns only
/// ever grow at the end, so this enum's order is part of the file format.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ArtSlot {
/// `Placements::materials` — streamed doodad/WMO submesh materials.
PlaceMats,
/// `EntityMaterials` — creature/player/GameObject model materials.
EntityMats,
/// `SkinComposites` — composited character body atlases (decision 0044).
Skins,
/// `WorldAssets::model_materials` — the ground-clutter material dedup.
ClutterMats,
/// `WorldAssets::textures` — decoded world BLPs by (path, wrap).
Textures,
/// `ClutterGeometry` — CPU submesh copies of every detail M2 decoded.
ClutterGeo,
}
impl ArtSlot {
/// Every slot, in journal-column order.
pub(crate) const ALL: [ArtSlot; 6] = [
ArtSlot::PlaceMats,
ArtSlot::EntityMats,
ArtSlot::Skins,
ArtSlot::ClutterMats,
ArtSlot::Textures,
ArtSlot::ClutterGeo,
];
/// The journal's column name for this slot.
pub(crate) fn column(self) -> &'static str {
match self {
ArtSlot::PlaceMats => "pmat",
ArtSlot::EntityMats => "emat",
ArtSlot::Skins => "skin",
ArtSlot::ClutterMats => "cmat",
ArtSlot::Textures => "tex",
ArtSlot::ClutterGeo => "cgeo",
}
}
fn idx(self) -> usize {
match self {
ArtSlot::PlaceMats => 0,
ArtSlot::EntityMats => 1,
ArtSlot::Skins => 2,
ArtSlot::ClutterMats => 3,
ArtSlot::Textures => 4,
ArtSlot::ClutterGeo => 5,
}
}
}
/// Per-cache residency, refreshed by every [`ArtScope::apply`] call — the instrument that localizes
/// a residency ratchet to **one cache** in a single journal row, instead of the run-length probe that
/// found B131. `Assets<T>::len()` says "materials are growing"; this says which map is holding them.
#[derive(Resource, Default)]
pub(crate) struct ArtCensus {
live: [usize; ArtSlot::ALL.len()],
dropped: [usize; ArtSlot::ALL.len()],
}
impl ArtCensus {
/// Live entries in one cache as of its last sweep.
pub(crate) fn live(&self, slot: ArtSlot) -> usize {
self.live[slot.idx()]
}
/// Entries dropped by distance across every cache since the run began.
pub(crate) fn dropped_total(&self) -> usize {
self.dropped.iter().sum()
}
}
/// The sweep's shared state: the radius, this tick's focus, and whether this frame is a sweep frame.
#[derive(Resource, Default)]
pub(crate) struct ArtScopeState {
/// Eviction radius in yards; `0` ⇒ never evict. Zero until [`configure_art_scope`] runs, so
/// nothing can be dropped before the streamer's reach is known.
radius: f32,
/// The view focus, wow coords ([`crate::terrain_stream::view_focus`] — the same ladder the
/// streamer and the WDL ring use). `None` when there is no focus at all (no avatar, no camera),
/// which suspends both stamping and sweeping.
focus: Option<[f32; 3]>,
/// Set on the one frame per [`SWEEP_SECS`] that sweeps.
due: bool,
last_sweep: f32,
/// Wall seconds since app start, this frame — the clock the dwell floor is measured on.
now: f32,
}
impl ArtScopeState {
/// The view focus this frame, wow coords — what the sweep measures from. Exposed for the FPS
/// journal: its `x,y,z` are the *avatar*, which stands still through a whole detached free-fly, so
/// on that leg the position columns say nothing about where the art was being asked for. This is
/// the column that does.
pub(crate) fn focus(&self) -> Option<[f32; 3]> {
self.focus
}
}
/// What an owning module needs to scope its caches: the policy, plus the census to report into.
#[derive(SystemParam)]
pub(crate) struct ArtScope<'w> {
state: Res<'w, ArtScopeState>,
census: ResMut<'w, ArtCensus>,
}
impl ArtScope<'_> {
/// Sweep one cache (on sweep frames) and record its residency. Safe to call for a cache that is
/// empty, unconfigured, or in a run with eviction switched off — it degrades to a census read.
pub(crate) fn apply<K: Eq + Hash, V>(&mut self, cache: &mut SpatialCache<K, V>, slot: ArtSlot) {
let dropped = match self.state.focus {
Some(focus) if self.state.due && self.state.radius > 0.0 => {
cache.scope(focus, self.state.radius, self.state.now)
}
_ => 0,
};
let i = slot.idx();
self.census.live[i] = cache.len();
self.census.dropped[i] += dropped;
if dropped > 0 {
debug!(
"art-scope: dropped {dropped} {} entries beyond {:.0} yd ({} live)",
slot.column(),
self.state.radius,
cache.len()
);
}
}
}
/// Resolve the radius once the streamer's configuration exists (it decides the floor).
fn configure_art_scope(
mut state: ResMut<ArtScopeState>,
cfg: Option<Res<crate::assets::RenderConfig>>,
) {
let tile_radius = cfg.map(|c| c.tile_radius).unwrap_or(2);
state.radius = radius_from_env(tile_radius);
if state.radius > 0.0 {
info!(
"art-scope: within-map art evicts beyond {:.0} yd of the view focus",
state.radius
);
} else {
warn!("art-scope: eviction OFF ($WOW_ART_RADIUS=0) — within-map residency is unbounded");
}
}
/// Publish this frame's focus and decide whether it is a sweep frame. Deliberately unordered against
/// every consumer — see the module docs.
fn track_art_scope(
mut state: ResMut<ArtScopeState>,
time: Res<Time<Real>>,
player: Res<crate::player::Player>,
camera: Query<&Transform, With<crate::player::WorldCamera>>,
roster: Option<Res<crate::char_select::Roster>>,
) {
let cam = camera.single().ok().map(|c| c.translation);
state.focus = (player.active || cam.is_some()).then(|| {
crate::terrain_stream::view_focus(
&player,
cam,
roster
.as_deref()
.and_then(crate::char_select::Roster::pending_entry),
)
});
let now = time.elapsed_secs();
state.now = now;
state.due = now - state.last_sweep >= SWEEP_SECS;
if state.due {
state.last_sweep = now;
}
}
/// Owns the sweep policy + the census. The caches themselves stay private to their modules; each
/// registers its own three-line system that hands its caches to [`ArtScope::apply`].
pub(crate) struct ArtScopePlugin;
impl Plugin for ArtScopePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ArtCensus>()
.init_resource::<ArtScopeState>()
.add_systems(
Startup,
configure_art_scope.after(crate::assets::AssetSet::Open),
)
.add_systems(Update, track_art_scope);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// When the art is used and stamped.
const NOW: f32 = 1000.0;
/// A sweep past the dwell floor — the earliest moment distance is *allowed* to expire anything.
/// Every "…is dropped" assertion sweeps here, which is itself the dwell floor's regression test:
/// swap this back to `NOW` and three of them fail (verified).
const LATER: f32 = NOW + MIN_DWELL_SECS + 1.0;
const HOME: [f32; 3] = [0.0, 0.0, 0.0];
const FAR: [f32; 3] = [4000.0, 0.0, 0.0];
const R: f32 = DEFAULT_RADIUS_YD;
/// A fresh entry survives its first sweep — that is the sweep that *stamps* it. Without this the
/// grace period would depend on where in the second the art happened to load.
#[test]
fn the_first_sweep_stamps_rather_than_judges() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
// Swept from the far side immediately: the entry has no stamp yet, so it cannot be too far.
assert_eq!(c.scope(FAR, R, NOW), 0);
assert_eq!(c.len(), 1);
// Now it is stamped at FAR, so a later sweep from HOME drops it.
assert_eq!(c.scope(HOME, R, LATER), 1);
assert!(c.fetch(&1).is_none());
}
#[test]
fn art_you_walked_away_from_is_dropped() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
c.scope(HOME, R, NOW); // stamp at home
assert_eq!(
c.scope([R * 0.9, 0.0, 0.0], R, LATER),
0,
"inside the radius"
);
assert_eq!(c.scope(FAR, R, LATER), 1, "beyond it");
}
/// A hit restarts the grace, which is what keeps art in the direction of travel resident: the
/// same tree material is re-fetched by every new placement that streams in ahead of you.
#[test]
fn a_use_restarts_the_grace() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
c.scope(HOME, R, NOW);
for step in 1..20 {
let at = [step as f32 * R * 0.5, 0.0, 0.0];
assert_eq!(c.fetch(&1), Some(10));
// Swept well past the dwell floor every step, so what keeps it alive is the use.
assert_eq!(
c.scope(at, R, NOW + step as f32 * (MIN_DWELL_SECS + 1.0)),
0,
"re-used at step {step}"
);
}
}
/// `or_insert_with` builds once and counts as a use, like `fetch`.
#[test]
fn or_insert_with_builds_once_and_counts_as_a_use() {
let mut c: SpatialCache<u32, Vec<u32>> = SpatialCache::default();
let mut builds = 0;
for _ in 0..3 {
let v = c.or_insert_with(7, || {
builds += 1;
vec![1, 2, 3]
});
assert_eq!(v.len(), 3);
}
assert_eq!(builds, 1);
assert_eq!(
c.scope(FAR, R, LATER),
0,
"the last use restarted the grace"
);
}
/// **The director's free-fly case.** A camera at boosted fly speed crosses the radius in ~5 s; if
/// distance alone decided, the art it just streamed would be dropped and then rebuilt as the same
/// camera came back. Nothing expires inside the dwell floor however far away the focus goes.
#[test]
fn a_fast_camera_cannot_outrun_the_dwell_floor() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
c.scope(HOME, R, NOW);
// 500 yd/s (FlyCam 100 × the Ctrl boost 5) for five seconds: far past the radius already.
for s in 1..=5 {
let at = [500.0 * s as f32, 0.0, 0.0];
assert_eq!(
c.scope(at, R, NOW + s as f32),
0,
"dropped {s} s out — the free-fly thrash"
);
}
// Fly all the way back inside the radius and the entry is still the same one: no rebuild.
assert_eq!(c.scope(HOME, R, NOW + 6.0), 0);
assert_eq!(c.fetch(&1), Some(10));
// Stay away past the floor and it does expire — the bound is delayed, not removed.
c.scope(HOME, R, NOW + 7.0);
assert_eq!(c.scope(FAR, R, NOW + 7.0 + MIN_DWELL_SECS), 1);
}
/// Height is not distance: flying over a city must not evict it.
#[test]
fn altitude_is_not_distance() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
c.scope(HOME, R, NOW);
assert_eq!(c.scope([0.0, 0.0, 10_000.0], R, LATER), 0);
}
/// Eviction must never outrun the streamer: the default radius clears the far corner of the
/// resident tile block at the default `$WOW_TILE_RADIUS`, and the floor rises with it.
#[test]
fn the_radius_clears_the_streamers_own_reach() {
assert!(
DEFAULT_RADIUS_YD > radius_floor(2),
"default {DEFAULT_RADIUS_YD} must clear the floor {}",
radius_floor(2)
);
// The corner of the 5×5 block at tile radius 2 is ~1886 yd; the floor adds a tile.
assert!(
(radius_floor(2) - 2418.7).abs() < 1.0,
"{}",
radius_floor(2)
);
assert!(
radius_floor(4) > DEFAULT_RADIUS_YD,
"a wider window raises it"
);
}
/// With `$WOW_ART_RADIUS` unset (every ordinary run, and the test binary) the radius is the
/// default, floored — never zero, so a shipped build always bounds itself.
#[test]
fn an_unconfigured_run_still_bounds_itself() {
assert_eq!(radius_from_env(2), DEFAULT_RADIUS_YD.max(radius_floor(2)));
assert!(radius_from_env(8) >= radius_floor(8));
}
/// **The mechanism the whole design rests on**: dropping the cache's handle really does free the
/// asset. If `Assets<T>` kept the value alive past its last strong handle, eviction would cost us
/// the dedup and recover no memory — a failure indistinguishable, from the outside, from "the fix
/// did nothing". Checked here rather than assumed, because the *reason* B131's `images` column
/// never fell is precisely that these caches held the last handle.
#[test]
fn dropping_the_last_handle_frees_the_asset() {
let mut app = App::new();
app.add_plugins((
bevy::app::TaskPoolPlugin::default(),
bevy::asset::AssetPlugin::default(),
))
.init_asset::<Image>();
let mut cache: SpatialCache<u32, Handle<Image>> = SpatialCache::default();
let id = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
let handle = images.add(Image::default());
let id = handle.id();
cache.insert(1, handle);
id
};
app.update();
assert!(
app.world().resource::<Assets<Image>>().contains(id),
"held by the cache"
);
cache.scope(HOME, R, NOW); // stamp it here…
assert_eq!(cache.scope(FAR, R, LATER), 1, "…then walk away");
// The drop is processed by `Assets::track_assets` on a later frame, not at the drop itself.
for _ in 0..3 {
app.update();
}
assert!(
!app.world().resource::<Assets<Image>>().contains(id),
"the image outlived its last handle — eviction would free nothing"
);
}
/// A cache nobody sweeps (the `Local<MaterialCache>`s in the glue booth / portrait bake) expires
/// nothing: without a `scope` call the stamps stay `None` forever.
#[test]
fn an_unswept_cache_never_expires() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
assert_eq!(c.fetch(&1), Some(10));
assert_eq!(c.len(), 1);
}
}
+65 -39
View File
@@ -27,7 +27,7 @@
//! and player vtables: "players 1.0" was the data talking, not a type fork — §6) — and handed
//! to **`0x614f80` StartAlphaFade(target, 1000 ms)**, the same ramp block (`+0xec..0x100`) and
//! the same `clamp01(t)³` ease (`0x614a90`) our appear-fade already rides
//! ([`crate::model_fade::fade_alpha`]). Per frame `model+0x180 = master × fade`
//! ([`benilla_world::model_fade::fade_alpha`]). Per frame `model+0x180 = master × fade`
//! (`0x614baa → 0x710cb0`), which multiplies into the per-batch alpha (`0x707680`).
//! The recompute has a second, aura-free caller: the DISPLAYID watcher's refresh (`0x60abe0 →
//! 0x60ad9f`), which is how a display whose row says `CreatureModelAlpha < 255` — Ghost Wolf's
@@ -63,7 +63,7 @@
//!
//! **Proc 1 ships too** (decision 0812): its nodes live here ([`AuraNodes::tint`], head-node-wins as
//! the reference's `unit+0xce0` list is) and [`apply_aura_tint`] publishes the head to
//! [`crate::instance_tint`], the per-instance modulate channel keyed on the part's rig slot. Written
//! [`benilla_world::instance_tint`], the per-instance modulate channel keyed on the part's rig slot. Written
//! on change, never eased — the reference's own asymmetry (`unit+0xd04` change-detection straight to
//! `0x710cf0`, with none of the alpha's 1000 ms ramp).
//!
@@ -82,7 +82,7 @@
//! - a **rigged** one carries its OWN slot, because the vertex stage indexes the skin palette with
//! that same field and cannot borrow it (a spell-effect instance; since 0841, the seven shoulder
//! models that weld geometry to a billboard bone). [`chained_tint`] walks such an instance's
//! [`crate::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk
//! [`benilla_world::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk
//! `ModelAlphas` already does for alpha.
//!
//! **Proc 11 ships too** (decisions 0889 + 0891): the **freeze**. Its param is a playback *rate*
@@ -102,9 +102,9 @@ use benilla_protocol::EntityKind;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::interior::InteriorLit;
use crate::model_fade::{fade_alpha, FadeMaterials, PendingAppearFade, RenderFade};
use crate::terrain::WowModelMaterial;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::interior::InteriorLit;
use benilla_world::model_fade::{fade_alpha, FadeMaterials, PendingAppearFade, RenderFade};
/// The reference's aura-alpha ramp: `StartAlphaFade(target, **1000 ms**)` (`0x614f80` from
/// `0x60d180`'s recompute), eased `clamp01(t)³` by `0x614a90` — [`fade_alpha`] is that curve.
@@ -137,7 +137,7 @@ pub(crate) enum AuraNode {
/// The CharProc node lists of one unit — the ECS twin of the reference's per-unit `unit+0xb50`
/// (alpha) and `unit+0xce0` (tint) lists, plus the ramp `0x614f80` drives from their heads.
///
/// Lives on the **net entity root**, like [`crate::entity_shade::GroundShade`]: the reference has one
/// Lives on the **net entity root**, like [`benilla_world::entity_shade::GroundShade`]: the reference has one
/// of these per CGUnit and every attached model (held item, helm, shoulder) renders off the owner's,
/// which is also why [`apply_aura_alpha`] walks the root's whole descendant tree rather than its
/// direct children.
@@ -235,7 +235,7 @@ impl AuraNodes {
}
}
/// Publish every rig's body tint to the per-instance channel ([`crate::instance_tint`], decision
/// Publish every rig's body tint to the per-instance channel ([`benilla_world::instance_tint`], decision
/// 0812): the head node's RGB packed the reference's way, keyed on the unit's rig slot — the slot
/// each of its skinned parts already carries in its `MeshTag`, and which the fragment stage reads to
/// index the table.
@@ -250,17 +250,21 @@ impl AuraNodes {
/// went away, not a unit whose last tint aura dropped. (The despawn/rebuild edge is covered
/// structurally too, in `RigSkin`'s free hook.)
pub(crate) fn apply_aura_tint(
rigs: Query<(Entity, &crate::rig_palette::RigSkin)>,
chain: Query<(Option<&AuraNodes>, Option<&crate::model_fade::ParentModel>)>,
mut tints: ResMut<crate::instance_tint::InstanceTints>,
rigs: Query<(Entity, &benilla_world::rig_palette::RigSkin)>,
chain: Query<(
Option<&AuraNodes>,
Option<&benilla_world::model_fade::ParentModel>,
)>,
mut tints: ResMut<benilla_world::instance_tint::InstanceTints>,
mut probe_logged: Local<bool>,
) {
let probe = tint_probe();
let mut painted = 0usize;
for (root, rig) in &rigs {
let word = probe
.or_else(|| chained_tint(&chain, root))
.map_or(crate::instance_tint::IDENTITY, crate::instance_tint::pack);
let word = probe.or_else(|| chained_tint(&chain, root)).map_or(
benilla_world::instance_tint::IDENTITY,
benilla_world::instance_tint::pack,
);
tints.set(rig.slot, word);
painted += 1;
}
@@ -271,7 +275,7 @@ pub(crate) fn apply_aura_tint(
}
/// The tint a rig instance renders with: its own head node, else the nearest one **up its
/// [`crate::model_fade::ParentModel`] chain** — the reference's `0x714000` recursion, which composes
/// [`benilla_world::model_fade::ParentModel`] chain** — the reference's `0x714000` recursion, which composes
/// an attached model's colours onto its parent's computed ones (the same walk [`ModelAlphas`] does
/// for alpha, and the same law `mesh_tag` cites for why an item's parts carry their wearer's slot).
///
@@ -280,11 +284,14 @@ pub(crate) fn apply_aura_tint(
/// without the walk, a dwarf's Stoneform tint would stop at exactly the pauldrons the rig was added
/// for. A despawned link ends the walk.
fn chained_tint(
chain: &Query<(Option<&AuraNodes>, Option<&crate::model_fade::ParentModel>)>,
chain: &Query<(
Option<&AuraNodes>,
Option<&benilla_world::model_fade::ParentModel>,
)>,
instance: Entity,
) -> Option<[u8; 3]> {
let mut at = instance;
for _ in 0..crate::model_fade::MAX_MODEL_CHAIN {
for _ in 0..benilla_world::model_fade::MAX_MODEL_CHAIN {
let Ok((nodes, parent)) = chain.get(at) else {
return None;
};
@@ -417,14 +424,14 @@ pub(crate) fn apply_aura_anim_rate(
/// It prints the speeds precisely because the first version's bug was invisible without them: a `@0`
/// on the thaw line is a clip that will never advance again.
fn trace_clock_edge(what: &str, rig: Entity, player: &AnimationPlayer) {
if !crate::dbg_trace::enabled_for("aur") {
if !benilla_assets::trace::enabled_for("aur") {
return;
}
let clips: Vec<String> = player
.playing_animations()
.map(|(n, a)| format!("{}@{}", n.index(), a.speed()))
.collect();
crate::dbg_trace::line(
benilla_assets::trace::line(
"aur",
&format!("{what} rig={rig} clips=[{}]", clips.join(" ")),
);
@@ -576,7 +583,7 @@ fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
/// one timeline with the movement/anim lines — a "why did it not fade when I moved" question needs
/// exactly that.
fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
if !crate::dbg_trace::enabled() {
if !benilla_assets::trace::enabled() {
return;
}
let alphas: Vec<f32> = n.alpha.iter().map(|(_, f)| *f).collect();
@@ -586,7 +593,7 @@ fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
let rate = n
.head_anim_rate()
.map_or_else(String::new, |r| format!(" animrate={r}"));
crate::dbg_trace::line(
benilla_assets::trace::line(
"aur",
&format!(
"{what} e={entity} spell={spell_id} alpha={alphas:?}{tint}{rate} base {:.2} cur {:.2} target {:.2}",
@@ -691,16 +698,16 @@ type AuraParts<'w, 's> = Query<
&'static FadeMaterials,
&'static mut MeshTag,
&'static mut MeshMaterial3d<WowModelMaterial>,
Option<&'static crate::doodad_anim::MatAnim>,
Option<&'static benilla_world::doodad_anim::MatAnim>,
Option<&'static InteriorLit>,
Option<&'static RenderFade>,
Has<PendingAppearFade>,
Has<crate::model_render::FarSideOfWater>,
Has<benilla_world::model_render::FarSideOfWater>,
),
// Disjointness for the card pass (both want `&mut MeshTag`); a card never carries
// `FadeMaterials`, so this filter excludes nothing that would otherwise match — the same
// trick `apply_self_model_fade` uses for the same pair of queries.
Without<crate::billboard::BillboardCard>,
Without<benilla_world::billboard::BillboardCard>,
>;
/// Drive every unit's aura-alpha ramp and author its parts' render alpha + material.
@@ -720,27 +727,46 @@ type AuraParts<'w, 's> = Query<
/// **The material swap is load-bearing, not cosmetic.** A cutout/opaque batch ignores instance
/// alpha, so writing `0.3` into the tag without moving the part onto its **blend twin** renders no
/// translucency at all — the same "blend pass while `0 < fade < 1`" the reference runs for its
/// doodad fade ([`crate::model_fade`]). It goes through [`FadeMaterials::material_for`], the one
/// doodad fade ([`benilla_world::model_fade`]). It goes through [`FadeMaterials::material_for`], the one
/// place the blend-vs-law axes compose, so this writer and the classifier can never disagree.
/// Note the swap keys on the *instance* alpha only: a batch whose own animated colour alpha dips is
/// a per-batch quantity the reference combines separately, and forcing those onto a blend pass would
/// change every existing unit's look.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(crate) fn apply_aura_alpha(
time: Res<Time>,
mut roots: Query<(Entity, &mut AuraNodes)>,
mut commands: Commands,
mut roots: Query<(
Entity,
&mut AuraNodes,
Option<&benilla_world::model_fade::ModelFade>,
)>,
children_of: Query<&Children>,
mut parts: AuraParts,
far_twins: Res<crate::model_render::FarSideTwins>,
far_twins: Res<benilla_world::model_render::FarSideTwins>,
mut cards: Query<(
&crate::billboard::BillboardCard,
&benilla_world::billboard::BillboardCard,
&mut MeshTag,
Option<&crate::doodad_anim::MatAnim>,
Option<&benilla_world::doodad_anim::MatAnim>,
)>,
mut reauthor: ResMut<crate::interior::InteriorReauthor>,
mut reauthor: ResMut<benilla_world::interior::InteriorReauthor>,
) {
let now = time.elapsed_secs();
for (root, mut n) in &mut roots {
for (root, mut n, declared) in &mut roots {
let alpha = n.tick(now);
// Declare it to the engine, which owns the composed model alpha and its chain walk
// (`model_fade::ModelFade` — 1164's inversion). This is the whole of the aura's claim on
// the channel: a number and a reason, never a write.
//
// Written **before** the settled-opaque early-out below, and unconditionally on change:
// a declaration the engine keeps reading has to be retired by its declarer, and the ramp
// arriving at 1.0 is exactly when that matters. (`AuraNodes` itself is never removed — it
// parks at 1.0 — so there is no removal hook to hang this on.)
if declared.is_none_or(|d| d.0 != alpha) {
commands
.entity(root)
.insert(benilla_world::model_fade::ModelFade(alpha));
}
let translucent = n.translucent();
if !translucent && !n.authoring {
continue; // settled opaque: not ours, nothing to release
@@ -780,9 +806,9 @@ fn author_cards(
alpha: f32,
walked: &bevy::ecs::entity::EntityHashSet,
cards: &mut Query<(
&crate::billboard::BillboardCard,
&benilla_world::billboard::BillboardCard,
&mut MeshTag,
Option<&crate::doodad_anim::MatAnim>,
Option<&benilla_world::doodad_anim::MatAnim>,
)>,
) {
for (card, mut tag, anim) in cards.iter_mut() {
@@ -793,7 +819,7 @@ fn author_cards(
// card's per-sequence alpha animation stays alive under the aura — and so the release frame's
// `alpha = 1` write lands exactly on the value the card would have had anyway.
let authored = anim.map_or(1.0, |a| a.current);
let bits = crate::mesh_tag::with_alpha(tag.0, authored * alpha);
let bits = benilla_world::mesh_tag::with_alpha(tag.0, authored * alpha);
if tag.0 != bits {
tag.0 = bits;
}
@@ -804,7 +830,7 @@ fn author_cards(
/// recurse regardless — a joint or an attach-model root carries no [`FadeMaterials`] itself but the
/// held weapon / helm / shoulder meshes hang beneath it, and the reference's alpha is a per-CGUnit
/// property that its attached models render off (the same reason
/// [`crate::model_fade::apply_despawn_fade`] and the self feather both walk descendants, not
/// [`benilla_world::model_fade::apply_despawn_fade`] and the self feather both walk descendants, not
/// children).
#[allow(clippy::too_many_arguments)] // the author's full channel set, threaded down the walk
fn author_descendants(
@@ -813,8 +839,8 @@ fn author_descendants(
now: f32,
children_of: &Query<&Children>,
parts: &mut AuraParts,
far_twins: &crate::model_render::FarSideTwins,
reauthor: &mut crate::interior::InteriorReauthor,
far_twins: &benilla_world::model_render::FarSideTwins,
reauthor: &mut benilla_world::interior::InteriorReauthor,
walked: &mut bevy::ecs::entity::EntityHashSet,
) {
walked.insert(entity);
@@ -836,14 +862,14 @@ fn author_descendants(
fade_alpha(f.from, f.to, t)
});
let combined = alpha * anim.map_or(1.0, |a| a.current) * ramp;
let bits = crate::mesh_tag::with_alpha(tag.0, combined);
let bits = benilla_world::mesh_tag::with_alpha(tag.0, combined);
if tag.0 != bits {
tag.0 = bits;
}
// The INSTANCE alpha decides the pass; a settled-opaque release hands the law's steady
// material back and asks the classifier to re-author, so nothing is left on a stale
// one. The water-plane axis composes here like everywhere else (`far_resolved`).
let want = crate::model_render::far_resolved(
let want = benilla_world::model_render::far_resolved(
fm.material_for(lit, alpha < 1.0),
far_side,
far_twins,
+46 -35
View File
@@ -248,7 +248,7 @@ fn app_with_chains() -> App {
app.add_message::<crate::creature_anim::SpellKitSound>();
app.add_message::<AuraProc>();
// The water-plane twin map the alpha author composes with (empty here — no water in a fixture).
app.init_resource::<crate::model_render::FarSideTwins>();
app.init_resource::<benilla_world::model_render::FarSideTwins>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([
(
@@ -337,8 +337,8 @@ fn app_with_chains() -> App {
/// palette allocator whose slots index it, and the publish system.
fn app_with_tint_channel() -> App {
let mut app = app_with_chains();
app.init_resource::<crate::instance_tint::InstanceTints>();
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_resource::<benilla_world::instance_tint::InstanceTints>();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
app.add_systems(Update, apply_aura_tint.after(drain_aura_procs));
app
}
@@ -480,19 +480,19 @@ fn an_unmodelled_proc_only_kit_arms_nothing() {
/// material, and the part re-queued for the classifier.
#[test]
fn the_author_owns_the_tree_then_releases_at_opaque() {
use crate::model_fade::FadeMaterials;
use benilla_world::model_fade::FadeMaterials;
use bevy::mesh::MeshTag;
let cutout: Handle<crate::terrain::WowModelMaterial> =
let cutout: Handle<benilla_assets::materials::WowModelMaterial> =
bevy::asset::uuid_handle!("c0000000-0000-4000-8000-00000000a114");
let blend: Handle<crate::terrain::WowModelMaterial> =
let blend: Handle<benilla_assets::materials::WowModelMaterial> =
bevy::asset::uuid_handle!("b1000000-0000-4000-8000-00000000a114");
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::interior::InteriorReauthor>();
app.init_resource::<benilla_world::interior::InteriorReauthor>();
// The water-plane twin map the author composes with (empty — no water in a fixture).
app.init_resource::<crate::model_render::FarSideTwins>();
app.init_resource::<benilla_world::model_render::FarSideTwins>();
app.add_systems(Update, apply_aura_alpha);
let fm = FadeMaterials {
@@ -538,13 +538,13 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
for part in [body, weapon] {
let tag = app.world().entity(part).get::<MeshTag>().unwrap().0;
assert!(
(crate::mesh_tag::alpha_of(tag) - 0.3).abs() < 1.0 / 63.0,
(benilla_world::mesh_tag::alpha_of(tag) - 0.3).abs() < 1.0 / 63.0,
"part {part} carries the aura alpha (tag {tag:#x})"
);
assert_eq!(
app.world()
.entity(part)
.get::<MeshMaterial3d<crate::terrain::WowModelMaterial>>()
.get::<MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>()
.unwrap()
.0,
blend,
@@ -564,11 +564,11 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
app.update();
for part in [body, weapon] {
let tag = app.world().entity(part).get::<MeshTag>().unwrap().0;
assert!((crate::mesh_tag::alpha_of(tag) - 1.0).abs() < 1.0 / 63.0);
assert!((benilla_world::mesh_tag::alpha_of(tag) - 1.0).abs() < 1.0 / 63.0);
assert_eq!(
app.world()
.entity(part)
.get::<MeshMaterial3d<crate::terrain::WowModelMaterial>>()
.get::<MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>()
.unwrap()
.0,
cutout,
@@ -593,9 +593,9 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
#[test]
fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
let mut app = app_with_tint_channel();
let skin = crate::rig_palette::RigSkin::allocate_bones(
let skin = benilla_world::rig_palette::RigSkin::allocate_bones(
app.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>()
.resource_mut::<benilla_world::rig_palette::RigPalettes>()
.as_mut(),
8,
Handle::default(),
@@ -614,7 +614,7 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
app.update();
let tints = app
.world()
.resource::<crate::instance_tint::InstanceTints>();
.resource::<benilla_world::instance_tint::InstanceTints>();
assert_eq!(
tints.get(slot),
0xff8c_b9fd,
@@ -631,8 +631,8 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
app.update();
let tints = app
.world()
.resource::<crate::instance_tint::InstanceTints>();
assert_eq!(tints.get(slot), crate::instance_tint::IDENTITY);
.resource::<benilla_world::instance_tint::InstanceTints>();
assert_eq!(tints.get(slot), benilla_world::instance_tint::IDENTITY);
}
/// **The chain (decision 0841).** A rigged ATTACHED model — a spell-effect instance, or one of the
@@ -647,10 +647,14 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() {
let (body, shoulder) = {
let mut palettes = app
.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>();
.resource_mut::<benilla_world::rig_palette::RigPalettes>();
let mut alloc = |bones| {
crate::rig_palette::RigSkin::allocate_bones(&mut palettes, bones, Handle::default())
.expect("a fresh palette has room")
benilla_world::rig_palette::RigSkin::allocate_bones(
&mut palettes,
bones,
Handle::default(),
)
.expect("a fresh palette has room")
};
(alloc(8), alloc(3))
};
@@ -665,12 +669,12 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() {
.id();
// The item root: its own rig, no aura nodes of its own, chained to the unit wearing it.
app.world_mut()
.spawn((shoulder, crate::model_fade::ParentModel(unit)));
.spawn((shoulder, benilla_world::model_fade::ParentModel(unit)));
app.update();
let tints = app
.world()
.resource::<crate::instance_tint::InstanceTints>();
.resource::<benilla_world::instance_tint::InstanceTints>();
assert_eq!(tints.get(body_slot), 0xff8c_b9fd, "the body is ghost-blue");
assert_eq!(
tints.get(shoulder_slot),
@@ -687,9 +691,12 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() {
app.update();
let tints = app
.world()
.resource::<crate::instance_tint::InstanceTints>();
assert_eq!(tints.get(body_slot), crate::instance_tint::IDENTITY);
assert_eq!(tints.get(shoulder_slot), crate::instance_tint::IDENTITY);
.resource::<benilla_world::instance_tint::InstanceTints>();
assert_eq!(tints.get(body_slot), benilla_world::instance_tint::IDENTITY);
assert_eq!(
tints.get(shoulder_slot),
benilla_world::instance_tint::IDENTITY
);
}
/// An unchained rig — a placed doodad, a world effect — has no parent to inherit from and must stay
@@ -701,10 +708,14 @@ fn an_unchained_rig_inherits_nothing() {
let (body, loose) = {
let mut palettes = app
.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>();
.resource_mut::<benilla_world::rig_palette::RigPalettes>();
let mut alloc = |bones| {
crate::rig_palette::RigSkin::allocate_bones(&mut palettes, bones, Handle::default())
.expect("a fresh palette has room")
benilla_world::rig_palette::RigSkin::allocate_bones(
&mut palettes,
bones,
Handle::default(),
)
.expect("a fresh palette has room")
};
(alloc(8), alloc(3))
};
@@ -717,9 +728,9 @@ fn an_unchained_rig_inherits_nothing() {
app.update();
assert_eq!(
app.world()
.resource::<crate::instance_tint::InstanceTints>()
.resource::<benilla_world::instance_tint::InstanceTints>()
.get(loose_slot),
crate::instance_tint::IDENTITY,
benilla_world::instance_tint::IDENTITY,
"a tinted unit standing next to it changes nothing"
);
}
@@ -730,9 +741,9 @@ fn an_unchained_rig_inherits_nothing() {
#[test]
fn the_rig_free_hook_clears_a_dead_units_tint() {
let mut app = app_with_tint_channel();
let skin = crate::rig_palette::RigSkin::allocate_bones(
let skin = benilla_world::rig_palette::RigSkin::allocate_bones(
app.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>()
.resource_mut::<benilla_world::rig_palette::RigPalettes>()
.as_mut(),
8,
Handle::default(),
@@ -749,7 +760,7 @@ fn the_rig_free_hook_clears_a_dead_units_tint() {
app.update();
assert_eq!(
app.world()
.resource::<crate::instance_tint::InstanceTints>()
.resource::<benilla_world::instance_tint::InstanceTints>()
.get(slot),
0xff8c_b9fd,
);
@@ -758,9 +769,9 @@ fn the_rig_free_hook_clears_a_dead_units_tint() {
app.world_mut().entity_mut(unit).despawn();
assert_eq!(
app.world()
.resource::<crate::instance_tint::InstanceTints>()
.resource::<benilla_world::instance_tint::InstanceTints>()
.get(slot),
crate::instance_tint::IDENTITY,
benilla_world::instance_tint::IDENTITY,
"the slot is clean before anyone can reallocate it",
);
}
+2 -2
View File
@@ -65,7 +65,7 @@ impl BindingDispatch {
/// always beats the general one.
///
/// `dev_plane` suppresses the **retry only**, and only on the keyboard path. `Ctrl`+`Shift` is
/// ours ([`crate::debug_panel::DEV_CHORD`]), and 0870 picked it on the argument that the plane
/// ours ([`benilla_world::modkeys::DEV_CHORD`]), and 0870 picked it on the argument that the plane
/// was empty — an argument that rested on the exact-match law 1142 corrects. It is
/// *nearly* empty anyway under the real law, because the single strip drops `CTRL` and leaves
/// `SHIFT-`*key*, never reaching the bare letter — but "nearly" is not a plane: `SHIFT-P` is a
@@ -167,7 +167,7 @@ impl Plugin for BindingsPlugin {
.chain()
.in_set(crate::ui_script::UiInput)
.in_set(BindingSet)
.before(crate::schedule::WorldStage::Input)
.before(benilla_world::schedule::WorldStage::Input)
.run_if(in_state(ClientState::InWorld)),
load_character_bindings.run_if(in_state(ClientState::InWorld)),
save_bindings.after(load_character_bindings),
+24 -52
View File
@@ -1,6 +1,6 @@
//! The **unit blob shadow** — the soft dark oval under every unit (the player, every NPC, every
//! creature), the reference's per-frame shadow pass rebuilt on the shared surface-decal projector
//! ([`crate::decal`]), drawn on the shared effect stream (0733).
//! ([`benilla_world::decal`]), drawn on the shared effect stream (0733).
//!
//! **The byte-verified mechanism** (wow-re `unit-blob-shadow.md`, a §5 cross-check; the "cloud
//! shadow" label on `0x6d7920` was corrected — it IS the unit shadow draw):
@@ -44,43 +44,23 @@
//! an off-screen shadow's triangles are vertex-clipped GPU-side — dozens of ~50-vert slices,
//! below any ledger line.)
use avian3d::prelude::Collider;
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::ecs::entity::{EntityHashMap, EntityHashSet};
use bevy::prelude::*;
use crate::collision::GroundDecalSurface;
use crate::creature_anim::AnimData;
use crate::decal::{project_decal, DecalFrame};
use crate::model_fade::{fade_alpha, RenderFade};
use crate::net::{NetEntity, SelfPlayer};
use crate::particles::buffer::{
begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex,
};
use crate::player::{CameraControl, WorldCamera};
use crate::schedule::WorldStage;
use crate::player::CameraControl;
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::model_fade::{fade_alpha, RenderFade};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
const SHADOW_TEXTURE: &str = "mpq://textures/shadowblob.blp";
/// The shadow's sort-ladder rung: half the ring's `RING_DEPTH_BIAS`, so where both decals stack
/// the ring draws later deterministically. Sort only — the rasterizer margin is
/// [`SHADOW_RASTER_BIAS`], sized independently (B131 split one constant into its two roles).
const SHADOW_SORT_BIAS: f32 = 4096.0;
/// The shadow's rasterizer depth-bias constant — the margin funding the `GreaterEqual` tie
/// against the drawn ground (`DECAL_WORLD_CLIP`, 0781). The reference's mechanism is the same
/// class: `shadowBias` cvar 0.1 → a **constant-only** polygon offset (−102.4 LSBs of its 24-bit
/// buffer; no slope term exists in the binary — wow-re unit-blob-shadow RE, corroborated across
/// every shadow draw of four apitraces). The *size* is ours, not the reference's number: our
/// residual is 0781's — the decal's CPU-baked world verts vs the receiver's GPU-transformed
/// verts diverge by ~1–3 ulps of the world coordinate, which is millimetres at city magnitudes
/// and lands on the depth tie where the receiver is *sloped* (horizontal ulps project onto the
/// tilted normal — the confirmed B131 flicker walk was the Stormwind gate ramp). 4096 absorbed
/// under half the 3-ulp worst case at close zoom and lost the tie while moving; 32768 dominates
/// it ≥2× at every zoom ≥1 yd and stays centimetre-order at 30 yd (no visible punch-through).
/// Sizing pinned by `raised_bias_dominates_the_bake_residual` (particles/render.rs).
pub(crate) const SHADOW_RASTER_BIAS: i32 = 32768;
/// The byte clamp on the animation box: each corner component is clamped INTO ±5 yd pre-scale
/// (`0x6992c0` MAX(−5) / `0x699250` MIN(+5) — a cap on huge authored boxes, never a floor).
const BOX_CLAMP: f32 = 5.0;
@@ -194,7 +174,7 @@ fn update_shadows(
rig: Option<Res<CameraControl>>,
shadow_assets: Option<Res<ShadowAssets>>,
images: Res<Assets<Image>>,
surfaces: Query<&Collider, With<GroundDecalSurface>>,
decals: WorldDecal,
// Spawn/despawn fades live on the model *part* entities; attribute each to its unit root so
// the shadow can ride the same alpha the body renders with (O(#currently-fading parts) — zero
// in the steady state).
@@ -239,7 +219,7 @@ fn update_shadows(
let slot = root_fade.entry(root).or_insert(1.0);
*slot = slot.min(alpha);
}
let surface_count = surfaces.iter().count();
let surface_count = decals.receiver_count();
for (shadow, mut key, mut verts) in &mut shadows {
n_total += 1;
let Ok((unit, anims, is_self, mount_child)) = owners.get(shadow.owner) else {
@@ -335,9 +315,8 @@ fn update_shadows(
let span_v = frame.max_y - frame.min_y;
verts.0.clear();
let projected = span_v > 0.0
&& project_decal(
&& decals.project(
&mut verts.0,
&surfaces,
&frame,
|p| {
// The trapezoid ramp over the vertical span (the reference's second texture
@@ -469,7 +448,7 @@ fn update_shadows(
fn push_shadows(
assets: Option<Res<ShadowAssets>>,
cam: Query<Entity, With<WorldCamera>>,
mut quads: ResMut<EffectQuads>,
mut draw: benilla_world::particles::buffer::WorldEffectDraw,
shadows: Query<(Entity, &ShadowKey, &ShadowVerts)>,
) {
let Some(assets) = assets else { return };
@@ -478,26 +457,19 @@ fn push_shadows(
if verts.0.is_empty() {
continue;
}
let start = quads.begin();
quads.verts.extend_from_slice(&verts.0);
quads.commit_tris(
start,
EffectDrawSpec {
cam,
texture: assets.texture.id(),
blend: EffectBlend::Multiply,
fog: EffectFog::Off,
// The modulate decal is its own darkening — the scene light is already in
// the ground it multiplies.
lit: false,
anchor: key.feet,
bias: SHADOW_SORT_BIAS,
raster_bias: SHADOW_RASTER_BIAS,
cam_relative: false,
main_entity: entity,
light: None,
},
);
// Multiply: the modulate decal is its own darkening — the scene light is already in the
// ground it multiplies, which is why the lane's unlit default is right here.
let mut batch = draw
.batch(cam, assets.texture.id())
.multiply()
.anchored(key.feet)
.rung(
benilla_world::sky_order::Rung::SHADOW_SORT,
benilla_world::sky_order::Rung::SHADOW_RASTER,
)
.owner(entity);
batch.vertices(&verts.0);
batch.tris();
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ impl Plugin for BowstringPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
PostUpdate,
draw_bowstrings.in_set(crate::billboard::BillboardPlace),
draw_bowstrings.in_set(benilla_world::billboard::BillboardPlace),
);
}
}
@@ -69,8 +69,8 @@ use bevy::render::view::{ExtractedView, ViewDepthTexture};
use bevy::render::{Render, RenderApp, RenderSystems};
use super::probes::ProbeClock;
use crate::particles::ParticleEmitter;
use crate::player::WorldCamera;
use benilla_world::particles::ParticleEmitter;
use benilla_world::view::WorldCamera;
pub(crate) struct DepthProbePlugin;
@@ -111,7 +111,7 @@ impl Plugin for DepthProbePlugin {
.add_systems(Update, arm)
.add_systems(
PostUpdate,
collect_quads.after(crate::billboard::BillboardPlace),
collect_quads.after(benilla_world::billboard::BillboardPlace),
)
.add_plugins((
ExtractResourcePlugin::<DepthWatch>::default(),
@@ -230,7 +230,7 @@ fn collect_quads(
mut probes: ResMut<QuadProbes>,
cam: Query<(&Camera, &GlobalTransform, &Projection), With<WorldCamera>>,
emitters: Query<(Entity, &ParticleEmitter)>,
quads: Res<crate::particles::buffer::EffectQuads>,
quads: Res<benilla_world::particles::buffer::EffectQuads>,
) {
let Some(bones) = watch.0.as_deref() else {
return;
@@ -699,7 +699,7 @@ mod tests {
Mat4::perspective_infinite_reverse_rh(
std::f32::consts::FRAC_PI_4,
3200.0 / 1800.0,
crate::player::CAM_NEAR,
benilla_world::view::CAM_NEAR,
)
}
+1 -1
View File
@@ -12,7 +12,7 @@ use benilla_protocol::guid;
use super::probes::ProbeClock;
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::WorldCamera;
use benilla_world::view::WorldCamera;
/// How far the subject gate's WARN looks when listing what *is* nearby — wide enough to catch the
/// "right room, wrong wing" mis-aim even when `WOW_SHOT_REQUIRE_DIST` is tight.
+35 -16
View File
@@ -47,7 +47,7 @@
//! worse: `assets/` resolved through Cargo's runtime `CARGO_MANIFEST_DIR`, so a bare run silently
//! loaded **no** WGSL shaders and every custom-shader layer — the *entire* player UI, sky, liquid,
//! models — rendered blank, read as a UI bug for hours once. The app now bakes the absolute
//! `crates/benilla-app/assets/` path in at compile time, so a bare run finds its shaders;
//! `crates/benilla-world/assets/` path in at compile time, so a bare run finds its shaders;
//! staleness is the trap that remains.) `WOW_CAPTURE_UI=1` opts the player UI into the shot (off
//! by default so world baselines stay UI-free; omit it for world-only scenes). `WOW_CAPTURE=list`
//! prints the scenario names. `scripts/visual.sh` wraps all of this.
@@ -61,11 +61,11 @@ use bevy::time::TimeUpdateStrategy;
use benilla_assets::coords::wow_to_bevy;
use crate::debug_panel::DebugState;
use crate::loading_screen::WorldLoadProgress;
use crate::perf::PerfHud;
use crate::player::WorldCamera;
use crate::schedule::WorldStage;
use benilla_world::dev_state::DebugState;
use benilla_world::schedule::WorldStage;
use benilla_world::terrain_stream::WorldLoadProgress;
use benilla_world::view::WorldCamera;
// The UI fixture seeding (the synthetic window states), the scenario table, and the live-run
// probe instruments (`probes`) each live in their own file — the server-less harness (settle,
@@ -101,13 +101,22 @@ pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::{rig_char_name_from_env, ProbeRigPlugin};
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probes::{
EntityCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin,
ProbeClock, ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
ProbeResizePlugin,
fx_draw_census_plugin, EntityCensusPlugin, LiveFpsPlugin, NodeProbePlugin,
ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, ProbeExitPlugin, ProbeFocusPlugin,
ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
};
use scenarios::{Scenario, SubjectKind, UiFixture, GROUND_EYE, SCENARIOS};
/// Is the app running a capture (`$WOW_CAPTURE` set)? Read by `main` to disable net + add the harness.
pub(crate) mod waterfx;
/// Is the app running a capture (`$WOW_CAPTURE` set)? Read by `main` to disable net + add the
/// harness.
///
/// The engine asks the same question of the same variable, on its own, as
/// `dev_state::deterministic_run` — because after 1160's split it *is* its own question: "run
/// deterministically" is a property of the world, and the world must be able to ask it with no
/// harness above it at all. One environment variable, two readers, no shared symbol across the
/// boundary. Keep the two names in step.
pub(crate) fn scenario_active() -> bool {
std::env::var("WOW_CAPTURE").is_ok()
}
@@ -421,6 +430,16 @@ pub(crate) struct CapturePlugin;
impl Plugin for CapturePlugin {
fn build(&self, app: &mut App) {
// The `waterfx` fixture drives its synthetic wading dummy before the foam emitter reads
// the motion — registered here, against the engine's ordering handle, because the
// instrument is the one that knows it is an instrument.
app.add_systems(
Update,
(waterfx::spawn, waterfx::drive)
.chain()
.before(benilla_world::water_fx::WaterFoamSet)
.in_set(benilla_world::schedule::WorldStage::Present),
);
let name = std::env::var("WOW_CAPTURE").unwrap_or_default();
// The fxview instrument: a synthetic scenario (ground scene, noon) + the fixture
// request from env. Not in SCENARIOS — `scripts/visual.sh`'s golden sweep must never
@@ -489,9 +508,9 @@ impl Plugin for CapturePlugin {
.unwrap_or(d)
};
let mode = match std::env::var("WOW_WFX_MODE").as_deref() {
Ok("wake") => crate::water_fx::WfxMode::Wake,
Ok("turn") => crate::water_fx::WfxMode::Turn,
_ => crate::water_fx::WfxMode::Ring,
Ok("wake") => waterfx::WfxMode::Wake,
Ok("turn") => waterfx::WfxMode::Turn,
_ => waterfx::WfxMode::Ring,
};
// Rig centre in raw WoW coords: over the Northshire ground scene, the synthetic
// surface a few yards above the terrain so the backdrop plane reads clean.
@@ -504,7 +523,7 @@ impl Plugin for CapturePlugin {
center[1] + dist * el.cos() * az.sin(),
center[2] + dist * el.sin(),
];
app.insert_resource(crate::water_fx::WaterFxView {
app.insert_resource(waterfx::WaterFxView {
mode,
speed: knob("WOW_WFX_SPEED", 4.0),
age: knob("WOW_WFX_AGE", 1.5),
@@ -735,11 +754,11 @@ fn drive_capture(
mut exit: MessageWriter<AppExit>,
time: Res<Time<bevy::time::Real>>,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
particles: Query<&crate::particles::ParticleEmitter>,
parts: Query<&ViewVisibility, With<crate::debug_panel::ModelPart>>,
particles: Query<&benilla_world::particles::ParticleEmitter>,
parts: Query<&ViewVisibility, With<benilla_world::model_render::ModelPart>>,
entities: Query<()>,
fx_req: Option<Res<FxViewRequest>>,
wfx_req: Option<Res<crate::water_fx::WaterFxView>>,
wfx_req: Option<Res<waterfx::WaterFxView>>,
mut fx_state: Option<ResMut<FxViewState>>,
// **The harness's one deliberate virtual clock** — the allowlisted exception in
// `probes::probe_schedules_read_the_wall_clock`. The fixture age below is an age *on the clock
+15 -7
View File
@@ -33,8 +33,8 @@ use bevy::render::texture::GpuImage;
use bevy::render::{Render, RenderApp, RenderSystems};
use super::probes::ProbeClock;
use crate::interact::WorldObject;
use crate::terrain::WowModelMaterial;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::interact::WorldObject;
pub(crate) struct PhaseProbePlugin;
@@ -148,7 +148,7 @@ fn collect_batches(
parts: Query<(
Entity,
&WorldObject,
&crate::debug_panel::ModelPart,
&benilla_world::model_render::ModelPart,
&MeshMaterial3d<WowModelMaterial>,
)>,
materials: Res<Assets<WowModelMaterial>>,
@@ -208,9 +208,17 @@ fn collect_batches(
fn collect_emitters(
mut watch: ResMut<PhaseWatch>,
time: ProbeClock,
emitters: Query<(Entity, &crate::particles::ParticleEmitter)>,
trails: Query<(Entity, &crate::ribbons::RibbonTrail, &GlobalTransform)>,
parts: Query<(Entity, &crate::debug_panel::ModelPart, &GlobalTransform)>,
emitters: Query<(Entity, &benilla_world::particles::ParticleEmitter)>,
trails: Query<(
Entity,
&benilla_world::ribbons::RibbonTrail,
&GlobalTransform,
)>,
parts: Query<(
Entity,
&benilla_world::model_render::ModelPart,
&GlobalTransform,
)>,
) {
if !watch.particles || watch.armed || time.elapsed_secs() < watch.at {
return;
@@ -365,7 +373,7 @@ fn report_phases(
for phase in transparent.values() {
if let Some(pos) = phase.items.iter().position(|item| item.entity.1 == main) {
// The sort key itself, not just the slot: `Transparent3d` orders on view-space z +
// the material's depth bias, ascending = farthest first (`crate::sky_order`). Two
// the material's depth bias, ascending = farthest first (`benilla_world::sky_order`). Two
// slots tell you WHICH drew last; the two distances tell you WHY, and whether the
// gap is a real spatial one or a tie the sort broke arbitrarily.
found.push(format!(
+14 -8
View File
@@ -3,7 +3,7 @@
//! The flicker instruments (decisions 0653/0656) localise a defect: `benilla-visual hotspot` hands
//! back a pixel box and says *this* is what would not hold still. Naming what is actually there was
//! then a manual step — read the ADT placements, guess which model the report meant, hope. The
//! interactive inspector ([`crate::interact`]) answers it with a cursor, which an unattended probe
//! interactive inspector ([`benilla_world::interact`]) answers it with a cursor, which an unattended probe
//! does not have.
//!
//! So: `WOW_PICK="<x>,<y>[;<x>,<y>…]"` (+ `WOW_PICK_AT=<secs>`, default 20) casts a ray through each
@@ -42,9 +42,9 @@ use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::probes::ProbeClock;
use crate::interact::{cast_pick_ray, PickParts, WorldObject};
use crate::player::WorldCamera;
use crate::terrain::WowModelMaterial;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::interact::{cast_pick_ray, PickParts, WorldObject};
use benilla_world::view::WorldCamera;
pub(crate) struct PickProbePlugin;
@@ -104,21 +104,24 @@ fn parse_pixels(spec: &str) -> Vec<Vec2> {
}
/// The filter deciding what a cast may hit — see [`fire_pick`]'s `objects` for why it is both.
type Pickable = Or<(With<WorldObject>, With<crate::debug_panel::ModelPart>)>;
type Pickable = Or<(
With<WorldObject>,
With<benilla_world::model_render::ModelPart>,
)>;
/// What a hit entity is asked for: its identity, the batch class it draws as, the material
/// carrying the WMO batch order, and its per-instance `MeshTag` (`Option` because a hit need not be a
/// model batch at all — and an equipped item's batch carries no [`WorldObject`]).
type HitIdentity = (
Option<&'static WorldObject>,
Option<&'static crate::debug_panel::ModelPart>,
Option<&'static benilla_world::model_render::ModelPart>,
Option<&'static MeshMaterial3d<WowModelMaterial>>,
Option<&'static MeshTag>,
// Is this hit a camera-FACING batch (decision 0153's world-root card) or the model's own
// geometry? The two are indistinguishable in the pixels and lead to completely different
// diagnoses — a card that should be culled vs a submesh drawn with the wrong texture — so the
// cast says which. (`card` in the line below.)
Has<crate::billboard::BillboardCard>,
Has<benilla_world::billboard::BillboardCard>,
);
/// Every shading input the batch actually has, as text — the five packed uniform rows and the base
@@ -311,7 +314,10 @@ fn fire_pick(
info!(
" {} tag {}",
shading_of(resolved),
tag.map_or_else(|| "<none>".to_string(), |t| crate::mesh_tag::describe(t.0)),
tag.map_or_else(
|| "<none>".to_string(),
|t| benilla_world::mesh_tag::describe(t.0)
),
);
}
}
@@ -14,7 +14,7 @@ use super::probes::ProbeClock;
use crate::net::{ClientCommand, Guid, SelfPlayer};
use crate::player::Player;
use crate::transport::{Transport, TransportAnchor};
use crate::world_map::CurrentMap;
use benilla_world::world_map::CurrentMap;
pub(crate) struct ProbeCrossingPlugin;
@@ -1,7 +1,7 @@
//! The melee live probe (`WOW_PROBE=melee`) — an agent-side instrument, inert without the env:
//! once in-world, periodically fire the attack-nearest core ([`AttackNearestRequest`] — the
//! action-bar attack's own no-selection path), so the probe character fights whatever is closest
//! while the `dbg_trace` sink (`WOW_MOVE_TRACE=<path>`) records the swing→impact→spawn combat-text
//! while the `benilla_assets::trace` sink (`WOW_MOVE_TRACE=<path>`) records the swing→impact→spawn combat-text
//! timeline for pair-birth analysis. Pair with the slot-keyed probe identity (method.md) + an outer
//! `timeout`.
//!
+3 -3
View File
@@ -18,10 +18,10 @@ use benilla_formats::{load_taxi_nodes, load_taxi_path_nodes};
use bevy::prelude::*;
use super::probes::ProbeClock;
use crate::assets::{LockRecover, WorldAssets};
use crate::net::{ClientCommand, Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::ui_taxi::TaxiState;
use benilla_assets::{LockRecover, WorldAssets};
/// The flight under test: Stormwind (node 2) → Sentinel Hill (node 4), TaxiPath id 6 — the pair
/// byte-verified against the real 5875 tables by the phase-1 catalog tests.
@@ -45,7 +45,7 @@ impl Plugin for ProbeTaxiPlugin {
app.init_resource::<TaxiProbe>()
.add_systems(
Startup,
load_expectations.after(crate::assets::AssetSet::Open),
load_expectations.after(benilla_assets::AssetSet::Open),
)
.add_systems(Update, taxi_probe)
.add_systems(PreUpdate, hold_w_post_land.after(bevy::input::InputSystems));
@@ -417,7 +417,7 @@ fn taxi_probe(
Dir3::NEG_Y,
52.0,
true,
&crate::collision::player_query_filter(),
&benilla_world::collision::WorldCollision::body_filter(),
&|_| true,
)
.map(|hit| origin.y - hit.distance);
@@ -3,7 +3,7 @@
//!
//! Built to answer "the login screen's braziers are simulating but nothing is on screen." A draw
//! record names the **main-world camera entity** whose view it belongs to
//! ([`super::buffer::EffectDraw::cam`]), and `particles::sim` resolves that for a booth-layered
//! ([`benilla_world::particles::buffer::EffectDraw::cam`]), and `particles::sim` resolves that for a booth-layered
//! emitter by finding the first booth camera whose `RenderLayers` intersect the emitter's. If two
//! booths share a layer, the wrong camera can win — and if that camera is inactive, the emitter
//! simulates forever and never draws. Nothing about that is visible in a frame time, a trace, or
@@ -19,8 +19,8 @@ use std::collections::{HashMap, HashSet};
use bevy::prelude::*;
use bevy::time::Real;
use super::buffer::EffectQuads;
use crate::portrait::BoothCam;
use benilla_world::particles::buffer::EffectQuads;
/// Off unless `WOW_FX_CENSUS=1` — the plugin adds no system at all otherwise.
pub(crate) fn plugin(app: &mut App) {
+83 -301
View File
@@ -1,14 +1,20 @@
//! The live frame-time sample ([`LiveFpsPlugin`]) and everything printed from the same sample
//! window: the `FPS_PROBE` line itself, the visible-submesh census (`VisCensus` —
//! `VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`), the asset-churn ratchet (`ChurnCensus` —
//! `MAT_CHURN`) and the residency meter (`ResidencyMeter` — `ASSET_DUMP`). One file because
//! one system, `drive_live_fps`, builds all four from the same frame's queries at the same
//! instant — they are one measurement with four line families, not four instruments.
//! window: the `FPS_PROBE` line itself, the visible-submesh census
//! (`VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`), the asset-churn ratchet (`MAT_CHURN`) and the
//! residency meter (`ASSET_DUMP`). One file because one system, `drive_live_fps`, prints all four
//! from the same frame at the same instant — they are one measurement with four line families,
//! not four instruments.
//!
//! **What is counted lives in [`benilla_world::world_census`]; what is printed lives here.** The census
//! is the engine's own published account of the frame it drew (decision 1164) — this probe adds
//! the timing window around it and owns the line shapes, which are a greppable contract of ours
//! and no business of the renderer's.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crate::capture::PROBE_WARMUP_FRAMES;
use benilla_world::world_census::WorldCensus;
/// The LIVE FPS probe (`WOW_LIVE_FPS=<frames>`, delay via `WOW_LIVE_FPS_AT` seconds, default 25;
/// `WOW_LIVE_FPS_MOVE=1` holds W through warmup + sampling, so the probe measures RUNNING through
@@ -44,29 +50,11 @@ impl Plugin for LiveFpsPlugin {
occluded_now: false,
occluded_frames: 0,
})
.init_resource::<ChurnCensus>()
.add_systems(Update, drive_live_fps)
.add_systems(
First,
(
(
churn_counter::<bevy::image::Image>("image"),
churn_counter::<Mesh>("mesh"),
churn_counter::<StandardMaterial>("std"),
churn_counter::<crate::terrain::TerrainMaterial>("terrain"),
churn_counter::<crate::terrain::WowModelMaterial>("model"),
churn_counter::<crate::terrain::WdlMaterial>("wdl"),
churn_counter::<crate::terrain::LiquidMaterial>("liquid"),
),
(
churn_counter::<crate::sky::SkyMaterial>("sky"),
churn_counter::<crate::sun::CelestialMaterial>("celestial"),
churn_counter::<crate::sun::StarMaterial>("star"),
churn_counter::<crate::clouds::CloudMaterial>("cloud"),
churn_counter::<crate::ui_pass::UiQuadMaterial>("uiquad"),
),
),
);
.add_systems(Update, drive_live_fps);
WorldCensus::churn_counters(app);
// The engine counts its own materials; the UI pass is ours, so we fold it into the same
// tally rather than keeping a second one that could disagree about the window.
WorldCensus::count_churn::<crate::ui_pass::UiQuadMaterial>(app, "uiquad");
}
}
@@ -106,116 +94,19 @@ struct LiveFps {
occluded_frames: usize,
}
/// Asset residency — the leak meter (the #bugs teleport leak: caches hold strong handles, so maps
/// visited earlier keep their materials/meshes/images resident, and every uv/tint registry survivor
/// is re-uploaded per frame). A tour probe reading the same counts as a fresh control is what
/// "torn down" means, machine-checked. One struct because `drive_live_fps` is at Bevy's
/// system-param arity limit.
#[derive(bevy::ecs::system::SystemParam)]
struct ResidencyMeter<'w> {
mats: Res<'w, Assets<crate::terrain::WowModelMaterial>>,
meshes: Res<'w, Assets<Mesh>>,
images: Res<'w, Assets<bevy::image::Image>>,
uv_reg: Res<'w, crate::doodad_anim::UvAnimMaterials>,
tint_reg: Res<'w, crate::doodad_anim::TintAnimMaterials>,
models: Res<'w, Assets<benilla_assets::M2Model>>,
server: Res<'w, AssetServer>,
churn: ResMut<'w, ChurnCensus>,
}
/// The asset-churn census (the probe's ratchet meter): `AssetEvent::Modified` counts per asset
/// type across the sample window, printed as one `MAT_CHURN` line beside `FPS_PROBE`. A modified
/// material re-creates its uniform buffers + bind group that frame (the Metal non-bindless path),
/// and a modified image/mesh re-uploads — the teleport leak's CPU engine was exactly a per-frame
/// ratchet of this shape, so the floor hunt names them instead of guessing suspects one at a
/// time. Counters register only under `WOW_LIVE_FPS` ([`LiveFpsPlugin`]) — a normal run carries
/// none of this.
#[derive(Resource, Default)]
struct ChurnCensus(std::collections::BTreeMap<&'static str, usize>);
/// One census counter for asset type `A`, folding this frame's `Modified` events in under
/// `label` (a short stable name — the `type_name` of an `ExtendedMaterial` alias is unreadable).
fn churn_counter<A: bevy::asset::Asset>(
label: &'static str,
) -> impl FnMut(MessageReader<bevy::asset::AssetEvent<A>>, ResMut<ChurnCensus>) {
move |mut reader, mut census| {
let n = reader
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Modified { .. }))
.count();
if n > 0 {
*census.0.entry(label).or_default() += n;
}
}
}
impl ResidencyMeter<'_> {
/// `WOW_ASSET_DUMP=1`: one `ASSET_DUMP` line per resident image/mesh at sample time, path
/// via the asset server (runtime-built assets have no path and print as `<unpathed>` counts).
/// Diffing a tour probe's dump against a fresh control's names exactly which files a
/// teardown left behind — the leak meter's magnifying glass.
fn dump(&self) {
let mut lines: Vec<String> = Vec::new();
let mut unpathed = [0usize; 3];
for (kind, ids) in [
(
"image",
self.images.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
(
"mesh",
self.meshes.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
(
"model",
self.models.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
] {
let slot = match kind {
"image" => 0,
"mesh" => 1,
_ => 2,
};
for id in ids {
match self.server.get_path(id) {
Some(p) => lines.push(format!("ASSET_DUMP {kind} {p}")),
None => unpathed[slot] += 1,
}
}
}
lines.sort();
for l in &lines {
println!("{l}");
}
println!(
"ASSET_DUMP <unpathed> images={} meshes={} models={} materials={}",
unpathed[0],
unpathed[1],
unpathed[2],
self.mats.len()
);
}
}
/// Where — and in what scene state — the sample was taken. Bundled because [`drive_live_fps`] sits
/// at Bevy's 16-param ceiling, and because these four are read together or not at all.
/// **Where** the sample was taken — the 0705 prove-the-run law: a probe number is evidence only
/// once the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently fail (a bad
/// map id, a refused command) leaving the run measuring the login spot.
///
/// The `room`/`windows` half is the exterior-scene gate's two terms (decision 0774), the same pair
/// the debug panel's World section shows. Without them a `drawn=` reading taken indoors cannot be
/// read at all: a big number means either "we claimed a room and the cull let everything through" or
/// "we never claimed a room, so nothing was gated" — opposite bugs with identical numbers, and the
/// difference cost a measurement (0780).
/// The *scene-state* half of the pin — which room the camera claims, how many exterior windows,
/// what the cull did — is the engine's, and arrives with the rest of the census. Without it a
/// `drawn=` reading taken indoors cannot be read at all: a big number means either "we claimed a
/// room and the cull let everything through" or "we never claimed a room, so nothing was gated" —
/// opposite bugs with identical numbers, and the difference cost a measurement (0780).
#[derive(SystemParam)]
struct SamplePin<'w> {
/// The map the sample landed on (0705's prove-the-run law): a probe number is evidence only once
/// the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently fail (a bad map
/// id, a refused command) leaving the run measuring the login spot.
map: Option<Res<'w, crate::world_map::CurrentMap>>,
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
body: Option<Res<'w, crate::player::Player>>,
room: Option<Res<'w, crate::wmo_portal::CameraInteriorClaim>>,
windows: Option<Res<'w, crate::wmo_portal::ExteriorWindows>>,
/// What the cull DID, not just what it was told — see [`crate::exterior_cull::ExteriorCullVerdict`].
verdict: Option<Res<'w, crate::exterior_cull::ExteriorCullVerdict>>,
}
/// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the
@@ -226,22 +117,19 @@ fn drive_live_fps(
time: Res<Time<bevy::time::Real>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
particles: Query<&crate::particles::ParticleEmitter>,
// Every spawned model submesh, with the two facts that make a visible one accountable: which
// subsystem it belongs to, and whether it is gated by the exterior-scene cull. See
// [`VisCensus`] for why "visible" alone was not enough to diagnose anything.
parts: Query<CensusData>,
// What the engine drew this frame and what it is holding — submeshes, the exterior-scene
// gate, emitters, resident assets, the churn window. One param, one instant.
mut census: WorldCensus,
streamed: Query<(), With<crate::net::NetEntity>>,
// The animation-LOD gate's effect, machine-readable per probe (decision 0448): how many
// streamed rigs sat parked at sample end.
parked: Query<(), With<crate::creature_anim::AnimParked>>,
parked: Query<(), With<benilla_world::rig_anim::AnimParked>>,
entities: Query<()>,
pin: SamplePin,
// The owned skin-palette occupancy (decision 0720) — `rigs=live/peak bones=live/peak` on the
// probe line proves the palette lane is actually populated (an all-zero table renders
// origin-collapsed rigs, which no other probe number would catch).
palettes: Option<Res<crate::rig_palette::RigPalettes>>,
mut residency: ResidencyMeter,
palettes: Option<Res<benilla_world::rig_palette::RigPalettes>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut key_events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
mut exit: MessageWriter<AppExit>,
@@ -299,7 +187,7 @@ fn drive_live_fps(
probe.sys_at_start = crate::perf::system_cpu_ticks();
// The churn census restarts with the window — warmup noise (streaming, shader
// warms) would otherwise read as steady-state ratchets.
residency.churn.0.clear();
census.restart_churn();
probe.occluded_frames = 0;
}
if probe.occluded_now {
@@ -314,19 +202,7 @@ fn drive_live_fps(
v.sort_by(f32::total_cmp);
let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize];
let mean = v.iter().sum::<f32>() / v.len() as f32;
let (emitters, active, live) = particles
.iter()
.fold((0usize, 0usize, 0usize), |(e, a, l), p| {
(e + 1, a + usize::from(p.live() > 0), l + p.live())
});
let mut census = VisCensus {
own_instance: pin.room.as_ref().and_then(|r| r.0).map(|c| c.room.instance),
..Default::default()
};
let (submeshes, drawn) = parts.iter().fold((0usize, 0usize), |(n, d), row| {
census.add(row);
(n + 1, d + usize::from(row.0.get()))
});
let seen = census.take();
let px = windows
.single()
.map(|w| (w.physical_width(), w.physical_height()))
@@ -373,30 +249,14 @@ fn drive_live_fps(
}
_ => String::new(),
};
let gate = {
let room = match pin.room.as_ref().and_then(|r| r.0) {
Some(claim) => format!("g{:02}", claim.room.group),
None => "none".to_string(),
};
match pin.windows.as_deref() {
Some(crate::wmo_portal::ExteriorWindows::Windows(rects)) => {
format!(" room={room} windows={}", rects.len())
}
Some(crate::wmo_portal::ExteriorWindows::Unrestricted) => {
format!(" room={room} windows=unrestricted")
}
None => String::new(),
}
let gate = match (seen.room.as_deref(), seen.windows.as_deref()) {
(Some(room), Some(w)) => format!(" room={room} windows={w}"),
_ => String::new(),
};
let culled = match pin.verdict.as_deref() {
let culled = match seen.cull.as_ref() {
Some(v) => format!(
" cull_windows={} cull_frusta={} cull_tested={} cull_hidden={} cull_unbounded={}",
v.windows
.map_or("unrestricted".to_string(), |n| n.to_string()),
v.frusta,
v.tested,
v.hidden,
v.unbounded
v.windows, v.frusta, v.tested, v.hidden, v.unbounded
),
None => String::new(),
};
@@ -411,20 +271,21 @@ fn drive_live_fps(
.unwrap_or_default();
let residency_line = format!(
" mats={} meshes={} images={} uv={} tint={}",
residency.mats.len(),
residency.meshes.len(),
residency.images.len(),
residency.uv_reg.0.len(),
residency.tint_reg.0.len(),
seen.mats, seen.meshes, seen.images, seen.uv_anims, seen.tint_anims,
);
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present} occluded_frames={}{at_pin}{gate}{culled}",
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present} occluded_frames={}{at_pin}{gate}{culled}",
v.len(),
at(0.50),
at(0.95),
at(0.99),
v[v.len() - 1],
1000.0 / mean,
seen.emitters,
seen.active_emitters,
seen.particles,
seen.submeshes,
seen.drawn,
streamed.iter().len(),
parked.iter().len(),
entities.iter().len(),
@@ -432,13 +293,12 @@ fn drive_live_fps(
px.1,
probe.occluded_frames,
);
census.print();
print_vis_census(&seen);
// The window's Modified-event totals per asset type — a type at ~1×/frame here is a
// per-frame re-upload ratchet (see [`ChurnCensus`]); absent means quiet.
if !residency.churn.0.is_empty() {
let churn = residency
// per-frame re-upload ratchet; absent means quiet.
if !seen.churn.is_empty() {
let churn = seen
.churn
.0
.iter()
.map(|(k, n)| format!("{k}={n}"))
.collect::<Vec<_>>()
@@ -446,7 +306,14 @@ fn drive_live_fps(
println!("MAT_CHURN frames={} {churn}", v.len());
}
if std::env::var_os("WOW_ASSET_DUMP").is_some() {
residency.dump();
let (lines, unpathed) = census.resident_assets();
for l in &lines {
println!("ASSET_DUMP {l}");
}
println!(
"ASSET_DUMP <unpathed> images={} meshes={} models={} materials={}",
unpathed[0], unpathed[1], unpathed[2], seen.mats
);
}
if probe.run {
keys.release(KeyCode::KeyW);
@@ -463,121 +330,36 @@ fn drive_live_fps(
/// `drawn=` alone cannot answer "why can I still see that from in here?". It is one number over
/// every model submesh, and the interesting split is not visible-vs-not: it is which *subsystem* the
/// visible thing belongs to and whether anything is gating it at all. A tree that draws through a
/// wall is a completely different defect depending on whether it carries
/// [`crate::exterior_cull::ExteriorScene`] (tagged but admitted — the cull or the bound is wrong) or
/// does not (nothing is gating it — the wrong lane spawned it). Naming which took a screenshot, an
/// asset dig and a wrong guess; this line answers it in one run.
/// wall is a completely different defect depending on whether it is tagged as exterior scene
/// (tagged but admitted — the cull or the bound is wrong) or is not (nothing is gating it — the
/// wrong lane spawned it). Naming which took a screenshot, an asset dig and a wrong guess; this
/// line answers it in one run.
///
/// `WOW_VIS_DUMP=1` then names the models: one `VIS_DUMP` line per distinct visible label, ungated
/// first, most-drawn first — which is the "so WHICH trees are they?" question.
#[derive(Default)]
struct VisCensus {
/// Per [`ModelKind`] index: `(visible, visible-and-gated)`.
kinds: [(usize, usize); 4],
/// `label -> (visible count, gated)`, for the dump.
labels: std::collections::HashMap<(String, bool), usize>,
/// Of every `ExteriorScene`-tagged submesh: how many the cull actually wrote `Hidden` on, and
/// how many carry **no `Aabb`** — the cull's fail-open arm, which admits them unconditionally.
/// A tagged-but-drawn object is one of those two, and they are opposite bugs.
gated_total: usize,
gated_hidden: usize,
gated_no_aabb: usize,
/// Tagged, bounded, NOT exempt, and yet NOT `Hidden` — the escapees, by `(label, is a
/// billboard card)`. **Exempt** means the piece belongs to the placement the camera is standing
/// in, which is not exterior scene to itself (decision 0784) and is *supposed* to draw; without
/// that subtraction this list is all room-you-are-in furniture and says nothing.
escaped: std::collections::HashMap<(String, bool), usize>,
/// The placement the camera is inside, for that subtraction.
own_instance: Option<Entity>,
/// How many tagged pieces were exempt — the number that explains the `tagged` vs `hidden` gap.
exempt: usize,
}
/// What [`VisCensus`] reads off every model submesh — the query shape and its fetched row.
type CensusData = (
&'static ViewVisibility,
&'static crate::debug_panel::ModelPart,
Has<crate::exterior_cull::ExteriorScene>,
Option<&'static crate::interact::WorldObject>,
&'static Visibility,
Option<&'static bevy::camera::primitives::Aabb>,
Has<crate::billboard::BillboardCard>,
Option<&'static crate::wmo_portal::WmoGroupVis>,
);
type CensusRow<'a> = (
&'a ViewVisibility,
&'a crate::debug_panel::ModelPart,
bool,
Option<&'a crate::interact::WorldObject>,
&'a Visibility,
Option<&'a bevy::camera::primitives::Aabb>,
bool,
Option<&'a crate::wmo_portal::WmoGroupVis>,
);
impl VisCensus {
fn add(&mut self, (vis, part, gated, object, want, aabb, card, group): CensusRow) {
if gated {
let exempt = group.is_some_and(|g| Some(g.instance) == self.own_instance);
self.gated_total += 1;
self.gated_hidden += usize::from(*want == Visibility::Hidden);
self.gated_no_aabb += usize::from(aabb.is_none());
self.exempt += usize::from(exempt);
if *want != Visibility::Hidden && aabb.is_some() && !exempt {
let label = object.map_or("<unlabelled>", |o| o.label.as_str());
*self.escaped.entry((label.to_string(), card)).or_default() += 1;
}
}
if !vis.get() {
return;
}
let slot = &mut self.kinds[kind_index(part.kind)];
slot.0 += 1;
slot.1 += usize::from(gated);
if let Some(o) = object {
*self.labels.entry((o.label.clone(), gated)).or_default() += 1;
}
/// first, most-drawn first — which is the "so WHICH trees are they?" question. The counting is
/// [`benilla_world::world_census`]'s; the line shapes below are ours.
fn print_vis_census(seen: &benilla_world::world_census::CensusReport) {
let line = seen
.kinds
.iter()
.map(|(name, vis, gated)| format!("{name}={vis}/gated{gated}"))
.collect::<Vec<_>>()
.join(" ");
println!(
"VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}",
seen.tagged, seen.hidden, seen.exempt, seen.no_aabb
);
// The escapees always print: a tagged, bounded object the cull left un-hidden is a defect by
// construction, and burying it behind a flag is how it stays unnoticed.
for (label, card, n) in &seen.escaped {
let c = if *card { " BILLBOARD-CARD" } else { "" };
println!("VIS_ESCAPED {n}{c} {label}");
}
fn print(&self) {
let line = ["doodad", "wmo", "creature", "gameobject"]
.iter()
.zip(&self.kinds)
.map(|(name, (vis, gated))| format!("{name}={vis}/gated{gated}"))
.collect::<Vec<_>>()
.join(" ");
println!(
"VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}",
self.gated_total, self.gated_hidden, self.exempt, self.gated_no_aabb
);
// The escapees always print: a tagged, bounded object the cull left un-hidden is a defect
// by construction, and burying it behind a flag is how it stays unnoticed.
let mut escapees: Vec<_> = self.escaped.iter().collect();
escapees.sort_by(|a, b| b.1.cmp(a.1));
for ((label, card), n) in escapees {
let c = if *card { " BILLBOARD-CARD" } else { "" };
println!("VIS_ESCAPED {n}{c} {label}");
}
if std::env::var_os("WOW_VIS_DUMP").is_none() {
return;
}
let mut rows: Vec<_> = self.labels.iter().collect();
// Ungated first (the leak candidates), then most-drawn first.
rows.sort_by(|a, b| a.0 .1.cmp(&b.0 .1).then(b.1.cmp(a.1)));
for ((label, gated), n) in rows {
let g = if *gated { "gated" } else { "UNGATED" };
println!("VIS_DUMP {g} {n} {label}");
}
if std::env::var_os("WOW_VIS_DUMP").is_none() {
return;
}
}
/// [`ModelKind`] has a private `index`; the census needs its own (and pins the column order).
fn kind_index(kind: crate::debug_panel::ModelKind) -> usize {
match kind {
crate::debug_panel::ModelKind::Doodad => 0,
crate::debug_panel::ModelKind::Wmo => 1,
crate::debug_panel::ModelKind::Creature => 2,
crate::debug_panel::ModelKind::GameObject => 3,
for (label, gated, n) in &seen.labels {
let g = if *gated { "gated" } else { "UNGATED" };
println!("VIS_DUMP {g} {n} {label}");
}
}
+19 -6
View File
@@ -10,6 +10,8 @@ use bevy::prelude::*;
/// The **actuation** probes — the three channels that make a live session do something
/// headlessly: chat/slash lines, synthetic key presses, and a Lua chunk in the UI VM.
mod fx_draw_census;
pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin;
mod act;
pub(crate) use act::{ProbeChatPlugin, ProbeKeyPlugin, ProbeLuaPlugin};
@@ -68,12 +70,23 @@ mod tests {
#[test]
fn probe_schedules_read_the_wall_clock() {
/// `(file, system, why it is genuinely an age or a delta on the animating clock)`.
const ALLOWED: &[(&str, &str, &str)] = &[(
"capture/mod.rs",
"drive_capture",
"the fixture AGE runs on the clock the effect animates on, and the capture freezes that \
same clock at save time — an age, not a schedule",
)];
const ALLOWED: &[(&str, &str, &str)] = &[
(
"capture/mod.rs",
"drive_capture",
"the fixture AGE runs on the clock the effect animates on, and the capture freezes \
that same clock at save time — an age, not a schedule",
),
(
"capture/waterfx.rs",
"spawn / drive",
"the foam fixture is a SIMULATION rig, not a schedule: it walks a synthetic dummy \
through the shipped emitter path, and the emitter reads its velocity on the same \
animating clock. A wall clock here would desync the dummy from the thing it is \
feeding — the one case where matching the virtual clock is the correctness \
requirement rather than the bug",
),
];
// The needles are assembled at runtime so the checker does not flag **its own source** —
// which is exactly what it did on its first run, and is the cheapest possible proof that it
@@ -58,11 +58,11 @@ fn fire_particle_census(
mut probe: ResMut<ParticleCensus>,
screen: Res<crate::loading_screen::LoadingScreen>,
time: ProbeClock,
view: Res<crate::view::ViewDistance>,
cam: Query<&GlobalTransform, With<crate::player::WorldCamera>>,
view: Res<benilla_world::view::ViewDistance>,
cam: Query<&GlobalTransform, With<benilla_world::view::WorldCamera>>,
emitters: Query<(
&crate::particles::ParticleEmitter,
Option<&crate::particles::EmitterFade>,
&benilla_world::particles::ParticleEmitter,
Option<&benilla_world::particles::EmitterFade>,
Option<&bevy::camera::visibility::RenderLayers>,
)>,
) {
+3 -3
View File
@@ -19,13 +19,13 @@ use super::ProbeClock;
/// it, so a key/chat/Lua probe defends itself exactly like the FPS one.
///
/// **The parking half is the other side of that bill** (decision 1148). Asserting `AlwaysOnTop`
/// every frame silently defeats [`crate::bgwin`]'s whole design — it overrides the
/// every frame silently defeats [`benilla_world::bgwin`]'s whole design — it overrides the
/// `AlwaysOnBottom` birth cage *and* the `Normal` handed back at release — so an instrumented run
/// sits on top for its entire life however politely it was launched. At the full-size default that
/// is a screen-filling window over whatever the director is doing, and a session that fires six
/// probes fires six of them (which is how it got reported). The answer is not to drop the
/// assertion — the throttle is real — but to shrink what is being asserted:
/// [`crate::bgwin::no_pixel_run`] sizes such a run at 640×360 and this parks it in the top-right
/// [`benilla_world::bgwin::no_pixel_run`] sizes such a run at 640×360 and this parks it in the top-right
/// corner. A run that photographs pixels is excluded from both and keeps the full window.
///
/// Write-gated on both counts: re-marking `Window` every frame would re-apply its whole state
@@ -85,7 +85,7 @@ fn keep_probe_window_on_top(
let Ok(mut w) = windows.single_mut() else {
return;
};
let no_pixel = crate::bgwin::no_pixel_run();
let no_pixel = benilla_world::bgwin::no_pixel_run();
let mode = park_mode();
// Only `Corner` needs the pin: the other two get out of the way by *position*, which is the
// point of measuring them — an edge/offscreen window that never reports `occluded_frames` has
+1 -1
View File
@@ -10,7 +10,7 @@ pub(super) struct Scenario {
/// `Map.dbc` id the eye/look coords belong to. Raw WoW coords repeat on every continent — the
/// Felwood spot's tile (`33_24`) exists in Azeroth too, empty — so a scenario that could not
/// name its map silently photographed the wrong world. The harness seeds
/// [`crate::world_map::CurrentMap`] from this before streaming starts (decision 0743).
/// [`benilla_world::world_map::CurrentMap`] from this before streaming starts (decision 0743).
pub(super) map: u32,
/// Camera eye, raw WoW coords `(x, y, z)`.
pub(super) eye: [f32; 3],
@@ -14,9 +14,9 @@ use bevy::prelude::*;
use benilla_assets::coords::wow_to_bevy;
use benilla_protocol::EntityKind;
use crate::capture::FxViewState;
use crate::liquid::{FoamPatch, WaterChunkInfo};
use super::FxViewState;
use crate::net::NetEntity;
use benilla_world::liquid::{FoamPatch, WaterChunkInfo};
/// Which foam behaviour the viewer exercises.
#[derive(Clone, Copy, PartialEq)]
@@ -54,7 +54,7 @@ const CELL: f32 = 33.333_332 / 8.0;
/// Once the capture harness arms the scene, stand up the rig: a dark backdrop plane just under
/// the surface, a synthetic wet-cell lattice (12×12 cells ≈ 50 yd square), and one wading dummy.
/// Sets [`FxViewState::attached_at`] as the age-clock zero.
pub(crate) fn waterfx_spawn(
pub(crate) fn spawn(
mut commands: Commands,
view: Option<Res<WaterFxView>>,
state: Option<ResMut<FxViewState>>,
@@ -101,7 +101,7 @@ pub(crate) fn waterfx_spawn(
WaterChunkInfo::new(
// The fixture stands in for an outdoor lake: ADT-sourced still water, so the
// `liquid_at` delegation answers it for an outdoors subject.
crate::liquid::LiquidSource::AdtChunk,
benilla_world::liquid::LiquidSource::AdtChunk,
benilla_formats::LiquidKind::Still,
[n + 1, n + 1],
positions,
@@ -125,6 +125,15 @@ pub(crate) fn waterfx_spawn(
display_id: None,
scale: 1.0,
},
// Stated at the spawn, not left to `entities::publish_world_units`: the reconciler runs
// between the wire drain and the rest of the frame, and this fixture spawns in
// `WorldStage::Present` — so waiting for it would cost the rig its first frame of foam
// and shift every ripple's age by one step for the whole aged capture.
benilla_world::world_unit::WorldUnit {
kind: EntityKind::Unit,
scale: 1.0,
height: crate::entities::CollisionHeight::default().0,
},
crate::entities::VisualAttached,
Transform::from_translation(wow_to_bevy([start_x, cy, surf - view.depth])),
));
@@ -144,7 +153,7 @@ pub(crate) fn waterfx_spawn(
/// Drive the dummy each frame: translate (wake) or spin (turn) until the age elapses; the foam
/// emitter reads the motion through its normal velocity/yaw proxies.
pub(crate) fn waterfx_drive(
pub(crate) fn drive(
view: Option<Res<WaterFxView>>,
state: Option<Res<FxViewState>>,
time: Res<Time>,
+1 -1
View File
@@ -82,7 +82,7 @@ impl Plugin for CharCreatePlugin {
)
.chain()
.run_if(in_state(ClientState::CharCreate))
.after(crate::schedule::WorldStage::Net),
.after(benilla_world::schedule::WorldStage::Net),
);
}
}
+1 -1
View File
@@ -17,11 +17,11 @@ use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
use bevy::window::PrimaryWindow;
use crate::assets::WorldAssets;
use crate::char_select::{race_name, wow_font};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
use benilla_assets::WorldAssets;
use super::parts::{CharCreateUi, DialRow, DynIcon, DynText, DynTint, StatusLine};
use super::{CreateAction, CreateSelection, ALLIANCE, HORDE, INITIAL_FACING};
+24 -2
View File
@@ -61,6 +61,20 @@ pub(crate) struct CharSelectPlugin {
pub(crate) start_in_world: bool,
}
/// Mirror the session's screen onto the engine's one-bit "is there a world" fact.
///
/// One writer, not a line at each of the dozen sites that set [`ClientState`] — a mirror a future
/// transition can forget is worse than the coupling it replaced.
fn publish_world_live(
state: Res<State<ClientState>>,
mut live: ResMut<benilla_world::schedule::WorldLive>,
) {
let now = benilla_world::schedule::WorldLive(*state.get() == ClientState::InWorld);
if *live != now {
*live = now;
}
}
impl Plugin for CharSelectPlugin {
fn build(&self, app: &mut App) {
app.insert_state(if self.start_in_world {
@@ -71,6 +85,14 @@ impl Plugin for CharSelectPlugin {
ClientState::Login
})
.init_resource::<Roster>()
// **The engine's world-existence bit** (1160's wire (b)): the session owner is this
// module, so this module tells the world whether there is one. Ordered ahead of every
// world stage so the fact and its falling edge are this frame's, not last frame's — which
// is the whole reason `WorldLive` is a resource and not a mirrored state.
.add_systems(
Update,
publish_world_live.before(benilla_world::schedule::WorldStage::Net),
)
.init_resource::<dialog::DeleteDialog>()
.add_systems(OnEnter(ClientState::CharSelect), screen::enter_select)
.add_systems(OnExit(ClientState::CharSelect), screen::exit_select)
@@ -100,7 +122,7 @@ impl Plugin for CharSelectPlugin {
.run_if(in_state(ClientState::CharSelect)),
)
.chain()
.after(crate::schedule::WorldStage::Net),
.after(benilla_world::schedule::WorldStage::Net),
);
}
}
@@ -394,7 +416,7 @@ fn debug_logout_smoke(
commands: Res<crate::net::NetCommands>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
streamer: Res<crate::terrain_stream::TerrainStreamer>,
streamer: Res<benilla_world::terrain_stream::TerrainStreamer>,
time: Res<Time>,
mut exit: MessageWriter<AppExit>,
mut phase: Local<u8>,
+1 -1
View File
@@ -17,7 +17,6 @@ use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
use bevy::window::PrimaryWindow;
use crate::assets::WorldAssets;
use crate::glue::art::{GlueArt, BACKDROP, DIM, GOLD, NAME_EDGE};
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
use crate::glue::widgets::{
@@ -25,6 +24,7 @@ use crate::glue::widgets::{
};
use crate::glue_strings::GlueStrings;
use crate::portrait::{GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
use benilla_assets::WorldAssets;
use super::wow_font;
+2 -2
View File
@@ -60,14 +60,14 @@ use benilla_ui::layout::Rect as GxRect;
use benilla_ui::script::{pieces, Backdrop, Insets};
use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::assets::{AssetSet, WorldAssets};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::net::{Guid, NetEntity, SelfGuid, SelfPlayer};
use crate::player::WorldCamera;
use crate::ui_chat::{default_color, ChatEventKind};
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas};
use crate::vplates::{gx_px, plate_basis, text_px, VPlateSet, VPlates};
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::view::WorldCamera;
/// The two CVars (registrar `0x603280`), host side. **Registered knobs since decision 1139** —
/// they were a pair of `const bool` from 0598 until the options window had a Social page to put
-86
View File
@@ -1,86 +0,0 @@
//! Physics **collision layers** — the two collision *audiences* the world geometry is queried by.
//!
//! The player body and the third-person camera collide against *different* sets of WMO faces (a
//! binary-VERIFIED 1.12.1 fact; see `benilla_formats`'s MOPY mask doc and wow-5875-re
//! `system/collision/collision.md`): the **walking** gather drops DETAIL faces (`0x04`), the
//! **camera/LOS** gather instead drops NOCAMCOLLIDE faces (`0x02`) — so the camera collides with
//! visible decals/overhangs (forge pipes, low beams) the player walks *under*, and passes through
//! NOCAMCOLLIDE faces the player still stands on. avian can't filter a single trimesh per-face, so
//! each WMO bakes two colliders, separated by these layers; the player and camera queries pick their
//! audience by mask. Everything else — terrain, doodads, GameObjects — sits on the default layer and is
//! collided by **both** (so it needs no explicit layer).
use avian3d::prelude::*;
use bevy::ecs::component::Component;
pub(crate) mod one_sided;
/// The collision audiences. avian reserves bit 0 for [`CollisionLayer::Default`], which every collider
/// without an explicit `CollisionLayers` belongs to — so terrain/doodad/GameObject colliders are seen by
/// both the player and camera queries automatically. Only the two per-WMO meshes carry an explicit layer.
#[derive(PhysicsLayer, Default, Clone, Copy)]
pub(crate) enum CollisionLayer {
/// Geometry both audiences collide with: terrain, doodads, GameObjects (the default layer).
#[default]
Default,
/// WMO faces only the **player body** collides with — the walking gather (skip DETAIL `0x04`).
Walk,
/// WMO faces only the **camera** collides with — the camera/LOS gather (skip NOCAMCOLLIDE `0x02`).
Camera,
}
/// `CollisionLayers` for a WMO's **walking** collider — member of [`CollisionLayer::Walk`] only, so the
/// camera query (which omits `Walk`) never hits it.
pub(crate) fn walk_layers() -> CollisionLayers {
CollisionLayers::new(CollisionLayer::Walk, LayerMask::ALL)
}
/// `CollisionLayers` for a WMO's **camera/LOS** collider — member of [`CollisionLayer::Camera`] only, so
/// the player movement query (which omits `Camera`) never hits it.
pub(crate) fn camera_layers() -> CollisionLayers {
CollisionLayers::new(CollisionLayer::Camera, LayerMask::ALL)
}
/// Spatial-query filter for the **player body**: the default layer (terrain/doodads/GameObjects) plus
/// the walk-only WMO faces — and *not* the camera-only WMO faces.
pub(crate) fn player_query_filter() -> SpatialQueryFilter {
SpatialQueryFilter::from_mask(LayerMask(
CollisionLayer::Default.to_bits() | CollisionLayer::Walk.to_bits(),
))
}
/// Spatial-query filter for the **third-person camera**: the default layer plus the camera-only WMO
/// faces — and *not* the walk-only (NOCAMCOLLIDE) WMO faces.
pub(crate) fn camera_query_filter() -> SpatialQueryFilter {
SpatialQueryFilter::from_mask(LayerMask(
CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits(),
))
}
/// Marks a static trimesh collider whose triangles **receive ground decals** (the selection ring).
/// The reference's decal collector is byte-verified (wow-re selection-circle RE, §5-cross-checked):
/// its box query gathers **terrain triangles + WMO group faces** — M2 doodads/GameObjects/units are
/// *never* collected (no collector exists for them; the ring draws under barrels, not onto them) —
/// with the decal draw sites passing flags `0x200122` (terrain on, WMO on, liquid off). The WMO side
/// filters faces only by a MOPY **material-class mask `0x88`** (no walkability/up-facing test — which
/// is why the reference ring drapes down vertical step faces). We mark the WMO **walk** collider as
/// the interim WMO face set — all faces minus DETAIL (`0x04`) — which differs from the reference's
/// minus-`0x88`-classes set only on those flagged faces (exact parity would need a third per-WMO face
/// bake; deferred until the MOPY `0x88` class semantics are RE'd).
#[derive(Component)]
pub(crate) struct GroundDecalSurface;
/// Marks a static collider that **occludes the mouse pick** — the reference's scene trace also
/// traces the world and discards the object hit iff the world hit is *strictly nearer* (wow-re
/// selection-circle RE PART 3, §5-cross-checked 2026-07-20: `0x480df0` @ `0x480eb4`,
/// `CWorld::Intersect 0x672170` mask `0x1000114`), so a unit/GameObject behind a wall is not
/// hoverable. The byte-decoded occluder set, and what carries the mark: **terrain** tiles (DDA
/// `0x69c920`), **WMO group faces** with MOPY reject-mask `0x84` — the **walk** bake (reject
/// `0x04`) is the nearest existing face set, differing only on `0x80`-flagged faces (exact parity
/// needs a third per-WMO bake, the same deferral as the decal path's `0x88`) — and **static
/// default-set map doodad hulls** (`0x69cdb0`; map doodads + WMO props here). NOT marked, per the
/// same decode: liquid (mask bits 16-19 are off on the pick path), server-spawned GameObject
/// hulls and units (they are the *object* trace — a chest must not occlude itself), and transport
/// hulls (the 0466 law: an NPC on deck stays hoverable through the railing).
#[derive(Component)]
pub(crate) struct PickOccluder;
+3 -3
View File
@@ -107,9 +107,9 @@ use bevy::prelude::*;
use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::player::WorldCamera;
use crate::ui_pass::{UiQuadAppend, UiQuads};
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
use benilla_world::view::WorldCamera;
/// Spawn one floating text over `anchor` — written by the `net/apply` handlers (the SMSG spawn
/// table), consumed by [`float_combat_text`]. The emitters' self-suppression (Gate A) happens at
@@ -210,11 +210,11 @@ pub(crate) fn float_combat_text(
"fct: \"{}\" (cat {}) over {:?}",
spawn.text, spawn.category, spawn.anchor
);
if crate::dbg_trace::enabled() {
if benilla_assets::trace::enabled() {
let via_attach = anchors
.get(spawn.anchor)
.is_ok_and(|a| a.points.contains_key(&crate::entities::ATTACH_OVERHEAD));
crate::dbg_trace::line(
benilla_assets::trace::line(
"fct",
&format!(
"spawn \"{}\" cat={} anchor={:?} pos=({:.3},{:.3},{:.3}) attach18={}",
+7 -4
View File
@@ -287,8 +287,8 @@ impl Cooldowns {
if spell.start_recovery_ms == 0 {
return;
}
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"cd",
&format!(
"arm-gcd spell={spell_id} gcdcat={} dur={}ms (cast-send)",
@@ -325,8 +325,11 @@ impl Cooldowns {
}
}
if touched {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("cd", &format!("clear-gcd spell={spell_id} (cast-fail)"));
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"cd",
&format!("clear-gcd spell={spell_id} (cast-fail)"),
);
}
self.prune(now);
self.bump();
+3 -19
View File
@@ -39,8 +39,8 @@ use benilla_assets::{AnimClip, ModelAnimations};
use benilla_formats::AnimDataCatalog;
use bevy::prelude::*;
use crate::assets::AssetSet;
use crate::schedule::WorldStage;
use benilla_assets::AssetSet;
use benilla_world::schedule::WorldStage;
/// The pure animation-selection logic (RF-0057/0073 tables, movement/Special state, gait/swing/ready
/// picks, playback-rate math) — kept in its own file as it carries the bulk of the unit-tested selector
@@ -54,25 +54,12 @@ use select::{Mode, Special};
mod twist;
pub(crate) use twist::{wrap_pi, BodyTwist};
/// Global-sequence bone channels (the eye-blink eyelid scale, resting fidget pulses): free-clock loops
/// the per-sequence reader drops, driven on their own clock over the gait.
mod global_seq;
pub(crate) use global_seq::GlobalSeqDrive;
/// The unit animation-LOD gate (decision 0448): park an off-frustum rig's per-bone pose
/// evaluation — the clocks, the driver state machine, and the event tracks keep running, so
/// off-screen combat stays audible (0075) and a re-appearing unit snaps to the absolute-clock
/// pose. A modernization, not fidelity — the reference animates every in-range unit (wow-re
/// `unit-anim-visibility-gate.md`).
mod lod;
pub(crate) use lod::AnimParked;
mod pose;
pub(crate) use pose::{RigAnchor, RigFrame, RigPose};
/// The collapsed rig's composition passes (decision 0724): the pre-propagation model compose +
/// anchor re-seat, and the post-propagation world pass (palette rows + billboard replacements).
mod compose;
pub(crate) use compose::{finalize_rig_worlds, PosePost};
/// The unit's wielded weapon classes — `(item class, item subclass)` per hand, `None` for an empty
/// (or non-item) hand. Written by the held-item resolution ([`crate::entities`], decision 0072) from
@@ -978,17 +965,14 @@ pub(crate) struct CreatureAnimPlugin;
impl Plugin for CreatureAnimPlugin {
fn build(&self, app: &mut App) {
twist::plugin(app);
global_seq::plugin(app);
lod::plugin(app);
pose::plugin(app);
compose::plugin(app);
breath::register(app);
// The breath classifier is a per-unit environment resolve, not an animation step: it runs
// off the area authority, at its own 10 s cadence, and only publishes the component
// `fire_breath` reads inside the chain below.
app.add_systems(
Update,
classify_breath.after(crate::terrain_stream::AreaAuthoritySet),
classify_breath.after(benilla_world::terrain_stream::AreaAuthoritySet),
);
app.add_message::<AnimSoundEvent>()
.add_message::<SwingMessage>()
@@ -10,8 +10,8 @@ use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::BloodCatalog;
use crate::assets::{LockRecover, WorldAssets};
use crate::net::NetEntity;
use benilla_assets::{LockRecover, WorldAssets};
use super::spell_visual::SpellVisuals;
use super::{SpellKitFx, SwingImpact, SwingMessage};
@@ -81,12 +81,9 @@
use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use benilla_assets::AdtTile;
use crate::area::AreaTableRes;
use crate::entities::Creatures;
use crate::net::{NetEntity, ObjectStore, SelfPlayer};
use crate::terrain_stream::{area_id_under, CurrentArea, TerrainStreamer};
use super::events::AnimSoundEvent;
use super::spell_visual::{FxClass, FxStage, SpellKitFx, SpellVisuals};
@@ -149,11 +146,9 @@ pub(super) fn classify_breath(
mut commands: Commands,
time: Res<Time>,
units: Query<(Entity, &GlobalTransform, Option<&BreathEnv>), With<NetEntity>>,
self_area: Res<CurrentArea>,
self_units: Query<(), With<SelfPlayer>>,
areas: Option<Res<AreaTableRes>>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
world: benilla_world::world_point::WorldPoint,
) {
let Some(areas) = areas else {
return; // no client data — the DBC-resource degrade shape, as everywhere else
@@ -166,9 +161,9 @@ pub(super) fn classify_breath(
// The player's area is `CurrentArea` — the client's own `GetAreaID`, WMO-interior claim
// included. Every other unit gets the outdoor leg alone (module docs).
let area = if self_units.contains(entity) {
self_area.0
world.area()
} else {
area_id_under(&streamer, &adt_tiles, transform.translation())
world.area_id_under(transform.translation())
};
let kind = match area {
Some(id) if areas.0.is_cold(id) => Breath::Cold,
+11 -11
View File
@@ -249,8 +249,8 @@ fn transplant_up(
id,
looping: false,
});
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!("anim transplant unit={entity} id={id} -> key-bone at {seek:.3}s (bone 0 takes the request)"),
);
@@ -909,8 +909,8 @@ pub(super) fn drive_animations(
active.set_speed(2.0);
}
drv.deferred = Some(id);
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"anim fastpath unit={entity} cur={cur} req={id} (cur 2x, req deferred)"
@@ -934,8 +934,8 @@ pub(super) fn drive_animations(
let base_live = matches!(drv.mode, Mode::Swing { id: m, .. } if m == id)
&& !oneshot_finished(&player, anims, id, catalog);
if overlay_live || base_live {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"anim dedup-eat unit={entity} id={id} overlay_live={overlay_live} base_live={base_live}"
@@ -1009,8 +1009,8 @@ pub(super) fn drive_animations(
base_played = true;
played_oneshot = Some(id);
}
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"anim play unit={entity} id={id} masked={masked_played} base={base_played} under={special:?}"
@@ -1221,7 +1221,7 @@ pub(super) fn drive_animations(
// frame's PlayAnimation calls).
overlay_fade_upkeep(&mut drv, &mut player, dt);
// The anim half of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]): one line per
// The anim half of the `WOW_MOVE_TRACE` debug trace ([`benilla_assets::trace`]): one line per
// frame the SELF unit's settled anim state *changes* — mode, gait, Special, flags, the
// rate-driving speed, and **both slots** — interleaved with the mover's `move` lines on the
// same clock, so a feel report ("the char's frames snap on landing") pins which layer moved
@@ -1235,7 +1235,7 @@ pub(super) fn drive_animations(
// goes slow when I land") is unreadable without it: the request was JumpLandRun 187, the
// clip playing was Run 5, and `rate=` was the wrong number for exactly that clip
// (decision 0906). `?` = the slot holds a node this model has no clip record for.
if traced && crate::dbg_trace::enabled() {
if traced && benilla_assets::trace::enabled() {
let base = tr
.get_main_animation()
.and_then(|n| anims.clips.iter().find(|c| c.node == n))
@@ -1261,7 +1261,7 @@ pub(super) fn drive_animations(
},
);
if anim_trace_last.get(&entity) != Some(&state) {
crate::dbg_trace::line("anim", &state);
benilla_assets::trace::line("anim", &state);
anim_trace_last.insert(entity, state);
}
}
@@ -482,11 +482,11 @@ pub(super) fn run(
// watchdog window). A re-armed Stand landing on its rare look-around
// variations IS the idle fidget.
let (c, budget) = roll_loop(anims, c, relaxed, rng);
if traced && crate::dbg_trace::enabled() {
if traced && benilla_assets::trace::enabled() {
// Every fresh gait play, including a same-clip replay (which the
// settled-state diff below cannot see) — the exact restart-from-head
// event a "frames snap" report is hunting.
crate::dbg_trace::line(
benilla_assets::trace::line(
"anim",
&format!(
"{subject}PLAY gait {} (was {:?}) rate {:.2}",
@@ -28,7 +28,7 @@
use bevy::prelude::*;
use crate::assets::{LockRecover, WorldAssets};
use benilla_assets::{LockRecover, WorldAssets};
/// The loaded 6-slot table (`None` until the startup load lands; absent = no environmental
/// feedback kits, like every optional DBC face).
@@ -134,8 +134,8 @@ pub(super) fn route_swing_impacts(
defended: false,
},
) {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"flush supersede atk={:?} dmg={}",
@@ -155,8 +155,8 @@ pub(super) fn route_swing_impacts(
// Fires once: the tag consumes the record (`0x6247d0` clears the GUIDs after
// dispatch) — a `$CPP` authored after this frame finds nothing (mutual exclusion).
if let Some(p) = pending.0.remove(&ev.entity) {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"impact tag={} atk={:?} dmg={}",
@@ -175,8 +175,8 @@ pub(super) fn route_swing_impacts(
text_only: false,
natural,
});
} else if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
} else if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"impact tag={} atk={:?} NO-PENDING",
@@ -208,8 +208,8 @@ pub(super) fn route_swing_impacts(
for SwingFlush(attacker) in flushes.read() {
// SMSG_ATTACKSTOP → 0x624e40: text-only flush + clear (death/stun arrive as this packet).
if let Some(p) = pending.0.remove(attacker) {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!("flush stop atk={:?} dmg={}", attacker, p.swing.damage),
);
+11 -17
View File
@@ -23,14 +23,14 @@
//! pose evaluator ([`super::pose`]) skips a parked rig, so its bone `Transform`s stop changing,
//! which quiets transform propagation and `extract_skins`' changed-joint upload by change
//! detection. The pose post-passes (body twist, global-sequence writes, billboard joint palette)
//! gate on the same marker. Contrast the DOODAD gate ([`crate::doodad_anim`]), which faithfully
//! gate on the same marker. Contrast the DOODAD gate ([`benilla_world::doodad_anim`]), which faithfully
//! stops the player and re-arms on the shared clock — correct there (no one consumes a doodad's
//! events off-screen), the exact 0075 trap here.
//!
//! **The room leg (decision 0739).** The frustum is the wrong instrument indoors: a dungeon
//! camera's view cone passes through walls, so most of an instance's population stays "in
//! frustum" while only the current room chain is drawable. The portal PVS
//! ([`crate::wmo_portal`], the faithful WMO group cull) already knows which rooms the camera can
//! ([`benilla_world::wmo_portal`], the faithful WMO group cull) already knows which rooms the camera can
//! reach, and [`UnitWmoRoom`] already names each unit's room — so a unit whose room (and every
//! room one portal hop from it) is outside this frame's PVS parks too. Soundness: a unit is
//! room-parked exactly when the geometry *around* it — its room's walls, floor, furniture — is
@@ -40,7 +40,7 @@
//! non-zero the flood marks its room visible (`compute_wmo_pvs` runs in `Update`, this gate in
//! `PostUpdate` before the evaluator — same-frame wake, absolute-clock pose). Fail-open at every
//! seam: no claim (outdoors, EXTERIOR groups), a despawned placement, a still-loading model, an
//! out-of-range index — all read visible, the [`crate::wmo_portal::WmoGroupVis::drawn_by`]
//! out-of-range index — all read visible, the [`benilla_world::wmo_portal::WmoGroupVis::drawn_by`]
//! convention.
//! `WOW_NO_ROOM_LOD=1` disables just this leg (the A/B lever); `WOW_RIG_COST=1` prints the
//! per-frame `[rig-gate]` counters.
@@ -54,17 +54,11 @@ use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalRef};
use crate::entities::mount::MountBody;
use crate::net::SelfPlayer;
use crate::player::WorldCamera;
use crate::target::SelectionRadius;
use crate::wmo_portal::{UnitWmoRoom, WmoPortalInstance, WmoRoom};
use benilla_world::view::WorldCamera;
use benilla_world::wmo_portal::{UnitWmoRoom, WmoPortalInstance, WmoRoom};
use super::RigPose;
/// This rig's per-bone pose evaluation is parked (decision 0448): the pose evaluator and the pose
/// post-passes skip it. The sequence clocks, the driver state machine, and the event scanner all
/// keep running — parking turns *sampling* off, nothing else.
#[derive(Component)]
pub(crate) struct AnimParked;
use benilla_world::rig_anim::{AnimParked, RigPose};
/// Park only after the rig has been continuously out of frustum this long — a camera swing
/// across the pack doesn't churn bone repoints. Waking is always instant.
@@ -131,7 +125,7 @@ pub(super) fn gate_rig_animation(
// rigs the frustum keeps that the PVS rejects — so a `WOW_NO_ROOM_LOD=1` leg reads the
// premise number without applying the leg (counted either way, applied only when the lever
// is off).
let cost_on = crate::rig_palette::rig_cost_enabled();
let cost_on = benilla_world::rig_palette::rig_cost_enabled();
let (mut n_rigs, mut n_parked, mut room_out) = (0u32, 0u32, 0u32);
let Ok(frustum) = cam.single() else {
return;
@@ -208,7 +202,7 @@ fn room_pvs_visible(
/// Is the claimed group — or any group one portal hop from it — in the PVS? The one-hop union is
/// the doorway-straddle guard: a body can extend past its feet's room only through a portal
/// opening, so the neighbour set bounds everything of the unit that could be on screen. Indices
/// out of range read visible (fail-open, [`crate::wmo_portal::WmoGroupVis::drawn_by`]'s
/// out of range read visible (fail-open, [`benilla_world::wmo_portal::WmoGroupVis::drawn_by`]'s
/// convention); a group with no portal refs at all can only be seen with the camera inside it
/// (the flood cannot reach it), which the direct bit already answered.
fn room_visible(visible: &[bool], nav: &[WmoGroupNav], refs: &[WmoPortalRef], group: u16) -> bool {
@@ -349,7 +343,7 @@ mod tests {
};
app.world_mut()
.entity_mut(root)
.insert(super::super::RigPose::new(root, &skeleton));
.insert(benilla_world::rig_anim::RigPose::new(root, &skeleton));
(root, root)
}
@@ -373,7 +367,7 @@ mod tests {
// The live schedule's shape: the gate ahead of the frame's pose evaluation; the event
// scanner reading the advanced seek after it.
app.add_systems(PostUpdate, gate_rig_animation.before(AnimationSystems));
super::super::pose::plugin(&mut app);
benilla_world::rig_anim::plugin(&mut app);
app.add_systems(PostUpdate, fire_anim_events.after(AnimationSystems));
app
}
@@ -396,7 +390,7 @@ mod tests {
fn bone(app: &App, rig: Entity) -> Vec3 {
app.world()
.entity(rig)
.get::<super::super::RigPose>()
.get::<benilla_world::rig_anim::RigPose>()
.unwrap()
.locals[0]
.translation
@@ -8,7 +8,7 @@ use benilla_assets::ModelAnimations;
use benilla_formats::AnimDataCatalog;
use bevy::prelude::*;
use crate::assets::{LockRecover, WorldAssets};
use benilla_assets::{LockRecover, WorldAssets};
use super::{find_resolved, select, AnimDriver, Wielded};
@@ -14,11 +14,11 @@ use bevy::prelude::*;
use benilla_formats::{SpellVisualCatalog, VisualKit, VisualStages, MISSILE_ATTACH_TABLE};
use benilla_protocol::EntityKind;
use crate::assets::{LockRecover, WorldAssets};
use crate::aura_visual::AuraProc;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore};
use benilla_assets::{LockRecover, WorldAssets};
use super::{CastEvent, CastEventKind, CastHold, EmoteAnim, SpellGoTargets, WoundAnim};
@@ -107,7 +107,7 @@ fn armed_shares(gap: f32, mounted: bool) -> (f32, f32) {
}
/// Compose the counter-twist onto the animated bone locals — PostUpdate, in the pose post-pass
/// window ([`super::PosePost`]: after the evaluator wrote this frame's pose, before the model
/// window ([`benilla_world::rig_anim::PosePost`]: after the evaluator wrote this frame's pose, before the model
/// compose folds it).
///
/// Each channel yaws its subtree about **world up through the bone's own pivot**: with `g` the
@@ -118,14 +118,14 @@ fn armed_shares(gap: f32, mounted: bool) -> (f32, f32) {
/// twisted spine, exactly the client's residual-gap composition. The ancestor walk runs up the
/// rig's own parent table, then the entity frames between its `joints_root` and the unit (a
/// conform node's tilt; a mounted rider's seat — splicing through the mount's bone chain via its
/// [`super::RigAnchor`]), exactly the frames the joint-entity walk used to compose.
/// [`benilla_world::rig_anim::RigAnchor`]), exactly the frames the joint-entity walk used to compose.
pub(super) fn apply_body_twist(
// A parked rig's bones are frozen (decision 0448) — composing the twist onto them would
// recompute the palette every frame for a unit no one sees; the wake re-seats `base` from the
// fresh sample on its own (`cur != last_out`).
mut units: Query<(Entity, &mut BodyTwist), Without<super::AnimParked>>,
mut rigs: Query<&mut super::RigPose>,
anchors: Query<&super::RigAnchor>,
mut units: Query<(Entity, &mut BodyTwist), Without<benilla_world::rig_anim::AnimParked>>,
mut rigs: Query<&mut benilla_world::rig_anim::RigPose>,
anchors: Query<&benilla_world::rig_anim::RigAnchor>,
parents: Query<&ChildOf>,
locals: Query<&Transform>,
stores: Query<&crate::net::ObjectStore>,
@@ -212,7 +212,10 @@ pub(super) fn apply_body_twist(
/// Register [`apply_body_twist`] in the pose post-pass window.
pub(super) fn plugin(app: &mut App) {
app.add_systems(PostUpdate, apply_body_twist.in_set(super::PosePost));
app.add_systems(
PostUpdate,
apply_body_twist.in_set(benilla_world::rig_anim::PosePost),
);
}
#[cfg(test)]
+6 -6
View File
@@ -28,7 +28,7 @@
//! the pointer sits at the icon's top-left corner, the icon hangs down-right, matching the
//! reference look (and `ui_script::extract::cursor_icon_quad`'s capture-only stand-in).
use crate::assets::AssetSet;
use benilla_assets::AssetSet;
use bevy::prelude::*;
/// The held cursor payload's icon path (`Interface\Icons\…`, extensionless — the DBC/FrameXML
@@ -46,7 +46,7 @@ fn payload_icon(script: &benilla_ui::script::UiScript) -> Option<String> {
}
/// Box-downsample a raw RGBA8 image (top-to-bottom raster order —
/// [`crate::assets::WorldAssets::decode_rgba`]'s layout) to a fixed 32×32 buffer: each output
/// [`benilla_assets::WorldAssets::decode_rgba`]'s layout) to a fixed 32×32 buffer: each output
/// texel averages a `(w/32)×(h/32)` block of the source. Vanilla item icons are 64×64 — a clean
/// 2×2 average per output texel (decision 0216 §5) — but this degrades gracefully for any other
/// source size.
@@ -87,7 +87,7 @@ fn box_downsample_32(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> {
/// (byte-verified, wow-re cursor-dragdrop-payload.md: 64×64 → 2×2 box filter → 32×32, alpha
/// written 0xFF; folded back by 0218). `None` on a missing/undecodable icon.
fn decode_payload_cursor_rgba(
assets: &mut crate::assets::WorldAssets,
assets: &mut benilla_assets::WorldAssets,
path: &str,
) -> Option<Vec<u8>> {
let (w, h, rgba) = assets.decode_rgba(path)?;
@@ -297,7 +297,7 @@ impl Plugin for CursorPlugin {
#[cfg(not(target_os = "macos"))]
mod other {
use super::{cursor_path, payload_icon, CURSOR_STEMS};
use crate::assets::{cursor_texture, WorldAssets};
use benilla_assets::{cursor_texture, WorldAssets};
use bevy::platform::collections::{HashMap, HashSet};
use bevy::prelude::*;
use bevy::window::{CursorIcon, CustomCursor, CustomCursorImage, PrimaryWindow};
@@ -418,9 +418,9 @@ mod other {
mod macos {
use std::collections::{HashMap, HashSet};
use crate::assets::LockRecover;
use crate::assets::WorldAssets;
use crate::player::CameraControl;
use benilla_assets::LockRecover;
use benilla_assets::WorldAssets;
use benilla_formats::read_texture_rgba;
use bevy::prelude::*;
use objc2::rc::Retained;
+2 -2
View File
@@ -29,7 +29,6 @@ use std::time::Instant;
use bevy::prelude::*;
use crate::chat_bubble::BubbleConfig;
use crate::clutter::ClutterConfig;
use crate::minimap::MinimapZoom;
use crate::nameplates::NameConfig;
use crate::player::camera::{LookConfig, ZoomLimit, MOUSE_SPEED_RANGE};
@@ -37,9 +36,10 @@ use crate::sound::SoundConfig;
use crate::target::ClickConfig;
use crate::ui_loot::LootConfig;
use crate::ui_script::UiScaleCvar;
use crate::view::{ViewDistance, FARCLIP_RANGE};
use benilla_ui::script::UiScript;
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
use benilla_world::clutter::ClutterConfig;
use benilla_world::view::{ViewDistance, FARCLIP_RANGE};
/// The host-backed CVars: `(registered name, default)`. Grows one row per knob a settings page
/// actually wires — never ahead of the knob (see the module doc).
+2 -2
View File
@@ -153,7 +153,7 @@ fn feed_death(
net: Res<NetCommands>,
index: Res<GuidIndex>,
transforms: Query<&Transform>,
map: Option<Res<crate::world_map::CurrentMap>>,
map: Option<Res<benilla_world::world_map::CurrentMap>>,
) {
let Some(mut script) = script else {
return;
@@ -330,7 +330,7 @@ fn sickness_duration(level: u32) -> Option<String> {
/// the consumers across their owning systems off the same derived flag).
fn drive_death_look(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut ffx: ResMut<crate::ffx_glow::FfxDeathFade>,
mut ffx: ResMut<benilla_world::ffx_glow::FfxDeathFade>,
) {
let ghost = self_q.single().is_ok_and(|store| store.0.player_is_ghost());
let target = if ghost { 1.0 } else { 0.0 };
@@ -2,19 +2,24 @@
//! card that follows the cursor over whatever [`MouseoverTarget`] picked. Split from the module
//! face for size only; the toggle, the card, and its readout lines live here unchanged.
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy_egui::{egui, EguiContexts};
use crate::debug_panel::{overlay_text, ModelKind, DEV_CHORD, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use super::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::net::ObjectStore;
use super::{InspectMode, MouseoverTarget, WorldObject};
use crate::ui_script::PointerOverUi;
use benilla_world::interact::{pick_at_cursor, PickParts, WorldObject};
use benilla_world::model_render::ModelKind;
use benilla_world::modkeys::DEV_CHORD;
use benilla_world::view::WorldCamera;
/// The **dev chord + `I`** (for *inspect*) arms/disarms the inspector — off the bare-letter plane the
/// game's own bindings own (0585), on whichever plane this OS leaves free (0867). Unmistakable as a
/// chord, so unlike the old bare `i` it needs no chat-bar/EditBox gate.
pub(super) fn toggle_inspect(keys: Res<ButtonInput<KeyCode>>, mut inspect: ResMut<InspectMode>) {
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyI) {
if benilla_world::modkeys::dev_chord(&keys, KeyCode::KeyI) {
inspect.enabled = !inspect.enabled;
}
}
@@ -48,8 +53,8 @@ type GoCollisionReadout = (
/// like the room and one like the street" is the report that made this a card line rather than a
/// rebuild with `WOW_INTERIOR_LOG`.
type EntityLightReadout = (
&'static crate::interior::InteriorAnchor,
Has<crate::interior::ContainmentAttach>,
&'static benilla_world::interior::InteriorAnchor,
Has<benilla_world::interior::ContainmentAttach>,
);
/// Everything the identity card reads off the **net entity** under the cursor, as one named
@@ -471,3 +476,63 @@ pub(super) fn inspect_ui(
});
Ok(())
}
/// Whether mouseover picking runs. Today it's armed/disarmed by the **dev chord + `I`** inspector toggle
/// ([`toggle_inspect`]).
#[derive(Resource, Default)]
pub struct InspectMode {
pub enabled: bool,
}
/// The nearest [`WorldObject`] under the cursor this frame, or `None`. Consumers read `entity` and look
/// up its [`WorldObject`]; `point`/`distance` are the world-space hit.
#[derive(Resource, Default)]
pub struct MouseoverTarget {
pub entity: Option<Entity>,
pub point: Vec3,
pub distance: f32,
}
/// Ray-cast from the cursor into the world and record the nearest [`WorldObject`] hit. Restricted to
/// entities carrying `WorldObject` (so terrain, particle billboards, and other un-identified meshes are
/// transparent to the pick), and skipped entirely unless inspection is active.
pub(super) fn update_mouseover(
inspect: Res<InspectMode>,
pointer_over_ui: Res<PointerOverUi>,
mut target: ResMut<MouseoverTarget>,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
objects: Query<Entity, With<WorldObject>>,
parts: PickParts,
) {
if !inspect.enabled {
if target.entity.is_some() {
*target = MouseoverTarget::default();
}
return;
}
target.entity = None;
// The pointer is over the dev UI (e.g. the now-overlaid debug panel), not the world — don't pick
// behind it. This replaces the old "is the cursor in the inset world viewport?" test, which no
// longer means anything now the panel overlays a full-screen view.
if pointer_over_ui.0 {
return;
}
let Ok((camera, cam_tf)) = camera.single() else {
return;
};
let Ok(window) = window.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return; // cursor left the window, or we're in mouselook (hidden)
};
let identified: HashSet<Entity> = objects.iter().collect();
if let Some((entity, point, distance)) =
pick_at_cursor(cursor, camera, cam_tf, &identified, &parts)
{
target.entity = Some(entity);
target.point = point;
target.distance = distance;
}
}
+87 -225
View File
@@ -34,19 +34,19 @@ use bevy_egui::{
EguiPrimaryContextPass, PrimaryEguiContext,
};
use benilla_formats::ModelBlend;
use benilla_world::lighting::{ClockSource, GameClock, WowLighting};
use benilla_world::model_render::{ModelKind, ModelPart};
use benilla_world::modkeys::{dev_chord, DEV_CHORD};
use benilla_world::view::ViewDistance;
use crate::lighting::{ClockSource, GameClock, WowLighting};
use crate::view::ViewDistance;
mod inspect;
mod journal;
/// The debug state — resource-only, faithful defaults (decision 0026: the always-present config
/// layer; this module is only its editor).
mod state;
pub use state::DebugState;
/// The per-frame model-visibility apply system (toggles + the far-clip wall-cull + small-prop fade).
mod visibility;
use visibility::apply_model_visibility;
/// The dev state this panel edits — resource-only, faithful defaults (decision 0026: the
/// always-present config layer; this module is only its editor). The engine owns and inits it,
/// and the per-frame model-visibility apply system that reads its toggles is `model_render`'s.
use benilla_world::dev_state::DebugState;
use benilla_world::model_render::{blend_index, kind_index};
/// The World section — identity/map/zone/position readout + the copy-`.go xyz` affordance.
mod world;
@@ -112,53 +112,23 @@ fn track_pointer_over_ui(mut contexts: EguiContexts, mut over: ResMut<EguiPointe
Ok(())
}
/// Which world-model subsystem a spawned submesh belongs to — lets the panel scope toggles (e.g.
/// hide doodads without touching NPCs).
#[derive(Component, Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum ModelKind {
Doodad,
Wmo,
Creature,
GameObject,
}
/// The panel's display order for [`ModelKind`], and the label it writes. Free functions rather
/// than an inherent `impl`: the type is engine vocabulary now (`model_render`) and an inherent impl
/// cannot follow it across the crate boundary decision 1160 is drawing — the *panel's* opinion
/// about how to present it belongs to the panel either way.
const MODEL_KINDS: [ModelKind; 4] = [
ModelKind::Doodad,
ModelKind::Wmo,
ModelKind::Creature,
ModelKind::GameObject,
];
impl ModelKind {
const ALL: [ModelKind; 4] = [Self::Doodad, Self::Wmo, Self::Creature, Self::GameObject];
fn index(self) -> usize {
match self {
Self::Doodad => 0,
Self::Wmo => 1,
Self::Creature => 2,
Self::GameObject => 3,
}
}
fn label(self) -> &'static str {
match self {
Self::Doodad => "Doodads (trees/props)",
Self::Wmo => "WMOs (buildings)",
Self::Creature => "Creatures (NPCs)",
Self::GameObject => "GameObjects",
}
}
}
/// Tags every spawned model submesh with the metadata the panel toggles on: its subsystem and its
/// blend mode (the "layer" — opaque trunk vs alpha-cut canopy).
#[derive(Component, Clone, Copy)]
pub struct ModelPart {
pub kind: ModelKind,
pub blend: ModelBlend,
}
fn blend_index(b: ModelBlend) -> usize {
match b {
ModelBlend::Opaque => 0,
ModelBlend::AlphaTest => 1,
ModelBlend::Blend => 2,
ModelBlend::Mod => 3,
ModelBlend::Mod2x => 4,
fn kind_label(kind: ModelKind) -> &'static str {
match kind {
ModelKind::Doodad => "Doodads (trees/props)",
ModelKind::Wmo => "WMOs (buildings)",
ModelKind::Creature => "Creatures (NPCs)",
ModelKind::GameObject => "GameObjects",
}
}
@@ -183,12 +153,9 @@ fn source_label(s: ClockSource) -> &'static str {
}
}
/// Ordering handle so the one system allowed to *override* the model-`Visibility` authority — the
/// self-avatar first-person hide ([`crate::player`]) — can run **after** it and win the frame.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ModelVisSet;
/// Adds the egui plugin, the debug-state resource, the panel UI, and the apply systems.
pub(crate) use inspect::{InspectMode, MouseoverTarget};
pub struct DebugPanelPlugin;
impl Plugin for DebugPanelPlugin {
@@ -206,43 +173,51 @@ impl Plugin for DebugPanelPlugin {
egui.enable_cursor_icon_updates = false;
}
app.insert_resource(DebugState {
// `$WOW_PANEL=1` — start with the panel **open**, which is how a headless capture run
// gets it into the frame. Without it a panel change (a new footer line, a section that
// grew past the scroll reserve) can only be checked in the director's own window, and
// clipping is exactly the failure a capture catches for free.
open: std::env::var_os("WOW_PANEL").is_some(),
..default()
})
.init_resource::<EguiPointerOver>()
.add_systems(Startup, spawn_egui_camera)
// Keep Tab out of egui: it's a bound game key, never focus-navigation for our overlays.
// Runs after bevy_egui fills `EguiInput` and before egui's pass consumes it (the seam
// bevy_egui documents for input edits).
.add_systems(
PreUpdate,
strip_egui_tab_focus
.after(EguiPreUpdateSet::ProcessInput)
.before(EguiPreUpdateSet::BeginPass),
)
.add_systems(
EguiPrimaryContextPass,
(debug_panel_ui, track_pointer_over_ui),
)
// `apply_model_visibility` reads the WMO portal PVS, so it runs after the compute that
// fills it (`crate::wmo_portal::WmoPvsSet`).
.add_systems(
Update,
(
// No ordering against the UI keyboard feed any more: the toggle is a dev chord
// (1043), which no focused EditBox can consume and which needs no capture gate —
// the same reason `perf`'s and `sound`'s toggles never needed one.
toggle_panel,
apply_model_visibility
.after(crate::wmo_portal::WmoPvsSet)
.in_set(ModelVisSet),
),
);
// `DebugState` itself is `benilla_world::dev_state`'s and the engine inits it — this panel is only
// its editor (decision 0026), and eight other subsystems read it whether or not the panel
// is installed.
app.init_resource::<EguiPointerOver>()
// The inspector surface and the cast journal — the two instruments that stood on
// `interact` and were registered by it until decision 1160's stage zero. The mouseover
// pick comes with them: it never ran except while the inspector was armed, so it was never
// the engine's picking, only this overlay's.
.init_resource::<InspectMode>()
.init_resource::<MouseoverTarget>()
.init_resource::<journal::CastJournal>()
// After the UI keyboard feed because `update_mouseover` reads `PointerOverUi`, whose
// player-UI half `UiInput` writes — the pick must see this frame's hover, not last
// frame's. (`toggle_inspect` itself no longer needs the ordering: its dev chord can't be
// typed text, so it reads no keyboard-capture flag — decision 0585.)
.add_systems(
Update,
(inspect::toggle_inspect, inspect::update_mouseover)
.chain()
.after(crate::ui_script::UiInput),
)
// Always recording (messages persist two frames — no ordering constraint needed).
.add_systems(Update, journal::record_casts)
.add_systems(
EguiPrimaryContextPass,
(inspect::inspect_ui, journal::journal_ui),
)
.add_systems(Startup, spawn_egui_camera)
// Keep Tab out of egui: it's a bound game key, never focus-navigation for our overlays.
// Runs after bevy_egui fills `EguiInput` and before egui's pass consumes it (the seam
// bevy_egui documents for input edits).
.add_systems(
PreUpdate,
strip_egui_tab_focus
.after(EguiPreUpdateSet::ProcessInput)
.before(EguiPreUpdateSet::BeginPass),
)
.add_systems(
EguiPrimaryContextPass,
(debug_panel_ui, track_pointer_over_ui),
)
// No ordering against the UI keyboard feed: the toggle is a dev chord (1043), which no
// focused EditBox can consume and which needs no capture gate — the same reason `perf`'s
// and `sound`'s toggles never needed one.
.add_systems(Update, toggle_panel);
}
}
@@ -280,86 +255,26 @@ fn toggle_panel(keys: Res<ButtonInput<KeyCode>>, mut debug: ResMut<DebugState>)
}
}
/// How the dev plane is written on screen. Every surface that names a chord reads this instead of
/// spelling one out, so the panel footer, the inspector badge and the mute checkbox can't drift from
/// what [`dev_chord`] actually listens for.
pub(crate) const DEV_CHORD: &str = "Ctrl+Shift";
/// Did the **dev-overlay chord** — [`DEV_CHORD`]+*key* — just fire? (decisions 0585, 0867, 0870,
/// 1043 — which moved the last two dev keys onto it, so the whole fleet is here now.)
///
/// The dev instruments used to sit on bare letters, which is a namespace we don't own: every letter is
/// a *game* binding in the reference client, so `P` both opened the spellbook and toggled the perf HUD
/// (0585). They moved to `Ctrl`+`Cmd`, and then off it: on Windows that is `Ctrl`+`Win`, a plane the
/// shell owns and keeps extending — `Win+Ctrl+M` is Magnifier settings, which had our mute (0867).
///
/// **`Ctrl`+`Shift`, one plane on every OS** (0870, director's call). The alternative was keeping
/// `Ctrl`+`Cmd` on macOS for its one real advantage — Cmd is outside the reference's binding namespace
/// (1.12 builds binding names from `ALT-`/`CTRL-`/`SHIFT-` only), so nothing in game could *ever* claim
/// it. That buys protection against a binding no default declares and no player has yet written, and
/// it costs a per-OS split in the docs, the hints and the reader's head. One plane wins. It also drops
/// our dependence on winit's `sendEvent:` swizzle, without which AppKit's swallowed `keyUp` under Cmd
/// would latch a chord after one use (0585's macOS risk, now simply not run).
///
/// `Ctrl`+`Shift` is the emptiest plane the reference *can* name: of its 152 defaults, exactly two
/// carry two modifiers — `CTRL-SHIFT-TAB` and `CTRL-SHIFT-PAGEDOWN` — and no letter at all.
/// `Ctrl`+`Alt` was never available: that is AltGr, which European layouts type real characters with
/// (decision 0702).
///
/// **Exactly those two modifiers and no others**, both sides of each. The block is what makes this
/// plane safe to leave ungated below: AltGr+Shift+*key* is `Ctrl`+`Alt`+`Shift`+*key*, and a German
/// layout typing one of those into chat must not fire an overlay.
///
/// **"No letter at all" was half the story, and the missing half cost us a plane's worth of safety**
/// (decision 1142). The reference does not match bindings by equality alone: an exact miss re-probes
/// **once** with the leftmost modifier dropped, so `CTRL-SHIFT-`*key* falls through to
/// `SHIFT-`*key* — never to the bare letter, which is why this plane survived the correction at all,
/// but far enough that `Ctrl`+`Shift`+`P` would open the pet paper doll (`SHIFT-P`,
/// `TOGGLECHARACTER3`) under the perf HUD. So the other half of the rule now lives where the law
/// itself does, in `bindings::BindingDispatch::resolve`: this plane spends the keyboard's
/// **fallback probe**, and only that — an exact `CTRL-SHIFT-` binding, the reference's two included,
/// still dispatches normally.
///
/// Deliberately **not** gated on [`crate::ui_script::UiKeyboardCapture`] the way the bare-key toggles
/// were: a chord can't be mistaken for typed text, so the dev overlays stay reachable with the chat bar
/// open.
pub(crate) fn dev_chord(keys: &ButtonInput<KeyCode>, key: KeyCode) -> bool {
dev_plane(keys) && keys.just_pressed(key)
}
/// The modifier half of [`dev_chord`]. See there for why the plane is what it is.
fn dev_plane(keys: &ButtonInput<KeyCode>) -> bool {
let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);
let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
let blocked = keys.any_pressed([
KeyCode::AltLeft,
KeyCode::AltRight,
KeyCode::SuperLeft,
KeyCode::SuperRight,
]);
ctrl && shift && !blocked
}
/// Draw the panel as a translucent **overlay** on the right — the world renders full-screen
/// underneath (no viewport inset). `ui_script::PointerOverUi` keeps the cursor's panel
/// interactions from leaking into gameplay mouse-look.
#[allow(clippy::too_many_arguments)]
fn debug_panel_ui(
mut contexts: EguiContexts,
stamp: Res<crate::build_id::BuildId>,
stamp: Res<benilla_world::build_id::BuildId>,
mut debug: ResMut<DebugState>,
mut view: ResMut<ViewDistance>,
clock: Res<GameClock>,
lighting: Res<WowLighting>,
parts: Query<&ModelPart>,
anim_hosts: Query<&crate::doodad_anim::DoodadAnimHost>,
mat_anims: Query<&crate::doodad_anim::MatAnim>,
uv_mats: Res<crate::doodad_anim::UvAnimMaterials>,
anim_hosts: Query<&benilla_world::doodad_anim::DoodadAnimHost>,
mat_anims: Query<&benilla_world::doodad_anim::MatAnim>,
uv_mats: Res<benilla_world::doodad_anim::UvAnimMaterials>,
mut sound_cfg: ResMut<crate::sound::SoundConfig>,
mut cull_probe: ResMut<crate::wmo_portal::WmoCullProbe>,
mut cull_probe: ResMut<benilla_world::wmo_portal::WmoCullProbe>,
net_status: Res<crate::net::NetStatus>,
dropped: Res<crate::net::DroppedOpcodes>,
mut weather_state: Option<ResMut<crate::weather::WeatherState>>,
mut weather_state: Option<ResMut<benilla_world::weather::WeatherState>>,
mut world: WorldReadout,
) -> Result {
if !debug.open {
@@ -370,7 +285,7 @@ fn debug_panel_ui(
let mut kind_counts = [0u32; 4];
let mut blend_counts = [0u32; 5];
for p in &parts {
kind_counts[p.kind.index()] += 1;
kind_counts[kind_index(p.kind)] += 1;
blend_counts[blend_index(p.blend)] += 1;
}
@@ -432,10 +347,10 @@ fn debug_panel_ui(
ui.add_space(6.0);
ui.strong("Type");
for k in ModelKind::ALL {
for k in MODEL_KINDS {
ui.checkbox(
&mut m.kind_visible[k.index()],
format!("{} · {}", k.label(), kind_counts[k.index()]),
&mut m.kind_visible[kind_index(k)],
format!("{} · {}", kind_label(k), kind_counts[kind_index(k)]),
);
}
// The doodad-animation cost meter (decision 0130): how many placed
@@ -484,7 +399,7 @@ fn debug_panel_ui(
// shared with the `$WOW_FARCLIP` capture knob so a setting seen here can
// be reproduced headlessly.
ui.add(
egui::Slider::new(&mut view.farclip, crate::view::FARCLIP_RANGE)
egui::Slider::new(&mut view.farclip, benilla_world::view::FARCLIP_RANGE)
.text("view distance / farclip (yd)"),
);
});
@@ -566,7 +481,7 @@ fn debug_panel_ui(
ws.effect_kind,
ws.effect_density,
ws.sky_density,
crate::weather::storm_blend(ws.sky_density),
benilla_world::weather::storm_blend(ws.sky_density),
));
}
// The `weatherDensity` setting (video-options Weather Intensity 0–3)
@@ -721,7 +636,7 @@ fn debug_panel_ui(
.small()
.color(OVERLAY_TEXT_DIM),
);
// …and which build this is (`crate::build_id`). Bottom line of the surface a reader is
// …and which build this is (`benilla_world::build_id`). Bottom line of the surface a reader is
// already on when something looks wrong: a click copies the full sha, so "it looks like
// this here" can name the code it happened on.
let build = ui.add(
@@ -742,56 +657,3 @@ fn debug_panel_ui(
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn keys(held: &[KeyCode]) -> ButtonInput<KeyCode> {
let mut k = ButtonInput::default();
for &c in held {
k.press(c);
}
k
}
/// The plane fires on its two modifiers, either side of each.
#[test]
fn ctrl_shift_is_the_plane() {
for pair in [
[KeyCode::ControlLeft, KeyCode::ShiftRight],
[KeyCode::ControlRight, KeyCode::ShiftLeft],
] {
assert!(dev_plane(&keys(&pair)), "{pair:?} fires");
}
}
/// One modifier is not the chord, and a third one names something else. The case that matters:
/// AltGr *is* `Ctrl`+`Alt`, so AltGr+Shift+key is a character a European layout types, never a
/// dev chord — the overlays are ungated while the chat bar is open. `Ctrl`+`Cmd` is likewise
/// nothing of ours now (0870): on Windows it is the shell's, `Win+Ctrl+M` being Magnifier
/// settings.
#[test]
fn a_lone_or_extra_modifier_is_not_the_chord() {
for held in [
vec![KeyCode::ControlLeft],
vec![KeyCode::ShiftLeft],
vec![KeyCode::ControlLeft, KeyCode::SuperLeft],
vec![KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::AltLeft],
vec![KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::SuperLeft],
] {
assert!(!dev_plane(&keys(&held)), "{held:?} is not the chord");
}
}
/// The label a hint prints is the plane actually listened for — one const, one predicate, so a
/// player is never told to press a key we stopped reading.
#[test]
fn the_label_matches_the_plane() {
assert_eq!(DEV_CHORD, "Ctrl+Shift");
assert!(dev_plane(&keys(&[
KeyCode::ControlLeft,
KeyCode::ShiftLeft
])));
}
}
+14 -12
View File
@@ -32,21 +32,21 @@ use crate::player::land::LandHere;
#[derive(SystemParam)]
pub(super) struct WorldReadout<'w, 's> {
player: Option<Res<'w, crate::player::Player>>,
map: Option<Res<'w, crate::world_map::CurrentMap>>,
catalog: Option<Res<'w, crate::world_map::MapCatalogRes>>,
area: Res<'w, crate::terrain_stream::CurrentArea>,
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
catalog: Option<Res<'w, benilla_assets::MapCatalogRes>>,
area: Res<'w, benilla_world::terrain_stream::CurrentArea>,
areas: Option<Res<'w, crate::area::AreaTableRes>>,
interior: Res<'w, crate::wmo_portal::CurrentAreaInterior>,
interior: Res<'w, benilla_world::wmo_portal::CurrentAreaInterior>,
/// The camera's own WMO room claim + the exterior-window worklist it produces — the two inputs
/// [`crate::exterior_cull`] runs on. See the readout below for why they are worth a line.
room: Res<'w, crate::wmo_portal::CameraInteriorClaim>,
windows: Res<'w, crate::wmo_portal::ExteriorWindows>,
skybox: Res<'w, crate::wmo_sky::CameraWmoSkybox>,
streamer: Res<'w, crate::terrain_stream::TerrainStreamer>,
/// [`benilla_world::exterior_cull`] runs on. See the readout below for why they are worth a line.
room: Res<'w, benilla_world::wmo_portal::CameraInteriorClaim>,
windows: Res<'w, benilla_world::wmo_portal::ExteriorWindows>,
skybox: Res<'w, benilla_world::wmo_sky::CameraWmoSkybox>,
streamer: Res<'w, benilla_world::terrain_stream::TerrainStreamer>,
self_guid: Res<'w, crate::net::SelfGuid>,
names: ResMut<'w, crate::names::NameCache>,
net_commands: Res<'w, crate::net::NetCommands>,
camera: Query<'w, 's, &'static Transform, With<crate::player::WorldCamera>>,
camera: Query<'w, 's, &'static Transform, With<benilla_world::view::WorldCamera>>,
land: MessageWriter<'w, LandHere>,
}
@@ -130,8 +130,10 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
None => "room —".to_string(),
};
let window_line = match &*world.windows {
crate::wmo_portal::ExteriorWindows::Unrestricted => "exterior unrestricted".to_string(),
crate::wmo_portal::ExteriorWindows::Windows(rects) => {
benilla_world::wmo_portal::ExteriorWindows::Unrestricted => {
"exterior unrestricted".to_string()
}
benilla_world::wmo_portal::ExteriorWindows::Windows(rects) => {
let widest = rects
.iter()
.map(|[x0, y0, x1, y1]| ((x1 - x0) * (y1 - y0)).max(0.0))
+360 -288
View File
@@ -24,15 +24,10 @@ use benilla_formats::{
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::lighting::SharedLightBuffer;
use crate::model_fade::{
apply_despawn_fade, apply_render_fade, arm_appear_fade, retire_unit_appear_fade,
};
use crate::model_render::MaterialCache;
use crate::net::NetEntity;
use crate::schedule::WorldStage;
use crate::terrain::WowModelMaterial;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::model_fade::apply_render_fade;
use benilla_world::schedule::WorldStage;
/// Resolving a display id to its [`DisplayModel`] and building its spawn parts once the model asset
/// loads (materials, skeleton, collider, camera/selection metrics) — the front half of this subsystem,
@@ -116,7 +111,6 @@ mod chain_beam;
pub(crate) use chain_beam::ChainHops;
use chain_beam::{simulate_chain_beams, spawn_chain_beams};
// The container feed reads the icon column off the same catalog resource (one DBC parse).
pub(crate) use attach::spawn_joints;
// The one InventoryType → equipment-slot table (`attach::preview`): the dressing-room feed places a
// tried-on item by the very same map the preview it feeds dresses by (decision 1060).
pub(crate) use attach::equip_slot;
@@ -317,17 +311,12 @@ struct SkinSections(CharSections);
#[derive(Resource)]
pub(crate) struct CharCreate(pub(crate) CharCreateCatalog);
/// The shared material-dedup cache for streamed-entity models (creatures + GameObjects share one look
/// across instances). Kept as its own resource so the build system can reach it independently.
#[derive(Resource, Default)]
struct EntityMaterials(MaterialCache);
/// Per-appearance cache of composited body-skin atlases (decision 0044): each distinct character look
/// composites + uploads its 256² skin once, and every player sharing that look reuses the handle. A
/// composite is a fresh `Image` asset per build (unlike an `asset_server.load` path, which dedups by
/// path), so without this cache every player would re-composite and break material dedup downstream.
#[derive(Resource, Default)]
struct SkinComposites(crate::art_scope::SpatialCache<SkinKey, Handle<Image>>);
struct SkinComposites(benilla_assets::SpatialCache<SkinKey, Handle<Image>>);
/// The appearance fields that determine a composited body skin (decision 0044): race/sex pick the
/// CharSections rows; skin/face/facialHair/hairStyle/hairColor pick the base + overlay variations;
@@ -364,8 +353,7 @@ pub(crate) struct EntityVisualsSet;
/// id, material key, and composited skin ever seen stayed resident for the life of the process.
#[allow(clippy::too_many_arguments)]
fn evict_display_caches(
mut changes: MessageReader<crate::world_map::MapChange>,
mut entity_mats: ResMut<EntityMaterials>,
mut changes: MessageReader<benilla_world::world_map::MapChange>,
mut composites: ResMut<SkinComposites>,
mut fx: ResMut<spell_fx::SpellFx>,
creatures: Option<ResMut<Creatures>>,
@@ -378,8 +366,7 @@ fn evict_display_caches(
}
changes.clear();
info!(
"display caches evicted: {} materials, {} creature / {} go / {} item / {} fx / {} glow models, {} skins",
entity_mats.0.len(),
"display caches evicted: {} creature / {} go / {} item / {} fx / {} glow models, {} skins",
creatures.as_ref().map_or(0, |c| c.models.len()),
gos.as_ref().map_or(0, |g| g.models.len()),
items.as_ref().map_or(0, |i| i.models.len()),
@@ -387,7 +374,6 @@ fn evict_display_caches(
glows.as_ref().map_or(0, |g| g.models.len()),
composites.0.len(),
);
entity_mats.0.clear();
composites.0.clear();
fx.models.clear();
if let Some(mut c) = creatures {
@@ -411,12 +397,70 @@ fn evict_display_caches(
/// `ItemDisplays`/`SpellFx`) are deliberately *not* swept: they key on ids, not places, and hold M2
/// assets whose count is bounded by the catalogs rather than by where you have been.
fn scope_entity_art(
mut scope: crate::art_scope::ArtScope,
mut mats: ResMut<EntityMaterials>,
mut scope: benilla_world::art_scope::ArtScope,
mut composites: ResMut<SkinComposites>,
) {
scope.apply(&mut mats.0, crate::art_scope::ArtSlot::EntityMats);
scope.apply(&mut composites.0, crate::art_scope::ArtSlot::Skins);
scope.apply(&mut composites.0, benilla_world::art_scope::ArtSlot::Skins);
}
/// **Restate every wire body as a [`WorldUnit`](benilla_world::world_unit::WorldUnit)** — the game's half
/// of the unit inversion (see that module).
///
/// One reconciler rather than a line at each of the dozen sites that spawn a `NetEntity`: a
/// spawn path that forgets the marker is a body the world cannot see — no ground shade, no room
/// claim, no foam — and that is exactly the kind of omission nobody notices until a screenshot.
/// Runs between the wire drain and the rest of the frame, so a unit that arrived this frame is
/// visible to the world this frame.
fn publish_world_units(
mut commands: Commands,
bodies: Query<(
Entity,
&NetEntity,
Option<&collision_height::CollisionHeight>,
Option<&benilla_world::world_unit::WorldUnit>,
)>,
viewers: Query<(
Entity,
Has<crate::net::SelfPlayer>,
Has<benilla_world::world_unit::ViewerUnit>,
)>,
) {
for (entity, net, height, current) in &bodies {
let want = benilla_world::world_unit::WorldUnit {
kind: net.kind,
scale: net.scale,
// `unwrap_or_default`, NOT zero: `CollisionHeight`'s Default is the client's own
// ctor value, and its doc says why — at 0.0 "every depth line collapses and the unit
// swims on dry land". A body whose display has not resolved yet must read as a
// default-sized body, which is what the foam site did before this component existed.
height: height.copied().unwrap_or_default().0,
};
// Only write on a real change: the component is change-detected downstream, and a
// per-frame rewrite would mark every body dirty for every reader every frame.
let same = current.is_some_and(|c| {
c.kind == want.kind && c.scale == want.scale && c.height == want.height
});
if !same {
commands.entity(entity).insert(want);
}
}
// The viewer marker is reconciled independently of `NetEntity`: the self entity exists before
// its wire record does, and a `/logout` takes the record away first.
for (entity, is_self, marked) in &viewers {
match (is_self, marked) {
(true, false) => {
commands
.entity(entity)
.insert(benilla_world::world_unit::ViewerUnit);
}
(false, true) => {
commands
.entity(entity)
.remove::<benilla_world::world_unit::ViewerUnit>();
}
_ => {}
}
}
}
/// The streamed-entity subsystem: builds the shared cube assets + display catalogs at startup, then
@@ -425,248 +469,193 @@ pub(crate) struct EntitiesPlugin;
impl Plugin for EntitiesPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<EntityMaterials>()
.init_resource::<SkinComposites>()
.init_resource::<spell_fx::SpellFx>()
.init_resource::<spell_fx::FxTintAnims>()
// The per-caster pending-projectile queues (the client's `unit+0xac` lists).
.init_resource::<missile::PendingMissiles>()
// The projectile flight-loop edges (`crate::sound::missile` consumes them).
.add_message::<MissileSound>()
// The cast router's dest one-shot orders (`dest_fx`, decision 0797).
.add_message::<dest_fx::GroundBurst>()
.add_systems(Startup, setup_entities.after(AssetSet::Open))
// The map-scope teardown (`world_map::MapChange`): drop every display/material dedup
// so a map's assets actually die with it — the #bugs teleport leak.
.add_systems(Update, (evict_display_caches, scope_entity_art))
// Every streamed unit's collision height, the frame after `apply_net_updates` spawns it
// (that stage's Commands are what create the entity, so this cannot be earlier). Its
// consumers all read `Option<&CollisionHeight>` against the ctor default, so a unit's
// first frame simply uses that — see `CollisionHeight`.
.add_systems(Update, stamp_collision_heights.after(WorldStage::Net))
// Resolve/build display models before attaching, so a model is ready the frame attach wants it.
// **After `WorldStage::Net`**: `apply_net_updates` spawns the entities (via Commands), so
// these must run *after* that stage — with the sync point it forces — or they'd attach an
// entity the same frame its display id is still unresolved (`dm == None`) and lock in a cube.
// The net stage was previously unordered (`schedule.rs` chained only Input→Stream→Present), so
// this held only by luck of Bevy's auto-sort; pinning it makes display resolution deterministic.
.add_systems(
Update,
app.add_systems(
Update,
publish_world_units
.after(benilla_world::schedule::WorldStage::Net)
.before(benilla_world::schedule::WorldStage::Input),
)
.init_resource::<SkinComposites>()
.init_resource::<spell_fx::SpellFx>()
.init_resource::<spell_fx::FxTintAnims>()
// The per-caster pending-projectile queues (the client's `unit+0xac` lists).
.init_resource::<missile::PendingMissiles>()
// The projectile flight-loop edges (`crate::sound::missile` consumes them).
.add_message::<MissileSound>()
// The cast router's dest one-shot orders (`dest_fx`, decision 0797).
.add_message::<dest_fx::GroundBurst>()
.add_systems(Startup, setup_entities.after(AssetSet::Open))
// The map-scope teardown (`world_map::MapChange`): drop every display/material dedup
// so a map's assets actually die with it — the #bugs teleport leak.
.add_systems(Update, (evict_display_caches, scope_entity_art))
// Every streamed unit's collision height, the frame after `apply_net_updates` spawns it
// (that stage's Commands are what create the entity, so this cannot be earlier). Its
// consumers all read `Option<&CollisionHeight>` against the ctor default, so a unit's
// first frame simply uses that — see `CollisionHeight`.
.add_systems(Update, stamp_collision_heights.after(WorldStage::Net))
// Resolve/build display models before attaching, so a model is ready the frame attach wants it.
// **After `WorldStage::Net`**: `apply_net_updates` spawns the entities (via Commands), so
// these must run *after* that stage — with the sync point it forces — or they'd attach an
// entity the same frame its display id is still unresolved (`dm == None`) and lock in a cube.
// The net stage was previously unordered (`schedule.rs` chained only Input→Stream→Present), so
// this held only by luck of Bevy's auto-sort; pinning it makes display resolution deterministic.
.add_systems(
Update,
(
// Equipment resolution first (decisions 0072/0074): it *creates* item
// DisplayModel entries, which update_display_models then builds the same frame.
resolve_equipment,
// Spell-effect resolution likewise creates its path-keyed entries (0099 P3),
// as does the missile launcher (P4) — both feed the same SpellFx model cache.
resolve_spell_fx,
move_missiles,
spawn_missiles,
// The dest-anchored lane (0797): the dynobj arm + the GO burst + the shard
// tick all create cache entries too — before the same-frame build below.
// One nested (unordered) element: three independent producers, and the
// outer tuple is at `chain()`'s 20-element ceiling.
(arm_ground_effects, spawn_ground_bursts, tick_shard_emitters),
// The fxview capture fixture (inert outside `WOW_CAPTURE=fxview`): creates
// its cache entry before the same-frame build, attaches after it.
spell_fx::drive_fx_view,
update_display_models,
// The char-create preview (decision 0423): assemble the selected look's parts from
// the freshly-built display model, for the create booth to bake. After
// `update_display_models` (its want-list built the body) — server-less, at char select.
build_glue_preview,
// The dressing room's preview (decision 1060): the same tuple-driven assembly,
// for the item nobody in the world is wearing. Beside the glue one — same
// dependency (the display cache built by `update_display_models` above), same
// retry-until-ready latch.
build_dressup_preview,
attach_entity_visuals,
// WMO-gameobject doodad props (the ship's sails): resolve the MODD list the
// frame after the WMO visual attaches, then spawn each prop as its M2 lands
// (parented under the entity — they sail with the boat).
resolve_wmo_gameobject_props,
spawn_wmo_gameobject_props,
attach_held_items,
// One nested (unordered) element — the two passes that hang effect models on
// a unit: its spell-kit instances, and the glows its held items' `ItemVisuals`
// ids name (0805 — after `attach_held_items`, which makes the item roots).
// Independent of each other, both before the tint tick below; the outer tuple
// is at `chain()`'s 20-element ceiling.
(attach_item_glows, attach_spell_fx),
// One nested (unordered) element — two independent free-model attach
// passes, plus the world-plant tender (0850: sweeps orphaned plants,
// re-plants root-aura ones on owner displacement); the outer tuple is at
// `chain()`'s 20-element ceiling.
(
// Equipment resolution first (decisions 0072/0074): it *creates* item
// DisplayModel entries, which update_display_models then builds the same frame.
resolve_equipment,
// Spell-effect resolution likewise creates its path-keyed entries (0099 P3),
// as does the missile launcher (P4) — both feed the same SpellFx model cache.
resolve_spell_fx,
move_missiles,
spawn_missiles,
// The dest-anchored lane (0797): the dynobj arm + the GO burst + the shard
// tick all create cache entries too — before the same-frame build below.
// One nested (unordered) element: three independent producers, and the
// outer tuple is at `chain()`'s 20-element ceiling.
(arm_ground_effects, spawn_ground_bursts, tick_shard_emitters),
// The fxview capture fixture (inert outside `WOW_CAPTURE=fxview`): creates
// its cache entry before the same-frame build, attaches after it.
spell_fx::drive_fx_view,
update_display_models,
// The char-create preview (decision 0423): assemble the selected look's parts from
// the freshly-built display model, for the create booth to bake. After
// `update_display_models` (its want-list built the body) — server-less, at char select.
build_glue_preview,
// The dressing room's preview (decision 1060): the same tuple-driven assembly,
// for the item nobody in the world is wearing. Beside the glue one — same
// dependency (the display cache built by `update_display_models` above), same
// retry-until-ready latch.
build_dressup_preview,
attach_entity_visuals,
// WMO-gameobject doodad props (the ship's sails): resolve the MODD list the
// frame after the WMO visual attaches, then spawn each prop as its M2 lands
// (parented under the entity — they sail with the boat).
resolve_wmo_gameobject_props,
spawn_wmo_gameobject_props,
attach_held_items,
// One nested (unordered) element — the two passes that hang effect models on
// a unit: its spell-kit instances, and the glows its held items' `ItemVisuals`
// ids name (0805 — after `attach_held_items`, which makes the item roots).
// Independent of each other, both before the tint tick below; the outer tuple
// is at `chain()`'s 20-element ceiling.
(attach_item_glows, attach_spell_fx),
// One nested (unordered) element — two independent free-model attach
// passes, plus the world-plant tender (0850: sweeps orphaned plants,
// re-plants root-aura ones on owner displacement); the outer tuple is at
// `chain()`'s 20-element ceiling.
(
attach_missile_models,
attach_ground_fx_models,
spell_fx::tend_world_plants,
),
// Tick the live per-instance tint clones AFTER the attach passes registered
// them, so a clone's first drawn frame is already on its own clock.
spell_fx::tick_fx_tint,
// Fire each live instance's crossed event keyframes (decision 0304) — after
// attach so a just-spawned instance's head window [0, cur] fires this frame.
// Beside it (one nested, unordered element — the outer tuple is at `chain()`'s
// 20-element ceiling): the effect-model completion callbacks, which advance an
// instance birth → Hold and arm the reap's Decay. Independent of the event
// scan; both only read a player Bevy already advanced in `PreUpdate`, and both
// want to run after the attach passes so a fresh instance is covered at once.
(spell_fx::fire_fx_anim_events, spell_fx::advance_fx_anim),
// A gear change re-dresses the standing visual in place — a re-composited atlas
// on the same parts, the equipment geosets re-selected, every attachment left
// alone (decision 0835, the reference's own shape).
attach::redress_player_looks,
// A mount transition does the same, and for the same reason (B199): the
// field diff **re-seats** the standing rig — onto the mount's attachment-0
// joint, or back onto its own frame — where it used to tear the whole rider
// down and let attach rebuild it. The reference re-parents the body model
// (`0x712f70`/`0x713020`); it never re-creates it.
reseat_mounts,
// A live display-id / scale change (decision 0695): the display swap is the
// same teardown-and-rebuild; the scale change arms the reference's 2 s ease.
// The rig heal (decision 0863) rides the same nest (Bevy's 20-tuple ceiling):
// a unit denied a palette rig at attach rebuilds — the same teardown — once
// the table has headroom again; never a permanent statue.
(
refresh_live_display,
tick_scale_ease,
live_display::heal_rig_starved,
)
.chain(),
)
.chain()
.in_set(EntityVisualsSet)
.after(WorldStage::Net),
)
// The ground-fx decal placement (`crate::ground_fx`): rides the BillboardPlace set —
// post-propagation (the effect rig's joints carry THIS frame's pose) — after the
// stream clear, since it pushes its cached projection into this frame's stream.
.add_systems(
PostUpdate,
crate::ground_fx::update_ground_fx_decals
.in_set(crate::billboard::BillboardPlace)
.after(crate::particles::buffer::begin_effect_frame),
)
// The chain-beam spawner (0955): in the visuals set, so the cast router — which is
// `.before(EntityVisualsSet)` — has already emitted this frame's beam plays, and the
// net stage's Commands (the hop arrays) have already been applied.
.add_systems(
Update,
spawn_chain_beams
.in_set(EntityVisualsSet)
.after(WorldStage::Net),
)
// …and its per-frame geometry, beside the ribbon trails and for the same reason: a
// beam's endpoints are attachment joints, so it must sample the pose the billboard
// palette and the rig finalizer just wrote.
//
// `.after(begin_effect_frame)` is the load-bearing one, and its omission is what made
// the whole beam invisible on first ship (B161): the clear carries an extra
// `.after(face_billboards)` the beam sim does not, so without this edge the sim
// becomes runnable a step EARLIER and its vertices are wiped before extract — every
// frame, silently, with the arithmetic perfect. Every writer into the shared stream
// declares this; the tripwire in `commit` now refuses a write that precedes the clear.
.add_systems(
PostUpdate,
simulate_chain_beams
.in_set(crate::billboard::BillboardPlace)
.after(crate::billboard::billboard_joint_palette)
.after(crate::creature_anim::finalize_rig_worlds)
.after(crate::particles::buffer::begin_effect_frame),
)
// Terrain conform (decisions 0482/0486, the byte law of wow-re `terrain-tilt.md`):
// reads each flagged unit's Update-final transform, writes its conform node's
// local rotation — before propagation so the composite's globals carry this
// frame's stance.
.add_systems(
PostUpdate,
conform::conform_units.before(bevy::transform::TransformSystems::Propagate),
)
// The unit lane's material-alpha compose: after every steady-state owner of the
// render-alpha field (the interior classifier, the visibility authority's own
// fade/effect writes), before the self-avatar feather that is allowed to override it.
.add_systems(
Update,
apply_unit_mat_alpha
.after(crate::interior::classify_entity_interior)
.after(crate::debug_panel::ModelVisSet)
.after(apply_render_fade)
.before(crate::player::apply_self_model_fade),
)
// The aura CharProc layer (`crate::aura_visual`): the state kit's effect on the BODY.
// The drain installs/removes this frame's nodes; the author then owns the render alpha
// of every part under a translucent unit — after the three steady-state alpha authors
// above so its override lands, before the self feather which folds the factor in itself.
.add_systems(
Update,
attach_missile_models,
attach_ground_fx_models,
spell_fx::tend_world_plants,
),
// Tick the live per-instance tint clones AFTER the attach passes registered
// them, so a clone's first drawn frame is already on its own clock.
spell_fx::tick_fx_tint,
// Fire each live instance's crossed event keyframes (decision 0304) — after
// attach so a just-spawned instance's head window [0, cur] fires this frame.
// Beside it (one nested, unordered element — the outer tuple is at `chain()`'s
// 20-element ceiling): the effect-model completion callbacks, which advance an
// instance birth → Hold and arm the reap's Decay. Independent of the event
// scan; both only read a player Bevy already advanced in `PreUpdate`, and both
// want to run after the attach passes so a fresh instance is covered at once.
(spell_fx::fire_fx_anim_events, spell_fx::advance_fx_anim),
// A gear change re-dresses the standing visual in place — a re-composited atlas
// on the same parts, the equipment geosets re-selected, every attachment left
// alone (decision 0835, the reference's own shape).
attach::redress_player_looks,
// A mount transition does the same, and for the same reason (B199): the
// field diff **re-seats** the standing rig — onto the mount's attachment-0
// joint, or back onto its own frame — where it used to tear the whole rider
// down and let attach rebuild it. The reference re-parents the body model
// (`0x712f70`/`0x713020`); it never re-creates it.
reseat_mounts,
// A live display-id / scale change (decision 0695): the display swap is the
// same teardown-and-rebuild; the scale change arms the reference's 2 s ease.
// The rig heal (decision 0863) rides the same nest (Bevy's 20-tuple ceiling):
// a unit denied a palette rig at attach rebuilds — the same teardown — once
// the table has headroom again; never a permanent statue.
(
// The display's own base alpha first (the reference's DISPLAYID-watcher leg
// of the same recompute, `base-render-alpha.md` §5 — no aura needed), so a
// same-frame aura edge retargets from the already-updated base.
crate::aura_visual::refresh_base_alpha,
crate::aura_visual::drain_aura_procs,
// The tint publish only needs the drain ahead of it (it writes a resource, not
// the alpha channel), so it rides the same chain rather than earning its own.
(
crate::aura_visual::apply_aura_alpha,
crate::aura_visual::apply_aura_tint,
),
)
.chain()
.after(apply_unit_mat_alpha)
.before(crate::player::apply_self_model_fade),
)
// Arm a queued appear-fade once the world is on-screen, then drive it each frame; the despawn
// fade-out re-arms the same `RenderFade` channel on stream-out. All disjoint from the interior
// classifier + the doodad fade (they touch different entities / the same channel only while a
// fade is pending or live).
.add_systems(
Update,
(
arm_appear_fade,
apply_despawn_fade,
apply_render_fade,
// Retires the unit-root clock (`UnitAppearFade`) once its mirrored ramp completes,
// so a part spawning after the unit has fully appeared reads no marker and spawns
// steady (`entities::attach`/`entities::equipment` join off it).
retire_unit_appear_fade,
refresh_live_display,
tick_scale_ease,
live_display::heal_rig_starved,
)
.chain(),
)
// The water-plane interleave's MESH half (the sibling of the effect half in
// `particles::sim`): every transparent M2 batch classifies against the water plane
// and takes its far-side twin, so the surface paints over a submerged model's
// sheen/glow layers. Two lanes: entities whose handle the Visibility authority owns
// (the `DoodadFade` holders it pins every frame, decision 0025) get a MARKER the
// authority composes into its own pick — so classify runs before `ModelVisSet`, and
// the pick sees this frame's side; everything else (equipment, spell-fx, fade twins)
// is swapped here directly, after the fade resolve whose choice it re-derives from
// the current handle. The self feather stays deliberately unordered: it composes the
// same marker through `far_resolved`, so the two writers can only disagree on a
// frame the marker itself flips — a one-frame stale side on the player's own parts
// at the instant the eye crosses the surface, inside the whole-screen atmosphere
// swap that crossing already is (the same accepted class as the interior law's
// one-frame lag, 0919).
.init_resource::<crate::model_render::FarSideTwins>()
.add_systems(
Update,
crate::model_render::classify_water_side
.after(crate::liquid::SubmersionVerdict)
.after(apply_render_fade)
.before(crate::debug_panel::ModelVisSet),
.chain()
.in_set(EntityVisualsSet)
.after(WorldStage::Net),
)
// The chain-beam spawner (0955): in the visuals set, so the cast router — which is
// `.before(EntityVisualsSet)` — has already emitted this frame's beam plays, and the
// net stage's Commands (the hop arrays) have already been applied.
.add_systems(
Update,
spawn_chain_beams
.in_set(EntityVisualsSet)
.after(WorldStage::Net),
)
// …and its per-frame geometry, beside the ribbon trails and for the same reason: a
// beam's endpoints are attachment joints, so it must sample the pose the billboard
// palette and the rig finalizer just wrote.
//
// `.after(begin_effect_frame)` is the load-bearing one, and its omission is what made
// the whole beam invisible on first ship (B161): the clear carries an extra
// `.after(face_billboards)` the beam sim does not, so without this edge the sim
// becomes runnable a step EARLIER and its vertices are wiped before extract — every
// frame, silently, with the arithmetic perfect. Every writer into the shared stream
// declares this; the tripwire in `commit` now refuses a write that precedes the clear.
.add_systems(
PostUpdate,
simulate_chain_beams
.in_set(benilla_world::billboard::BillboardPlace)
.after(benilla_world::billboard::billboard_joint_palette)
.after(benilla_world::rig_anim::finalize_rig_worlds)
.after(benilla_world::particles::buffer::begin_effect_frame),
)
// Terrain conform (decisions 0482/0486, the byte law of wow-re `terrain-tilt.md`):
// reads each flagged unit's Update-final transform, writes its conform node's
// local rotation — before propagation so the composite's globals carry this
// frame's stance.
.add_systems(
PostUpdate,
conform::conform_units.before(bevy::transform::TransformSystems::Propagate),
)
// The unit lane's material-alpha compose: after every steady-state owner of the
// render-alpha field (the interior classifier, the visibility authority's own
// fade/effect writes), before the self-avatar feather that is allowed to override it.
.add_systems(
Update,
apply_unit_mat_alpha
.after(benilla_world::interior::classify_entity_interior)
.after(benilla_world::model_render::ModelVisSet)
.after(apply_render_fade)
.before(crate::player::apply_self_model_fade),
)
// The aura CharProc layer (`crate::aura_visual`): the state kit's effect on the BODY.
// The drain installs/removes this frame's nodes; the author then owns the render alpha
// of every part under a translucent unit — after the three steady-state alpha authors
// above so its override lands, before the self feather which folds the factor in itself.
.add_systems(
Update,
(
// The display's own base alpha first (the reference's DISPLAYID-watcher leg
// of the same recompute, `base-render-alpha.md` §5 — no aura needed), so a
// same-frame aura edge retargets from the already-updated base.
crate::aura_visual::refresh_base_alpha,
crate::aura_visual::drain_aura_procs,
// The tint publish only needs the drain ahead of it (it writes a resource, not
// the alpha channel), so it rides the same chain rather than earning its own.
(
crate::aura_visual::apply_aura_alpha,
crate::aura_visual::apply_aura_tint,
),
)
// …and publish those same ramps as ONE number per model instance, for the consumers
// that are not meshes and so cannot read a `MeshTag`: an emitter's particles and a
// ribbon's strip (decision 0827 — the reference's `CM2Model+0x19c`). PostUpdate, so
// every Update-side fade writer and the camera controller's self feather have already
// run this frame, and before the effect sims that read it.
.add_systems(
PostUpdate,
crate::model_fade::publish_model_alpha.before(crate::billboard::BillboardPlace),
)
// The depth-prime twins (decision 0831 — the reference's `M2UseZFill`): PostUpdate too,
// after the same Update-side tag writers, so a twin arms on its episode's first frame.
.add_systems(PostUpdate, crate::zfill::sync_zfill_twins);
.chain()
.after(apply_unit_mat_alpha)
.before(crate::player::apply_self_model_fade),
);
}
}
@@ -863,22 +852,18 @@ fn update_display_models(
mut spell_fx: Option<ResMut<spell_fx::SpellFx>>,
mut glows: Option<ResMut<ItemGlows>>,
model_assets: (Res<Assets<M2Model>>, Res<Assets<WmoModel>>),
mut forms: ResMut<crate::model_forms::ModelForms>,
mut forms: ResMut<benilla_world::model_forms::ModelForms>,
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut entity_mats: ResMut<EntityMaterials>,
shared_light: Option<Res<SharedLightBuffer>>,
mut mats: benilla_world::model_render::M2BatchMaterials,
// The glue-preview want (decisions 0423 + 0465): the glue screens' look's body displayId, so
// its model builds with no wire entity (the screens run pre-world, where no NetEntity carries it).
glue_preview: Option<Res<crate::portrait::GluePreview>>,
char_create: Option<Res<CharCreate>>,
) {
let Some(light) = shared_light else {
if !mats.ready() {
return; // no lighting yet → no materials to build
};
}
let (m2s, wmos) = (&model_assets.0, &model_assets.1);
let cache = &mut entity_mats.0;
let light = &light.0;
// The (kind, display) pairs live in the world this frame — cheap to collect.
let mut actives: Vec<(EntityKind, u32)> = entities
@@ -916,9 +901,7 @@ fn update_display_models(
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
light,
&mut mats,
false, // gameobject: creatures — no hull collider, no bake variant
);
}
@@ -940,9 +923,7 @@ fn update_display_models(
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
light,
&mut mats,
true, // gameobject: hull collider + the interior BAKE material variant
);
}
@@ -963,9 +944,7 @@ fn update_display_models(
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
light,
&mut mats,
false, // gameobject: held items — unit lighting, no collider
);
}
@@ -983,9 +962,7 @@ fn update_display_models(
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
light,
&mut mats,
false, // gameobject: effects — unit lighting, no collider
);
}
@@ -1003,9 +980,7 @@ fn update_display_models(
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
light,
&mut mats,
false, // gameobject: effects — unit lighting, no collider
);
}
@@ -1020,7 +995,7 @@ fn update_display_models(
/// factor, the dimming half, which only a `MeshTag` write can express.
///
/// A fourth alpha writer is exactly what decision 0066's protocol forbids, so this is not one: it
/// writes **only** the alpha field, through [`crate::mesh_tag::with_alpha`] (the probe slot,
/// writes **only** the alpha field, through [`benilla_world::mesh_tag::with_alpha`] (the probe slot,
/// interior-fog bit, shade byte and highlight bit all ride through), it writes the sampled factor
/// **verbatim** rather than multiplying into what it finds — so re-running it is idempotent, unlike
/// a compounding read-modify-write — and it is ordered after the steady-state owner (the interior
@@ -1034,10 +1009,13 @@ fn update_display_models(
#[allow(clippy::type_complexity)]
fn apply_unit_mat_alpha(
mut parts: Query<
(&crate::doodad_anim::MatAnim, &mut bevy::mesh::MeshTag),
(
Without<crate::model_fade::RenderFade>,
Without<crate::model_fade::PendingAppearFade>,
&benilla_world::doodad_anim::MatAnim,
&mut bevy::mesh::MeshTag,
),
(
Without<benilla_world::model_fade::RenderFade>,
Without<benilla_world::model_fade::PendingAppearFade>,
),
>,
mut logged: Local<bool>,
@@ -1050,7 +1028,7 @@ fn apply_unit_mat_alpha(
if anim.current <= 0.0 {
culled += 1;
}
let bits = crate::mesh_tag::with_alpha(tag.0, anim.current);
let bits = benilla_world::mesh_tag::with_alpha(tag.0, anim.current);
if tag.0 != bits {
tag.0 = bits;
}
@@ -1063,3 +1041,97 @@ fn apply_unit_mat_alpha(
info!("unit material alpha: {culled} batch(es) culled at A <= 0 (the first frame any did)");
}
}
#[cfg(test)]
mod world_unit_tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
fn net(kind: EntityKind, scale: f32) -> NetEntity {
NetEntity {
kind,
display_id: None,
scale,
}
}
/// The reconciler is the *only* thing that makes a wire body visible to the world — no
/// `WorldUnit` means no ground shade, no WMO room claim, no foam ring, and nothing louder than
/// a slightly emptier screenshot to say so. So: a body gets one, the viewer gets its marker,
/// and both track the facts they restate.
#[test]
fn every_wire_body_becomes_a_world_unit_and_the_viewer_is_marked() {
let mut app = App::new();
let plain = app.world_mut().spawn(net(EntityKind::Unit, 1.25)).id();
let me = app
.world_mut()
.spawn((
net(EntityKind::Player, 1.0),
crate::net::SelfPlayer,
collision_height::CollisionHeight(2.5),
))
.id();
app.world_mut()
.run_system_once(publish_world_units)
.expect("reconciler runs");
let w = app.world();
let unit = w
.get::<benilla_world::world_unit::WorldUnit>(plain)
.expect("a wire body is a world unit");
assert_eq!(unit.kind, EntityKind::Unit);
assert_eq!(unit.scale, 1.25);
assert_eq!(
unit.height,
collision_height::CollisionHeight::default().0,
"an unresolved height reads as the CLIENT's ctor default, never 0.0 — at zero every \
depth line collapses and the body swims on dry land (see the type's own doc)"
);
let mine = w
.get::<benilla_world::world_unit::WorldUnit>(me)
.expect("so is the avatar's");
assert_eq!(mine.height, 2.5, "the collision cylinder travels with it");
assert!(
w.get::<benilla_world::world_unit::ViewerUnit>(me).is_some(),
"and the eye's own body is marked, which is what first-person feathering filters on"
);
assert!(
w.get::<benilla_world::world_unit::ViewerUnit>(plain)
.is_none(),
"…and nothing else is"
);
}
/// The viewer marker is reconciled, not stamped once: a `/logout` takes `SelfPlayer` away and
/// the marker has to go with it, or the next character is feathered as someone else's avatar.
#[test]
fn the_viewer_marker_follows_self_player_off_as_well_as_on() {
let mut app = App::new();
let me = app
.world_mut()
.spawn((net(EntityKind::Player, 1.0), crate::net::SelfPlayer))
.id();
app.world_mut()
.run_system_once(publish_world_units)
.expect("reconciler runs");
assert!(app
.world()
.get::<benilla_world::world_unit::ViewerUnit>(me)
.is_some());
app.world_mut()
.entity_mut(me)
.remove::<crate::net::SelfPlayer>();
app.world_mut()
.run_system_once(publish_world_units)
.expect("reconciler runs");
assert!(
app.world()
.get::<benilla_world::world_unit::ViewerUnit>(me)
.is_none(),
"the marker is reconciled off, not left behind"
);
}
}
@@ -9,13 +9,10 @@
use benilla_formats::{CharSkinSlot, ModelBlend};
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use bevy::render::render_resource::Buffer;
use crate::assets::{repeat_texture_authored, LockRecover, WorldAssets};
use crate::lighting::SharedLightBuffer;
use crate::model_render::{model_material, MaterialCache, ShadeSel};
use crate::net::{NetEntity, ObjectStore};
use crate::terrain::WowModelMaterial;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::{repeat_texture_authored, LockRecover, WorldAssets};
use super::super::{DisplayModel, EntityPart, SkinKey, SkinSections};
@@ -207,7 +204,7 @@ pub(super) type MatQuint = (
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
Option<Handle<WowModelMaterial>>,
);
/// The full character material set [`build_char_skin_materials`] returns: `(body, hair, object,
@@ -221,81 +218,6 @@ pub(super) type CharSkinMaterials = (
(Option<MatQuint>, Option<MatQuint>),
);
/// Build the (steady, interior-matte, fade-blend, interior-bake, interior-bake-blend) variants for
/// one character texture at one blend + sidedness. The body is opaque, hair is alpha-cut (its own
/// blend) — so the caller passes the blend, and the fade variants carry it too as the SOURCE blend
/// (`model_material` builds them `AlphaMode::Blend` regardless; the source decides the twin's
/// 224/255 cutout marker — decision 0842. The feathers ramp alpha; the bake-blend keeps the probe
/// light through a feather — 0355). All sky-lit; `model_material` dedups them by (texture, blend,
/// sidedness, …).
fn build_char_materials(
tex: Handle<Image>,
blend: ModelBlend,
two_sided: bool,
light: &Buffer,
materials: &mut Assets<WowModelMaterial>,
cache: &mut MaterialCache,
) -> MatQuint {
// The depth-prime twin (decision 0831): body/hair batches are opaque/alpha-cut and always
// z-writing, so every character part twins; `cutout` mirrors the colour twin's 224/255
// discard exactly as the model-built parts' twins do — only an AlphaKey source alpha-tests
// while fading (decision 0842). Built before `mk` captures the caches.
let zfill = crate::model_render::zfill_material(
cache,
materials,
Some(tex.clone()),
two_sided,
blend == ModelBlend::AlphaTest,
light,
);
let mut mk = |blend, shade: ShadeSel, fade, probe| {
model_material(
cache,
materials,
Some(tex.clone()),
blend,
two_sided,
false,
probe, // interior-PROP mode: the shader reads the MeshTag as an SH-probe slot
false,
false,
fade,
false,
false,
// A character body/hair texture is a synthetic composite, not one M2 batch — it carries
// no blend-mode fog policy of its own (body/hair are always opaque/alpha-cut, which the
// byte table fogs toward the scene colour anyway).
benilla_formats::FogPolicy::Scene,
// …and no generated texcoords: a composite body/hair sheet is sampled by the body's
// own authored UVs. Env-mapping reaches a character through the ArmorReflect batches
// of its HELD items, which spawn as ordinary M2 submeshes and carry their own flag.
false,
// Steady/fade: LIT like every entity M2 — the owner's dynamic MCSH shade reaches these
// parts per instance through the MeshTag shade byte (`entity_shade`), not the shared
// material. Interior-matte: the plain pair at sun ×1.0 — the null-node fallback.
// Interior-bake: the footprint-MOCV probe, the steady indoor law for every entity M2
// (wow-re `unit-m2-shader-light.md` — units take the same entity-node bake as
// GameObjects; shade is unread on the probe lane).
shade,
0,
None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads)
None, // character skin/hair swaps never carry an animated M2Color tint
None, // worn/held part: light selection anchors at the instance origin
None, // M2 carries no MOMT SIDN colour
false, // …nor the WINDOW flag
light,
)
};
(
mk(blend, ShadeSel::Lit, false, false),
mk(blend, ShadeSel::Matte, false, false),
mk(blend, ShadeSel::Lit, true, false),
mk(blend, ShadeSel::Matte, false, true),
mk(blend, ShadeSel::Matte, true, true),
zfill,
)
}
/// Build a character body's per-appearance materials — the **body** atlas (as a (single-sided,
/// two-sided) pair — a body batch keeps its own M2 0x04, e.g. the robe skirt) and the **hair**-mesh
/// texture (a single CharSections BLP, decision 0045) — each as a (steady, interior-matte, fade,
@@ -319,18 +241,27 @@ pub(super) fn build_char_skin_materials(
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
world_assets: Option<&WorldAssets>,
light: Option<&SharedLightBuffer>,
parts: &[EntityPart],
images: &mut Assets<Image>,
skin_cache: &mut crate::art_scope::SpatialCache<SkinKey, Handle<Image>>,
skin_cache: &mut benilla_assets::SpatialCache<SkinKey, Handle<Image>>,
asset_server: &AssetServer,
materials: &mut Assets<WowModelMaterial>,
cache: &mut MaterialCache,
mats: &mut benilla_world::model_render::M2BatchMaterials,
) -> CharSkinMaterials {
let (Some(sections), Some(light)) = (sections, light) else {
let (Some(sections), true) = (sections, mats.ready()) else {
return (None, None, None, (None, None));
};
let light = &light.0;
// The quint IS the engine's variant set, in the order the character swap reads it: steady,
// interior-matte, appear-fade-blend, interior-bake, interior-bake-blend, depth-prime twin.
let quint = |v: benilla_world::model_render::BatchVariants| {
(
v.steady,
v.interior,
v.fade_blend,
v.interior_bake,
v.interior_bake_blend,
v.zfill,
)
};
// Body-skin atlas. A character-model NPC loads its shipped, pre-baked atlas directly; a player (or an
// NPC row without a bake name) composites it live from CharSections + overlays, uploaded + cached
@@ -393,15 +324,14 @@ pub(super) fn build_char_skin_materials(
// the second quint is a handful of cache entries per look, shared like the first.
let body = body_tex.map(|tex| {
(
build_char_materials(
tex.clone(),
ModelBlend::Opaque,
false,
light,
materials,
cache,
quint(
mats.char_variants(tex.clone(), ModelBlend::Opaque, false)
.expect("light buffer checked at entry"),
),
quint(
mats.char_variants(tex, ModelBlend::Opaque, true)
.expect("light buffer checked at entry"),
),
build_char_materials(tex, ModelBlend::Opaque, true, light, materials, cache),
)
});
@@ -424,14 +354,8 @@ pub(super) fn build_char_skin_materials(
"mpq://{}",
path.replace('\\', "/").to_ascii_lowercase()
));
Some(build_char_materials(
tex,
hair_part.blend,
hair_part.two_sided,
light,
materials,
cache,
))
mats.char_variants(tex, hair_part.blend, hair_part.two_sided)
.map(&quint)
});
// Cape texture (decision 0074's empirical pin): the worn cloak's ItemDisplayInfo
@@ -450,14 +374,8 @@ pub(super) fn build_char_skin_materials(
"mpq://item/objectcomponents/cape/{}.blp",
tex_name.to_ascii_lowercase()
));
Some(build_char_materials(
tex,
part.blend,
part.two_sided,
light,
materials,
cache,
))
mats.char_variants(tex, part.blend, part.two_sided)
.map(&quint)
});
// Extra-skin texture (the tauren fur, M2 type 8) — CharSections `sectionType 0` `TextureName[1]`
@@ -479,14 +397,8 @@ pub(super) fn build_char_skin_materials(
let part = parts.iter().find(|p| {
p.char_slot == Some(CharSkinSlot::SkinExtra) && p.two_sided == two_sided
})?;
Some(build_char_materials(
tex.clone(),
part.blend,
part.two_sided,
light,
materials,
cache,
))
mats.char_variants(tex.clone(), part.blend, part.two_sided)
.map(&quint)
};
(quint_for(false), quint_for(true))
});
+24 -27
View File
@@ -16,12 +16,12 @@ use bevy::camera::visibility::NoFrustumCulling;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::billboard::BillboardCard;
use crate::debug_panel::{ModelKind, ModelPart};
use crate::interact::WorldObject;
use crate::interior::part_interior_lit;
use crate::model_fade::{FadeSet, JoinedFade, PartFade};
use crate::terrain::WowModelMaterial;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::WorldObject;
use benilla_world::interior::part_interior_lit;
use benilla_world::model_fade::{FadeSet, JoinedFade, PartFade};
use benilla_world::model_render::{ModelKind, ModelPart};
use super::super::EntityPart;
use super::char_skin::CharSkinMaterials;
@@ -90,7 +90,7 @@ pub(super) fn part_materials<'a>(
fade_blend: Some(fade),
bake: Some(bake),
bake_blend: Some(bake_blend),
zfill: Some(zfill),
zfill: zfill.as_ref(),
},
None => PartMaterials {
steady: &part.material,
@@ -127,9 +127,6 @@ pub(super) struct PartDress<'a> {
pub(super) char_mats: &'a CharSkinMaterials,
/// Identity for the mouseover inspector, cloned onto every part and card.
pub(super) object: &'a WorldObject,
/// The unit's rig-palette slot in `MeshTag` bits — a per-INSTANCE identity every part carries,
/// skinned or not, so a tinted unit tints whole (decision 0812).
pub(super) rig_tag: u32,
/// `0` = the palette table was full: parts fall back to the static bind-pose mesh.
pub(super) inst_slot: u16,
/// Whether the unit built a rig at all — the gate on a part drawing its skinned twin.
@@ -145,7 +142,7 @@ pub(super) struct PartDress<'a> {
pub(super) now: f32,
/// What this part should do about the unit's appear-fade. A fresh visual passes
/// `Pending { since: now }` (decision 0032); a part spawned by a **re-dress** JOINS whatever the
/// unit's own clock says ([`crate::model_fade::join_unit_appear_fade`]), exactly as a
/// unit's own clock says ([`benilla_world::model_fade::join_unit_appear_fade`]), exactly as a
/// late-resolving held item does — so a gear change during the login cascade neither pops the
/// new geoset opaque over a still-feathering body nor restarts a second ramp beside it.
pub(super) fade: JoinedFade,
@@ -206,7 +203,7 @@ pub(super) fn spawn_part(
dress.object.clone(),
// The picker's triangles (decision 0857): the render meshes are `RENDER_WORLD`-only, so
// the ray pickers read the model's resident geometry instead.
crate::interact::PickMesh(part.geometry.clone()),
benilla_world::interact::PickMesh(part.geometry.clone()),
DressedPart {
index: index as u32,
card: None,
@@ -217,14 +214,15 @@ pub(super) fn spawn_part(
// and because the two conditional writers that used to seed it (the interior classifier, the
// animated-alpha compose) each covered only their own subset, leaving a part in neither with no
// tag at all.
child.insert(MeshTag(
dress.rig_tag | crate::mesh_tag::alpha_bits(tag_alpha),
));
child.insert(MeshTag(benilla_world::mesh_tag::spawn_tag(
dress.inst_slot,
tag_alpha,
)));
// `RigPart` stays gated on actually being skinned by that rig — it is the CPU-side link for the
// mouseover picker's skinned ray test (decision 0720), which has nothing to say about a static
// part.
if skinned {
child.insert(crate::rig_palette::RigPart(dress.unit));
child.insert(benilla_world::rig_palette::RigPart(dress.unit));
}
if dress.rigged && part.skinned_mesh.is_some() {
// A streamed entity's M2 is **never view-culled** — the reference registers entity render
@@ -267,7 +265,7 @@ pub(super) fn spawn_part(
// SEQUENCE, so which of its batches draw is a function of what it is playing. Sampling follows
// the unit's own `AnimationPlayer`, so the alpha stays in phase with the pose.
if let Some(anim) = &part.alpha_anim {
child.insert(crate::doodad_anim::MatAnim::following(
child.insert(benilla_world::doodad_anim::MatAnim::following(
anim.clone(),
dress.unit,
));
@@ -337,7 +335,7 @@ fn spawn_billboard_part(
dress.object.clone(),
// The picker's triangles (decision 0857) — the caster centres a card at its pivot, the
// same bake the render form draws with.
crate::interact::PickMesh(part.geometry.clone()),
benilla_world::interact::PickMesh(part.geometry.clone()),
card_follow,
));
// A card takes its MODEL's indoor law through the same constructor as the sibling meshes it was
@@ -345,9 +343,10 @@ fn spawn_billboard_part(
// even though it is never skinned by it — it is a batch of the unit's own model, so a tinted
// unit tints its eye-glow and torch cards too (decision 0812). No char-slot variants are
// consulted: a body/hair/cape/skin-extra batch is never a billboard batch.
card.insert(MeshTag(
dress.rig_tag | crate::mesh_tag::alpha_bits(tag_alpha),
));
card.insert(MeshTag(benilla_world::mesh_tag::spawn_tag(
dress.inst_slot,
tag_alpha,
)));
// The card's build-time bound (decision 0834) — `calculate_bounds` can no longer derive one
// from the `RENDER_WORLD`-only static form's data.
if let Some(aabb) = part.aabb {
@@ -365,7 +364,7 @@ fn spawn_billboard_part(
// A card shares its batch's per-sequence alpha loops (the billboard split copies them onto every
// group), sampled off the same unit clock as the mesh parts.
if let Some(anim) = &part.alpha_anim {
card.insert(crate::doodad_anim::MatAnim::following(
card.insert(benilla_world::doodad_anim::MatAnim::following(
anim.clone(),
dress.unit,
));
@@ -384,7 +383,7 @@ fn spawn_billboard_part(
#[cfg(test)]
mod tests {
use super::*;
use crate::model_fade::{FadeMaterials, PendingAppearFade};
use benilla_world::model_fade::{FadeMaterials, PendingAppearFade};
/// One synthetic batch at `char_slot`/`two_sided`, carrying its own (distinguishable) built
/// material set so the fallback arm is visible in the assertions.
@@ -430,7 +429,7 @@ mod tests {
mat(seed * 8 + 2),
mat(seed * 8 + 3),
mat(seed * 8 + 4),
mat(seed * 8 + 5),
Some(mat(seed * 8 + 5)),
)
}
@@ -491,7 +490,6 @@ mod tests {
kind: ModelKind::Creature,
char_mats: &empty,
object: &object,
rig_tag: 0,
inst_slot: 0,
rigged: false,
anchors: &anchors,
@@ -556,7 +554,6 @@ mod tests {
kind: ModelKind::Creature,
char_mats: &empty,
object: &object,
rig_tag: 0,
inst_slot: 0,
rigged: false,
anchors: &anchors,
@@ -596,7 +593,7 @@ mod tests {
);
assert_eq!(found[0].1, mat(99), "…and the card OPENS on it");
assert!(
crate::mesh_tag::alpha_of(found[0].0) <= 1.0 / 63.0,
benilla_world::mesh_tag::alpha_of(found[0].0) <= 1.0 / 63.0,
"…at the encoder's ≈0 floor",
);
}
+58 -104
View File
@@ -8,27 +8,25 @@
//! reaches the shared types + caches in the parent via `super::`.
use avian3d::prelude::RigidBody;
use benilla_assets::{ModelAnimations, ModelSkeleton};
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::assets::WorldAssets;
use benilla_assets::WorldAssets;
use bevy::animation::transition::AnimationTransitions;
use crate::creature_anim::AnimDriver;
use crate::debug_panel::ModelKind;
use crate::interact::WorldObject;
use crate::lighting::SharedLightBuffer;
use crate::model_fade::JoinedFade;
use crate::net::{NetEntity, ObjectStore};
use crate::particles;
use crate::player::CameraPivot;
use crate::target::SelectionRadius;
use crate::terrain::WowModelMaterial;
use benilla_world::interact::WorldObject;
use benilla_world::model_fade::JoinedFade;
use benilla_world::model_render::ModelKind;
use benilla_world::particles;
use super::{
Characters, Creatures, CubeAssets, DisplayModel, EntityMaterials, GameObjects, ModelHandle,
SkinComposites, SkinSections, VisualAttached,
Characters, Creatures, CubeAssets, DisplayModel, GameObjects, ModelHandle, SkinComposites,
SkinSections, VisualAttached,
};
mod char_skin;
@@ -52,7 +50,7 @@ pub(super) use redress::redress_player_looks;
/// clock is worth arming on every instance, because a sequence drives far more than a pose.
///
/// This lane spawns **no joint
/// entities** (decision 0724): the pose lives in a [`crate::creature_anim::RigPose`] array on
/// entities** (decision 0724): the pose lives in a [`benilla_world::rig_anim::RigPose`] array on
/// `entity`, and only the CONSUMER bones — attachment points, event markers, emitter/ribbon/
/// light hosts, billboard-card bones — get an anchor entity under `joints_root`, re-seated from
/// the composed pose each frame it changes. Returns the anchors + the palette rig slot
@@ -62,7 +60,7 @@ pub(super) use redress::redress_player_looks;
/// (Milestone A).
fn setup_skinned_instance(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
palettes: &mut benilla_world::rig_palette::RigPalettes,
entity: Entity,
joints_root: Entity,
d: &DisplayModel,
@@ -73,7 +71,7 @@ fn setup_skinned_instance(
// rider's frame is the seat anchor instead (decision 0441), a conform-tilted model's its
// conform node, while the `AnimationPlayer`/driver components stay on `entity`. Skinned
// parts render purely from the palette, so their own parentage is free.
let mut pose = crate::creature_anim::RigPose::new(joints_root, &d.skeleton);
let mut pose = benilla_world::rig_anim::RigPose::new(joints_root, &d.skeleton);
// The consumer bones: every bone something in the world reaches by entity — an attachment
// point (held items, spell effects, the mount seat, overhead anchors), an event marker, an
// emitter/ribbon/light host, a billboard card's bone. Everything else is palette-only.
@@ -108,7 +106,7 @@ fn setup_skinned_instance(
scale,
},
Visibility::default(),
crate::creature_anim::RigAnchor { rig: entity, bone },
benilla_world::rig_anim::RigAnchor { rig: entity, bone },
))
.id();
commands.entity(joints_root).add_child(anchor);
@@ -118,36 +116,39 @@ fn setup_skinned_instance(
// The owned palette rig (decision 0720): the world pass writes this rig's composed frames ×
// these bindposes into the slot; every skinned part below tags the slot so the vertex stage
// finds its palette. The on-replace hook frees the slot with the visual teardown.
let slot =
match crate::rig_palette::RigSkin::allocate_bones(palettes, nbones as u32, ibp.clone()) {
Some(rig) => {
let slot = rig.slot;
// The marker's invariant is "currently slot-less": a heal-triggered (or any
// other) rebuild that lands a slot clears it, or the healer would loop.
commands
.entity(entity)
.insert(rig)
.remove::<crate::rig_palette::RigStarved>();
slot
}
None => {
// Table full (warned): parts render the static bind-pose mesh — but no longer
// for ever. The marker hands the unit to `heal_rig_starved` (decision 0863),
// which rebuilds the visual once the table has headroom; without it a mob that
// streamed in during a full-table window stayed a statue for its whole life.
commands
.entity(entity)
.insert(crate::rig_palette::RigStarved);
0
}
};
let slot = match benilla_world::rig_palette::RigSkin::allocate_bones(
palettes,
nbones as u32,
ibp.clone(),
) {
Some(rig) => {
let slot = rig.slot;
// The marker's invariant is "currently slot-less": a heal-triggered (or any
// other) rebuild that lands a slot clears it, or the healer would loop.
commands
.entity(entity)
.insert(rig)
.remove::<benilla_world::rig_palette::RigStarved>();
slot
}
None => {
// Table full (warned): parts render the static bind-pose mesh — but no longer
// for ever. The marker hands the unit to `heal_rig_starved` (decision 0863),
// which rebuilds the visual once the table has headroom; without it a mob that
// streamed in during a full-table window stayed a statue for its whole life.
commands
.entity(entity)
.insert(benilla_world::rig_palette::RigStarved);
0
}
};
if let Some(anims) = d.animations.as_ref() {
// Global-sequence bone channels (the eye-blink eyelid scale, resting fidget pulses; a GO's
// free-running flicker): free-clock loops the per-sequence reader drops, driven on their own clock.
// Rig-bound (it writes bone slots), unlike the sequence clock — which is armed by
// [`arm_sequence_clock`] whether or not this instance ended up with a rig.
if let Some(drive) =
crate::creature_anim::GlobalSeqDrive::new_rig(&anims.global_bones, nbones)
benilla_world::rig_anim::GlobalSeqDrive::new_rig(&anims.global_bones, nbones)
{
commands.entity(entity).insert(drive);
}
@@ -235,7 +236,7 @@ fn arm_sequence_clock(
// A (re)built rig is born live: fresh joints spawn pointing at the root, so a
// stale park marker from the torn-down visual would desync the LOD gate's
// edge-triggered bookkeeping (decision 0448). It re-parks on its own merits.
.remove::<crate::creature_anim::AnimParked>();
.remove::<benilla_world::rig_anim::AnimParked>();
}
}
}
@@ -291,27 +292,17 @@ pub(super) fn attach_entity_visuals(
skin_build: (
Option<Res<SkinSections>>,
Option<Res<WorldAssets>>,
Option<Res<SharedLightBuffer>>,
ResMut<Assets<Image>>,
ResMut<SkinComposites>,
Res<AssetServer>,
ResMut<Assets<WowModelMaterial>>,
ResMut<EntityMaterials>,
benilla_world::model_render::M2BatchMaterials,
),
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
time: Res<Time>,
) {
let (
sections,
world_assets,
shared_light,
mut images,
mut skin_composites,
asset_server,
mut materials,
mut entity_mats,
) = skin_build;
let (sections, world_assets, mut images, mut skin_composites, asset_server, mut mats) =
skin_build;
// Arm each entity's appear-fade at the moment its visual attaches (≈ its first-visible moment).
let now = time.elapsed_secs();
for (entity, net, equipment, reattached, mount_child, mount_body, anchored) in &pending {
@@ -415,7 +406,7 @@ pub(super) fn attach_entity_visuals(
// lighting pop).
commands
.entity(entity)
.insert_if_new(crate::entity_shade::GroundShade::default());
.insert_if_new(benilla_world::entity_shade::GroundShade::default());
// The root's canonical fold reference: held items share the root's interior verdict
// (one light node per unit — the reference aliases the wearer's collector into each
// equipped item, wow-re `unit-light-combine-storm.md`), and their classifier fold must
@@ -423,7 +414,7 @@ pub(super) fn attach_entity_visuals(
// change re-derives it with the new body model.
commands
.entity(entity)
.insert(crate::interior::BodyBakeCenter(bake_center));
.insert(benilla_world::interior::BodyBakeCenter(bake_center));
// The light node's ATTACH MODE — the reference's `[node+0x90]` bit 13, written once at
// node creation from the descriptor TYPEMASK (`0x613e10`/`0x670db0`) and dispatched at
// `0x6a86d0`: a GameObject attaches by CONTAINMENT (`0x6a8c10`), anchored at the world
@@ -434,12 +425,12 @@ pub(super) fn attach_entity_visuals(
EntityKind::GameObject => {
commands
.entity(entity)
.insert(crate::interior::ContainmentAttach);
.insert(benilla_world::interior::ContainmentAttach);
}
_ => {
commands
.entity(entity)
.remove::<crate::interior::ContainmentAttach>();
.remove::<benilla_world::interior::ContainmentAttach>();
}
}
// Identity for the mouseover inspector (and, later, hover tooltips / targeting).
@@ -514,8 +505,8 @@ pub(super) fn attach_entity_visuals(
{
let state_machine = go_state_machine;
let ambient = !matches!(
crate::doodad_anim::classify(&d.skeleton, d.animations.as_ref()),
crate::doodad_anim::DoodadAnimTier::Static
benilla_world::doodad_anim::classify(&d.skeleton, d.animations.as_ref()),
benilla_world::doodad_anim::DoodadAnimTier::Static
);
// A state GO whose model poses NO bone in any sequence gets no rig: skinning
// it could only reproduce the bind-pose mesh it already renders, at the cost
@@ -620,13 +611,11 @@ pub(super) fn attach_entity_visuals(
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
shared_light.as_deref(),
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut materials,
&mut entity_mats.0,
&mut mats,
),
None => (None, None, None, (None, None)),
};
@@ -640,7 +629,6 @@ pub(super) fn attach_entity_visuals(
// every part and card below. See the per-part note at the spawn for why it is the
// instance's identity rather than a skinning detail (decision 0812).
let inst_slot = skin.as_ref().map_or(0, |rb| rb.slot);
let rig_tag = crate::mesh_tag::rig_bits(inst_slot);
// The armed idle's **authored** CAaBox (decision 0637) — the mouseover picker's
// volume for a skinned part, NOT a culling volume (skinned entity parts are never
// frustum-culled; see the `NoFrustumCulling` note at the insert below). The bind-pose
@@ -665,7 +653,6 @@ pub(super) fn attach_entity_visuals(
kind,
char_mats: &char_mats,
object: &object,
rig_tag,
inst_slot,
rigged: skin.is_some(),
anchors: skin.as_ref().map_or(&no_anchors, |rb| &rb.anchors),
@@ -704,11 +691,11 @@ pub(super) fn attach_entity_visuals(
if unit_will_fade {
commands
.entity(entity)
.insert(crate::model_fade::UnitAppearFade::Pending { since: now });
.insert(benilla_world::model_fade::UnitAppearFade::Pending { since: now });
} else if reattached {
commands
.entity(entity)
.remove::<crate::model_fade::UnitAppearFade>();
.remove::<benilla_world::model_fade::UnitAppearFade>();
}
// Final size = the server's per-object scale (`OBJECT_FIELD_SCALE_X`) alone. The server
// already folds the unit's DBC scale (`CreatureModelData.modelScale ×
@@ -812,13 +799,13 @@ pub(super) fn attach_entity_visuals(
// GameObjects) drew every trail a model authored in every state: the Frost
// Trap's twelve trigger-only streamers became a permanent spinning column
// over the placed trap (decision 1011).
crate::ribbons::spawn_ribbon(
benilla_world::ribbons::spawn_ribbon(
&mut commands,
rb,
owner,
use_pivot,
placement.scale.max_element(),
crate::ribbons::RibbonSeq::Host(entity),
benilla_world::ribbons::RibbonSeq::Host(entity),
// The unit's own render alpha gates its trail (0827).
Some(entity),
);
@@ -879,7 +866,7 @@ pub(super) fn attach_entity_visuals(
// The cube has no resident `RenderSubmesh` to cast against — its `Aabb` IS
// its shape, which it must SAY (decision 0929): the picker requires pick
// geometry rather than inferring a box from its absence.
crate::interact::PickBox,
benilla_world::interact::PickBox,
));
});
}
@@ -912,43 +899,10 @@ pub(super) fn attach_entity_visuals(
/// they inherit the entity's world pose, others under their parent joint. Returns the joints in
/// bone order, so a vertex's joint index maps straight in and every submesh's palette rig shares
/// this one set. **The doodad/effect/booth lane only** (decision 0724): a streamed unit's rig is
/// the joint-less [`crate::creature_anim::RigPose`] buffer instead — this hierarchy remains for
/// the joint-less [`benilla_world::rig_anim::RigPose`] buffer instead — this hierarchy remains for
/// the Bevy-graph-driven hosts. `holder` is the rig root that carries (or will carry) the
/// [`crate::rig_palette::RigSkin`] — every joint marks it with a `RigJoint`, which is what the
/// [`benilla_world::rig_palette::RigSkin`] — every joint marks it with a `RigJoint`, which is what the
/// palette change-sweep iterates (0720).
pub(crate) fn spawn_joints(
commands: &mut Commands,
root: Entity,
holder: Entity,
skeleton: &ModelSkeleton,
) -> Vec<Entity> {
let joints: Vec<Entity> = skeleton
.joints
.iter()
.map(|j| {
commands
// Visibility too, not just Transform: held items and spell effects hang their
// visible roots under joints, and a gap in the chain both trips Bevy's B0004 and
// orphans those subtrees from the unit root's visibility (a hidden unit would
// keep its weapon on screen).
.spawn((
Transform::from_translation(j.local_translation),
Visibility::default(),
crate::rig_palette::RigJoint(holder),
))
.id()
})
.collect();
for (i, j) in skeleton.joints.iter().enumerate() {
let parent = usize::try_from(j.parent)
.ok()
.and_then(|p| joints.get(p).copied())
.unwrap_or(root);
commands.entity(parent).add_child(joints[i]);
}
joints
}
/// A display model's source path as a readable inspector label (the asset path, sans `mpq://` source).
/// Empty for the model-less variant or a path-less handle.
fn display_label(handle: &ModelHandle) -> String {
@@ -33,19 +33,17 @@ use benilla_formats::CharSkinSlot;
use benilla_protocol::{CharEnumItem, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM};
use bevy::prelude::*;
use crate::assets::WorldAssets;
use crate::lighting::SharedLightBuffer;
use crate::portrait::{
DressUpBake, DressUpLook, DressUpPreview, GlueLook, GluePreview, GluePreviewBake,
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider,
};
use crate::terrain::WowModelMaterial;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WorldAssets;
use super::super::equipment::{attach_id, ensure_item_model, placement, ItemModelKind};
use super::super::item_glow::{self, ItemGlows};
use super::super::{
CharCreate, Characters, Creatures, EntityMaterials, EntityPart, ItemDisplays, SkinComposites,
SkinSections,
CharCreate, Characters, Creatures, EntityPart, ItemDisplays, SkinComposites, SkinSections,
};
use super::char_skin::{equip_geosets, BodySkin, CharLook};
@@ -137,7 +135,7 @@ pub(in crate::entities) struct Assembled {
/// the character/skin data the composite is built from, and the asset stores it writes into. A
/// plain borrow struct rather than a `SystemParam`: each driver already holds these as its own
/// `Res`/`ResMut` params and simply lends them for the call.
pub(in crate::entities) struct PreviewCtx<'a> {
pub(in crate::entities) struct PreviewCtx<'a, 'w> {
pub(in crate::entities) creatures: &'a Creatures,
pub(in crate::entities) characters: Option<&'a Characters>,
pub(in crate::entities) displays: Option<&'a mut ItemDisplays>,
@@ -146,12 +144,10 @@ pub(in crate::entities) struct PreviewCtx<'a> {
pub(in crate::entities) glows: Option<&'a mut ItemGlows>,
pub(in crate::entities) sections: Option<&'a SkinSections>,
pub(in crate::entities) world_assets: Option<&'a WorldAssets>,
pub(in crate::entities) shared_light: Option<&'a SharedLightBuffer>,
pub(in crate::entities) images: &'a mut Assets<Image>,
pub(in crate::entities) skin_composites: &'a mut SkinComposites,
pub(in crate::entities) asset_server: &'a AssetServer,
pub(in crate::entities) materials: &'a mut Assets<WowModelMaterial>,
pub(in crate::entities) entity_mats: &'a mut EntityMaterials,
pub(in crate::entities) mats: &'a mut benilla_world::model_render::M2BatchMaterials<'w>,
}
/// Assemble the preview part (+ rider) lists when the glue-screen look changes. Runs in the
@@ -171,12 +167,10 @@ pub(in crate::entities) fn build_glue_preview(
mut glows: Option<ResMut<ItemGlows>>,
sections: Option<Res<SkinSections>>,
world_assets: Option<Res<WorldAssets>>,
shared_light: Option<Res<SharedLightBuffer>>,
mut images: ResMut<Assets<Image>>,
mut skin_composites: ResMut<SkinComposites>,
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut entity_mats: ResMut<EntityMaterials>,
mut mats: benilla_world::model_render::M2BatchMaterials,
// Change tracking: the look we last emitted a bake for, and whether that bake succeeded (so a
// look whose models weren't ready yet keeps retrying until they build).
mut state: Local<PreviewState>,
@@ -266,12 +260,10 @@ pub(in crate::entities) fn build_glue_preview(
glows: glows.as_deref_mut(),
sections: sections.as_deref(),
world_assets: world_assets.as_deref(),
shared_light: shared_light.as_deref(),
images: &mut images,
skin_composites: &mut skin_composites,
asset_server: &asset_server,
materials: &mut materials,
entity_mats: &mut entity_mats,
mats: &mut mats,
},
) else {
return; // a model (body, item, or glow) is still loading — retry next frame
@@ -321,12 +313,10 @@ pub(in crate::entities) fn build_dressup_preview(
mut glows: Option<ResMut<ItemGlows>>,
sections: Option<Res<SkinSections>>,
world_assets: Option<Res<WorldAssets>>,
shared_light: Option<Res<SharedLightBuffer>>,
mut images: ResMut<Assets<Image>>,
mut skin_composites: ResMut<SkinComposites>,
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut entity_mats: ResMut<EntityMaterials>,
mut mats: benilla_world::model_render::M2BatchMaterials,
mut state: Local<DressUpState>,
) {
// A new look resets the retry latch (the glue driver's own shape).
@@ -381,12 +371,10 @@ pub(in crate::entities) fn build_dressup_preview(
glows: glows.as_deref_mut(),
sections: sections.as_deref(),
world_assets: world_assets.as_deref(),
shared_light: shared_light.as_deref(),
images: &mut images,
skin_composites: &mut skin_composites,
asset_server: &asset_server,
materials: &mut materials,
entity_mats: &mut entity_mats,
mats: &mut mats,
},
) else {
return; // an item model is still loading — retry next frame
@@ -421,7 +409,7 @@ pub(in crate::entities) fn build_dressup_preview(
/// `None` means **not ready**: the body model, an item model, or a glow model is still loading.
/// A caller must treat that as "retry next frame" and leave its bake untouched, never as an empty
/// look — a geared character popping in piecewise would flicker on every change.
fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx) -> Option<Assembled> {
fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assembled> {
let PreviewSpec {
display_id,
race,
@@ -518,13 +506,11 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx) -> Option<Assembled> {
ctx.displays.as_deref(),
ctx.sections,
ctx.world_assets,
ctx.shared_light,
parts,
ctx.images,
&mut ctx.skin_composites.0,
ctx.asset_server,
ctx.materials,
&mut ctx.entity_mats.0,
ctx.mats,
);
// Whether this look shows the part's geoset (billboard or body alike).
@@ -31,18 +31,19 @@
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::assets::WorldAssets;
use crate::interior::InteriorLit;
use crate::lighting::SharedLightBuffer;
use crate::model_fade::{join_unit_appear_fade, FadeMaterials, PendingAppearFade, RenderFade};
use crate::net::{NetEntity, ObjectStore};
use crate::portrait::PortraitPart;
use crate::terrain::WowModelMaterial;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WorldAssets;
use benilla_world::interior::InteriorLit;
use benilla_world::model_fade::{
join_unit_appear_fade, FadeMaterials, PendingAppearFade, RenderFade,
};
use super::super::equipment::AppliedEquipment;
use super::super::{
Characters, Creatures, EntityMaterials, EntityPart, Equipment, ItemDisplays, SkinComposites,
SkinSections, VisualAttached,
Characters, Creatures, EntityPart, Equipment, ItemDisplays, SkinComposites, SkinSections,
VisualAttached,
};
use super::char_skin::{
build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip,
@@ -76,10 +77,10 @@ pub(in crate::entities) fn redress_player_looks(
&Equipment,
&mut AppliedEquipment,
&Children,
Option<&crate::rig_palette::RigSkin>,
Option<&benilla_world::rig_palette::RigSkin>,
Option<&super::super::BoneAttach>,
Option<&crate::interior::BodyBakeCenter>,
Option<&crate::model_fade::UnitAppearFade>,
Option<&benilla_world::interior::BodyBakeCenter>,
Option<&benilla_world::model_fade::UnitAppearFade>,
),
With<VisualAttached>,
>,
@@ -102,25 +103,15 @@ pub(in crate::entities) fn redress_player_looks(
skin_build: (
Option<Res<SkinSections>>,
Option<Res<WorldAssets>>,
Option<Res<SharedLightBuffer>>,
ResMut<Assets<Image>>,
ResMut<SkinComposites>,
Res<AssetServer>,
ResMut<Assets<WowModelMaterial>>,
ResMut<EntityMaterials>,
benilla_world::model_render::M2BatchMaterials,
),
time: Res<Time>,
) {
let (
sections,
world_assets,
shared_light,
mut images,
mut skin_composites,
asset_server,
mut materials,
mut entity_mats,
) = skin_build;
let (sections, world_assets, mut images, mut skin_composites, asset_server, mut mats) =
skin_build;
let now = time.elapsed_secs();
for (entity, net, live, mut applied, children, rig, bones, bake_center, unit_fade) in
&mut players
@@ -162,13 +153,11 @@ pub(in crate::entities) fn redress_player_looks(
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
shared_light.as_deref(),
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut materials,
&mut entity_mats.0,
&mut mats,
),
None => (None, None, None, (None, None)),
};
@@ -220,15 +209,14 @@ pub(in crate::entities) fn redress_player_looks(
let no_anchors = std::collections::HashMap::new();
let dress = PartDress {
unit: entity,
kind: crate::debug_panel::ModelKind::Creature,
kind: benilla_world::model_render::ModelKind::Creature,
char_mats: &char_mats,
object: &crate::interact::WorldObject {
kind: crate::debug_panel::ModelKind::Creature,
object: &benilla_world::interact::WorldObject {
kind: benilla_world::model_render::ModelKind::Creature,
label: super::display_label(&dm.handle),
id: net.display_id.unwrap_or(0),
detail: format!("emitters: {}", dm.emitters.len()),
},
rig_tag: crate::mesh_tag::rig_bits(rig.map_or(0, |r| r.slot)),
inst_slot: rig.map_or(0, |r| r.slot),
rigged: bones.is_some(),
anchors: bones.map_or(&no_anchors, |b| &b.anchors),
@@ -266,7 +254,7 @@ pub(in crate::entities) fn redress_player_looks(
/// ramps resolve their twin every frame from [`FadeMaterials`] + the law, and the portrait booths
/// mirror the steady one whenever a booth rebuilds. The **displayed** material is only written when
/// no ramp owns that channel — a part mid-appear-fade wears its blend twin, and
/// [`crate::model_fade::apply_render_fade`] re-resolves it from the two records above on its next
/// [`benilla_world::model_fade::apply_render_fade`] re-resolves it from the two records above on its next
/// tick, so writing the steady handle here would flash it opaque for a frame.
fn repoint_part(
part: &EntityPart,
@@ -368,7 +356,9 @@ mod tests {
.init_asset::<Image>()
.init_asset::<WowModelMaterial>()
.init_resource::<SkinComposites>()
.init_resource::<EntityMaterials>();
// The engine's material cache — normally `model_render::plugin`'s, which this bare
// harness does not install.
.init_resource::<benilla_world::model_render::ModelMaterials>();
let mut dm = empty_display();
dm.parts = Some(geosets.iter().map(|g| part(*g)).collect());
@@ -1,5 +1,5 @@
//! **Carried M2 lights** — the dynamic point lights an *entity* brings into the world, as opposed to
//! the ones a placed ADT doodad / WMO prop brings (`crate::terrain_stream`'s `spawn_lights_for`).
//! the ones a placed ADT doodad / WMO prop brings (`benilla_world::terrain_stream`'s `spawn_lights_for`).
//!
//! The law is one law. `0x718960` runs per frame over **every** CM2Model the scene draws — a placed
//! doodad, a creature, a GameObject, and (recursing at `7191b9`/`719286`) each attached child model —
@@ -20,13 +20,13 @@
//! joint entity** with the def position rebased into that bone's frame (`position − bone_pivot`),
//! exactly as the emitters and ribbons ride (0130 phase 4). Bevy's transform propagation then walks
//! the light through the animation for free, and the per-frame light packer
//! ([`crate::lighting`]) reads its `GlobalTransform` like any other point light.
//! ([`benilla_world::lighting`]) reads its `GlobalTransform` like any other point light.
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::ModelLight;
use bevy::prelude::*;
use crate::terrain_stream::point_light;
use benilla_world::terrain_stream::point_light;
/// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of
/// an entity's model.
+15 -26
View File
@@ -51,8 +51,8 @@
//!
//! **Render state** (`0x7afcb0`, decoded against wow-re's own verified `EGxRs` map): additive
//! `SRC_ALPHA/ONE`, emissive white — a beam is **never tinted** — two-sided, depth-write off, fog
//! **off**. It rides the shared effect-quad stream ([`crate::particles::buffer::EffectQuads`]) like
//! every other dynamic effect; `crate::ribbons` is the structural model (same strip-as-quads
//! **off**. It rides the shared effect-quad stream ([`benilla_world::particles::buffer::EffectQuads`]) like
//! every other dynamic effect; `benilla_world::ribbons` is the structural model (same strip-as-quads
//! conversion, same commit).
//!
//! **Named approximations.** (a) The strand count (`CharParamOne`, ≤ 3 — only Chain Burn ships > 1)
@@ -71,8 +71,8 @@ use bevy::prelude::*;
use crate::creature_anim::{ChainProcPlay, FxClass, SpellKitFx, SpellVisuals};
use crate::net::GuidIndex;
use crate::particles::buffer::{EffectDrawSpec, EffectFog, EffectQuads, EffectVertex};
use crate::player::WorldCamera;
use benilla_world::particles::buffer::EffectVertex;
use benilla_world::view::WorldCamera;
use super::{BoneAttach, OverheadFallback};
@@ -434,7 +434,7 @@ fn push_strand(verts: &mut Vec<EffectVertex>, pts: &[Vec3], half_width: f32, u_s
pub(crate) fn simulate_chain_beams(
time: Res<Time>,
mut commands: Commands,
mut quads: ResMut<EffectQuads>,
mut draw: benilla_world::particles::buffer::WorldEffectDraw,
images: Res<Assets<Image>>,
world_cam: Query<Entity, With<WorldCamera>>,
units: Query<(&GlobalTransform, Option<&BoneAttach>)>,
@@ -474,7 +474,7 @@ pub(crate) fn simulate_chain_beams(
..
} = &mut *beam;
let start = quads.begin();
let mut batch = draw.batch(cam, texture);
let (mut anchor_sum, mut anchor_n) = (Vec3::ZERO, 0.0f32);
for (i, bolt) in bolts.iter_mut().enumerate() {
// The per-hop window — bypassed entirely while the flag is set (`0x6ec520`).
@@ -517,31 +517,20 @@ pub(crate) fn simulate_chain_beams(
pts.clear();
pts.extend_from_slice(&scratch);
}
push_strand(&mut quads.verts, pts, half_width, u_scroll);
push_strand(batch.verts_mut(), pts, half_width, u_scroll);
}
}
if anchor_n == 0.0 {
continue; // every hop hidden — `commit_quads` over an empty range is a no-op anyway
}
quads.commit_quads(
start,
EffectDrawSpec {
cam,
texture,
// `EGxRs 0x07 = 3` → `glBlendFunc(SRC_ALPHA, ONE)`, and `0x0f = 0` → GL_FOG off.
// Depth-write off and two-sided come with the lane's additive pipeline.
blend: crate::particles::buffer::EffectBlend::Add,
fog: EffectFog::Off,
lit: false,
anchor: anchor_sum / anchor_n,
// No owner rung: a beam is not a model's emitter (module docs, approximation (d)).
bias: 0.0,
raster_bias: 0,
cam_relative: false,
main_entity: entity,
light: None,
},
);
// `EGxRs 0x07 = 3` → `glBlendFunc(SRC_ALPHA, ONE)`, and `0x0f = 0` → GL_FOG off (the
// lane's default). Depth-write off and two-sided come with the additive pipeline. No
// owner rung: a beam is not a model's emitter (module docs, approximation (d)).
batch
.additive()
.anchored(anchor_sum / anchor_n)
.owner(entity)
.quads();
}
}
+5 -5
View File
@@ -22,7 +22,7 @@ const TILT_WALKABLE_Y: f32 = 0.0175;
/// FREEZES the smoothed up-vector (holds the last stance) instead of conforming.
const TILT_FREEZE_Y: f32 = 0.3572;
/// The up-vector smoothing decay — the bridge's `[0x80c6a0]`: per-frame factor `0.0018^dt`
/// (≈ `e^(−6.32·dt)`, τ ≈ 158 ms) applied to the RESIDUAL toward the target normal.
/// (≈ `e^(−6.32·dt)`, τ ≈ 158 world) applied to the RESIDUAL toward the target normal.
const TILT_DECAY: f32 = 0.0018;
/// The tilt carrier a flagged model's root bones parent under — spawned by the attach path when
@@ -98,8 +98,8 @@ fn conform_rotation(mode: u8, n_local: Vec3) -> Quat {
pub(super) fn conform_units(
time: Res<Time>,
spatial: SpatialQuery,
ground: Query<(), With<crate::collision::GroundDecalSurface>>,
units: Query<(&Transform, Has<crate::creature_anim::AnimParked>), Without<ConformNode>>,
decals: benilla_world::decal::WorldDecal,
units: Query<(&Transform, Has<benilla_world::rig_anim::AnimParked>), Without<ConformNode>>,
mut nodes: Query<(&ConformNode, &mut Transform)>,
mut ups: Local<EntityHashMap<Vec3>>,
mut disabled: Local<Option<bool>>,
@@ -110,7 +110,7 @@ pub(super) fn conform_units(
if *disabled.get_or_insert_with(|| std::env::var_os("WOW_NO_MOUNT_TILT").is_some()) {
return;
}
let filter = crate::collision::player_query_filter();
let filter = benilla_world::collision::WorldCollision::body_filter();
let decay = TILT_DECAY.powf(time.delta_secs());
let mut seen = EntityHashSet::default();
let mut max_pitch: Option<(Entity, f32)> = None;
@@ -142,7 +142,7 @@ pub(super) fn conform_units(
TILT_PROBE_UP + TILT_PROBE_DOWN,
true,
&filter,
&|e| ground.contains(e),
&|e| decals.receives(e),
)
.map(|hit| hit.normal);
tilt_target(sampled)
+3 -3
View File
@@ -39,8 +39,8 @@
use bevy::prelude::*;
use crate::creature_anim::{SpellKitSound, SpellVisuals};
use crate::model_render::m2_url;
use crate::net::{NetEntity, ObjectStore};
use benilla_assets::m2_url;
use benilla_protocol::EntityKind;
use super::spell_fx::{attach_effect_visuals, SpellFx};
@@ -275,10 +275,10 @@ pub(super) fn attach_ground_fx_models(
time: Res<Time>,
mut instances: Query<(Entity, &mut GroundFx, Option<&mut AnimationPlayer>)>,
fx: Option<Res<SpellFx>>,
mut wow_materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
mut wow_materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
let Some(fx) = fx else { return };
let now = time.elapsed_secs();
+39 -239
View File
@@ -16,8 +16,8 @@ use benilla_formats::{
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
use bevy::prelude::*;
use crate::model_render::{m2_url, model_material, skin_url, wmo_url, MaterialCache, ShadeSel};
use crate::terrain::WowModelMaterial;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::{m2_url, skin_url, wmo_url};
/// A loaded display model's asset handle — an M2 (creatures, most GameObjects) or a WMO (building
/// GameObjects). `None` ⇒ the display resolved to no model (invisible trigger / missing) → cube/none.
@@ -34,7 +34,7 @@ pub(super) enum ModelHandle {
pub(super) struct EntityPart {
pub(super) mesh: Handle<Mesh>,
/// The batch's decoded geometry — the model's resident CPU copy (`ModelSubmesh::geometry`),
/// cloned onto every spawned part/card as [`crate::interact::PickMesh`] so the ray pickers read
/// cloned onto every spawned part/card as [`benilla_world::interact::PickMesh`] so the ray pickers read
/// triangles from here: the static render form is `RENDER_WORLD`-only (decision 0834), so no
/// main-world mesh data exists to ray-cast (decision 0857).
pub(super) geometry: std::sync::Arc<benilla_formats::RenderSubmesh>,
@@ -50,7 +50,7 @@ pub(super) struct EntityPart {
pub(super) material: Handle<WowModelMaterial>,
/// The interior-lit variant (M2 only): the same material in the plain day/night matte
/// (`interior = false`, sun ×1.0 — the base-CGLight law), which the interior classifier
/// ([`crate::interior`]) swaps to when the entity stands inside a WMO room. `None` for
/// ([`benilla_world::interior`]) swaps to when the entity stands inside a WMO room. `None` for
/// WMO-display entities (their interior is per-submesh, already baked into `material`).
pub(super) material_interior: Option<Handle<WowModelMaterial>>,
/// The interior BAKE variant (every M2 part): the material in interior-PROP mode, whose
@@ -67,7 +67,7 @@ pub(super) struct EntityPart {
/// the entity feathers in on this while `α < 1`, then swaps back to `material`. `Some` for M2 parts
/// (creatures + M2 GameObjects, the CGObjects that appear-fade); `None` for WMO-display parts.
pub(super) fade_blend: Option<Handle<WowModelMaterial>>,
/// The **depth-prime twin** material ([`crate::model_render::zfill_material`] — the reference's
/// The **depth-prime twin** material ([`benilla_world::model_render::zfill_material`] — the reference's
/// `M2UseZFill` clone, wow-re `m2-blend-promotion-zfill.md` §4). While the part draws
/// translucent, `model_fade::sync_zfill_twins` spawns a child mesh on this: colour-masked,
/// blend-off, z-writing, sorted before the colour parts — one blended layer everywhere, no
@@ -79,7 +79,7 @@ pub(super) struct EntityPart {
/// Whether this part blends **additively** (M2 blend mode 3 `NoAlphaAdd` / 4 `Add`).
/// [`ModelBlend`] deliberately folds "alpha-blended / additive" into its single `Blend`
/// variant, so [`Self::blend`] alone CANNOT answer this — the material path recovers it from
/// [`crate::model_render`]'s separate `is_additive` input (marker bit 2 of `clutter_fade.z`,
/// [`benilla_world::model_render`]'s separate `is_additive` input (marker bit 2 of `clutter_fade.z`,
/// which `specialize` turns into the pure-add state). Any consumer that re-derives a blend
/// state from `blend` needs this alongside it, or every additive batch silently draws
/// alpha-blended — decision 0748, the black ground-decal tile.
@@ -106,7 +106,7 @@ pub(super) struct EntityPart {
pub(super) char_slot: Option<CharSkinSlot>,
/// A billboard batch (glow card / chain): its pivot + facing info. The spawn sites must NOT
/// spawn it as an ordinary child — its mesh is centred at the bone pivot and its transform is
/// owned by the billboard system (a following [`crate::billboard::BillboardCard`], decision
/// owned by the billboard system (a following [`benilla_world::billboard::BillboardCard`], decision
/// 0153: the brazier/torch "glow on the ground" family). `None` for ordinary geometry.
pub(super) billboard: Option<benilla_assets::BillboardInfo>,
/// This part's geometry is **welded** to a billboard bone the card split refused
@@ -229,7 +229,7 @@ pub(super) struct DisplayModel {
pub(super) bbox_z_local: f32,
/// The M2 vertex-box CENTRE in Bevy model-local (pre-scale) — the interior fold's MOLR
/// reference point for a GameObject's footprint bake (the byte-cited `[def+0x5c]` anchor
/// family; `crate::interior`). `Vec3::ZERO` for a bounds-less / WMO / model-less display.
/// family; `benilla_world::interior`). `Vec3::ZERO` for a bounds-less / WMO / model-less display.
pub(super) bake_center_local: Vec3,
/// The model's terrain-conform gate — MD20 `GlobalModelFlags & 3` (wow-re `terrain-tilt.md`,
/// §5): `1` = pitch to slope, `3` = pitch+roll, else level. Captured with `parts` on load;
@@ -354,15 +354,16 @@ pub(super) fn build_parts(
// The app-built render forms (decision 0834): parts wait for the furnisher exactly as they
// wait for the asset. Entity displays request at priority 0 — a mob walking into view never
// queues behind a city crossing's scenery (whose placements request at 16+).
forms: &mut crate::model_forms::ModelForms,
forms: &mut benilla_world::model_forms::ModelForms,
asset_server: &AssetServer,
materials: &mut Assets<WowModelMaterial>,
cache: &mut MaterialCache,
light: &bevy::render::render_resource::Buffer,
mats: &mut benilla_world::model_render::M2BatchMaterials,
// Whether this display is a GAMEOBJECT: gates the hull-collider build (chests/veins/doors;
// creatures use unit collision).
gameobject: bool,
) {
if !mats.ready() {
return; // no shared light buffer yet — retry next frame, like a still-loading asset
}
let mut emitters = Vec::new();
let mut ribbons = Vec::new();
let mut lights = Vec::new();
@@ -390,9 +391,7 @@ pub(super) fn build_parts(
// Every M2 entity lane can rig (creatures, animated GameObjects, held items with
// billboard chains), so both forms are requested; the static form still serves the
// truly static instances and the billboard cards.
let key = crate::model_forms::ModelKey::from(h);
let want = crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED;
if !forms.require(key, want, 0) {
if !forms.require_rigged(h, 0) {
return; // forms still building — same retry-next-frame as the asset itself
}
emitters = model.emitters.clone();
@@ -448,8 +447,8 @@ pub(super) fn build_parts(
if gameobject {
collider = model.collision.as_ref().and_then(model_local_collider);
}
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
let skin_forms = forms.skinned_meshes(key).unwrap_or(&[]);
let built = forms.slices(h);
let (stat_forms, skin_forms) = (built.stat, built.skin.unwrap_or(&[]));
model
.submeshes
.iter()
@@ -467,194 +466,13 @@ pub(super) fn build_parts(
// sheen + Blend overlay) draw in file order instead of re-flipping a sort tie
// every frame (`model_render::BATCH_ORDER_SORT_EPS`).
let order = u16::try_from(pi + 1).unwrap_or(u16::MAX);
let exterior = model_material(
cache,
materials,
texture.clone(),
sub.blend,
sub.two_sided,
false,
false, // exterior (sky-lit) variant
sub.emissive, // M2 UNLIT (0x01) glass/glow → fullbright
sub.additive, // M2 blend 3/4 → additive (glow cards)
false,
sub.no_depth_write, // M2 render flag 0x10
sub.no_depth_test, // M2 render flag 0x08
sub.fog_policy,
sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs
// Every entity M2 (unit/player/GameObject/held/fx) is built LIT — the verified
// §9 chain gives them the same 2.5/0.5 lane as ADT doodads, and the DYNAMIC half
// (the MCSH sample at their feet) rides the per-instance MeshTag shade byte
// (`entity_shade`), not the shared material.
ShadeSel::Lit,
order,
None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads)
sub.rgb_anim.as_ref(), // animated M2Color tint, seeded at its first key
None, // entity M2: light selection anchors at the instance origin
None, // M2 carries no MOMT SIDN colour
false, // …nor the WINDOW flag
light,
);
// The AlphaMode::Blend twin for the spawn appear-fade (decision 0032) — the exterior
// look feathered, the same variant DoodadFade uses. Reuse the exterior when it's
// already Blend (its cutout already feathers; no separate twin needed). A MULTIPLY
// batch (Mod/Mod2x) also reuses its steady self: its blend equation reads no
// alpha, so it cannot feather through the alpha channel — the reference's
// instanceAlpha ramp leaves the sheen at full strength while the base fades under
// it (0528; re-confirmed for items by wow-re `m2-item-texture-fill.md`). benilla
// fades it anyway as a deliberate deviation: the part arms the ramp like any
// other, and `wow_model.wgsl` lerps a multiply batch's colour toward its blend
// IDENTITY by the tag alpha, so the sheen rides the same ramp instead of popping
// over a body that hasn't faded in (the director's login report; decision 0865).
// The depth-prime twin (wow-re `m2-blend-promotion-zfill.md` §4): every batch
// that can fade AND writes depth gets one. `cutout` mirrors what the part's
// colour pass discards while translucent — the fade twin's hard 224/255 for
// AlphaKey sources only (an Opaque source never alpha-tests, steady or
// promoted — §2; decision 0842), nothing for authored-Blend sources (their
// colour pass is the plain blend material) — so depth and colour coverage agree.
let zfill = if sub.no_depth_write || sub.no_depth_test {
None
} else {
match sub.blend {
ModelBlend::Mod | ModelBlend::Mod2x => None,
b => Some(crate::model_render::zfill_material(
cache,
materials,
texture.clone(),
sub.two_sided,
b == ModelBlend::AlphaTest,
light,
)),
}
};
let fade_blend = match sub.blend {
// The steady material IS the "twin": no swap, the ramp only drives the tag
// alpha the shader's identity-lerp reads (see the comment block above).
ModelBlend::Mod | ModelBlend::Mod2x => Some(exterior.clone()),
ModelBlend::Blend => Some(exterior.clone()),
// The SOURCE blend rides into the twin (Opaque or AlphaKey here): with
// fade_variant it still builds AlphaMode::Blend, but the source decides
// the twin's 224/255 cutout marker (decision 0842).
_ => Some(model_material(
cache,
materials,
texture.clone(),
sub.blend,
sub.two_sided,
false,
false, // exterior
sub.emissive,
sub.additive,
true, // fade_variant — the blend twin the shader feathers
sub.no_depth_write,
sub.no_depth_test,
sub.fog_policy,
sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs
ShadeSel::Lit, // matches the exterior variant above
order,
None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads)
sub.rgb_anim.as_ref(),
None, // entity M2 fade twin: same instance-origin anchor
None,
false,
light,
)),
};
// The interior BAKE variant (every M2 entity): interior-PROP mode — the shader
// evaluates the model's SH probe (footprint MOCV + MOLR lobes, `crate::interior`)
// by the MeshTag slot. The reference hands EVERY entity M2 — unit, player,
// GameObject — the same entity-node fill (`Node::SetModel` → the footprint bake;
// wow-re `unit-m2-shader-light.md`), so all classes build the variant and take it
// indoors. Cache-deduped like every variant.
let interior_bake = Some(model_material(
cache,
materials,
texture.clone(),
sub.blend,
sub.two_sided,
false,
true, // interior-PROP mode — the probe lane
sub.emissive,
sub.additive,
false,
sub.no_depth_write,
sub.no_depth_test,
sub.fog_policy,
sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs
ShadeSel::Matte, // unread on the probe lane
order,
None,
sub.rgb_anim.as_ref(),
None,
None,
false,
light,
));
// The bake lane's Blend twin — the probe-lit feather (the self-avatar zoom,
// a despawn ramp): same probe mode, alpha-blend state. Without it a fade swaps
// an indoor entity to the EXTERIOR blend twin and its light jumps to the lit
// outdoor intensity mid-feather (director-caught, 2026-07-13). Reuse the bake
// variant when the part is already Blend, like the exterior twin above — and
// for a multiply batch, whose steady bake IS the twin (the shader's
// identity-lerp does the feather, decision 0865).
let interior_bake_blend = interior_bake.as_ref().map(|bake| {
match sub.blend {
ModelBlend::Mod | ModelBlend::Mod2x | ModelBlend::Blend => bake.clone(),
// Source blend through to the twin, like the exterior twin above (0842).
_ => model_material(
cache,
materials,
texture.clone(),
sub.blend,
sub.two_sided,
false,
true, // interior-PROP mode — the probe lane
sub.emissive,
sub.additive,
true, // fade_variant — the blend twin the shader feathers
sub.no_depth_write,
sub.no_depth_test,
sub.fog_policy,
sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs
ShadeSel::Matte, // unread on the probe lane
order,
None,
sub.rgb_anim.as_ref(),
None,
None,
false,
light,
),
}
});
// The interior MATTE variant: the plain day/night pair at sun ×1.0 — the
// reference's null-node fallback (`0x672a20` with no registered node), which the
// classifier applies when the footprint ray misses / hits MOPY&1 / the probe
// table is full. Not the steady indoor law — that's the bake above.
let interior = model_material(
cache,
materials,
texture,
sub.blend,
sub.two_sided,
false,
false, // NOT the prop MOCV path — entities have no interior colour leg
sub.emissive,
sub.additive,
false,
sub.no_depth_write,
sub.no_depth_test,
sub.fog_policy,
sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs
ShadeSel::Matte, // sun ×1.0, the forced indoor intensity — const so it dedups
order,
None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads)
sub.rgb_anim.as_ref(),
None, // entity M2 interior variant: same instance-origin anchor
None,
false,
light,
);
// The whole variant set for this batch — steady, the indoor pair, their
// blend twins and the depth-prime twin — built once by the engine, which owns
// the law that decides which of them collapse onto each other (decisions 0842
// / 0865 / 0831 / 0355). `None` cannot happen here: the caller gated on the
// light buffer before the first build.
let v = mats.entity_variants(sub, texture, order);
let v = v.expect("light buffer checked at entry");
EntityPart {
// Index-parallel with the submeshes by the forms contract; a miss is a
// broken contract — a default (dead) handle draws nothing rather than panic.
@@ -665,12 +483,12 @@ pub(super) fn build_parts(
geometry: sub.geometry.clone(),
aabb: stat_forms.get(pi).and_then(|(_, a)| *a),
skinned_mesh: skin_forms.get(pi).cloned(),
material: exterior,
material_interior: Some(interior),
material_interior_bake: interior_bake,
material_interior_bake_blend: interior_bake_blend,
fade_blend,
zfill,
material: v.steady,
material_interior: Some(v.interior),
material_interior_bake: Some(v.interior_bake),
material_interior_bake_blend: Some(v.interior_bake_blend),
fade_blend: Some(v.fade_blend),
zfill: v.zfill,
blend: sub.blend,
additive: sub.additive,
two_sided: sub.two_sided,
@@ -690,14 +508,13 @@ pub(super) fn build_parts(
return;
};
// WMO-display GameObjects never skin — static forms only.
let key = crate::model_forms::ModelKey::from(h);
if !forms.require(key, crate::model_forms::WANT_STATIC, 0) {
if !forms.require_static(h, 0) {
return;
}
if gameobject {
collider = model.collision.as_ref().and_then(model_local_collider);
}
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
let stat_forms = forms.slices(h).stat;
model
.submeshes
.iter()
@@ -712,30 +529,13 @@ pub(super) fn build_parts(
geoset_id: sub.geoset_id, // 0 for WMO — no geoset selection
char_slot: None, // WMO is never a character body
skinned_mesh: None, // WMO-display GameObjects don't skin (no skeleton)
material: model_material(
cache,
materials,
sub.texture.clone(),
sub.blend,
sub.two_sided,
true,
sub.interior,
sub.emissive,
sub.additive, // false for WMO (additive WMO batches deferred)
false,
sub.no_depth_write, // false for WMO (standard depth state)
sub.no_depth_test,
sub.fog_policy, // always Scene for WMO
false, // WMO batches never env-map — the M2 texcoord mechanism has no MOMT analogue
ShadeSel::Matte, // WMO uses the FFP N·L path, not the lobe — sun_scale unused
0,
None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads)
None, // WMO batches carry no M2Color tint
sub.wmo_batch, // an interior group's INT/TRANS lighting law rides tint.w
sub.sidn, // MOMT SIDN night-glow colour
sub.window, // MOMT WINDOW midpoint light
light,
),
// A WMO-display GameObject draws one material per batch: its interior law
// is per-submesh and already baked into the batch (MOBA class + SIDN +
// WINDOW), so there is no M2 footprint-probe pair to build. Batch order 0 —
// these are single-building props, not the streamer's coplanar MOBA stacks.
material: mats
.steady(sub, sub.texture.clone(), 0)
.expect("light buffer checked at entry"),
material_interior: None, // WMO entity: interior is per-submesh, baked into `material`
material_interior_bake: None, // …and never the M2 footprint lane
material_interior_bake_blend: None,
@@ -778,7 +578,7 @@ pub(super) fn build_parts(
/// Bake a model-local avian trimesh collider from a model's raw-WoW collision hull, mapping each vertex
/// through `wow_to_bevy` so it coincides with the entity's rendered submeshes (same convention as the
/// map-doodad [`crate::terrain_stream`] bake, minus the placement — the entity's own pose places it).
/// map-doodad [`benilla_world::terrain_stream`] bake, minus the placement — the entity's own pose places it).
/// `None` for a hull-less or degenerate mesh: collide-iff-hull, so herbs/props stay non-solid.
fn model_local_collider(hull: &CollisionMesh) -> Option<Collider> {
if hull.indices.len() < 3 {
@@ -33,7 +33,7 @@ use benilla_formats::ItemDisplayCatalog;
use bevy::prelude::*;
use super::{DisplayModel, ModelHandle};
use crate::model_render::m2_url;
use benilla_assets::m2_url;
mod resolve;
pub(in crate::entities) use resolve::placement;
@@ -6,10 +6,12 @@
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::billboard::BillboardCard;
use crate::debug_panel::{ModelKind, ModelPart};
use crate::interior::part_interior_lit;
use crate::model_fade::{join_unit_appear_fade, FadeSet, JoinedFade, PartFade, UnitAppearFade};
use benilla_world::billboard::BillboardCard;
use benilla_world::interior::part_interior_lit;
use benilla_world::model_fade::{
join_unit_appear_fade, FadeSet, JoinedFade, PartFade, UnitAppearFade,
};
use benilla_world::model_render::{ModelKind, ModelPart};
use super::super::{item_glow::ItemGlow, spawn_carried_lights};
use super::{
@@ -38,11 +40,11 @@ struct WearerCtx<'a> {
joined: JoinedFade,
now: f32,
/// The wearer's rig-palette slot, pre-shifted into `MeshTag` bits (decision 0812).
rig_tag: u32,
rig_slot: u16,
/// The wearer's body bake centre, when it has one — the interior classifier's fold reference.
body_center: Option<Vec3>,
/// The wire scale the unit renders at: the world yards its held effects' draw-order rung is
/// measured in ([`crate::particles::owner_last_bias`]).
/// measured in ([`benilla_world::particles::owner_last_bias`]).
scale: f32,
}
@@ -122,7 +124,7 @@ pub(in crate::entities) fn attach_held_items(
Option<&mut HeldAttached>,
Entity,
Option<&UnitAppearFade>,
Option<&crate::interior::BodyBakeCenter>,
Option<&benilla_world::interior::BodyBakeCenter>,
// The unit root's own transform — its scale is the wire `NetEntity::scale` the streamer
// writes (`entities::attach`), and the held effects' draw-order rung is measured in the
// world yards that scale produces.
@@ -133,14 +135,14 @@ pub(in crate::entities) fn attach_held_items(
// used to SKIN those parts: they draw the static mesh, and the vertex stage's slot read is
// gated on the mesh's own joint attributes. The one exception is an item that rigs itself
// (0841) — it carries its own slot instead, and inherits the tint up the `ParentModel` chain.
Option<&crate::rig_palette::RigSkin>,
Option<&benilla_world::rig_palette::RigSkin>,
)>,
held: Option<Res<ItemDisplays>>,
time: Res<Time>,
mut seats: SeatWriters,
// An item model normally spawns no rig — but a display that welds geometry to a billboard bone
// has no correct rigid placement, so its spawn allocates a palette slot of its own (0841).
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
let Some(held) = held else {
return;
@@ -157,7 +159,7 @@ pub(in crate::entities) fn attach_held_items(
bones,
joined: join_unit_appear_fade(unit_fade.copied()),
now,
rig_tag: crate::mesh_tag::rig_bits(skin.map_or(0, |rb| rb.slot)),
rig_slot: skin.map_or(0, |rb| rb.slot),
body_center: body_center.map(|c| c.0),
scale: unit_tf.map_or(1.0, |t| t.scale.max_element()),
};
@@ -249,7 +251,7 @@ pub(in crate::entities) fn attach_held_items(
/// the ribbons. `None` when the display has no built parts or the body has no such attach point.
fn spawn_slot(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
palettes: &mut benilla_world::rig_palette::RigPalettes,
ctx: &WearerCtx,
held: &ItemDisplays,
slot_idx: usize,
@@ -270,7 +272,7 @@ fn spawn_slot(
// `[model+0x1cc]` parent link): the wearer's computed render alpha multiplies
// everything this root carries, and everything chained below it in turn — its glow
// instances included (decision 0833).
crate::model_fade::ParentModel(entity),
benilla_world::model_fade::ParentModel(entity),
))
.id();
commands.entity(joint).add_child(root);
@@ -331,7 +333,7 @@ fn spawn_slot(
// 0839 stopped tearing it into a card, which left it whole and *still*. The reference bends it
// by blending the billboard bone's camera-replaced palette row per vertex (`m2_vertex_skin`,
// `0x71a460`) — so we build exactly that: the joint hierarchy, a palette slot, and the
// [`crate::billboard::BillboardJointRig`] that rewrites the billboard joints' world rotations.
// [`benilla_world::billboard::BillboardJointRig`] that rewrites the billboard joints' world rotations.
//
// Nothing else about the item lane's "rests at bind pose" law moves: no `AnimationPlayer`, no
// global-sequence drive — all seven affected models are **keyless** (`m2bones`: not one
@@ -342,15 +344,17 @@ fn spawn_slot(
.as_ref()
.filter(|_| dm.welds_billboard() && !dm.skeleton.joints.is_empty())
.and_then(|ibp| {
let joints = super::super::spawn_joints(commands, root, root, &dm.skeleton);
if let Some(bb) = crate::billboard::BillboardJointRig::new(&dm.skeleton, &joints, root)
let joints =
benilla_world::rig_palette::spawn_joints(commands, root, root, &dm.skeleton);
if let Some(bb) =
benilla_world::billboard::BillboardJointRig::new(&dm.skeleton, &joints, root)
{
commands.entity(root).insert(bb);
}
// Slot 0 (table full, warned once) ⇒ no rig: the parts below fall back to the static
// mesh and the wearer's slot, i.e. exactly the pre-0841 look. Never a crash, never a
// missing shoulder.
let rig = crate::rig_palette::RigSkin::allocate(palettes, joints, ibp.clone())?;
let rig = benilla_world::rig_palette::RigSkin::allocate(palettes, joints, ibp.clone())?;
let slot = rig.slot;
commands.entity(root).insert(rig);
// The lane is otherwise invisible from outside the renderer — seven models in 9691 take
@@ -368,9 +372,9 @@ fn spawn_slot(
// rigged item must carry its OWN, because the vertex stage indexes the palette with the same
// field; the wearer's tint reaches it through the `ParentModel` chain instead, which is the
// reference's own route for an attached model's colours (`0x714000`, `aura_visual`).
let rig_tag = match item_rig {
Some(slot) => crate::mesh_tag::rig_bits(slot),
None => ctx.rig_tag,
let rig_slot = match item_rig {
Some(slot) => slot,
None => ctx.rig_slot,
};
// Billboard batches (the torch's glow card) collected for the world-root card spawn
// below — as plain children they'd render at the item root (the grip), not the
@@ -403,7 +407,7 @@ fn spawn_slot(
},
// The picker's triangles (decision 0857): the `WOW_PICK` probe names worn gear
// through `ModelPart`, and the render meshes are `RENDER_WORLD`-only.
crate::interact::PickMesh(part.geometry.clone()),
benilla_world::interact::PickMesh(part.geometry.clone()),
// The portrait booth mirrors this rider ([`crate::portrait`]): steady
// material (not the fade twin) + where it sits, so the bake can seat it at
// the bone's bind-pose global (the booth spawns no skeleton). It stays the
@@ -421,7 +425,7 @@ fn spawn_slot(
// `Aabb` below is not the volume it draws in — the same reason the effect lane's
// skinned parts opt out (`spell_fx`). Seven models' worth of always-drawn parts.
child.insert((
crate::rig_palette::RigPart(root),
benilla_world::rig_palette::RigPart(root),
bevy::camera::visibility::NoFrustumCulling,
));
}
@@ -444,7 +448,9 @@ fn spawn_slot(
// with no interior variant got no tag at all before, so it was also invisible to
// the ground-shade ramp that darkens its wielder; both now follow the body, which
// is the same light-collector aliasing the comment above describes.
child.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(tag_alpha)));
child.insert(MeshTag(benilla_world::mesh_tag::spawn_tag(
rig_slot, tag_alpha,
)));
// The item part's build-time bound (decision 0834): `calculate_bounds` can no longer
// derive one from the `RENDER_WORLD`-only static form's data. Skipped for a skinned
// part — it opted out of the frustum cull above, and a stale bound would only mislead
@@ -470,7 +476,7 @@ fn spawn_slot(
// strength — the Hungering Cold's five glow cards blaze at 1.0 where the file says
// 0.30 (decision 0836).
if let Some(anim) = &part.alpha_anim {
child.insert(crate::doodad_anim::MatAnim::resting(anim.clone()));
child.insert(benilla_world::doodad_anim::MatAnim::resting(anim.clone()));
}
effective.dress(&mut child, &set);
}
@@ -514,7 +520,7 @@ fn spawn_slot(
blend: part.blend,
},
// The picker's triangles (decision 0857), pivot-centred by the caster like the bake.
crate::interact::PickMesh(part.geometry.clone()),
benilla_world::interact::PickMesh(part.geometry.clone()),
BillboardCard::following(&info, root),
));
// The card's build-time bound (decision 0834) — same rule as its mesh siblings above.
@@ -525,7 +531,9 @@ fn spawn_slot(
// constructor and anchored at the same WEARER (decision 0778) — so a held torch's
// glow card can never split from the arm holding it. …and the wearer's instance slot,
// like its mesh siblings: a tinted body colours the torch's glow card too (0812).
card.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(tag_alpha)));
card.insert(MeshTag(benilla_world::mesh_tag::spawn_tag(
rig_slot, tag_alpha,
)));
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
@@ -538,7 +546,7 @@ fn spawn_slot(
// The card's own share of the authored material alpha (the Hungering Cold's gems are
// keyed 0.30) — same pinned lane as the mesh parts.
if let Some(anim) = &part.alpha_anim {
card.insert(crate::doodad_anim::MatAnim::resting(anim.clone()));
card.insert(benilla_world::doodad_anim::MatAnim::resting(anim.clone()));
}
effective.dress(&mut card, &set);
}
@@ -603,11 +611,11 @@ fn spawn_slot(
}
None => (root, [0.0; 3]),
};
crate::particles::spawn_emitter(
benilla_world::particles::spawn_emitter(
commands,
em,
spawn_tf,
crate::particles::EmitterFrames {
benilla_world::particles::EmitterFrames {
owner: Some(owner),
// A held item is an attached model — the flame fans with the swing.
attach: Some(root),
@@ -617,7 +625,7 @@ fn spawn_slot(
// reference frees a model's emitters at its dtor — so no cloud is left hanging in
// the air behind the character (decision 0826). A sheath swap no longer comes
// through here at all: the root is MOVED, and this pool rides it.
on_owner_loss: crate::particles::OwnerLoss::Free,
on_owner_loss: benilla_world::particles::OwnerLoss::Free,
// This emitter's own model instance is the item root; `ParentModel` above chains
// it to the wearer, and the chain is what an ATTACHED model's composed alpha is
// (`0x714000`) — so the sparkle on a pauldron fades in with the body wearing it
@@ -629,7 +637,7 @@ fn spawn_slot(
},
// A held item spawns no rig; its emitters run the item model's own slot-0
// loop on the spawn clock (the torch burns always — the doodad law).
crate::particles::EmitClock::Pinned,
benilla_world::particles::EmitClock::Pinned,
);
}
// The item's own M2 point light — **the held torch's glow** (decision 0016's law on the
@@ -645,7 +653,7 @@ fn spawn_slot(
// rests in Stand (anim 0): a thrown weapon's trail is keyed dark there, so the flight
// ribbon never shows in the hand — it lights only on the InFlight missile.
for rb in &dm.ribbons {
crate::ribbons::spawn_ribbon(
benilla_world::ribbons::spawn_ribbon(
commands,
rb,
root,
@@ -653,7 +661,7 @@ fn spawn_slot(
ctx.scale,
// A worn item rests in `Stand` and nothing on it plays anything else — the one lane
// where the enable gate genuinely has a fixed answer for the instance's life.
crate::ribbons::RibbonSeq::Fixed(0),
benilla_world::ribbons::RibbonSeq::Fixed(0),
// Its own model instance — chained to the wearer above — so an enchant streamer is
// gone with the avatar in first person and absent until the body is shown (0827/0833).
Some(root),
@@ -721,7 +729,7 @@ mod tests {
const KIND: ItemModelKind = ItemModelKind::ShoulderLeft;
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
@@ -768,9 +776,9 @@ mod tests {
attach: attach_id::SHOULDER_LEFT,
visual: NO_GLOW,
});
let skin = crate::rig_palette::RigSkin::allocate_bones(
let skin = benilla_world::rig_palette::RigSkin::allocate_bones(
app.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>()
.resource_mut::<benilla_world::rig_palette::RigPalettes>()
.as_mut(),
8,
Handle::default(),
@@ -791,7 +799,7 @@ mod tests {
// The item root's rig is the one that is NOT the wearer's (the wearer holds its own).
let item_rig = app
.world_mut()
.query::<&crate::rig_palette::RigSkin>()
.query::<&benilla_world::rig_palette::RigSkin>()
.iter(app.world())
.map(|r| r.slot)
.find(|&s| s != wearer_slot);
@@ -816,7 +824,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
// `RigSkin`'s free hook frees the slot through this on teardown.
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
@@ -840,9 +848,9 @@ mod tests {
visual: NO_GLOW,
});
let skin = rigged.then(|| {
crate::rig_palette::RigSkin::allocate_bones(
benilla_world::rig_palette::RigSkin::allocate_bones(
app.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>()
.resource_mut::<benilla_world::rig_palette::RigPalettes>()
.as_mut(),
8,
Handle::default(),
@@ -878,7 +886,7 @@ mod tests {
assert!(slot >= 1, "the wearer really has a rig");
assert_eq!(tags.len(), 1, "one part, one tag (interior={interior})");
assert_eq!(
crate::mesh_tag::rig_of(tags[0]),
benilla_world::mesh_tag::rig_of(tags[0]),
slot,
"the wearer's slot, interior={interior}",
);
@@ -893,7 +901,7 @@ mod tests {
let (tags, _) = attach_a_helm(true, false);
assert_eq!(tags.len(), 1);
assert_ne!(tags[0], 0, "not the untagged ⇒ opaque sentinel");
assert!((crate::mesh_tag::alpha_of(tags[0]) - 1.0).abs() < 1.0 / 63.0);
assert!((benilla_world::mesh_tag::alpha_of(tags[0]) - 1.0).abs() < 1.0 / 63.0);
}
/// A wearer with no rig at all (a boneless model holding something) lands on the identity slot 0
@@ -902,7 +910,7 @@ mod tests {
fn a_rigless_wearer_leaves_the_slot_at_identity() {
let (tags, _) = attach_a_helm(false, true);
assert_eq!(tags.len(), 1);
assert_eq!(crate::mesh_tag::rig_of(tags[0]), 0);
assert_eq!(benilla_world::mesh_tag::rig_of(tags[0]), 0);
}
/// **The item rig (decision 0841, restored by 0854).** A display whose geometry is welded to a
@@ -917,7 +925,7 @@ mod tests {
/// again. The withdrawal's premise — a spherical billboard sweeping the spikes through the
/// plate — was refuted at the geometry (0853) and at the bytes (wow-re
/// `billboard-bone-law.md` §6), and
/// [`crate::billboard::tests::a_spike_along_its_bone_axis_points_screen_down_from_every_angle`]
/// [`benilla_world::billboard::tests::a_spike_along_its_bone_axis_points_screen_down_from_every_angle`]
/// is the standing guard that the arc stays absent in our own basis.
#[test]
fn a_welded_billboard_item_spawns_its_own_rig() {
@@ -930,7 +938,7 @@ mod tests {
);
assert_eq!(tags.len(), 1);
assert_eq!(
crate::mesh_tag::rig_of(tags[0]),
benilla_world::mesh_tag::rig_of(tags[0]),
item_slot,
"the part indexes the ITEM's palette, not the body's"
);
@@ -944,7 +952,7 @@ mod tests {
fn an_ordinary_item_still_spawns_no_rig() {
let (tags, wearer_slot, item_rig, mesh) = attach_a_shoulder(false);
assert!(item_rig.is_none(), "no palette slot for a rigid item");
assert_eq!(crate::mesh_tag::rig_of(tags[0]), wearer_slot);
assert_eq!(benilla_world::mesh_tag::rig_of(tags[0]), wearer_slot);
assert_eq!(mesh, Some(Handle::default()), "the static mesh, as before");
}
@@ -965,7 +973,7 @@ mod tests {
const PIVOT: Vec3 = Vec3::new(-0.06, 0.162, -0.012);
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
@@ -981,7 +989,7 @@ mod tests {
});
dm.parts = Some(vec![part(false), card]);
dm.emitters = vec![benilla_assets::ModelEmitter {
def: crate::particles::tests::plain_def(),
def: benilla_world::testing::plain_particle_def(),
texture: None,
bone_pivot: [0.0; 3],
billboard: Some((benilla_formats::BillboardKind::Spherical, [0.0; 3])),
@@ -1051,13 +1059,13 @@ mod tests {
fn an_items_card_joins_the_wearers_fade_and_keeps_its_authored_alpha() {
const KIND: ItemModelKind = ItemModelKind::Weapon;
const SINCE: f32 = 3.0;
let blend: Handle<crate::terrain::WowModelMaterial> = Handle::Uuid(
let blend: Handle<benilla_assets::materials::WowModelMaterial> = Handle::Uuid(
bevy::asset::uuid::Uuid::from_u128(99),
std::marker::PhantomData,
);
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
@@ -1113,10 +1121,10 @@ mod tests {
let mut q = app.world_mut().query::<(
&BillboardCard,
&MeshTag,
&MeshMaterial3d<crate::terrain::WowModelMaterial>,
Option<&crate::model_fade::FadeMaterials>,
Option<&crate::model_fade::PendingAppearFade>,
Option<&crate::doodad_anim::MatAnim>,
&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>,
Option<&benilla_world::model_fade::FadeMaterials>,
Option<&benilla_world::model_fade::PendingAppearFade>,
Option<&benilla_world::doodad_anim::MatAnim>,
)>();
let found: Vec<_> = q.iter(app.world()).collect();
assert_eq!(found.len(), 1, "the one camera-facing batch spawned a card");
@@ -1133,7 +1141,7 @@ mod tests {
);
assert_eq!(mat.0, blend, "…and opens ON the blend twin, not the cutout");
assert!(
crate::mesh_tag::alpha_of(tag.0) <= 1.0 / 63.0,
benilla_world::mesh_tag::alpha_of(tag.0) <= 1.0 / 63.0,
"…at the encoder's ≈0 floor, so it never flashes opaque for a frame",
);
let anim = anim.expect("the batch's authored alpha rides the card");
@@ -1159,13 +1167,13 @@ mod tests {
fn a_multiply_sheen_joins_the_wearers_ramp_on_its_steady_material() {
const KIND: ItemModelKind = ItemModelKind::Weapon;
const SINCE: f32 = 3.0;
let steady: Handle<crate::terrain::WowModelMaterial> = Handle::Uuid(
let steady: Handle<benilla_assets::materials::WowModelMaterial> = Handle::Uuid(
bevy::asset::uuid::Uuid::from_u128(0x5133),
std::marker::PhantomData,
);
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
@@ -1203,9 +1211,9 @@ mod tests {
let mut q = app.world_mut().query::<(
&MeshTag,
&MeshMaterial3d<crate::terrain::WowModelMaterial>,
Option<&crate::model_fade::FadeMaterials>,
Option<&crate::model_fade::PendingAppearFade>,
&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>,
Option<&benilla_world::model_fade::FadeMaterials>,
Option<&benilla_world::model_fade::PendingAppearFade>,
)>();
let found: Vec<_> = q.iter(app.world()).collect();
assert_eq!(found.len(), 1, "the one sheen batch spawned");
@@ -1222,7 +1230,7 @@ mod tests {
let fm = fm.expect("a FadeMaterials record, so despawn/stealth ramps re-arm it");
assert_eq!(fm.blend, steady, "…whose 'twin' is the steady self");
assert!(
crate::mesh_tag::alpha_of(tag.0) <= 1.0 / 63.0,
benilla_world::mesh_tag::alpha_of(tag.0) <= 1.0 / 63.0,
"…opening at tag alpha ≈ 0: the shader's identity-lerp makes it contribute nothing",
);
}
@@ -1232,7 +1240,7 @@ mod tests {
fn dress_a_wearer() -> (App, Entity) {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
@@ -1245,7 +1253,7 @@ mod tests {
dm.parts = Some(vec![part(false)]);
if effects {
dm.emitters = vec![benilla_assets::ModelEmitter {
def: crate::particles::tests::plain_def(),
def: benilla_world::testing::plain_particle_def(),
texture: None,
bone_pivot: [0.0; 3],
billboard: None,
@@ -1416,7 +1424,7 @@ mod tests {
/// rebuilt.
#[test]
fn an_item_model_is_chained_to_its_wearer() {
use crate::model_fade::ParentModel;
use benilla_world::model_fade::ParentModel;
let (mut app, wearer) = dress_a_wearer();
let shoulder = roots_of(&app, wearer)[4].expect("shoulder");
+3 -3
View File
@@ -51,8 +51,8 @@ use std::collections::HashMap;
use benilla_formats::{EnchantCatalog, ItemVisualCatalog, ITEM_VISUAL_SLOTS};
use bevy::prelude::*;
use crate::model_render::m2_url;
use crate::terrain::WowModelMaterial;
use benilla_assets::m2_url;
use benilla_assets::materials::WowModelMaterial;
use super::equipment::{ItemDisplays, ItemModelKind};
use super::spell_fx::{attach_effect_visuals, EffectHost, FxTintAnims};
@@ -183,7 +183,7 @@ pub(super) fn attach_item_glows(
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
// One breadcrumb per session the first time a glow actually spawns — the machine-readable
// "this lane is live" signal for a subsystem whose whole symptom is *absence* (the same
// idiom `apply_unit_mat_alpha` uses for its cull).
+28 -18
View File
@@ -127,11 +127,11 @@ pub(super) fn refresh_live_display(
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
(
crate::creature_anim::RigPose,
benilla_world::rig_anim::RigPose,
crate::creature_anim::BodyTwist,
crate::creature_anim::GlobalSeqDrive,
benilla_world::rig_anim::GlobalSeqDrive,
),
crate::rig_palette::RigSkin,
benilla_world::rig_palette::RigSkin,
super::BoneAttach,
super::equipment::HeldAttached,
)>()
@@ -211,7 +211,7 @@ const HEAL_MIN_HEADROOM: usize = 128;
const HEAL_PER_FRAME: usize = 2;
/// Rebuild the visuals of units whose attach was DENIED a palette rig (decision 0863 — the
/// [`RigStarved`](crate::rig_palette::RigStarved) marker): the same teardown set as the
/// [`RigStarved`](benilla_world::rig_palette::RigStarved) marker): the same teardown set as the
/// display-id swap above (the reference's `0x60abe0` rebuild), fade-skipped via `Reattached` —
/// a heal is not a spawn. `attach_entity_visuals` rebuilds next frame(s), allocating with the
/// headroom this system waited for; if the table filled again in between, the attach re-marks
@@ -220,8 +220,14 @@ const HEAL_PER_FRAME: usize = 2;
#[allow(clippy::type_complexity)] // one query's two-marker filter
pub(super) fn heal_rig_starved(
mut commands: Commands,
palettes: Res<crate::rig_palette::RigPalettes>,
starved: Query<(Entity, &Guid), (With<crate::rig_palette::RigStarved>, With<VisualAttached>)>,
palettes: Res<benilla_world::rig_palette::RigPalettes>,
starved: Query<
(Entity, &Guid),
(
With<benilla_world::rig_palette::RigStarved>,
With<VisualAttached>,
),
>,
) {
if starved.is_empty() || palettes.slot_headroom() < HEAL_MIN_HEADROOM {
return;
@@ -246,14 +252,14 @@ pub(super) fn heal_rig_starved(
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
(
crate::creature_anim::RigPose,
benilla_world::rig_anim::RigPose,
crate::creature_anim::BodyTwist,
crate::creature_anim::GlobalSeqDrive,
benilla_world::rig_anim::GlobalSeqDrive,
),
crate::rig_palette::RigSkin,
benilla_world::rig_palette::RigSkin,
super::BoneAttach,
super::equipment::HeldAttached,
crate::rig_palette::RigStarved,
benilla_world::rig_palette::RigStarved,
)>()
.insert(super::equipment::Reattached);
}
@@ -270,13 +276,13 @@ mod tests {
#[test]
fn a_starved_unit_rebuilds_when_the_table_has_room_and_waits_when_it_has_not() {
let mut app = App::new();
app.init_resource::<crate::rig_palette::RigPalettes>();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
app.add_systems(Update, heal_rig_starved);
let child = app.world_mut().spawn_empty().id();
let unit = app
.world_mut()
.spawn((
crate::rig_palette::RigStarved,
benilla_world::rig_palette::RigStarved,
VisualAttached,
crate::net::Guid(0xB0B),
AppliedDisplay(Some(7)),
@@ -285,13 +291,17 @@ mod tests {
.id();
// Choke the table under the heal's minimum headroom: the unit is left alone.
let hoard: Vec<crate::rig_palette::RigSkin> = {
let hoard: Vec<benilla_world::rig_palette::RigSkin> = {
let mut palettes = app
.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>();
(0..(crate::mesh_tag::MAX_RIG_SLOTS - 1 - HEAL_MIN_HEADROOM / 2))
.resource_mut::<benilla_world::rig_palette::RigPalettes>();
(0..(benilla_world::mesh_tag::MAX_RIG_SLOTS - 1 - HEAL_MIN_HEADROOM / 2))
.filter_map(|_| {
crate::rig_palette::RigSkin::allocate_bones(&mut palettes, 1, Handle::default())
benilla_world::rig_palette::RigSkin::allocate_bones(
&mut palettes,
1,
Handle::default(),
)
})
.collect()
};
@@ -305,7 +315,7 @@ mod tests {
{
let mut palettes = app
.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>();
.resource_mut::<benilla_world::rig_palette::RigPalettes>();
for rig in hoard.iter().take(HEAL_MIN_HEADROOM) {
let slot = rig.slot;
palettes.free(slot);
@@ -315,7 +325,7 @@ mod tests {
let e = app.world().entity(unit);
assert!(!e.contains::<VisualAttached>(), "attach re-armed");
assert!(
!e.contains::<crate::rig_palette::RigStarved>(),
!e.contains::<benilla_world::rig_palette::RigStarved>(),
"marker consumed"
);
assert!(
+3 -3
View File
@@ -68,7 +68,7 @@ use crate::creature_anim::{
oneshot_is_live, AnimData, AnimDriver, AnimSoundEvent, CastEvent, CastEventKind, DefenseAnim,
MissileSpawn,
};
use crate::model_render::m2_url;
use benilla_assets::m2_url;
use super::equipment::ItemDisplays;
use super::spell_fx::{attach_effect_visuals, EffectHost, SpellFx};
@@ -597,10 +597,10 @@ pub(super) fn attach_missile_models(
mut missiles: Query<(Entity, &mut Missile)>,
fx: Option<Res<SpellFx>>,
time: Res<Time>,
mut wow_materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
mut wow_materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
let Some(fx) = fx else {
return;
+3 -3
View File
@@ -39,8 +39,8 @@
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::creature_anim::RigPose;
use crate::net::{NetEntity, ObjectStore};
use benilla_world::rig_anim::RigPose;
/// The mount child — the second creature visual a mounted unit carries (the client's
/// `unit+0xdc` secondary model instance). Spawned by [`seat_or_spawn_mount`] (from the first
@@ -104,7 +104,7 @@ pub(super) fn spawn_mount_child(
/// the client's body carries a compensating own/mount base ratio, `0x607b49 fld [esi+0x98]; fdiv
/// [esi+0x9c]` → `0x710620` — wow-re `mount-composition.md` Q3).
///
/// [`crate::creature_anim::RigFrame`] marks it as `rider`'s model frame so the world pass cascades
/// [`benilla_world::rig_anim::RigFrame`] marks it as `rider`'s model frame so the world pass cascades
/// a re-seat into the rider's palette (decision 0724).
fn spawn_seat_anchor(
commands: &mut Commands,
@@ -118,7 +118,7 @@ fn spawn_seat_anchor(
Transform::from_translation(offset)
.with_scale(Vec3::splat(1.0 / mount_scale.max(0.001))),
Visibility::default(),
crate::creature_anim::RigFrame(rider),
benilla_world::rig_anim::RigFrame(rider),
))
.id();
commands.entity(joint).add_child(anchor);
@@ -50,7 +50,7 @@
//! repeating play instead. Corpus: of the 692 `Spells\` models, 507 have a single sequence, 119
//! two, 65 three and **one** has four or more — so a multi-variation `Hold` is at most that single
//! model, and it is not worth a per-pass re-roll's complexity until one shows.
//! - A **ribbon**'s per-sequence visibility is still decided once at spawn ([`crate::ribbons`],
//! - A **ribbon**'s per-sequence visibility is still decided once at spawn ([`benilla_world::ribbons`],
//! which spawns per fixed-sequence entity). A trail authored dark in `Stand` and lit in `Hold`
//! would stay dark. Left as a residual: the ribbon lane's own spawn shape has to change for it,
//! and no reported effect turns on it.
@@ -188,10 +188,10 @@ pub(crate) fn advance_fx_anim(
/// drop prints `fx leg decay` followed by the `kit expire` a further 1.10 s on. A duration question
/// is answered from timestamps, never from watching.
pub(super) fn trace_leg(leg: &str, root: Entity, anim_id: u16) {
if !crate::dbg_trace::enabled() {
if !benilla_assets::trace::enabled() {
return;
}
crate::dbg_trace::line("fx", &format!("leg {leg} e={root} anim={anim_id}"));
benilla_assets::trace::line("fx", &format!("leg {leg} e={root} anim={anim_id}"));
}
/// The reap's decay-out (`0x614150`, gates at `0x614187`–`0x6141a1`): arm `Decay` if the model
+61 -57
View File
@@ -26,11 +26,11 @@
//! authored `Stand` → `Hold` → `Decay` lifecycle ([`lifecycle`], which is where the Ice Barrier
//! shield's pulse comes from), and run their **material
//! animation**: each part's colour-alpha × transparency-weight loops sample per instance on the
//! attach clock (a [`MatAnim`](crate::doodad_anim::MatAnim) that owns the part's render-alpha
//! attach clock (a [`MatAnim`](benilla_world::doodad_anim::MatAnim) that owns the part's render-alpha
//! tag — Battle Shout's staggered crescent pulses), and an animated M2Color RGB ticks a
//! **per-instance material clone**'s tint uniform ([`FxTintAnims`] — the white-hot flash cooling
//! to red; per instance because one cast = one phase, unlike the doodad lane's shared-clock
//! loops in [`crate::doodad_anim::TintAnimMaterials`]).
//! loops in [`benilla_world::doodad_anim::TintAnimMaterials`]).
//!
//! Effect instances **fire their model's event track** ([`fire_fx_anim_events`], decision 0304):
//! the playing clip's `$SND`-family keyframes emit the same [`AnimSoundEvent`] stream creatures
@@ -54,10 +54,10 @@ use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use crate::creature_anim::{scan_events, AnimSoundEvent, FxClass, FxStage, SpellKitFx};
use crate::debug_panel::{ModelKind, ModelPart};
use crate::model_render::m2_url;
use crate::particles;
use crate::terrain::WowModelMaterial;
use benilla_assets::m2_url;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::model_render::{ModelKind, ModelPart};
use benilla_world::particles;
use super::{BoneAttach, DisplayModel, EntityPart, ModelHandle};
pub(super) use lifecycle::advance_fx_anim;
@@ -96,7 +96,7 @@ pub(super) struct SpellFx {
/// The live **per-instance tint clones**: an effect part whose M2Color RGB animates gets its own
/// material clone at attach (one cast = one phase — the doodad lane's shared-clock registry
/// [`crate::doodad_anim::TintAnimMaterials`] would run every Battle Shout on one global pulse),
/// [`benilla_world::doodad_anim::TintAnimMaterials`] would run every Battle Shout on one global pulse),
/// keyed by the clone's asset id → `(RGB loop, attach-time clock origin)`. An entry drops with
/// its material asset — the instance root's despawn releases the only strong handle.
#[derive(Resource, Default)]
@@ -172,7 +172,7 @@ pub(crate) fn drive_fx_view(
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
spatial: avian3d::prelude::SpatialQuery,
mut transforms: Query<&mut Transform>,
) {
@@ -200,7 +200,7 @@ pub(crate) fn drive_fx_view(
Dir3::NEG_Y,
500.0,
true,
&crate::collision::player_query_filter(),
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
@@ -239,7 +239,7 @@ pub(crate) fn drive_fx_view(
Dir3::NEG_Y,
500.0,
true,
&crate::collision::player_query_filter(),
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
@@ -273,7 +273,7 @@ pub(crate) fn drive_fx_view(
Dir3::NEG_Y,
500.0,
true,
&crate::collision::player_query_filter(),
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
@@ -374,7 +374,7 @@ pub(super) struct EffectHost {
/// (the cloud fans with the host's motion), a missile is not — its trail stays world-frozen
/// (wow-re `part-kit-effect-attach-orient.md`).
pub attached: bool,
/// The model instance this one is **chained to** ([`crate::model_fade::ParentModel`]): the
/// The model instance this one is **chained to** ([`benilla_world::model_fade::ParentModel`]): the
/// unit a kit effect is hung on, the item root a weapon glow rides. `None` for a model that
/// belongs to no other — a missile, the fixture preview — which is also what keeps the 0202
/// drain for the impacting trail.
@@ -415,7 +415,7 @@ pub(super) fn attach_effect_visuals(
wow_materials: &mut Assets<WowModelMaterial>,
tint_reg: &mut FxTintAnims,
ibps: &Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>,
palettes: &mut crate::rig_palette::RigPalettes,
palettes: &mut benilla_world::rig_palette::RigPalettes,
preferred_anim: Option<u16>,
) -> bool {
let Some(parts) = dm.parts.as_ref() else {
@@ -428,16 +428,16 @@ pub(super) fn attach_effect_visuals(
if let Some(parent) = host.parent {
commands
.entity(root)
.insert(crate::model_fade::ParentModel(parent));
.insert(benilla_world::model_fade::ParentModel(parent));
}
// …and, from the same fact, what losing the owner means: an instance hung on another model is
// part of that model's tree, so its pool is freed with it (the reference's dtor) instead of
// being left to finish in world space — the ghost clouds a gear change used to strand at the
// body. A free-standing instance (a missile) keeps the 0202 drain.
let on_owner_loss = if host.parent.is_some() {
crate::particles::OwnerLoss::Free
benilla_world::particles::OwnerLoss::Free
} else {
crate::particles::OwnerLoss::Drain
benilla_world::particles::OwnerLoss::Drain
};
let is_ground_decal = |part: &EntityPart| ground_anchor && part.ground_quad.is_some();
// Per-part materials for THIS instance (a tint clone where the RGB animates), resolved
@@ -458,14 +458,12 @@ pub(super) fn attach_effect_visuals(
// hook frees the slot when the instance despawns (impact reap, missile arrival).
let rig_slot = match (&dm.inverse_bindposes, joints.is_empty()) {
(Some(ibp), false) => {
crate::rig_palette::RigSkin::allocate(palettes, joints.clone(), ibp.clone()).map_or(
0,
|rig| {
benilla_world::rig_palette::RigSkin::allocate(palettes, joints.clone(), ibp.clone())
.map_or(0, |rig| {
let slot = rig.slot;
commands.entity(root).insert(rig);
slot
},
)
})
}
_ => 0,
};
@@ -509,14 +507,12 @@ pub(super) fn attach_effect_visuals(
},
// The picker's triangles (decision 0857): the probe names fx batches through
// `ModelPart`, and the render meshes are `RENDER_WORLD`-only.
crate::interact::PickMesh(part.geometry.clone()),
benilla_world::interact::PickMesh(part.geometry.clone()),
));
if let (true, Some(_)) = (rigged, &part.skinned_mesh) {
child.insert((
crate::rig_palette::RigPart(root),
bevy::mesh::MeshTag(
crate::mesh_tag::rig_bits(rig_slot) | crate::mesh_tag::alpha_bits(1.0),
),
benilla_world::rig_palette::RigPart(root),
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(rig_slot, 1.0)),
));
}
// The part's colour-alpha × weight loops, on this instance's clock: the sampler owns
@@ -524,15 +520,18 @@ pub(super) fn attach_effect_visuals(
// The rig field rides the whole-tag seed (decision 0720).
if let Some(anim) = &part.alpha_anim {
let mat_anim =
crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq)
benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq)
.following_host(seq_host);
let rig_tag = if rigged && part.skinned_mesh.is_some() {
crate::mesh_tag::rig_bits(rig_slot)
let tag_slot = if rigged && part.skinned_mesh.is_some() {
rig_slot
} else {
0
};
child.insert((
bevy::mesh::MeshTag(rig_tag | crate::mesh_tag::alpha_bits(mat_anim.current)),
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
tag_slot,
mat_anim.current,
)),
mat_anim,
));
}
@@ -543,8 +542,8 @@ pub(super) fn attach_effect_visuals(
continue;
};
let card = match joints.get(info.bone as usize) {
Some(&j) => crate::billboard::BillboardCard::following_joint(info, j),
None => crate::billboard::BillboardCard::following(info, root),
Some(&j) => benilla_world::billboard::BillboardCard::following_joint(info, j),
None => benilla_world::billboard::BillboardCard::following(info, root),
};
let mut spawned = commands.spawn((
Mesh3d(part.mesh.clone()),
@@ -555,7 +554,7 @@ pub(super) fn attach_effect_visuals(
blend: part.blend,
},
// The picker's triangles (decision 0857), pivot-centred by the caster like the bake.
crate::interact::PickMesh(part.geometry.clone()),
benilla_world::interact::PickMesh(part.geometry.clone()),
card,
));
// The card's build-time bound (decision 0834): `calculate_bounds` can no longer derive
@@ -565,10 +564,11 @@ pub(super) fn attach_effect_visuals(
}
// A card shares its batch's material-alpha loops (the billboard split copies them).
if let Some(anim) = &part.alpha_anim {
let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq)
.following_host(seq_host);
let mat_anim =
benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq)
.following_host(seq_host);
spawned.insert((
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(mat_anim.current)),
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(0, mat_anim.current)),
mat_anim,
));
}
@@ -593,31 +593,33 @@ pub(super) fn attach_effect_visuals(
),
None => (root, Mat4::IDENTITY), // boneless model: the quad rides the instance root
};
let (texture, fog) = match wow_materials.get(part.material.id()) {
// The material's packed fog byte, handed over raw — the lane owns what it maps to.
// `7` (Scene) is the no-material fallback the packer's own default agrees with.
let (texture, fog_bits) = match wow_materials.get(part.material.id()) {
Some(mat) => (
mat.base.base_color_texture.clone(),
crate::particles::buffer::EffectFog::from_model_policy(
(mat.extension.clutter_fade.z as u32 >> 4) & 7,
),
(mat.extension.clutter_fade.z as u32 >> 4) & 7,
),
None => (None, crate::particles::buffer::EffectFog::Scene),
None => (None, u32::from(benilla_formats::FogPolicy::Scene as u8)),
};
let Some(texture) = texture else {
continue; // texture-less ground quad: nothing the stream could draw
};
let decal = crate::ground_fx::spawn_ground_fx_decal(
let decal = benilla_world::ground_fx::spawn_ground_fx_decal(
commands,
texture,
crate::particles::buffer::EffectBlend::from_model(part.blend, part.additive),
fog,
part.blend,
part.additive,
fog_bits,
part.rgb_anim.as_ref().map(|a| (a.clone(), now)),
&quad,
joint,
ibp,
);
if let Some(anim) = &part.alpha_anim {
let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq)
.following_host(seq_host);
let mat_anim =
benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq)
.following_host(seq_host);
commands.entity(decal).insert(mat_anim);
}
}
@@ -664,7 +666,7 @@ pub(super) fn attach_effect_visuals(
let (owner, use_pivot) = joints
.get(rb.def.bone as usize)
.map_or((root, false), |&j| (j, true));
crate::ribbons::spawn_ribbon(
benilla_world::ribbons::spawn_ribbon(
commands,
rb,
owner,
@@ -676,7 +678,7 @@ pub(super) fn attach_effect_visuals(
// The instance's own root carries the clip: an effect model that steps
// Stand -> Hold -> Decay re-answers the gate at each step, instead of freezing the
// birth clip's answer for the whole life.
crate::ribbons::RibbonSeq::Host(root),
benilla_world::ribbons::RibbonSeq::Host(root),
// This instance's own model alpha, chained to its host (0827/0833): a standalone
// instance has none above it and draws exactly as before.
Some(root),
@@ -713,10 +715,11 @@ pub(super) fn arm_effect_rig(
if dm.skeleton.joints.is_empty() {
return (Vec::new(), false);
}
let joints = super::spawn_joints(commands, root, root, &dm.skeleton);
let joints = benilla_world::rig_palette::spawn_joints(commands, root, root, &dm.skeleton);
// Billboard bones face the camera at the PALETTE level, children inheriting (the frost-armor
// sheets skin to a lock-Z bone's child) — the joint pass needs the map.
if let Some(bb) = crate::billboard::BillboardJointRig::new(&dm.skeleton, &joints, root) {
if let Some(bb) = benilla_world::billboard::BillboardJointRig::new(&dm.skeleton, &joints, root)
{
commands.entity(root).insert(bb);
}
let mut armed = false;
@@ -746,7 +749,8 @@ pub(super) fn arm_effect_rig(
}
armed = true;
}
if let Some(drive) = crate::creature_anim::GlobalSeqDrive::new(&anims.global_bones, &joints)
if let Some(drive) =
benilla_world::rig_anim::GlobalSeqDrive::new(&anims.global_bones, &joints)
{
// Fresh anchor per play — the byte-verified effect lifecycle (0858): CreateModel
// always allocates+attaches, so every cast's gseq loops open at phase 0, exactly
@@ -1001,7 +1005,7 @@ pub(super) fn attach_spell_fx(
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
let Some(fx) = fx else {
return;
@@ -1015,8 +1019,8 @@ pub(super) fn attach_spell_fx(
if let Some(root) = inst.root {
commands.entity(root).despawn();
}
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fx",
&format!("kit expire unit={unit} path={}", inst.path),
);
@@ -1137,8 +1141,8 @@ pub(super) fn attach_spell_fx(
let span = dm.first_seq_span.unwrap_or(FALLBACK_SPAN);
inst.expires = Some(now + span);
}
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fx",
&format!(
"kit spawn unit={unit} path={} persistent={} span={:?}",
@@ -1233,7 +1237,7 @@ mod tests {
.init_asset::<SkinnedMeshInverseBindposes>()
.init_resource::<SpellFx>()
.init_resource::<FxTintAnims>()
.init_resource::<crate::rig_palette::RigPalettes>()
.init_resource::<benilla_world::rig_palette::RigPalettes>()
.add_systems(Update, attach_spell_fx);
let roots: Vec<Entity> = persistent
.iter()
+27 -34
View File
@@ -9,7 +9,7 @@
//! extracted roots: ship 134 props / zeppelin 1, all in set 0).
//!
//! Each prop spawns through the shared placed-model assembler
//! ([`crate::terrain_stream::spawn_model_entities`]) with its doodad-LOCAL transform, and the
//! ([`benilla_world::terrain_stream::spawn_model_entities`]) with its doodad-LOCAL transform, and the
//! result is parented under the streamed gameobject entity: transform propagation carries the
//! props with the moving boat, the entity's despawn cascades through them, and the transport's
//! off-map hide (`Visibility` on the root) is inherited. The animated props (sails, rotors) get
@@ -30,7 +30,7 @@
//! refreshes the placement from the owner's propagated `GlobalTransform` every frame and the
//! risen cloud re-anchors to the prop's live position (the reference's per-frame
//! `translate(−emitterPos)` draw-matrix rebuild: a moving model carries its flame). No
//! [`crate::particles::EmitterFade`] — that gate's centre is a baked world point, and a mover has
//! [`benilla_world::particles::EmitterFade`] — that gate's centre is a baked world point, and a mover has
//! none. The emitter entity parents under the gameobject, so the off-map hide reaches it and
//! despawn cascades.
//! ⚠ **This used to say the population is "bounded by server visibility instead". That was
@@ -38,7 +38,7 @@
//! streams transports **map-wide**, so a ship's deck lanterns are resident from anywhere on the
//! map. Measured from Durotar: 56 emitters ticking and drawing at 4853–7080 yd, this boat's
//! lanterns among them. The bound is now the far-clip wall, applied in
//! [`crate::particles`]' sim to every world-lane emitter regardless of `EmitterFade`.
//! [`benilla_world::particles`]' sim to every world-lane emitter regardless of `EmitterFade`.
//! - **M2 point lights** (the lantern's glow source) spawn as CHILDREN at the prop-local
//! position — propagation carries the source with the hull, and the hide/despawn follow.
//! - **Interior cabin props** (an INDOOR-group MODD — ~118 of the ship's 133) take the interior
@@ -54,17 +54,17 @@ use benilla_assets::{DoodadBase, M2Model, WmoModel};
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::lighting::{PropProbeSlot, PropProbes, SharedLightBuffer};
use crate::model_render::{m2_url, ShadeSel};
use crate::net::NetEntity;
use crate::particles;
use crate::terrain::WowModelMaterial;
use crate::terrain_stream::{
use benilla_assets::m2_url;
use benilla_world::lighting::{PropProbeSlot, PropProbes};
use benilla_world::model_render::ShadeSel;
use benilla_world::particles;
use benilla_world::terrain_stream::{
build_collider_task, fold_interior_probe, m2_fade, placement_collider_data, point_light,
spawn_model_entities, PendingCollider, PropLobeLight,
};
use super::{EntityMaterials, GameObjects, ModelHandle, VisualAttached};
use super::{GameObjects, ModelHandle, VisualAttached};
/// Marks a streamed entity the prop resolver has visited — once, ever (units and M2 gameobjects
/// included, so the resolve query never re-scans them). A WMO gameobject with props also gets
@@ -184,20 +184,18 @@ pub(super) fn resolve_wmo_gameobject_props(
pub(super) fn spawn_wmo_gameobject_props(
mut commands: Commands,
m2s: Res<Assets<M2Model>>,
mut forms: ResMut<crate::model_forms::ModelForms>,
shared_light: Option<Res<SharedLightBuffer>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut entity_mats: ResMut<EntityMaterials>,
mut uv_reg: ResMut<crate::doodad_anim::UvAnimMaterials>,
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
mut forms: ResMut<benilla_world::model_forms::ModelForms>,
mut mats: benilla_world::model_render::M2BatchMaterials,
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut tint_reg: ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
mut probes: ResMut<PropProbes>,
time: Res<Time>,
mut hosts: Query<(Entity, &GlobalTransform, &mut WmoProps)>,
) {
let Some(shared_light) = shared_light else {
return;
let Some((mat_cache, materials, light)) = mats.pieces() else {
return; // no shared light buffer yet
};
let light = &shared_light.0;
let light = &light;
// The animated-prop clock origin (decision 0130) — per-instance phase = spawn time.
let now = time.elapsed_secs();
for (entity, host_gt, mut props) in &mut hosts {
@@ -213,14 +211,12 @@ pub(super) fn spawn_wmo_gameobject_props(
};
// The prop's app-built render forms (decision 0834): a transport's cabin props are
// the entity lane — priority 0, same as any creature walking into view.
let key = crate::model_forms::ModelKey::from(&prop.handle);
let kinds = crate::model_forms::WANT_STATIC
| if crate::doodad_anim::wants_rig(m) {
crate::model_forms::WANT_SKINNED
} else {
0
};
if !forms.require(key, kinds, 0) {
let ready = if benilla_world::doodad_anim::wants_rig(m) {
forms.require_rigged(&prop.handle, 0)
} else {
forms.require_static(&prop.handle, 0)
};
if !ready {
return true; // forms still building — retry next frame
}
// A boneless prop's glow card follows an ANCHOR child at the prop's placement (the
@@ -264,14 +260,11 @@ pub(super) fn spawn_wmo_gameobject_props(
});
let (ents, host) = spawn_model_entities(
&mut commands,
&mut entity_mats.0,
&mut materials,
mat_cache,
materials,
light,
&m.submeshes,
crate::model_forms::FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
skin: forms.skinned_meshes(key),
},
forms.slices(&prop.handle),
prop.local, // doodad-LOCAL — the parent composes the world pose
false,
// Deck props: plain matte, like a terrain exterior prop on lit ground (a boat is
@@ -366,13 +359,13 @@ pub(super) fn spawn_wmo_gameobject_props(
.map(|h| h.joints.as_slice())
.and_then(|js| js.get(rb.def.bone as usize))
.map_or((root, false), |&j| (j, true));
if crate::ribbons::spawn_ribbon(
if benilla_world::ribbons::spawn_ribbon(
&mut commands,
rb,
owner,
use_pivot,
placement.scale.max_element(),
crate::ribbons::RibbonSeq::Host(entity),
benilla_world::ribbons::RibbonSeq::Host(entity),
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
)
+3 -3
View File
@@ -19,7 +19,7 @@
//!
//! Named deviations (same class as the bowstring's, decision 1099): the anchor rides the static
//! prop frame (the pole's bone 1 is not posed — item props rest at bind pose here); the color
//! samples the scene ambient ([`crate::lighting::WowLighting`]) rather than a per-model light
//! samples the scene ambient ([`benilla_world::lighting::WowLighting`]) rather than a per-model light
//! collector; gizmo lines are unfogged. The reference's sheath side-trigger (`0x60d2f0` calls
//! `SetSheatheState(1)` when the watcher finds the pole on the back) and the FishingCast→
//! FishingLoop anim handoff (`0x5fc3f0` case 0x85) are the channel-anim family's, not drawn here.
@@ -59,7 +59,7 @@ fn draw_fishing_lines(
&GlobalTransform,
Option<&OverheadFallback>,
)>,
lighting: Option<Res<crate::lighting::WowLighting>>,
lighting: Option<Res<benilla_world::lighting::WowLighting>>,
mut gizmos: Gizmos,
) {
for (pole, prop, vis) in &poles {
@@ -120,7 +120,7 @@ impl Plugin for FishingLinePlugin {
fn build(&self, app: &mut App) {
app.add_systems(
PostUpdate,
draw_fishing_lines.in_set(crate::billboard::BillboardPlace),
draw_fishing_lines.in_set(benilla_world::billboard::BillboardPlace),
);
}
}
+28 -43
View File
@@ -1,5 +1,5 @@
//! **Footprint decals** — the prints a walking unit leaves on snow and sand (B212, decisions
//! 1006/1012): the fourth client of the shared surface-decal projector ([`crate::decal`]), drawn
//! 1006/1012): the fourth client of the shared surface-decal projector ([`benilla_world::decal`]), drawn
//! on the shared effect stream like the blob shadow, but **spawn-once**: a print is projected the
//! frame its foot plants and the cached triangles replay every frame until the fade retires it —
//! exactly the reference's own shape (baked once at spawn over collector-gathered ground
@@ -7,7 +7,7 @@
//!
//! **The mechanism — wow-re §5 cross-checked** (`footprint-decals.md`, folded back in 1012):
//! - **Surface gate**: `TerrainType.Flags & 1` (`0x699eb6`) — set on exactly **Snow** and
//! **Sand** — on the surface [`crate::surface`] resolves under the unit, the *same* value the
//! **Sand** — on the surface [`benilla_world::surface`] resolves under the unit, the *same* value the
//! footstep sound uses. The reference resolves it once per unit into `CGUnit+0xc60` and the
//! decal, the spray and `$FSD` all read that one dword (wow-re `wmo-footstep-surface.md` F3), so
//! indoors the gate reads the building's own floor: a tavern's floorboards take no prints while
@@ -44,23 +44,18 @@
use std::collections::VecDeque;
use avian3d::prelude::Collider;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::blob_shadow::SHADOW_RASTER_BIAS;
use crate::collision::GroundDecalSurface;
use crate::creature_anim::{footfall_side, move_flags, AnimSoundEvent, MovementState};
use crate::decal::{project_decal, DecalFrame};
use crate::entities::{BoneAttach, Creatures};
use crate::net::{NetEntity, ObjectStore, SelfPlayer};
use crate::particles::buffer::{
begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex,
};
use crate::player::WorldCamera;
use crate::schedule::WorldStage;
use crate::sound::footsteps::Footsteps;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
/// Print lifetime, spawn to gone — the reference's 6000 ms (byte-verified, wow-re
/// `footprint-decals.md`: `t = 1 − age/6000`, die at `t < 0`).
@@ -169,12 +164,7 @@ fn spawn_footprints(
time: Res<Time>,
// GlobalTransform for the same reason as the footstep sounds (0441): a mounted unit's steps
// are the MOUNT child's tags, whose local Transform is the seat-relative ~origin.
units: Query<(
&NetEntity,
&GlobalTransform,
Option<&BoneAttach>,
Option<&crate::wmo_portal::UnitWmoRoom>,
)>,
units: Query<(&NetEntity, &GlobalTransform, Option<&BoneAttach>)>,
// The spawner's ROOT (the rider for a mount child): the pool select, the print scale (the
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
parents: Query<&ChildOf>,
@@ -184,8 +174,8 @@ fn spawn_footprints(
footsteps: Option<Res<Footsteps>>,
creatures: Option<Res<Creatures>>,
ink: Option<Res<FootprintInk>>,
surface: crate::surface::SurfaceUnderfoot,
surfaces: Query<&Collider, With<GroundDecalSurface>>,
world: benilla_world::world_point::WorldPoint,
decals: WorldDecal,
mut prints: ResMut<Footprints>,
) {
if events.is_empty() {
@@ -202,7 +192,7 @@ fn spawn_footprints(
let Some(side) = footfall_side(&ev.ident) else {
continue;
};
let Ok((net, transform, attach, room)) = units.get(ev.entity) else {
let Ok((net, transform, attach)) = units.get(ev.entity) else {
continue;
};
// The ink + dims come from the EVENT's model (the mount for a mounted composite); the
@@ -252,7 +242,11 @@ fn spawn_footprints(
// that one dword (wow-re `wmo-footstep-surface.md` F3). So this reads the UNIT's surface,
// not a per-foot sample of the ground — indoors that is the building's own floor, which is
// why a tavern's floorboards take no prints while the snow outside its door does.
let terrain = surface.terrain_type(&footsteps.0, room, transform.translation());
let terrain = world.terrain_type(
&footsteps.0,
benilla_world::world_point::Subject::Unit(ev.entity),
transform.translation(),
);
if !terrain.is_some_and(|t| footsteps.0.terrain_leaves_footprints(t)) {
continue;
}
@@ -285,9 +279,8 @@ fn spawn_footprints(
};
let mirror = side == b'R';
let mut verts = Vec::new();
let projected = project_decal(
let projected = decals.project(
&mut verts,
&surfaces,
&frame,
|_| 1.0,
|x, z| {
@@ -348,7 +341,7 @@ fn push_footprints(
time: Res<Time>,
cam: Query<Entity, With<WorldCamera>>,
lane: Option<Res<FootprintLane>>,
mut quads: ResMut<EffectQuads>,
mut draw: benilla_world::particles::buffer::WorldEffectDraw,
mut prints: ResMut<Footprints>,
) {
let now = time.elapsed_secs();
@@ -365,27 +358,19 @@ fn push_footprints(
let Some(lane) = lane else { return };
for print in prints.own.iter().chain(prints.shared.iter()) {
let alpha = fade(now - print.spawned);
let start = quads.begin();
quads.verts.extend(print.verts.iter().map(|v| EffectVertex {
let mut batch = draw
.batch(cam, print.texture)
.anchored(print.anchor)
.rung(
PRINT_SORT_BIAS,
benilla_world::sky_order::Rung::SHADOW_RASTER,
)
.owner(lane.0);
batch.extend(print.verts.iter().map(|v| EffectVertex {
color: [v.color[0], v.color[1], v.color[2], v.color[3] * alpha],
..*v
}));
quads.commit_tris(
start,
EffectDrawSpec {
cam,
texture: print.texture,
blend: EffectBlend::Alpha,
fog: EffectFog::Off,
lit: false,
anchor: print.anchor,
bias: PRINT_SORT_BIAS,
raster_bias: SHADOW_RASTER_BIAS,
cam_relative: false,
main_entity: lane.0,
light: None,
},
);
batch.tris();
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ pub(crate) struct AddUiMaterial {
impl UiMaterial for AddUiMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/ui_add.wgsl".into()
"game://shaders/ui_add.wgsl".into()
}
fn specialize(descriptor: &mut RenderPipelineDescriptor, _key: UiMaterialKey<Self>) {
+1 -1
View File
@@ -8,7 +8,7 @@
use bevy::prelude::*;
use crate::assets::WorldAssets;
use benilla_assets::WorldAssets;
use super::add_material::AddUiMaterial;
use super::backdrop::BackdropEdges;
+1 -1
View File
@@ -7,7 +7,7 @@
use bevy::prelude::*;
use crate::assets::{sprite_image, sprite_image_tiled, WorldAssets};
use benilla_assets::{sprite_image, sprite_image_tiled, WorldAssets};
/// `UI-Tooltip-Background`'s native size (the tile-period → `stretch_value` conversion).
const TOOLTIP_BG_NATIVE: f32 = 64.0;
+1 -1
View File
@@ -28,7 +28,7 @@ impl Plugin for GluePlugin {
.init_resource::<GlueArt>()
.add_systems(
Startup,
crate::glue_strings::load_glue_strings.after(crate::assets::AssetSet::Open),
crate::glue_strings::load_glue_strings.after(benilla_assets::AssetSet::Open),
)
.add_systems(
Update,
+1 -1
View File
@@ -12,7 +12,7 @@ use std::collections::HashMap;
use bevy::prelude::*;
use crate::assets::{LockRecover, WorldAssets};
use benilla_assets::{LockRecover, WorldAssets};
const GLUE_STRINGS: &str = "Interface\\GlueXML\\GlueStrings.lua";
+1 -1
View File
@@ -74,7 +74,7 @@ use std::time::Duration;
use crate::creature_anim::{advance_track, scan_events, AnimSoundEvent};
use crate::net::{GuidIndex, ObjectStore};
use crate::schedule::WorldStage;
use benilla_world::schedule::WorldStage;
/// `GO_STATE_ACTIVE` (vmangos `GOState`) — the **open** state (door swung, chest lid up). Passable.
const GO_STATE_ACTIVE: u32 = 0;
-183
View File
@@ -1,183 +0,0 @@
//! World interaction foundation — *what is the player pointing at?*
//!
//! This is the shared base for every "point at the world" feature: the debug **object inspector**,
//! hover tooltips, the contextual cursor (gear over objects, sword over attackable units),
//! mouseover-targeting, and right-click-to-interact. Built once so we don't build picking +
//! identity twice.
//!
//! The pieces:
//! - [`WorldObject`] — an **identity** component on every pickable world entity (a doodad/WMO model, a
//! creature, a GameObject): its kind, a human label (model path or unit name), an id, and an optional
//! detail line. Attached at the spawn sites.
//! - the **ray caster** ([`pick`]) — the pick-geometry declarations ([`PickMesh`] / [`PickBox`]) and
//! the shared triangle-accurate cast against the **actual resident mesh geometry** (so it works on
//! colliderless props — most doodads, including the campfire — which a physics raycast misses),
//! plus the generous inflated pass the mouse pick retries with (decision 1071).
//! - [`MouseoverTarget`] — the resource [`update_mouseover`] fills each frame with the nearest
//! `WorldObject` under the cursor, for the inspector.
//! - the **inspector surface** ([`inspect`]) — a standalone, key-toggleable overlay (**the dev
//! chord + `I`**): a weak "armed" pill plus a small identity card that follows the cursor over any
//! picked object. It's its own surface, *not* a section of the dev-chord debug panel, so
//! identifying a thing costs one chord and no panel.
//! - the **cast journal** ([`journal`]) — the *temporal* half of the same instrument: a spell is an
//! event, gone before a cursor could reach it, so every cast edge is recorded as it flows past and
//! the same inspector overlay lists the recent ones, click-to-copy.
//!
//! The inspector's own picking only runs while [`InspectMode`] is on (the inspector chord); the
//! player-facing consumers (`crate::target`) run their picks through [`pick`] unconditionally.
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy_egui::EguiPrimaryContextPass;
use crate::debug_panel::ModelKind;
use crate::player::WorldCamera;
use crate::ui_script::PointerOverUi;
mod inspect;
mod journal;
mod pick;
pub use pick::{
cast_pick_ray, cast_pick_ray_inflated, pick_at_cursor, PickBox, PickMesh, PickParts,
};
pub(crate) use pick::{ray_aabb, ray_triangle, world_aabb};
/// The identity of a pickable world thing, read by the inspector now and by tooltips/cursor/targeting
/// later. Attached to a thing's renderable mesh entities at spawn.
#[derive(Component, Clone)]
pub struct WorldObject {
pub kind: ModelKind,
/// Primary label — a model path (doodads/WMOs/GameObjects) or a unit name. Shown to the player/dev.
pub label: String,
/// Identifier — placement uniqueId, server guid, or display id (`0` if none).
pub id: u32,
/// Optional second line of kind-specific detail (e.g. `"emitters: 2"`); shown when non-empty.
pub detail: String,
}
/// A left *select* gesture in the world. Emitted by [`crate::player::control`] on the button's
/// **release**, when the press satisfied the reference's click predicate — under 200 ms whatever the
/// mouse did, or under 800 ms having turned the camera less than 2.25° of yaw / 2.0° of pitch
/// ([`crate::player::camera::PressGesture`], decision 1122).
///
/// **A drag emits this too.** Orbit and select are independent in the reference: the press engages
/// the camera look immediately and arms this test alongside it, so a fast flick-and-click orbits the
/// camera *and* selects. This doc comment used to assert the opposite as design — *"a left drag
/// engages the orbit and emits nothing"* — and a reporter quoted it back at us with an A/B against
/// the real client proving it wrong (ledger B226).
///
/// It carries no position because the *pick* is latched separately, at the press
/// ([`crate::target::PressPick`]) — never the live hover, which is blank by the time a dragged
/// click lands.
#[derive(Message, Clone, Copy)]
pub struct WorldClick;
/// The right button's context gesture — same arbiter and same predicate as [`WorldClick`], for the
/// button whose drag is the character turn. Vanilla's context action: attack a hostile under the
/// cursor (later: interact/gossip on a friendly).
#[derive(Message, Clone, Copy)]
pub struct WorldRightClick;
/// The right button's **DOWN edge** in the world — emitted at the press, before the click-vs-drag
/// test even starts, whenever the press belongs to the world (in the viewport off the UI, or any
/// press while a look session already owns the hidden cursor). The reference's
/// `CGWorldFrame::OnMouseDown 0x483c40` analogue: ground-targeting's right-click cancel hangs off
/// this edge (`0x492c20`, wow-re `world-click-targeting.md` Q3), which fires whether the press
/// becomes a click OR a turn-drag, and consumes nothing (the ref handler returns 0, so the
/// BUTTON2 turn and the release's context click still run).
#[derive(Message, Clone, Copy)]
pub struct WorldRightPress;
/// Whether mouseover picking runs. Today it's armed/disarmed by the **dev chord + `I`** inspector toggle
/// ([`inspect::toggle_inspect`]).
#[derive(Resource, Default)]
pub struct InspectMode {
pub enabled: bool,
}
/// The nearest [`WorldObject`] under the cursor this frame, or `None`. Consumers read `entity` and look
/// up its [`WorldObject`]; `point`/`distance` are the world-space hit.
#[derive(Resource, Default)]
pub struct MouseoverTarget {
pub entity: Option<Entity>,
pub point: Vec3,
pub distance: f32,
}
/// Ray-cast from the cursor into the world and record the nearest [`WorldObject`] hit. Restricted to
/// entities carrying `WorldObject` (so terrain, particle billboards, and other un-identified meshes are
/// transparent to the pick), and skipped entirely unless inspection is active.
fn update_mouseover(
inspect: Res<InspectMode>,
pointer_over_ui: Res<PointerOverUi>,
mut target: ResMut<MouseoverTarget>,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
objects: Query<Entity, With<WorldObject>>,
parts: PickParts,
) {
if !inspect.enabled {
if target.entity.is_some() {
*target = MouseoverTarget::default();
}
return;
}
target.entity = None;
// The pointer is over the dev UI (e.g. the now-overlaid debug panel), not the world — don't pick
// behind it. This replaces the old "is the cursor in the inset world viewport?" test, which no
// longer means anything now the panel overlays a full-screen view.
if pointer_over_ui.0 {
return;
}
let Ok((camera, cam_tf)) = camera.single() else {
return;
};
let Ok(window) = window.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return; // cursor left the window, or we're in mouselook (hidden)
};
let identified: HashSet<Entity> = objects.iter().collect();
if let Some((entity, point, distance)) =
pick_at_cursor(cursor, camera, cam_tf, &identified, &parts)
{
target.entity = Some(entity);
target.point = point;
target.distance = distance;
}
}
/// Registers the mouseover foundation (the [`MouseoverTarget`] + [`InspectMode`] resources and the
/// per-frame pick), the standalone dev-chord `I` inspector surface, and the cast journal (recording
/// always, drawing under the same toggle).
pub struct InteractPlugin;
impl Plugin for InteractPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MouseoverTarget>()
.init_resource::<InspectMode>()
.init_resource::<journal::CastJournal>()
.add_message::<WorldClick>()
.add_message::<WorldRightClick>()
.add_message::<WorldRightPress>()
// After the UI keyboard feed because `update_mouseover` reads `PointerOverUi`, whose
// player-UI half `UiInput` writes — the pick must see this frame's hover, not last
// frame's. (`toggle_inspect` itself no longer needs the ordering: its dev chord
// can't be typed text, so it reads no keyboard-capture flag — decision 0585.)
.add_systems(
Update,
(inspect::toggle_inspect, update_mouseover)
.chain()
.after(crate::ui_script::UiInput),
)
// Always recording (messages persist two frames — no ordering constraint needed).
.add_systems(Update, journal::record_casts)
.add_systems(
EguiPrimaryContextPass,
(inspect::inspect_ui, journal::journal_ui),
);
}
}
+136 -354
View File
@@ -24,66 +24,39 @@
mod area;
mod area_trigger;
mod art_scope;
mod asset_churn;
mod assets;
mod aura_visual;
mod bgwin;
mod billboard;
mod bindings;
mod blob_shadow;
mod bowstring;
mod build_id;
mod capture;
mod char_create;
mod char_select;
mod chat_bubble;
mod clouds;
mod clutter;
mod collision;
mod combat_text;
mod cooldowns;
mod creature_anim;
mod cursor;
mod cvars;
mod dbg_trace;
mod death;
mod debug_panel;
mod decal;
mod doodad_anim;
mod entities;
mod entity_shade;
mod exterior_cull;
mod ffx_glow;
mod fishing_line;
mod footprints;
mod glue;
mod glue_strings;
mod go_anim;
mod go_templates;
mod ground_fx;
mod hover_log;
mod instance_tint;
mod interact;
mod interior;
mod items;
mod lighting;
mod liquid;
mod loading_screen;
mod local_state;
mod login;
mod map_proj;
mod mesh_tag;
mod minimap;
mod model_fade;
mod model_forms;
mod model_render;
mod modkeys;
mod nameplates;
mod names;
mod net;
mod npc_text;
mod particles;
mod pending_item_ops;
mod perf;
mod pipe_warm;
@@ -93,20 +66,10 @@ mod preflight;
mod probe_shield;
mod quest_markers;
mod raid_marks;
mod ribbons;
mod rig_palette;
mod schedule;
mod sky;
mod sky_order;
mod smart_rect;
mod sound;
mod sun;
mod surface;
mod target;
mod terrain;
mod terrain_stream;
mod textinput;
mod thread_qos;
mod transport;
mod ui_action;
mod ui_aura;
@@ -155,52 +118,26 @@ mod ui_tradeskill;
mod ui_trainer;
mod ui_unit;
mod ui_world_map;
mod view;
mod vplates;
mod water_fx;
mod wdl;
mod weather;
mod wmo_portal;
mod wmo_sky;
mod world_map;
mod world_state;
mod zfill;
use assets::AssetPlugin;
use avian3d::prelude::*;
use bevy::prelude::*;
use billboard::BillboardPlugin;
use blob_shadow::BlobShadowPlugin;
use bowstring::BowstringPlugin;
use clouds::CloudsPlugin;
use clutter::ClutterPlugin;
use creature_anim::CreatureAnimPlugin;
use cursor::CursorPlugin;
use debug_panel::DebugPanelPlugin;
use doodad_anim::DoodadAnimPlugin;
use entities::EntitiesPlugin;
use entity_shade::EntityShadePlugin;
use fishing_line::FishingLinePlugin;
use footprints::FootprintsPlugin;
use interact::InteractPlugin;
use interior::InteriorPlugin;
use lighting::LightingPlugin;
use liquid::LiquidPlugin;
use loading_screen::LoadingScreenPlugin;
use net::NetPlugin;
use particles::ParticlePlugin;
use perf::PerfPlugin;
use player::PlayerPlugin;
use portrait::PortraitPlugin;
use quest_markers::QuestMarkersPlugin;
use ribbons::RibbonPlugin;
use schedule::SchedulePlugin;
use sky::SkyPlugin;
use sound::SoundPlugin;
use sun::SunPlugin;
use target::TargetPlugin;
use terrain::{TerrainMaterial, WowModelMaterial};
use terrain_stream::TerrainPlugin;
use textinput::TextInputPlugin;
use transport::TransportPlugin;
use ui_action::UiActionPlugin;
@@ -243,20 +180,20 @@ use ui_trade::UiTradePlugin;
use ui_tradeskill::UiTradeSkillPlugin;
use ui_trainer::UiTrainerPlugin;
use ui_unit::UiUnitPlugin;
use wdl::WdlPlugin;
use weather::WeatherPlugin;
use wmo_portal::WmoPortalPlugin;
use world_map::WorldMapPlugin;
// The `benilla` launcher shim (the bin package) is this library's only caller: it stamps the
// build id at compile time and hands it into [`run`]. Re-exported so the shim needs no bevy
// dep of its own.
pub use benilla_world::build_id::BuildId;
/// The world viewer's entry point — the engine with no game attached (decision 1160).
/// Its shim (`benilla-worldview`) is this library's second caller; see [`worldview`].
pub use benilla_world::worldview::run as run_worldview;
pub use bevy::app::AppExit;
pub use build_id::BuildId;
/// Anchor the loaded terrain block on the Human start (Northshire), where `one`/`One`
/// logs in — so the player sits in the middle of the block instead of Stormwind's edge.
const SPAWN_XY: (f32, f32) = (-8949.95, -132.49);
/// The asset-source name for the game's own files (`crates/benilla-app/assets`) — the UI shaders
/// today, anything else the *game* ships tomorrow. The engine's files stay on Bevy's default
/// source, which `benilla-world` owns; a path with no scheme is the engine's by construction.
const GAME_SOURCE: &str = "game";
/// Build and run the client app. `build` is the launcher shim's compile-time git stamp
/// ([`build_id`]) — passed in as plain data so the sha lives in the shim's fingerprint, not
@@ -284,7 +221,7 @@ pub fn run(build: BuildId) -> AppExit {
let capturing = capture::scenario_active();
// Any instrumented run — captures AND the live-probe fleet — opens in the background so it
// never fights the director's screen (decision 0703; `WOW_BG` overrides). See `bgwin`.
let background = bgwin::background_run();
let background = benilla_world::bgwin::background_run();
if capturing {
// Ground clutter scatters with per-run randomness, so disable it for byte-stable baselines
// — clutter isn't what the lighting rework validates, and the regression diff must not be
@@ -319,273 +256,145 @@ pub fn run(build: BuildId) -> AppExit {
eprintln!("benilla-assets: mpq:// source unavailable ({e:#})");
}
app.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "benilla".into(),
// UI-fixture captures shrink the window so the docked panel fills the frame —
// the capture is the look-pass instrument and the window is its subject. The
// action bar is the exception: it spans 1024px + 128px end caps along the screen
// bottom, so it gets a wide, short window instead of the tall default (else the
// caps crop). The vplates scenario pins the 1:1 gx window: at 1024×768 one gx
// unit = 1280 px, so the plate must land at the border texture's native
// 128×32 — directly diffable against the decoded BLP. Sized per-capture off
// WOW_CAPTURE.
resolution: if capturing
&& std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
{
// `$WOW_WIN` overrides here too — the resolution-A/B instrument for UI
// scenarios (a scale-dependent text bug looks fine at the scenario's
// default size and truncates at fullscreen heights).
if let Some(win) = 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()?))
}) {
win
} else {
match std::env::var("WOW_CAPTURE").as_deref() {
Ok("ui-actionbar") => UVec2::new(1300, 260),
Ok("vplates") => UVec2::new(1024, 768),
// The director's small-window shape: short enough that the action bar
// strip overlaps the chat edit box rows — the overlap is the subject.
Ok("ui-chatedit") => UVec2::new(566, 377),
// The fullscreen map's chrome is a centered 1024×768 block; a hair of
// margin shows the blackout doing its job.
Ok("ui-worldmap") => UVec2::new(1100, 800),
// The 920×724 era options window wants air on every side so the
// straddling right-edge tile and the hung close X stay in frame.
Ok("ui-options")
| Ok("ui-options-audio")
| Ok("ui-options-graphics") => UVec2::new(1200, 900),
_ => UVec2::new(640, 700),
}
}
.into()
} else {
// `$WOW_WIN=WxH` (logical px): override the world capture/window size —
// the resolution-A/B instrument. The FFXGlow blur geometry is byte-pinned
// in TEXELS, so its angular footprint shrinks as resolution grows and
// thin bright features (fence rails) self-amplify at 4K where the
// 1024-era reference diluted them; matching the era's pixel density
// (e.g. `WOW_WIN=512x288` on a 2× display → 1024×576 physical) isolates
// that term. Also the knob for any future era-resolution comparison.
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()?))
})
// A run that reads no pixels gets a SMALL window. It is held
// `AlwaysOnTop` for its whole life so it can never be occluded into
// the ~1 fps throttle (`capture::ProbeFocusPlugin`, decision 0906) —
// at the full default that meant every agent probe planted a
// screen-filling window over the director's work. Small + cornered
// (`ProbeFocusPlugin` parks it) is un-occludable AND out of the way;
// anything photographing pixels keeps the full size, and `WOW_WIN`
// overrides either way (decision 1148).
.unwrap_or(if bgwin::no_pixel_run() {
UVec2::new(640, 360)
} else {
UVec2::new(1600, 900)
})
.into()
},
// `$WOW_NOVSYNC=1`: uncap presentation so a headless FPS-journal run measures
// true frame cost, not the vsync ceiling — the same uncap the capture probe
// flips mid-run, available from boot for non-capture probes (perf triage at
// the glue screens, where no capture scenario runs).
present_mode: if std::env::var("WOW_NOVSYNC").as_deref() == Ok("1") {
bevy::window::PresentMode::AutoNoVsync
} else {
bevy::window::PresentMode::default()
},
// An instrumented run must never fight the director's screen (decision 0703).
// Focused, it steals the keyboard — on 2026-07-19 a login-shot run swallowed
// their keystrokes out of another app and typed them into the account box,
// which is also how that capture lost the bare caret it was taken to measure.
// So every probe/capture/regression run (`bgwin`) opens unfocused; an ordinary
// `cargo run` is unaffected and focuses normally.
//
// A background run is BORN at `AlwaysOnBottom` (`kCGNormalWindowLevel - 1`)
// so it can never flash over their work on the way up — winit raises a new
// window twice before our first frame runs, and at the normal level that
// showed as ~half a second of probe window on top (measured). But it does not
// STAY there: that level is a cage, and 0703 leaving it on for the whole run
// is why an instrumented window could never be raised again however hard you
// clicked it. `BgWinPlugin` promotes it back to Normal the moment the launch
// settles (decision 0709), and owns the app-level half — winit's forced macOS
// app activation — as well.
focused: !background,
window_level: if background {
bevy::window::WindowLevel::AlwaysOnBottom
} else {
bevy::window::WindowLevel::Normal
},
..default()
}),
..default()
})
// Our file assets (the WGSL shaders, the default UI XML) live beside THIS crate —
// but the binary is built by the `benilla` shim package, so Bevy's runtime
// `CARGO_MANIFEST_DIR` fallback would resolve `assets/` in the shim's dir (and a
// bare-binary run has no manifest dir at all — the silently-no-shaders trap in
// `capture/mod.rs`'s header). Bake this crate's absolute path at compile time:
// right under `cargo run` from any package, and a bare `target/debug/benilla`
// now finds its shaders too. Machine-local, like any dev build (decision 0993).
.set(bevy::asset::AssetPlugin {
file_path: concat!(env!("CARGO_MANIFEST_DIR"), "/assets").into(),
..default()
})
// Quiet wgpu/naga; our own crates stay at info. (This filter once also quieted the
// warcraft-rs parsers `wow_m2`/`wow_blp` — retired in-repo by decision 0021; the
// in-repo parsers don't log, so those entries were dead and are gone.)
.set(bevy::log::LogPlugin {
filter: "wgpu=error,naga=warn".into(),
..default()
})
// Asset streaming is this client's load bottleneck: every M2/WMO/BLP read decompresses
// from MPQ and parses **synchronously** on Bevy's IO task pool, and the AssetServer runs
// *all* loads there. Bevy's default caps that pool at 4 threads, so a teleport into a
// dense area — terrain + WMOs + their doodad props, all bursting at once — saturates it
// and the net-driven NPC/GameObject models queue behind the flood. Give IO more of the
// box (it sits idle when not streaming); the world-render path is GPU/IO-bound, not
// compute-bound, so trading some compute threads for streaming throughput is the right call.
// Thread QoS (decision 0609): Bevy's workers spawn at default QoS — the same Darwin
// scheduling class as rustc or an OBS encoder — so under a background build the
// frame's own threads queue behind the compiler for P-core time. Promote them at
// spawn: compute runs this frame's systems (user-interactive); IO/async-compute feed
// upcoming frames (user-initiated — above default, below the frame itself). The
// render thread has no spawn hook; `ThreadQosPlugin` promotes it from inside.
.set(TaskPoolPlugin {
task_pool_options: TaskPoolOptions {
io: bevy::app::TaskPoolThreadAssignmentPolicy {
min_threads: 2,
max_threads: 8,
percent: 0.5,
on_thread_spawn: Some(std::sync::Arc::new(|| {
thread_qos::promote_current_thread(thread_qos::QosClass::UserInitiated)
})),
on_thread_destroy: None,
},
async_compute: bevy::app::TaskPoolThreadAssignmentPolicy {
on_thread_spawn: Some(std::sync::Arc::new(|| {
thread_qos::promote_current_thread(thread_qos::QosClass::UserInitiated)
})),
..TaskPoolOptions::default().async_compute
},
compute: bevy::app::TaskPoolThreadAssignmentPolicy {
on_thread_spawn: Some(std::sync::Arc::new(|| {
thread_qos::promote_current_thread(
thread_qos::QosClass::UserInteractive,
)
})),
// `WOW_THREADS=1` serialises the systems that run this frame. Not a
// performance dial — a **diagnostic**: a defect that alternates frame to
// frame with no camera, geometry or draw-order change behind it is what an
// unordered write between two systems looks like, and that is separable from
// every other cause only by taking the concurrency away. Anything that
// survives `WOW_THREADS=1` is not a race.
max_threads: match std::env::var("WOW_THREADS").ok().as_deref() {
Some("1") => 1,
_ => TaskPoolOptions::default().compute.max_threads,
},
..TaskPoolOptions::default().compute
},
..default()
},
})
// Sound is kira behind our own mixer seam (decision 0070); Bevy's AudioPlugin would
// only open a second, never-used OS output stream at startup. Off (0530). Its
// rodio/cpal stack still compiles in via bevy's default feature — trimming the
// feature set is a separate, wider call.
.disable::<bevy::audio::AudioPlugin>(),
)
.add_plugins(thread_qos::ThreadQosPlugin)
// The `game://` asset source — the GAME's own files, beside THIS crate (decision 1171).
// `boot::tuned_default_plugins` points Bevy's default source at `benilla-world/assets`,
// because both binaries need shaders and only one of them has a UI; the game's five UI
// shaders live here instead and load as `game://shaders/…`. Registered before `AssetPlugin`
// builds, like `mpq://` above. The absolute path is baked at compile time for the same reason
// the engine's is: the binary is built by a shim package, so Bevy's runtime
// `CARGO_MANIFEST_DIR` fallback would resolve `assets/` in the shim's directory (0993).
app.register_asset_source(
GAME_SOURCE,
bevy::asset::io::AssetSourceBuilder::platform_default(
concat!(env!("CARGO_MANIFEST_DIR"), "/assets"),
None,
),
);
app.add_plugins(benilla_world::boot::tuned_default_plugins(Window {
title: "benilla".into(),
// UI-fixture captures shrink the window so the docked panel fills the frame — the capture
// is the look-pass instrument and the window is its subject. The action bar is the
// exception: it spans 1024px + 128px end caps along the screen bottom, so it gets a wide,
// short window instead of the tall default (else the caps crop). The vplates scenario pins
// the 1:1 gx window: at 1024×768 one gx unit = 1280 px, so the plate must land at the
// border texture's native 128×32 — directly diffable against the decoded BLP. Sized
// per-capture off WOW_CAPTURE.
resolution: if capturing && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") {
// `$WOW_WIN` overrides here too — the resolution-A/B instrument for UI scenarios (a
// scale-dependent text bug looks fine at the scenario's default size and truncates at
// fullscreen heights).
if let Some(win) = 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()?))
}) {
win
} else {
match std::env::var("WOW_CAPTURE").as_deref() {
Ok("ui-actionbar") => UVec2::new(1300, 260),
Ok("vplates") => UVec2::new(1024, 768),
// The director's small-window shape: short enough that the action bar strip
// overlaps the chat edit box rows — the overlap is the subject.
Ok("ui-chatedit") => UVec2::new(566, 377),
// The fullscreen map's chrome is a centered 1024×768 block; a hair of margin
// shows the blackout doing its job.
Ok("ui-worldmap") => UVec2::new(1100, 800),
// The 920×724 era options window wants air on every side so the straddling
// right-edge tile and the hung close X stay in frame.
Ok("ui-options") | Ok("ui-options-audio") | Ok("ui-options-graphics") => {
UVec2::new(1200, 900)
}
_ => UVec2::new(640, 700),
}
}
.into()
} else {
// `$WOW_WIN=WxH` (logical px): override the world capture/window size — the
// resolution-A/B instrument. The FFXGlow blur geometry is byte-pinned in TEXELS, so its
// angular footprint shrinks as resolution grows and thin bright features (fence rails)
// self-amplify at 4K where the 1024-era reference diluted them; matching the era's
// pixel density (e.g. `WOW_WIN=512x288` on a 2× display → 1024×576 physical) isolates
// that term. Also the knob for any future era-resolution comparison.
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()?))
})
// A run that reads no pixels gets a SMALL window. It is held `AlwaysOnTop` for its
// whole life so it can never be occluded into the ~1 fps throttle
// (`capture::ProbeFocusPlugin`, decision 0906) — at the full default that meant
// every agent probe planted a screen-filling window over the director's work. Small
// + cornered (`ProbeFocusPlugin` parks it) is un-occludable AND out of the way;
// anything photographing pixels keeps the full size, and `WOW_WIN` overrides either
// way (decision 1148).
.unwrap_or(if benilla_world::bgwin::no_pixel_run() {
UVec2::new(640, 360)
} else {
UVec2::new(1600, 900)
})
.into()
},
// `$WOW_NOVSYNC=1`: uncap presentation so a headless FPS-journal run measures true frame
// cost, not the vsync ceiling — the same uncap the capture probe flips mid-run, available
// from boot for non-capture probes (perf triage at the glue screens, where no capture
// scenario runs).
present_mode: if std::env::var("WOW_NOVSYNC").as_deref() == Ok("1") {
bevy::window::PresentMode::AutoNoVsync
} else {
bevy::window::PresentMode::default()
},
// An instrumented run must never fight the director's screen (decision 0703).
// Focused, it steals the keyboard — on 2026-07-19 a login-shot run swallowed
// their keystrokes out of another app and typed them into the account box,
// which is also how that capture lost the bare caret it was taken to measure.
// So every probe/capture/regression run (`bgwin`) opens unfocused; an ordinary
// `cargo run` is unaffected and focuses normally.
//
// A background run is BORN at `AlwaysOnBottom` (`kCGNormalWindowLevel - 1`)
// so it can never flash over their work on the way up — winit raises a new
// window twice before our first frame runs, and at the normal level that
// showed as ~half a second of probe window on top (measured). But it does not
// STAY there: that level is a cage, and 0703 leaving it on for the whole run
// is why an instrumented window could never be raised again however hard you
// clicked it. `BgWinPlugin` promotes it back to Normal the moment the launch
// settles (decision 0709), and owns the app-level half — winit's forced macOS
// app activation — as well.
focused: !background,
window_level: if background {
bevy::window::WindowLevel::AlwaysOnBottom
} else {
bevy::window::WindowLevel::Normal
},
..default()
}))
.add_plugins(benilla_world::thread_qos::ThreadQosPlugin)
// The app-side half of background instrumented runs: undo winit's forced macOS app
// activation so a probe/capture launch never yanks focus off the director's screen. The
// window-side half (unfocused + always-on-bottom) is in the `Window` above. Decision 0703.
.add_plugins(bgwin::BgWinPlugin)
.add_plugins(MaterialPlugin::<TerrainMaterial>::default())
.add_plugins(MaterialPlugin::<WowModelMaterial>::default())
// Physics (avian3d): collider storage + broadphase BVH + shape-casts for the character
// controller (decision 0009). The streamed terrain/placement entities carry `Collider`s; the
// player drives `MoveAndSlide` against them. WoW gravity (19.29 yd/s², binary-derived — now a
// feel knob, not a fidelity target) replaces avian's 9.81 default.
.add_plugins(PhysicsPlugins::default())
// One solver substep, not avian's 6: the world has NO dynamic bodies (static terrain,
// kinematic transports/attachments; the player is a shape-cast controller), so the substep
// loop's contact/joint solving iterates over nothing — and kinematic motion integrates
// exactly (constant velocity, no forces) at any substep count. Six substeps were pure
// fixed-tick schedule overhead (~10 substep-schedule runs per frame on the idle-floor
// ledger). Revisit if a dynamic body ever enters the world.
.insert_resource(avian3d::prelude::SubstepCount(1))
.insert_resource(Gravity(Vec3::NEG_Y * 19.291_105))
// The per-frame world-transition ordering (Input → Stream → Present) the loading screen relies
// on to cover a teleport the same frame it happens. See `schedule.rs`.
.add_plugins(SchedulePlugin)
// The faithful view distance (`farclip`) — one source of truth for the wall + the per-object
// cull (and, post-split, the stream radius). See `view.rs`.
.init_resource::<view::ViewDistance>()
.add_plugins(benilla_world::bgwin::BgWinPlugin)
// **The engine, as one name** (decision 1164). Everything `benilla-world` will own,
// in the order both binaries used before this group existed — see `world_plugins.rs`
// for the two ordering edges inside it that are load-bearing, and for what is
// deliberately left out (`pipe_warm`).
.add_plugins(benilla_world::world_plugins::WorldPlugins)
// The instruments, which the engine group deliberately does not carry (1160: instruments at
// the top of the stack). The panel first — `PerfPlugin` needs the egui plugin/context it sets
// up. Toggles: the dev chord + D, and P.
.add_plugins(DebugPanelPlugin)
// World-interaction foundation: mouseover picking + object identity (the debug inspector reads it
// now; hover tooltips / contextual cursor / targeting will later).
.add_plugins(InteractPlugin)
// M2 billboard cards (glow halos, chains) — faced to the camera each frame.
.add_plugins(BillboardPlugin)
// The owned skin palette (decision 0720): every skinned rig's joint matrices, computed by
// us and skinned in wow_model.wgsl — Bevy's SkinnedMesh lane is fully replaced.
.add_plugins(rig_palette::plugin)
// The per-instance body tint (decision 0812), on the same slot index as that palette: the aura
// state kit's CharProc-1 colour, uploaded to its own region of the shared light buffer.
.add_plugins(instance_tint::plugin)
.add_plugins(PerfPlugin)
// `WOW_FX_CENSUS=1`: where this frame's particle draws are addressed, and whether the view
// they name is switched on (decision 0775). An instrument, and one that reads the portrait
// booths — so it belongs on this side of the line, not in the group.
.add_plugins(capture::fx_draw_census_plugin)
.add_plugins(BowstringPlugin)
.add_plugins(FishingLinePlugin)
.add_plugins(QuestMarkersPlugin)
// Frame-time HUD + diagnostics — the performance standard.
// After DebugPanelPlugin so the egui plugin/context it sets up already exists. Toggle: P.
.add_plugins(PerfPlugin)
// Pipeline-compile counters + the live-compile tripwire (decision 0837: macOS builds every
// pipeline synchronously on the render thread, so a live compile is a felt stall).
.add_plugins(pipe_warm::plugin)
.add_plugins(hover_log::HoverLogPlugin)
.add_plugins(asset_churn::AssetChurnPlugin)
// Within-map art residency (decision 0793): the dedup caches expire by DISTANCE, so a
// long flight inside one map stops ratcheting. Registered before AssetPlugin only so the
// census resource exists for anything that reads it at startup; it needs no ordering.
.add_plugins(art_scope::ArtScopePlugin)
.add_plugins(particles::census::plugin)
// Foundation: opens the patch chain + inserts WorldAssets/RenderConfig (AssetSet::Open), which
// every other subsystem's startup runs after.
.add_plugins(AssetPlugin)
// World-map state (Map.dbc catalog + CurrentMap), loaded right after the chain opens — the
// terrain/WDL streamers, loading screen, and lighting all key off it.
.add_plugins(WorldMapPlugin)
// Time-of-day lighting: sun + WoW shader colors, sky background, per-frame update→apply.
.add_plugins(LightingPlugin)
// Sky dome: the Light.dbc gradient backdrop (camera-centred), driven by the same lighting.
.add_plugins(SkyPlugin)
// WMO skybox: the authored sky a building's `0x40000` group swaps in for that gradient
// (Stratholme's burning city) — registered after SkyPlugin, whose dome it stands down.
.add_plugins(wmo_sky::WmoSkyPlugin)
// Cloud coverage: the reference's procedural field — glare occlusion (occ1) + the visible layer.
.add_plugins(CloudsPlugin)
// Weather: the SMSG_WEATHER state machine driving the storm light-blend + precipitation
// (decision 0310). Lighting reads its densities `.after(WeatherTick)`.
.add_plugins(WeatherPlugin)
// Sun disc + glow halo: the celestial sprites WoW draws at the sun (RE'd from CSky::Render).
.add_plugins(SunPlugin)
// Interior lighting classifier: lights M2 entities (GameObjects/NPCs/other players) standing inside a
// WMO room off the baked floor colour, day/night-independent (the streamer fills its volume registry).
.add_plugins(InteriorPlugin)
.add_plugins(EntityShadePlugin)
// WMO portal visibility: per-frame, decides which of a building's groups are reachable through
// portals from the camera's group, so the Stormwind cathedral culls from the Trade District. Only
// computes the PVS; the Visibility authority (DebugPanelPlugin) applies it (decisions 0025/0031).
.add_plugins(WmoPortalPlugin)
// The exterior scene draws only through portal windows the flood left behind (decision 0774):
// from inside a building, terrain and ADT doodads are gated on the deferred window worklist.
.add_plugins(exterior_cull::ExteriorCullPlugin)
// Streamed world entities: cube assets + display catalogs at startup, sync each frame.
.add_plugins(EntitiesPlugin)
// Creature animation: pick Stand/Walk/Run from each creature's movement state each frame (Milestone C).
@@ -597,35 +406,13 @@ pub fn run(build: BuildId) -> AppExit {
// Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand,
// spawn-once projections on the same decal projector, fading off the effect stream.
.add_plugins(FootprintsPlugin)
// Doodad animation (decision 0130): placed M2s loop their first sequence + global sequences,
// gated to drawn instances.
.add_plugins(DoodadAnimPlugin)
// GameObject animation (decision 0242): net-streamed GObjects (doors/chests) play an M2 sequence
// on GAMEOBJECT_STATE change — the state-machine sibling of the doodad idle loop above.
.add_plugins(go_anim::plugin)
// Ground clutter: the GroundEffect catalog + the lazy per-chunk build lifecycle, owned
// independently of the terrain streamer (so the streamer can be swapped). Whichever streamer is
// active scatters into the ClutterChunks this builds.
.add_plugins(ClutterPlugin)
// Terrain streaming (the AdtTile pipeline) is added after this chain — see below.
// Distant low-detail terrain (WDL): the fogged horizon hills beyond the streamed tiles.
.add_plugins(WdlPlugin)
// Liquid: animated lake/river/ocean water surfaces (MCLQ), spawned with their terrain tile.
.add_plugins(LiquidPlugin)
// Particle emitters: the additive flames/glows of campfires, torches, braziers (decision 0014).
.add_plugins(ParticlePlugin)
// Water foam decals (CWater0Ripple wake/ring/step-in splash) — the record model, rebuilt from
// the byte RE + two reference-trace reconstructions (decision 0264).
.add_plugins(water_fx::WaterFxPlugin)
.add_plugins(ffx_glow::FfxGlowPlugin)
.add_plugins(RibbonPlugin)
// Avatar + camera + input.
.add_plugins(PlayerPlugin)
// The real client's hardware mouse cursor (native NSCursor on macOS).
.add_plugins(CursorPlugin)
// Stuck-modifier reconciliation: macOS system shortcuts (⇧⌘5) swallow modifier releases
// without a focus loss, wedging every bare-key binding (decision 0606).
.add_plugins(modkeys::ModKeysPlugin)
// Net↔ECS bridge: spawns the world thread, exposes the snapshot + writer resources. In capture
// mode the IO thread is skipped (`connect: false`) so the scene is deterministic.
.add_plugins(NetPlugin {
@@ -818,11 +605,6 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(UiQuestLogPlugin)
.add_plugins(UiChatPlugin);
// Terrain streaming: the benilla-assets `AdtTile` pipeline — streams tiles around the player through
// the `AssetServer`, owning the terrain mesh/material/collision, doodads/WMOs, liquid, clutter, and
// loading-screen residency.
app.add_plugins(TerrainPlugin);
// Register benilla-assets' loaders AFTER `AssetPlugin` (they go into the live `AssetServer`).
benilla_assets::register_asset_loaders(&mut app);
+7 -59
View File
@@ -30,10 +30,12 @@ use std::collections::HashMap;
use benilla_formats::{load_loading_screens, LoadingScreenCatalog};
use crate::assets::LockRecover;
use crate::assets::{AssetSet, WorldAssets};
use crate::schedule::WorldStage;
use crate::world_map::{CurrentMap, MapCatalogRes};
use benilla_assets::LockRecover;
use benilla_assets::MapCatalogRes;
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::schedule::WorldStage;
use benilla_world::terrain_stream::WorldLoadProgress;
use benilla_world::world_map::CurrentMap;
// Bar layout — VERIFIED THREE ways (build 5875): the `WoW.exe` `LoadingScreen.cpp` bar descriptor
// table (@0x7ffd34, `FUN_00407150`) gives entry = {cx, cy, halfW, halfH} with rect = [cx ± halfW·0.5]
@@ -68,59 +70,6 @@ const CLEAR_AFTER_READY_FRAMES: u32 = 3;
const WAIT_LOG_AFTER: f32 = 3.0;
const WAIT_LOG_EVERY: f32 = 2.0;
/// How much of the world the streamer wants around the view focus is actually there. Written each
/// frame by `terrain_stream::stream_terrain` (tiles + placements) and
/// `terrain_stream::finish_colliders` (the attach queue); read here to drive the bar + the clear
/// condition, and by the streamer's own settle release (decision 0737 — the physics hold keys on
/// this same signal, never on ground contact).
#[derive(Resource, Default)]
pub(crate) struct WorldLoadProgress {
/// Units done, of [`Self::total`]: desired tiles spawned + focus-neighbourhood placements up.
pub(crate) ready: usize,
pub(crate) total: usize,
/// Whether the tile under the view focus (the nearest desired tile) is spawned. `false` until the
/// streamer first runs, and again whenever a teleport/worldport drops the focus onto unloaded
/// ground — the backstop trigger (covers startup, cross-map worldport, AND far same-map
/// teleports like a cross-continent `.tele`, which don't change `CurrentMap`). During normal
/// streaming the focus tile is always resident, so it never fires spuriously.
pub(crate) focus_resident: bool,
/// The scene term (0737): the focus tile **and its 8 neighbours** are spawned with every
/// placement they reference (WMOs, doodads, WMO props) — "the buildings and trees around you
/// exist", which is what makes a reveal read as a world rather than bare terrain. Collider
/// quiet is deliberately *not* folded in here (it is published a stage apart); consumers use
/// [`Self::presentable`].
pub(crate) scene_ready: bool,
/// Outstanding collider attaches (decision 0610's queue) — published by `finish_colliders`.
pub(crate) colliders_pending: usize,
/// Focus-neighbourhood placements not yet spawned (the wait instrument's term).
pub(crate) placements_pending: usize,
}
impl WorldLoadProgress {
/// 0..1 residency fraction for the bar — **0.0 when nothing is wanted yet** (the cold-start /
/// pre-`desired` frame), so the bar starts empty rather than flashing full. (Distinct from the
/// clear test [`Self::is_ready`], which treats `total == 0` as not-ready.)
fn fraction(&self) -> f32 {
if self.total == 0 {
0.0
} else {
(self.ready as f32 / self.total as f32).clamp(0.0, 1.0)
}
}
/// True once every wanted unit is resident. Requires `total > 0` so the cold-start / post-swap
/// frame (before `desired` is computed) doesn't read as "done".
fn is_ready(&self) -> bool {
self.total > 0 && self.ready >= self.total
}
/// The world at the focus is presentable — the scene term plus a quiet collider queue. What
/// both consumers (the screen clear here, the settle release in the streamer) key on.
pub(crate) fn presentable(&self) -> bool {
self.scene_ready && self.colliders_pending == 0
}
}
/// Bevy resource wrapper around the format-crate [`LoadingScreenCatalog`] (the `LoadingScreenID` → BLP
/// path table). Paired with [`MapCatalogRes`] (the `mapId` → `LoadingScreenID` FK) to resolve art.
#[derive(Resource)]
@@ -198,8 +147,7 @@ pub(crate) struct LoadingScreenPlugin;
impl Plugin for LoadingScreenPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WorldLoadProgress>()
.init_resource::<LoadingScreen>()
app.init_resource::<LoadingScreen>()
.add_systems(Startup, setup_loading_screen.after(AssetSet::Open))
// In `WorldStage::Present` (after Input + Stream): we read residency for the SAME frame's
// player position — a teleport snaps in Input → the streamer recomputes focus in Stream →
+1 -1
View File
@@ -77,7 +77,7 @@ impl Plugin for LoginPlugin {
(debug_login_smoke, screen::debug_login_shot),
)
.chain()
.after(crate::schedule::WorldStage::Net),
.after(benilla_world::schedule::WorldStage::Net),
);
}
}
+1 -1
View File
@@ -18,7 +18,6 @@
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use crate::assets::WorldAssets;
use crate::char_select::wow_font;
use crate::glue::art::{GlueArt, BACKDROP, GOLD};
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
@@ -28,6 +27,7 @@ use crate::glue::widgets::{
};
use crate::glue_strings::GlueStrings;
use crate::portrait::{PortraitImages, PortraitSource, GLUE_SLOT};
use benilla_assets::WorldAssets;
use super::{ClientState, DialogKind, Field, LoginForm};
+11 -27
View File
@@ -44,13 +44,14 @@ use interior::{interior_group_selection, wmo_minimap_stem};
use benilla_ui::widget::MINIMAP_DEFAULT_ZOOM;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::player::Player;
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuadMask, UiQuads};
use crate::wmo_portal::{
use benilla_assets::MapCatalogRes;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::wmo_portal::{
down_ray_seeds, terrain_z_local, WmoPortalInstance, INTERIOR_PROBE_HEIGHT,
};
use crate::world_map::{CurrentMap, MapCatalogRes};
use benilla_world::world_map::CurrentMap;
/// Yards per ADT tile / per MCNK chunk (16 chunks per tile edge).
const TILE_YARDS: f32 = 533.333_3;
@@ -281,11 +282,10 @@ fn emit_minimap(
map: Option<Res<CurrentMap>>,
catalog: Option<Res<MapCatalogRes>>,
player: Res<Player>,
lighting: Option<Res<crate::lighting::WowLighting>>,
lighting: Option<Res<benilla_world::lighting::WowLighting>>,
instances: Query<&WmoPortalInstance>,
wmos: Res<Assets<WmoModel>>,
streamer: Res<crate::terrain_stream::TerrainStreamer>,
adt_tiles: Res<Assets<benilla_assets::AdtTile>>,
world: benilla_world::world_point::WorldPoint,
asset_server: Res<AssetServer>,
death_net: Res<crate::death::DeathNet>,
blip_inputs: blips::BlipInputs,
@@ -347,7 +347,7 @@ fn emit_minimap(
let eye = player.pos + Vec3::Y * INTERIOR_PROBE_HEIGHT;
// The down-ray races the terrain, exactly as the interior/zone tracker does — standing on the
// grass above a mine's tunnels is not standing in the mine.
let terrain = crate::terrain_stream::terrain_height_under(&streamer, &adt_tiles, eye);
let terrain = world.terrain_height_under(eye);
instances.iter().find_map(|inst| {
let model = wmos.get(&inst.handle)?;
if model.wmo_id == 0 {
@@ -634,15 +634,7 @@ fn emit_minimap(
assets.forms.as_ref(),
icons,
player_indoors,
|feet| {
crate::wmo_portal::indoors_at(
&wmos,
instances.iter(),
&streamer,
&adt_tiles,
feet,
)
},
|feet| world.indoors_at(feet),
&mut quads,
&mut hover,
);
@@ -655,15 +647,7 @@ fn emit_minimap(
player_indoors,
// A dot NPC's own containment — the same faces-only down-ray the entity light
// classifier stands on (dots are few; the per-frame rays are cheap).
|feet| {
crate::wmo_portal::indoors_at(
&wmos,
instances.iter(),
&streamer,
&adt_tiles,
feet,
)
},
|feet| world.indoors_at(feet),
&mut quads,
&mut hover,
);
@@ -716,11 +700,11 @@ fn emit_minimap(
/// indoors at level 3), which is the director's report (2026-07-09).
fn feed_minimap_inside(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
interior: Res<crate::wmo_portal::CurrentWmoInterior>,
world: benilla_world::world_point::WorldPoint,
mut was_inside: Local<Option<bool>>,
) {
let Some(mut script) = script else { return };
let inside = interior.0.is_some();
let inside = world.interior().is_some();
script.set_minimap_inside(inside);
if *was_inside != Some(inside) {
*was_inside = Some(inside);
-92
View File
@@ -1,92 +0,0 @@
//! Stuck-modifier reconciliation against the OS's live flag state (decision 0606).
//!
//! macOS system shortcuts that grab the keyboard *without* de-focusing the window — the ⇧⌘5
//! screenshot overlay is the canonical one — swallow the modifiers' release events: the overlay is
//! a non-activating panel, so the app never receives `Focused(false)` (Bevy's stuck-key reset,
//! `KeyboardFocusLost` → `release_all`, never fires), and winit only synthesizes modifier key
//! events from `flagsChanged`, which the grabbed keyboard never delivers (verified in
//! winit 0.30.13 `platform_impl/macos/view.rs::update_modifiers`). `ButtonInput<KeyCode>` then
//! reports Shift/Cmd held indefinitely, and every bare-key binding — the action bar's number row
//! behind `ui_script::input`'s bare-binding gate — goes dead until the user happens to tap the
//! stuck modifier again.
//!
//! The fix: poll the OS's live modifier state (`+[NSEvent modifierFlags]` — hardware-derived and
//! documented as independent of the event stream) once per frame, right after Bevy's own input
//! collection, and **release** any modifier Bevy believes is down but the OS says is up.
//! Release-only by design: the hardware state leads the event stream, so a legitimately
//! just-pressed modifier is never wrongly released — whereas synthesizing *presses* from the poll
//! could race in-flight release events and manufacture the same bug in reverse. The poll can't
//! tell left from right within a family, so an up family releases both variants (only ever a
//! correction — a genuinely held key keeps its family bit set and is never touched).
use bevy::prelude::*;
pub struct ModKeysPlugin;
impl Plugin for ModKeysPlugin {
fn build(&self, app: &mut App) {
#[cfg(target_os = "macos")]
app.add_systems(
PreUpdate,
mac::reconcile_modifiers.after(bevy::input::InputSystems),
);
#[cfg(not(target_os = "macos"))]
let _ = app;
}
}
#[cfg(target_os = "macos")]
mod mac {
use bevy::ecs::system::NonSendMarker;
use bevy::input::keyboard::Key;
use bevy::prelude::*;
use objc2_app_kit::{NSEvent, NSEventModifierFlags};
/// Release any modifier key Bevy holds pressed that the OS's live flag state says is up.
/// `NonSendMarker` pins the system to the main thread, which AppKit requires.
pub(super) fn reconcile_modifiers(
_main_thread: NonSendMarker,
mut codes: ResMut<ButtonInput<KeyCode>>,
mut logical: ResMut<ButtonInput<Key>>,
) {
let flags = unsafe { NSEvent::modifierFlags_class() };
let families = [
(
NSEventModifierFlags::NSEventModifierFlagShift,
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
Key::Shift,
),
(
NSEventModifierFlags::NSEventModifierFlagControl,
[KeyCode::ControlLeft, KeyCode::ControlRight],
Key::Control,
),
(
NSEventModifierFlags::NSEventModifierFlagOption,
[KeyCode::AltLeft, KeyCode::AltRight],
Key::Alt,
),
(
NSEventModifierFlags::NSEventModifierFlagCommand,
[KeyCode::SuperLeft, KeyCode::SuperRight],
Key::Super,
),
];
for (flag, keys, key) in families {
if flags.contains(flag) {
continue;
}
// Guarded so an in-sync frame (the overwhelmingly common case) never marks the
// resources changed.
for code in keys {
if codes.pressed(code) {
warn!("modkeys: releasing stuck {code:?} (OS reports it up)");
codes.release(code);
}
}
if logical.pressed(key.clone()) {
logical.release(key);
}
}
}
}
+3 -12
View File
@@ -64,9 +64,9 @@ use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
use crate::player::WorldCamera;
use crate::target::{ring_reaction, ring_variant, CombatFlash, Factions, RingVariant, Selection};
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
use benilla_world::view::WorldCamera;
/// The height-scale law (`0x6c6e90`): `d > KNEE ? d/KNEE · RATE · FLOOR : FLOOR`, `d` the anchor's
/// height above the feet in world units.
@@ -74,15 +74,6 @@ const SCALE_FLOOR: f32 = 0.2; // [0x80679c]
const SCALE_KNEE: f32 = 4.0; // [0x8112a8]
const SCALE_RATE: f32 = 1.5; // [0x8112ac]
/// Transparent-sort bias on every plate material — the ladder's TOP rung: names draw after every
/// ordinary transparent (the reference draws its world text late in the frame — decision 0519) and
/// after the celestial glare ([`crate::sky_order`], whose rungs all sit below this one, so a flare
/// never washes text). **Positive = drawn later**; the sign law, read off the bevy sources, is in
/// [`crate::sky_order`] — it was inverted here and the water erased the glyphs (decision 0639).
/// Small on purpose: 6× the far plane is all the ordering needs, and this same field doubles as
/// the rasterizer depth bias on a layer that is depth-TESTED (walls must keep occluding names).
pub(crate) const NAMEPLATE_DEPTH_BIAS: f32 = 4.0e4;
/// benilla's nameplate config (the client's `0xce8720` cvar mask, reduced to what streams today),
/// player-settable since 0992: 1.12's own `UnitNamePlayer`/`UnitNameNPC`/`UnitNameOwn` CVars over
/// these gates (the Options window's Nameplates page, through the 0954 store — the arms live in
@@ -372,7 +363,7 @@ pub(crate) fn drive_nameplates(
// draws its world text late in the frame, after the liquid — this reproduces
// that order; walls still occlude via the depth test, and plate-vs-plate
// ordering is unchanged (uniform bias).
depth_bias: NAMEPLATE_DEPTH_BIAS,
depth_bias: benilla_world::sky_order::Rung::NAMEPLATE,
..default()
}),
);
@@ -658,7 +649,7 @@ impl Plugin for NameplatesPlugin {
/// doc): it grows with every distinct name ever seen and the old map's population never comes
/// back. The palette `materials` stay — a small fixed set, rebuilt only if the atlas changes.
fn evict_name_meshes(
mut changes: MessageReader<crate::world_map::MapChange>,
mut changes: MessageReader<benilla_world::world_map::MapChange>,
mut plates: ResMut<Nameplates>,
) {
if changes.is_empty() {
+26 -14
View File
@@ -24,7 +24,7 @@ use benilla_protocol::{
use bevy::prelude::*;
use crossbeam_channel::{Receiver, Sender};
use crate::schedule::WorldStage;
use benilla_world::schedule::WorldStage;
mod apply;
pub(crate) mod io;
@@ -66,6 +66,13 @@ impl Plugin for NetPlugin {
if !self.connect {
app.insert_resource(NetOffline);
}
// In the wire-drain stage, because that is what it is the product of. The lighting
// resolve is ordered after that stage so it always reads THIS frame's clock rather than
// whichever way the executor happened to break the tie.
app.add_systems(
Update,
publish_world_time.in_set(benilla_world::schedule::WorldStage::Net),
);
app.insert_resource(NetEvents(handles.events))
.insert_resource(NetCommands(handles.commands))
.insert_resource(CharPick(handles.pick))
@@ -91,7 +98,6 @@ impl Plugin for NetPlugin {
.add_message::<SpeedChangeMessage>()
.add_message::<MoveModeMessage>()
.add_message::<ServerSoundMessage>()
.add_message::<WeatherMessage>()
.add_message::<EmoteMessage>()
.add_message::<AiReactionMessage>()
.add_message::<WorldportMessage>()
@@ -350,6 +356,24 @@ pub(crate) struct DropTally {
#[derive(Resource, Default)]
pub(crate) struct ServerTime(pub(crate) Option<GameTime>);
/// Publish the session clock into the engine's [`benilla_world::lighting::WorldTime`] input, once a
/// frame. The renderer wants three scalars, not a wire sample with an `Instant` in it — and with
/// no server at all it wants noon, which is `WorldTime`'s own default rather than a branch here.
pub(crate) fn publish_world_time(
server: Res<ServerTime>,
mut world_time: ResMut<benilla_world::lighting::WorldTime>,
) {
*world_time = match server.0 {
Some(gt) => benilla_world::lighting::WorldTime {
minute: gt.minute_of_day(),
minute_f: gt.minute_of_day_f32(),
day: gt.day_continuous(),
live: true,
},
None => benilla_world::lighting::WorldTime::default(),
};
}
/// The server's **wall clock** (`SMSG_QUERY_TIME_RESPONSE`, asked for on entering the world),
/// advanced monotonically from the sample. `None` until the first answer lands.
///
@@ -1455,15 +1479,3 @@ pub(crate) struct AiReactionMessage {
pub(crate) unit: Entity,
pub(crate) hostile: bool,
}
/// The zone's weather state (`SMSG_WEATHER`, bridged from the Net drain): the loop kit for the
/// sound subsystem; `weather_type`/`grade`/`instant` for the visuals' state machine
/// (`weather::weather_tick`, decision 0310).
#[derive(Message, Clone, Copy)]
pub(crate) struct WeatherMessage {
pub(crate) weather_type: u32,
pub(crate) grade: f32,
/// A SoundEntries loop kit (8533..8558), 0 = clear skies.
pub(crate) sound_id: u32,
pub(crate) instant: bool,
}
+2 -1
View File
@@ -12,8 +12,9 @@ use super::{
AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage,
Guid, GuidIndex, LoggedOutMessage, NetCommands, NetEvents, NetStatus, ObjectStore,
PendingTransfer, RemoteMotion, Reputations, SelfGuid, SelfPlayer, ServerSoundMessage,
ServerTime, ServerWallClock, TeleportMessage, WeatherMessage, WorldportMessage,
ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
};
use benilla_world::weather::WeatherMessage;
mod anim;
mod chat;
+7 -4
View File
@@ -66,8 +66,11 @@ pub(super) fn chat(
"net: addon chat [{:#04x}] from {:#x}: {:?} (suppressed — not a chat line)",
m.chat_type, m.sender_guid, m.text
);
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("addon", &format!("[{:#04x}] {:?}", m.chat_type, m.text));
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"addon",
&format!("[{:#04x}] {:?}", m.chat_type, m.text),
);
}
}
return;
@@ -87,8 +90,8 @@ pub(super) fn chat(
// may or may not be reaching it — and its answer is only usable if it lands on the same
// timeline as the `snd`/`rly`/`run` lines it must be read against. `debug!` timestamps are
// wall-clock in a different format; this is one clock, one file.
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"sys",
&format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")),
);
+2 -2
View File
@@ -83,8 +83,8 @@ pub(super) fn attacker_state(
seq: u64,
) {
let victim = index.0.get(&s.victim).copied();
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"recv swing atk={:#x} victim={:#x} dmg={} vstate={} hit={:#x}",
@@ -190,8 +190,8 @@ pub(super) fn spell_damage_log(
feedback: &mut MessageWriter<UnitCombatFeedback>,
center: &mut MessageWriter<CombatTextEvent>,
) {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"recv spelldmg atk={:#x} target={:#x} dmg={} crit={}",
@@ -262,8 +262,8 @@ pub(super) fn periodic_aura_log(
names: &mut NameCache,
net: &NetCommands,
) {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"recv periodic caster={:#x} target={:#x} ticks={}",
+1 -1
View File
@@ -11,8 +11,8 @@ use bevy::prelude::*;
use crate::go_templates::GameObjectTemplates;
use crate::items::Items;
use crate::model_fade::DespawnFade;
use crate::names::NameCache;
use benilla_world::model_fade::DespawnFade;
use super::super::motion::{
create_spline, monster_move_spline, pose_transform, resolve_facing, trace_create_spline,
+4 -4
View File
@@ -406,9 +406,9 @@ pub(super) fn spell_go(
}
}
}
if crate::dbg_trace::enabled() {
if benilla_assets::trace::enabled() {
if let Some(d) = display {
crate::dbg_trace::line(
benilla_assets::trace::line(
"cd",
&format!(
"arm spell={spell_id} rec={}ms cat={}:{}ms (GO self-insert)",
@@ -430,8 +430,8 @@ pub(super) fn spell_go(
// bump is belt-and-braces for the case where the same spell re-arms to an identical
// triple (a zero-length re-cast), which the diff would otherwise swallow.
pet_bar.bar_signals = pet_bar.bar_signals.wrapping_add(1);
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"cd",
&format!(
"arm spell={spell_id} rec={}ms cat={}:{}ms (GO pet-insert)",
+2 -1
View File
@@ -8,7 +8,8 @@ use bevy::prelude::*;
use crate::world_state::WorldStates;
use super::super::{GuidIndex, ServerSoundKind, ServerSoundMessage, WeatherMessage};
use super::super::{GuidIndex, ServerSoundKind, ServerSoundMessage};
use benilla_world::weather::WeatherMessage;
/// `SMSG_PLAY_SOUND` — a 2D (non-positional) one-shot.
pub(super) fn play_sound(sound_id: u32, out: &mut MessageWriter<ServerSoundMessage>) {
+12 -12
View File
@@ -168,8 +168,8 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
.name("wow-net-write".into())
.spawn(move || {
// Latency-sensitive: movement packets queue here (thread QoS, decision 0609).
crate::thread_qos::promote_current_thread(
crate::thread_qos::QosClass::UserInitiated,
benilla_world::thread_qos::promote_current_thread(
benilla_world::thread_qos::QosClass::UserInitiated,
);
writer_loop(&cmd_rx, writer_rx, &clock)
})
@@ -177,8 +177,8 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
thread::Builder::new()
.name("wow-net".into())
.spawn(move || {
crate::thread_qos::promote_current_thread(
crate::thread_qos::QosClass::UserInitiated,
benilla_world::thread_qos::promote_current_thread(
benilla_world::thread_qos::QosClass::UserInitiated,
);
loop {
match run(&cfg, &events_tx, &writer_tx, &pick_rx, &login_rx, &abandon) {
@@ -446,8 +446,8 @@ fn run(
// event, which no instrument could see. With this line every packet off the wire
// is accounted for by name, so "the server stopped relaying" and "we dropped it on
// the floor" are finally different pictures instead of the same silence.
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"in",
&format!(
"{opcode:#06x} {} ev={}",
@@ -475,8 +475,8 @@ fn run(
// arrived: the inbound census counts it either way, and no `rly` line is emitted
// either way. That ambiguity is what made a starving remote mover unattributable —
// so the skips get their own line, with the opcode that died.
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("skip", &format!("opcode={opcode:#06x} {reason}"));
if benilla_assets::trace::enabled() {
benilla_assets::trace::line("skip", &format!("opcode={opcode:#06x} {reason}"));
}
// Log which packet we dropped (opcode + message name), capped so it can't itself
// flood — enough to capture a post-teleport burst of unparseable object updates.
@@ -557,8 +557,8 @@ fn writer_loop(
// No live writer: the session is gone and this command evaporates. Traced
// unconditionally — this is the state in which a client keeps *deciding* to send
// movement (`snd` lines) that no one will ever receive (decision 0621).
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("wire", "DROPPED — no live session");
if benilla_assets::trace::enabled() {
benilla_assets::trace::line("wire", "DROPPED — no live session");
}
if warned < SEND_WARN_CAP {
bevy::log::warn!("net: dropping command — not connected");
@@ -912,8 +912,8 @@ fn writer_loop(
// failures are traced: a silent `wire` log beside a busy `snd` log means every packet
// went out.
if let Err(e) = result {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("wire", &format!("SEND FAILED: {e:#}"));
if benilla_assets::trace::enabled() {
benilla_assets::trace::line("wire", &format!("SEND FAILED: {e:#}"));
}
if warned < SEND_WARN_CAP {
bevy::log::warn!("net: send failed: {e:#}");
+9 -19
View File
@@ -2,7 +2,6 @@
//! [`super`]'s motion model (decision 0053): flag-driven ground locomotion between the ~2 Hz
//! heartbeats, and the jump as a locally-played ballistic event.
use avian3d::character_controller::move_and_slide::MoveAndSlide;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_protocol::{JumpInfo, MoveSpeeds};
use bevy::prelude::*;
@@ -124,12 +123,12 @@ pub(in crate::net) fn trace_relay(
queued: usize,
out: RelayOutcome,
) {
if !crate::dbg_trace::enabled() {
if !benilla_assets::trace::enabled() {
return;
}
let lead = chain.lead_ms(now_ms);
let kind = if mv.heartbeat { "hb" } else { "tr" };
crate::dbg_trace::line(
benilla_assets::trace::line(
"rly",
&format!(
"guid={guid:#x} {kind} wire={} flags={:#x} out={} lead={lead:7.1} q={queued}",
@@ -165,7 +164,7 @@ fn trace_runaway(guid_hint: Entity, rm: &RemoteMotion, now_ms: f64, silent_s: u3
// the whole census frozen means the **socket** died with nobody noticing (decision 0621).
let (pkts, age) = crate::net::io::inbound_census();
let age = age.map_or_else(|| "never".to_string(), |ms| format!("{ms}ms"));
crate::dbg_trace::line(
benilla_assets::trace::line(
"run",
&format!(
"{guid_hint} RUNAWAY flags={:#x} silent={silent_s}s drift={:.1}yd since={:.0}ms pos=[{:.1},{:.1}] netpkts={pkts} lastpkt={age}",
@@ -200,7 +199,7 @@ const REMOTE_TRACE_MS: f64 = 500.0;
///
/// `age` (ms since a packet last applied) rides along so a sample is never read as server truth.
fn trace_remote(e: Entity, rm: &RemoteMotion, held: f32, dz: f32, now_ms: f64) {
crate::dbg_trace::line(
benilla_assets::trace::line(
"rem",
&format!(
"{e} flags={:#x} pos=[{:.2},{:.2},{:.2}] dz={dz:+.3} held={held:.3} age={:.0}ms",
@@ -411,7 +410,7 @@ pub(in crate::net) fn extrapolate_remote_units(
mut commands: Commands,
// Avian's kinematic move-and-slide + the player-body capsule — the *same* pair the local
// controller sweeps (decision 0626): one controller, every mover.
move_and_slide: MoveAndSlide,
world: benilla_world::collision::WorldCollision,
capsule: Res<crate::player::PlayerCapsule>,
// The runaway watch's per-mover throttle: the last whole silent second reported, so a stuck
// mover writes one line a second rather than one a frame. Trace-only, hence a `Local` and not
@@ -435,11 +434,10 @@ pub(in crate::net) fn extrapolate_remote_units(
use crate::creature_anim::{ease_strafe_yaw, strafe_body_offset, wrap_pi};
let dt = time.delta_secs();
let now_ms = time.elapsed_secs_f64() * 1000.0;
let filter = crate::collision::player_query_filter();
for (e, mut t, mut rm, speeds, twist, latched, riding) in &mut q {
// The runaway watch (trace-only): a mover still carrying a direction flag, with nothing
// queued behind it and nothing applied for seconds, is running on our extrapolation alone.
if crate::dbg_trace::enabled() {
if benilla_assets::trace::enabled() {
let silent = now_ms - rm.last_apply_ms;
let moving = rm.flags & move_flags::ANY_MOVE != 0;
if moving && rm.pending.is_empty() && silent > RUNAWAY_SILENCE_MS {
@@ -494,19 +492,11 @@ pub(in crate::net) fn extrapolate_remote_units(
Vec3::ZERO
};
let resolved_center = if airborne {
crate::player::mover::airborne_step(
&move_and_slide,
&capsule.0,
&filter,
from,
vel,
time.delta(),
)
crate::player::mover::airborne_step(&world, &capsule.0, from, vel, time.delta())
} else {
crate::player::mover::grounded_step(
&move_and_slide,
&world,
&capsule.0,
&filter,
from,
vel,
time.delta(),
@@ -584,7 +574,7 @@ pub(in crate::net) fn extrapolate_remote_units(
rm.vertical_velocity = vertical_velocity;
rm.speed = speed;
// The remote-pose watch (trace-only): one sample per mover per half-second.
if crate::dbg_trace::enabled()
if benilla_assets::trace::enabled()
&& sampled
.get(&e)
.is_none_or(|t| now_ms - t >= REMOTE_TRACE_MS)
+24 -30
View File
@@ -5,18 +5,14 @@
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use avian3d::character_controller::move_and_slide::MoveAndSlide;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_protocol::{CreateSpline, EntityKind};
use bevy::prelude::*;
use crate::collision::player_query_filter;
use crate::entities::CollisionHeight;
use crate::liquid::{unit_claim, water_surface_at, WaterChunkInfo};
use crate::player::swim_enter_depth;
use super::super::NetEntity;
use crate::wmo_portal::UnitWmoRoom;
/// A server-dictated movement path (`SMSG_MONSTER_MOVE`): the unit traverses `points` at constant
/// speed over `duration` from `start`. All points are raw WoW coords; [`sample_splines`] interpolates
@@ -132,7 +128,7 @@ impl Spline {
/// the spline tangent (`mover+0x5c`), so the climb angle IS that tangent's vertical
/// component. (The `mover+0x20` asin scalar is the SWIM branch's — flight never reaches it.)
/// - **Bank**: `θ = 2·sign(cross)·acos(clamp(t̂ₓᵧ·d̂ₓᵧ, −1, 1))` between the XY-normalized
/// tangent and the XY direction to the **1000 ms look-ahead** point (`0x3e8` @ `0x7c5623`;
/// tangent and the XY direction to the **1000 world look-ahead** point (`0x3e8` @ `0x7c5623`;
/// a degenerate XY length — the client's eps `2.384e-7` — banks 0), then the **snap** — not
/// a soft clamp: `θ < −π/2 → −π`, `θ ≥ +π/2 → +π` (the sharp-corner/antipodal guard: a
/// hairpin reads as a momentary full roll, visible on the reference at switchbacks). No
@@ -263,7 +259,7 @@ pub(in crate::net) fn create_spline(spline: CreateSpline) -> Option<Spline> {
/// exactly how far this unit will drift from us while we hold it still, and therefore how big a jump
/// its next `SMSG_MONSTER_MOVE` has to make up. Read it against the `mmv` lines' realized snaps.
pub(in crate::net) fn trace_create_spline(guid: u64, spline: Option<&CreateSpline>) {
if !crate::dbg_trace::enabled() {
if !benilla_assets::trace::enabled() {
return;
}
let Some(s) = spline else { return };
@@ -277,10 +273,10 @@ pub(in crate::net) fn trace_create_spline(guid: u64, spline: Option<&CreateSplin
.sum();
let ridden = s.time_passed_ms as f32 / s.duration_ms.max(1) as f32;
let left = length * (1.0 - ridden).clamp(0.0, 1.0);
crate::dbg_trace::line(
benilla_assets::trace::line(
"csp",
&format!(
"{guid:#x} nodes={} len={length:.2} left={left:.2} t={}/{} ms{}{}{}",
"{guid:#x} nodes={} len={length:.2} left={left:.2} t={}/{} world{}{}{}",
s.path.len(),
s.time_passed_ms,
s.duration_ms,
@@ -312,14 +308,14 @@ pub(in crate::net) fn trace_move_snap(
stop: bool,
duration_ms: u32,
) {
if !crate::dbg_trace::enabled() {
if !benilla_assets::trace::enabled() {
return;
}
let snap = from.map_or("xy=? z=?".to_string(), |f| {
let (dx, dy, dz) = (start[0] - f[0], start[1] - f[1], start[2] - f[2]);
format!("xy={:.2} z={dz:+.2}", (dx * dx + dy * dy).sqrt())
});
crate::dbg_trace::line(
benilla_assets::trace::line(
"mmv",
&format!(
"{guid:#x} {snap} start=[{:.2},{:.2},{:.2}] dur={duration_ms}{}",
@@ -403,7 +399,7 @@ const GROUND_CLAMP_DOWN: f32 = 4.0;
/// the grounded fork zeroes the spline Z-delta and the WALK resolver reads Z off the world trace), and
/// an idle unit reads grounded against the reference too (the exact idle path isn't byte-pinned yet —
/// decision 0059). We mirror the *behaviour*: cast a ray straight down against the terrain/WMO
/// **walking** colliders — the same set the player stands on ([`player_query_filter`]) — and set the
/// **walking** colliders — the same set the player stands on ([`benilla_world::collision::WorldCollision::body_filter`]) — and set the
/// unit's Y to the hit.
///
/// Scope: **`Unit` creatures only** (a player owns its Z via the controller / `RemoteMotion`; a
@@ -417,7 +413,7 @@ const GROUND_CLAMP_DOWN: f32 = 4.0;
/// what we re-ground; an idle unit (no spline) is re-grounded in place each frame, which also catches
/// it once its terrain collider loads.
pub(in crate::net) fn ground_clamp_creatures(
ms: MoveAndSlide,
world: benilla_world::collision::WorldCollision,
mut q: Query<(
&NetEntity,
Option<&Spline>,
@@ -425,7 +421,6 @@ pub(in crate::net) fn ground_clamp_creatures(
Has<CreatureSwimming>,
)>,
) {
let filter = player_query_filter();
for (net, spline, mut t, swimming) in &mut q {
if net.kind != EntityKind::Unit {
continue; // players + GameObjects own their Z
@@ -441,9 +436,7 @@ pub(in crate::net) fn ground_clamp_creatures(
// The one-sided down-ray (decision 0970): a creature grounds like the player grounds — a
// face whose winding points away is no floor, or an idle NPC would stand mid-air on the
// very shell face the player mover now falls through.
if let Some(hit) =
crate::collision::one_sided::cast_ray(&ms, origin, Dir3::NEG_Y, reach, &filter)
{
if let Some(hit) = world.ray_body(origin, Dir3::NEG_Y, reach) {
t.translation.y = origin.y - hit.distance; // the down-ray's hit point Y = feet on the surface
}
}
@@ -457,7 +450,6 @@ type SwimMarkQuery = (
&'static NetEntity,
&'static Transform,
Option<&'static CollisionHeight>,
Option<&'static UnitWmoRoom>,
Has<CreatureSwimming>,
);
@@ -488,26 +480,28 @@ pub(crate) struct CreatureSwimming;
pub(in crate::net) fn mark_swimming_creatures(
mut commands: Commands,
units: Query<SwimMarkQuery>,
water: Query<&WaterChunkInfo>,
placements: crate::liquid::RoomPlacements,
world: benilla_world::world_point::WorldPoint,
) {
for (e, net, t, collision, room, marked) in &units {
for (e, net, t, collision, marked) in &units {
if net.kind != EntityKind::Unit {
continue; // players carry the real flag on the wire; GameObjects don't swim
}
// The unit's OWN room claim decides whose liquid answers (0696). It used to pass "no claim",
// so both sources answered: Undercity's NPCs read Tirisfal's ADT water 95 yd over their
// heads and swam on dry stone in rooms the player walked.
let claim = unit_claim(room, &placements);
// A unit the room tracker hasn't reached yet cannot ENTER swim. `Unknown` admits both
// sources — the very false positive this fix removes — so a freshly streamed NPC standing
// in an interior would flash the swim gait for the frame before its claim lands. It can
// still LEAVE: a stale mark must always be able to clear.
if !marked && claim == crate::liquid::LiquidClaim::Unknown {
// The unit's OWN room decides whose liquid answers (0696). Before it, both sources
// answered: Undercity's NPCs read Tirisfal's ADT water 95 yd over their heads and swam on
// dry stone in rooms the player walked.
//
// A unit the room tracker hasn't reached yet cannot ENTER swim — an unsettled claim admits
// both sources, the very false positive that fix removes, so a freshly streamed NPC
// standing in an interior would flash the swim gait for the frame before its room lands.
// It can still LEAVE: a stale mark must always be able to clear.
let who = benilla_world::world_point::Subject::Unit(e);
if !marked && !world.room_settled(who) {
continue;
}
let wow = bevy_to_wow(t.translation);
let depth = water_surface_at(water.iter(), wow, claim).map_or(f32::MIN, |s| s - wow[2]);
let depth = world
.water_surface_at(who, wow)
.map_or(f32::MIN, |s| s - wow[2]);
let boundary = swim_enter_depth(collision.copied().unwrap_or_default().0);
let swimming = if marked {
depth >= boundary - CREATURE_SWIM_EXIT_BAND
+9 -61
View File
@@ -40,7 +40,7 @@ use bevy::window::{PresentMode, PrimaryWindow};
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
use crate::debug_panel::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::player::WorldCamera;
use benilla_world::view::WorldCamera;
/// The frame budget: a 60 fps floor. No frame should exceed this.
pub const FRAME_BUDGET_MS: f32 = 1000.0 / 60.0;
@@ -81,7 +81,6 @@ impl Plugin for PerfPlugin {
))
.init_resource::<FrameStats>()
.init_resource::<PerfHud>()
.init_resource::<StreamActivity>()
.insert_resource(StreamTrace {
path: std::env::var("WOW_STREAM_TRACE").unwrap_or_default(),
frame: 0,
@@ -104,57 +103,6 @@ impl Plugin for PerfPlugin {
}
}
/// Per-frame terrain-streamer activity, bumped by the `WorldStage::Stream` chain as it works
/// (a handful of integer adds + three self-timers — maintained unconditionally, zeroed by
/// [`trace_stream`] every frame). Exists because B181's symptom — a frame spike on every ADT
/// tile-boundary crossing — is keyed to *what the streamer did that frame*, which no 1 Hz journal
/// row can attribute: the spike frame needs its own row saying what was dropped, requested and
/// spawned beside what the frame cost.
#[derive(Resource, Default)]
pub(crate) struct StreamActivity {
/// Tiles whose owned entities (terrain root + cells, liquid, clutter) were despawned.
pub(crate) tiles_dropped: u32,
/// Shared placements whose refcount hit zero (their entities despawned with them).
pub(crate) placements_dropped: u32,
/// Entity handles despawned with those placements (submeshes, colliders, lights, props).
pub(crate) placement_entities_dropped: u32,
/// New ADT asset requests fired.
pub(crate) tiles_requested: u32,
/// Loaded tiles spawned (root + collider kicked off + placements registered + liquid + clutter).
pub(crate) tiles_spawned: u32,
/// MCNK cell meshes built + spawned by the paced furnisher (decision 0832).
pub(crate) cells_spawned: u32,
/// Model submesh render forms built by the paced model furnisher (decision 0834).
pub(crate) model_meshes_built: u32,
/// Placements (or WMO props) whose model landed and spawned.
pub(crate) placements_spawned: u32,
/// Off-thread colliders attached.
pub(crate) colliders_attached: u32,
/// Self-time of `stream_terrain` / `furnish_tile_cells` / `furnish_model_forms` /
/// `spawn_loaded_placements` / `finish_colliders` (ms).
pub(crate) stream_ms: f32,
pub(crate) furnish_ms: f32,
pub(crate) mfurnish_ms: f32,
pub(crate) spawn_ms: f32,
pub(crate) collider_ms: f32,
}
impl StreamActivity {
/// Did the streamer *do* anything this frame? (The self-timers don't count: the desired-set
/// scan runs every frame and would make every row an "event".)
fn any_event(&self) -> bool {
self.tiles_dropped
+ self.placements_dropped
+ self.tiles_requested
+ self.tiles_spawned
+ self.cells_spawned
+ self.model_meshes_built
+ self.placements_spawned
+ self.colliders_attached
> 0
}
}
/// A frame past this is logged even with no streamer activity — catches a spike whose cost lands
/// outside the Stream chain (command application, render extraction, asset frees frames later).
const TRACE_FRAME_MS: f32 = 25.0;
@@ -192,7 +140,7 @@ const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,furnis
#[allow(clippy::too_many_arguments)]
fn trace_stream(
mut trace: ResMut<StreamTrace>,
mut activity: ResMut<StreamActivity>,
mut activity: ResMut<benilla_world::terrain_stream::StreamActivity>,
time: Res<Time<Real>>,
entities: Query<()>,
mut adt_events: MessageReader<bevy::asset::AssetEvent<benilla_assets::AdtTile>>,
@@ -397,7 +345,7 @@ fn toggle_hud(keys: Res<ButtonInput<KeyCode>>, mut hud: ResMut<PerfHud>) {
// The dev chord + `P`, not a bare `p` — `P` is the reference's TOGGLESPELLBOOK, and a dev
// doesn't get to squat on a game binding (decision 0585). The chord can't be mistaken for typed
// text, so unlike the old bare key it needs no chat-bar/EditBox gate.
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyP) {
if benilla_world::modkeys::dev_chord(&keys, KeyCode::KeyP) {
hud.visible = !hud.visible;
}
}
@@ -680,18 +628,18 @@ struct FpsJournal {
/// 0793, because nothing but a `MapChange` evicted anything (0729).
#[derive(bevy::ecs::system::SystemParam)]
struct JournalResidency<'w> {
mats: Res<'w, Assets<crate::terrain::WowModelMaterial>>,
mats: Res<'w, Assets<benilla_assets::materials::WowModelMaterial>>,
meshes: Res<'w, Assets<Mesh>>,
images: Res<'w, Assets<bevy::image::Image>>,
m2: Res<'w, Assets<benilla_assets::M2Model>>,
uv_reg: Res<'w, crate::doodad_anim::UvAnimMaterials>,
tint_reg: Res<'w, crate::doodad_anim::TintAnimMaterials>,
art: Res<'w, crate::art_scope::ArtCensus>,
uv_reg: Res<'w, benilla_world::doodad_anim::UvAnimMaterials>,
tint_reg: Res<'w, benilla_world::doodad_anim::TintAnimMaterials>,
art: Res<'w, benilla_world::art_scope::ArtCensus>,
/// The **view focus** — where art is actually being asked for. Distinct from the row's `x,y,z`,
/// which is the avatar: through a detached free-fly the body stands still while the camera covers
/// kilometres, so on that leg the position columns describe nothing that is happening. The
/// director's first run was exactly that leg, and reading it needed this column.
scope: Res<'w, crate::art_scope::ArtScopeState>,
scope: Res<'w, benilla_world::art_scope::ArtScopeState>,
}
fn journal_fps(
@@ -743,7 +691,7 @@ fn journal_fps(
residency.tint_reg.0.len(),
);
// The per-cache breakdown, in `ArtSlot::ALL` order — which IS the header's column order.
for slot in crate::art_scope::ArtSlot::ALL {
for slot in benilla_world::art_scope::ArtSlot::ALL {
line.push_str(&format!(",{}", residency.art.live(slot)));
}
line.push_str(&format!(",{}", residency.art.dropped_total()));
+11 -9
View File
@@ -13,12 +13,14 @@ use bevy::mesh::{Indices, MeshTag, PrimitiveTopology};
use bevy::prelude::*;
use bevy::render::render_resource::Buffer;
use crate::clouds::CloudMaterial;
use crate::model_render::{far_twin_of, model_material, zfill_material, MaterialCache, ShadeSel};
use crate::sky::SkyMaterial;
use crate::sun::{CelestialMaterial, StarMaterial};
use crate::terrain::{LiquidMaterial, WowModelMaterial};
use crate::wmo_sky::{WmoSkyboxExt, WmoSkyboxMaterial};
use benilla_assets::materials::{LiquidMaterial, WowModelMaterial};
use benilla_world::clouds::CloudMaterial;
use benilla_world::model_render::{
far_twin_of, model_material, zfill_material, MaterialCache, ShadeSel,
};
use benilla_world::sky::SkyMaterial;
use benilla_world::sun::{CelestialMaterial, StarMaterial};
use benilla_world::wmo_sky::{WmoSkyboxExt, WmoSkyboxMaterial};
use super::WarmRig;
@@ -473,7 +475,7 @@ pub(super) fn spawn_menagerie(
unlit: true,
alpha_mode: AlphaMode::Blend,
cull_mode: None,
depth_bias: crate::nameplates::NAMEPLATE_DEPTH_BIAS,
depth_bias: benilla_world::sky_order::Rung::NAMEPLATE,
..default()
});
spawn_lane_rig(
@@ -539,9 +541,9 @@ fn spawn_model_rig(
mat: &Handle<WowModelMaterial>,
) {
let tag = if skinned {
MeshTag(crate::mesh_tag::rig_bits(0) | crate::mesh_tag::alpha_bits(1.0))
MeshTag(benilla_world::mesh_tag::rig_bits(0) | benilla_world::mesh_tag::alpha_bits(1.0))
} else {
MeshTag(crate::mesh_tag::alpha_bits(1.0))
MeshTag(benilla_world::mesh_tag::alpha_bits(1.0))
};
let mut e = commands.spawn((
Mesh3d(mesh.clone()),

Some files were not shown because too many files have changed in this diff Show More