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:
Sam
2026-09-15 16:54:29 +07:00
co-authored by Claude Fable 5.1
parent 9b6b84aea3
commit cde247fe39
20 changed files with 1014 additions and 271 deletions
+126 -9
View File
@@ -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();
+20 -4
View File
@@ -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()
},
));
+28 -10
View File
@@ -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"
);
}
+28 -13
View File
@@ -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());
+30 -1
View File
@@ -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
}
}
+10 -1
View File
@@ -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()));
}
}
+17 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+45 -93
View File
@@ -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
+90 -4
View File
@@ -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
+56
View File
@@ -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)`).
///
+39
View File
@@ -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.
+84 -37
View File
@@ -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),
+19 -20
View File
@@ -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
+11 -1
View File
@@ -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
+131 -42
View File
@@ -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,
+98
View File
@@ -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,
)
})
}
}
+3
View File
@@ -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)
+1
View File
@@ -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;
+176 -32
View File
@@ -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 {