the world draws inside the UI pass, and Questie's zone table fills

- the world is the first draw of the UI camera's main pass, not a pass before it: one render-pass boundary fewer, −0.9 ms of GPU on the Air at full render scale (2234)
- the world-map catalog seeds at the VM's birth, before any addon file runs, so Questie's Astrolabe builds its zone table instead of raising on every path (2240)
- the login's own tutorials wait for the interface, and a cross-map worldport fires PLAYER_LEAVING_WORLD, guarded by the reference's once-per-world latch (2232, 2235, 2239)
- WOW_PROBE_BGQUEUE makes "log in while queued for a battleground" reproducible (2237)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 16:59:49 +07:00
co-authored by Claude Fable 5.1
parent 1727b748ad
commit f7406de1fd
22 changed files with 1559 additions and 360 deletions
+2
View File
@@ -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;
@@ -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<N-spelled>`; `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::<BgQueueProbe>()
.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<BgQueueProbe>,
me: Query<&ObjectStore, With<SelfPlayer>>,
player: Res<Player>,
battlefield: Res<Battlefield>,
queue: Res<BattlefieldQueue>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<crate::net::NetCommands>,
) {
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 => {}
}
}
+7
View File
@@ -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`).
+4 -3
View File
@@ -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 `<Minimap>` widget's extracted hole with
// the streamed tile window + mask + player arrow, and feeds the zone text.
+3 -1
View File
@@ -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,
@@ -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()
+9 -8
View File
@@ -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),
+80 -1
View File
@@ -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::<crate::items::Items>()
.init_resource::<crate::sound::MessageSounds>()
// 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::<crate::ui_trainer::TrainerOpen>()
.init_resource::<crate::ui_merchant::MerchantOpen>()
.init_resource::<crate::ui_taxi::TaxiState>()
.init_resource::<crate::ui_party::GroupState>()
.init_resource::<Player>()
.add_message::<EnteredWorldMessage>();
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::<Tutorials>();
t.apply_flags(&[0u8; 32]);
t.trigger(id::WELCOME, 0, Instant::now());
}
app.update();
assert_eq!(
app.world().resource::<Tutorials>().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::<crate::ui_script::PendingEntryUiLoad>();
app.update();
assert!(
app.world().resource::<Tutorials>().fired.is_empty(),
"…and once there is an interface, it is published exactly once"
);
}
}
+7
View File
@@ -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<u64> {
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)
+50 -76
View File
@@ -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<UiQuad>,
pub quads: Vec<UiQuad>,
/// The append lane — see the struct doc. Compared by the rebuild, never flagged.
pub overlays: Vec<UiQuad>,
@@ -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<Image>`, 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<AssetId<Image>> = 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<Image>` 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::<UiQuads>();
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
+3 -3
View File
@@ -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::<Vec<_>>().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!(
+114 -4
View File
@@ -499,6 +499,14 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
.get_resource::<crate::cvars::CvarPersist>()
.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 `<OnLoad>` 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::<LeavingWorldArmed>() {
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<SelfPlayer>` 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<crate::net::SelfPlayer>>,
mut armed: ResMut<LeavingWorldArmed>,
) {
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::<AddOnIdentity>()
.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::<LeavingWorldArmed>()
.is_some_and(|mut l| l.spend());
if !ui_never_loaded {
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
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<NonSendMut<UiScript>>,
id: Res<AddOnIdentity>,
pending_entry: Option<Res<PendingEntryUiLoad>>,
mut armed: ResMut<LeavingWorldArmed>,
mut exits: MessageReader<AppExit>,
) {
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);
}
}
+6 -1
View File
@@ -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::<LeavingWorldArmed>()
.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.
@@ -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::<benilla_ui::script::UiScript>()
.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::<super::LeavingWorldArmed>();
assert!(
!world.resource::<super::LeavingWorldArmed>().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::<super::LeavingWorldArmed>().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::<super::LeavingWorldArmed>().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::<benilla_ui::script::UiScript>()
.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::<i64>("return SeenLeaving").unwrap(),
0,
"the glue-screen quit fired PLAYER_LEAVING_WORLD — the reference's 0x490bf5 skips it"
);
assert_eq!(
script.eval::<i64>("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::<i64>("return SeenLeaving").unwrap(),
1,
"an in-world root must fire PLAYER_LEAVING_WORLD — the control for the assertion above"
);
assert_eq!(
script.eval::<i64>("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::<benilla_ui::script::UiScript>()
.and_then(|s| s.eval::<Option<u32>>(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);
}
+3 -1
View File
@@ -34,7 +34,9 @@ pub(crate) struct TaxiCatalogs {
}
/// Load [`TaxiCatalogs`] once the patch chain exists. Never re-runs past the first success/failure
/// (`Local<bool>`) — same shape as [`crate::ui_world_map::load_world_map_ui`].
/// (`Local<bool>`), 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<bool>,
world_assets: Option<Res<WorldAssets>>,
+176
View File
@@ -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<NonSendMut<UiScript>>,
mut armed: ResMut<crate::ui_script::LeavingWorldArmed>,
mut ports: MessageReader<crate::net::WorldportMessage>,
) {
// `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::<crate::sound::MessageSounds>()
.add_message::<UnitCombatFeedback>()
.add_message::<CombatTextEvent>()
// …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::<crate::net::WorldportMessage>()
// 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::<crate::ui_script::LeavingWorldArmed>()
.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::<crate::net::WorldportMessage>()
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_systems(Update, fire_leaving_world_on_worldport);
app.insert_non_send_resource(UiScript::new().expect("VM"));
app.world_mut()
.non_send_resource::<UiScript>()
.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::<UiScript>()
.eval::<i64>("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::<crate::ui_script::LeavingWorldArmed>()
.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");
}
}
+63 -40
View File
@@ -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<crate::ui_script::VmMemo<bool>>,
script: Option<NonSendMut<UiScript>>,
world_assets: Option<ResMut<WorldAssets>>,
maps: Option<Res<MapCatalogRes>>,
areas: Option<Res<crate::area::AreaTableRes>>,
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<WorldMapContinentView>);
/// **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::<WorldMapCatalog>() {
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::<usize>()
);
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::<WorldMapCatalog>() 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::<usize>()
);
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<WorldMapContinentView>, Vec<ContinentEntry>)> {
let assets = world.get_resource::<WorldAssets>()?;
let maps = world.get_resource::<MapCatalogRes>()?;
let areas = world.get_resource::<crate::area::AreaTableRes>()?;
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),
+136 -129
View File
@@ -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<Image>,
/// 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<Image>`, 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<Image>`. 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<Image>` 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::<PreparedMaterial2d<M>>::default()` — `AFTER = ()`, i.e. **not** ordered
/// after `prepare_assets::<GpuImage>` — 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<Image>` 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<WorldBackdrop>,
mut images: ResMut<Assets<Image>>,
@@ -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<WorldBackdrop>,
mut quads: ResMut<UiQuads>,
cameras: Query<&Camera, With<WorldCamera>>,
windows: Query<&Window, With<PrimaryWindow>>,
/// 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<WorldCamera>>,
mut ui: Query<&mut FfxBackdrop, With<PlayerUiCamera>>,
) {
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::<FfxBackdrop>(ui).unwrap().source;
app.update();
assert_eq!(source(&mut app), Some(world_cam), "active: claimed");
app.world_mut()
.get_mut::<Camera>(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()
);
}
+9 -1
View File
@@ -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
+452 -81
View File
@@ -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<Entity>,
}
/// 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<Entity, (With<WorldCamera>, Without<FfxGlow>)>,
mut with: Query<(Entity, &mut Camera), With<FfxGlow>>,
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<bool> = 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::<FfxGlow>();
// With no combine left to write the output, the view goes back to bevy's blit — a
// `Skip` camera (2206, `final_pass`) would otherwise present a target nothing wrote.
if matches!(camera.output_mode, CameraOutputMode::Skip) {
camera.output_mode = CameraOutputMode::Write {
blend_state: None,
clear_color: ClearColorConfig::Default,
};
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<u32>,
/// 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<FfxCombinePair>,
}
/// 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<FfxCombinePipeline>>,
}
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<u32>,
) -> 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<TextureCache>,
render_device: Res<RenderDevice>,
pipeline_cache: Res<PipelineCache>,
pipelines: Res<FfxGlowPipelines>,
mut combine_pipelines: ResMut<SpecializedRenderPipelines<FfxCombinePipeline>>,
mut specializer: CombineSpecializer,
claims: Res<FfxBackdropClaims>,
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::<FfxGlowPipelines>();
let pipeline_cache = world.resource::<PipelineCache>();
let death = world.resource::<FfxDeathFade>().0;
let wave = *world.resource::<FfxWave>();
let uniform = combine_uniform(
world.resource::<FfxGlowGain>().0,
FfxPassState::world(death, world.resource::<FfxHazeMix>().0),
FfxPassState::glue(world.resource::<GlueFfx>().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::<RenderQueue>().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::<RenderQueue>().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::<FfxDeathFade>().0;
let wave = *world.resource::<FfxWave>();
let uniform = combine_uniform(
world.resource::<FfxGlowGain>().0,
FfxPassState::world(death, world.resource::<FfxHazeMix>().0),
FfxPassState::glue(world.resource::<GlueFfx>().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<Entity>);
/// 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<Entity>,
}
/// 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<FfxGlow>, Changed<ViewTarget>)>;
/// 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<ViewTarget>`: `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<FfxBackdropClaims>,
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::<ViewSortedRenderPhases<Transparent2d>>()
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::<ViewTarget>(source),
world.get::<FfxGlowTextures>(source),
world.get::<FfxGlow>(source),
) else {
return None;
};
let pipelines = world.resource::<FfxGlowPipelines>();
let pipeline_cache = world.resource::<PipelineCache>();
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::<RenderQueue>().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::<FfxWave>()
.add_plugins((
ExtractComponentPlugin::<FfxGlow>::default(),
ExtractComponentPlugin::<FfxBackdrop>::default(),
ExtractResourcePlugin::<FfxGlowGain>::default(),
ExtractResourcePlugin::<FfxDeathFade>::default(),
ExtractResourcePlugin::<GlueFfx>::default(),
@@ -1068,10 +1427,14 @@ impl Plugin for FfxGlowPlugin {
};
render_app
.init_resource::<SpecializedRenderPipelines<FfxCombinePipeline>>()
.init_resource::<FfxBackdropClaims>()
.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::<ViewNodeRunner<FfxGlowNode>>(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::<ViewNodeRunner<FfxTransparent2dNode>>(
Core2d,
Node2d::MainTransparentPass,
);
}
}
+7 -5
View File
@@ -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;
+10 -1
View File
@@ -125,14 +125,23 @@ fn screen_space_dither(frag_coord: vec2<f32>) -> vec3<f32> {
}
// 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<f32>, alpha: f32, frag_coord: vec2<f32>) -> vec4<f32> {
let dithered = clamp(
outg + screen_space_dither(frag_coord) * step(0.5, ffx.lane.w),
vec3<f32>(0.0),
vec3<f32>(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<f32>(dithered * alpha, alpha);
#else
return vec4<f32>(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