the bevy 0.19 excursion: graded 5.5 ms slower lane-for-lane, rolled back
- the full bevy 0.19 / avian 0.7 / egui 0.35 port landed and rendered at visual parity, then profiling matched the lanes honestly: +5.5 ms against 0.18 in our scenes, priced to three named buckets — so it was reverted the same day, with the pins mapped for a future release - back on 0.18 the client is faster than 0.18 ever ran here, keeping both performance days' wins - what the hunt left behind: the material-animation scan visits only rows that can be registry keys, and a capture that settles on an all-black image now says EMPTY instead of grading it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -861,6 +861,23 @@ fn drive_capture(
|
||||
watch.stable,
|
||||
);
|
||||
}
|
||||
// The emptiness tripwire (1373): stability proves the image stopped changing, not
|
||||
// that it contains anything — a lane that renders nothing is perfectly stable, and
|
||||
// the 1371 black era sailed through this gate without a word. The bytes are already
|
||||
// in hand; color channels only, since alpha is opaque even on a black frame.
|
||||
if watch.stable >= stable_frames() || capped {
|
||||
if let Some(px) = watch.prev.as_deref() {
|
||||
if px
|
||||
.chunks_exact(4)
|
||||
.all(|p| p[0] == 0 && p[1] == 0 && p[2] == 0)
|
||||
{
|
||||
error!(
|
||||
"capture: settled image is EMPTY — every color channel is zero; the \
|
||||
scene rendered nothing (1373)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if watch.stable < stable_frames() && !capped {
|
||||
Phase::Building(n + 1)
|
||||
} else if let Some((rw, rh)) = resize_request().filter(|_| !ctx.resized) {
|
||||
|
||||
@@ -29,6 +29,11 @@ use benilla_world::world_census::WorldCensus;
|
||||
/// (scenario=`live`), and exits.
|
||||
pub(crate) struct LiveFpsPlugin;
|
||||
|
||||
/// How long past `WOW_LIVE_FPS_AT` the probe waits for a world before declaring the run dead.
|
||||
/// Entry normally lands well inside `at` itself; 60 s of grace covers a cold-cache load without
|
||||
/// ever letting a stranded run ride out a harness timeout (see the `Waiting` arm).
|
||||
const BOOT_DEADLINE_SECS: f32 = 60.0;
|
||||
|
||||
impl Plugin for LiveFpsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let frames = std::env::var("WOW_LIVE_FPS")
|
||||
@@ -146,6 +151,19 @@ fn drive_live_fps(
|
||||
// (The un-occludable window that keeps the SETTLE phase from streaming the world at
|
||||
// ~1 fps is [`ProbeFocusPlugin`]'s now — every probe needs it, not just this one.)
|
||||
if time.elapsed_secs() < probe.at || self_player.is_empty() {
|
||||
// The boot deadline: entering the world takes seconds, and `at` already grants a
|
||||
// settle window on top — a run still worldless this far past it is stranded on a
|
||||
// glue screen (dead server, refused login the marker arms didn't catch) and every
|
||||
// second more is the 1371 sitting's dead wall-clock again. `FATAL` is the marker
|
||||
// leg.sh keys on.
|
||||
if time.elapsed_secs() > probe.at + BOOT_DEADLINE_SECS {
|
||||
error!(
|
||||
"live-fps: FATAL — still not in world {:.0}s past the probe delay; a measurement run with no world is dead. exiting",
|
||||
BOOT_DEADLINE_SECS
|
||||
);
|
||||
exit.write(AppExit::error());
|
||||
probe.phase = LiveFpsPhase::Done;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Ok(mut w) = windows.single_mut() {
|
||||
|
||||
@@ -277,6 +277,7 @@ fn apply_roster_policy(
|
||||
// (decision 1174's always-present run fact) — absent in every ordinary run, which is the
|
||||
// player answer and the one this screen was written for.
|
||||
rig: Option<Res<crate::run_mode::RigCharacter>>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
) {
|
||||
if !roster.env_read {
|
||||
roster.env_read = true;
|
||||
@@ -357,7 +358,18 @@ fn apply_roster_policy(
|
||||
info!("char select: WOW_CHAR={name} — fast path");
|
||||
send_pick(&mut roster, &pick, guid);
|
||||
}
|
||||
None => warn!("char select: WOW_CHAR={name} not on this account — showing roster"),
|
||||
None => {
|
||||
warn!("char select: WOW_CHAR={name} not on this account — showing roster");
|
||||
// A harness run (env creds, no smoke) can never pick a different row itself —
|
||||
// parked here it burns its whole wall-clock. Same marker the login arm uses,
|
||||
// same greppable verdict for leg.sh.
|
||||
if crate::run_mode::unattended_login()
|
||||
&& std::env::var_os("WOW_LOGIN_SMOKE").is_none()
|
||||
{
|
||||
error!("login: FATAL — WOW_CHAR={name} is not on this account; exiting");
|
||||
exit.write(AppExit::error());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
//! re-authenticates on its own takes the account back off whoever just displaced it.
|
||||
//!
|
||||
//! Module split: this file (state, policy, input, dialogs, the saved-account persistence),
|
||||
//! [`screen`] (the authored layout, transcribed from `AccountLogin.xml`).
|
||||
//! [`screen`] (the authored layout, transcribed from `AccountLogin.xml`), [`smoke`] (the
|
||||
//! `WOW_LOGIN_SMOKE` headless prover).
|
||||
|
||||
mod screen;
|
||||
mod smoke;
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -42,6 +44,7 @@ use crate::portrait::{GluePreview, GlueScene};
|
||||
use crate::sound::GlueSound;
|
||||
|
||||
pub(crate) use screen::LoginAction;
|
||||
pub(crate) use smoke::smoke_character;
|
||||
|
||||
/// The flat resubmit pacing after a transport failure with pending credentials (decision 0065's
|
||||
/// reconnect cadence, moved app-side by 0539 — the IO thread never sleeps).
|
||||
@@ -79,7 +82,7 @@ impl Plugin for LoginPlugin {
|
||||
)
|
||||
.chain()
|
||||
.run_if(in_state(ClientState::Login)),
|
||||
(debug_login_smoke, screen::debug_login_shot),
|
||||
(smoke::debug_login_smoke, screen::debug_login_shot),
|
||||
)
|
||||
.chain()
|
||||
.after(benilla_world::schedule::WorldStage::Net),
|
||||
@@ -205,8 +208,15 @@ fn drive_policy(
|
||||
mut stages: MessageReader<LoginStageMessage>,
|
||||
mut failures: MessageReader<LoginFailedMessage>,
|
||||
mut disconnects: MessageReader<DisconnectedMessage>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
// A harness run (env creds, and not the smoke — the smoke owns its own verdict) has nobody at
|
||||
// the keyboard: a login failure no resubmit can change leaves it parked on a dialog for its
|
||||
// whole wall-clock, and every retry a runner grants it is spent the same way. Those failures
|
||||
// exit non-zero instead, on one greppable marker — "login: FATAL" — that leg.sh keys on.
|
||||
let harness =
|
||||
crate::run_mode::unattended_login() && std::env::var_os("WOW_LOGIN_SMOKE").is_none();
|
||||
let empty = GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
|
||||
@@ -239,6 +249,10 @@ fn drive_policy(
|
||||
Err(why) => {
|
||||
error!("login: REFUSING the env fast path — {why} Set WOW_ALLOW_ACCOUNT=1 if the cross-account login is deliberate.");
|
||||
dialog.open_error(&why);
|
||||
// The refusal is deterministic — the slot is baked into the binary — so the
|
||||
// run can never get past this screen (the 1371 legs burned 3 × timeout on it).
|
||||
error!("login: FATAL — account guard refused the only credentials this run has; exiting");
|
||||
exit.write(AppExit::error());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +272,10 @@ fn drive_policy(
|
||||
warn!("login: {}", msg.reason);
|
||||
intent.clear();
|
||||
dialog.open_error(&msg.reason);
|
||||
if harness {
|
||||
error!("login: FATAL — terminal login failure with nobody at the keyboard ({}); exiting", msg.reason);
|
||||
exit.write(AppExit::error());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match msg.code {
|
||||
@@ -267,6 +285,10 @@ fn drive_policy(
|
||||
warn!("login: refused (code {code:#04x}) — {}", msg.reason);
|
||||
intent.clear();
|
||||
dialog.open_error(fail_text(strings, Some(code)));
|
||||
if harness {
|
||||
error!("login: FATAL — refused (code {code:#04x}) and no resubmit can change it; exiting");
|
||||
exit.write(AppExit::error());
|
||||
}
|
||||
}
|
||||
None if intent.announced => {
|
||||
warn!("login: {}", msg.reason);
|
||||
@@ -760,87 +782,6 @@ fn save_account(name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Instruments ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Split `WOW_LOGIN_SMOKE=user:pass[:Character]` into its three fields. **Three-way, once**: a
|
||||
/// `split_once(':')` here would hand the character name to the password, which is exactly what it
|
||||
/// did on this instrument's first live run (`result 0x04`, and the wrong-password path is what
|
||||
/// this smoke exists to prove, so it looked plausible).
|
||||
fn smoke_spec(spec: &str) -> (&str, &str, Option<&str>) {
|
||||
let mut parts = spec.splitn(3, ':');
|
||||
let user = parts.next().unwrap_or_default();
|
||||
let pass = parts.next().unwrap_or_default();
|
||||
let character = parts.next().map(str::trim).filter(|n| !n.is_empty());
|
||||
(user, pass, character)
|
||||
}
|
||||
|
||||
/// The optional third field — the body to enter as, or `None` for the plain
|
||||
/// reach-character-select smoke. [`crate::char_select`]'s roster policy is what actually answers
|
||||
/// the roster with it.
|
||||
pub(crate) fn smoke_character(spec: &str) -> Option<String> {
|
||||
smoke_spec(spec).2.map(str::to_string)
|
||||
}
|
||||
|
||||
/// The login smoke (`WOW_LOGIN_SMOKE=user:pass[:Character]`, decision 0539 §7): once the screen is
|
||||
/// up, submit those credentials through the real screen path; exit success on reaching CharSelect,
|
||||
/// log + exit failure on a refusal — the wrong-password path is provable headlessly.
|
||||
///
|
||||
/// **Naming a character keeps the run going into the world instead of exiting** (decision 1262).
|
||||
/// This is the only headless way to reach the world down the *player's* path: `WOW_CHAR` also
|
||||
/// enters the world, but setting it makes the run **unattended**
|
||||
/// ([`crate::run_mode::unattended_login`]), and an unattended run takes the other branch at every
|
||||
/// session decision — so the attended half was untestable without a person at the keyboard. The
|
||||
/// pick itself stays `char_select`'s (`apply_roster_policy` reads the third field the same way it
|
||||
/// reads `WOW_CHAR`); this only declines to exit. Pair with `WOW_PROBE_EXIT_AT` to bound the run.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn debug_login_smoke(
|
||||
state: Res<State<ClientState>>,
|
||||
mut intent: ResMut<LoginIntent>,
|
||||
submit: Res<LoginSubmit>,
|
||||
abandon: Res<LoginAbandon>,
|
||||
mut failures: MessageReader<LoginFailedMessage>,
|
||||
time: Res<Time>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
mut phase: Local<u8>,
|
||||
) {
|
||||
let Ok(spec) = std::env::var("WOW_LOGIN_SMOKE") else {
|
||||
return;
|
||||
};
|
||||
match *phase {
|
||||
0 if *state.get() == ClientState::Login && time.elapsed_secs() > 2.0 => {
|
||||
let (user, pass, _) = smoke_spec(&spec);
|
||||
info!("login-smoke: submitting as {user}");
|
||||
let (user, pass) = (user.to_string(), pass.to_string());
|
||||
send_login(&mut intent, &submit, &abandon, &user, &pass, true);
|
||||
*phase = 1;
|
||||
}
|
||||
1 => {
|
||||
if let Some(f) = failures.read().last() {
|
||||
error!("login-smoke: FAILED code={:?} reason={}", f.code, f.reason);
|
||||
// `WOW_LOGIN_SMOKE_HOLD=1`: keep running on a refusal instead of exiting — the
|
||||
// error dialog stays up, so a shot instrument can photograph it (the dialog is
|
||||
// otherwise unreachable headlessly; pair with `WOW_PROBE_EXIT_AT`).
|
||||
if std::env::var_os("WOW_LOGIN_SMOKE_HOLD").is_none() {
|
||||
exit.write(AppExit::error());
|
||||
}
|
||||
*phase = 2;
|
||||
} else if *state.get() == ClientState::CharSelect {
|
||||
match smoke_character(&spec) {
|
||||
Some(name) => {
|
||||
info!("login-smoke: reached character select — entering as {name}");
|
||||
}
|
||||
None => {
|
||||
info!("login-smoke: reached character select — done");
|
||||
exit.write(AppExit::Success);
|
||||
}
|
||||
}
|
||||
*phase = 2;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -956,23 +897,6 @@ mod tests {
|
||||
assert!(app.world().resource::<LoginDialog>().kind.is_none());
|
||||
}
|
||||
|
||||
/// The smoke spec splits three ways — the character name must never end up in the password.
|
||||
/// A `split_once` did exactly that on the instrument's first live run, and the refusal it
|
||||
/// produced (`0x04`) is indistinguishable from the wrong-password case this smoke exists to
|
||||
/// exercise.
|
||||
#[test]
|
||||
fn the_smoke_spec_splits_three_ways() {
|
||||
assert_eq!(
|
||||
smoke_spec("probe1:pprobe1:Probeone"),
|
||||
("probe1", "pprobe1", Some("Probeone"))
|
||||
);
|
||||
assert_eq!(smoke_spec("probe1:pprobe1"), ("probe1", "pprobe1", None));
|
||||
// A trailing colon names no body; a password may not itself contain one (the client caps
|
||||
// both fields at 16 letters and vmangos accounts have none).
|
||||
assert_eq!(smoke_spec("one:pass:"), ("one", "pass", None));
|
||||
assert_eq!(smoke_spec("one"), ("one", "", None));
|
||||
}
|
||||
|
||||
/// The code→string map quotes the client's own strings for the vmangos-verified rows.
|
||||
#[test]
|
||||
fn fail_text_maps_the_verified_codes() {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! The login smoke (`WOW_LOGIN_SMOKE`) — the headless prover for the screen's own submit path.
|
||||
//! Split from `mod.rs` when the file outgrew its budget; the policy and the screen stay there.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{send_login, LoginIntent};
|
||||
use crate::char_select::ClientState;
|
||||
use crate::net::{LoginAbandon, LoginFailedMessage, LoginSubmit};
|
||||
|
||||
/// Split `WOW_LOGIN_SMOKE=user:pass[:Character]` into its three fields. **Three-way, once**: a
|
||||
/// `split_once(':')` here would hand the character name to the password, which is exactly what it
|
||||
/// did on this instrument's first live run (`result 0x04`, and the wrong-password path is what
|
||||
/// this smoke exists to prove, so it looked plausible).
|
||||
fn smoke_spec(spec: &str) -> (&str, &str, Option<&str>) {
|
||||
let mut parts = spec.splitn(3, ':');
|
||||
let user = parts.next().unwrap_or_default();
|
||||
let pass = parts.next().unwrap_or_default();
|
||||
let character = parts.next().map(str::trim).filter(|n| !n.is_empty());
|
||||
(user, pass, character)
|
||||
}
|
||||
|
||||
/// The optional third field — the body to enter as, or `None` for the plain
|
||||
/// reach-character-select smoke. [`crate::char_select`]'s roster policy is what actually answers
|
||||
/// the roster with it.
|
||||
pub(crate) fn smoke_character(spec: &str) -> Option<String> {
|
||||
smoke_spec(spec).2.map(str::to_string)
|
||||
}
|
||||
|
||||
/// The login smoke (`WOW_LOGIN_SMOKE=user:pass[:Character]`, decision 0539 §7): once the screen is
|
||||
/// up, submit those credentials through the real screen path; exit success on reaching CharSelect,
|
||||
/// log + exit failure on a refusal — the wrong-password path is provable headlessly.
|
||||
///
|
||||
/// **Naming a character keeps the run going into the world instead of exiting** (decision 1262).
|
||||
/// This is the only headless way to reach the world down the *player's* path: `WOW_CHAR` also
|
||||
/// enters the world, but setting it makes the run **unattended**
|
||||
/// ([`crate::run_mode::unattended_login`]), and an unattended run takes the other branch at every
|
||||
/// session decision — so the attended half was untestable without a person at the keyboard. The
|
||||
/// pick itself stays `char_select`'s (`apply_roster_policy` reads the third field the same way it
|
||||
/// reads `WOW_CHAR`); this only declines to exit. Pair with `WOW_PROBE_EXIT_AT` to bound the run.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn debug_login_smoke(
|
||||
state: Res<State<ClientState>>,
|
||||
mut intent: ResMut<LoginIntent>,
|
||||
submit: Res<LoginSubmit>,
|
||||
abandon: Res<LoginAbandon>,
|
||||
mut failures: MessageReader<LoginFailedMessage>,
|
||||
time: Res<Time>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
mut phase: Local<u8>,
|
||||
) {
|
||||
let Ok(spec) = std::env::var("WOW_LOGIN_SMOKE") else {
|
||||
return;
|
||||
};
|
||||
match *phase {
|
||||
0 if *state.get() == ClientState::Login && time.elapsed_secs() > 2.0 => {
|
||||
let (user, pass, _) = smoke_spec(&spec);
|
||||
info!("login-smoke: submitting as {user}");
|
||||
let (user, pass) = (user.to_string(), pass.to_string());
|
||||
send_login(&mut intent, &submit, &abandon, &user, &pass, true);
|
||||
*phase = 1;
|
||||
}
|
||||
1 => {
|
||||
if let Some(f) = failures.read().last() {
|
||||
error!("login-smoke: FAILED code={:?} reason={}", f.code, f.reason);
|
||||
// `WOW_LOGIN_SMOKE_HOLD=1`: keep running on a refusal instead of exiting — the
|
||||
// error dialog stays up, so a shot instrument can photograph it (the dialog is
|
||||
// otherwise unreachable headlessly; pair with `WOW_PROBE_EXIT_AT`).
|
||||
if std::env::var_os("WOW_LOGIN_SMOKE_HOLD").is_none() {
|
||||
exit.write(AppExit::error());
|
||||
}
|
||||
*phase = 2;
|
||||
} else if *state.get() == ClientState::CharSelect {
|
||||
match smoke_character(&spec) {
|
||||
Some(name) => {
|
||||
info!("login-smoke: reached character select — entering as {name}");
|
||||
}
|
||||
None => {
|
||||
info!("login-smoke: reached character select — done");
|
||||
exit.write(AppExit::Success);
|
||||
}
|
||||
}
|
||||
*phase = 2;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The smoke spec splits three ways — the character name must never end up in the password.
|
||||
/// A `split_once` did exactly that on the instrument's first live run, and the refusal it
|
||||
/// produced (`0x04`) is indistinguishable from the wrong-password case this smoke exists to
|
||||
/// exercise.
|
||||
#[test]
|
||||
fn the_smoke_spec_splits_three_ways() {
|
||||
assert_eq!(
|
||||
smoke_spec("probe1:pprobe1:Probeone"),
|
||||
("probe1", "pprobe1", Some("Probeone"))
|
||||
);
|
||||
assert_eq!(smoke_spec("probe1:pprobe1"), ("probe1", "pprobe1", None));
|
||||
// A trailing colon names no body; a password may not itself contain one (the client caps
|
||||
// both fields at 16 letters and vmangos accounts have none).
|
||||
assert_eq!(smoke_spec("one:pass:"), ("one", "pass", None));
|
||||
assert_eq!(smoke_spec("one"), ("one", "", None));
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,14 @@ pub fn sample_mat_anim(
|
||||
/// seq-band loop per play (arm cursor) and a gseq loop per instance (attach anchor,
|
||||
/// `gseq-anchor.md`), but one uniform per material cannot phase per instance — meaningless for a
|
||||
/// seamless scroll either way. Entries drop when the material asset does.
|
||||
/// The scan marker (1375): a part whose material can ever be a [`UvAnimMaterials`]/
|
||||
/// [`TintAnimMaterials`] key — inserted at spawn, beside the registration itself, and only for a
|
||||
/// loop with a real period (a period-0 constant is fully served by its material seed). Without
|
||||
/// it, [`tick_anim_materials`]'s draw scan visited every `WowModelMaterial` row in the world
|
||||
/// (~48k at Stormwind) to find the placed instances of ~113 animated models.
|
||||
#[derive(bevy::prelude::Component)]
|
||||
pub struct AnimMatPart;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct UvAnimMaterials(
|
||||
pub std::collections::HashMap<
|
||||
@@ -264,16 +272,21 @@ pub struct UvAnimMaterials(
|
||||
/// It over-includes (a part left `Inherited` under a hidden ancestor counts as drawn), which is the
|
||||
/// safe direction: an extra write costs a frame's uniform upload, a missed one would freeze a
|
||||
/// visible scroll.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn tick_anim_materials(
|
||||
time: Res<Time>,
|
||||
real: Res<Time<bevy::time::Real>>,
|
||||
mut uv_reg: ResMut<UvAnimMaterials>,
|
||||
mut tint_reg: ResMut<TintAnimMaterials>,
|
||||
mut materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
|
||||
parts: Query<(
|
||||
&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>,
|
||||
&Visibility,
|
||||
)>,
|
||||
parts: Query<
|
||||
(
|
||||
&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>,
|
||||
&Visibility,
|
||||
),
|
||||
With<AnimMatPart>,
|
||||
>,
|
||||
twins: Res<crate::model_render::FarSideTwins>,
|
||||
mut drawn: Local<
|
||||
bevy::platform::collections::HashSet<AssetId<benilla_assets::materials::WowModelMaterial>>,
|
||||
>,
|
||||
@@ -287,7 +300,12 @@ pub(super) fn tick_anim_materials(
|
||||
drawn.clear();
|
||||
for (mat, vis) in &parts {
|
||||
if *vis != Visibility::Hidden {
|
||||
drawn.insert(mat.id());
|
||||
let id = mat.id();
|
||||
// A far-classified instance carries the far TWIN's id — never a registry key. Count
|
||||
// it as its near identity, or a batch whose every instance sits beyond the water
|
||||
// plane marks its near entry not-drawn and freezes both variants' scroll (1375; the
|
||||
// twin itself keeps getting written by `classify_water_side`'s Modified mirror).
|
||||
drawn.insert(twins.near_of(id).unwrap_or(id));
|
||||
}
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
@@ -298,23 +316,45 @@ pub(super) fn tick_anim_materials(
|
||||
if !drawn.contains(id) {
|
||||
return materials.contains(*id);
|
||||
}
|
||||
let Some(mat) = materials.get_mut(*id) else {
|
||||
return false; // material unloaded with its cache — drop the entry
|
||||
};
|
||||
let uv = anim.sample(now);
|
||||
mat.extension.sun_scale.z = uv[0];
|
||||
mat.extension.sun_scale.w = uv[1];
|
||||
// The write gate (`write_gated`'s reasoning, 1375): a `get_mut` deref alone marks the
|
||||
// asset Modified — a uniform re-upload, a bind-group rebuild on the non-bindless Metal
|
||||
// path, and the whole-population `AssetChanged` walks — so an unchanged value must be
|
||||
// decided on the immutable view and never deref. Quantized because the input drifts
|
||||
// continuously: 1/4096 of a texture repeat is below what any face can show.
|
||||
let (zq, wq) = (
|
||||
benilla_assets::quantize(uv[0], 4096.0),
|
||||
benilla_assets::quantize(uv[1], 4096.0),
|
||||
);
|
||||
match materials.get(*id) {
|
||||
None => return false, // material unloaded with its cache — drop the entry
|
||||
Some(m) if m.extension.sun_scale.z == zq && m.extension.sun_scale.w == wq => {
|
||||
return true;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
let Some(mat) = materials.get_mut(*id) else {
|
||||
return false;
|
||||
};
|
||||
mat.extension.sun_scale.z = zq;
|
||||
mat.extension.sun_scale.w = wq;
|
||||
true
|
||||
});
|
||||
tint_reg.0.retain(|id, anim| {
|
||||
if !drawn.contains(id) {
|
||||
return materials.contains(*id);
|
||||
}
|
||||
let rgb = benilla_assets::quant255(anim.sample(now));
|
||||
let tint = bevy::math::Vec4::new(rgb[0], rgb[1], rgb[2], 1.0);
|
||||
match materials.get(*id) {
|
||||
None => return false,
|
||||
Some(m) if m.extension.tint == tint => return true,
|
||||
Some(_) => {}
|
||||
}
|
||||
let Some(mat) = materials.get_mut(*id) else {
|
||||
return false;
|
||||
};
|
||||
let rgb = anim.sample(now);
|
||||
mat.extension.tint = bevy::math::Vec4::new(rgb[0], rgb[1], rgb[2], 1.0);
|
||||
mat.extension.tint = tint;
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ mod lazy;
|
||||
mod mat_anim;
|
||||
pub(crate) use lazy::{LazyRig, SkinnedTwin};
|
||||
use mat_anim::tick_anim_materials;
|
||||
pub use mat_anim::{playing_seq, sample_mat_anim, MatAnim, TintAnimMaterials, UvAnimMaterials};
|
||||
pub use mat_anim::{
|
||||
playing_seq, sample_mat_anim, AnimMatPart, MatAnim, TintAnimMaterials, UvAnimMaterials,
|
||||
};
|
||||
|
||||
/// The client's single global `rand()` stream — the MSVC LCG at `0x7400e5`, returning `[0, 32767]`
|
||||
/// (wow-re `doodad-anim-host.md` §5, decision 0768). Every doodad's variation roll draws from **one**
|
||||
|
||||
@@ -684,6 +684,16 @@ impl FarSideTwins {
|
||||
) -> Option<&Handle<WowModelMaterial>> {
|
||||
self.to_far.get(&near.id())
|
||||
}
|
||||
|
||||
/// The near identity of a far twin, if `id` is one — the mat-anim draw scan asks, because
|
||||
/// its registry keys are always near ids while a far-classified instance carries the twin's
|
||||
/// (1375).
|
||||
pub(crate) fn near_of(
|
||||
&self,
|
||||
id: AssetId<WowModelMaterial>,
|
||||
) -> Option<AssetId<WowModelMaterial>> {
|
||||
self.to_near.get(&id).map(bevy::asset::Handle::id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The far-axis compose every material-handle OWNER applies at its own write site: the handle it
|
||||
|
||||
@@ -263,13 +263,16 @@ pub fn spawn_model_entities(
|
||||
};
|
||||
// Register both variants' materials for the per-frame UV scroll (idempotent per material —
|
||||
// every instance of the batch lands on the same deduped handle).
|
||||
if let Some(anim) = &sub.uv_anim {
|
||||
// A period-0 loop is a constant the material seed already wrote (`sample(0.0)` IS its
|
||||
// forever value) — registering it would buy a per-frame re-write of the same number
|
||||
// (1375), so only a real loop enters the registry.
|
||||
if let Some(anim) = sub.uv_anim.as_ref().filter(|a| a.period > 0.0) {
|
||||
uv_reg.0.entry(cutout.id()).or_insert_with(|| anim.clone());
|
||||
uv_reg.0.entry(blend.id()).or_insert_with(|| anim.clone());
|
||||
}
|
||||
// …and for the per-frame tint re-sample (the animated M2Color RGB, same shared clock —
|
||||
// the same invisible seq-band phase divergence as the UV scroll, recorded there).
|
||||
if let Some(anim) = &sub.rgb_anim {
|
||||
if let Some(anim) = sub.rgb_anim.as_ref().filter(|a| a.period > 0.0) {
|
||||
tint_reg
|
||||
.0
|
||||
.entry(cutout.id())
|
||||
@@ -398,6 +401,16 @@ pub fn spawn_model_entities(
|
||||
// same way since the 0355 re-lane gave the probe-slot payload its own alpha field (bits
|
||||
// 0..=15) — the 0130-era "partial alpha can't show on the colour payload" deferral is
|
||||
// collected (bug B30: the Undercity lightshaft's authored 0.10/0.05 dimming).
|
||||
// The scan marker (1375), same predicate as the registration above — so a marked row
|
||||
// always has a registry key to match, and `tick_anim_materials` never has to visit an
|
||||
// unmarked one.
|
||||
if sub.uv_anim.as_ref().is_some_and(|a| a.period > 0.0)
|
||||
|| sub.rgb_anim.as_ref().is_some_and(|a| a.period > 0.0)
|
||||
{
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(crate::doodad_anim::AnimMatPart);
|
||||
}
|
||||
if let Some(anim) = &sub.alpha_anim {
|
||||
commands
|
||||
.entity(entity)
|
||||
|
||||
@@ -220,5 +220,35 @@ impl Plugin for WorldFoundation {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// Direct draws on EVERY camera — the default, not a knob (1374/1376). Bevy's indirect
|
||||
// lane is a per-draw indirect encode loop on Metal (no MULTI_DRAW_INDIRECT) plus the GPU
|
||||
// preprocessing dispatches, and the 1374 bracket priced it ~4.3 cpu_ms at LBRS on 0.18 —
|
||||
// shipped unclaimed for the campaign's whole run because 1364's knob under-measured it
|
||||
// (the UI Camera2d leak, 1370). `WOW_INDIRECT=1` restores the upstream default for
|
||||
// re-pricing. 1370's note that the lever must cover every camera, not just the world's,
|
||||
// is why this is a sweep.
|
||||
if std::env::var_os("WOW_INDIRECT").is_none() {
|
||||
app.add_systems(bevy::app::Update, force_direct_draws);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert [`NoIndirectDrawing`] on any camera that lacks it — see the note at the registration
|
||||
/// site. Runs every frame so late-spawned cameras (portrait booths, the glue booth) are covered;
|
||||
/// the query is empty in steady state.
|
||||
fn force_direct_draws(
|
||||
mut commands: Commands,
|
||||
cams: Query<
|
||||
Entity,
|
||||
(
|
||||
With<bevy::camera::Camera>,
|
||||
Without<bevy::render::view::NoIndirectDrawing>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
for e in &cams {
|
||||
commands
|
||||
.entity(e)
|
||||
.insert(bevy::render::view::NoIndirectDrawing);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user