From cde247fe395d14dab4ad417f3f6058fcbd3d7f1a Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 15 Sep 2026 16:54:29 +0700 Subject: [PATCH] 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 --- crates/benilla-app/src/bindings.rs | 135 +++++++++++- crates/benilla-app/src/debug_panel/mod.rs | 24 +- crates/benilla-app/src/go_anim.rs | 38 +++- crates/benilla-app/src/net/apply/objects.rs | 41 ++-- crates/benilla-app/src/perf/journal.rs | 31 ++- crates/benilla-app/src/pipe_warm/menagerie.rs | 11 +- crates/benilla-app/src/player/setup.rs | 19 +- crates/benilla-app/src/shaders/ui_gamma.wgsl | 4 +- crates/benilla-app/src/target/click.rs | 138 ++++-------- crates/benilla-app/src/ui_action/cast_send.rs | 94 +++++++- crates/benilla-app/src/ui_action/drain.rs | 56 +++++ crates/benilla-app/src/ui_action/mod.rs | 39 ++++ crates/benilla-app/src/ui_gamma.rs | 121 ++++++---- crates/benilla-app/src/ui_pass.rs | 39 ++-- crates/benilla-app/tests/world_api_wall.rs | 12 +- crates/benilla-world/src/ffx_glow.rs | 173 +++++++++++---- crates/benilla-world/src/final_pass.rs | 98 +++++++++ crates/benilla-world/src/interior.rs | 3 + crates/benilla-world/src/lib.rs | 1 + crates/benilla-world/src/model_fade.rs | 208 +++++++++++++++--- 20 files changed, 1014 insertions(+), 271 deletions(-) create mode 100644 crates/benilla-world/src/final_pass.rs diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index 1e5999b3..295a05a4 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -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, } 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 = 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, + buttons: &ButtonInput, +) -> 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::() } @@ -1299,6 +1361,61 @@ mod tests { assert!(app.world().resource::().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::>() + .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(); diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs index 81065799..512506a0 100644 --- a/crates/benilla-app/src/debug_panel/mod.rs +++ b/crates/benilla-app/src/debug_panel/mod.rs @@ -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() }, )); diff --git a/crates/benilla-app/src/go_anim.rs b/crates/benilla-app/src/go_anim.rs index b3cc8c58..8b4fbcc1 100644 --- a/crates/benilla-app/src/go_anim.rs +++ b/crates/benilla-app/src/go_anim.rs @@ -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>) /// /// 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>, @@ -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::() + .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::(go).is_some(), + "the window ended — the pin drops and the deferred destroy hands the model to the fade" + ); + assert!( + app.world().get::(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::(go).is_some(), + "nothing to play ⇒ the teardown fade, same frame — never a pop" ); } diff --git a/crates/benilla-app/src/net/apply/objects.rs b/crates/benilla-app/src/net/apply/objects.rs index c5ec7edb..23b25b3d 100644 --- a/crates/benilla-app/src/net/apply/objects.rs +++ b/crates/benilla-app/src/net/apply/objects.rs @@ -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, 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()); diff --git a/crates/benilla-app/src/perf/journal.rs b/crates/benilla-app/src/perf/journal.rs index b8c6fcf5..8ca74f74 100644 --- a/crates/benilla-app/src/perf/journal.rs +++ b/crates/benilla-app/src/perf/journal.rs @@ -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, + /// `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, +} + +/// `WOW_GPU_PASSES=1`, read once. +fn passes_armed() -> bool { + static ON: std::sync::OnceLock = 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 = 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 = 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 } } diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index bf625815..b2423700 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -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())); } } diff --git a/crates/benilla-app/src/player/setup.rs b/crates/benilla-app/src/player/setup.rs index 0bfda121..e30af1d8 100644 --- a/crates/benilla-app/src/player/setup.rs +++ b/crates/benilla-app/src/player/setup.rs @@ -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. diff --git a/crates/benilla-app/src/shaders/ui_gamma.wgsl b/crates/benilla-app/src/shaders/ui_gamma.wgsl index 8e31a269..854dfb82 100644 --- a/crates/benilla-app/src/shaders/ui_gamma.wgsl +++ b/crates/benilla-app/src/shaders/ui_gamma.wgsl @@ -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. diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 5c838ebf..018081c9 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -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, + // 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, ), ) { - 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), @@ -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 diff --git a/crates/benilla-app/src/ui_action/cast_send.rs b/crates/benilla-app/src/ui_action/cast_send.rs index 1b2dabb0..b2385e13 100644 --- a/crates/benilla-app/src/ui_action/cast_send.rs +++ b/crates/benilla-app/src/ui_action/cast_send.rs @@ -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, ) { 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, commands: &NetCommands, self_player: &Query<(Entity, Has), With>, 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::().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::().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 diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index 144b0dda..bb884333 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -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, + script: Option>, + 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, + 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)`). /// diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index e68e027c..7235ffba 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -149,6 +149,39 @@ pub(crate) struct AutoRepeatActive(pub Option); #[derive(Resource, Default)] pub(crate) struct ChainCasts(pub(crate) Vec); +/// **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); + +/// 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::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -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. diff --git a/crates/benilla-app/src/ui_gamma.rs b/crates/benilla-app/src/ui_gamma.rs index c5d6cd1d..84e838a1 100644 --- a/crates/benilla-app/src/ui_gamma.rs +++ b/crates/benilla-app/src/ui_gamma.rs @@ -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, 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, + 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, + pipeline: Res, + mut pipelines: ResMut>, + views: Query<(Entity, &ExtractedCamera, &ViewTarget), With>, +) { + 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, fullscreen_shader: Res, asset_server: Res, - pipeline_cache: Res, ) { 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::(); let pipeline_cache = world.resource::(); - 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::>() .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::>(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), diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index 95b85efe..0def3b7e 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -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 diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index 47cd34bb..609fc560 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -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 diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs index 9a912ee3..9963a832 100644 --- a/crates/benilla-world/src/ffx_glow.rs +++ b/crates/benilla-world/src/ffx_glow.rs @@ -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, Without)>, - with: Query>, + mut with: Query<(Entity, &mut Camera), With>, ) { // 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 = 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::(); + // 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, + 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, pipeline_cache: Res, pipelines: Res, - views: Query<(Entity, &ExtractedCamera, Option<&FfxGlowTextures>), With>, + mut combine_pipelines: ResMut>, + views: Query< + ( + Entity, + &ExtractedCamera, + &ViewTarget, + Option<&FfxGlowTextures>, + ), + With, + >, ) { - 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::(); @@ -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::>() .add_systems(RenderStartup, init_pipelines) .add_systems( Render, diff --git a/crates/benilla-world/src/final_pass.rs b/crates/benilla-world/src/final_pass.rs new file mode 100644 index 00000000..ba8f338a --- /dev/null +++ b/crates/benilla-world/src/final_pass.rs @@ -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, + ) + }) + } +} diff --git a/crates/benilla-world/src/interior.rs b/crates/benilla-world/src/interior.rs index d41e18ed..d1efd070 100644 --- a/crates/benilla-world/src/interior.rs +++ b/crates/benilla-world/src/interior.rs @@ -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) diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 065408a1..e2e6b40a 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -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; diff --git a/crates/benilla-world/src/model_fade.rs b/crates/benilla-world/src/model_fade.rs index cc50d065..ea21a77b 100644 --- a/crates/benilla-world/src/model_fade.rs +++ b/crates/benilla-world/src/model_fade.rs @@ -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>, - cards: Query<(Entity, &crate::billboard::BillboardCard), With>, + // 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, With>, + cards: Query<(Entity, &crate::billboard::BillboardCard, Option<&MeshTag>), With>, ) { 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::(); } +/// 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>, + fm: &Query, With>, 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 {