diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index c7e612e8..a59c397a 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -92,6 +92,7 @@ mod phase_probe; mod pick_probe; mod probe_auction; mod probe_bank; +mod probe_bg_queue; mod probe_binder; mod probe_book; mod probe_castcancel; @@ -121,6 +122,7 @@ pub(crate) use phase_probe::PhaseProbePlugin; pub(crate) use pick_probe::PickProbePlugin; pub(crate) use probe_auction::ProbeAuctionPlugin; pub(crate) use probe_bank::ProbeBankPlugin; +pub(crate) use probe_bg_queue::ProbeBgQueuePlugin; pub(crate) use probe_binder::ProbeBinderPlugin; pub(crate) use probe_book::ProbeBookPlugin; pub(crate) use probe_castcancel::ProbeCastCancelPlugin; diff --git a/crates/benilla-app/src/capture/probe_bg_queue.rs b/crates/benilla-app/src/capture/probe_bg_queue.rs new file mode 100644 index 00000000..b1da1fdd --- /dev/null +++ b/crates/benilla-app/src/capture/probe_bg_queue.rs @@ -0,0 +1,210 @@ +//! The battleground-queue live probe (`WOW_PROBE_BGQUEUE=1`) — the instrument that makes +//! "**log in while queued for a battleground**" a shape anyone can reproduce, inert without the +//! env. +//! +//! Decision 2232 audited the 29 drains that could still lose a login burst and argued this one +//! safe on paper: `SMSG_BATTLEFIELD_STATUS` is published as `UPDATE_BATTLEFIELD_STATUS` off a +//! `mem::take` latch that no `VmMemo` re-arms, so a message consumed in 2214's one-frame window +//! is gone — but [`crate::ui_battlefield::reset_on_world_enter`] re-sends +//! `CMSG_BATTLEFIELD_STATUS` on entry and the server answers slot by slot, so the queue heals +//! itself. That record named the argument **unexercised**, and the reason it stayed unexercised +//! is that the setup is three server-side gates deep, not that nobody thought to look. +//! +//! So: level past the bracket floor, GM-hop to the Stormwind Warsong Gulch battlemaster, greet +//! him on the real wire (`CMSG_BATTLEMASTER_HELLO` → `SMSG_BATTLEFIELD_LIST`), queue through the +//! guid that list carried (`CMSG_BATTLEMASTER_JOIN`), and report the slot the server put us in. +//! The probe then **leaves the body queued** — vmangos keeps the queue entry across a logout +//! (`BattleGroundQueue::PlayerLoggedOut` only marks it offline) and re-sends the status from +//! `Player::LoadFromDB`, i.e. inside the login burst. The NEXT plain login is the measurement; +//! this run is its fixture. +//! +//! **Why the guid and not `CMSG_BATTLEFIELD_JOIN`.** vmangos routes the bodyless join through +//! `RequestBgJoinQueue(ObjectGuid{}, …)`, whose `GetNPCIfCanInteractWith(battlemaster, +//! UNIT_NPC_FLAG_BATTLEMASTER)` cannot resolve an empty guid — it logs an anticheat line and +//! returns, silently. Only the battlemaster leg queues anyone, which is why this probe has to +//! stand next to one. +//! +//! **The undo is one line**, and it belongs here rather than in whatever record cites the probe: +//! the body stays queued until something dequeues it, so leave it clean with +//! `WOW_PROBE_LUA='AcceptBattlefieldPort(1,0)'` on a plain login — `GetBattlefieldStatus(1)` then +//! answers `none`. A body left queued is not harmful, only surprising: every later login on that +//! account carries an extra `SMSG_BATTLEFIELD_STATUS` in its burst. +//! +//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN +//! WOW_CHAR=Probe`; `method.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when +//! it runs unattended. One `timeout`'d run plus a grep for `PROBE bgqueue:` is the whole harness. + +use bevy::prelude::*; + +use super::probes::ProbeClock; +use crate::net::{ClientCommand, Guid, NetEntity, ObjectStore, SelfPlayer}; +use crate::player::Player; +use crate::target::cursor_mode::npc_flags; +use crate::ui_battlefield::Battlefield; +use crate::ui_dialog_verbs::BattlefieldQueue; + +/// Elfarran's spawn (vmangos `creature` guid 54614, entry 14981, map 0) — Stormwind's Warsong +/// Gulch battlemaster, `battlemaster_entry` row `14981 → bg_template 2`. The `.go xyz` target; +/// the battlemaster himself is then found in the streamed world by his npc flag, never by a guid. +const BATTLEMASTER_AT: [f32; 3] = [-8454.62, 318.85, 120.97]; +/// Warsong Gulch's Map.dbc row — what `GetBattleGroundTypeIdByMapId` turns back into template 2. +const WSG_MAP: u32 = 489; +/// The level the probe body is raised to before queueing. +/// +/// `battleground_template` carries one row per content patch, and the server picks the highest at +/// or below its own: WSG opens at **10** on patch 6, **20** on patch 5 and **21** on patch 3. 25 +/// clears all three, so the probe never has to know which patch the local server is running — +/// a wrong guess there fails as a bare `LANG_YOUR_BG_LEVEL_REQ_ERROR` notification with no queue, +/// which reads exactly like a broken client. +const QUEUE_LEVEL: u32 = 25; +/// `UNIT_NPC_FLAG_BATTLEMASTER`'s service range is the ordinary NPC one; 15 yd is the hop's +/// settle radius, not a gate — the server applies its own interaction check. +const NEAR_YD: f32 = 15.0; + +pub(crate) struct ProbeBgQueuePlugin; + +impl Plugin for ProbeBgQueuePlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, bg_queue_probe); + } +} + +#[derive(Resource, Default)] +struct BgQueueProbe { + phase: Phase, +} + +/// `Wait` → (levelled + GM hop sent) `Hopped` → (battlemaster found, hello sent) `Greeted` → +/// (the list landed, join sent) `Joined` → verdict → `Done`. +#[derive(Default, PartialEq)] +enum Phase { + #[default] + Wait, + Hopped { + sent_at: f64, + }, + Greeted { + master: u64, + sent_at: f64, + }, + Joined { + sent_at: f64, + }, + Done, +} + +// One Bevy system's full input set (the guard-poi probe's shape). +#[allow(clippy::too_many_arguments)] +fn bg_queue_probe( + time: ProbeClock, + mut probe: ResMut, + me: Query<&ObjectStore, With>, + player: Res, + battlefield: Res, + queue: Res, + units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without>, + net: Res, +) { + let Ok(store) = me.single() else { + return; + }; + let now = time.elapsed_secs_f64(); + match probe.phase { + Phase::Wait => { + // **Revive first, unconditionally.** `CanInteractWithNPC` refuses a dead player + // outright (`!IsAlive() && !VISIBLE_TO_GHOSTS`), and the refusal surfaces as an + // anticheat line about an "invalid creature" — which reads like a wrong guid, not + // like a corpse. A probe body parks where the last run left it, and this one is + // easy to leave drowned; `.revive` on a living character is a no-op. + let _ = net.0.send(ClientCommand::Chat { + kind: crate::net::ChatKind::Say, + target: None, + text: ".revive".to_string(), + }); + // The level next: a body under the bracket floor is refused at the HELLO, before the + // list is ever built, so there would be nothing to queue through. + let level = store.0.unit_level().unwrap_or(0); + if level < QUEUE_LEVEL { + info!("PROBE bgqueue: level {level} — raising to {QUEUE_LEVEL} for the bracket"); + let _ = net.0.send(ClientCommand::Chat { + kind: crate::net::ChatKind::Say, + target: None, + text: format!(".levelup {}", QUEUE_LEVEL - level), + }); + } + let [x, y, z] = BATTLEMASTER_AT; + info!("PROBE bgqueue: hopping to the Warsong Gulch battlemaster at ({x}, {y}, {z})"); + let _ = net.0.send(ClientCommand::Chat { + kind: crate::net::ChatKind::Say, + target: None, + text: format!(".go xyz {x} {y} {z} 0"), + }); + probe.phase = Phase::Hopped { sent_at: now }; + } + Phase::Hopped { sent_at } => { + if now - sent_at < 3.0 { + return; // post-teleport settle: let the battlemaster stream in + } + let here = player.pos; + let master = units.iter().find(|(_, net_e, store, tf)| { + net_e.kind == benilla_protocol::EntityKind::Unit + && store.0.unit_npc_flags() & npc_flags::BATTLEMASTER != 0 + && tf.translation.distance(here) < NEAR_YD + }); + if let Some((guid, ..)) = master { + info!( + "PROBE bgqueue: battlemaster {:#x} in range — greeting", + guid.0 + ); + let _ = net.0.send(ClientCommand::BattlemasterHello { npc: guid.0 }); + probe.phase = Phase::Greeted { + master: guid.0, + sent_at: now, + }; + } else if now - sent_at > 15.0 { + error!("PROBE bgqueue: FAILURE — no battlemaster streamed in within 15 s"); + probe.phase = Phase::Done; + } + } + Phase::Greeted { master, sent_at } => { + // Waiting for the LIST, not for a clock: the guid it carries is the one the join has + // to quote, and its arrival is the proof the level gate was cleared. + if battlefield.battlemaster() == Some(master) { + info!("PROBE bgqueue: list landed — queueing for map {WSG_MAP}"); + let _ = net.0.send(ClientCommand::BattlemasterJoin { + battlemaster: master, + map_id: WSG_MAP, + instance_id: 0, + as_group: false, + }); + probe.phase = Phase::Joined { sent_at: now }; + } else if now - sent_at > 8.0 { + error!( + "PROBE bgqueue: FAILURE — no SMSG_BATTLEFIELD_LIST 8 s after the hello \ + (a level under the bracket floor is refused here, silently)" + ); + probe.phase = Phase::Done; + } + } + Phase::Joined { sent_at } => { + let queued = queue + .slots() + .iter() + .enumerate() + .find_map(|(i, s)| s.as_ref().map(|(status, _)| (i, status))); + if let Some((slot, status)) = queued { + info!( + "PROBE bgqueue: QUEUED — slot {slot}, map {}, status {}, instance {} \ + (the body stays queued; the next plain login is the measurement)", + status.map_id, status.status, status.instance_id, + ); + probe.phase = Phase::Done; + } else if now - sent_at > 15.0 { + error!("PROBE bgqueue: FAILURE — no queue slot 15 s after CMSG_BATTLEMASTER_JOIN"); + probe.phase = Phase::Done; + } + } + Phase::Done => {} + } +} diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 2bada3d6..f06dc6e9 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -311,6 +311,13 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_PROBE").as_deref() == Ok("guardpoi") { app.add_plugins(crate::capture::ProbeGuardPoiPlugin); } + // The battleground-queue live probe: `WOW_PROBE_BGQUEUE=1` levels past the bracket + // floor, greets Stormwind's Warsong Gulch battlemaster on the real wire and queues + // through the guid his list carried — the fixture that makes "log in while queued" + // reproducible (decision 2232's unexercised shape; see `capture::ProbeBgQueuePlugin`). + if std::env::var("WOW_PROBE_BGQUEUE").is_ok() { + app.add_plugins(crate::capture::ProbeBgQueuePlugin); + } // The mail-arc live probe: `WOW_PROBE_MAIL=1` GM-mails the probe's own character, opens the // Goldshire mailbox on the real wire, and drives the inbox/take/send/delete surface through // the live Lua VM — decisions 0544/0548's end-to-end instrument (see `capture::ProbeMailPlugin`). diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 141f64ba..54a2ad52 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -608,9 +608,10 @@ pub fn run(build: BuildId) -> AppExit { // The player-UI quad pass (decision 0068 §2): its own composited-above-the-world, // below-the-egui-dev-overlays camera + sorted-quad renderer. `$WOW_UI_DEMO=1` seeds a proof scene. .add_plugins(PlayerUiPlugin) - // The world's frame, rendered off-screen and handed to the UI pass as its first quad — the - // seam that puts the UI-over-world blend back into gamma bytes (0161/0254's last piece). - // Registered AFTER the UI pass: it writes `UiQuads`, which that plugin owns. + // The world's frame, rendered off-screen and drawn first in the UI camera's main pass — + // the seam that puts the UI-over-world blend back into gamma bytes (0161/0254's last piece, + // a pass rather than a quad since 2234). Registered AFTER the UI pass: it points that + // plugin's camera at the world camera. .add_plugins(WorldBackdropPlugin) // The HUD minimap (decision 0203 phase 1): fills the `` widget's extracted hole with // the streamed tile window + mask + player arrow, and feeds the zone text. diff --git a/crates/benilla-app/src/perf/journal.rs b/crates/benilla-app/src/perf/journal.rs index 8ca74f74..e17c2fd3 100644 --- a/crates/benilla-app/src/perf/journal.rs +++ b/crates/benilla-app/src/perf/journal.rs @@ -118,7 +118,9 @@ enum GpuBucket { Static, /// bevy's transparent (and transmissive) 3D passes — water, glow cards, particles. Transparent, - /// The `ffx_glow` chain: the quarter-res downsample, the two Gauss taps, the combine. + /// The `ffx_glow` chain: the quarter-res downsample, the two Gauss taps — and a bake's + /// combine. The world's combine is the first draw of the UI camera's main pass since 2234, + /// nested under `main_transparent_pass_2d`, so it lands in [`Self::Ui`] with that pass. Glow, /// The full-screen tail on every camera: tonemapping, upscaling, the MSAA writeback. Post, diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index b2423700..4a6a6c8b 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -804,7 +804,9 @@ mod tests { // 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.) + // and compiles under the same cover: the player-UI camera's backdrop pair is keyed on + // that camera's own main texture and specialised on its first frame, pre-world, and a + // bake's pair on the bake 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() diff --git a/crates/benilla-app/src/player/setup.rs b/crates/benilla-app/src/player/setup.rs index e30af1d8..a0b422ee 100644 --- a/crates/benilla-app/src/player/setup.rs +++ b/crates/benilla-app/src/player/setup.rs @@ -25,11 +25,13 @@ 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. +/// The world camera's `Camera` — output mode `Skip` (decisions 2206 and 2234, +/// [`benilla_world::final_pass`]): nothing writes this camera's target, and bevy's `upscaling` +/// blit — a pure copy of the finished frame into it — is skipped. The world's final pass, the +/// FFXGlow combine, is the first draw of the player-UI camera's main pass now +/// (`benilla_world::ffx_glow::FfxBackdrop`), reading this view's finished main texture straight +/// into the interface's byte buffer; the target the camera carries is a size-carrier only +/// ([`crate::world_backdrop`]). fn world_camera_output() -> Camera { Camera { output_mode: CameraOutputMode::Skip, @@ -131,9 +133,8 @@ pub(super) fn setup_player( // tonemapper + scene-referred lighting for a modern look.) 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, which it now - // writes straight into the backdrop (2206). + // The faithful FFXGlow pass (decision 0158/0161): the byte-pinned `scene + glow·blur²`. + // This view runs its blur; its combine is the UI camera's own ground pass (2234). 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), diff --git a/crates/benilla-app/src/tutorial.rs b/crates/benilla-app/src/tutorial.rs index a41602af..67de1ef6 100644 --- a/crates/benilla-app/src/tutorial.rs +++ b/crates/benilla-app/src/tutorial.rs @@ -580,7 +580,27 @@ impl Plugin for TutorialPlugin { run_world_enter_cascade.before(feed_tutorials), watch_windows.before(feed_tutorials), watch_self.after(on_world_enter).before(feed_tutorials), - feed_tutorials.before(UiInput), + // **Not before the in-game UI exists** (decision 2232). This is the one feed + // in 2226's 29-system audit that could still lose a login's own payload: + // `run_world_enter_cascade` triggers WELCOME and QUESTGIVERS the moment + // `SelfPlayer` exists, a 0-delay `trigger` pushes straight into `fired`, and + // the `mem::take(&mut tutorials.fired)` below fires `TUTORIAL_TRIGGER` at + // whatever VM is in the world. No `VmMemo` sits behind that publication, so + // 2226's fresh session cannot bring it back. + // + // The VM guard already covers the *park* — it sits above the take, so a + // parked frame returns with `fired` intact. What it does not cover is the + // one-frame window where the wire is in-world and the boot VM is still live + // (2214): `on_world_enter` reads `EnteredWorldMessage` out of the same drain + // that writes it, so the arm, the trigger and the take can all land in that + // one frame — the coin flip being whether `apply_net_updates`' spawn command + // has applied yet, which nothing here declares an edge against. + // + // The gate costs nothing: the cascade still arms and still triggers, `fired` + // simply waits, and the first frame with an interface delivers the set. + feed_tutorials + .before(UiInput) + .run_if(crate::ui_script::ingame_ui_up), drain_tutorials.after(UiInput), ), ) @@ -783,4 +803,63 @@ mod tests { app.update(); assert!(enabled(&mut app), "a moved bank reaches the live VM"); } + + /// **The login's own tutorials wait for an interface** (decision 2232) — built on the REAL + /// plugin, so it fails if the gate is taken off the registration rather than off a copy of it. + /// + /// `TUTORIAL_TRIGGER` has no [`crate::ui_script::VmMemo`] behind it: `trigger` sets the bank + /// bit and pushes the id into `fired`, and the feed's `mem::take` fires it once at whatever VM + /// is in the world. So unlike every one-shot 2226 covers, a firing lost to a frameless VM + /// cannot be recovered by the entry load minting a new session — there is nothing left to + /// re-spend. `run_world_enter_cascade` puts WELCOME and QUESTGIVERS into exactly that position + /// on every login. + /// + /// Against the ungated shape the first assertion reads *"`fired` is empty — the login's two + /// tutorials were published to a VM with no frames and are gone for the session"*. + #[test] + fn the_world_enter_tutorials_are_not_fired_into_a_ui_less_vm() { + let mut app = App::new(); + app.add_plugins(TutorialPlugin) + .init_resource::() + .init_resource::() + // The plugin's other watchers' inputs — none of them is the subject here; they are + // present so the REAL plugin can be driven rather than a copy of one of its systems. + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_message::(); + let (tx, _rx) = crossbeam_channel::unbounded(); + app.insert_resource(crate::net::NetCommands(tx)); + + // The deferral window (1978/2214): in the world on the wire, the entry load still owed, + // and a live boot VM that has strings and fonts but not one frame. + app.insert_resource(State::new(crate::char_select::ClientState::InWorld)); + app.insert_resource(crate::ui_script::PendingEntryUiLoad); + app.insert_non_send_resource(UiScript::new().expect("the boot VM")); + + { + let mut t = app.world_mut().resource_mut::(); + t.apply_flags(&[0u8; 32]); + t.trigger(id::WELCOME, 0, Instant::now()); + } + + app.update(); + assert_eq!( + app.world().resource::().fired, + vec![id::WELCOME + 1], + "the window must not publish: there is no frame registered for TUTORIAL_TRIGGER, and \ + the take is one-way" + ); + + // The interface comes up; the same frame delivers what was waiting. + app.world_mut() + .remove_resource::(); + app.update(); + assert!( + app.world().resource::().fired.is_empty(), + "…and once there is an interface, it is published exactly once" + ); + } } diff --git a/crates/benilla-app/src/ui_battlefield.rs b/crates/benilla-app/src/ui_battlefield.rs index 70c25696..1cf4adce 100644 --- a/crates/benilla-app/src/ui_battlefield.rs +++ b/crates/benilla-app/src/ui_battlefield.rs @@ -98,6 +98,13 @@ impl Battlefield { self.players.clear(); } + /// The guid the last `SMSG_BATTLEFIELD_LIST` came from — the join's opcode choice reads it, + /// and [`crate::capture::ProbeBgQueuePlugin`] waits on it to know the list actually landed + /// (a body under the bracket floor is refused at the HELLO, so the list never arrives). + pub(crate) fn battlemaster(&self) -> Option { + self.list.as_ref().map(|l| l.battlemaster) + } + /// The listed map — `[0xb6eba4]`, which is 0 (a real Map.dbc row) with nothing listed. fn map_id(&self) -> u32 { self.list.as_ref().map_or(0, |l| l.map_id) diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index 74efcf53..203f29c3 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -15,9 +15,10 @@ //! 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); 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). +//! layer 0); the world reaches it as the first DRAW of its main pass — the FFXGlow combine, +//! `benilla_world::ffx_glow::FfxBackdrop` ([`crate::world_backdrop`], decision 2234) — 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 @@ -297,17 +298,6 @@ impl Default for UiQuad { /// whole UI re-batched every frame (the 0365 live-city churn). #[derive(Resource, Default)] pub(crate) struct UiQuads { - /// The **world backdrop** ([`crate::world_backdrop`]): the frame the world camera rendered, - /// drawn before every other quad so the UI blends over it in the same gamma bytes it blends - /// over itself in. Not part of either lane and not sorted with them — it is not content - /// competing for a `z_key`, it is the ground. `None` whenever there is no world to paint (the - /// glue screens, the loading screen, a gated camera). - /// - /// It changes no batching decision beyond being first, and it flags [`Self::dirty`] only when - /// the QUAD changes — arrival, departure, a resize. Its image's contents change every frame and - /// deliberately do not flag anything: the batch holds the handle and the material samples - /// whatever the world camera just rendered into it. - pub backdrop: Option, pub quads: Vec, /// The append lane — see the struct doc. Compared by the rebuild, never flagged. pub overlays: Vec, @@ -371,9 +361,10 @@ fn ui_render_layers() -> RenderLayers { /// camera (bumped to order 2 in `debug_panel::spawn_egui_camera`). See the module doc's arbitration note. const UI_CAMERA_ORDER: isize = 1; -/// Marker on the player-UI camera. +/// Marker on the player-UI camera. `pub(crate)` for one writer: [`crate::world_backdrop`], which +/// points the camera's ground pass at the world camera drawing this frame. #[derive(Component)] -struct PlayerUiCamera; +pub(crate) struct PlayerUiCamera; /// Marker on each rebuilt batch mesh entity, so [`rebuild_ui_mesh`] can despawn last frame's batches /// before spawning the new ones. `pub(crate)` for one reader: `FPS_PROBE`'s `ui_batches=`, the @@ -624,9 +615,10 @@ fn spawn_ui_camera(mut commands: Commands) { // (the portrait booths, the minimap composite); this one simply never did, and paid for it. // // There is nothing here for multisampling to resolve. The world arrives already resolved — - // since decision 1603 the world camera renders offscreen and this pass draws the finished - // image as its first quad ([`crate::world_backdrop`]), so this camera's samples only - // re-average an image whose own MSAA is long since done. What it draws itself is + // since decision 1603 the world camera renders offscreen, and since 2234 this camera's main + // pass draws that finished image straight in first ([`crate::world_backdrop`]), so this + // camera's samples would only re-average a picture whose own MSAA is long since done. What + // it draws itself is // axis-aligned rects, and the Bevy UI trees riding this camera (decision 0541) antialias // their own edges analytically in-shader. What it *costs* is a full-window 4× sampled // colour texture, a full-window 4× multisampled Core2d depth texture (Bevy sizes that one @@ -637,6 +629,10 @@ fn spawn_ui_camera(mut commands: Commands) { // The gamma composite lane's mandatory decode (decision 0254) — without it the UI presents // ~2.2× bright, since the quad pass leaves gamma values in the target. crate::ui_gamma::UiGammaLane::default(), + // The world is the first draw of this camera's main pass (decision 2234): the FFXGlow + // combine of the world camera [`crate::world_backdrop`] points it at, rendered into this + // target ahead of the quads. `source` is `None` until a world camera draws. + benilla_world::ffx_glow::FfxBackdrop::default(), // Every Bevy UI tree renders HERE (decision 0541) — the glue screens and the loading screen. // Without the marker, Bevy UI picks the highest-order camera targeting the window, which is // the egui dev overlay (order 2): the glue screens rode the dev camera, outside the gamma @@ -655,7 +651,7 @@ fn spawn_ui_camera(mut commands: Commands) { // 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 + // into this camera'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 @@ -1039,13 +1035,16 @@ fn rebuild_ui_mesh( // // A cached material holds a strong `Handle`, and its PREPARED form holds the whole GPU // texture behind a bind group Bevy never re-prepares. So an entry keyed on a retired asset - // pins that texture for as long as the cache holds the key — and the world backdrop retires a - // full-window `Rgba16Float` image (46 MB at 3200×1800) on every resize and every render-scale - // change, which is once a frame while a window is being dragged (decision 1647). + // pins that texture for as long as the cache holds the key. The producer that taught this was + // the world backdrop — a full-window `Rgba16Float` image (46 MB at 3200×1800) retired on every + // resize, once a frame while a window is being dragged (decision 1647); since 2234 no quad + // samples the world's target at all, and the hygiene stays for every other image a quad names + // and then loses. // // `Removed` only, not `Unused`: `Unused` fires when the last strong handle drops, and this - // cache IS a strong handle, so for a cached texture it can never fire. The producer removes - // the asset explicitly (`world_backdrop::track_render_size`) precisely so this can hear it. + // cache IS a strong handle, so for a cached texture it can never fire. A producer that retires + // an image removes the asset explicitly (`world_backdrop::track_render_size` still does) + // precisely so this can hear it. let retired: Vec> = stores .2 .read() @@ -1087,21 +1086,24 @@ fn rebuild_ui_mesh( // TOGGLEUI hides at the *draw*, not at the producers: both lanes keep filling, so the UI comes // back exactly as it was (see [`crate::ui_hide::UiHidden`]). // - // **It hides the two LANES, never the backdrop.** The world reaches the screen as this pass's - // own first quad now ([`crate::world_backdrop`]), so the older "retire every batch while - // hidden" would black the screen — the exact inverse of a binding whose stated point is to - // leave "the world and nothing else". While dark we still swallow each frame's change flag and - // keep the append-lane mirror current, so neither lane can hand the rebuild a stale "nothing - // changed" the moment the UI returns. The edge is the resource's own change tick (`UiHidden` is - // written only by the binding and the world-exit reset), not a `Local` mirror. + // **It hides the two LANES, and the world is in neither.** The world reaches the screen as + // the first draw of this camera's main pass ([`crate::world_backdrop`], decision 2234), not + // as a batch, so + // retiring every batch while hidden leaves exactly what the binding's stated point is: "the + // world and nothing else". (Between 1603 and 2234 the world was this pass's first QUAD, and + // the dark path had to keep one batch alive to keep the screen from going black.) While dark + // we still swallow each frame's change flag and keep the append-lane mirror current, so + // neither lane can hand the rebuild a stale "nothing changed" the moment the UI returns. The + // edge is the resource's own change tick (`UiHidden` is written only by the binding and the + // world-exit reset), not a `Local` mirror. if hidden.is_changed() { q.dirty = true; } let lanes_hidden = hidden.0; if lanes_hidden { q.last_overlays.clone_from(&q.overlays); - // Nothing either lane produces can move a pixel while dark, so only the toggle edge and - // the backdrop's own arrival/departure (which sets `dirty`) reach the rebuild below. + // Nothing either lane produces can move a pixel while dark, so only the toggle edge + // reaches the rebuild below. if !q.dirty { return; } @@ -1153,7 +1155,7 @@ fn rebuild_ui_mesh( return; }; let lanes_empty = lanes_hidden || (q.quads.is_empty() && q.overlays.is_empty()); - if q.backdrop.is_none() && lanes_empty { + if lanes_empty { retire_batches(&mut pools, &mut commands); q.dirty = false; q.last_overlays.clone_from(&q.overlays); @@ -1177,13 +1179,6 @@ fn rebuild_ui_mesh( sorted.sort_by_key(|q| q.z_key); let n_sorted = sorted.len(); let us_sort = lap(); - // The backdrop is PREPENDED, not sorted in. Giving it a `z_key` would mean picking a number - // below every other producer's and trusting all of them to stay above it — and the append - // lane's lowest band is already 0 (`overlay_z::WORLD_TEXT`), so there is no room under it - // without renumbering a total order that encodes fidelity facts. Position, not arithmetic. - if let Some(backdrop) = q.backdrop.as_ref() { - sorted.insert(0, backdrop); - } // Geometry probe (`WOW_UI_PROBE=1`): dump each textured quad's screen rect once — the // capture-harness companion for diagnosing extracted-vs-rendered geometry by data instead of @@ -1767,8 +1762,8 @@ mod tests { /// The cache holds a strong `Handle` per entry and its prepared form holds the whole GPU /// texture behind a bind group Bevy never re-prepares — so an entry keyed on an asset that no /// longer exists pins that texture indefinitely. Nothing here notices until something retires - /// images in bulk, and the world backdrop does exactly that: one full-window `Rgba16Float` - /// image per resize, which is once a frame while a window is being dragged. + /// images in bulk, and the world backdrop did exactly that while it was a quad (1603–2234): + /// one full-window `Rgba16Float` image per resize, once a frame while a window was dragged. /// /// The removal is seen a few frames late by construction (the loop below names the three /// lags). That is fine for memory hygiene and would NOT be fine for correctness — which is @@ -1842,26 +1837,18 @@ mod tests { .any(|(_, m)| m.texture.as_ref().is_some_and(|t| t.id() == id)) } - /// **TOGGLEUI hides the UI, not the world.** Since the world arrives as this pass's own - /// backdrop quad ([`crate::world_backdrop`]), the dark path can no longer mean "retire every - /// batch" — that would black the screen, which is the exact inverse of what the binding is - /// for. One batch survives while dark (the backdrop's), and the lanes come back on top of it. + /// **TOGGLEUI hides the UI, not the world — and the world is not a batch.** The world is the + /// first draw of the UI camera's main pass (`benilla_world::ffx_glow::FfxBackdrop`, 2234), not a + /// quad in either lane, so the dark path is what it was before 1603 put a backdrop quad in + /// here: retire every batch. That leaves "the world and nothing else", which is the binding's + /// stated point, and the lanes come back on top of it exactly as they were. /// - /// This is the regression the change itself created: every earlier version of the hidden path - /// asserted zero batches, and zero batches is now a black screen. + /// (Between 1603 and 2234 this test held ONE batch alive while dark — the backdrop's — because + /// zero batches was then a black screen. A future version that keeps a batch alive while dark + /// is drawing something the binding says must not be drawn.) #[test] - fn toggleui_keeps_the_world_backdrop() { + fn toggleui_retires_every_batch_and_the_world_is_none_of_them() { let mut app = rebuild_app(); - let backdrop = UiQuad { - rect: Rect::from_corners(Vec2::ZERO, Vec2::new(800.0, 600.0)), - texture: None, - ..UiQuad::default() - }; - { - let mut q = app.world_mut().resource_mut::(); - q.backdrop = Some(backdrop); - q.dirty = true; - } app.update(); let lit = batches(&mut app); assert!(lit >= 1, "content drawn to begin with"); @@ -1870,8 +1857,8 @@ mod tests { app.update(); assert_eq!( batches(&mut app), - 1, - "dark ⇒ the backdrop alone; anything less is a black screen" + 0, + "dark ⇒ nothing drawn: the world is this camera's own pass, not a batch" ); set_hidden(&mut app, false); @@ -1883,19 +1870,6 @@ mod tests { ); } - /// With no world to paint (the glue screens, the loading screen, a gated camera) the dark path - /// is the old one: nothing at all. The backdrop earns an exemption because it IS the world, - /// not because it is first in the list. - #[test] - fn toggleui_with_no_backdrop_still_retires_everything() { - let mut app = rebuild_app(); - app.update(); - assert_eq!(batches(&mut app), 1, "one batch drawn to begin with"); - set_hidden(&mut app, true); - app.update(); - assert_eq!(batches(&mut app), 0, "no world, no backdrop, nothing drawn"); - } - /// **The desaturation flag reaches the MATERIAL, and splits the run** (decision 1327). /// /// Everything upstream of here can be right — the Lua sets it, extract carries it, the quad diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index 2ad80c28..997ce1d0 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -2889,7 +2889,7 @@ mod tests { script .run("KeeperDB.count = 7 KeeperDB.note = 'hi' KeeperChar = 'mine'") .unwrap(); - crate::ui_script::shutdown_ui_state(&mut script, Some(&id)); + crate::ui_script::shutdown_ui_state(&mut script, Some(&id), true); // ── session two: a fresh VM reads them back ── let mut next = UiScript::new().unwrap(); @@ -2949,7 +2949,7 @@ mod tests { script .run("GramDB = { ['on'] = true, ['n'] = 2, ['s'] = 'a\\\"b', ['t'] = { 1 } } GramChar = 5") .unwrap(); - crate::ui_script::shutdown_ui_state(&mut script, Some(&id)); + crate::ui_script::shutdown_ui_state(&mut script, Some(&id), true); let account = std::fs::read_to_string(home.join("saved/Gram.lua")).unwrap(); let body = account.lines().skip(2).collect::>().join("\n"); @@ -3008,7 +3008,7 @@ mod tests { let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, Some(&id), true); - crate::ui_script::shutdown_ui_state(&mut script, Some(&id)); + crate::ui_script::shutdown_ui_state(&mut script, Some(&id), true); let written = std::fs::read_to_string(home.join("saved/Last.lua")).unwrap(); assert!( diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index ed65bf5b..5b3e9d9d 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -499,6 +499,14 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { .get_resource::() .is_none_or(crate::cvars::CvarPersist::addon_version_check) }); + // **The world map's catalog, before the first addon file runs** (decision 2240). The continent + // and zone lists behind `GetMapContinents`/`GetMapZones` are static DBC data, and the corpus + // reads them at file scope: Astrolabe — the positioning library under Questie and Cartographer + // — builds its entire continent → zone table inside `AceLibrary:Register`'s synchronous + // `activate`. Pushed from an `Update` system it landed ~210 ms after this whole call returned, + // so that table was built from two empty lists and every icon placement afterwards indexed a + // nil zone. Same shape as the four seeds above, and the reference has no timing here at all. + crate::ui_world_map::seed_world_map_catalog(world, &mut script); // **The VM's font engine, before the first `` runs** (decision 2028). Every file the // walk below loads may measure the text it just set — the era's own tab law is // `label:GetStringWidth() + 40` at OnLoad, and the addon corpus writes the same pair — and a @@ -593,6 +601,18 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { script.clear_instruction_budget(); world.insert_non_send_resource(script); world.insert_resource(AddOnIdentity(identity)); + // **The UI load's own arm of the world latch** (2239) — the reference's `0x490168 call + // 0x4908c0`, `UI_Init`'s conditional leg into the world-enter cascade. It is what makes a + // SECOND `/reload` fire `PLAYER_LEAVING_WORLD` again after the first spent the latch: no + // entity is created or destroyed by a reload, so nothing else here would re-arm it. + // + // Unconditional, where the reference guards on the active-player GUID pair (`0x490166 je`) + // and therefore *skips* this leg on a fresh login, arming from the player's create instead. + // The outcome is the same byte either way — both roots reach "armed" — and reproducing which + // of the two did it would buy nothing observable. + if let Some(mut armed) = world.get_resource_mut::() { + armed.arm(); + } } /// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in @@ -656,6 +676,68 @@ pub(crate) fn seat_from_roster( #[derive(Resource, Default)] pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>); +/// **The world latch — `[0xb4b424]`, ours** (decision 2239). +/// +/// `PLAYER_LEAVING_WORLD` has one fire site (`0x490b4d`) and three callers (2238), and the +/// reference does not keep them apart by asking each one "is this your occasion?". It keeps a +/// single byte: **set at `0x4908ce` inside the world-enter cascade `0x4908c0`, cleared at +/// `0x490a8d` by the fire itself**, so whichever caller runs first in a world fires and the rest +/// are no-ops until the next world entry re-arms it. +/// +/// We have two producers — [`shutdown_ui_state`]'s tail and +/// [`crate::ui_unit::fire_leaving_world_on_worldport`] — and 2238 gated the tail on a *predicate* +/// instead (was the client `InWorld`), which is the shape this replaces. The predicate was wrong +/// in the one window that matters: a cross-map worldport despawns our avatar and re-streams it +/// (`net::apply`'s `tag_self_player`) **without leaving `InWorld`**, so a quit on the loading +/// screen fired the event a second time where the reference fires none. Enumerating occasions +/// found three of four; the latch is the mechanism, and the fourth falls out of it. +/// +/// Reading the enumeration back through the latch, every case is one rule: +/// +/// | occasion | latch | fires | +/// |---|---|---| +/// | `/logout`, disconnect | armed since the login | yes — and our avatar is already despawned by then, which is why a "is there a player?" predicate would have broken this, the commonest root | +/// | cross-map worldport | armed | yes, and spends it | +/// | quit *during* that port's loading screen | spent, not yet re-armed | **no** — the port's fire was this departure's | +/// | quit at the character screen | spent by the logout that got there | **no** | +/// | in-world `/reload` | armed | yes; the rebuild re-arms, so a second `/reload` fires again | +#[derive(Resource, Default)] +pub(crate) struct LeavingWorldArmed(bool); + +impl LeavingWorldArmed { + /// Arm it — a world began. Idempotent, like the reference's `mov byte [0xb4b424],1`. + pub(crate) fn arm(&mut self) { + self.0 = true; + } + + /// Take the one-shot: `true` at most once per world, `0x490a8d`'s clear folded in. + pub(crate) fn spend(&mut self) -> bool { + std::mem::take(&mut self.0) + } + + /// Read without taking — tests only, so the arm and the spend can be asserted separately. + #[cfg(test)] + pub(crate) fn is_armed(&self) -> bool { + self.0 + } +} + +/// Arm the latch when the local player's entity appears — the reference's `0x5deb60` entry into +/// the world-enter cascade, which is the one a fresh login and a worldport's new-world create +/// block both take (`0x5deb49 call 0x468570` / `0x5deb50 jne 0x5deb6a`). +/// +/// `Added` rather than a hook inside `net::apply::tag_self_player`, so the net layer +/// keeps knowing nothing about the UI's event law; the edge is the same one, read from the other +/// side. +pub(crate) fn arm_leaving_world_on_self_create( + created: Query<(), Added>, + mut armed: ResMut, +) { + if !created.is_empty() { + armed.arm(); + } +} + /// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail wow-5875-re /// carves as (`system/ui/ui.md`): /// @@ -696,8 +778,24 @@ pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>); /// **There is no autosave**, deliberately: the reference has none (decision 1128, and /// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles /// a few times a session, and every file is written whole from the live globals. -pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) { - script.fire_event("PLAYER_LEAVING_WORLD", vec![]); +pub(crate) fn shutdown_ui_state( + script: &mut UiScript, + identity: Option<&(String, String)>, + leaving_world: bool, +) { + // **`PLAYER_LEAVING_WORLD` is the one step of this tail that is conditional** (2238/2239). + // The reference reaches the fire through `0x490c20 call 0x490a80` and gets there past two + // independent tests: `0x490bd0`'s own active-player guard (`0x490bee call 0x468550` / + // `0x490bf3 or eax,edx` / `0x490bf5 je 0x490c25`, whose taken side skips exactly that one + // instruction and lands on the `PLAYER_LOGOUT` block) and then `0x490a80`'s world latch. + // `PLAYER_LOGOUT` at `0x490c2a` is outside both and fires on every root. + // + // `leaving_world` is [`LeavingWorldArmed::spend`]'s answer, and the caller spends it rather + // than this function so that the one-shot is visibly *taken* at the root — there are two + // producers, and a latch read in the callee would let a second root take it again. + if leaving_world { + script.fire_event("PLAYER_LEAVING_WORLD", vec![]); + } script.fire_event("PLAYER_LOGOUT", vec![]); crate::ui_layout::save_now(script, identity); crate::ui_saved::save(script); @@ -732,9 +830,14 @@ pub(crate) fn end_ui_session(world: &mut World) { let identity = world .get_resource::() .and_then(|id| id.0.clone()); + // Spent before the tail, at the root, because there are two producers (2239): this edge and + // the worldport's. Whichever reaches a departure first owns it. + let leaving_world = world + .get_resource_mut::() + .is_some_and(|mut l| l.spend()); if !ui_never_loaded { if let Some(mut script) = world.get_non_send_resource_mut::() { - shutdown_ui_state(&mut script, identity.as_ref()); + shutdown_ui_state(&mut script, identity.as_ref(), leaving_world); } } // The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after @@ -831,10 +934,16 @@ pub(crate) fn run_pending_reload(world: &mut World) { /// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather /// than a state edge because a quit from in-world never leaves `InWorld`. +/// +/// **The root that can reach the tail with nothing to leave** — a quit from the character screen, +/// and a quit on a worldport's loading screen, where the port already fired. Both fall out of +/// [`LeavingWorldArmed`] rather than being tested for (2239): the first has never been re-armed, +/// the second was spent minutes ago by the port. pub(crate) fn shutdown_on_exit( script: Option>, id: Res, pending_entry: Option>, + mut armed: ResMut, mut exits: MessageReader, ) { if exits.read().next().is_none() { @@ -844,8 +953,9 @@ pub(crate) fn shutdown_on_exit( if pending_entry.is_some() { return; } + let leaving_world = armed.spend(); if let Some(mut script) = script { - shutdown_ui_state(&mut script, id.0.as_ref()); + shutdown_ui_state(&mut script, id.0.as_ref(), leaving_world); } } diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 60aebd0e..233db30a 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -454,6 +454,11 @@ impl Plugin for UiScriptPlugin { OnExit(crate::char_select::ClientState::InWorld), end_ui_session, ) + // The world latch (2239) and its create-side arm. The resource is `init_` rather than + // `insert_` here and in [`crate::ui_unit::UiUnitPlugin`], because both of that law's + // producers live in different plugins and either may be built alone in a test. + .init_resource::() + .add_systems(Update, lifecycle::arm_leaving_world_on_self_create) // A queued `ReloadUI()` runs in `PreUpdate` — one whole frame after the drain that // queued it (the reference's own deferral, `0x495590`), and BEFORE every `Update` // system, so no per-VM seed or feed can run against the dying VM in the reload frame @@ -566,7 +571,7 @@ fn arbitrate_pointer_over_ui( mod lifecycle; pub(crate) use lifecycle::{ end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, - PendingEntryUiLoad, ReloadUiPending, + LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, }; // Consumed only from other modules' test code (the emote-table checks, the harness's UI-init // tail, the quit-once pin) — a plain re-export would warn unused in a non-test build. diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index f792082a..21873195 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -79,16 +79,28 @@ fn roster_named(name: &str, guid: u64) -> Roster { /// A hermetic state folder holding one addon — the probe — and the guards that point the whole /// client at it. Every guard must outlive the world. fn hermetic_probe(tag: &str) -> (std::path::PathBuf, EnvGuard, EnvGuard) { + hermetic_addon(tag, "SwitchProbe", PROBE_TOC, PROBE_LUA) +} + +/// [`hermetic_probe`] for any one-file addon: the folder, its `.toc` and its Lua, and the guards. +/// The whole point of every test here is what an addon sees **while its file scope runs**, and +/// that differs per question — so the probe's body is a parameter. +fn hermetic_addon( + tag: &str, + name: &str, + toc: &str, + lua: &str, +) -> (std::path::PathBuf, EnvGuard, EnvGuard) { // The pid keeps two concurrent `benilla_app` test binaries out of each other's tree, the same // reason `addons::tests::hermetic_root` carries one. let tmp = std::env::temp_dir().join(format!("benilla-world-entry-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let home = tmp.join("benilla-config"); - let dir = home.join("AddOns").join("SwitchProbe"); + let dir = home.join("AddOns").join(name); std::fs::create_dir_all(&dir).expect("probe addon dir"); - std::fs::write(dir.join("SwitchProbe.toc"), PROBE_TOC).expect("probe toc"); - std::fs::write(dir.join("SwitchProbe.lua"), PROBE_LUA).expect("probe lua"); + std::fs::write(dir.join(format!("{name}.toc")), toc).expect("probe toc"); + std::fs::write(dir.join(format!("{name}.lua")), lua).expect("probe lua"); let capture = EnvGuard::unset("WOW_CAPTURE"); let benilla_home = EnvGuard::set("BENILLA_HOME", home.to_str().expect("utf-8 temp path")); (tmp, capture, benilla_home) @@ -257,7 +269,9 @@ fn quitting_from_the_character_screen_does_not_blank_the_session_it_wrote() { let mut script = world .remove_non_send_resource::() .expect("a boot VM is live at the character screen"); - super::shutdown_ui_state(&mut script, identity.as_ref()); + // `false`: this models `shutdown_on_exit` at the CHARACTER SCREEN, and that is the one root + // the reference reaches with no active player — see the test below. + super::shutdown_ui_state(&mut script, identity.as_ref(), false); assert_eq!( std::fs::read_to_string(&saved).ok().as_deref(), @@ -269,6 +283,122 @@ fn quitting_from_the_character_screen_does_not_blank_the_session_it_wrote() { let _ = std::fs::remove_dir_all(&tmp); } +/// **The login arms the world latch, and the logout spends it** (decision 2239) — the chain that +/// makes `PLAYER_LEAVING_WORLD` fire on the commonest root of all. +/// +/// This is the regression the latch design could most easily have caused, so it is pinned rather +/// than argued. The obvious guard for the tail — "is there a player object?" — reads FALSE on a +/// `/logout` by the time the tail runs: `net::apply::session::logged_out` despawns our avatar in +/// the same drain that writes `LoggedOutMessage`, `back_on_logout` sets `NextState` off that same +/// message, and `OnExit(InWorld)` does not run until the next frame's `StateTransition`. A +/// predicate would have silenced the event on every logout to fix a quit on a loading screen. +/// +/// The latch does not care: it was armed by the entry load (the reference's `0x490168 call +/// 0x4908c0`) and nothing between there and here spends it. +#[test] +fn the_login_arms_the_world_latch_and_the_logout_spends_it() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("latch"); + let mut world = booted_world(); + // `booted_world` seats the VM and the state this file needs, not the whole `UiScriptPlugin`, + // so the latch is declared here the way the plugin declares it. + world.init_resource::(); + + assert!( + !world.resource::().is_armed(), + "the character screen is not a world — nothing has armed the latch yet" + ); + + log_in_as(&mut world, "Onehunter", 1); + assert!( + world.resource::().is_armed(), + "the entry UI load must arm the latch, or no logout ever fires PLAYER_LEAVING_WORLD" + ); + + super::end_ui_session(&mut world); + assert!( + !world.resource::().is_armed(), + "the shutdown tail must SPEND it — an unspent latch lets the following quit fire again" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + +/// **A quit from the character screen fires `PLAYER_LOGOUT` and NOT `PLAYER_LEAVING_WORLD`** — +/// the reference's one guard inside the shutdown tail (decision 2238). +/// +/// `0x490bd0` tests the object manager's active-player GUID pair (`0x490bee call 0x468550` / +/// `0x490bf3 or eax,edx` / `0x490bf5 je 0x490c25`) and the taken side skips exactly one +/// instruction — `0x490c20 call 0x490a80`, the `PLAYER_LEAVING_WORLD` fire — landing on the +/// `PLAYER_LOGOUT` block. Every in-world root takes the other side and fires both. +/// +/// Ours fired both from every root, which was invisible: the only root that reaches the tail +/// without a player is this one, and its VM has no frames and no addon files, so nothing was +/// listening. The test exists because that is an argument about *today's* boot VM, not about the +/// split — and the day a glue addon loads, an unguarded fire would be a bug with no trail back. +/// +/// Both directions in one test on purpose: asserting only the absence would pass against a +/// `shutdown_ui_state` that had stopped firing the event at all. +#[test] +fn a_quit_from_the_character_screen_fires_logout_without_leaving_world() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("quitsplit"); + let mut world = booted_world(); + let mut script = world + .remove_non_send_resource::() + .expect("a boot VM is live at the character screen"); + + script + .run( + r#" + SeenLeaving = 0 SeenLogout = 0 + local f = CreateFrame("Frame") + f:RegisterEvent("PLAYER_LEAVING_WORLD") + f:RegisterEvent("PLAYER_LOGOUT") + f:SetScript("OnEvent", function() + if event == "PLAYER_LEAVING_WORLD" then + SeenLeaving = SeenLeaving + 1 + else + SeenLogout = SeenLogout + 1 + end + end) + "#, + ) + .expect("the probe frame registers"); + + super::shutdown_ui_state(&mut script, None, false); + assert_eq!( + script.eval::("return SeenLeaving").unwrap(), + 0, + "the glue-screen quit fired PLAYER_LEAVING_WORLD — the reference's 0x490bf5 skips it" + ); + assert_eq!( + script.eval::("return SeenLogout").unwrap(), + 1, + "…and it must still fire PLAYER_LOGOUT, which is the whole of the taken side" + ); + + super::shutdown_ui_state(&mut script, None, true); + assert_eq!( + script.eval::("return SeenLeaving").unwrap(), + 1, + "an in-world root must fire PLAYER_LEAVING_WORLD — the control for the assertion above" + ); + assert_eq!( + script.eval::("return SeenLogout").unwrap(), + 2, + "…and PLAYER_LOGOUT on every root, guarded or not" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + /// Between a logout and the next login there is **no in-game UI at all** — the character screen is /// native, and the previous session's frame tree must not survive behind it. /// @@ -1295,3 +1425,74 @@ fn run_ingame_ui_up(world: &mut World) -> bool { .run_system_once(super::lifecycle::ingame_ui_up) .expect("the condition runs") } + +/// **The map catalog is in the VM before the first addon file runs** (decision 2240) — Questie's +/// `Astrolabe.lua:62: attempt to index local 'zoneData' (a nil value)`, from the side that causes +/// it. +/// +/// `GetMapContinents`/`GetMapZones` are static DBC data, and the corpus reads them at **file +/// scope**: Astrolabe — the positioning library under Questie and Cartographer — builds its whole +/// continent → zone table inside `AceLibrary:Register`'s synchronous `activate`, and every icon it +/// ever places indexes that table. Pushed from an `Update` system, the catalog landed ~210 ms after +/// this edge returned (measured live, both logins of a round trip: `conts=0 zones(1)=0`), so the +/// table was built from two empty lists and every placement afterwards indexed a nil zone. +/// +/// The catalog is planted rather than built: the question here is the ORDER, and the build itself +/// stands against the real chain in +/// [`super::world_map_tests::the_real_feralas_catalog_names_dire_maul_under_the_cursor`]. +#[test] +fn an_addon_reads_the_map_catalog_at_file_scope() { + const MAP_PROBE_TOC: &str = "\ +## Interface: 11200 +MapProbe.lua +"; + // Astrolabe's own two calls, in its own idiom — a list constructor around a multi-return. + const MAP_PROBE_LUA: &str = "\ +MapProbeContinents = table.getn({ GetMapContinents() }) +MapProbeZones = table.getn({ GetMapZones(1) }) +"; + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_addon("mapcatalog", "MapProbe", MAP_PROBE_TOC, MAP_PROBE_LUA); + let mut world = booted_world(); + world.insert_resource(crate::ui_world_map::WorldMapCatalog(vec![ + benilla_ui::script::WorldMapContinentView { + name: "Kalimdor".into(), + zones: vec![ + benilla_ui::script::WorldMapZoneView { + name: "Durotar".into(), + ..Default::default() + }, + benilla_ui::script::WorldMapZoneView { + name: "Mulgore".into(), + ..Default::default() + }, + ], + ..Default::default() + }, + ])); + + log_in_as(&mut world, "Onewarrior", 1); + + let read = |expr: &str| { + world + .get_non_send_resource::() + .and_then(|s| s.eval::>(expr).ok()) + .flatten() + }; + assert_eq!( + read("MapProbeContinents"), + Some(1), + "an addon's file scope must see the continent list — it is DBC data the reference has held \ + since load, not a feed that arrives later" + ); + assert_eq!( + read("MapProbeZones"), + Some(2), + "…and the zone list with it: this is the one Astrolabe builds its whole table from" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/crates/benilla-app/src/ui_taxi/routing.rs b/crates/benilla-app/src/ui_taxi/routing.rs index d34f46e2..73e199e7 100644 --- a/crates/benilla-app/src/ui_taxi/routing.rs +++ b/crates/benilla-app/src/ui_taxi/routing.rs @@ -34,7 +34,9 @@ pub(crate) struct TaxiCatalogs { } /// Load [`TaxiCatalogs`] once the patch chain exists. Never re-runs past the first success/failure -/// (`Local`) — same shape as [`crate::ui_world_map::load_world_map_ui`]. +/// (`Local`), and that is right *here* where it was wrong for the map catalog (2240): this +/// inserts a Bevy resource and pushes nothing into the VM, so no Lua caller can be early for it — +/// nothing asks until a flight master opens the window. pub(super) fn load_taxi_catalogs( mut done: Local, world_assets: Option>, diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 5fe2f4be..87a2f0ba 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -93,6 +93,85 @@ pub(crate) struct CombatTextEvent { /// The feed's change-tracking memory: what we last told the VM, plus one server-side log-once. /// +/// **`PLAYER_LEAVING_WORLD` on a cross-map worldport** (decision 2235, corrected by 2238). +/// +/// The reference fires event `0x111` at `0x490b48`, inside `0x490a80`. wow-re's census of both +/// signal helpers puts the id at exactly one site image-wide — 336/336 ids resolved through +/// `0x703e50`, 149/149 through `0x703f50`, and the encoding `b9 11 01 00 00` occurs once in the +/// binary — so that is the whole of the FIRE, and it is what this doc used to conflate with the +/// whole of the event. +/// +/// **One fire site, three callers, and this system is one of them** (2238; wow-re `5ad31a12`). +/// `0x490a80` is reached from the local player object's own destructor (`0x401bc0` → `0x467700` +/// → `0x467800` → the per-object `[vtbl+0]` → `0x5dd500` → `0x5dd600` → `0x5dd72c` → `0x5dd543`) +/// — **this system's occasion** — and also from `0x490c20` inside the shutdown tail `0x490bd0`, +/// which is where an in-world `/reload`, a logout, a quit and a disconnect reach it, and from +/// `0x5e9b5a`, vtable slot 1, on a DESTROY / OUT_OF_RANGE of the local player object. +/// 2235 read `5ce96437`'s "three gates" as three gates on the event and its subject line as a +/// census of the callers; neither is what they were. Two of those gates (`0x5dd71c`/`0x5dd721`/ +/// `0x5dd725` and `0x5dd539`/`0x5dd53e`/`0x5dd541`) sit in the destructor chain and gate only the +/// caller below; `0x490a80`'s own only gate is the latch at `[0xb4b424]`. +/// +/// **The tail's occasions are already ours**, and were years before this system existed: +/// [`crate::ui_script::shutdown_ui_state`] fires `PLAYER_LEAVING_WORLD` then `PLAYER_LOGOUT` as +/// the head of the same `0x490bd0` tail, from `end_ui_session` (logout, disconnect, and +/// `run_pending_reload`'s `/reload`) and from `shutdown_on_exit` (quit). So the two producers are +/// complements, not duplicates — a fact worth writing down precisely because nothing in either +/// file said so, and a reader of this doc alone would take the tail's fire for a bug. +/// +/// **Cross-map only *for this occasion*, and that falls out of the gate rather than being a rule +/// on top of it.** The first gate (`0x5dd728`) admits only the local player's destructor, and a +/// same-map teleport never destroys the object — including the >30 yd variant that forces a +/// blocking terrain reload, which reloads tiles rather than `CGObject`s. So +/// [`crate::net::WorldportMessage`] is the right edge and `needs_ack` is the right discriminator: +/// the one worldport that does NOT need an ack is the initial-login map, where nothing is being +/// left. +/// +/// **Nothing shipped listens to it.** Zero of the 232 extracted reference interface files +/// register `PLAYER_LEAVING_WORLD` (controls, same sweep: `PLAYER_ENTERING_WORLD` 22 files, +/// `VARIABLES_LOADED` 7, `PLAYER_LOGIN` 1). This is an addon-facing event, which is exactly why +/// it went missing here for so long — no stock window breaks without it, and only the corpus +/// notices. It is also why this stays a bare fire and promises nothing more: in the reference the +/// handler runs *during* teardown, after the three manager unlinks and ~35 of `0x490a80`'s 37 +/// teardown calls, so a real addon's handler already sees a substantially dismantled UI. +/// +/// **What "dismantled" costs there is the opposite of what 2235 guessed** (2238; wow-re +/// `20210d32`). The GUID hash's link is `obj+0x1c` and the destructor splices the object out +/// (`0x467887 call 0x468680`) *before* `call [vtbl+0]`, so on THIS occasion — and only this one — +/// a hash lookup misses. But `UnitName("player")` never asks the hash: `0x517020` short-circuits +/// at `0x51707d` and answers from the cached character record `[0xc27d88]`, which has one writer +/// (`CGlueMgr::EnterWorld`) and no clearer; `UnitRace`, `UnitClass` and `0x517ee0` take the same +/// fast path. The binding that *does* go nil is `UnitExists("player")`, whose `0x515970` resolves +/// through `0x468460` and takes `0x5159c9 je 0x515a39` → `0:0` on the miss. We reproduce none of +/// that ordering and are not trying to: ours fires with the descriptor still present, so every +/// unit binding answers. That is a divergence in our favour, recorded rather than closed — +/// closing it would mean deliberately breaking `UnitExists` to match a teardown artifact no +/// stock file observes. +fn fire_leaving_world_on_worldport( + script: Option>, + mut armed: ResMut, + mut ports: MessageReader, +) { + // `needs_ack` false is the initial-login map (`player::wire_in`'s own split): an entry, not a + // departure. Read the whole iterator either way so the cursor never carries one over. + let leaving = ports.read().filter(|w| w.needs_ack).count() > 0; + if !leaving { + return; + } + // **The world latch, spent here** (2239): this producer and the shutdown tail are the + // reference's `0x5dd543` and `0x490c20`, two callers of one fire site, and `[0xb4b424]` is + // what keeps them from both claiming one departure. Spent even if the VM turns out to be + // absent below — the reference clears it at `0x490a8d`, ahead of the fire and of every + // teardown call after it, so a departure nobody could be told about is still a departure. + if !armed.spend() { + return; + } + let Some(mut script) = script else { + return; + }; + script.fire_event("PLAYER_LEAVING_WORLD", Vec::new()); +} + /// The VM half lives behind a [`crate::ui_script::VmMemo`], **inside the resource** — the same /// law 1290 wrote for `Local` memos, reached the way a `ResMut` system has to reach it: a memory /// about what THIS VM was told is unreadable against the next VM, so a `/reload` (1291) — which @@ -188,9 +267,23 @@ impl Plugin for UiUnitPlugin { .init_resource::() .add_message::() .add_message::() + // …and the worldport edge [`fire_leaving_world_on_worldport`] reads, for exactly the + // reason above: the message belongs to `crate::net`, which a UI-only harness does not + // stand up, and an unregistered `MessageReader` is a system-validation panic rather than + // an empty read. `add_message` is idempotent, so the net plugin declaring it too costs + // nothing. (1348's own `the_login_one_shots_wait_for_the_in_game_ui` is the harness that + // found this — it builds this plugin alone.) + .add_message::() + // The world latch (2239): this plugin owns one of its two producers, so it declares the + // resource as well as the message — same reason, and `init_resource` is idempotent + // against `UiScriptPlugin`'s own. + .init_resource::() .add_systems( Update, ( + // FIRST in the chain, so a worldport's leaving edge precedes the entering edge + // the same port raises in `feed_units` once the new descriptor lands. + fire_leaving_world_on_worldport, feed_units, feed_unit_reach, feed_player_control, @@ -3287,4 +3380,87 @@ mod tests { "the window closed, yet UnitExists(\"npc\")" ); } + + /// **`PLAYER_LEAVING_WORLD` fires on a cross-map worldport, and only on one** (decision 2235). + /// + /// Measured live before this existed: an addon counting all three world events across a real + /// mapId 0 → 1 port read `enter=2 leave=0 login=1`. Two of those already matched the + /// reference — `PLAYER_ENTERING_WORLD` re-fires because the port destroys and re-creates the + /// descriptor, and `PLAYER_LOGIN` correctly does not, being armed only by a UI load. The + /// leaving half was simply never wired. + /// + /// The `needs_ack` split is the reference's own: `0x111` is fired from the local player + /// object's destructor, which a same-map teleport never reaches, and the one worldport that + /// owes no ack is the initial-login map — an arrival, with nothing behind it to leave. + #[test] + fn a_cross_map_worldport_fires_leaving_world_and_the_login_map_does_not() { + let mut app = App::new(); + app.add_message::() + .init_resource::() + .add_systems(Update, fire_leaving_world_on_worldport); + app.insert_non_send_resource(UiScript::new().expect("VM")); + app.world_mut() + .non_send_resource::() + .run( + "Left = 0 \ + local f = CreateFrame(\"Frame\") \ + f:RegisterEvent(\"PLAYER_LEAVING_WORLD\") \ + f:SetScript(\"OnEvent\", function() Left = Left + 1 end)", + ) + .expect("probe frame"); + let left = |app: &mut App| -> i64 { + app.world_mut() + .non_send_resource_mut::() + .eval::("return Left") + .unwrap() + }; + let port = |needs_ack: bool| crate::net::WorldportMessage { + map_id: 1, + position: [0.0; 3], + orientation: 0.0, + needs_ack, + transport_entry: None, + }; + + let arm = |app: &mut App| { + app.world_mut() + .resource_mut::() + .arm(); + }; + + // A world began (2239's latch — the reference arms it from the local player's create). + arm(&mut app); + app.world_mut().write_message(port(false)); + app.update(); + assert_eq!(left(&mut app), 0, "the initial-login map is an arrival"); + + app.world_mut().write_message(port(true)); + app.update(); + assert_eq!(left(&mut app), 1, "a cross-map port leaves a world"); + + app.update(); + assert_eq!( + left(&mut app), + 1, + "once per port, not once per frame after it" + ); + + // **And once per WORLD, which is the latch's own law** (2239): the port above spent it, + // and nothing here re-armed — no new avatar was created. A second departure off the same + // world is the window a quit on the loading screen lands in, and the reference fires + // nothing there. + app.world_mut().write_message(port(true)); + app.update(); + assert_eq!( + left(&mut app), + 1, + "a second departure with the latch spent fired again — [0xb4b424] is per world" + ); + + // Re-armed, as the new world's create does: the next departure is its own. + arm(&mut app); + app.world_mut().write_message(port(true)); + app.update(); + assert_eq!(left(&mut app), 2, "the next world's departure fires again"); + } } diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index e8442a0a..517f7537 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -1,10 +1,10 @@ //! The world-map data feed (decision 0203 phase 2) — the app half behind //! stock `Interface\FrameXML\WorldMapFrame.xml` and benilla-ui's `script/worldmap.rs` bindings. //! -//! Two systems, the quest-log seam shape: -//! - [`load_world_map_ui`] (once, when the chain + VM + Map.dbc catalog all exist): builds the -//! static **catalog** from `WorldMapArea` × `AreaTable` × `WorldMapContinent` × `Map` × -//! the `.zmp` bitmaps and pushes it into the engine. Every ordering/naming rule is the +//! A seed and a feed, the quest-log seam shape: +//! - [`seed_world_map_catalog`] (at the world-entry edge, before any interface file runs — 2240): +//! builds the static **catalog** from `WorldMapArea` × `AreaTable` × `WorldMapContinent` × +//! `Map` × the `.zmp` bitmaps and pushes it into the engine. Every ordering/naming rule is the //! wow-re-verified one (Q1/Q3 verdicts, 2026-07-07): continents in WorldMapArea **file //! order** (Kalimdor, then EK — the `0x4a5d00` builder's walk), displayed under their //! `Map.dbc` localized names ("Eastern Kingdoms", not the art folder's "Azeroth"); zones @@ -73,7 +73,7 @@ fn zone_rect(a: &WorldMapArea) -> ZoneRect { } } -/// Build the catalog off the patch chain — the pure half of [`load_world_map_ui`], split out +/// Build the catalog off the patch chain — the pure half of [`seed_world_map_catalog`], split out /// so a test can drive the REAL `WorldMapArea` × `AreaTable` × `WorldMapContinent` × `Map` × /// `.zmp` build rather than a hand-written stand-in. A fixture catalog can only ever prove the /// engine's arithmetic; what the player hovers is this, and the two were never joined before @@ -238,44 +238,68 @@ pub(crate) fn build_catalog( Some((views, entries)) } -/// Build + push the static catalog once the patch chain, the VM, and the Map.dbc catalog all -/// exist (Update-gated rather than Startup-ordered, like every feed that needs the script). -fn load_world_map_ui( - mut done: Local>, - script: Option>, - world_assets: Option>, - maps: Option>, - areas: Option>, - mut commands: Commands, -) { - let (Some(mut script), Some(assets), Some(maps), Some(areas)) = - (script, world_assets, maps, areas) - else { - return; - }; - // Once per **VM** (1290), not once per process: the catalog is static, the VM it is pushed - // into is not — a login builds a fresh one, and without this the map window has no continents. - if !done.claim(&script) { - return; +/// The built catalog, kept for the life of the process. A static `WorldMapArea` × `AreaTable` × +/// `WorldMapContinent` × `Map` × `.zmp` walk whose answer cannot change, so the second login +/// re-seeds its VM from here rather than reading the chain again. +/// +/// `pub(crate)` because the seam it feeds is what +/// [`crate::ui_script::world_entry_tests::an_addon_reads_the_map_catalog_at_file_scope`] asserts: +/// the test plants a catalog and drives the entry edge, which is the ordering question without +/// the DBC walk (the walk itself is covered against the real chain by +/// `world_map_tests::the_real_feralas_catalog_names_dire_maul_under_the_cursor`). +#[derive(Resource)] +pub(crate) struct WorldMapCatalog(pub(crate) Vec); + +/// **The map catalog goes into the VM before a single interface file runs** (decision 2240). +/// +/// Called from [`crate::ui_script::load_ingame_ui_on_world_entry`], beside the CVar table, the +/// realm name and the addon-info array, and for exactly their reason: that edge mints a fresh VM +/// and runs FrameXML and every addon inside ONE call, so anything pushed from an `Update` system +/// lands after the whole interface has already asked its questions. This one was pushed there — +/// measured live, the addon file scope read `conts=0 zones(1)=0 zones(2)=0` and the catalog +/// arrived 210 ms later, on both logins of a round trip. +/// +/// `GetMapContinents`/`GetMapZones` are answered off this catalog, and they are **file-scope** +/// reads in the corpus: Astrolabe — Questie's and Cartographer's positioning library — builds its +/// whole continent → zone table inside `AceLibrary:Register`'s synchronous `activate`, from those +/// two calls. Built from nothing, that table has no numeric zone entries at all, and every icon +/// placement afterwards indexes a nil zone (`attempt to index local 'zoneData'`). +/// +/// In the reference the question has no timing: the catalog is DBC data the client has held since +/// load, and the getters read it whenever they are asked. +pub(crate) fn seed_world_map_catalog(world: &mut World, script: &mut UiScript) { + if !world.contains_resource::() { + let Some((views, entries)) = build_catalog_from_world(world) else { + return; + }; + info!( + "world map: catalog — {} continents, {} zones", + views.len(), + views.iter().map(|c| c.zones.len()).sum::() + ); + world.insert_resource(WorldMapUiData { + continents: entries, + }); + world.insert_resource(WorldMapCatalog(views)); } - let areas = &areas.0; - - let mut chain = assets.chain.lock_recover(); - let built = build_catalog(&mut chain, areas, &maps); - drop(chain); - let Some((views, entries)) = built else { + let Some(catalog) = world.get_resource::() else { return; }; + script.set_world_map_catalog(catalog.0.clone()); +} - info!( - "world map: catalog — {} continents, {} zones", - views.len(), - views.iter().map(|c| c.zones.len()).sum::() - ); - script.set_world_map_catalog(views); - commands.insert_resource(WorldMapUiData { - continents: entries, - }); +/// [`build_catalog`] over the resources the app holds — `None` when the patch chain or either DBC +/// catalog is missing, which in a real run cannot happen at this edge: all three are `Startup` +/// systems and the initial state transition is after `PostStartup` (decision 1038). A bare test +/// world takes the `None`. +fn build_catalog_from_world( + world: &World, +) -> Option<(Vec, Vec)> { + let assets = world.get_resource::()?; + let maps = world.get_resource::()?; + let areas = world.get_resource::()?; + let mut chain = assets.chain.lock_recover(); + build_catalog(&mut chain, &areas.0, maps) } /// Which of the map's three levels is displayed — the reference's `(continent, zone)` globals @@ -863,7 +887,6 @@ impl Plugin for WorldMapUiPlugin { app.add_systems( Update, ( - load_world_map_ui, // After the script tick (UiInput), like the minimap's zone feed: the projection // for a selection changed THIS tick lands next tick — invisible at frame rate. feed_world_map.after(UiInput), diff --git a/crates/benilla-app/src/world_backdrop.rs b/crates/benilla-app/src/world_backdrop.rs index dadffe87..96ce7f43 100644 --- a/crates/benilla-app/src/world_backdrop.rs +++ b/crates/benilla-app/src/world_backdrop.rs @@ -30,41 +30,44 @@ //! //! ## The fix: put the world *inside* the UI's byte buffer //! -//! Not a third lane. The world camera renders to an off-screen image instead of the swapchain, and -//! that image is drawn as the **first quad of the UI pass** — the ground everything else is painted -//! on. Every UI blend over the world is then the same blend as every UI blend over UI: the one -//! 0254 already verified, in the target it already verified it in. The output blit stops blending -//! entirely (it now carries an opaque frame), which retires the `rgb·a²` hazard 0254 had to patch +//! Not a third lane. The world camera renders off-screen, and the **first draw of the UI +//! camera's main pass** is the world's own final pass — the FFXGlow combine ([`FfxBackdrop`], +//! decision 2234) — rendered straight into the UI's byte target as the ground everything else is +//! painted on, inside the very pass that paints it (a pass of its own would cost a tile GPU a +//! load and a store of the whole target). Every UI blend +//! over the world is then the same blend as every UI blend over UI: the one 0254 already +//! verified, in the target it already verified it in. The output blit stops blending entirely (it +//! now carries an opaque frame), which retires the `rgb·a²` hazard 0254 had to patch //! `PREMULTIPLIED_ALPHA_BLENDING` around. //! -//! **The image is un-encoded float, and both halves of that are borrowed rather than invented** — -//! it is the portrait booths' own target format, chosen there for the same two reasons -//! ([`crate::portrait`]'s `new_target_image`). Un-encoded, because the UI arc composites in gamma -//! and takes its one decode at the end: a backdrop that pre-encoded would land a second encode in -//! that chain. `ui_quad.wgsl`'s ordinary arm re-encodes what it samples (`linear_to_srgb`), which -//! turns FFXGlow's linear output back into the client's byte — the same round trip a booth image -//! takes, and exact in f32. Float rather than `Rgba8Unorm`, because quantizing *un-encoded* values -//! to 8 bits is B126's banding collapse (decision 0804): below display byte 100 an un-encoded 8-bit -//! grid reaches ~25 levels where the gamma backbuffer has 100. +//! **It was a picture first** (1603): the combine wrote a full-window un-encoded float image that +//! this pass drew as its first quad, the quad re-encoding what it sampled back to the byte the +//! combine had computed. That is one write and one read of every pixel at eight bytes each, and +//! on a bandwidth-bound GPU it is a third of the full-screen chain (2234's rig). The combine now +//! stores that byte itself, in the UI lane's own terms (`ui_quad.wgsl`'s premultiply, no decode), +//! and the picture is gone. What the world camera still carries as a target is a **size-carrier**: +//! a one-byte image at the world's render size that nothing writes, kept because bevy sizes a +//! view's main texture from its target and the camera's logical viewport is held through it +//! ([`retarget_world_camera`]). //! -//! Nothing about the world lane changes: FFXGlow keeps its decode, the frame still holds exactly -//! one, and an opaque world pixel must come out byte-identical to before. That identity is the -//! regression test, the same one 0161 used. +//! Nothing about the world lane's arithmetic changes: the combine's byte math is the same, the +//! frame still holds exactly one decode — the UI lane's, at its end — and an opaque world pixel +//! comes out the byte it came out before, minus the float image's own rounding on the way. //! //! ## …and that seam turned out to be a dial: **render scale** (decision 1639) //! //! Once the world is a picture the UI paints on, the picture does not have to be the window's size. -//! [`RenderScale`] sizes it — the world renders at `window × scale`, the quad still covers the -//! window, and **the UI is untouched at native resolution**, because [`emit_backdrop_quad`] -//! measures the quad in the window's LOGICAL size and nothing here can move that. That is the whole -//! reason this is worth having rather than "run the game in a smaller window": text, icons and -//! frame art stay exactly as sharp as they were, and only the 3D pays. +//! [`RenderScale`] sizes it — the world renders at `window × scale`, the combine still covers the +//! window, and **the UI is untouched at native resolution**, because the combine runs at the UI +//! camera's own size and only *samples* the scaled world, and nothing here can move that. That is +//! the whole reason this is worth having rather than "run the game in a smaller window": text, +//! icons and frame art stay exactly as sharp as they were, and only the 3D pays. //! //! **Below 1 it buys frames; above 1 it is supersampling** — and above 1 is the half this machine //! can measure, since `gxMultisample` defaults to off (1629) and the client therefore ships with no -//! antialiasing of any kind. At exactly 2.0 the plain bilinear resolve *is* a 2×2 box average (the -//! destination pixel centre lands on the corner four texels share, weighting each 0.25), so SSAA×2 -//! needs no filter of its own. +//! antialiasing of any kind. At exactly 2.0 the combine's plain bilinear read of the scene *is* a +//! 2×2 box average (the destination pixel centre lands on the corner four texels share, weighting +//! each 0.25), so SSAA×2 needs no filter of its own. //! //! **The one number that must not move is the camera's LOGICAL viewport**, and //! [`render_target_for`] is built around holding it fixed — [`retarget_world_camera`] says what @@ -77,7 +80,8 @@ use bevy::render::camera::MipBias; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages}; use bevy::window::PrimaryWindow; -use crate::ui_pass::{UiQuad, UiQuads}; +use crate::ui_pass::PlayerUiCamera; +use benilla_world::ffx_glow::FfxBackdrop; use benilla_world::view::WorldCamera; /// The **render scale** — benilla's own CVar `renderScale`, no 1.12 counterpart. @@ -185,13 +189,17 @@ fn mip_bias(effective: f32) -> f32 { effective.log2().min(0.0) } -/// The off-screen image the world camera renders into, and which the UI pass draws first. +/// The world camera's target — the size-carrier (see the module doc) — and the claim that makes +/// the world the first draw of the UI camera's main pass. /// /// Sized in physical pixels **× [`RenderScale`]**; at the default 1.0 that matches the swapchain 1:1 -/// and the UI pass's sample is an identity resample, which is the property the composite lane was -/// built on and the reason the scale ships off. +/// and the combine's read of the world is an identity resample, which is the property the +/// composite lane was built on and the reason the scale ships off. #[derive(Resource)] pub(crate) struct WorldBackdrop { + /// The image the world camera targets. Nothing writes it (the camera runs `Skip`, its final + /// pass is the UI camera's — [`FfxBackdrop`]) and nothing samples it; it exists to give the + /// view its render size and its logical viewport. pub(crate) image: Handle, /// The size the image was last built at, in physical px — the resize gate. size: UVec2, @@ -211,8 +219,11 @@ impl WorldBackdrop { } } -/// A fresh backdrop image at `size` physical px. See the module doc for the format's two halves. -fn new_backdrop_image(size: UVec2) -> Image { +/// A fresh world-camera target at `size` physical px: the size-carrier of the module doc. One +/// byte a pixel and a render attachment only — nothing writes it and nothing samples it, so the +/// cheapest format a camera target may have is the honest one, and the CPU copy is dropped once +/// the render world has it. +fn new_world_target(size: UVec2) -> Image { let mut image = Image::new_fill( Extent3d { width: size.x.max(1), @@ -220,12 +231,11 @@ fn new_backdrop_image(size: UVec2) -> Image { depth_or_array_layers: 1, }, TextureDimension::D2, - &[0; 8], - TextureFormat::Rgba16Float, - RenderAssetUsages::default(), + &[0], + TextureFormat::R8Unorm, + RenderAssetUsages::RENDER_WORLD, ); - image.texture_descriptor.usage = - TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT; + image.texture_descriptor.usage = TextureUsages::RENDER_ATTACHMENT; image } @@ -249,7 +259,7 @@ fn setup_backdrop( scale.0, ) }); - let image = images.add(new_backdrop_image(size)); + let image = images.add(new_world_target(size)); commands.insert_resource(WorldBackdrop { image, size, @@ -259,10 +269,11 @@ fn setup_backdrop( }); } -/// Keep the backdrop at `window × `[`RenderScale`]. A stale-sized backdrop would still *work* (the -/// quad covers the window either way) — what it would break is the pairing: the size and the -/// factor [`retarget_world_camera`] stamps are two halves of one number, and a size that moved -/// without the factor following it is exactly the B169 defect with a different constant. +/// Keep the target at `window × `[`RenderScale`]. A stale-sized target would still *work* (the +/// combine samples whatever size the world rendered at) — what it would break is the pairing: the +/// size and the factor [`retarget_world_camera`] stamps are two halves of one number, and a size +/// that moved without the factor following it is exactly the B169 defect with a different +/// constant. /// /// Runs before the stamp (the plugin's `.chain()`), so the factor is always computed against the /// image that now exists. @@ -270,41 +281,19 @@ fn setup_backdrop( /// ## The rebuild publishes a NEW asset — it does not write through the old handle (decision 1647) /// /// It used to do exactly that (`*images.get_mut(&handle) = new_backdrop_image(size)`), and **the -/// world froze**: change any graphics setting or resize the window while in the world and the 3D -/// stopped dead at the frame of the change, stretched over the new window, while the interface -/// carried on. That is the director's report of 2026-08-27, and it reproduces on the bench (see the -/// decision record's measurement). +/// world froze**: change any graphics setting or resize the window in the world and the 3D stopped +/// dead at the frame of the change while the interface carried on — the director's report of +/// 2026-08-27. Mutating an `Image` behind its handle makes a whole new GPU texture, and the UI +/// pass's material cache, keyed on `AssetId`, kept its bind group on the texture just +/// thrown away, sampling it forever. /// -/// The mechanism is a **texture-identity lie**, and it is Bevy's prepared-bind-group model meeting -/// our material cache: -/// -/// - Mutating an `Image` behind its handle makes a whole new GPU texture — `GpuImage`'s -/// `prepare_asset` (`bevy_render/texture/gpu_image.rs`) calls `create_texture` afresh on every -/// `AssetEvent::Modified`. -/// - A material's bind group, by contrast, is captured **once**: `PreparedMaterial2d::prepare_asset` -/// (`bevy_sprite_render/mesh2d/material.rs`) calls `as_bind_group` when the *material* asset is -/// added or modified, and nothing re-prepares it when a texture it named is re-created. -/// - [`crate::ui_pass`] keys its material cache on `AssetId`. Same id ⇒ same material ⇒ the -/// same bind group, still holding the `TextureView` of the texture we just threw away. -/// -/// So the world camera drew into the new texture and the UI kept sampling the old one, forever. -/// -/// A new handle fixes it at the identity rather than by invalidating a cache: **`AssetId` is -/// the tree's name for a GPU texture, so a new GPU texture gets a new name.** Every consumer then -/// reacts on its own — the cache misses and builds a fresh material, [`emit_backdrop_quad`]'s quad -/// differs and flags a rebuild, and [`retarget_world_camera`] re-points the camera. -/// -/// It is also the only version that is safe against prepare *order*. `Material2dPlugin` registers -/// `RenderAssetPlugin::>::default()` — `AFTER = ()`, i.e. **not** ordered -/// after `prepare_assets::` — so the "touch the material so its bind group rebuilds" -/// remedy (which `benilla_world::clouds` uses, and whose comment already named this whole hazard) -/// can re-bind the *previous* `GpuImage` if the material happens to prepare first. A fresh -/// `AssetId` cannot: it either finds its own `GpuImage` or `as_bind_group` returns -/// `RetryNextUpdate` and Bevy retries next frame. -/// -/// The retired asset is **removed**, not merely dropped, because the stale cache entry is still -/// holding a strong handle to it; `ui_pass`'s rebuild forgets materials whose image was removed, -/// which is what keeps a window drag from retiring tens of megabytes a frame into a live cache. +/// Since 2234 no material samples this image at all, so that door is closed; the rule stays +/// because it was never that cache's rule but the tree's: **`AssetId` is the name of a GPU +/// texture, so a new GPU texture gets a new name.** Every consumer then reacts on its own — here +/// [`retarget_world_camera`] re-points the camera — and nothing anywhere can hold a stale view by +/// a name that did not change. The retired asset is **removed**, not merely dropped, for the same +/// reason: a name that goes away is one nothing can keep by mistake, and `ui_pass`'s rebuild +/// forgets materials on exactly that event. fn track_render_size( mut backdrop: ResMut, mut images: ResMut>, @@ -322,7 +311,7 @@ fn track_render_size( if size == backdrop.size { return; } - let retired = std::mem::replace(&mut backdrop.image, images.add(new_backdrop_image(size))); + let retired = std::mem::replace(&mut backdrop.image, images.add(new_world_target(size))); images.remove(&retired); backdrop.size = size; // Said out loud on every change, because a measurement taken at the wrong scale looks @@ -411,39 +400,32 @@ fn retarget_world_camera( } } -/// Publish the backdrop quad for this frame — full-window, opaque, ahead of every other quad. +/// Point the player-UI camera's ground pass at the world camera drawing this frame +/// ([`FfxBackdrop`]): the active one, `None` when there is none. /// -/// Emitted only while the world camera is actually drawing. With no world (the glue screens, the -/// loading screen, a gated camera) the image holds a stale or never-written frame, and painting it -/// would be worse than the transparent clear the UI pass falls back to. -fn emit_backdrop_quad( - backdrop: Res, - mut quads: ResMut, - cameras: Query<&Camera, With>, - windows: Query<&Window, With>, +/// With no world (the glue screens, the loading screen, a gated camera) the world view's main +/// texture holds a stale or never-written frame, and grounding the interface on it would be +/// worse than the transparent clear the UI camera falls back to. Whether the camera named here +/// actually rendered this frame is the render world's to know, and it checks +/// (`ffx_glow::prepare_backdrops`). +fn claim_backdrop( + cameras: Query<(Entity, &Camera), With>, + mut ui: Query<&mut FfxBackdrop, With>, ) { - let drawing = cameras.iter().any(|c| c.is_active); - let next = windows.single().ok().filter(|_| drawing).map(|window| { - // Logical px, y-down from the top-left — `rebuild_ui_mesh`'s own rect space. - UiQuad { - rect: Rect::from_corners(Vec2::ZERO, Vec2::new(window.width(), window.height())), - texture: Some(backdrop.image.clone()), - ..UiQuad::default() + let source = cameras + .iter() + .find_map(|(entity, camera)| camera.is_active.then_some(entity)); + for mut backdrop in &mut ui { + // Compare first: the component is extracted every frame regardless, and a write here + // would trip change detection for nothing. + if backdrop.source != source { + backdrop.source = source; } - }); - // **Flag the rebuild only when the quad itself changes** — its arrival, its departure, a - // resize. Its CONTENTS change every frame and must not: the mesh batch holds the image handle - // and the material samples whatever the world camera just rendered into it, so a per-frame - // `dirty` here would drag every batch in the pass through a full rebuild for a picture that - // did not move — the 0365 live-city churn, re-introduced from the one producer that runs every - // single frame. - if quads.backdrop != next { - quads.backdrop = next; - quads.dirty = true; } } -/// Owns the backdrop image, the world camera's target, and the quad. See the module doc. +/// Owns the world camera's target (the size-carrier) and the UI camera's claim on the world. +/// See the module doc. pub(crate) struct WorldBackdropPlugin; impl Plugin for WorldBackdropPlugin { @@ -452,9 +434,7 @@ impl Plugin for WorldBackdropPlugin { .add_systems(Startup, setup_backdrop) .add_systems( Update, - (track_render_size, retarget_world_camera, emit_backdrop_quad) - .chain() - .before(crate::ui_pass::UiQuadAppend), + (track_render_size, retarget_world_camera, claim_backdrop).chain(), ); } } @@ -464,31 +444,59 @@ mod tests { use super::*; use bevy::image::TextureFormatPixelInfo as _; - /// The backdrop is float and un-encoded — the two halves the module doc argues for, and the - /// pair a future "make it 8-bit, it's only a backdrop" edit would quietly break (an sRGB label - /// double-encodes through `ui_quad`; an 8-bit un-encoded grid is B126's banding). + /// **The world camera's target is a size-carrier and nothing else** (decision 2234): one byte + /// a pixel, a render attachment because a camera target must be one, and NOT a texture — + /// nothing samples it. The float image this used to be (1603) carried the world to the UI + /// pass's first quad; the combine carries it now, straight into the UI target, and a future + /// "make the target samplable again" edit is a second copy of the world coming back. #[test] - fn the_backdrop_is_unencoded_float() { - let image = new_backdrop_image(UVec2::new(320, 200)); - assert_eq!(image.texture_descriptor.format, TextureFormat::Rgba16Float); + fn the_world_target_is_a_size_carrier_only() { + let image = new_world_target(UVec2::new(320, 200)); + assert_eq!(image.texture_descriptor.format, TextureFormat::R8Unorm); + let usage = image.texture_descriptor.usage; + assert!(usage.contains(TextureUsages::RENDER_ATTACHMENT)); assert!( - !image.texture_descriptor.format.is_srgb(), - "an sRGB label would land a second encode in a chain that decodes once, at the end" + !usage.contains(TextureUsages::TEXTURE_BINDING), + "nothing samples the world's target: the UI camera's ground pass reads the view's \ + main texture, not this image" ); - assert!( - !TextureFormat::Rgba16Float.is_srgb(), - "the swapchain's own view is sRGB — the backdrop deliberately is not" + assert_eq!( + image.asset_usage, + RenderAssetUsages::RENDER_WORLD, + "the CPU copy of a texture nothing reads is dropped once the render world has it" ); } - /// It must be usable as a camera target AND samplable by the UI pass. Dropping either usage - /// bit fails at device level, far from here. + /// The UI camera's ground pass follows the world camera that is drawing: the active one, + /// `None` while there is none. #[test] - fn the_backdrop_is_both_a_target_and_a_texture() { - let image = new_backdrop_image(UVec2::new(320, 200)); - let usage = image.texture_descriptor.usage; - assert!(usage.contains(TextureUsages::RENDER_ATTACHMENT)); - assert!(usage.contains(TextureUsages::TEXTURE_BINDING)); + fn the_ui_camera_claims_the_drawing_world_camera() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_systems(Update, claim_backdrop); + let ui = app + .world_mut() + .spawn((PlayerUiCamera, FfxBackdrop::default())) + .id(); + let world_cam = app + .world_mut() + .spawn(( + WorldCamera, + Camera { + is_active: true, + ..default() + }, + )) + .id(); + let source = |app: &mut App| app.world().get::(ui).unwrap().source; + app.update(); + assert_eq!(source(&mut app), Some(world_cam), "active: claimed"); + app.world_mut() + .get_mut::(world_cam) + .unwrap() + .is_active = false; + app.update(); + assert_eq!(source(&mut app), None, "gated: the claim goes with it"); } /// **The world camera's target carries the WINDOW's scale factor, not `1.0`** — the pick @@ -552,17 +560,16 @@ mod tests { ); } - /// Physical, not logical: the backdrop matches the swapchain 1:1 so the UI pass's sample is an - /// identity resample. A zero-size window (minimised on some platforms) must still produce a - /// legal texture rather than a device error. + /// Physical, not logical: the target is the world's render size. A zero-size window (minimised + /// on some platforms) must still produce a legal texture rather than a device error. #[test] fn a_degenerate_size_still_builds_a_legal_texture() { - let image = new_backdrop_image(UVec2::ZERO); + let image = new_world_target(UVec2::ZERO); assert_eq!(image.texture_descriptor.size.width, 1); assert_eq!(image.texture_descriptor.size.height, 1); assert_eq!( image.data.as_ref().map(Vec::len), - TextureFormat::Rgba16Float.pixel_size().ok() + TextureFormat::R8Unorm.pixel_size().ok() ); } diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index 609fc560..62ba79e9 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -422,7 +422,15 @@ fn is_instrument_consumer(rel: &str) -> bool { /// 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; +/// And 181 → 182: `ffx_glow::FfxBackdrop`, a PUBLISH — the component that makes the world's +/// FFX combine the first draw of the player-UI camera's main pass (decision 2234). The combine is engine-side +/// (the world lane's byte math, 0161); the camera it now runs on is the game's (the interface, +/// 0254); so the engine publishes the claim as one component the game puts on its camera and +/// points at the world camera it owns, and the two nodes behind it stay private. It retires a +/// full-window float image that one camera wrote and the next read back — the seam 1603 built +/// and 2215 measured — and it could not go the other way for 2206's reason: the UI camera cannot +/// move into the engine. +const CEILING: usize = 182; /// 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 9963a832..8a9e26a2 100644 --- a/crates/benilla-world/src/ffx_glow.rs +++ b/crates/benilla-world/src/ffx_glow.rs @@ -18,8 +18,15 @@ //! reference runs its FFX pass inside the WorldFrame's paint, with every UI frame compositing //! afterwards. [`FfxGlow::state_scale`] is that whole class in one field, `0` on every bake //! (decision 1481). +//! +//! **Two nodes since decision 2234.** The world view's node runs the three filter passes and, +//! for a view nobody claims, the combine where [`crate::final_pass`] says. The player-UI camera +//! claims the world view ([`FfxBackdrop`]) and draws the combine itself, first in its own main +//! pass, straight into its byte target: the world enters the interface's buffer with no picture +//! in between — no full-window float image written by one camera and read back by the next. -use bevy::camera::CameraOutputMode; +use bevy::core_pipeline::core_2d::graph::{Core2d, Node2d}; +use bevy::core_pipeline::core_2d::Transparent2d; use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d}; use bevy::core_pipeline::FullscreenShader; use bevy::ecs::query::QueryItem; @@ -31,11 +38,13 @@ use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin}; use bevy::render::render_graph::{ NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, }; +use bevy::render::render_phase::{TrackedRenderPass, ViewSortedRenderPhases}; use bevy::render::render_resource::binding_types::{sampler, texture_2d, uniform_buffer_sized}; use bevy::render::render_resource::*; use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue}; +use bevy::render::sync_world::MainEntity; use bevy::render::texture::{CachedTexture, TextureCache}; -use bevy::render::view::ViewTarget; +use bevy::render::view::{ExtractedView, ViewDepthTexture, ViewTarget}; use bevy::render::{Render, RenderApp, RenderStartup, RenderSystems}; use crate::final_pass::FinalPassTarget; @@ -152,6 +161,37 @@ impl Default for FfxGlow { } } +/// **A 2D camera whose ground is the world's FFX combine** (decision 2234) — the player-UI +/// camera. +/// +/// The world reaches the interface's byte buffer as the FIRST DRAW of this camera's main pass: +/// the combine of the view `source` names, rendered straight into this view's main texture in +/// the UI lane's terms — the gamma byte, no decode ([`FfxCombineKey::gamma_out`]) — before +/// anything the camera draws itself, inside the same pass ([`FfxTransparent2dNode`]). What that replaces is a picture: the combine used to write a full-window float +/// image that the UI pass then drew as its first quad, one write and one read of every pixel at +/// eight bytes each, for a value the quad re-encoded straight back to the byte the combine had +/// computed. +/// +/// `source` is the world camera drawing this frame — its main-world entity — or `None` when +/// none is (the glue screens, the loading screen, a gated camera): the pass then does nothing +/// and the camera's own clear is the ground, exactly as the quad's absence was. The render world +/// resolves it to the view that actually rendered this frame ([`prepare_backdrops`]): a camera +/// that is active but was not extracted — its target's image not prepared yet, a spawn frame — +/// is not a source, so the pass never samples a main texture older than the frame. The component +/// itself stays on the camera so its pair of combine pipelines is specialised on the camera's +/// first frame, before any world exists to hitch on their compile. +/// +/// The world view it names keeps its three filter passes — the ¼-res blur this combine samples +/// — and runs no combine of its own: nothing writes its camera's target, which is a size-carrier +/// only (`benilla_app::world_backdrop`). Its main texture is read unflipped, so a claimed camera +/// runs `CameraOutputMode::Skip`. +#[derive(Component, Clone, Copy, Default, ExtractComponent)] +pub struct FfxBackdrop { + /// The world camera whose combine this camera runs — a main-world entity — or `None` while + /// no world is drawing. + pub source: Option, +} + /// The per-zone glow weight `w` (`LightParams.glow` — authored data, synced from /// [`crate::lighting::WowLighting`] every frame). #[derive(Resource, Clone, ExtractResource)] @@ -426,23 +466,24 @@ fn sync_gain( fn ensure_ffx_glow( mut commands: Commands, cam: Query, Without)>, - mut with: Query<(Entity, &mut Camera), With>, + mut with: Query<&mut FfxGlow>, ) { - // Perf-bisect kill-switch: $WOW_NO_FFX strips the pass from every camera (the frame then shows - // undecoded gamma — ~2.2× bright — which is fine: this is a measurement mode, not a look mode). + // Perf-bisect kill-switch: $WOW_NO_FFX strips the GLOW from every camera — the three filter + // passes and the blur term, [`FfxGlow::UI_PANE`]'s shape — and keeps the combine, because in + // the gamma lane the combine is not an effect: it is the frame's one decode on a `Write` + // view and the UI camera's ground pass on a claimed one (decision 2234), and a frame + // without it has no world in it. The frame shows the world un-glowed at its right + // brightness; what the lever prices is the blur chain and the glow add. 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, 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, - }; + for mut glow in &mut with { + if glow.gain_scale != 0.0 || glow.state != FfxState::None { + *glow = FfxGlow::UI_PANE; } } + for e in &cam { + commands.entity(e).insert(FfxGlow::UI_PANE); + } return; } for e in &cam { @@ -480,8 +521,9 @@ struct FfxGlowPipelines { /// 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 +/// texture (a bake's image), a `Write` view into the HDR main texture for bevy's blit to copy +/// out, as before — and the world view's combine is the UI camera's own pass, keyed on the UI +/// target (decision 2234, [`FfxBackdrop`]). The three filter passes never leave the ¼-res chain /// and stay unspecialised. struct FfxCombinePipeline { layout: BindGroupLayoutDescriptor, @@ -492,6 +534,16 @@ struct FfxCombinePipeline { #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct FfxCombineKey { format: TextureFormat, + /// The combine as a **gamma-lane** pass (decision 2234): it stores the gamma byte it computed + /// and leaves the frame's one decode to the lane that owns it — the UI camera's, at its end, + /// for a view this combine grounds ([`FfxBackdrop`]). `false` is the world lane's own exit, + /// the decode here (0161). + gamma_out: bool, + /// The combine drawn INSIDE a 2D main pass — the first draw of a backdrop camera's + /// transparent pass — which carries the `Core2d` depth attachment and the view's sample + /// count, and a pipeline in that pass must say so (wgpu validates the pair). `None` is the + /// combine's own pass: colour only, one sample. + inside_2d: Option, /// 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 @@ -514,7 +566,11 @@ impl SpecializedRenderPipeline for FfxCombinePipeline { vertex: self.fullscreen.to_vertex_state(), fragment: Some(FragmentState { shader: self.shader.clone(), - shader_defs: vec![], + shader_defs: if key.gamma_out { + vec!["GAMMA_OUT".into()] + } else { + vec![] + }, entry_point: Some(entry.into()), targets: vec![Some(ColorTargetState { format: key.format, @@ -522,6 +578,20 @@ impl SpecializedRenderPipeline for FfxCombinePipeline { write_mask: ColorWrites::ALL, })], }), + // Inside a 2D main pass the depth attachment is bound but never consulted: the + // ground writes every pixel under everything, and the phase's own items decide + // their order among themselves as they always did. + depth_stencil: key.inside_2d.map(|_| DepthStencilState { + format: bevy::core_pipeline::core_2d::CORE_2D_DEPTH_FORMAT, + depth_write_enabled: false, + depth_compare: CompareFunction::Always, + stencil: StencilState::default(), + bias: DepthBiasState::default(), + }), + multisample: MultisampleState { + count: key.inside_2d.unwrap_or(1), + ..default() + }, ..default() } } @@ -671,21 +741,89 @@ struct FfxGlowTextures { /// `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, + /// The combine pipelines for this view, specialised on the format the combine renders in + /// for THIS camera's output mode (`FinalPassTarget::format`) — or `None` for a view some + /// [`FfxBackdrop`] claims, whose combine is that camera's own pass and not this node's. + combine: Option, +} + +/// One view's combine pipelines — dry and underwater (`fs_combine_wave`) — and the format they +/// were keyed on: the freshness gate beside the two textures'. +#[derive(Clone, Copy)] +struct FfxCombinePair { + dry: CachedRenderPipelineId, + wave: CachedRenderPipelineId, + format: TextureFormat, + /// The sample count the pair was keyed on — 1 for a combine's own pass, the view's for a + /// backdrop draw inside a 2D main pass. + samples: u32, +} + +impl FfxCombinePair { + /// The pipeline the pass-list swap selects: the underwater entry while the wave is armed. + fn armed(&self, wave: bool) -> CachedRenderPipelineId { + if wave { + self.wave + } else { + self.dry + } + } +} + +/// What specialising a combine pair takes — the one hand the two prepare systems reach for it +/// with, so neither carries the three resources separately. +#[derive(bevy::ecs::system::SystemParam)] +struct CombineSpecializer<'w> { + pipeline_cache: Res<'w, PipelineCache>, + pipelines: Res<'w, FfxGlowPipelines>, + cache: ResMut<'w, SpecializedRenderPipelines>, +} + +impl CombineSpecializer<'_> { + /// The combine's own pass: colour only, the decode at its exit. + fn pair(&mut self, format: TextureFormat) -> FfxCombinePair { + self.pair_for(format, false, None) + } + + /// The backdrop draw inside a 2D main pass of `samples` samples: the gamma-lane exit, the + /// pass's depth attachment declared. + fn backdrop_pair(&mut self, format: TextureFormat, samples: u32) -> FfxCombinePair { + self.pair_for(format, true, Some(samples)) + } + + fn pair_for( + &mut self, + format: TextureFormat, + gamma_out: bool, + inside_2d: Option, + ) -> FfxCombinePair { + let mut key = |wave| { + self.cache.specialize( + &self.pipeline_cache, + &self.pipelines.combine, + FfxCombineKey { + format, + wave, + gamma_out, + inside_2d, + }, + ) + }; + FfxCombinePair { + dry: key(false), + wave: key(true), + format, + samples: inside_2d.unwrap_or(1), + } + } } fn prepare_textures( mut commands: Commands, mut texture_cache: ResMut, render_device: Res, - pipeline_cache: Res, - pipelines: Res, - mut combine_pipelines: ResMut>, + mut specializer: CombineSpecializer, + claims: Res, views: Query< ( Entity, @@ -700,7 +838,11 @@ fn prepare_textures( let Some(vp) = camera.physical_viewport_size else { continue; }; - let combine_format = FinalPassTarget::format(&camera.output_mode, target); + // A claimed view (some [`FfxBackdrop`] names it) has no combine of its own — the UI + // camera's ground pass is it, keyed on THAT camera's target — so it carries none rather + // than a pair keyed on a target nothing writes. + let combine_format = (!claims.0.contains(&entity)) + .then(|| 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( @@ -729,27 +871,15 @@ 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 + && t.combine.as_ref().map(|c| c.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 combine = combine_format.map(|format| specializer.pair(format)); + let pipelines = &specializer.pipelines; + let layout_filter = specializer + .pipeline_cache + .get_bind_group_layout(&pipelines.layout_filter); let gauss_h_bind = render_device.create_bind_group( "ffx_glow_gauss_h", &layout_filter, @@ -773,8 +903,6 @@ fn prepare_textures( gauss_h_bind, gauss_v_bind, combine, - combine_wave, - combine_format, }); } } @@ -891,41 +1019,36 @@ impl ViewNode for FfxGlowNode { ) -> Result<(), NodeRunError> { let pipelines = world.resource::(); let pipeline_cache = world.resource::(); - let death = world.resource::().0; - let wave = *world.resource::(); - let uniform = combine_uniform( - world.resource::().0, - FfxPassState::world(death, world.resource::().0), - FfxPassState::glue(world.resource::().death()), - glow, - wave, - ); - // 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) { - textures.combine_wave - } else { - textures.combine - }; - let (Some(downsample), Some(gauss_h), Some(gauss_v), Some(combine)) = ( + let (uniform, wave) = live_combine(world, glow); + let (Some(downsample), Some(gauss_h), Some(gauss_v)) = ( pipeline_cache.get_render_pipeline(pipelines.downsample), pipeline_cache.get_render_pipeline(pipelines.gauss_h), pipeline_cache.get_render_pipeline(pipelines.gauss_v), - pipeline_cache.get_render_pipeline(combine_id), ) else { return Ok(()); // pipelines still compiling — draw the frame un-glowed }; - let render_device = render_context.render_device().clone(); - world.resource::().write_buffer( - &textures.gain_buf, - 0, - bytemuck::cast_slice(&uniform), - ); // 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); + // itself, a `Write` camera's ping-pong for bevy's blit to copy out. A CLAIMED view + // (decision 2234, [`FfxBackdrop`]) has no combine here at all: the UI camera's ground + // pass is its combine, and samples this view's finished main texture — unflipped, which + // is why a claimed camera runs `Skip` — once the filter passes below have built the + // blur it reads beside it. + let out = match textures.combine.as_ref() { + None => None, + Some(pair) => { + // 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 Some(combine) = pipeline_cache.get_render_pipeline(pair.armed(wave)) else { + return Ok(()); + }; + Some((combine, FinalPassTarget::resolve(camera, view_target))) + } + }; + let source = out + .as_ref() + .map_or(view_target.main_texture_view(), |(_, out)| out.source); + let render_device = render_context.render_device().clone(); let diagnostics = render_context.diagnostic_recorder(); // The combine reads the blur through exactly two terms — `w·blur²` (`lane.x`) and the @@ -940,14 +1063,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 `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`]. + // The downsample binds the finished main texture, 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((out.source, &pipelines.sampler)), + &BindGroupEntries::sequential((source, &pipelines.sampler)), ); // The filter passes (byte-pinned chain): source→¼ (one Box4), ¼a→¼b (H), ¼b→¼a (V). @@ -997,8 +1120,19 @@ impl ViewNode for FfxGlowNode { } } + let Some((combine, out)) = out else { + return Ok(()); // claimed: the combine is the UI camera's pass + }; + // The uniform is the combine's alone (the filter shaders never bind it), written by + // whichever node runs the combine — this one here, the backdrop node for a claimed view. + world.resource::().write_buffer( + &textures.gain_buf, + 0, + bytemuck::cast_slice(&uniform), + ); // 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 layout_combine = pipeline_cache.get_bind_group_layout(&pipelines.layout_combine); let bind = render_device.create_bind_group( "ffx_glow_combine", &layout_combine, @@ -1033,6 +1167,230 @@ impl ViewNode for FfxGlowNode { } } +/// The live inputs of one view's combine, read off the render world: its uniform, and whether +/// the underwater entry is armed. One reading for the two nodes that can run a combine. +fn live_combine(world: &World, glow: &FfxGlow) -> ([f32; 8], bool) { + let death = world.resource::().0; + let wave = *world.resource::(); + let uniform = combine_uniform( + world.resource::().0, + FfxPassState::world(death, world.resource::().0), + FfxPassState::glue(world.resource::().death()), + glow, + wave, + ); + (uniform, wave_armed(glow.state, wave, death)) +} + +/// Every view an [`FfxBackdrop`] names this frame: the render-world entities whose combine is +/// somebody else's pass. Rebuilt in prepare, read by [`prepare_textures`] (no combine pair for a +/// claimed view) and by the world node (no combine pass). +#[derive(Resource, Default)] +struct FfxBackdropClaims(Vec); + +/// A backdrop camera's view, as the render world resolved it this frame: its own combine pair — +/// specialised on ITS main texture's format with the gamma-lane exit +/// ([`FfxCombineKey::gamma_out`]), where a world view's pair is keyed on the world's target and +/// decodes — and the world view it grounds on, a render-world entity whose `ViewTarget` was +/// prepared THIS frame, or `None`. +#[derive(Component)] +struct FfxBackdropView { + pair: FfxCombinePair, + source: Option, +} + +/// The world views bevy prepared a `ViewTarget` for this frame — the ones that rendered — by +/// their main-world entity. +type RenderedWorldViews<'w, 's> = + Query<'w, 's, (Entity, &'static MainEntity), (With, Changed)>; + +/// Resolve every [`FfxBackdrop`]: the claims, and each claiming view's pair and source. +/// +/// The source is looked up among the views whose `ViewTarget` bevy prepared this frame +/// (`Changed`: `prepare_view_targets` re-inserts it for every view it extracted), +/// by the main-world entity the camera named. A world camera that is active in the main world +/// but was not rendered this frame — its target's image not yet prepared, the frame it spawned +/// — resolves to nothing, so the backdrop pass never samples a main texture older than the +/// frame; and a world view is claimed from its very first rendered frame, so it never builds a +/// combine pair it would drop a frame later. +/// +/// The pair is keyed on the view's main texture, which exists from the view's first frame — so +/// a player-UI camera spawned before the world has its pipelines compiling before there is a +/// world to hitch on them (`pipe_warm`'s pre-world cover), whether or not it has a source yet. +fn prepare_backdrops( + mut commands: Commands, + mut claims: ResMut, + mut specializer: CombineSpecializer, + views: Query<( + Entity, + &FfxBackdrop, + &ViewTarget, + &Msaa, + Option<&FfxBackdropView>, + )>, + rendered: RenderedWorldViews, +) { + claims.0.clear(); + for (entity, backdrop, target, msaa, existing) in &views { + let source = backdrop.source.and_then(|main| { + rendered + .iter() + .find(|(_, m)| m.id() == main) + .map(|(view, _)| view) + }); + claims.0.extend(source); + let (format, samples) = (target.main_texture_format(), msaa.samples()); + let fresh = + |view: &FfxBackdropView| view.pair.format == format && view.pair.samples == samples; + let pair = match existing { + Some(view) if fresh(view) => view.pair, + _ => specializer.backdrop_pair(format, samples), + }; + if existing.is_some_and(|view| view.source == source && fresh(view)) { + continue; + } + commands + .entity(entity) + .insert(FfxBackdropView { pair, source }); + } +} + +/// **The 2D main pass with the world as its first draw** (decision 2234) — bevy's +/// `MainTransparentPass2dNode` with one addition, registered under bevy's own label +/// (`RenderGraph::add_node` is a map insert; the graph's edges, keyed by label, carry over — the +/// same replacement `benilla_app::opaque2d` makes of the opaque node). +/// +/// On a view carrying an [`FfxBackdrop`] whose source rendered this frame +/// ([`FfxBackdropView`]), the pass opens on the view's main texture — taking its first-call clear +/// — and the world view's combine is drawn before any item of the transparent phase: the +/// finished world main texture and the blur the world's own node just built, through the +/// gamma-lane exit, into every pixel. Then the interface, over it, exactly as before. +/// +/// **Inside the pass, not a pass of its own**, and that is the point on a tile GPU: a render +/// pass boundary on a full-window target is a store of every tile to memory and a load of every +/// tile back, so a separate ground pass before this one cost the Air 0.9 ms a frame it did not +/// cost the RTX (2234's measurement). A draw inside the pass costs the tile nothing but the +/// fragment work. On any other view — no source, a world that has not drawn yet, a camera that +/// is not a backdrop camera at all — this is bevy's node to the letter, and the camera's own +/// clear is the ground, as the backdrop quad's absence was. +#[derive(Default)] +struct FfxTransparent2dNode; + +impl ViewNode for FfxTransparent2dNode { + type ViewQuery = ( + &'static ExtractedCamera, + &'static ExtractedView, + &'static ViewTarget, + &'static ViewDepthTexture, + Option<&'static FfxBackdropView>, + ); + + fn run<'w>( + &self, + graph: &mut RenderGraphContext, + render_context: &mut RenderContext<'w>, + (camera, view, target, depth, backdrop): QueryItem<'w, '_, Self::ViewQuery>, + world: &'w World, + ) -> Result<(), NodeRunError> { + let Some(transparent_phases) = + world.get_resource::>() + else { + return Ok(()); + }; + let view_entity = graph.view_entity(); + let Some(transparent_phase) = transparent_phases.get(&view.retained_view_entity) else { + return Ok(()); + }; + + // The ground: resolved and bound here, drawn inside the pass below. Everything it needs + // is read before the pass so the task closure owns only handles. + let ground = backdrop + .and_then(|view| view.source.map(|source| (view, source))) + .and_then(|(view, source)| { + let (Some(world_target), Some(textures), Some(glow)) = ( + world.get::(source), + world.get::(source), + world.get::(source), + ) else { + return None; + }; + let pipelines = world.resource::(); + let pipeline_cache = world.resource::(); + let (uniform, wave) = live_combine(world, glow); + // Still compiling: the frame has no world in it for a frame. + let combine = pipeline_cache.get_render_pipeline(view.pair.armed(wave))?; + world.resource::().write_buffer( + &textures.gain_buf, + 0, + bytemuck::cast_slice(&uniform), + ); + let layout = pipeline_cache.get_bind_group_layout(&pipelines.layout_combine); + // The world view's finished main texture — the very texture its own node would + // have combined from — and the ¼-res blur its filter passes built this frame. + // Per-frame, as the world node's own combine bind group is. + let bind = render_context.render_device().create_bind_group( + "ffx_glow_combine", + &layout, + &BindGroupEntries::sequential(( + world_target.main_texture_view(), + &pipelines.sampler, + &textures.quarter_a.default_view, + textures.gain_buf.as_entire_binding(), + &pipelines.wave_view, + &pipelines.wave_sampler, + )), + ); + Some((combine, bind)) + }); + + let diagnostics = render_context.diagnostic_recorder(); + let color_attachments = [Some(target.get_color_attachment())]; + let depth_stencil_attachment = Some(depth.get_attachment(StoreOp::Store)); + + render_context.add_command_buffer_generation_task(move |render_device| { + let mut command_encoder = + render_device.create_command_encoder(&CommandEncoderDescriptor { + label: Some("main_transparent_pass_2d_command_encoder"), + }); + { + let render_pass = command_encoder.begin_render_pass(&RenderPassDescriptor { + label: Some("main_transparent_pass_2d"), + color_attachments: &color_attachments, + depth_stencil_attachment, + timestamp_writes: None, + occlusion_query_set: None, + }); + let mut render_pass = TrackedRenderPass::new(&render_device, render_pass); + let pass_span = diagnostics.pass_span(&mut render_pass, "main_transparent_pass_2d"); + if let Some(viewport) = camera.viewport.as_ref() { + render_pass.set_camera_viewport(viewport); + } + if let Some((combine, bind)) = ground.as_ref() { + // Its own span inside the pass, so the per-pass split (`WOW_GPU_PASSES`) + // still reads one `ffx_glow_combine` a frame on a device that times inside + // a pass; on one that does not, the transparent pass carries it. + let ground_span = diagnostics.pass_span(&mut render_pass, "ffx_glow_combine"); + render_pass.set_render_pipeline(combine); + render_pass.set_bind_group(0, bind, &[]); + render_pass.draw(0..3, 0..1); + ground_span.end(&mut render_pass); + } + if !transparent_phase.items.is_empty() { + if let Err(err) = transparent_phase.render(&mut render_pass, world, view_entity) + { + error!( + "Error encountered while rendering the transparent 2D phase {err:?}" + ); + } + } + pass_span.end(&mut render_pass); + } + command_encoder.finish() + }); + Ok(()) + } +} + pub struct FfxGlowPlugin; impl Plugin for FfxGlowPlugin { @@ -1044,6 +1402,7 @@ impl Plugin for FfxGlowPlugin { .init_resource::() .add_plugins(( ExtractComponentPlugin::::default(), + ExtractComponentPlugin::::default(), ExtractResourcePlugin::::default(), ExtractResourcePlugin::::default(), ExtractResourcePlugin::::default(), @@ -1068,10 +1427,14 @@ impl Plugin for FfxGlowPlugin { }; render_app .init_resource::>() + .init_resource::() .add_systems(RenderStartup, init_pipelines) .add_systems( Render, - prepare_textures.in_set(RenderSystems::PrepareResources), + // The claims first: a claimed world view builds no combine pair of its own. + (prepare_backdrops, prepare_textures) + .chain() + .in_set(RenderSystems::PrepareResources), ) .add_render_graph_node::>(Core3d, FfxGlowLabel) .add_render_graph_edges( @@ -1081,6 +1444,14 @@ impl Plugin for FfxGlowPlugin { FfxGlowLabel, Node3d::Bloom, ), + ) + // The 2D main pass with the world as its first draw, replacing bevy's transparent + // node under bevy's own label (the edges survive; `benilla_app::opaque2d` does the + // same to the opaque node, which is skipped when empty so this pass takes the + // target's first-call clear under the ground draw). + .add_render_graph_node::>( + Core2d, + Node2d::MainTransparentPass, ); } } diff --git a/crates/benilla-world/src/final_pass.rs b/crates/benilla-world/src/final_pass.rs index ba8f338a..a3564464 100644 --- a/crates/benilla-world/src/final_pass.rs +++ b/crates/benilla-world/src/final_pass.rs @@ -10,10 +10,12 @@ //! 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 +//! renders **straight into the target** — a `Skip` bake's combine into its 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 world camera's combine no longer lands anywhere of its own: since decision 2234 it is +//! the first draw of the UI camera's main pass, `ffx_glow::FfxBackdrop`, and the world's target is a size-carrier +//! nothing writes — so a claimed world view never resolves a destination at all.) 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. //! @@ -22,7 +24,7 @@ //! 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`. +//! That is why `$WOW_NO_FFX` (`ffx_glow::ensure_ffx_glow`) strips the glow and keeps the combine. use bevy::camera::{CameraOutputMode, Viewport}; use bevy::color::LinearRgba; diff --git a/crates/benilla-world/src/shaders/ffx_glow.wgsl b/crates/benilla-world/src/shaders/ffx_glow.wgsl index 64e0fa0c..965666be 100644 --- a/crates/benilla-world/src/shaders/ffx_glow.wgsl +++ b/crates/benilla-world/src/shaders/ffx_glow.wgsl @@ -125,14 +125,23 @@ fn screen_space_dither(frag_coord: vec2) -> vec3 { } // The combine's one exit: dither (when armed) in GAMMA space — the space the present-encode -// rounds in — then the frame's single decode. +// rounds in — then the frame's single decode, or, under `GAMMA_OUT`, no decode at all. fn combine_out(outg: vec3, alpha: f32, frag_coord: vec2) -> vec4 { let dithered = clamp( outg + screen_space_dither(frag_coord) * step(0.5, ffx.lane.w), vec3(0.0), vec3(1.0), ); +#ifdef GAMMA_OUT + // The gamma-lane exit (decision 2234): this combine is the UI camera's own ground pass, so it + // stores the byte it computed the way that lane's quads store theirs (`ui_quad.wgsl`: colour + // premultiplied by its own alpha, alpha as coverage) and leaves the frame's decode to the + // lane's end. It is exactly the value the backdrop quad used to re-encode from the float + // image, minus the image. + return vec4(dithered * alpha, alpha); +#else return vec4(srgb_to_linear(dithered), alpha); +#endif } // The combine both entries run — the dry one at the fragment's own UV, the underwater one at the