- black rain is a zero-padded mip: `RainDrop01.blp` stores its 2×16 and 1×8 DXT3 levels as one block each where the block grid needs four and two, our decoder zero-padded the rest — an all-zero BC2 block is black at alpha 0 — and the rain's Mod2x lane reads no alpha, so every far streak that minified onto levels 3 and 4 (three-quarters and half black by the census) multiplied the scene toward black from ~43 yd, where level 3 enters the trilinear blend, to the fog at 70; the reference sizes a stored level at `bpp·max(4,w)·max(4,h)/8` bytes from its file offset and the header's `mipSizes` never sizes an upload, so a short level is completed by the next levels' own grey blocks — `[L3 | L4 | L5 | L6]` — and `Header::dxt_level_span` reads that span on both decode entry points, CPU and native byte-identical, with `pad_to` kept only for a file that ends inside a level, which no shipped asset does (543 of 32,309 DXT files carry a short level, every one non-square, none past EOF); not a mip cap and not a grey fill — the file's next bytes are what the reference draws; wow-re's cross-check settled which arm is which: the block copier `0x5a5780` is live on the shipped Direct3D default (`0x5a0a80` → `0x5a4f60`'s cell `[6][6]`), the upload's `imageSize` at `0x59f4e6`–`0x59f515` over the zero-copy pointer (`0x5a8555`) on OpenGL, the same bytes either way — and the `GL_TEXTURE_MAX_LOD` clamp is dead on every device (its gate `[0xc0f274]` has one writer, a zeroed `ebx`), so every tail level is sampled; why it surfaced now: 1642/1645's isotropic trilinear takes the streak's width for its LOD where the retired anisotropy 8 took its length, which carried the far streaks onto level 3 inside the fog window for the first time; `benilla-extract blp --mips` writes every authored level and prints the per-level texel census, and a real-asset test pins the copy byte for byte (B358/B225, 2020, 2024)
- the cooldown sweep draws again: the extract's `ModelPane` arm drew the atlas cell it found in the bridge, but the cell is packed by `sync_tiles` AFTER the extract and the conversion is memoized on the engine's list — so a cooldown armed on a quiet interface extracted once with no cell, the cell arrived a frame later, nothing re-ran the conversion, and the tile lived on the atlas for the whole cooldown with no quad on screen (the 2013 harness test had re-pinged the minimap to force a second conversion, encoding the defect as the contract); the arm now publishes the request — rect, paint key, alpha, clip beside the unit ladder — and `compose_tiles` appends one premultiplied quad per cell packed THIS frame in the `UiQuadAppend` lane, a function of the bridge alone; the second defect behind it: 2019's rotation rows were uploaded to the world's shared light buffer only, while a tile's material twin binds the tile's own black-light buffer whose mat-anim region is zero-initialised, so every quadrant read the identity — and `cooldown.blp` is 0.6-alpha black outside a four-texel strip, so the sweep would have drawn as a static disc and never cleared; `MatAnimMirrors` now carries the region into every buffer a lane binds under one generation gate; measured six seconds into a 30 s stance cooldown, the button rects read means of 79 and 22 against 150 and 48 before, the top-right wedge cleared from 12 toward 3 o'clock; `WOW_TILE_TRACE=1` is the tile probe (2023)
- a body inside a GameObject's hull is left where the server put it: the reference's resolver is a time-of-impact sweep and nothing else — no push-out stage anywhere in `0x634040`'s closure, and `0x632830`'s clip drops a candidate face whose clipped vertices all sit more than 1/36 yd (`[0x7ff9c8]`) behind the prism's leading plane — where avian's slide opened and closed with a Gauss–Seidel push-out whose minimum translation for a tall capsule in a small box is horizontal, shoving a seated body 0.555 yd off its stool on the first frame after the server seated it (B359); the `depenetrate` stage is deleted rather than disabled, and because the deletion alone left the body unable to walk out (a straddled seat top read as a wall at t = 0) the sweep carries the reference's band as `BACKFACE_BAND`, measured against the face's vertices, so a body walks off a stool and still stands on a sloped floor its feet sit a few centimetres under; the creature clamp cast its down-ray from 2.5 yd above the seat, a probe the reference never casts — its settle sweeps DOWN from the current position (`0x636dcd`–`0x636e45`) and the only way up is the step-up after a blocking horizontal hit — which found a cage's lid as Galen Goodward's floor (B372; seat 22.416, ray from 24.916, lid 24.667): a walker continuing its path now runs the shared `grounded_step` from where it stood with a creature's own 2.0 yd rise budget (`[0x801628]`) and takes only the Y, an idle unit casts down from its seat with the 1/36 band above it, and continuity is one server path long since `0x7c6420` re-bases on every packet; the `clmp` trace tag (`WOW_CLAMP_TRACE`) reads one clamp cast and the `move` tag marks input-less travel as CREEP (2018); and a seated body is not re-faced by the mouse: the reference's mouse-MOVE handler `0x514400` rotates the camera unconditionally and hands its facing to the body only through `0x5145e0`, whose last conjunct is `GetStandState() == 0` (`0x51460c`, the client-predicted cache), with `0x5151b0` re-testing the descriptor byte — so a right-drag while sitting orbits the camera round a body that keeps its facing and sends nothing; benilla restored the facing on the stun/death predicate only and spun a seated body in its chair with every sample, streaming a `SET_FACING` each; `MoverInput::mouse_may_turn_body` carries the stand-state term, and the keyboard turn keeps `may_turn` alone because that path stands the body (2025)
- the vendor window's face and name: the stock `MerchantFrame.lua` takes its title from `UnitName("NPC")` and its face from `SetPortraitTexture(MerchantFramePortrait, "NPC")` — the `"npc"` token, `CGGameUI`'s `[0xb4e2d0]` that every NPC window reads on its own `*_SHOW` — and that seam had four defects stacked on it: `feed_interact_npc` ran a frame ahead of the net apply, so the frame `SMSG_LIST_INVENTORY` raised `MERCHANT_SHOW` still held the previous NPC (the Goldshire inn titled "Innkeeper Farley" over Brog Hamfist's stock); `feed_units`' dirty gate had no interact term, so a swap to a second vendor moved no descriptor and skipped the feed; the memo row was never written, so `UnitExists("npc")` stayed true after a close; and `SetPortraitTexture` stored its token raw where the booth slots are keyed lowercase — the reference resolves every unit token through `0x515970`, whose ten compares are all `_strnicmp`, so the binding folds on the way in; the writer now seats itself `.after(apply_net_updates).before(drive_display_facing)`, pinned in the built schedule graph rather than the source, the gate takes `interact.is_changed()`, the memo is written, and the fold lands at the one seam where a Lua token becomes a slot name — every NPC window reads the same token through the same chain; `WOW_PROBE_VENDOR_SWAP=1` stands between Brog Hamfist and Barkeep Dobbins, 5.45 yd apart, reads the name inside `MERCHANT_SHOW`, opens one over the other and closes it — five FAILs before, five PASSes after (2022)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
652 lines
34 KiB
Rust
652 lines
34 KiB
Rust
//! The **NPC-session range guard** — the one standardized rule for every UI window bound to a live
|
|
//! NPC (merchant stock, gossip menu, questgiver panel): when the player walks out of the
|
|
//! NPC-service range, or the NPC despawns, the window client-side-closes. That close is the exact
|
|
//! no-packet clear the window's own close button does (vanilla sends nothing for any of the three);
|
|
//! each window's feed then diffs the cleared state into its `*_CLOSED` event and the panel hides.
|
|
//!
|
|
//! The threshold is the byte-verified NPC-service gate the cursor grays at
|
|
//! ([`SERVICE_RANGE_SQ`], 5.5556 yd center-to-center — `crate::target::cursor_mode`), so a window
|
|
//! closes exactly where the cursor says its NPC is out of service — one law for "out of service".
|
|
//!
|
|
//! **That last sentence used to end "…is INFERRED (its close mechanism isn't RE'd)". It is now a
|
|
//! fact** (decision 1741): the close mechanism is `0x493230`, called per frame from
|
|
//! `CGWorldFrame`'s layer update, and a census of all fourteen of the client's arming sites found
|
|
//! thirteen pushing this very constant. The one thing the guess missed is that the leash is not
|
|
//! the window's at all — it belongs to the latched OBJECT, and swaps to 14.0 yd for a player
|
|
//! ([`leash_sq`]).
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfPlayer};
|
|
use crate::target::SERVICE_RANGE_SQ;
|
|
use benilla_world::schedule::WorldStage;
|
|
|
|
/// Owns the cross-window session state: [`InteractNpc`] and the one system that feeds it.
|
|
///
|
|
/// It has a plugin of its own because [`InteractNpc`] has **two** consumers that must not depend on
|
|
/// each other — the portrait booth's `"npc"` token (decision 0081) and the interaction face-me
|
|
/// (`crate::net::motion`'s display-facing chain, decision 1467). It used to be initialised and fed
|
|
/// from inside the portrait plugin, which silently made a *facing* law contingent on the portrait
|
|
/// plugin being mounted; a shared resource belongs to the thing it describes.
|
|
pub(crate) struct UiSessionPlugin;
|
|
|
|
impl Plugin for UiSessionPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
// Seated INSIDE the net chain: after the apply pass that opens and swaps the sessions
|
|
// it reads, before the facing chain that reads its answer. **Same frame, not next
|
|
// frame** — this used to be "deliberately unordered w.r.t. the apply pass", on the
|
|
// reasoning that a window is open for seconds and one frame is invisible against an
|
|
// ~8-frame facing ease. That was true for the facing and false for the token: the unit
|
|
// feed runs after this set and the window feeds run after the unit feed, so the frame a
|
|
// `SMSG_LIST_INVENTORY` opened or swapped the merchant window was the frame its own
|
|
// `MERCHANT_SHOW` handler read `UnitName("NPC")` — and this had not yet been told. The
|
|
// title read the PREVIOUS NPC's name over the new vendor's stock, and a second vendor
|
|
// opened over an open window never swapped the name at all (decision 2022).
|
|
app.init_resource::<InteractNpc>().add_systems(
|
|
Update,
|
|
feed_interact_npc
|
|
.in_set(WorldStage::Net)
|
|
.after(crate::net::apply_net_updates)
|
|
.before(crate::net::drive_display_facing),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A UI session bound to a live NPC (or, for the mailbox, a GameObject): the shared face the range
|
|
/// guard closes through. Implemented by [`crate::ui_merchant::MerchantOpen`],
|
|
/// [`crate::ui_gossip::GossipState`], [`crate::ui_quest::QuestGiver`],
|
|
/// [`crate::ui_trainer::TrainerOpen`], [`crate::ui_taxi::TaxiState`],
|
|
/// [`crate::ui_mail::MailOpen`], [`crate::ui_trade::TradeSession`], [`crate::ui_bank::BankOpen`]
|
|
/// and [`crate::ui_auction::AuctionOpen`] — plus the two free-standing *questions*, which are
|
|
/// sessions for the same reason a window is ([`crate::ui_binder::BinderState`],
|
|
/// [`crate::ui_talent_wipe::TalentWipeState`]: the reference re-runs its own interact-range test
|
|
/// against a latched guid, so "walked away" retracting the dialog is its law, not our tidiness).
|
|
/// Each *NPC* window registers
|
|
/// [`close_npc_session_out_of_range::<T>`] ahead of its feed so the clear turns into the window's
|
|
/// `*_CLOSED` event the same frame — trade does **not** (its cancel is server-driven, decision 0592).
|
|
/// [`feed_interact_npc`] collapses the portrait-bound sessions into the [`InteractNpc`] the
|
|
/// portrait booth reads for its `"npc"` slot; the mailbox is deliberately excluded from that
|
|
/// collapse (its window icon is art, not a unit-model bake — decision 0544). Trade's "npc" is a
|
|
/// live *player* (the partner), baked exactly like a vendor (decision 0592).
|
|
pub(crate) trait NpcSession: Resource {
|
|
/// The NPC this session is bound to; `None` = no window open.
|
|
fn npc(&self) -> Option<u64>;
|
|
/// The window's client-side close (no packet) — the same clear its close button does.
|
|
fn close(&mut self);
|
|
/// What the WALK-AWAY close owes the wire, if anything — sent by the guard below just before
|
|
/// it closes, and `None` for every session but one.
|
|
///
|
|
/// It exists because the reference's watchdog does not route through the window's own close
|
|
/// verb: `0x4933da` calls the quest session's end (`0x501130`) **directly**, skipping
|
|
/// `DeclineQuest`'s core — so walking away from an NPC's quest panel is network-silent (no
|
|
/// giver re-open, and the DETAILS suppression flag is not consulted) while walking away from a
|
|
/// party member's SHARED quest still owes them `MSG_QUEST_PUSH_RESULT{DECLINE_QUEST}`
|
|
/// (decision 1741). A hook rather than a call to [`Self::close`] because that difference —
|
|
/// send *less* on the walk-away than on the button — is exactly what the binary does.
|
|
fn walk_away_send(&self, _npc: u64) -> Option<ClientCommand> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// The leash the walk-away guard measures against, **chosen by the latched object's type, not by
|
|
/// the window** — the reference's `0x4930d0`, which stores the opener's own range and then
|
|
/// overwrites it when the object carries the PLAYER typemask bit (`0x493156`).
|
|
///
|
|
/// Thirteen of the client's fourteen arming sites push the same `50/9` yd
|
|
/// ([`SERVICE_RANGE_SQ`]) — mailbox, loot, stable master, auctioneer, trainer, flight master,
|
|
/// gossip, item text, petition, bank, vendor, questgiver — so benilla having one constant for all
|
|
/// of them is right, and byte-exact. What benilla did not have is the swap: a session latched onto
|
|
/// a **player** is leashed at `10.0 + 4.0 = 14.0` yd instead (`[0x8044b0] + [0x80306c]`, squared
|
|
/// into the cell at `0x49316d`). The only such session that registers this guard is a shared
|
|
/// quest — and 1733 read that case as "not range-guarded at all", which is wrong in the same
|
|
/// direction a plain revert to 5.56 yd would have been wrong (decision 1741).
|
|
///
|
|
/// The 14.0 coinciding with vmangos's `QUEST_SHARE_DISTANCE` is corroboration, not the derivation:
|
|
/// the client's number is two `.rdata` floats summed.
|
|
fn leash_sq(npc: u64) -> f32 {
|
|
if benilla_protocol::guid::is_player(npc) {
|
|
PLAYER_LEASH_SQ
|
|
} else {
|
|
SERVICE_RANGE_SQ
|
|
}
|
|
}
|
|
|
|
/// `14.0²` — the player-typemask leash (see [`leash_sq`]).
|
|
const PLAYER_LEASH_SQ: f32 = 196.0;
|
|
|
|
/// Did an NPC-bound window switch to a **different** NPC of the same kind while it was already open
|
|
/// (a `Some(a) → Some(b)`, `a != b`)? The real client can't just repaint: its `ShowUIPanel` early-
|
|
/// returns when the frame is already visible, so the only way MERCHANT_SHOW (etc.) re-plays the open
|
|
/// sound is if the frame is HIDDEN first. So the client fires `*_CLOSED` then `*_SHOW` on a
|
|
/// vendor→vendor (or gossip→gossip, quest→quest) change — a real close+open, both sounds. Each feed
|
|
/// reproduces that, then **consumes the close intent** its own `*_CLOSED`→OnHide→`CloseX` queued, so
|
|
/// the session it just re-opened to `b` is not wiped by the drain (decision 0096).
|
|
pub(crate) fn npc_switched(prev: Option<u64>, now: Option<u64>) -> bool {
|
|
matches!((prev, now), (Some(a), Some(b)) if a != b)
|
|
}
|
|
|
|
/// Close `T`'s open session when the player leaves the NPC-service range or the NPC despawns.
|
|
pub(crate) fn close_npc_session_out_of_range<T: NpcSession>(
|
|
mut session: ResMut<T>,
|
|
index: Res<GuidIndex>,
|
|
commands: Res<NetCommands>,
|
|
self_q: Query<&Transform, With<SelfPlayer>>,
|
|
transforms: Query<&Transform>,
|
|
) {
|
|
let Some(npc) = session.npc() else { return };
|
|
let Some(self_tf) = self_q.iter().next() else {
|
|
return;
|
|
};
|
|
// Boundary-INCLUSIVE, centre-to-centre, no bounding radii — the reference's own compare
|
|
// (`0x4932f2`/`0x4932fe`: `dist² <= leash²` keeps). The leash is the object's, not the
|
|
// window's ([`leash_sq`]).
|
|
let out = match index.0.get(&npc).and_then(|e| transforms.get(*e).ok()) {
|
|
Some(tf) => tf.translation.distance_squared(self_tf.translation) > leash_sq(npc),
|
|
None => true, // the NPC despawned out from under the window
|
|
};
|
|
if out {
|
|
debug!("ui_session: NPC {npc:#x} out of range/gone — client-side close");
|
|
if let Some(cmd) = session.walk_away_send(npc) {
|
|
let _ = commands.0.send(cmd);
|
|
}
|
|
session.close();
|
|
}
|
|
}
|
|
|
|
/// The one unit an NPC-interaction window points its portrait at: whichever [`NpcSession`] is
|
|
/// currently open (gossip / quest / merchant / trainer / taxi / trade / bank / auction — only one is
|
|
/// ever open in play),
|
|
/// resolved through the [`GuidIndex`] to its live world entity. The portrait booth reads this for
|
|
/// the `"npc"` token exactly as it reads [`crate::target::Selection`] for `"target"` — the
|
|
/// decision-0105 face bake, wired to the interaction arc's NPC (decision 0081). `None` = no NPC
|
|
/// window open, so the booth empties; the ring is hidden with its window then, so the dark disc
|
|
/// never shows.
|
|
#[derive(Resource, Default, PartialEq, Eq)]
|
|
pub(crate) struct InteractNpc(pub(crate) Option<Entity>, pub(crate) Option<u64>);
|
|
|
|
/// Collapse the portrait-bound sessions into [`InteractNpc`] each frame: the open one's guid,
|
|
/// resolved to its entity. One deterministic writer (not one publish-system per session), so there is
|
|
/// no cross-system race over who owns the `"npc"` token — the sessions are mutually exclusive, and a
|
|
/// bare `.or` chain is the whole rule. `Option<Res<_>>` keeps it safe in a headless test that mounts
|
|
/// the portrait plugin without every window plugin.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn feed_interact_npc(
|
|
gossip: Option<Res<crate::ui_gossip::GossipState>>,
|
|
quest: Option<Res<crate::ui_quest::QuestGiver>>,
|
|
merchant: Option<Res<crate::ui_merchant::MerchantOpen>>,
|
|
trainer: Option<Res<crate::ui_trainer::TrainerOpen>>,
|
|
taxi: Option<Res<crate::ui_taxi::TaxiState>>,
|
|
// Trade points the "npc" portrait at the partner (a live player) while its window is open — the
|
|
// same booth path, one more mutually-exclusive session in the chain (decision 0592 P1).
|
|
trade: Option<Res<crate::ui_trade::TradeSession>>,
|
|
// The banker's portrait while the vault is open (decision 0604) — same booth path.
|
|
bank: Option<Res<crate::ui_bank::BankOpen>>,
|
|
// The auctioneer's, while the auction house is open — the same booth path again. Its absence
|
|
// here is what left `AuctionPortraitTexture` a black disc: the window asks for `"npc"` in its
|
|
// OnShow like every other NPC window, and nothing was answering.
|
|
auction: Option<Res<crate::ui_auction::AuctionOpen>>,
|
|
// The guild registrar's, while the charter window is open (decision 1672) — same booth path,
|
|
// and it is NOT covered by the gossip arm above: the server closes the gossip menu before it
|
|
// sends `SMSG_PETITION_SHOWLIST` (`Player.cpp:12428-12431` — `CloseGossip()` then
|
|
// `SendPetitionShowList`), so by the time `GuildRegistrar_OnShow` asks for `"npc"` the gossip
|
|
// session is already gone. Without this arm the window's portrait is the auctioneer's black
|
|
// disc all over again and its name banner is blank.
|
|
registrar: Option<Res<crate::ui_petition::GuildRegistrarState>>,
|
|
// The stable master's, while the pet stable is open (decision 1684) — the black disc a THIRD
|
|
// time, and this one could not have leaned on the gossip arm even by accident: benilla asks a
|
|
// menuless stable master for its pet list DIRECTLY on the interact leg (decision 1680, the
|
|
// client's own `0x5f05bc` path), so there is no gossip session behind it at all. The registrar
|
|
// arm above is the same shape for the same reason.
|
|
stable: Option<Res<crate::ui_stable::StableOpen>>,
|
|
// The tabard designer's vendor (decision 1977) — its portrait and name banner, the same way.
|
|
tabard: Option<Res<crate::ui_tabard::TabardOpen>>,
|
|
index: Option<Res<GuidIndex>>,
|
|
mut out: ResMut<InteractNpc>,
|
|
) {
|
|
let guid = gossip
|
|
.and_then(|s| s.npc())
|
|
.or_else(|| quest.and_then(|s| s.npc()))
|
|
.or_else(|| merchant.and_then(|s| s.npc()))
|
|
.or_else(|| trainer.and_then(|s| s.npc()))
|
|
.or_else(|| taxi.and_then(|s| s.npc()))
|
|
.or_else(|| trade.and_then(|s| s.npc()))
|
|
.or_else(|| bank.and_then(|s| s.npc()))
|
|
.or_else(|| auction.and_then(|s| s.npc()))
|
|
.or_else(|| registrar.and_then(|s| s.npc()))
|
|
.or_else(|| stable.and_then(|s| s.npc()))
|
|
.or_else(|| tabard.and_then(|s| s.npc()));
|
|
// Field 0 is the entity the portrait booth bakes and the facing chain steers by; field 1 is
|
|
// the same NPC's **guid**, which `crate::ui_unit`'s feed needs to resolve the `"npc"` unit
|
|
// token's name (a name lives in the `NameCache`, keyed by guid — there is no way back to one
|
|
// from an entity). Both are set together and cleared together; a guid whose entity is not
|
|
// streamed still names the unit, which is why they are two fields rather than one lookup.
|
|
//
|
|
// Written only on a change, so `is_changed()` means it: the unit feed's dirty gate lists
|
|
// this resource among its inputs (decision 2022), and an unconditional write every frame
|
|
// would hold that gate open for the whole session.
|
|
let next = InteractNpc(
|
|
guid.and_then(|g| index.and_then(|i| i.0.get(&g).copied())),
|
|
guid,
|
|
);
|
|
out.set_if_neq(next);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::ui_merchant::MerchantOpen;
|
|
|
|
/// A realistic NPC guid — `HIGHGUID_UNIT` in the high word. Not a bare low guid: the high word
|
|
/// is what makes a guid a *player* (`guid::is_player`), and since 1741 the walk-away leash
|
|
/// depends on that, so a "vendor" numbered `0x42` is silently a player and gets 14 yd.
|
|
const VENDOR: u64 = 0xF130_0000_0000_0042;
|
|
|
|
/// Drive the guard in a minimal Bevy app over the merchant session (the trait's first
|
|
/// consumer): in range keeps the window, stepping past the service gate closes it, and a
|
|
/// despawned NPC closes it too.
|
|
///
|
|
/// The vendor guid is a real `HIGHGUID_UNIT` one. It used to be a bare `0x42`, which is a
|
|
/// **player** guid (the high word is what decides — `guid::is_player`), and that went unnoticed
|
|
/// until the leash started depending on the type: a "vendor" with a player guid is leashed at
|
|
/// 14 yd and this test's 6 yd step stopped closing it (decision 1741).
|
|
#[test]
|
|
fn out_of_range_or_despawned_npc_closes_the_session() {
|
|
let mut app = App::new();
|
|
app.init_resource::<MerchantOpen>();
|
|
app.init_resource::<GuidIndex>();
|
|
// The guard sends the walk-away answer for the one session that owes one (1741); the
|
|
// merchant owes none, but the channel has to exist for the system to run.
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
app.insert_resource(NetCommands(tx));
|
|
app.add_systems(Update, close_npc_session_out_of_range::<MerchantOpen>);
|
|
app.world_mut()
|
|
.spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
|
|
let vendor = app
|
|
.world_mut()
|
|
.spawn(Transform::from_xyz(5.0, 0.0, 0.0))
|
|
.id();
|
|
app.world_mut()
|
|
.resource_mut::<GuidIndex>()
|
|
.0
|
|
.insert(VENDOR, vendor);
|
|
|
|
// 5.0 yd < the 5.5556 yd service gate: stays open.
|
|
app.world_mut()
|
|
.resource_mut::<MerchantOpen>()
|
|
.open(VENDOR, vec![]);
|
|
app.update();
|
|
assert!(app.world().resource::<MerchantOpen>().is_open());
|
|
|
|
// Step to 6 yd: past the gate — client-side close.
|
|
*app.world_mut()
|
|
.entity_mut(vendor)
|
|
.get_mut::<Transform>()
|
|
.unwrap() = Transform::from_xyz(6.0, 0.0, 0.0);
|
|
app.update();
|
|
assert!(!app.world().resource::<MerchantOpen>().is_open());
|
|
|
|
// Re-open, then the NPC despawns out from under the window: closes too.
|
|
app.world_mut()
|
|
.resource_mut::<MerchantOpen>()
|
|
.open(VENDOR, vec![]);
|
|
app.world_mut()
|
|
.resource_mut::<GuidIndex>()
|
|
.0
|
|
.remove(&VENDOR);
|
|
app.update();
|
|
assert!(!app.world().resource::<MerchantOpen>().is_open());
|
|
}
|
|
|
|
/// **The leash belongs to the OBJECT, not the window** (decision 1741, correcting 1733): a
|
|
/// session latched onto a player is guarded at 14.0 yd, one latched onto an NPC at 5.56 yd.
|
|
/// 1733 read the share case as "not range-guarded at all" — wrong in the same direction that
|
|
/// reverting it to the NPC range would have been wrong, which is why both bounds are moved
|
|
/// here against the same panel.
|
|
///
|
|
/// It also pins what the walk-away SENDS: a shared quest still owes the sharer the decline,
|
|
/// and an NPC's panel closes silently — the reference's watchdog skips the giver re-open the
|
|
/// button's path does.
|
|
#[test]
|
|
fn the_walk_away_leash_is_the_givers_own_and_a_share_still_answers() {
|
|
use crate::ui_quest::{QuestGiver, QuestView};
|
|
|
|
fn detail(npc: u64) -> QuestView {
|
|
QuestView::Detail(benilla_protocol::messages::QuestDetails {
|
|
npc,
|
|
quest_id: 1,
|
|
title: "A Threat Within".into(),
|
|
details: String::new(),
|
|
objectives: String::new(),
|
|
auto_finish: 0,
|
|
choices: Vec::new(),
|
|
rewards: Vec::new(),
|
|
money: 0,
|
|
reward_spell: 0,
|
|
})
|
|
}
|
|
|
|
const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word
|
|
const NPC: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
|
|
|
|
/// Open the panel on `giver`, stand `dist` yards away, run the guard once, and report
|
|
/// whether the window survived and what went out.
|
|
fn walk(giver: u64, dist: f32) -> (bool, Vec<ClientCommand>) {
|
|
let (tx, rx) = crossbeam_channel::unbounded();
|
|
let mut app = App::new();
|
|
app.insert_resource(NetCommands(tx));
|
|
app.init_resource::<QuestGiver>();
|
|
app.init_resource::<GuidIndex>();
|
|
app.add_systems(Update, close_npc_session_out_of_range::<QuestGiver>);
|
|
app.world_mut()
|
|
.spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
|
|
let e = app
|
|
.world_mut()
|
|
.spawn(Transform::from_xyz(dist, 0.0, 0.0))
|
|
.id();
|
|
app.world_mut()
|
|
.resource_mut::<GuidIndex>()
|
|
.0
|
|
.insert(giver, e);
|
|
app.world_mut()
|
|
.resource_mut::<QuestGiver>()
|
|
.open(giver, detail(giver));
|
|
app.update();
|
|
(
|
|
app.world().resource::<QuestGiver>().is_open(),
|
|
rx.try_iter().collect(),
|
|
)
|
|
}
|
|
|
|
// A SHARE: 10 yd is past the NPC gate and well inside the player leash — 1733 would have
|
|
// kept this for the wrong reason, a plain revert would have closed it.
|
|
let (open, sent) = walk(SHARER, 10.0);
|
|
assert!(open, "a share is leashed at 14 yd, not 5.56");
|
|
assert!(sent.is_empty(), "still in range, nothing owed: {sent:?}");
|
|
|
|
// Past 14 yd it does close — and answers the sharer.
|
|
let (open, sent) = walk(SHARER, 15.0);
|
|
assert!(!open, "past the player leash the panel closes");
|
|
assert!(
|
|
matches!(
|
|
sent.as_slice(),
|
|
[ClientCommand::QuestPushResult {
|
|
sharer: SHARER,
|
|
msg: benilla_protocol::messages::QuestShareMsg::DECLINE_QUEST,
|
|
}]
|
|
),
|
|
"walking away from a share still declines it: {sent:?}"
|
|
);
|
|
|
|
// An NPC at the same 10 yd is long gone — the leash did not follow the window.
|
|
let (open, sent) = walk(NPC, 10.0);
|
|
assert!(!open, "an NPC giver is still leashed at 5.56 yd");
|
|
assert!(
|
|
sent.is_empty(),
|
|
"the walk-away skips the giver re-open the button does: {sent:?}"
|
|
);
|
|
|
|
// Boundary-inclusive: exactly on the leash keeps the window (`dist² <= leash²`).
|
|
assert!(walk(SHARER, 14.0).0, "14.0 yd exactly is still in range");
|
|
}
|
|
|
|
/// The `"npc"` portrait token's resolver ([`feed_interact_npc`]): the open NPC session's guid,
|
|
/// mapped through the [`GuidIndex`] to its world entity. No window → None; an unindexed
|
|
/// (not-yet-streamed) NPC → None, never a stale entity; the gossip session wins the `.or` chain
|
|
/// when more than one carries a guid (the same NPC on a browse-goods hop).
|
|
#[test]
|
|
fn interact_npc_resolves_the_open_session_to_its_entity() {
|
|
use crate::ui_gossip::GossipState;
|
|
|
|
let mut app = App::new();
|
|
app.init_resource::<GossipState>();
|
|
app.init_resource::<MerchantOpen>();
|
|
app.init_resource::<GuidIndex>();
|
|
app.init_resource::<InteractNpc>();
|
|
app.add_systems(Update, feed_interact_npc);
|
|
|
|
let npc = app.world_mut().spawn_empty().id();
|
|
app.world_mut()
|
|
.resource_mut::<GuidIndex>()
|
|
.0
|
|
.insert(VENDOR, npc);
|
|
|
|
// No window open → no npc.
|
|
app.update();
|
|
assert_eq!(app.world().resource::<InteractNpc>().0, None);
|
|
|
|
// A merchant opens on the indexed NPC → resolves to its entity.
|
|
app.world_mut()
|
|
.resource_mut::<MerchantOpen>()
|
|
.open(VENDOR, vec![]);
|
|
app.update();
|
|
assert_eq!(app.world().resource::<InteractNpc>().0, Some(npc));
|
|
|
|
// Gossip carries the same guid (a browse-goods hop) and wins the priority chain — still the
|
|
// one NPC's entity.
|
|
app.world_mut().resource_mut::<GossipState>().npc = Some(VENDOR);
|
|
app.update();
|
|
assert_eq!(app.world().resource::<InteractNpc>().0, Some(npc));
|
|
|
|
// Bound to a guid with no index entry (NPC not streamed) → None, not the last entity.
|
|
app.world_mut().resource_mut::<GossipState>().npc = Some(0xdead);
|
|
app.update();
|
|
assert_eq!(app.world().resource::<InteractNpc>().0, None);
|
|
|
|
// Every window that draws a portrait must be IN this chain — a session missing from it
|
|
// does not fail loudly, it just renders a black disc where the NPC's face goes, which is
|
|
// exactly how the auction house shipped (director's report, 2026-08-22). Asserted per
|
|
// session rather than in the abstract, because the omission is a missing `.or_else` and
|
|
// nothing but a test can notice one.
|
|
app.world_mut().resource_mut::<GossipState>().npc = None;
|
|
app.world_mut().resource_mut::<MerchantOpen>().close();
|
|
app.init_resource::<crate::ui_auction::AuctionOpen>();
|
|
app.world_mut()
|
|
.resource_mut::<crate::ui_auction::AuctionOpen>()
|
|
.open(VENDOR, 1);
|
|
app.update();
|
|
assert_eq!(
|
|
app.world().resource::<InteractNpc>().0,
|
|
Some(npc),
|
|
"the auctioneer's own portrait"
|
|
);
|
|
|
|
app.world_mut()
|
|
.resource_mut::<crate::ui_auction::AuctionOpen>()
|
|
.clear();
|
|
|
|
// The stable master's (decision 1684) — the same black disc, reported by the director on
|
|
// 2026-08-28. Note WHERE it shipped from: this very test already existed, with the
|
|
// paragraph above it saying every portrait window must be in the chain — and the stable
|
|
// still went out black, because the guard is **opt-in per session**. A window nobody
|
|
// remembers to add here is a window nobody's test covers. Worse for this one than for the
|
|
// auction house: benilla asks a menuless stable master for its list directly on the
|
|
// interact leg (decision 1680), so there is no gossip session to accidentally carry it.
|
|
app.init_resource::<crate::ui_stable::StableOpen>();
|
|
app.world_mut()
|
|
.resource_mut::<crate::ui_stable::StableOpen>()
|
|
.open(VENDOR, 2, vec![]);
|
|
app.update();
|
|
assert_eq!(
|
|
app.world().resource::<InteractNpc>().0,
|
|
Some(npc),
|
|
"the stable master's own portrait"
|
|
);
|
|
|
|
// Every session closes → None again.
|
|
app.world_mut().resource_mut::<GossipState>().close();
|
|
app.world_mut().resource_mut::<MerchantOpen>().close();
|
|
app.world_mut()
|
|
.resource_mut::<crate::ui_stable::StableOpen>()
|
|
.clear();
|
|
app.update();
|
|
assert_eq!(app.world().resource::<InteractNpc>().0, None);
|
|
}
|
|
|
|
/// **The structural tripwire the three black discs earned** (decision 1684).
|
|
///
|
|
/// The assertions above are opt-in: each names one session, and a window nobody remembers to
|
|
/// add is a window nobody covers. That is not hypothetical — the auction house shipped a black
|
|
/// portrait (0x-2026-08-22), the guild registrar was caught only because someone reasoned about
|
|
/// the gossip close, and the stable master shipped black on 2026-08-28 with this very test
|
|
/// already in the file telling everyone to add their session to it.
|
|
///
|
|
/// So this one is exhaustive by construction: it reads the app's own sources, finds every
|
|
/// `impl NpcSession for X`, and requires each `X` to be either **in the `.or` chain** or on the
|
|
/// **explicit exclusion list** below with a stated reason. A new NPC window cannot be silent —
|
|
/// it either wires its portrait or it says why it has none.
|
|
#[test]
|
|
fn every_npc_session_is_portrait_bound_or_explicitly_excluded() {
|
|
use std::path::Path;
|
|
|
|
/// Sessions that deliberately do NOT own the `"npc"` portrait token, each with the reason
|
|
/// it does not. Adding a name here is a decision; leaving one out is a black disc.
|
|
const EXCLUDED: &[(&str, &str)] = &[
|
|
// Decision 0544: the mail window's icon is authored art, not a unit-model bake, so it
|
|
// has no portrait to point and must not steal the token from a window that does.
|
|
("MailOpen", "its window icon is art, not a unit bake (0544)"),
|
|
// Reached only from inside an open gossip menu, which is still open behind it and is
|
|
// the FIRST arm of the chain — so the trainer's own NPC is already the answer. Unlike
|
|
// the registrar's case, the server does not close the menu first.
|
|
("TalentWipeState", "rides the still-open gossip session"),
|
|
("BinderState", "rides the still-open gossip session"),
|
|
// The pet trainer's question (1963): the talent wipe's twin, reached the same way.
|
|
("PetUnlearnState", "rides the still-open gossip session"),
|
|
];
|
|
|
|
// The chain's own source is the authority on what is wired — not a hand-copied list here,
|
|
// which would be the very drift this test exists to catch.
|
|
let this_file = include_str!("ui_session.rs");
|
|
let chain = this_file
|
|
.split_once("pub(crate) fn feed_interact_npc")
|
|
.expect("feed_interact_npc")
|
|
.1
|
|
.split_once("\n}\n")
|
|
.expect("end of feed_interact_npc")
|
|
.0;
|
|
|
|
let mut missing = Vec::new();
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
|
let mut stack = vec![root];
|
|
while let Some(dir) = stack.pop() {
|
|
for entry in std::fs::read_dir(&dir).expect("read_dir") {
|
|
let path = entry.expect("dir entry").path();
|
|
if path.is_dir() {
|
|
stack.push(path);
|
|
continue;
|
|
}
|
|
if path.extension().is_none_or(|e| e != "rs") {
|
|
continue;
|
|
}
|
|
// This file itself: the trait lives here and no session implements it here, but
|
|
// the prose above spells the pattern out — so scanning ourselves finds the doc
|
|
// comment and nothing real.
|
|
if path.file_name().is_some_and(|f| f == "ui_session.rs") {
|
|
continue;
|
|
}
|
|
let src = std::fs::read_to_string(&path).expect("read source");
|
|
for (_, rest) in src
|
|
.match_indices("impl NpcSession for ")
|
|
.map(|(i, _)| (i, &src[i + "impl NpcSession for ".len()..]))
|
|
{
|
|
let ty: String = rest
|
|
.chars()
|
|
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
|
.collect();
|
|
if ty.is_empty() || EXCLUDED.iter().any(|(name, _)| *name == ty) {
|
|
continue;
|
|
}
|
|
// Wired = the chain mentions the type. The chain names each session by its
|
|
// resource type in a `Res<...>` parameter, so a substring test is exact enough
|
|
// and cannot be fooled by a comment (comments naming a type still mean someone
|
|
// thought about it, which is the point).
|
|
if !chain.contains(&ty) {
|
|
missing.push(format!("{ty} (in {})", path.display()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
missing.is_empty(),
|
|
"these NpcSession windows own no `\"npc\"` portrait and are not on the exclusion \
|
|
list, so each renders a BLACK DISC where the NPC's face goes — wire them into \
|
|
`feed_interact_npc`'s chain, or add them to EXCLUDED with a reason: {missing:#?}"
|
|
);
|
|
}
|
|
|
|
/// The seam's ORDER, pinned in the BUILT schedule graph rather than in the source (decision
|
|
/// 2022): `feed_interact_npc` runs after the net apply that opens and swaps the sessions it
|
|
/// reads, and before the facing chain that reads its answer. Mounted for real — a `.after()`
|
|
/// against a system that is not in the schedule is silently nothing, so a source grep could
|
|
/// not tell a live edge from a dead one — and asked by system identity, not by name (the
|
|
/// debug names are a feature that may be off).
|
|
///
|
|
/// Why it is worth a graph walk: the writer used to sit ahead of the whole chain, which is
|
|
/// invisible for an 8-frame facing ease and exactly the frame the `"npc"` unit token was
|
|
/// stale on when a window's own `MERCHANT_SHOW` handler read it.
|
|
#[test]
|
|
fn the_interact_npc_writer_is_seated_between_the_net_apply_and_the_facing_chain() {
|
|
use bevy::ecs::schedule::graph::Direction::{Incoming, Outgoing};
|
|
use bevy::ecs::schedule::{NodeId, Schedule};
|
|
use bevy::ecs::system::{IntoSystem, System};
|
|
use std::any::TypeId;
|
|
use std::collections::HashSet;
|
|
|
|
/// `a` runs before `b` in the built graph: the dependency DAG walked with the hierarchy
|
|
/// DAG unfolded — a set `a` sits in precedes whatever that set precedes, and a set
|
|
/// reached as a successor carries every member with it.
|
|
fn runs_before(schedule: &Schedule, a: NodeId, b: NodeId) -> bool {
|
|
let dep = schedule.graph().dependency().graph();
|
|
let hier = schedule.graph().hierarchy().graph();
|
|
let (mut after, mut containers) = (HashSet::new(), HashSet::new());
|
|
let mut work = vec![(a, false)];
|
|
while let Some((n, is_after)) = work.pop() {
|
|
let fresh = if is_after {
|
|
after.insert(n)
|
|
} else {
|
|
containers.insert(n)
|
|
};
|
|
if !fresh {
|
|
continue;
|
|
}
|
|
work.extend(dep.neighbors_directed(n, Outgoing).map(|m| (m, true)));
|
|
work.extend(hier.neighbors_directed(n, Incoming).map(|p| (p, false)));
|
|
if is_after {
|
|
work.extend(hier.neighbors_directed(n, Outgoing).map(|c| (c, true)));
|
|
}
|
|
}
|
|
after.contains(&b)
|
|
}
|
|
|
|
let apply = System::type_id(&IntoSystem::into_system(crate::net::apply_net_updates));
|
|
let writer = System::type_id(&IntoSystem::into_system(feed_interact_npc));
|
|
let facing = System::type_id(&IntoSystem::into_system(crate::net::drive_display_facing));
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins((crate::net::NetPlugin { connect: false }, UiSessionPlugin));
|
|
// `schedule_scope`, not `resource_scope::<Schedules>`: initializing the schedule
|
|
// registers things that insert `Schedules` themselves, which a resource scope refuses.
|
|
app.world_mut().schedule_scope(Update, |world, schedule| {
|
|
schedule
|
|
.initialize(world)
|
|
.expect("the Update schedule builds");
|
|
let key = |id: TypeId| -> NodeId {
|
|
schedule
|
|
.systems()
|
|
.expect("initialized")
|
|
.find(|&(_, s)| System::type_id(&**s) == id)
|
|
.map(|(k, _)| NodeId::System(k))
|
|
.expect("the system is in Update")
|
|
};
|
|
let (apply, writer, facing) = (key(apply), key(writer), key(facing));
|
|
assert!(
|
|
runs_before(schedule, apply, writer),
|
|
"feed_interact_npc must run after apply_net_updates"
|
|
);
|
|
assert!(
|
|
runs_before(schedule, writer, facing),
|
|
"drive_display_facing must run after feed_interact_npc"
|
|
);
|
|
});
|
|
}
|
|
}
|