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:
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user