a chest fades out, a held key survives an alt-tab, the blits go
- a looted chest fades out over two seconds on the reference's own smoothstep, from the alpha it is showing, instead of blinking away; every destroy and out-of-range exit takes the fade (2198, 2203) - opening a chest goes through the cast ladder, so mashing right-click no longer cancels the running cast bar with "Another action is in progress" (2199) - a held key resumes after an alt-tab: a repeat is a key we already have down, not the OS repeat bit, as the reference classifies it (2204) - both upscaling blits are skipped: the world's combine and the UI's gamma decode write their camera's target themselves, pixel-identical, and the costliest pass on the lab's RTX is gone (2206) - WOW_GPU_PASSES prints the journal's per-pass GPU split beside each row (2205) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,19 @@ pub(crate) struct BindingsState {
|
||||
/// Accumulated analog amount per host command this frame (wheel notches; a key press adds
|
||||
/// the reference's own 1.0 step) — the camera zoom's input.
|
||||
amounts: Vec<(Cmd, f32)>,
|
||||
/// **The pressed-key set — what makes a press a REPEAT** (decision 2204). The reference
|
||||
/// classifies auto-repeat off its own list of keys it believes are down (`0x4248b3`), *not*
|
||||
/// off the Win32 `lParam` repeat bit — so the OS bit is not what this reads either.
|
||||
///
|
||||
/// Base keys, normalized ([`chord::normalize_key`]) exactly as [`latched`](Self::latched) is,
|
||||
/// so `NUMPADENTER` repeating under a held `ENTER` is the repeat it looks like. Reconciled
|
||||
/// against `ButtonInput` at the top of every pass, which is what wipes it on a window
|
||||
/// activation change for free — and why a key still held across an alt-tab starts running
|
||||
/// again on the way back, as it does in the reference.
|
||||
///
|
||||
/// **Keyboard only, and the type says so.** The mouse plane is read as edges
|
||||
/// (`just_pressed`), which cannot repeat, so it needs no classification of its own.
|
||||
down: Vec<KeyCode>,
|
||||
}
|
||||
|
||||
impl BindingsState {
|
||||
@@ -534,6 +547,24 @@ fn latch_and_dispatch(
|
||||
// at the bottom of this system turns that into real releases (the loading cover reaches it the
|
||||
// same way, through `loading_screen::input`'s `swallow`).
|
||||
//
|
||||
// ── The pressed-key reconcile ── the reference keeps its own list of keys it believes are
|
||||
// down and calls a press a REPEAT when the key is already on it (`0x4248b3`) — it never reads
|
||||
// the Win32 `lParam` repeat bit. Ours is reconciled against bevy's button planes here, before
|
||||
// this frame's messages are read, which buys two things at once (decision 2204):
|
||||
//
|
||||
// - a release the window never saw drops off the list, exactly as it drops off `latched` in
|
||||
// the sweep below — the list cannot go stale and start swallowing real presses;
|
||||
// - **WM_ACTIVATE's wipe of the pressed-key set comes for free.** `KeyboardFocusLost` →
|
||||
// `ButtonInput::release_all` empties the plane, so a key still physically held across an
|
||||
// alt-tab is no longer on the list when we come back, and its next auto-repeat is therefore
|
||||
// a fresh DOWN that re-latches. That is the reference's own behaviour and not a happy
|
||||
// accident: `0x514490` cleared the direction bits on the way out, and `0x424790`'s wipe of
|
||||
// the pressed-key set is what lets the first repeat put them back — so you resume running
|
||||
// without lifting the key.
|
||||
state
|
||||
.down
|
||||
.retain(|&kc| physically_down(BindKey::Key(kc), &keys, &buttons));
|
||||
|
||||
// ── Keyboard ── press edges latch/fire (exact-modifier chord match, no repeats, gated on the
|
||||
// two ownership terms and the capture arm); release edges unlatch and fire the runOnUp up-half.
|
||||
let typing = capture.typing;
|
||||
@@ -557,7 +588,14 @@ fn latch_and_dispatch(
|
||||
// world map's fullscreen `OnKeyDown`, a cinematic, the stack-split spinner. Per
|
||||
// key, so the map eating its own `M` leaves every other binding alone.
|
||||
let eaten = capture.consumed.contains(&ev.key_code);
|
||||
if armed || (typing && !arrow_exempt) || eaten || sup || ev.repeat {
|
||||
// **A repeat is a key we already believe is down** — the reference's own test, not
|
||||
// the OS's repeat bit (2204). Registered before the gates below, so a key held
|
||||
// through a focused chat box is still "down" and its repeats stay repeats.
|
||||
let repeat = state.down.contains(&key);
|
||||
if !repeat {
|
||||
state.down.push(key);
|
||||
}
|
||||
if armed || (typing && !arrow_exempt) || eaten || sup || repeat {
|
||||
continue;
|
||||
}
|
||||
if state.latched.iter().any(|&(k, _)| k == BindKey::Key(key)) {
|
||||
@@ -581,6 +619,7 @@ fn latch_and_dispatch(
|
||||
}
|
||||
}
|
||||
ButtonState::Released => {
|
||||
state.down.retain(|&kc| kc != key);
|
||||
release(
|
||||
&mut state,
|
||||
&mut script,
|
||||
@@ -699,14 +738,7 @@ fn latch_and_dispatch(
|
||||
// keyboard focus reaches here, and that is the point (2196).
|
||||
let mut stuck: Vec<BindKey> = Vec::new();
|
||||
for &(k, _) in &state.latched {
|
||||
let up = match k {
|
||||
BindKey::Key(kc) => {
|
||||
!keys.pressed(kc) && !(kc == KeyCode::Enter && keys.pressed(KeyCode::NumpadEnter))
|
||||
}
|
||||
BindKey::Mouse(b) => !buttons.pressed(b),
|
||||
BindKey::WheelUp | BindKey::WheelDown => true,
|
||||
};
|
||||
if up && !stuck.contains(&k) {
|
||||
if !physically_down(k, &keys, &buttons) && !stuck.contains(&k) {
|
||||
stuck.push(k);
|
||||
}
|
||||
}
|
||||
@@ -738,6 +770,31 @@ impl BindingDispatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this base key physically down right now, per bevy's own button planes?
|
||||
///
|
||||
/// The one place that question is answered, because two callers ask it for two reasons and an
|
||||
/// answer that drifted between them would be a stuck latch on one side or a lost repeat on the
|
||||
/// other: the [stuck-latch sweep](latch_and_dispatch) (a release the window never saw) and the
|
||||
/// pressed-key reconcile beside it ([`BindingsState::down`]).
|
||||
///
|
||||
/// `ENTER` is the case that needs saying: [`chord::normalize_key`] folds `NUMPADENTER` into it, so
|
||||
/// the normalized key is down while *either* physical key is.
|
||||
fn physically_down(
|
||||
key: BindKey,
|
||||
keys: &ButtonInput<KeyCode>,
|
||||
buttons: &ButtonInput<MouseButton>,
|
||||
) -> bool {
|
||||
match key {
|
||||
BindKey::Key(KeyCode::Enter) => {
|
||||
keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter)
|
||||
}
|
||||
BindKey::Key(kc) => keys.pressed(kc),
|
||||
BindKey::Mouse(b) => buttons.pressed(b),
|
||||
// A notch is a press and a release in one frame; it is never "held".
|
||||
BindKey::WheelUp | BindKey::WheelDown => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One matching press: latch the held kinds, run/fire by class.
|
||||
fn press(
|
||||
state: &mut BindingsState,
|
||||
@@ -892,6 +949,11 @@ mod tests {
|
||||
fn release_key(app: &mut App, k: KeyCode) {
|
||||
key(app, k, bevy::input::ButtonState::Released, false);
|
||||
}
|
||||
/// A press the OS has flagged as auto-repeat. Whether it *acts* as one is ours to decide
|
||||
/// (2204), which is the whole point of the tests that use this.
|
||||
fn repeat_key(app: &mut App, k: KeyCode) {
|
||||
key(app, k, bevy::input::ButtonState::Pressed, true);
|
||||
}
|
||||
fn state(app: &App) -> &BindingsState {
|
||||
app.world().resource::<BindingsState>()
|
||||
}
|
||||
@@ -1299,6 +1361,61 @@ mod tests {
|
||||
assert!(app.world().resource::<BindingsState>().latched.is_empty());
|
||||
}
|
||||
|
||||
/// **Auto-repeat is classified off OUR pressed-key set, not the OS bit** (2204), and the
|
||||
/// window-deactivate wipe is what makes a held key resume on the way back.
|
||||
///
|
||||
/// Three laws in one run, because they are one mechanism:
|
||||
///
|
||||
/// 1. a repeat of a key we already believe is down does NOT re-run its binding — the
|
||||
/// reference's own test (`0x4248b3`), which matters for the kinds that never latch (a held
|
||||
/// SPACE must jump once, not every 33 ms);
|
||||
/// 2. a press carrying `repeat: true` that we do NOT have down is a fresh DOWN. The OS bit is
|
||||
/// not the authority — the reference reads its own list and never the Win32 `lParam` bit;
|
||||
/// 3. so after a window deactivate — bevy's `KeyboardFocusLost` → `release_all`, which is our
|
||||
/// `0x514490`+`0x424790` pair — the first repeat of a key still physically held re-latches,
|
||||
/// and **you start running again without lifting the key**, as the reference does.
|
||||
#[test]
|
||||
fn a_repeat_is_a_key_we_already_have_down_so_a_held_key_resumes_after_an_alt_tab() {
|
||||
let mut app = harness();
|
||||
press_key(&mut app, KeyCode::Space);
|
||||
app.update();
|
||||
assert!(state(&app).fired(cmd::JUMP), "the first press jumps");
|
||||
|
||||
// (1) A repeat of a key we have down fires nothing — JUMP never latches, so the
|
||||
// pressed-key set is the only thing standing between a held SPACE and a jump per frame.
|
||||
repeat_key(&mut app, KeyCode::Space);
|
||||
app.update();
|
||||
assert!(
|
||||
!state(&app).fired(cmd::JUMP),
|
||||
"a repeat of a key already down is not a press"
|
||||
);
|
||||
|
||||
// Movement, so the resume below has something to observe.
|
||||
press_key(&mut app, KeyCode::KeyW);
|
||||
app.update();
|
||||
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
|
||||
|
||||
// (3) The window is deactivated. Bevy empties the keyboard plane, which unlatches through
|
||||
// the stuck-latch sweep AND empties our pressed-key set — the reference's two wipes.
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.release_all();
|
||||
app.update();
|
||||
assert!(
|
||||
!state(&app).pressed(cmd::MOVE_FORWARD),
|
||||
"the deactivate releases the direction bits (0x514490)"
|
||||
);
|
||||
|
||||
// (2)+(3) Back in the window, still holding W. The OS calls this a repeat; we do not have
|
||||
// the key down any more, so it is a fresh press — and you run again without lifting it.
|
||||
repeat_key(&mut app, KeyCode::KeyW);
|
||||
app.update();
|
||||
assert!(
|
||||
state(&app).pressed(cmd::MOVE_FORWARD),
|
||||
"the first repeat after re-activation re-latches (2204)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_commands_latch_across_frames_and_release_per_base_key() {
|
||||
let mut app = harness();
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
//! panel, and one `apply_foo` system — no plumbing changes. (Weather is the worked example.)
|
||||
//!
|
||||
//! Rendering uses bevy_egui's manual-context mode: auto-creation is disabled in [`DebugPanelPlugin`]
|
||||
//! and a dedicated full-window overlay camera composites egui over the 3D scene (alpha-blended, no
|
||||
//! clear). See bevy_egui's `side_panel` example.
|
||||
//! and a dedicated full-window overlay camera composites egui over the finished frame (a transparent
|
||||
//! canvas, premultiplied over the swapchain). See bevy_egui's `side_panel` example.
|
||||
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
use bevy::camera::CameraOutputMode;
|
||||
@@ -308,11 +308,27 @@ fn spawn_egui_camera(mut commands: Commands) {
|
||||
RenderLayers::none(),
|
||||
Camera {
|
||||
order: 2,
|
||||
// **An overlay composites only its own pixels** — the law the player-UI camera's
|
||||
// clear already states. egui paints onto a transparent canvas, and the output blit
|
||||
// lays that canvas over the finished frame in the swapchain. bevy_egui's own pass
|
||||
// blends PREMULTIPLIED (`egui::Color32` is premultiplied, and its pipeline says so),
|
||||
// so the canvas holds premultiplied colour with coverage in alpha, and the blit has
|
||||
// to compose it as such — `SrcAlpha` would weight it by alpha twice.
|
||||
//
|
||||
// It used to load the shared main texture instead (`ClearColorConfig::None`), which
|
||||
// happened to hold the player-UI camera's decoded frame: two cameras on one window
|
||||
// share bevy's main textures, and that camera's decode flipped them. The blit then
|
||||
// re-emitted the whole frame over itself. Since decision 2206 the player-UI camera
|
||||
// writes the swapchain directly and leaves its main texture un-decoded, so an
|
||||
// overlay that loaded it would present the UI ~2.2× bright whenever the panel was
|
||||
// open. Clearing is what an overlay should have done all along; and, like the
|
||||
// player-UI camera, it never touches the world image, so writeback is off.
|
||||
output_mode: CameraOutputMode::Write {
|
||||
blend_state: Some(BlendState::ALPHA_BLENDING),
|
||||
blend_state: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||||
clear_color: ClearColorConfig::None,
|
||||
},
|
||||
clear_color: ClearColorConfig::None,
|
||||
clear_color: ClearColorConfig::Custom(Color::NONE),
|
||||
msaa_writeback: bevy::camera::MsaaWriteback::Off,
|
||||
..default()
|
||||
},
|
||||
));
|
||||
|
||||
@@ -74,6 +74,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::creature_anim::{advance_track, scan_events, select, AnimSoundEvent};
|
||||
use crate::net::{GuidIndex, ObjectStore};
|
||||
use benilla_world::model_fade::DespawnFade;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
/// `GO_STATE_ACTIVE` (vmangos `GOState`) — the **open** state (door swung, chest lid up). Passable.
|
||||
@@ -581,10 +582,18 @@ fn arm_despawn_anim(mut gos: Query<&mut GoAnim, Changed<DespawnAnimAnnounced>>)
|
||||
///
|
||||
/// Runs AFTER [`drive_go_anim`], and that ordering is load-bearing in both directions. On the
|
||||
/// arming frame the drive has just set [`GoAnim::transient`], so the object is held; on the frame
|
||||
/// [`retire_transient_anim`] clears it (the window ended) nothing re-arms 157, so the object pops.
|
||||
/// [`retire_transient_anim`] clears it (the window ended) nothing re-arms 157, so the release runs.
|
||||
/// An entity that never armed anything — no [`GoAnim`], a model that doesn't author 157 — fails the
|
||||
/// test on its very first pass and pops the same frame, which is today's behaviour for everything
|
||||
/// test on its very first pass and is released the same frame, which is the case for everything
|
||||
/// that isn't an egg.
|
||||
///
|
||||
/// **"Released" is a fade, not a pop** (decision 2198). What the deferred `0x464920` runs into is
|
||||
/// the base OnDeactivate `0x6145e0`, which hands the object's model to the `SWModelFadeout`
|
||||
/// scheduler `0x672df0` on its way out — so the object stops existing while its model keeps
|
||||
/// drawing and ramps to zero. A looted chest whose static model authors no `Despawn` sequence is
|
||||
/// exactly this path with no animation in front of it, and popping it was the report that found
|
||||
/// the missing hop. [`DespawnFade`] is that hand-off; it arms once and drives itself, so the pin
|
||||
/// comes off with it and this query stops matching.
|
||||
fn release_despawn_pin(
|
||||
mut commands: Commands,
|
||||
pinned: Query<(Entity, Option<&GoAnim>), With<PendingDestroy>>,
|
||||
@@ -593,7 +602,10 @@ fn release_despawn_pin(
|
||||
if go.is_some_and(|g| g.transient.is_some_and(|t| t.id == ANIM_DESPAWN)) {
|
||||
continue;
|
||||
}
|
||||
commands.entity(e).try_despawn();
|
||||
commands
|
||||
.entity(e)
|
||||
.try_remove::<PendingDestroy>()
|
||||
.try_insert(DespawnFade::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1627,17 +1639,23 @@ mod tests {
|
||||
advance(&mut app, 2700);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().get_entity(go).is_err(),
|
||||
"the window ended — the pin drops and the deferred destroy runs"
|
||||
app.world().get::<DespawnFade>(go).is_some(),
|
||||
"the window ended — the pin drops and the deferred destroy hands the model to the fade"
|
||||
);
|
||||
assert!(
|
||||
app.world().get::<PendingDestroy>(go).is_none(),
|
||||
"and the pin comes off with it, so the release stops re-arming the fade"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same rule: a model that doesn't author 157 (or an object with no
|
||||
/// animation machine at all — a totem, a DynamicObject, both of which `SendObjectDeSpawnAnim`
|
||||
/// also fires for) must keep today's **instant pop**. Nothing arms, so the pin never forms and
|
||||
/// the release runs on its first pass.
|
||||
/// also fires for) has nothing to play, so the pin never forms and the release runs on its
|
||||
/// first pass — straight to the fade, with no animation in front of it. **This is the looted
|
||||
/// chest** (decision 2198): `DeadmineCargoBoxes.m2` is a fully static model, so the announced
|
||||
/// despawn resolves to nothing and the teardown fade is the whole observable.
|
||||
#[test]
|
||||
fn an_unownable_despawn_anim_still_pops_instantly() {
|
||||
fn an_unownable_despawn_anim_goes_straight_to_the_fade() {
|
||||
let (mut app, go) = crate_app(&[]); // the crate family only — no 157
|
||||
app.update();
|
||||
|
||||
@@ -1646,8 +1664,8 @@ mod tests {
|
||||
.insert((DespawnAnimAnnounced, PendingDestroy));
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().get_entity(go).is_err(),
|
||||
"nothing to play ⇒ the ordinary instant destroy, same frame"
|
||||
app.world().get::<DespawnFade>(go).is_some(),
|
||||
"nothing to play ⇒ the teardown fade, same frame — never a pop"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -458,18 +458,31 @@ pub(super) fn object_destroyed(
|
||||
index: &mut GuidIndex,
|
||||
items: &mut Items,
|
||||
) {
|
||||
// The reference frees it on the spot, an **instant pop** with no fade-out
|
||||
// (byte-verified, wow-re selection-death-clear RE — the only lifecycle fade is the
|
||||
// appear-fade on create). A respawn then streams in as a fresh entity. If it was the
|
||||
// target, the ring's gone-entity branch clears the selection next frame — the
|
||||
// reference's teardown does the same (and sends the same `CMSG_SET_SELECTION 0`).
|
||||
// **The object goes away by the same fade its stream-out takes** — `DespawnFade`, not a raw
|
||||
// despawn (decision 2198). The reference's object-manager destroy hands the object's *model*
|
||||
// to the `SWModelFadeout` scheduler on the way out: the base OnDeactivate `0x6145e0` (vtable
|
||||
// slot 1, which `0x464920` invokes on **both** DESTROY and OUT_OF_RANGE) unbinds the scene
|
||||
// handle and calls `0x672df0`, which keeps the detached model drawing and ramps its alpha to
|
||||
// zero. So "the object is freed instantly" and "the model fades" are both true, one hop
|
||||
// apart — and the paragraph that used to stand here read the first as the whole story,
|
||||
// because it went looking for a `FadeTo` on the OBJECT and found none.
|
||||
//
|
||||
// That is why a looted chest pops: the chest's model authors no `Despawn` sequence, so the
|
||||
// announced despawn animation produces nothing and there was nothing left but the pop. The
|
||||
// fade is not the animation; it is what every teardown does underneath it.
|
||||
//
|
||||
// The guid leaves the index either way — to the server it no longer exists, and a respawn
|
||||
// streams in as a fresh entity that fades in over the top. If it was the target, the ring's
|
||||
// gone-entity branch clears the selection next frame, the same as the reference's teardown
|
||||
// (which sends the same `CMSG_SET_SELECTION 0`).
|
||||
//
|
||||
// …*unless the object is pinned* — `0x464920` on a still-pinned object only sets the
|
||||
// pending-destroy bit and returns, and the real free waits for the last pin to drop (wow-re
|
||||
// `go-display-sound-events.md` §6d). The one pin benilla takes is the despawn animation
|
||||
// announced a moment earlier by `SMSG_GAMEOBJECT_DESPAWN_ANIM`, which is the whole of how an
|
||||
// object gets to play its own despawn after the server says it is gone (decision 1404).
|
||||
// The guid leaves the index either way: to the server it no longer exists.
|
||||
// object gets to play its own despawn after the server says it is gone (decision 1404); the
|
||||
// fade then follows the animation, where the deferred destroy runs
|
||||
// ([`crate::go_anim::release_despawn_pin`]).
|
||||
if let Some(e) = index.0.remove(&guid) {
|
||||
commands.queue(move |world: &mut bevy::ecs::world::World| {
|
||||
if world
|
||||
@@ -479,8 +492,8 @@ pub(super) fn object_destroyed(
|
||||
if let Ok(mut ent) = world.get_entity_mut(e) {
|
||||
ent.insert(crate::go_anim::PendingDestroy);
|
||||
}
|
||||
} else if let Ok(ent) = world.get_entity_mut(e) {
|
||||
ent.despawn();
|
||||
} else if let Ok(mut ent) = world.get_entity_mut(e) {
|
||||
ent.insert(DespawnFade::default());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -492,10 +505,12 @@ pub(super) fn object_destroyed(
|
||||
/// left its range.
|
||||
pub(super) fn objects_removed(guids: Vec<u64>, commands: &mut Commands, index: &mut GuidIndex) {
|
||||
// Don't pop the entity, fade it out, then despawn (`apply_despawn_fade` drives the ramp; an
|
||||
// entity with no fadeable geometry pops straight out there). Director-verified look:
|
||||
// on the reference, distant mobs fade out, never blink out (destroy, above, is the
|
||||
// byte-verified instant pop; 0067's open question, settled by the director's eyes —
|
||||
// which reference mechanism produces the fade is unpinned and doesn't matter here).
|
||||
// entity with no fadeable geometry pops straight out there). Director-verified look: on the
|
||||
// reference, distant mobs fade out, never blink out (0067's open question, settled by their
|
||||
// eyes). The mechanism behind it is the same one [`object_destroyed`] above now takes — the
|
||||
// OUT_OF_RANGE block and DESTROY reach `0x464920` alike, and its OnDeactivate hands the model
|
||||
// to the `SWModelFadeout` scheduler either way ([`DespawnFade`], decision 2198). That the two
|
||||
// routes agree is not a convenience here; it is the reference's own shape.
|
||||
for g in guids {
|
||||
if let Some(e) = index.0.remove(&g) {
|
||||
commands.entity(e).insert(DespawnFade::default());
|
||||
|
||||
@@ -161,6 +161,17 @@ struct GpuAccum {
|
||||
/// The newest measurement time consumed, so each fold reads only what arrived since. All
|
||||
/// measurements of one sync share one `Instant`, which is what makes "a frame" countable.
|
||||
seen: Option<Instant>,
|
||||
/// `WOW_GPU_PASSES=1` — the same sums per PASS, printed beside each row as a `GPU_PASSES`
|
||||
/// line: the journal's buckets fold both 2D passes, the UI pass and the gamma decode into
|
||||
/// one `gpu_ui`, and a pass-level question (an empty pass encoded every frame; one filter
|
||||
/// pass of a chain) needs the raw split. Empty and unread unless armed.
|
||||
passes: std::collections::BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
/// `WOW_GPU_PASSES=1`, read once.
|
||||
fn passes_armed() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_GPU_PASSES").is_some())
|
||||
}
|
||||
|
||||
impl GpuAccum {
|
||||
@@ -169,14 +180,21 @@ impl GpuAccum {
|
||||
let mut newest = self.seen;
|
||||
let mut frame_times: Vec<Instant> = Vec::new();
|
||||
for diagnostic in store.iter() {
|
||||
let Some(bucket) = gpu_bucket(diagnostic.path().as_str()) else {
|
||||
let path = diagnostic.path().as_str();
|
||||
let Some(bucket) = gpu_bucket(path) else {
|
||||
continue;
|
||||
};
|
||||
let pass = passes_armed()
|
||||
.then(|| path.strip_prefix("render/")?.strip_suffix("/elapsed_gpu"))
|
||||
.flatten();
|
||||
for m in diagnostic
|
||||
.measurements()
|
||||
.filter(|m| self.seen.is_none_or(|s| m.time > s))
|
||||
{
|
||||
self.sum[bucket as usize] += m.value;
|
||||
if let Some(pass) = pass {
|
||||
*self.passes.entry(pass.to_string()).or_default() += m.value;
|
||||
}
|
||||
if !frame_times.contains(&m.time) {
|
||||
frame_times.push(m.time);
|
||||
}
|
||||
@@ -203,9 +221,20 @@ impl GpuAccum {
|
||||
for bucket in self.sum {
|
||||
s.push_str(&format!(",{:.2}", bucket / n));
|
||||
}
|
||||
if passes_armed() {
|
||||
// Costliest first, ms per read frame — the raw split the buckets fold.
|
||||
let mut rows: Vec<(&String, &f64)> = self.passes.iter().collect();
|
||||
rows.sort_by(|a, b| b.1.total_cmp(a.1));
|
||||
let line: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={:.3}", *v / n))
|
||||
.collect();
|
||||
eprintln!("GPU_PASSES frames={} {}", self.frames, line.join(" "));
|
||||
}
|
||||
}
|
||||
self.sum = [0.0; GPU_BUCKETS];
|
||||
self.frames = 0;
|
||||
self.passes.clear();
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,6 +797,15 @@ mod tests {
|
||||
/// be named in the pipe_warm module, or this red-bars the build.
|
||||
#[test]
|
||||
fn every_custom_pipeline_lane_has_a_warm_contributor() {
|
||||
// Lanes whose one pipeline compiles covered BY CONSTRUCTION, each with the reason:
|
||||
// - UiGammaPipeline (`ui_gamma`, decision 2206): one variant, keyed on the swapchain's
|
||||
// view format, specialised in the first frame's prepare — pre-world, so covered
|
||||
// (`publish_cover`: `state != InWorld`) — and the surface's format never changes
|
||||
// after, so no later variant exists. Not a timing race like `UiQuadMaterial`'s: the
|
||||
// view exists from frame one, and the pipeline is what bevy's own output blit was.
|
||||
// (Its world-lane twin, benilla-world's `FfxCombinePipeline`, is outside this scan
|
||||
// and compiles under the same cover, keyed on the backdrop image's fixed format.)
|
||||
let exempt = ["UiGammaPipeline"];
|
||||
let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let warm_src = std::fs::read_to_string(src_root.join("pipe_warm/mod.rs")).unwrap()
|
||||
+ &std::fs::read_to_string(src_root.join("pipe_warm/menagerie.rs")).unwrap();
|
||||
@@ -813,7 +822,7 @@ mod tests {
|
||||
.chars()
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||
.collect();
|
||||
if !warm_src.contains(&ty) {
|
||||
if !exempt.contains(&ty.as_str()) && !warm_src.contains(&ty) {
|
||||
missing.push(format!("{ty} (impl in {})", path.display()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! the per-frame loop — the plugin remains the stable face that wires both.
|
||||
|
||||
use avian3d::prelude::*;
|
||||
use bevy::camera::{PerspectiveProjection, Projection};
|
||||
use bevy::camera::{CameraOutputMode, PerspectiveProjection, Projection};
|
||||
use bevy::core_pipeline::tonemapping::Tonemapping;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::view::Hdr;
|
||||
@@ -25,6 +25,18 @@ use super::{
|
||||
/// the character controller is in.)
|
||||
const DEFAULT_MOVE_SPEED: f32 = 7.0;
|
||||
|
||||
/// The world camera's `Camera` — output mode `Skip` (decision 2206,
|
||||
/// [`benilla_world::final_pass`]): the FFXGlow combine, the world lane's final pass, renders
|
||||
/// straight into the camera's target — the backdrop image ([`crate::world_backdrop`]) — and
|
||||
/// bevy's `upscaling` blit, a pure copy of the finished frame, is skipped. `$WOW_NO_FFX` strips
|
||||
/// the combine and flips this back to `Write` itself.
|
||||
fn world_camera_output() -> Camera {
|
||||
Camera {
|
||||
output_mode: CameraOutputMode::Skip,
|
||||
..default()
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_fallback_camera(commands: &mut Commands, msaa: Msaa) {
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
@@ -36,6 +48,7 @@ fn spawn_fallback_camera(commands: &mut Commands, msaa: Msaa) {
|
||||
Hdr,
|
||||
Tonemapping::None,
|
||||
benilla_world::ffx_glow::FfxGlow::WORLD,
|
||||
world_camera_output(),
|
||||
Transform::from_xyz(0.0, 50.0, 100.0).looking_at(Vec3::ZERO, Vec3::Y),
|
||||
FlyCam {
|
||||
yaw: 0.0,
|
||||
@@ -119,8 +132,10 @@ pub(super) fn setup_player(
|
||||
Hdr,
|
||||
Tonemapping::None,
|
||||
// The faithful FFXGlow pass (decision 0158/0161): the byte-pinned `scene + glow·blur²`
|
||||
// — and, in the gamma lane, the owner of the frame's single output decode.
|
||||
// — and, in the gamma lane, the owner of the frame's single output decode, which it now
|
||||
// writes straight into the backdrop (2206).
|
||||
benilla_world::ffx_glow::FfxGlow::WORLD,
|
||||
world_camera_output(),
|
||||
Transform::from_translation(spawn + Vec3::new(0.0, 60.0, 60.0)).looking_at(spawn, Vec3::Y),
|
||||
// PHASE 0: no PBR ambient fill, no distance fog — pitch-black clean slate. The faithful scene
|
||||
// light is rebuilt in-shader from Light.dbc (terrain/model WGSL), not via Bevy PBR lights.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// `ui_quad.wgsl` composites the whole UI in gamma bytes, like the reference's fixed-function
|
||||
// backbuffer. The UI camera's target is `Rgba8UnormSrgb`, so a stored byte round-trips through the
|
||||
// hardware's encode/decode and the sampler hands this pass back the gamma value the UI pass wrote.
|
||||
// Emitting `srgb_to_linear` of it re-encodes on write to the exact client byte, which the output
|
||||
// blit then carries into the swapchain unchanged.
|
||||
// Emitting `srgb_to_linear` of it re-encodes on write to the exact client byte — written straight
|
||||
// into the swapchain (decision 2206: the camera's output mode is `Skip`, so no blit follows).
|
||||
//
|
||||
// RGB is premultiplied by coverage; alpha is coverage and carries no gamma, so it passes through.
|
||||
|
||||
|
||||
@@ -322,9 +322,14 @@ pub(super) fn act_on_right_click(
|
||||
// The reader session the TEXT branch opens (decision 1105) — like the mailbox, a
|
||||
// client-side window with no packet behind it.
|
||||
ResMut<crate::ui_item_text::ItemTextOpen>,
|
||||
// The GameObject opener's one-frame queue (decision 2199): this system cannot hold
|
||||
// `CastLadder` (it would reach `Items`/`CastErrors` twice), so the lock chain's verdict
|
||||
// travels to `ui_action::drain::drain_go_openers` instead of becoming a packet here.
|
||||
ResMut<crate::ui_action::GoOpenerCasts>,
|
||||
),
|
||||
) {
|
||||
let (mut ui_error_keys, mut cast_errors, mut loot_latch, mut mail, mut item_text) = ui_feedback;
|
||||
let (mut ui_error_keys, mut cast_errors, mut loot_latch, mut mail, mut item_text, mut openers) =
|
||||
ui_feedback;
|
||||
if clicks.read().last().is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -483,7 +488,7 @@ pub(super) fn act_on_right_click(
|
||||
&seam.net,
|
||||
) {
|
||||
GoAction::Use if cursor.unable => {}
|
||||
GoAction::OpenLock(_) | GoAction::OpenByKey { .. } if cursor.unable => {}
|
||||
GoAction::OpenLock(_) | GoAction::OpenByKey(_) if cursor.unable => {}
|
||||
GoAction::Use => {
|
||||
debug!("right-click gameobject use: {guid:#x}");
|
||||
if benilla_assets::trace::enabled_for("use") {
|
||||
@@ -494,87 +499,28 @@ pub(super) fn act_on_right_click(
|
||||
}
|
||||
let _ = seam.net.0.send(ClientCommand::GameObjUse { guid });
|
||||
}
|
||||
// **Both opener arms queue for the one cast path** (decision 2199) — they
|
||||
// do not send. The reference reaches `TryCast 0x6e4b60` from the GameObject
|
||||
// strategy's use-sender (`0x5f35c0 → 0x6e5a90 → 0x6e4b60`, §8.4) exactly as it
|
||||
// does from a button press, so an opener owes the whole ladder — the in-flight
|
||||
// refusal above all, which is what keeps a mashed right-click from shipping a
|
||||
// duplicate the server answers "Another action is in progress" while red-fading
|
||||
// the bar of the cast that is still running. The local reagent/totem and
|
||||
// mounted checks that used to stand here are ladder rungs; they ran here only
|
||||
// because this arm could not reach the ladder at all.
|
||||
GoAction::OpenLock(spell_id) => {
|
||||
// The opener cast funnels through the ref's TryCast like any other cast
|
||||
// (§8.4: `0x5f35c0 → 0x6e5a90 → 0x6e4b60`), so the pre-send totem check
|
||||
// `0x6e4000` gates it too (decision 0552): a pickless Mining cast
|
||||
// refuses HERE with the local red "Requires Mining Pick" and sends
|
||||
// nothing — vmangos would answer the sent cast with the wrong code.
|
||||
let def = go_inputs
|
||||
.spells
|
||||
.as_ref()
|
||||
.and_then(|s| s.catalog.get(spell_id));
|
||||
if crate::ui_action::reagent_totem_refusal(
|
||||
spell_id,
|
||||
def,
|
||||
self_store,
|
||||
&go_inputs.items,
|
||||
&mut cast_errors,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Same funnel, same requirement validator: a mounted opener refuses with
|
||||
// reason `0x39` and sends nothing (`0x609c6c`). Mining and Herbalism are
|
||||
// exactly the gathering casts a rider tries without dismounting, and the
|
||||
// server would silently dismount us instead of saying so. **After** the
|
||||
// reagent check, which is TryCast's own order — step 5 (`0x6e4dec`) before
|
||||
// step 7 (`0x6e4f3b`) — so a pickless mounted miner still reads "Requires
|
||||
// Mining Pick", exactly as [`CastLadder`] orders its own two rungs.
|
||||
if crate::ui_action::cast_mounted_refusal(self_mounted, def) {
|
||||
debug!("right-click gameobject open-lock: refused locally — mounted");
|
||||
cast_errors.push_local(spell_id, 0x39);
|
||||
return;
|
||||
}
|
||||
debug!("right-click gameobject open-lock: cast {spell_id} at {guid:#x}");
|
||||
let _ = seam.net.0.send(ClientCommand::CastSpellGameObject {
|
||||
openers.0.push(crate::ui_action::GoOpener::Spell {
|
||||
spell_id,
|
||||
go_guid: guid,
|
||||
});
|
||||
}
|
||||
GoAction::OpenByKey {
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
} => {
|
||||
// No reagent/totem pre-check here, unlike the skill arm above: that gate is
|
||||
// about a Mining cast without a pick, and a key has no reagents — the ref's
|
||||
// `0x6e4000` would pass every key trivially.
|
||||
//
|
||||
// The MOUNTED gate is not skippable the same way: an item use IS a cast
|
||||
// (decision 0908) and reaches the same requirement validator, so a rider
|
||||
// turning a key refuses and sends nothing. The key's ON_USE *spell* is
|
||||
// exactly what this arm does not resolve (the stated 0914 gap below), so
|
||||
// the record is `None` — which the predicate reads as "no exemption to
|
||||
// claim" and refuses. That is the right way to be wrong: no 1.12 key
|
||||
// carries Attributes bit 24. Raised by KEY rather than through
|
||||
// [`CastErrors`], because a reason-coded entry wants a spell id and we
|
||||
// have none — the red line is identical either way; what a spell-less
|
||||
// entry would cost is the combat-log twin, which needs the spell's name
|
||||
// and cannot have it here. A disclosed shortfall of 0914's gap, not a new
|
||||
// one.
|
||||
if crate::ui_action::cast_mounted_refusal(self_mounted, None) {
|
||||
debug!("right-click gameobject open-by-key: refused locally — mounted");
|
||||
ui_error_keys
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("SPELL_FAILED_NOT_MOUNTED"));
|
||||
return;
|
||||
}
|
||||
GoAction::OpenByKey(it) => {
|
||||
debug!(
|
||||
"right-click gameobject open-by-key: use item ({bag_index},{slot}) blk {spell_index} at {guid:#x}"
|
||||
"right-click gameobject open-by-key: use item ({},{}) blk {} at {guid:#x}",
|
||||
it.bag_index, it.slot, it.spell_index
|
||||
);
|
||||
// NOT through [`crate::ui_action::CastLadder`] yet — this arm and the
|
||||
// `OpenLock` cast above are the last two sends outside the one ladder, and
|
||||
// folding them in is its own slice: the system is already at the
|
||||
// SystemParam ceiling, the resolver would have to carry the key's ON_USE
|
||||
// *spell* as well as its block ordinal, and the binder has no GameObject
|
||||
// arm (`CastCommit::Item::on_object` is the seam that awaits it). Stated
|
||||
// gap, decision 0914.
|
||||
let _ = seam.net.0.send(ClientCommand::UseItem {
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
target: benilla_protocol::messages::UseItemTarget::Object(guid),
|
||||
});
|
||||
openers.0.push(crate::ui_action::GoOpener::Key(it));
|
||||
}
|
||||
GoAction::Refuse(err) => {
|
||||
// `None` is a case the ref is silent on too (a key-item record miss —
|
||||
@@ -976,11 +922,11 @@ pub(crate) enum GoAction {
|
||||
/// (decision 0769; wow-re `cursor-system.md` §8.4 — "the client never sends a bare
|
||||
/// CMSG_CAST_SPELL for a key lock"). The distinction is the whole ballgame: `Spell::CanOpenLock`
|
||||
/// honours a `Lock.dbc` KEY slot only when `m_CastItem` is set, which only USE_ITEM supplies.
|
||||
OpenByKey {
|
||||
bag_index: u8,
|
||||
slot: u8,
|
||||
spell_index: u8,
|
||||
},
|
||||
///
|
||||
/// Carried as a whole [`crate::ui_items::ItemUse`] (decision 2199) because the reference's
|
||||
/// lock chain calls `CGItem::Use` with the lock's guid — the one item-use fork every surface
|
||||
/// takes — rather than building a packet of its own. `on_object` is that guid.
|
||||
OpenByKey(crate::ui_items::ItemUse),
|
||||
/// A lock present that we cannot open — the client-local refusal (§8.4: `DisplayError`, **no
|
||||
/// packet**). `Some` = the red toast to queue; `None` = the ref is silent for this case too.
|
||||
Refuse(Option<crate::ui_action::UiError>),
|
||||
@@ -1091,7 +1037,7 @@ pub(crate) fn resolve_go_action(
|
||||
let Some(store) = me_store else {
|
||||
return GoAction::Refuse(None);
|
||||
};
|
||||
let Some((bag_index, slot, _)) = crate::ui_items::find_item(
|
||||
let Some((bag_index, slot, key_guid)) = crate::ui_items::find_item(
|
||||
&store.0,
|
||||
&inputs.items,
|
||||
key_entry,
|
||||
@@ -1102,18 +1048,24 @@ pub(crate) fn resolve_go_action(
|
||||
};
|
||||
// The template is ask-once — a miss queries and does nothing this click, exactly like the
|
||||
// toast's own name miss. `use_spell_index` is the BLOCK ordinal, the packet's third byte.
|
||||
match inputs
|
||||
.items
|
||||
.template(key_entry, 0, net)
|
||||
.and_then(|i| i.use_spell_index())
|
||||
{
|
||||
Some(spell_index) => GoAction::OpenByKey {
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
},
|
||||
None => GoAction::Refuse(None),
|
||||
}
|
||||
let Some(tmpl) = inputs.items.template(key_entry, 0, net) else {
|
||||
return GoAction::Refuse(None);
|
||||
};
|
||||
let Some(spell_index) = tmpl.use_spell_index() else {
|
||||
return GoAction::Refuse(None);
|
||||
};
|
||||
GoAction::OpenByKey(crate::ui_items::ItemUse {
|
||||
guid: Some(key_guid),
|
||||
start_quest: tmpl.start_quest,
|
||||
bag_index,
|
||||
slot,
|
||||
entry: key_entry,
|
||||
spell_index,
|
||||
use_spell: tmpl.use_spell.as_ref().map(|u| u.spell_id),
|
||||
// The bound lock — `CGItem::Use`'s own target argument (decision 0769).
|
||||
on_object: Some(guid),
|
||||
is_charter: tmpl.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The client-local toast for an unopenable lock — the ref's routing, transcribed (wow-re
|
||||
|
||||
@@ -194,11 +194,38 @@ impl CastLadder<'_, '_> {
|
||||
spell_id: u32,
|
||||
ctx: &cast_target::CastContext,
|
||||
commit: CastCommit,
|
||||
) {
|
||||
self.send_bound(spell_id, ctx, commit, None);
|
||||
}
|
||||
|
||||
/// The same ladder for a cast the **caller** has already bound to a world GameObject — the
|
||||
/// lock chain's opener (decisions 0239 / 0752 / 2199). TryCast takes its target as an ordinary
|
||||
/// argument (`6e4ef4` hands `0x612df0` the guid pair it was *passed*), and the GameObject
|
||||
/// strategy's use-sender is one of the callers that fills it: `0x5f35c0 → 0x6e5a90 →
|
||||
/// 0x6e4b60`. So this is not a second send path — it is the one path, told what the click
|
||||
/// already resolved, and the bound guid short-circuits the target binder exactly as the
|
||||
/// key-in-a-lock arm's does.
|
||||
pub(crate) fn send_at_object(
|
||||
&mut self,
|
||||
spell_id: u32,
|
||||
ctx: &cast_target::CastContext,
|
||||
go_guid: u64,
|
||||
) {
|
||||
self.send_bound(spell_id, ctx, CastCommit::Spell, Some(go_guid));
|
||||
}
|
||||
|
||||
fn send_bound(
|
||||
&mut self,
|
||||
spell_id: u32,
|
||||
ctx: &cast_target::CastContext,
|
||||
commit: CastCommit,
|
||||
on_object: Option<u64>,
|
||||
) {
|
||||
send_spell_cast(
|
||||
spell_id,
|
||||
ctx,
|
||||
commit,
|
||||
on_object,
|
||||
&self.commands,
|
||||
&self.self_player,
|
||||
self.spells.as_deref(),
|
||||
@@ -236,6 +263,10 @@ fn send_spell_cast(
|
||||
spell_id: u32,
|
||||
ctx: &cast_target::CastContext,
|
||||
commit: CastCommit,
|
||||
// The GameObject the CALLER bound, for a SPELL commit (`CastLadder::send_at_object`). The
|
||||
// item commit carries its own on `CastCommit::Item`, because `CGItem::Use` is handed the
|
||||
// lock's guid and the commit is what remembers it.
|
||||
bound_object: Option<u64>,
|
||||
commands: &NetCommands,
|
||||
self_player: &Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
|
||||
spells: Option<&Spells>,
|
||||
@@ -343,7 +374,7 @@ fn send_spell_cast(
|
||||
let mut deferred_refusal = None;
|
||||
let explicit_object = match commit {
|
||||
CastCommit::Item { on_object, .. } => on_object,
|
||||
CastCommit::Spell => None,
|
||||
CastCommit::Spell => bound_object,
|
||||
};
|
||||
let candidates = cast_target::CastCandidates {
|
||||
selection: ctx.selection_guid,
|
||||
@@ -675,14 +706,18 @@ fn send_spell_cast(
|
||||
}
|
||||
// The commit's ONE branch (`SendCast 0x6e54f0`): same block, two opcodes.
|
||||
let _ = commands.0.send(match commit {
|
||||
CastCommit::Spell => match item_target {
|
||||
CastCommit::Spell => match (explicit_object, item_target) {
|
||||
// The lock chain's opener, bound by the click that resolved it (decision 2199) — the
|
||||
// same builder `commit_targeted`'s `TargetedBind::Object` reaches, because
|
||||
// `BindTarget`'s GameObject arm fills the block on both routes.
|
||||
(Some(go_guid), _) => ClientCommand::CastSpellGameObject { spell_id, go_guid },
|
||||
// `SendCast 0x6e54f0`'s item leg — the same block the bag click's commit reaches, just
|
||||
// arrived at without a click (decision 1552).
|
||||
Some(item_guid) => ClientCommand::CastSpellItem {
|
||||
(None, Some(item_guid)) => ClientCommand::CastSpellItem {
|
||||
spell_id,
|
||||
item_guid,
|
||||
},
|
||||
None => ClientCommand::CastSpell { spell_id, target },
|
||||
(None, None) => ClientCommand::CastSpell { spell_id, target },
|
||||
},
|
||||
CastCommit::Item {
|
||||
bag_index,
|
||||
@@ -838,6 +873,57 @@ mod tests {
|
||||
.expect("the ladder runs as a one-shot system");
|
||||
}
|
||||
|
||||
fn send_at_go(world: &mut World, spell_id: u32, go_guid: u64) {
|
||||
world
|
||||
.run_system_once(move |mut ladder: CastLadder| {
|
||||
ladder.send_at_object(spell_id, &ctx(), go_guid);
|
||||
})
|
||||
.expect("the ladder runs as a one-shot system");
|
||||
}
|
||||
|
||||
/// **The mashed chest, at the ladder** (decision 2199). Right-clicking a lockable GameObject
|
||||
/// resolves to a known `OPEN_LOCK` spell cast *at the object*; before this the arm sent its own
|
||||
/// packet, so every extra click shipped another `CMSG_CAST_SPELL`, vmangos answered each one
|
||||
/// `SPELL_FAILED_SPELL_IN_PROGRESS`, and that failure — naming the **same** spell as the cast
|
||||
/// still running — red-faded its bar while the chest opened anyway.
|
||||
///
|
||||
/// Two halves, both the reference's: the bound guid reaches the wire as the GameObject block
|
||||
/// (`BindTarget`'s GO arm), and the re-click is `6e4d43`'s **silent** same-spell bail — no
|
||||
/// packet, and no red line either, because the client refusing itself says nothing.
|
||||
#[test]
|
||||
fn a_mashed_gameobject_opener_never_ships_the_duplicate() {
|
||||
const OPENING: u32 = 6478;
|
||||
const CHEST: u64 = 0xF110_0000_0000_1234;
|
||||
let (mut world, rx) = world();
|
||||
|
||||
send_at_go(&mut world, OPENING, CHEST);
|
||||
assert!(
|
||||
matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpellGameObject { spell_id, go_guid })
|
||||
if spell_id == OPENING && go_guid == CHEST
|
||||
),
|
||||
"the first click commits as a GameObject-targeted cast and arms the one inflight id"
|
||||
);
|
||||
|
||||
send_at_go(&mut world, OPENING, CHEST);
|
||||
assert!(rx.try_recv().is_err(), "no duplicate on the wire");
|
||||
assert!(
|
||||
world.resource::<CastErrors>().0.is_empty(),
|
||||
"the same spell's re-press is the ref's SILENT bail (6e4d43), not \"Another action is \
|
||||
in progress\""
|
||||
);
|
||||
|
||||
// The control: a *different* cast mid-opener is the loud refusal (6e4d97), unchanged.
|
||||
send(&mut world, MOUNT, CastCommit::Spell);
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
world.resource::<CastErrors>().0,
|
||||
vec![CastFail::local(MOUNT, 0x61)],
|
||||
"a different spell mid-cast still errors 0x61"
|
||||
);
|
||||
}
|
||||
|
||||
// ── The melee ⟷ auto-repeat exclusion (the 5875 image, read 2026-08-06) ──────────────────
|
||||
//
|
||||
// Two client-local calls make Auto Shot and a queued Raptor Strike mutually exclusive, and
|
||||
|
||||
@@ -163,6 +163,62 @@ pub(super) fn drain_chain_casts(
|
||||
}
|
||||
}
|
||||
|
||||
/// **The world right-click's GameObject opener**, run through the one cast path (decision 2199) —
|
||||
/// the seam [`crate::ui_action::GoOpenerCasts`] exists for.
|
||||
///
|
||||
/// The reference reaches TryCast from the GameObject strategy's use-sender exactly as it does from
|
||||
/// a button press (`0x5f35c0 → 0x6e5a90 → 0x6e4b60`, wow-re `cursor-system.md` §8.4), so an opener
|
||||
/// takes **every rung** — in-flight, cooldown/GCD, power, crowd control, mounted, water, moving,
|
||||
/// form, reagents — and not the two the click used to check on its own. The rung that matters for
|
||||
/// the report this closes is the in-flight one: the second right-click on a chest whose Opening
|
||||
/// cast is still running is `6e4d43`'s **silent** same-spell bail, so no duplicate ever reaches the
|
||||
/// wire and the running bar is never red-faded by the server's answer to a packet we should not
|
||||
/// have sent.
|
||||
pub(super) fn drain_go_openers(
|
||||
mut queue: ResMut<crate::ui_action::GoOpenerCasts>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
targeting: cast_target::CastTargeting,
|
||||
mut ladder: CastLadder,
|
||||
// The by-key local-refusal sink, passed explicitly for the same reason `drain_action_uses`
|
||||
// does: a resource reachable twice from one system is a `B0002` panic (decision 1903).
|
||||
mut ui_errors: ResMut<UiErrorKeys>,
|
||||
mut gate: crate::ui_bind_confirm::BindGate,
|
||||
) {
|
||||
if queue.0.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ctx = targeting.context();
|
||||
let mut script = script;
|
||||
for opener in std::mem::take(&mut queue.0) {
|
||||
match opener {
|
||||
super::GoOpener::Spell { spell_id, go_guid } => {
|
||||
debug!("ui_action: gameobject opener casts {spell_id} at {go_guid:#x}");
|
||||
ladder.send_at_object(spell_id, &ctx, go_guid);
|
||||
}
|
||||
// The key's own `CGItem::Use` fork — one function for every use surface (decision
|
||||
// 0664), so the key reaches the wire through the same route a bag click does.
|
||||
super::GoOpener::Key(it) => {
|
||||
let Some(script) = script.as_deref_mut() else {
|
||||
continue;
|
||||
};
|
||||
debug!(
|
||||
"ui_action: gameobject opener uses the key at wire {}/{} on {:?}",
|
||||
it.bag_index, it.slot, it.on_object
|
||||
);
|
||||
crate::ui_items::send_item_use(
|
||||
it,
|
||||
&ctx,
|
||||
&mut ladder,
|
||||
script,
|
||||
&mut gate,
|
||||
false,
|
||||
&mut ui_errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The **self-cast modifier**, applied to a cast's targeting inputs: `UseAction`'s third argument
|
||||
/// (1.12's `SELFACTIONBUTTON1`-`12`, `ALT-1`…`ALT-=`, through `ActionButtonUp(id, 1)`).
|
||||
///
|
||||
|
||||
@@ -149,6 +149,39 @@ pub(crate) struct AutoRepeatActive(pub Option<u32>);
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ChainCasts(pub(crate) Vec<u32>);
|
||||
|
||||
/// **The world right-click's GameObject-opener queue** — the lock chain's resolved action, carried
|
||||
/// one frame to the one cast path (decision 2199).
|
||||
///
|
||||
/// It exists for exactly the reason [`ChainCasts`] does, and no other: the right-click system
|
||||
/// ([`crate::target::click::act_on_right_click`]) and [`CastLadder`] want the same half-dozen
|
||||
/// resources, so the click cannot call the ladder in place — a resource reachable twice from one
|
||||
/// system is a `B0002` panic on the first live frame. A one-frame queue is the seam, and it keeps
|
||||
/// the rule that **nothing sends a cast except the ladder**.
|
||||
///
|
||||
/// Before it, the opener was the last cast in the tree that sent its own packet. That is not a
|
||||
/// tidiness point: the ladder is where the in-flight rung lives (`6e4d97` — the reference's
|
||||
/// already-casting refusal, whose same-spell leg `6e4d43` is *silent*), so spamming right-click on
|
||||
/// a chest shipped a `CMSG_CAST_SPELL` per click, vmangos answered every duplicate
|
||||
/// `SPELL_FAILED_SPELL_IN_PROGRESS`, and that failure — naming the **same** spell as the running
|
||||
/// cast — red-faded the running bar while the cast completed anyway. Character for character the
|
||||
/// B200 report decision 0908 fixed for items; this is the same bug at the arm 0914 named as still
|
||||
/// open.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct GoOpenerCasts(pub(crate) Vec<GoOpener>);
|
||||
|
||||
/// One queued opener — what the lock chain resolved the right-click to
|
||||
/// ([`crate::target::click::resolve_go_action`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum GoOpener {
|
||||
/// A known `OPEN_LOCK` spell the player satisfies, cast **at the object** — `CMSG_CAST_SPELL`
|
||||
/// carrying the GameObject target block (decision 0239).
|
||||
Spell { spell_id: u32, go_guid: u64 },
|
||||
/// A key slot we carry: `CGItem::Use` with the lock's guid, which the commit turns into
|
||||
/// `CMSG_USE_ITEM` + `TARGET_FLAG_GAMEOBJECT` (decision 0769). The item carries the bound guid
|
||||
/// on its own [`crate::ui_items::ItemUse::on_object`].
|
||||
Key(crate::ui_items::ItemUse),
|
||||
}
|
||||
|
||||
/// The spell display catalog + the shapeshift bonus-bar map (absent when the client data isn't —
|
||||
/// every consumer tolerates that). `pub(crate)`: the cast-visual router
|
||||
/// (`crate::creature_anim::spell_visual`) resolves spell → visual through the same catalog — one
|
||||
@@ -333,6 +366,7 @@ impl Plugin for UiActionPlugin {
|
||||
.init_resource::<crate::cooldowns::Cooldowns>()
|
||||
.init_resource::<AutoRepeatActive>()
|
||||
.init_resource::<ChainCasts>()
|
||||
.init_resource::<GoOpenerCasts>()
|
||||
.init_resource::<cast_target::AutoSelfCast>()
|
||||
.init_resource::<targeting::SpellTargeting>()
|
||||
.init_resource::<targeting::EnchantConfirmItem>()
|
||||
@@ -370,6 +404,11 @@ impl Plugin for UiActionPlugin {
|
||||
// takes. After the input pass like the other drains — the queue is filled by
|
||||
// the net drain, which runs earlier in the frame.
|
||||
drain::drain_chain_casts.after(UiInput),
|
||||
// The world right-click's opener (2199), beside the chain cast and for the
|
||||
// same reason: the click resolved it, the ladder sends it. After the input
|
||||
// pass like the other drains — the queue is filled by the target chain,
|
||||
// which runs earlier in the frame.
|
||||
drain::drain_go_openers.after(UiInput),
|
||||
// The learned-ability latches must be current before the target chain's
|
||||
// cursor classifier reads them; the book feed runs in `UnitFeed`, so sitting
|
||||
// right after it is enough.
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! multiply, every blend is gamma arithmetic clamped at each write, and `alphaMode="ADD"` is the
|
||||
//! byte add `dst + texel·α` (EGxBlend 3 = `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`; wow-re
|
||||
//! `system/gx/gx.md`, factor tables `0x85c1f8`/`0x85c224`). This node converts that finished gamma
|
||||
//! image to linear ONCE, so the `Rgba8UnormSrgb` write re-encodes it to the exact client byte and
|
||||
//! the camera's output blit carries it to the swapchain unchanged.
|
||||
//! image to linear ONCE, rendering straight into the swapchain — the camera's output mode is
|
||||
//! `Skip`, so there is no output blit (decision 2206, [`benilla_world::final_pass`]) — whose sRGB
|
||||
//! write re-encodes it to the exact client byte.
|
||||
//!
|
||||
//! The node is **mandatory** on the player-UI camera: without it the whole UI presents ~2.2× bright
|
||||
//! (the same failure mode `$WOW_NO_FFX` produces for the world). It is gated on [`UiGammaLane`], so
|
||||
@@ -22,8 +23,8 @@
|
||||
use bevy::core_pipeline::core_2d::graph::{Core2d, Node2d};
|
||||
use bevy::core_pipeline::FullscreenShader;
|
||||
use bevy::ecs::query::QueryItem;
|
||||
use bevy::image::BevyDefault as _;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::camera::ExtractedCamera;
|
||||
use bevy::render::diagnostic::RecordDiagnostics;
|
||||
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
|
||||
use bevy::render::render_graph::{
|
||||
@@ -33,13 +34,15 @@ use bevy::render::render_resource::binding_types::{sampler, texture_2d, uniform_
|
||||
use bevy::render::render_resource::*;
|
||||
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
|
||||
use bevy::render::view::ViewTarget;
|
||||
use bevy::render::{RenderApp, RenderStartup};
|
||||
use bevy::render::{Render, RenderApp, RenderStartup, RenderSystems};
|
||||
use bevy::ui_render::graph::NodeUi;
|
||||
use bevy::ui_render::ui_texture_slice_pipeline::{
|
||||
init_ui_texture_slice_pipeline, UiTextureSlicePipeline,
|
||||
};
|
||||
use bevy::ui_render::{init_ui_pipeline, UiPipeline};
|
||||
|
||||
use benilla_world::final_pass::FinalPassTarget;
|
||||
|
||||
/// Marks the camera whose target holds a gamma-composited image awaiting this pass's one decode,
|
||||
/// and carries the [`DisplayGamma`] exponent that decode applies on the way through (2182).
|
||||
#[derive(Component, Clone, Copy, ExtractComponent)]
|
||||
@@ -111,23 +114,72 @@ fn stamp_lane_gamma(gamma: Res<DisplayGamma>, mut lanes: Query<&mut UiGammaLane>
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
struct UiGammaLabel;
|
||||
|
||||
/// The decode's shared halves. The pipeline itself is specialised per view on the format it
|
||||
/// renders in ([`ViewUiGammaPipeline`]).
|
||||
#[derive(Resource)]
|
||||
struct UiGammaPipeline {
|
||||
layout: BindGroupLayoutDescriptor,
|
||||
sampler: Sampler,
|
||||
decode: CachedRenderPipelineId,
|
||||
shader: Handle<Shader>,
|
||||
fullscreen: FullscreenShader,
|
||||
/// The ramp exponent's 16-byte uniform, rewritten each frame with a queue write (which lands
|
||||
/// before the graph's submit executes) — the same shape `ffx_glow`'s combine uses. One buffer,
|
||||
/// not one per view: [`UiGammaLane`] is on exactly one camera.
|
||||
ramp: Buffer,
|
||||
}
|
||||
|
||||
impl SpecializedRenderPipeline for UiGammaPipeline {
|
||||
type Key = TextureFormat;
|
||||
|
||||
fn specialize(&self, format: Self::Key) -> RenderPipelineDescriptor {
|
||||
RenderPipelineDescriptor {
|
||||
label: Some("ui_gamma_decode".into()),
|
||||
layout: vec![self.layout.clone()],
|
||||
vertex: self.fullscreen.to_vertex_state(),
|
||||
fragment: Some(FragmentState {
|
||||
shader: self.shader.clone(),
|
||||
shader_defs: vec![],
|
||||
entry_point: Some("fs_decode".into()),
|
||||
// An 8-bit sRGB target either way: the swapchain's view for the player-UI camera
|
||||
// (`Skip`, 2206), or — for a `Write` camera — its own main texture, which is not
|
||||
// `Hdr` and so carries Bevy's default 8-bit sRGB format, the one that makes the
|
||||
// lane clamp at every blend like the reference.
|
||||
targets: vec![Some(ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
..default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The decode pipeline for one view — specialised on where the decode lands (2206,
|
||||
/// [`benilla_world::final_pass`]): the swapchain, for the player-UI camera, whose output mode is
|
||||
/// `Skip`. Stamped every frame, the way bevy stamps its own `ViewUpscalingPipeline`.
|
||||
#[derive(Component)]
|
||||
struct ViewUiGammaPipeline(CachedRenderPipelineId);
|
||||
|
||||
fn prepare_view_pipelines(
|
||||
mut commands: Commands,
|
||||
pipeline_cache: Res<PipelineCache>,
|
||||
pipeline: Res<UiGammaPipeline>,
|
||||
mut pipelines: ResMut<SpecializedRenderPipelines<UiGammaPipeline>>,
|
||||
views: Query<(Entity, &ExtractedCamera, &ViewTarget), With<UiGammaLane>>,
|
||||
) {
|
||||
for (entity, camera, target) in &views {
|
||||
let format = FinalPassTarget::format(&camera.output_mode, target);
|
||||
let id = pipelines.specialize(&pipeline_cache, &pipeline, format);
|
||||
commands.entity(entity).insert(ViewUiGammaPipeline(id));
|
||||
}
|
||||
}
|
||||
|
||||
fn init_pipeline(
|
||||
mut commands: Commands,
|
||||
render_device: Res<RenderDevice>,
|
||||
fullscreen_shader: Res<FullscreenShader>,
|
||||
asset_server: Res<AssetServer>,
|
||||
pipeline_cache: Res<PipelineCache>,
|
||||
) {
|
||||
let layout = BindGroupLayoutDescriptor::new(
|
||||
"ui_gamma_layout",
|
||||
@@ -150,24 +202,6 @@ fn init_pipeline(
|
||||
address_mode_v: AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
let decode = pipeline_cache.queue_render_pipeline(RenderPipelineDescriptor {
|
||||
label: Some("ui_gamma_decode".into()),
|
||||
layout: vec![layout.clone()],
|
||||
vertex: fullscreen_shader.to_vertex_state(),
|
||||
fragment: Some(FragmentState {
|
||||
shader: asset_server.load("embedded://benilla_app/shaders/ui_gamma.wgsl"),
|
||||
shader_defs: vec![],
|
||||
entry_point: Some("fs_decode".into()),
|
||||
// The UI camera is not `Hdr`, so its `ViewTarget` carries Bevy's default 8-bit sRGB
|
||||
// format — which is what makes the lane clamp at every blend, like the reference.
|
||||
targets: vec![Some(ColorTargetState {
|
||||
format: TextureFormat::bevy_default(),
|
||||
blend: None,
|
||||
write_mask: ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
..default()
|
||||
});
|
||||
let ramp = render_device.create_buffer(&BufferDescriptor {
|
||||
label: Some("ui_gamma_ramp"),
|
||||
size: 16,
|
||||
@@ -177,7 +211,8 @@ fn init_pipeline(
|
||||
commands.insert_resource(UiGammaPipeline {
|
||||
layout,
|
||||
sampler,
|
||||
decode,
|
||||
shader: asset_server.load("embedded://benilla_app/shaders/ui_gamma.wgsl"),
|
||||
fullscreen: fullscreen_shader.clone(),
|
||||
ramp,
|
||||
});
|
||||
}
|
||||
@@ -186,18 +221,23 @@ fn init_pipeline(
|
||||
struct UiGammaNode;
|
||||
|
||||
impl ViewNode for UiGammaNode {
|
||||
type ViewQuery = (&'static ViewTarget, &'static UiGammaLane);
|
||||
type ViewQuery = (
|
||||
&'static ViewTarget,
|
||||
&'static UiGammaLane,
|
||||
&'static ExtractedCamera,
|
||||
&'static ViewUiGammaPipeline,
|
||||
);
|
||||
|
||||
fn run<'w>(
|
||||
&self,
|
||||
_graph: &mut RenderGraphContext,
|
||||
render_context: &mut RenderContext<'w>,
|
||||
(view_target, lane): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
(view_target, lane, camera, pipeline): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
world: &'w World,
|
||||
) -> Result<(), NodeRunError> {
|
||||
let pipelines = world.resource::<UiGammaPipeline>();
|
||||
let pipeline_cache = world.resource::<PipelineCache>();
|
||||
let Some(decode) = pipeline_cache.get_render_pipeline(pipelines.decode) else {
|
||||
let Some(decode) = pipeline_cache.get_render_pipeline(pipeline.0) else {
|
||||
// Still compiling. Skipping leaves the UI undecoded (over-bright) for a frame or two,
|
||||
// which beats dropping the UI entirely.
|
||||
return Ok(());
|
||||
@@ -208,33 +248,34 @@ impl ViewNode for UiGammaNode {
|
||||
0,
|
||||
bytemuck::cast_slice(&[lane.gamma, 0.0, 0.0, 0.0]),
|
||||
);
|
||||
let post = view_target.post_process_write();
|
||||
// Where the decode lands (2206, `final_pass`): the swapchain itself for the player-UI
|
||||
// camera (`Skip`), the ping-pong for a `Write` camera.
|
||||
let out = FinalPassTarget::resolve(camera, view_target);
|
||||
let layout = pipeline_cache.get_bind_group_layout(&pipelines.layout);
|
||||
let bind = render_context.render_device().create_bind_group(
|
||||
"ui_gamma_decode",
|
||||
&layout,
|
||||
&BindGroupEntries::sequential((
|
||||
post.source,
|
||||
out.source,
|
||||
&pipelines.sampler,
|
||||
pipelines.ramp.as_entire_binding(),
|
||||
)),
|
||||
);
|
||||
// Its diagnostic span: the journal's `gpu_ui` column counts this decode (2008).
|
||||
let diagnostics = render_context.diagnostic_recorder();
|
||||
let scissor = out.scissor_rect();
|
||||
let mut pass = render_context
|
||||
.command_encoder()
|
||||
.begin_render_pass(&RenderPassDescriptor {
|
||||
label: Some("ui_gamma_decode"),
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: post.destination,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: Operations::default(),
|
||||
})],
|
||||
color_attachments: &[Some(out.destination)],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
if let Some((x, y, w, h)) = scissor {
|
||||
pass.set_scissor_rect(x, y, w, h);
|
||||
}
|
||||
let span = diagnostics.pass_span(&mut pass, "ui_gamma_decode");
|
||||
pass.set_pipeline(decode);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
@@ -283,7 +324,12 @@ impl Plugin for UiGammaPlugin {
|
||||
return;
|
||||
};
|
||||
render_app
|
||||
.init_resource::<SpecializedRenderPipelines<UiGammaPipeline>>()
|
||||
.add_systems(RenderStartup, init_pipeline)
|
||||
.add_systems(
|
||||
Render,
|
||||
prepare_view_pipelines.in_set(RenderSystems::Prepare),
|
||||
)
|
||||
.add_systems(
|
||||
RenderStartup,
|
||||
use_gamma_ui_shaders
|
||||
@@ -291,7 +337,8 @@ impl Plugin for UiGammaPlugin {
|
||||
.after(init_ui_texture_slice_pipeline),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<UiGammaNode>>(Core2d, UiGammaLabel)
|
||||
// After the quads are composited, before the blit that composites the UI over the world.
|
||||
// After the quads are composited, in the slot before the output blit's — which a
|
||||
// `Skip` camera leaves empty: the decode IS the output write.
|
||||
.add_render_graph_edges(
|
||||
Core2d,
|
||||
(Node2d::EndMainPass, UiGammaLabel, Node2d::Upscaling),
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
//! ordering exactly where 0025 already reserved it ("where player-UI arbitration will live later") —
|
||||
//! **dev overlays stay on top of the player UI, which stays on top of the world.** The camera renders
|
||||
//! nothing from the 3D world (its own [`RenderLayers`] layer, disjoint from the world camera's default
|
||||
//! layer 0) and composites over whatever the world camera already painted (`CameraOutputMode::Write`
|
||||
//! with alpha blending, no clear — same pattern as the egui overlay camera).
|
||||
//! layer 0); the world reaches it as its first quad ([`crate::world_backdrop`]), and its finished
|
||||
//! frame reaches the swapchain through [`crate::ui_gamma`]'s decode directly — output mode `Skip`,
|
||||
//! no blit (decision 2206).
|
||||
//!
|
||||
//! ## Colour space: the UI gamma composite lane (decision 0254)
|
||||
//! The reference draws its whole UI through the fixed-function device into an 8-bit backbuffer, so
|
||||
@@ -643,24 +644,22 @@ fn spawn_ui_camera(mut commands: Commands) {
|
||||
ui_render_layers(),
|
||||
Camera {
|
||||
order: UI_CAMERA_ORDER,
|
||||
output_mode: CameraOutputMode::Write {
|
||||
// **No blend at all** — this camera now carries the world too
|
||||
// ([`crate::world_backdrop`]), so its target is a whole opaque frame and the blit
|
||||
// is a copy. That is the point: the blend that used to happen HERE, against the
|
||||
// sRGB swapchain view, was the frame's one linear composite, and it was the only
|
||||
// one that mixed UI with world. Moving the world into the UI's own byte buffer
|
||||
// moves that blend into `ui_quad.wgsl`'s gamma target, where every other UI blend
|
||||
// already lives.
|
||||
//
|
||||
// It also retires the hazard 0254 patched around here: `ui_quad.wgsl` writes
|
||||
// PREMULTIPLIED colour, so the original `ALPHA_BLENDING`'s `SrcAlpha` factor
|
||||
// weighted it by alpha twice (`rgb·a²`), and a pure-additive quad (a = 0) over the
|
||||
// world was multiplied clean away. `PREMULTIPLIED_ALPHA_BLENDING` fixed the
|
||||
// arithmetic but kept the blend — and kept it in the wrong space. With nothing to
|
||||
// blend against, neither factor can be wrong.
|
||||
blend_state: None,
|
||||
clear_color: ClearColorConfig::None,
|
||||
},
|
||||
// **No output blit at all** (decision 2206, [`benilla_world::final_pass`]): the
|
||||
// lane's final pass — [`crate::ui_gamma`]'s decode — renders straight into the
|
||||
// swapchain, so bevy's `upscaling` copy of the finished frame is skipped.
|
||||
//
|
||||
// It could already have been a plain copy, because this camera carries the world
|
||||
// too ([`crate::world_backdrop`]) and its target is a whole opaque frame. That was
|
||||
// the point of the backdrop: the blend that used to happen HERE, against the sRGB
|
||||
// swapchain view, was the frame's one linear composite, and the only one that mixed
|
||||
// UI with world; moving the world into the UI's own byte buffer moved that blend
|
||||
// into `ui_quad.wgsl`'s gamma target, where every other UI blend already lives. It
|
||||
// also retired the hazard 0254 patched around here: `ui_quad.wgsl` writes
|
||||
// PREMULTIPLIED colour, so the original `ALPHA_BLENDING`'s `SrcAlpha` factor
|
||||
// weighted it by alpha twice (`rgb·a²`), and a pure-additive quad (a = 0) over the
|
||||
// world was multiplied clean away. With nothing to blend against, neither factor can
|
||||
// be wrong — and with no blit, there is no pass left to get it wrong in.
|
||||
output_mode: CameraOutputMode::Skip,
|
||||
// An overlay must composite ONLY its own pixels. `ClearColorConfig::None` made this
|
||||
// camera depend on `MsaaWriteback::Auto`, which (for any non-first camera on a target)
|
||||
// COPIES the world camera's already-final output into this camera's MSAA texture and
|
||||
|
||||
@@ -412,7 +412,17 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// item (the `#[cfg(feature = "dev")]` weather panel is its only other namer); what changed is
|
||||
/// that a game module names it now, which is exactly the crossing this gate exists to make
|
||||
/// visible.
|
||||
const CEILING: usize = 180;
|
||||
/// And 180 → 181: `final_pass::FinalPassTarget`, a PUBLISH — where a colour lane's final pass
|
||||
/// lands, as one noun (decision 2206). The client has two colour lanes that end in a full-screen
|
||||
/// decode: the world's (the FFXGlow combine, engine-side) and the UI's (`crate::ui_gamma`'s,
|
||||
/// game-side since 0254 — the interface is the game's). Both used to write bevy's main texture
|
||||
/// and let its `upscaling` blit copy the result out; 2206 has each render straight into its
|
||||
/// camera's target when the camera's output mode is `Skip`, and the rule that turns an output
|
||||
/// mode into a destination, a format and a scissor is one rule, not two copies of it that drift.
|
||||
/// It could not go the other way: moving the UI decode into the engine would put the UI lane's
|
||||
/// colour law on the wrong side of the wall. So the engine publishes the rule as one type with
|
||||
/// two associated functions, and the game names it once.
|
||||
const CEILING: usize = 181;
|
||||
|
||||
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
|
||||
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! afterwards. [`FfxGlow::state_scale`] is that whole class in one field, `0` on every bake
|
||||
//! (decision 1481).
|
||||
|
||||
use bevy::camera::CameraOutputMode;
|
||||
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
|
||||
use bevy::core_pipeline::FullscreenShader;
|
||||
use bevy::ecs::query::QueryItem;
|
||||
@@ -37,6 +38,7 @@ use bevy::render::texture::{CachedTexture, TextureCache};
|
||||
use bevy::render::view::ViewTarget;
|
||||
use bevy::render::{Render, RenderApp, RenderStartup, RenderSystems};
|
||||
|
||||
use crate::final_pass::FinalPassTarget;
|
||||
use crate::view::WorldCamera;
|
||||
|
||||
/// Marks a camera rendering with the faithful FFXGlow pass (extracted to the render world).
|
||||
@@ -424,14 +426,22 @@ fn sync_gain(
|
||||
fn ensure_ffx_glow(
|
||||
mut commands: Commands,
|
||||
cam: Query<Entity, (With<WorldCamera>, Without<FfxGlow>)>,
|
||||
with: Query<Entity, With<FfxGlow>>,
|
||||
mut with: Query<(Entity, &mut Camera), With<FfxGlow>>,
|
||||
) {
|
||||
// Perf-bisect kill-switch: $WOW_NO_FFX strips the pass from every camera (the frame then shows
|
||||
// undecoded gamma — ~2.2× bright — which is fine: this is a measurement mode, not a look mode).
|
||||
static NO_FFX: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
if *NO_FFX.get_or_init(|| std::env::var_os("WOW_NO_FFX").is_some()) {
|
||||
for e in &with {
|
||||
for (e, mut camera) in &mut with {
|
||||
commands.entity(e).remove::<FfxGlow>();
|
||||
// With no combine left to write the output, the view goes back to bevy's blit — a
|
||||
// `Skip` camera (2206, `final_pass`) would otherwise present a target nothing wrote.
|
||||
if matches!(camera.output_mode, CameraOutputMode::Skip) {
|
||||
camera.output_mode = CameraOutputMode::Write {
|
||||
blend_state: None,
|
||||
clear_color: ClearColorConfig::Default,
|
||||
};
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -465,12 +475,56 @@ struct FfxGlowPipelines {
|
||||
downsample: CachedRenderPipelineId,
|
||||
gauss_h: CachedRenderPipelineId,
|
||||
gauss_v: CachedRenderPipelineId,
|
||||
combine: CachedRenderPipelineId,
|
||||
/// The underwater combine. A SEPARATE pipeline rather than a branch inside `fs_combine`,
|
||||
/// compiled once at startup beside it: a dry frame then binds byte-for-byte the pipeline it
|
||||
/// bound before this feature existed and pays nothing at all for the warp — no extra pass, no
|
||||
/// extra target, not even a uniform branch.
|
||||
combine_wave: CachedRenderPipelineId,
|
||||
combine: FfxCombinePipeline,
|
||||
}
|
||||
|
||||
/// The combine — specialised on the **format it renders in** (decision 2206, [`crate::final_pass`]):
|
||||
/// a view whose camera runs `CameraOutputMode::Skip` gets it rendered straight into the output
|
||||
/// texture (the backdrop image, for the world camera), a `Write` view into the HDR main texture
|
||||
/// for bevy's blit to copy out, as before. The three filter passes never leave the ¼-res chain
|
||||
/// and stay unspecialised.
|
||||
struct FfxCombinePipeline {
|
||||
layout: BindGroupLayoutDescriptor,
|
||||
shader: Handle<Shader>,
|
||||
fullscreen: FullscreenShader,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct FfxCombineKey {
|
||||
format: TextureFormat,
|
||||
/// The underwater combine (`fs_combine_wave`). A SEPARATE pipeline rather than a branch inside
|
||||
/// `fs_combine`: a dry frame then binds byte-for-byte the pipeline it bound before the warp
|
||||
/// existed and pays nothing at all for it — no extra pass, no extra target, not even a uniform
|
||||
/// branch.
|
||||
wave: bool,
|
||||
}
|
||||
|
||||
impl SpecializedRenderPipeline for FfxCombinePipeline {
|
||||
type Key = FfxCombineKey;
|
||||
|
||||
fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
|
||||
let (label, entry) = if key.wave {
|
||||
("ffx_glow_combine_wave", "fs_combine_wave")
|
||||
} else {
|
||||
("ffx_glow_combine", "fs_combine")
|
||||
};
|
||||
RenderPipelineDescriptor {
|
||||
label: Some(label.into()),
|
||||
layout: vec![self.layout.clone()],
|
||||
vertex: self.fullscreen.to_vertex_state(),
|
||||
fragment: Some(FragmentState {
|
||||
shader: self.shader.clone(),
|
||||
shader_defs: vec![],
|
||||
entry_point: Some(entry.into()),
|
||||
targets: vec![Some(ColorTargetState {
|
||||
format: key.format,
|
||||
blend: None,
|
||||
write_mask: ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
..default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_pipelines(
|
||||
@@ -581,16 +635,11 @@ fn init_pipelines(
|
||||
pipeline_cache.queue_render_pipeline(pipeline("ffx_glow_h", &layout_filter, "fs_gauss_h"));
|
||||
let gauss_v =
|
||||
pipeline_cache.queue_render_pipeline(pipeline("ffx_glow_v", &layout_filter, "fs_gauss_v"));
|
||||
let combine = pipeline_cache.queue_render_pipeline(pipeline(
|
||||
"ffx_glow_combine",
|
||||
&layout_combine,
|
||||
"fs_combine",
|
||||
));
|
||||
let combine_wave = pipeline_cache.queue_render_pipeline(pipeline(
|
||||
"ffx_glow_combine_wave",
|
||||
&layout_combine,
|
||||
"fs_combine_wave",
|
||||
));
|
||||
let combine = FfxCombinePipeline {
|
||||
layout: layout_combine.clone(),
|
||||
shader,
|
||||
fullscreen: fullscreen_shader.clone(),
|
||||
};
|
||||
commands.insert_resource(FfxGlowPipelines {
|
||||
layout_filter,
|
||||
layout_combine,
|
||||
@@ -601,7 +650,6 @@ fn init_pipelines(
|
||||
gauss_h,
|
||||
gauss_v,
|
||||
combine,
|
||||
combine_wave,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -617,11 +665,18 @@ struct FfxGlowTextures {
|
||||
/// submit executes).
|
||||
gain_buf: Buffer,
|
||||
/// The two Gauss bind groups bind only the ¼-res ping-pong views + sampler — all stable
|
||||
/// while the textures hold. The downsample and combine bind groups bind the view target's
|
||||
/// post-process source, which `ViewTarget::post_process_write` flips per call, so those two
|
||||
/// CANNOT be cached (they would sample last frame's texture) and stay per-frame in `run`.
|
||||
/// while the textures hold. The downsample and combine bind groups bind the view's finished
|
||||
/// main texture, which `ViewTarget::post_process_write` flips per call on a `Write` view, so
|
||||
/// those two are not cached (they would sample last frame's texture) and stay per-frame in
|
||||
/// `run`.
|
||||
gauss_h_bind: BindGroup,
|
||||
gauss_v_bind: BindGroup,
|
||||
/// The combine pipelines for this view — dry and wave — specialised on the format the
|
||||
/// combine renders in for THIS camera's output mode (`FinalPassTarget::format`).
|
||||
combine: CachedRenderPipelineId,
|
||||
combine_wave: CachedRenderPipelineId,
|
||||
/// The format those were keyed on: the freshness gate beside the two textures'.
|
||||
combine_format: TextureFormat,
|
||||
}
|
||||
|
||||
fn prepare_textures(
|
||||
@@ -630,12 +685,22 @@ fn prepare_textures(
|
||||
render_device: Res<RenderDevice>,
|
||||
pipeline_cache: Res<PipelineCache>,
|
||||
pipelines: Res<FfxGlowPipelines>,
|
||||
views: Query<(Entity, &ExtractedCamera, Option<&FfxGlowTextures>), With<FfxGlow>>,
|
||||
mut combine_pipelines: ResMut<SpecializedRenderPipelines<FfxCombinePipeline>>,
|
||||
views: Query<
|
||||
(
|
||||
Entity,
|
||||
&ExtractedCamera,
|
||||
&ViewTarget,
|
||||
Option<&FfxGlowTextures>,
|
||||
),
|
||||
With<FfxGlow>,
|
||||
>,
|
||||
) {
|
||||
for (entity, camera, existing) in &views {
|
||||
for (entity, camera, target, existing) in &views {
|
||||
let Some(vp) = camera.physical_viewport_size else {
|
||||
continue;
|
||||
};
|
||||
let combine_format = FinalPassTarget::format(&camera.output_mode, target);
|
||||
// The reference's RT-dim chain: ½ and ¼, floored (clamp ≥8 — `ffx_compute_rt_dims`).
|
||||
let mut tex = |label: &'static str, w: u32, h: u32| {
|
||||
texture_cache.get(
|
||||
@@ -664,9 +729,26 @@ fn prepare_textures(
|
||||
if existing.is_some_and(|t| {
|
||||
t.quarter_a.texture.id() == quarter_a.texture.id()
|
||||
&& t.quarter_b.texture.id() == quarter_b.texture.id()
|
||||
&& t.combine_format == combine_format
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let combine = combine_pipelines.specialize(
|
||||
&pipeline_cache,
|
||||
&pipelines.combine,
|
||||
FfxCombineKey {
|
||||
format: combine_format,
|
||||
wave: false,
|
||||
},
|
||||
);
|
||||
let combine_wave = combine_pipelines.specialize(
|
||||
&pipeline_cache,
|
||||
&pipelines.combine,
|
||||
FfxCombineKey {
|
||||
format: combine_format,
|
||||
wave: true,
|
||||
},
|
||||
);
|
||||
let layout_filter = pipeline_cache.get_bind_group_layout(&pipelines.layout_filter);
|
||||
let gauss_h_bind = render_device.create_bind_group(
|
||||
"ffx_glow_gauss_h",
|
||||
@@ -690,6 +772,9 @@ fn prepare_textures(
|
||||
gain_buf,
|
||||
gauss_h_bind,
|
||||
gauss_v_bind,
|
||||
combine,
|
||||
combine_wave,
|
||||
combine_format,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -794,13 +879,14 @@ impl ViewNode for FfxGlowNode {
|
||||
&'static ViewTarget,
|
||||
&'static FfxGlowTextures,
|
||||
&'static FfxGlow,
|
||||
&'static ExtractedCamera,
|
||||
);
|
||||
|
||||
fn run<'w>(
|
||||
&self,
|
||||
_graph: &mut RenderGraphContext,
|
||||
render_context: &mut RenderContext<'w>,
|
||||
(view_target, textures, glow): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
(view_target, textures, glow, camera): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
world: &'w World,
|
||||
) -> Result<(), NodeRunError> {
|
||||
let pipelines = world.resource::<FfxGlowPipelines>();
|
||||
@@ -817,9 +903,9 @@ impl ViewNode for FfxGlowNode {
|
||||
// THE PASS-LIST SWAP (`0x6cc630`). The reference forks the whole list here; we fork one
|
||||
// pipeline, which is the same fork — the first two passes are identical in both lists.
|
||||
let combine_id = if wave_armed(glow.state, wave, death) {
|
||||
pipelines.combine_wave
|
||||
textures.combine_wave
|
||||
} else {
|
||||
pipelines.combine
|
||||
textures.combine
|
||||
};
|
||||
let (Some(downsample), Some(gauss_h), Some(gauss_v), Some(combine)) = (
|
||||
pipeline_cache.get_render_pipeline(pipelines.downsample),
|
||||
@@ -835,7 +921,10 @@ impl ViewNode for FfxGlowNode {
|
||||
0,
|
||||
bytemuck::cast_slice(&uniform),
|
||||
);
|
||||
let post = view_target.post_process_write();
|
||||
// Where this view's combine lands (2206, `final_pass`): a `Skip` camera's output texture
|
||||
// itself, a `Write` camera's ping-pong for bevy's blit to copy out. The downsample reads
|
||||
// the same finished main texture either way.
|
||||
let out = FinalPassTarget::resolve(camera, view_target);
|
||||
let layout_combine = pipeline_cache.get_bind_group_layout(&pipelines.layout_combine);
|
||||
let diagnostics = render_context.diagnostic_recorder();
|
||||
|
||||
@@ -851,14 +940,14 @@ impl ViewNode for FfxGlowNode {
|
||||
let blur_read = uniform[0] != 0.0 || uniform[2] != 0.0;
|
||||
if blur_read {
|
||||
let layout_filter = pipeline_cache.get_bind_group_layout(&pipelines.layout_filter);
|
||||
// The downsample binds `post.source`, which `post_process_write` flips per call —
|
||||
// this bind group cannot be cached (it would sample last frame's texture); the two
|
||||
// Gauss ones bind only the stable ¼-res ping-pong and ride prepared on
|
||||
// [`FfxGlowTextures`].
|
||||
// The downsample binds `out.source`, which a `Write` view's `post_process_write`
|
||||
// flips per call — this bind group is not cached (it would sample last frame's
|
||||
// texture); the two Gauss ones bind only the stable ¼-res ping-pong and ride
|
||||
// prepared on [`FfxGlowTextures`].
|
||||
let down_bind = render_device.create_bind_group(
|
||||
"ffx_glow_down_quarter",
|
||||
&layout_filter,
|
||||
&BindGroupEntries::sequential((post.source, &pipelines.sampler)),
|
||||
&BindGroupEntries::sequential((out.source, &pipelines.sampler)),
|
||||
);
|
||||
|
||||
// The filter passes (byte-pinned chain): source→¼ (one Box4), ¼a→¼b (H), ¼b→¼a (V).
|
||||
@@ -908,13 +997,13 @@ impl ViewNode for FfxGlowNode {
|
||||
}
|
||||
}
|
||||
|
||||
// Combine: screen + w·blur² (gamma-space byte math in the shader) → the post destination.
|
||||
// Also binds the flipping `post.source` — per-frame for the downsample's reason.
|
||||
// Combine: screen + w·blur² (gamma-space byte math in the shader) → the view's output.
|
||||
// Also binds the flipping `out.source` — per-frame for the downsample's reason.
|
||||
let bind = render_device.create_bind_group(
|
||||
"ffx_glow_combine",
|
||||
&layout_combine,
|
||||
&BindGroupEntries::sequential((
|
||||
post.source,
|
||||
out.source,
|
||||
&pipelines.sampler,
|
||||
&textures.quarter_a.default_view,
|
||||
textures.gain_buf.as_entire_binding(),
|
||||
@@ -922,20 +1011,19 @@ impl ViewNode for FfxGlowNode {
|
||||
&pipelines.wave_sampler,
|
||||
)),
|
||||
);
|
||||
let scissor = out.scissor_rect();
|
||||
let mut pass = render_context
|
||||
.command_encoder()
|
||||
.begin_render_pass(&RenderPassDescriptor {
|
||||
label: Some("ffx_glow_combine"),
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: post.destination,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: Operations::default(),
|
||||
})],
|
||||
color_attachments: &[Some(out.destination)],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
if let Some((x, y, w, h)) = scissor {
|
||||
pass.set_scissor_rect(x, y, w, h);
|
||||
}
|
||||
let span = diagnostics.pass_span(&mut pass, "ffx_glow_combine");
|
||||
pass.set_pipeline(combine);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
@@ -979,6 +1067,7 @@ impl Plugin for FfxGlowPlugin {
|
||||
return;
|
||||
};
|
||||
render_app
|
||||
.init_resource::<SpecializedRenderPipelines<FfxCombinePipeline>>()
|
||||
.add_systems(RenderStartup, init_pipelines)
|
||||
.add_systems(
|
||||
Render,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! **Where a colour lane's final pass lands** (decision 2206) — the one rule the FFXGlow combine
|
||||
//! ([`crate::ffx_glow`]) and the UI gamma decode (`benilla_app::ui_gamma`) share.
|
||||
//!
|
||||
//! Each of benilla's two colour lanes ends in a full-screen pass that owns the frame's one gamma
|
||||
//! decode (0161 for the world, 0254 for the UI). Bevy's default for a camera is
|
||||
//! [`CameraOutputMode::Write`]: that pass writes the view's main texture, and bevy's `upscaling`
|
||||
//! node then **blits** the result into the camera's target — a pure copy of every pixel, once per
|
||||
//! camera. On the Windows lab's RTX that copy was the costliest pass of the frame (2205: `upscaling`
|
||||
//! 1.1–1.4 ms of a 4.3 ms GPU frame, both cameras' blits summed), and on a tile GPU it is one more
|
||||
//! full-screen pass on the render thread 1975 called the floor.
|
||||
//!
|
||||
//! With the camera set to [`CameraOutputMode::Skip`] the blit is off, and the lane's final pass
|
||||
//! renders **straight into the target** — the world camera's combine into the backdrop image, the
|
||||
//! UI camera's decode into the swapchain. Same shader, same values, one full-screen pass fewer per
|
||||
//! camera; the pipeline is specialised on the target's format because that is now the format it
|
||||
//! writes. The first use of the output in a frame is a clear rather than a load: a fast clear on an
|
||||
//! immediate-mode GPU, and on a tile GPU the difference between not reading the old contents and
|
||||
//! reading them — the fullscreen triangle covers every pixel of the viewport either way.
|
||||
//!
|
||||
//! A `Write` camera keeps the ping-pong ([`ViewTarget::post_process_write`]) and the blit, exactly
|
||||
//! as before: a bake's own `clear_color` and viewport (the glue booth's pillarbox, 1619) are the
|
||||
//! blit's to honour, and `benilla-worldview` has no reason to change. So the choice is the camera's
|
||||
//! — made where the camera is spawned — and a node only reads it. **The one hazard is the
|
||||
//! half-way state:** a `Skip` camera whose final pass never ran presents a target nothing wrote.
|
||||
//! `ffx_glow::ensure_ffx_glow`'s `$WOW_NO_FFX` arm therefore flips a stripped camera back to `Write`.
|
||||
|
||||
use bevy::camera::{CameraOutputMode, Viewport};
|
||||
use bevy::color::LinearRgba;
|
||||
use bevy::render::camera::ExtractedCamera;
|
||||
use bevy::render::render_resource::{
|
||||
Operations, RenderPassColorAttachment, TextureFormat, TextureView,
|
||||
};
|
||||
use bevy::render::view::ViewTarget;
|
||||
|
||||
/// One final pass's source and destination for one view — the seam's one noun. Its two
|
||||
/// associated functions are the two halves of the rule: [`Self::format`] for the pipeline (at
|
||||
/// prepare), [`Self::resolve`] for the pass (in the node).
|
||||
pub struct FinalPassTarget<'a> {
|
||||
/// The finished main texture the pass reads.
|
||||
pub source: &'a TextureView,
|
||||
/// The attachment it renders into: the output texture (`Skip`) or the other main texture
|
||||
/// (`Write`).
|
||||
pub destination: RenderPassColorAttachment<'a>,
|
||||
/// A `Skip` camera's viewport, to be applied as the pass's scissor — what bevy's blit does for
|
||||
/// the same camera. `None` covers the whole target.
|
||||
pub scissor: Option<&'a Viewport>,
|
||||
}
|
||||
|
||||
impl<'a> FinalPassTarget<'a> {
|
||||
/// The format a lane's final pass renders in, for a view whose camera runs `output_mode`:
|
||||
/// the output texture's own for a `Skip` camera, the main texture's for a `Write` one. What
|
||||
/// the pass's pipeline is specialised on.
|
||||
pub fn format(output_mode: &CameraOutputMode, target: &ViewTarget) -> TextureFormat {
|
||||
match output_mode {
|
||||
CameraOutputMode::Skip => target.out_texture_view_format(),
|
||||
CameraOutputMode::Write { .. } => target.main_texture_format(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a view's final pass target from its camera's output mode. Call **once** per view
|
||||
/// per frame: the `Write` arm takes the view's post-process write, which flips the main
|
||||
/// texture.
|
||||
pub fn resolve(camera: &'a ExtractedCamera, target: &'a ViewTarget) -> Self {
|
||||
match camera.output_mode {
|
||||
CameraOutputMode::Skip => Self {
|
||||
source: target.main_texture_view(),
|
||||
destination: target.out_texture_color_attachment(Some(LinearRgba::NONE)),
|
||||
scissor: camera.viewport.as_ref(),
|
||||
},
|
||||
CameraOutputMode::Write { .. } => {
|
||||
let post = target.post_process_write();
|
||||
Self {
|
||||
source: post.source,
|
||||
destination: RenderPassColorAttachment {
|
||||
view: post.destination,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: Operations::default(),
|
||||
},
|
||||
scissor: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The scissor rect the pass sets on its render pass — `(x, y, width, height)` in physical
|
||||
/// pixels — when [`Self::scissor`] names one.
|
||||
pub fn scissor_rect(&self) -> Option<(u32, u32, u32, u32)> {
|
||||
self.scissor.map(|viewport| {
|
||||
(
|
||||
viewport.physical_position.x,
|
||||
viewport.physical_position.y,
|
||||
viewport.physical_size.x,
|
||||
viewport.physical_size.y,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1532,6 +1532,7 @@ mod tests {
|
||||
duration: 2.0,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
curve: crate::model_fade::FadeCurve::Cubic,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
@@ -1610,6 +1611,7 @@ mod tests {
|
||||
duration: 2.0,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
curve: crate::model_fade::FadeCurve::Cubic,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
@@ -1664,6 +1666,7 @@ mod tests {
|
||||
duration: 1.0,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
curve: crate::model_fade::FadeCurve::Cubic,
|
||||
});
|
||||
world
|
||||
.entity_mut(part)
|
||||
|
||||
@@ -139,6 +139,7 @@ pub mod doodad_anim;
|
||||
pub mod entity_shade;
|
||||
pub mod exterior_cull;
|
||||
pub mod ffx_glow;
|
||||
pub mod final_pass;
|
||||
pub mod frame_pace;
|
||||
pub mod ground_fx;
|
||||
pub mod instance_tint;
|
||||
|
||||
@@ -130,19 +130,52 @@ pub fn fade_band(radius: f32) -> Option<(f32, f32)> {
|
||||
/// ([`FadeMaterials::material_for`]) — so an entity that streams in indoors ramps up already lit by
|
||||
/// its room instead of appearing under exterior light and snapping to the room's when it latches.
|
||||
///
|
||||
/// Built general (`from`/`to`/`duration`) so the **despawn fade-out** (our stream-out look — the
|
||||
/// binary has no teardown fade, see [`DespawnFade`]) is a `{from: α, to: 0}` instance + a
|
||||
/// despawn-on-complete system — no new channel needed. The appear case is
|
||||
/// `{from: 0, to: 1, duration: 2 s}`. (Decision 0032; teardown fidelity corrected in 0067.)
|
||||
/// One channel, **two curves**, and that is the reference's shape rather than a generalisation of
|
||||
/// ours (decisions 2198/2203). Both ramps write this same per-instance alpha, but they are different
|
||||
/// functions armed by different subsystems: the appear ramp is `FadeTo 0x614f80` eased cubically by
|
||||
/// the object's own vtable slot 14, and the teardown ramp is the `SWModelFadeout` pump `0x672ef0`
|
||||
/// smoothstepping a *detached* model the object left behind. [`FadeCurve`] is which one; the
|
||||
/// teardown case is `{curve: Smoothstep, from: the live α, to: 0, duration: 2 s}` plus a
|
||||
/// despawn-on-complete system ([`DespawnFade`]), the appear case
|
||||
/// `{curve: Cubic, from: 0, to: 1, duration: 2 s}`. (Decision 0032; teardown fidelity corrected in
|
||||
/// 0067, its mechanism pinned in 2198 and its curve in 2203.)
|
||||
#[derive(Component, Clone)]
|
||||
pub struct RenderFade {
|
||||
/// `Time::elapsed_secs` when the fade was armed (the entity's first-visible moment).
|
||||
pub started: f32,
|
||||
/// Fade length in seconds. Appear = [`APPEAR_FADE_SECS`].
|
||||
/// Fade length in seconds. Both ramps are [`APPEAR_FADE_SECS`] in 1.12 — the appear `FadeTo`'s
|
||||
/// `0x7d0` argument and the teardown pump's `age > 0x7d0` unlink are the same 2000 ms.
|
||||
pub duration: f32,
|
||||
/// Cubic ramp endpoints. Appear `0 → 1`; despawn `α → 0`.
|
||||
/// Ramp endpoints. Appear `0 → 1`; teardown `live α → 0` (where `from` is the pump's
|
||||
/// `startAlpha`, which scales the ramp rather than lerping from it — see
|
||||
/// [`teardown_fade_alpha`]).
|
||||
pub from: f32,
|
||||
pub to: f32,
|
||||
/// Which of the reference's two ramps this is.
|
||||
pub curve: FadeCurve,
|
||||
}
|
||||
|
||||
/// Which of the reference's two render-alpha ramps a [`RenderFade`] runs. They are not
|
||||
/// parameterisations of one curve: the appear ease lives on the CGObject
|
||||
/// (`0x614a90`, `α = lerp(from, to, t³)`) and the teardown ease lives on the scene-node fadeout
|
||||
/// scheduler the object hands its model to on the way out (`0x672ef0`, `α = smoothstep(1 − t)`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum FadeCurve {
|
||||
/// [`fade_alpha`] — the appear ramp.
|
||||
#[default]
|
||||
Cubic,
|
||||
/// [`teardown_fade_alpha`] — the `SWModelFadeout` ramp.
|
||||
Smoothstep,
|
||||
}
|
||||
|
||||
impl RenderFade {
|
||||
/// This fade's alpha at fractional age `t`, by its own curve.
|
||||
pub fn alpha_at(&self, t: f32) -> f32 {
|
||||
match self.curve {
|
||||
FadeCurve::Cubic => fade_alpha(self.from, self.to, t),
|
||||
FadeCurve::Smoothstep => teardown_fade_alpha(self.from, t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's appear-fade duration — `FadeTo(1.0, 2000 ms)` (`wow-5875-re` object-layer/`appear-fade`:
|
||||
@@ -157,18 +190,46 @@ impl RenderFade {
|
||||
duration: APPEAR_FADE_SECS,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
curve: FadeCurve::Cubic,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's cubic-ease render-alpha: `α = lerp(from, to, clamp(t, 0, 1)³)` (`0x614a90`: `fld t;
|
||||
/// fmul t; fmul t`). `t` is the fractional fade age. Appear (0→1) accelerates up from invisible; a
|
||||
/// despawn (α→0) eases out.
|
||||
/// fmul t; fmul t`). `t` is the fractional fade age. This is the **appear** ramp, and only the
|
||||
/// appear ramp: the teardown runs a different function entirely ([`teardown_fade_alpha`]).
|
||||
pub fn fade_alpha(from: f32, to: f32, t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
from + (to - from) * t * t * t
|
||||
}
|
||||
|
||||
/// The reference's **teardown** render-alpha — the `SWModelFadeout` pump `0x672ef0`
|
||||
/// (`doodad_despawn_fade`, wow-re models PRIMITIVE, diffed bit-exact against the x87), which is
|
||||
/// what drives a model handed to the scheduler `0x672df0` by the base OnDeactivate `0x6145e0`
|
||||
/// (decision 2203):
|
||||
///
|
||||
/// ```text
|
||||
/// t = clamp((1 − age_ms · 0.0005 [0x80c698]) · startAlpha, 0, 1)
|
||||
/// α = (3 − 2t) · t² ; smoothstep
|
||||
/// ```
|
||||
///
|
||||
/// `t_frac` is the fractional fade age (`age_ms · 0.0005` at the reference's fixed 2 s window), so
|
||||
/// the ramp inside the smoothstep *descends*. `start_alpha` is the object's live transition alpha
|
||||
/// at the hand-off (`obj+0xf4`) — **scaling the ramp, not the output**, which is why this is not
|
||||
/// expressible as a `lerp` and gets its own function rather than a flag on [`fade_alpha`].
|
||||
///
|
||||
/// Against the appear curve reversed — what benilla ran before the mechanism was pinned — this is a
|
||||
/// visibly different shape: at the half-way mark `1 − t³` is still at 0.875 where this is at 0.5.
|
||||
pub fn teardown_fade_alpha(start_alpha: f32, t_frac: f32) -> f32 {
|
||||
let t = ((1.0 - t_frac) * start_alpha).clamp(0.0, 1.0);
|
||||
(3.0 - 2.0 * t) * t * t
|
||||
}
|
||||
|
||||
/// The scheduler's own **skip gate** (`0x672df0` @ `0x672e21`, the float compare against
|
||||
/// `[0x8029d0]`): a model handed over at less than this alpha is unlinked on the spot
|
||||
/// (`0x671ac0`) instead of being faded — it had nothing left to show. Below it, benilla pops.
|
||||
pub const TEARDOWN_MIN_ALPHA: f32 = 0.01;
|
||||
|
||||
/// One model instance's live **render alpha** — the reference's `CM2Model+0x19c`, and the single
|
||||
/// slot every fade in the client writes through (wow-re object-layer/`appear-fade` §"the alpha
|
||||
/// slot"): `+0x19c = argAlpha · +0x180`, with `+0x180 = obj+0x100 (master) · obj+0xf4 (the appear
|
||||
@@ -299,8 +360,11 @@ pub fn model_render_alpha(
|
||||
fade_alpha(0.0, 1.0, (now - started) / APPEAR_FADE_SECS)
|
||||
}
|
||||
};
|
||||
// The teardown's own curve (2203) — the same smoothstep the mesh channel runs, so an emitter's
|
||||
// particles and a ribbon's strip thin out in step with the geometry they hang off rather than
|
||||
// holding opaque while it feathers away.
|
||||
let despawn = despawn_started.map_or(1.0, |started| {
|
||||
fade_alpha(1.0, 0.0, (now - started) / APPEAR_FADE_SECS)
|
||||
teardown_fade_alpha(1.0, (now - started) / APPEAR_FADE_SECS)
|
||||
});
|
||||
(appear * despawn * self_fade * declared).clamp(0.0, 1.0)
|
||||
}
|
||||
@@ -425,8 +489,47 @@ mod unit_render_alpha_tests {
|
||||
unit_at(11.0, |u| {
|
||||
u.insert(DespawnFade { started: 10.0 });
|
||||
}),
|
||||
fade_alpha(1.0, 0.0, 0.5),
|
||||
"armed: the ramp down"
|
||||
teardown_fade_alpha(1.0, 0.5),
|
||||
"armed: the ramp down — the SWModelFadeout smoothstep, not the appear cubic reversed"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The two ramps are different functions** (decision 2203), and this is the test that would
|
||||
/// have caught benilla running one of them backwards in place of the other. The appear ease is
|
||||
/// the CGObject's own (`0x614a90`, `t³`); the teardown ease is the scene-node fadeout
|
||||
/// scheduler's (`0x672ef0`, `smoothstep(1 − t)`), and they agree only at the endpoints.
|
||||
#[test]
|
||||
fn the_teardown_curve_is_not_the_appear_curve_reversed() {
|
||||
// Endpoints — the only place they may agree.
|
||||
assert_eq!(teardown_fade_alpha(1.0, 0.0), 1.0, "opaque at the hand-off");
|
||||
assert_eq!(teardown_fade_alpha(1.0, 1.0), 0.0, "gone at the window end");
|
||||
// The middle, where the old curve was visibly wrong: 1 − 0.5³ = 0.875 against 0.5.
|
||||
assert!((teardown_fade_alpha(1.0, 0.5) - 0.5).abs() < 1e-6);
|
||||
assert!((fade_alpha(1.0, 0.0, 0.5) - 0.875).abs() < 1e-6);
|
||||
// Monotone down over the window, and clamped past it (the pump unlinks at `age > 2000`,
|
||||
// so nothing ever samples beyond 1.0 — but a late frame must not read as re-appearing).
|
||||
let mut prev = f32::INFINITY;
|
||||
for i in 0..=20 {
|
||||
let a = teardown_fade_alpha(1.0, i as f32 / 20.0);
|
||||
assert!(a <= prev + 1e-6, "monotone at t={i}");
|
||||
prev = a;
|
||||
}
|
||||
assert_eq!(
|
||||
teardown_fade_alpha(1.0, 2.0),
|
||||
0.0,
|
||||
"clamped past the window"
|
||||
);
|
||||
|
||||
// `start_alpha` scales the RAMP, not the output — the reference multiplies it inside the
|
||||
// clamp (`(1 − age·0.0005)·startAlpha`), which is why this is not a lerp from it.
|
||||
assert!(
|
||||
teardown_fade_alpha(0.25, 0.0) < 0.25,
|
||||
"smoothstep(0.25) < 0.25"
|
||||
);
|
||||
assert_eq!(
|
||||
teardown_fade_alpha(0.0, 0.0),
|
||||
0.0,
|
||||
"handed nothing, shows nothing"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -588,7 +691,7 @@ pub fn apply_render_fade(
|
||||
// `A = instanceAlpha × colourAlpha × weight` (wow-re `m2-alpha-combine-cull.md`), and the
|
||||
// fade ramp IS this instance's alpha. Without this a unit appearing mid-Death would flash
|
||||
// its death-only geometry opaque for the length of the ramp.
|
||||
let alpha = fade_alpha(fade.from, fade.to, t) * anim.map_or(1.0, |a| a.current);
|
||||
let alpha = fade.alpha_at(t) * anim.map_or(1.0, |a| a.current);
|
||||
// `with_alpha` handles the `MeshTag == 0` opaque-sentinel — else a just-spawned object at
|
||||
// α 0 would flash fully opaque — and preserves the ground-shade byte, so a unit fading in
|
||||
// under MCSH shadow doesn't flash lit (the conventions live in `crate::mesh_tag`).
|
||||
@@ -905,6 +1008,7 @@ impl PartFade {
|
||||
duration: APPEAR_FADE_SECS,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
curve: FadeCurve::Cubic,
|
||||
});
|
||||
true
|
||||
}
|
||||
@@ -912,11 +1016,25 @@ impl PartFade {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a streamed entity (the parent) that went out of range: instead of popping it out,
|
||||
/// [`apply_despawn_fade`] fades it out then despawns it. **Our** stream-out look, not a verified
|
||||
/// mechanism — the wow-re teardown RE found no fade-out in the binary (a *destroyed* object pops
|
||||
/// instantly, and the net bridge despawns it directly, bypassing this). `started < 0` ⇒ not yet
|
||||
/// armed/stamped.
|
||||
/// Marks a streamed entity (the parent) whose object has gone away — out of range, destroyed, or
|
||||
/// released from the despawn-animation pin: instead of popping it out, [`apply_despawn_fade`]
|
||||
/// fades it out and then despawns it. `started < 0` ⇒ not yet armed/stamped.
|
||||
///
|
||||
/// **This is the reference's teardown, one hop below where an earlier round looked** (decision
|
||||
/// 2198). The object-manager destroy `0x464920` — which `SMSG_DESTROY_OBJECT` (`0x4674a0`) and the
|
||||
/// `SMSG_UPDATE_OBJECT` OUT_OF_RANGE **destroy** `0x465ec0` both reach (`0x465f4f`/`0x465fa6`;
|
||||
/// 2198 cited `0x465fd0` for this, which is the type-4/5 *block* arm and genuinely is reached —
|
||||
/// it just unpacks the packed guids and discards every one. The destroy runs earlier, off the
|
||||
/// leading-block check `0x4651e1`) — invokes the object's vtable
|
||||
/// slot 1, whose base `0x6145e0` unbinds the scene handle and hands the model to the
|
||||
/// **`SWModelFadeout` scheduler `0x672df0`**: the model outlives the object and its alpha is
|
||||
/// ramped to zero by the per-frame pump `0x672ef0` (wow-re `models/models.md` §"Doodad despawn
|
||||
/// fade", `object-layer/scratch/w2d2-decomp.c` `FUN_006145e0`). So the object *is* freed on the
|
||||
/// spot — the paragraph this supersedes was right about that — and the thing you watch fade is
|
||||
/// its orphaned model. One arm covers both wire routes, which is why one component does here.
|
||||
///
|
||||
/// 0067 settled the *look* from the director's eyes while the mechanism was unpinned; the
|
||||
/// mechanism is the scheduler; the curve and window it uses are 2203's.
|
||||
#[derive(Component)]
|
||||
pub struct DespawnFade {
|
||||
pub(crate) started: f32,
|
||||
@@ -958,8 +1076,12 @@ pub(crate) fn apply_despawn_fade(
|
||||
mut commands: Commands,
|
||||
mut q: Query<(Entity, &mut DespawnFade)>,
|
||||
children_of: Query<&Children>,
|
||||
fm: Query<(), With<FadeMaterials>>,
|
||||
cards: Query<(Entity, &crate::billboard::BillboardCard), With<FadeMaterials>>,
|
||||
// The tag, not a bare `With` marker: the ramp starts from the alpha the part is SHOWING (the
|
||||
// scheduler's `startAlpha` argument — [`arm_fade_out`]), and the tag is where that lives.
|
||||
// `Option`, because a part that never had one is the shader's untagged-⇒-opaque sentinel, not
|
||||
// a part that cannot fade — tying fadeability to the tag's presence would pop it instead.
|
||||
fm: Query<Option<&MeshTag>, With<FadeMaterials>>,
|
||||
cards: Query<(Entity, &crate::billboard::BillboardCard, Option<&MeshTag>), With<FadeMaterials>>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
for (parent, mut df) in &mut q {
|
||||
@@ -975,10 +1097,13 @@ pub(crate) fn apply_despawn_fade(
|
||||
&mut any,
|
||||
&mut walked,
|
||||
);
|
||||
for (card, follow) in &cards {
|
||||
for (card, follow, tag) in &cards {
|
||||
if follow.follows().is_some_and(|a| walked.contains(&a)) {
|
||||
any = true;
|
||||
arm_fade_out(card, now, &mut commands);
|
||||
let start = start_alpha(tag);
|
||||
if start >= TEARDOWN_MIN_ALPHA {
|
||||
any = true;
|
||||
arm_fade_out(card, now, start, &mut commands);
|
||||
}
|
||||
}
|
||||
}
|
||||
if any {
|
||||
@@ -992,24 +1117,37 @@ pub(crate) fn apply_despawn_fade(
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm one fadeable entity's `{from: 1, to: 0}` ramp. No material is chosen here:
|
||||
/// [`apply_render_fade`] resolves it per frame from the part's live law (0755), so a
|
||||
/// bake-classified part fades out probe-lit — and keeps doing so if it re-classifies mid-ramp,
|
||||
/// which a pair latched at this instant could not express. `try_*`: a child (a held item
|
||||
/// Arm one fadeable entity's teardown ramp, **from the alpha it is actually showing**. The
|
||||
/// scheduler is handed `obj+0xf4`, the object's live transition alpha (wow-re, decision 2203), so a
|
||||
/// model torn down mid-appear-fade ramps down from where it stood — where a hardcoded `1.0` would
|
||||
/// snap it opaque first and then fade, a visible pop in the one case the fade exists to avoid.
|
||||
/// Ours lives in the part's `MeshTag`, which is the same channel the ramp is about to write.
|
||||
///
|
||||
/// No material is chosen here: [`apply_render_fade`] resolves it per frame from the part's live law
|
||||
/// (0755), so a bake-classified part fades out probe-lit — and keeps doing so if it re-classifies
|
||||
/// mid-ramp, which a pair latched at this instant could not express. `try_*`: a child (a held item
|
||||
/// mid-re-resolve, a gear swap) can be despawned by its own owner in the same frame — the
|
||||
/// lifetime contract in [`arm_appear_fade`].
|
||||
fn arm_fade_out(entity: Entity, now: f32, commands: &mut Commands) {
|
||||
fn arm_fade_out(entity: Entity, now: f32, start_alpha: f32, commands: &mut Commands) {
|
||||
commands
|
||||
.entity(entity)
|
||||
.try_insert(RenderFade {
|
||||
started: now,
|
||||
duration: APPEAR_FADE_SECS,
|
||||
from: 1.0,
|
||||
from: start_alpha,
|
||||
to: 0.0,
|
||||
curve: FadeCurve::Smoothstep,
|
||||
})
|
||||
.try_remove::<PendingAppearFade>();
|
||||
}
|
||||
|
||||
/// The alpha a part is showing, as the teardown ramp's `startAlpha`. No tag at all is the shader's
|
||||
/// untagged-⇒-opaque sentinel ([`crate::mesh_tag::alpha_of`] reads a zero payload as `1.0`), which
|
||||
/// is the same answer for the same reason.
|
||||
fn start_alpha(tag: Option<&MeshTag>) -> f32 {
|
||||
tag.map_or(1.0, |t| crate::mesh_tag::alpha_of(t.0))
|
||||
}
|
||||
|
||||
/// Depth-first helper for [`apply_despawn_fade`]: arm the fade-out on `entity` if it carries
|
||||
/// [`FadeMaterials`], and recurse into its children either way (a joint / held-item root carries none
|
||||
/// itself but has fadeable meshes beneath it). Every entity visited — parts, joints, attach roots,
|
||||
@@ -1021,14 +1159,20 @@ fn arm_despawn_descendants(
|
||||
now: f32,
|
||||
commands: &mut Commands,
|
||||
children_of: &Query<&Children>,
|
||||
fm: &Query<(), With<FadeMaterials>>,
|
||||
fm: &Query<Option<&MeshTag>, With<FadeMaterials>>,
|
||||
any: &mut bool,
|
||||
walked: &mut bevy::ecs::entity::EntityHashSet,
|
||||
) {
|
||||
walked.insert(entity);
|
||||
if fm.contains(entity) {
|
||||
*any = true;
|
||||
arm_fade_out(entity, now, commands);
|
||||
// The scheduler's own skip gate (`0x672e21`): a part already showing nothing is not worth a
|
||||
// ramp, and an object whose every part is under it has no fade at all — `any` stays false and
|
||||
// the caller pops it, which is the reference unlinking at `0x671ac0`.
|
||||
if let Ok(tag) = fm.get(entity) {
|
||||
let start = start_alpha(tag);
|
||||
if start >= TEARDOWN_MIN_ALPHA {
|
||||
*any = true;
|
||||
arm_fade_out(entity, now, start, commands);
|
||||
}
|
||||
}
|
||||
if let Ok(children) = children_of.get(entity) {
|
||||
for &child in children {
|
||||
|
||||
Reference in New Issue
Block a user