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:
Generated
+31
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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`.
|
||||
//!
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)>,
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-5
@@ -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>,
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+71
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::assets::WorldAssets;
|
||||
use benilla_assets::WorldAssets;
|
||||
|
||||
use super::add_material::AddUiMaterial;
|
||||
use super::backdrop::BackdropEdges;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', " ⏎ ")),
|
||||
);
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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:#}");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user