Enter World stops freezing character select, and bags close by the law
- Enter World stops freezing the character screen: from the click, the select image stayed on the glass ~100 ms because the loading cover could not draw until the state-flip frame — which cost 60 ms, 57 of it render+present: the world camera's first render of a 3000-entity world on cold pipelines, plus fifteen booth cameras waking on the very frame that owes the glass a cover; the glue now yields the glass on the click's own frame and the world camera (booths with it) holds off until the cover has presented — flip frame 60 → 43 ms and off the critical path — measured by the new `WOW_FRAME_PHASES` instrument, which stamps every phase of a frame and prints the render+present remainder no in-schedule stamp can see - switching to a character you haven't clicked before no longer flashes somebody else's body in their stage — the background swapped the frame the selection changed while the character waited for its whole assembly, so whichever won is what the screen showed (53 ms of the human standing in the orc's stage, and the reverse under a stage still loading); stage and character now commit as a PAIR, loads still starting on the click and an empty stage still swapping at once — on the same cold walk, eight selections make eight rebakes, every one the right body in the frame its stage lands, where before it was twelve with four of them wrong - the bank's six bag buttons repaint again — the stock UI repaints them only on `PLAYERBANKSLOTS_CHANGED`, which benilla fired for the vault's 24 slots alone, so a bag dropped in never appeared and one moved between slots stayed drawn in the one it left; the lock clear becomes its own system ordered ahead of BOTH feeds (resolve, then push, then fire), ending the stuck-grey icon — and `GetInventoryItemCount` is carved as not-the-stack-count: an EMPTY slot answers 1 (a sentinel, not a count), a container past the slot cutoff answers a literal 0 before any lookup (the whole answer to the stray digit on the bank's bag buttons), and an equipped bag answers the SUM of its contents gated on a DBC bit our class-11 guess missed twice — including the Soul Bag, so a warlock's shard count never showed - the bags close by the law: `BAG_CLOSED` is the only thing in all of FrameXML that hides an open bag window, and benilla never fired it, so a window outlived its bag in both bands (equipped and bank); the carve pins the condition — the bag changed AND the slot was occupied — so a swap fires BAG_CLOSED then BAG_UPDATE with the slot occupied throughout, an unequip fires it with the announce suppressed for exactly that transition, and two identical empty bags exchanged between slots still close both windows, because the diff is on the bag's own guid rather than the container state; `PLAYERBANKSLOTS_CHANGED` meanwhile turns out to carry NO arguments — a census of all 485 dispatcher call sites — the slot travels in how many times it fires, and every bank button repaints from its own slot id Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -127,6 +127,7 @@ impl Plugin for CharSelectPlugin {
|
||||
// Before the list refresh, and before `select_input` reads a click that
|
||||
// landed on the panel rather than the screen (decision 1196).
|
||||
debug_select_addons,
|
||||
debug_select_walk,
|
||||
addons::drive_addons_panel,
|
||||
refresh::refresh_list,
|
||||
refresh::refresh_banner_and_buttons,
|
||||
@@ -142,6 +143,14 @@ impl Plugin for CharSelectPlugin {
|
||||
)
|
||||
.chain()
|
||||
.after(benilla_world::schedule::WorldStage::Net),
|
||||
)
|
||||
// AFTER the cover's own stage, so the raise it reads is this frame's — see the
|
||||
// system's doc for why the glue must yield the glass the moment the cover goes up.
|
||||
.add_systems(
|
||||
Update,
|
||||
screen::hide_under_world_cover
|
||||
.after(benilla_world::schedule::WorldStage::Present)
|
||||
.run_if(in_state(ClientState::CharSelect)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -746,6 +755,83 @@ fn debug_logout_smoke(
|
||||
}
|
||||
}
|
||||
|
||||
/// The select-screen **walk** instrument (`WOW_CHARSELECT_WALK=<period_s>[:<enter_after_s>]`) —
|
||||
/// the thing a headless run had no way to do at all: *click a row*. It steps the selection one row
|
||||
/// per `period_s` through the whole roster (the ref's arrow-key cycle, in code), starting from the
|
||||
/// row the screen opened on, and logs each step with its elapsed stamp — so a booth/scene timeline
|
||||
/// (`WOW_BOOTH_LOG=1`, `WOW_STREAM_TRACE`) is attributable to the selection that caused it. With
|
||||
/// `enter_after_s`, it then presses **Enter World** that long after the last step, which is the
|
||||
/// only headless way to land on the world-entry edge from a screen that was actually *used*
|
||||
/// (`WOW_CHAR`'s fast path answers the first roster and never renders a second selection).
|
||||
///
|
||||
/// Built for the two reports this file's booth feed owns — the cold-selection model swap and the
|
||||
/// entry-edge stall — and kept, because every future "what does the screen do when you click"
|
||||
/// question needs exactly this and nothing more. Inert without the env.
|
||||
fn debug_select_walk(
|
||||
mut roster: ResMut<Roster>,
|
||||
pick: Res<CharPick>,
|
||||
time: Res<Time>,
|
||||
mut plan: Local<Option<(f32, Option<f32>)>>,
|
||||
mut next_at: Local<Option<f32>>,
|
||||
mut row: Local<usize>,
|
||||
mut done: Local<bool>,
|
||||
) {
|
||||
if *done {
|
||||
return;
|
||||
}
|
||||
let (period, enter_after) = *plan.get_or_insert_with(|| {
|
||||
let Ok(spec) = std::env::var("WOW_CHARSELECT_WALK") else {
|
||||
return (f32::INFINITY, None);
|
||||
};
|
||||
let (p, e) = spec.split_once(':').unwrap_or((spec.as_str(), ""));
|
||||
(
|
||||
p.trim().parse::<f32>().unwrap_or(3.0),
|
||||
e.trim().parse::<f32>().ok(),
|
||||
)
|
||||
});
|
||||
if !period.is_finite() {
|
||||
*done = true;
|
||||
return;
|
||||
}
|
||||
if roster.chars.is_empty() {
|
||||
return; // roster not in yet — the walk starts at the first real screen
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
let at = *next_at.get_or_insert(now + period);
|
||||
if now < at {
|
||||
return;
|
||||
}
|
||||
if *row < roster.chars.len() {
|
||||
let i = *row;
|
||||
*row += 1;
|
||||
let c = &roster.chars[i];
|
||||
info!(
|
||||
"char select: WALK t={now:.3} → row {i} ({}, race {}, class {})",
|
||||
c.name, c.race, c.class
|
||||
);
|
||||
roster.select(Some(i));
|
||||
// The last row waits `enter_after` (not `period`) before the entry, so the screen the
|
||||
// stall is measured from is a settled one.
|
||||
*next_at = Some(
|
||||
now + if *row == roster.chars.len() {
|
||||
enter_after.unwrap_or(period)
|
||||
} else {
|
||||
period
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The roster is walked. Either enter the world on the last selection, or stop.
|
||||
*done = true;
|
||||
if enter_after.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some((guid, name)) = roster.selected_char().map(|c| (c.guid, c.name.clone())) {
|
||||
info!("char select: WALK t={now:.3} → ENTER WORLD as {name}");
|
||||
send_pick(&mut roster, &pick, guid);
|
||||
}
|
||||
}
|
||||
|
||||
/// The shot instrument's delete-dialog dial (`WOW_CHARSELECT_DIALOG=<typed>`): open the
|
||||
/// typed-confirm dialog for the selected character a few seconds after the screen is up, with
|
||||
/// `<typed>` pre-typed (may be empty) — so the dialog's geometry (the ChatInputBorder edit box,
|
||||
|
||||
@@ -614,6 +614,42 @@ fn rotate_cluster(ui: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Fo
|
||||
});
|
||||
}
|
||||
|
||||
/// **A world loading cover outranks the glue screen.**
|
||||
///
|
||||
/// The glue screens sit at [`SCREEN_Z`] = 1100 and the loading screen at 1000, so the glue draws
|
||||
/// *over* the cover. That is right for the logout direction — the world→glue swap must show the
|
||||
/// glue — and wrong for the entry one: the cover is raised in `WorldStage::Present` while we are
|
||||
/// still `CharSelect`, so the raise frame renders the character screen on top of it and the FIRST
|
||||
/// frame that can show the cover is the **state-flip** frame, which is also the frame the whole
|
||||
/// world arrives on. Measured this round: click → cover on the glass was ~100 ms, of which the
|
||||
/// flip frame alone was 60 — the character screen held, frozen, for every millisecond of it. That
|
||||
/// is the director's *"the char freezes briefly before the loading screen appears"*; 1345 fixed
|
||||
/// the same shape for the FrameXML burst and left this half standing, because nothing had ever
|
||||
/// asked which frame the cover first *drew* on.
|
||||
///
|
||||
/// Hiding the root moves the cover onto the raise frame's own render, a whole frame earlier and —
|
||||
/// far more importantly — **off** the flip frame's critical path: the cover is already drawn and
|
||||
/// presenting while that frame churns. Registered after `WorldStage::Present` so it reads *this*
|
||||
/// frame's raise; visibility propagates in `PostUpdate` and renders the same frame, exactly like
|
||||
/// the loading screen's own show/hide. Read from [`LoadingScreen::covering`] and not from
|
||||
/// [`crate::loading_screen::EntryCover`]: this is the one consumer that must react while still
|
||||
/// `CharSelect`, which is precisely the frame `EntryCover` cannot count.
|
||||
pub(super) fn hide_under_world_cover(
|
||||
loading: Res<crate::loading_screen::LoadingScreen>,
|
||||
mut roots: Query<&mut Visibility, With<CharSelectUi>>,
|
||||
) {
|
||||
let want = if loading.covering() {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
for mut vis in &mut roots {
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exit_select(
|
||||
mut commands: Commands,
|
||||
roots: Query<Entity, With<CharSelectUi>>,
|
||||
|
||||
@@ -78,6 +78,87 @@ const WAIT_LOG_EVERY: f32 = 2.0;
|
||||
/// do; the reported case — `.tele` across a city — is 458 yd.
|
||||
const SNAP_LOAD_MIN_YD: f32 = 100.0;
|
||||
|
||||
/// Consecutive covered+in-world frames before the cover counts as **on the glass**.
|
||||
///
|
||||
/// Renders are serial, so at 3 the two intermediate frames' renders have committed their
|
||||
/// presents. This was 0962's argument and its constant; it now lives beside the fact it defines
|
||||
/// rather than being restated in each consumer (it had already been copied twice, and the third
|
||||
/// consumer — the world camera — never got it at all).
|
||||
const COVER_PRESENT_FRAMES: u32 = 3;
|
||||
|
||||
/// **Is the entry cover ON THE GLASS?** — the one fact 0962's rule is written in terms of, held
|
||||
/// in one place because restating it is exactly how it gets forgotten.
|
||||
///
|
||||
/// The world-entry raise happens in `Update`; the state flips a frame later, so the FIRST
|
||||
/// covered+in-world frame is also the first frame whose render can draw the cover. Anything
|
||||
/// synchronous on that frame holds the *previous* present — the character-select screen, frozen
|
||||
/// — for its whole duration. That is the director's report, three times now (0962, 1345, and the
|
||||
/// world camera below), and each time it was one consumer nobody had counted:
|
||||
///
|
||||
/// - the **pipeline-warm menagerie** (0962) — its own `covered_frames`, now this;
|
||||
/// - the **world-entry FrameXML load** (1345) — its own `covered_frames`, now this;
|
||||
/// - the **world camera and the booth wake** — never deferred at all, and measured (this record's
|
||||
/// round) as **57 of the flip frame's 60 ms**: the first render of a 3 000-entity world with
|
||||
/// cold pipelines, plus fifteen booth cameras woken by the warm pass, all on the one frame that
|
||||
/// owes the glass a loading screen.
|
||||
///
|
||||
/// Counted in `First` so a `PreUpdate` reader (the entry load, an exclusive system) and an
|
||||
/// `Update` reader (the warm pass, the camera gate) see the same frame's answer.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct EntryCover {
|
||||
/// Consecutive covered+in-world frames; reset the moment either goes false.
|
||||
frames: u32,
|
||||
}
|
||||
|
||||
impl EntryCover {
|
||||
/// Has the cover had enough frames to reach the glass? **True whenever no cover is up** —
|
||||
/// there is then no glass to protect, and a consumer that waited would wait forever (the
|
||||
/// capture that boots straight in-world is exactly this case).
|
||||
pub(crate) fn presented(&self) -> bool {
|
||||
self.frames == 0 || self.frames >= COVER_PRESENT_FRAMES
|
||||
}
|
||||
|
||||
/// Is a cover up and still owed its first present? The inverse of the arm above that a
|
||||
/// *renderer* wants: "do not draw anything but the cover this frame".
|
||||
pub(crate) fn owes_a_present(&self) -> bool {
|
||||
self.frames > 0 && self.frames < COVER_PRESENT_FRAMES
|
||||
}
|
||||
|
||||
/// **Is a world cover up right now?** — `LoadingScreen::covering()` ∧ in world, which is
|
||||
/// the pair every cover consumer actually means, counted once here.
|
||||
pub(crate) fn covering(&self) -> bool {
|
||||
self.frames > 0
|
||||
}
|
||||
|
||||
/// Covered frames so far — what the tests assert on.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn frames(&self) -> u32 {
|
||||
self.frames
|
||||
}
|
||||
}
|
||||
|
||||
impl EntryCover {
|
||||
/// One frame's worth of counting — the whole rule, so the test seam and the system cannot
|
||||
/// drift apart.
|
||||
pub(crate) fn tick(&mut self, covered: bool) {
|
||||
self.frames = if covered {
|
||||
self.frames.saturating_add(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// `First`: advance (or reset) the covered-frame count. One writer, read by every consumer.
|
||||
fn count_entry_cover(
|
||||
screen: Res<LoadingScreen>,
|
||||
state: Res<State<crate::char_select::ClientState>>,
|
||||
mut cover: ResMut<EntryCover>,
|
||||
) {
|
||||
let in_world = *state.get() == crate::char_select::ClientState::InWorld;
|
||||
cover.tick(screen.covering() && in_world);
|
||||
}
|
||||
|
||||
/// Bevy resource wrapper around the format-crate [`LoadingScreenCatalog`] (the `LoadingScreenID` → BLP
|
||||
/// path table). Paired with [`MapCatalogRes`] (the `mapId` → `LoadingScreenID` FK) to resolve art.
|
||||
#[derive(Resource)]
|
||||
@@ -169,6 +250,9 @@ pub(crate) struct LoadingScreenPlugin;
|
||||
impl Plugin for LoadingScreenPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<LoadingScreen>()
|
||||
.init_resource::<EntryCover>()
|
||||
// `First`, ahead of every consumer in `PreUpdate` and `Update` — see [`EntryCover`].
|
||||
.add_systems(First, count_entry_cover)
|
||||
.add_systems(Startup, setup_loading_screen.after(AssetSet::Open))
|
||||
// In `WorldStage::Present` (after Input + Stream): we read residency for the SAME frame's
|
||||
// player position — a teleport snaps in Input → the streamer recomputes focus in Stream →
|
||||
@@ -610,3 +694,49 @@ fn drive_loading_screen(
|
||||
node.width = Val::Percent(frac * FILL_MAX_WIDTH * 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// **The one fact three consumers now share** ([`EntryCover`]). Two of them each kept their
|
||||
/// own copy of this count (0962's menagerie, 1345's FrameXML load) and the third — the world
|
||||
/// camera, which renders the whole arriving world on the same frame — never had one at all,
|
||||
/// which is the freeze this round measured. The arithmetic is trivial; that it has exactly one
|
||||
/// home is the point.
|
||||
#[test]
|
||||
fn the_cover_counts_frames_to_the_glass_and_resets_the_moment_it_drops() {
|
||||
let mut cover = EntryCover::default();
|
||||
assert!(
|
||||
cover.presented(),
|
||||
"no cover up: nothing is watching the previous present, so nothing waits"
|
||||
);
|
||||
assert!(!cover.owes_a_present(), "no cover owes no present");
|
||||
|
||||
cover.tick(true);
|
||||
assert_eq!(cover.frames(), 1);
|
||||
assert!(
|
||||
!cover.presented(),
|
||||
"the cover's own render has not committed yet"
|
||||
);
|
||||
assert!(cover.owes_a_present());
|
||||
|
||||
cover.tick(true);
|
||||
assert!(!cover.presented(), "one render committed, not two");
|
||||
|
||||
cover.tick(true);
|
||||
assert!(
|
||||
cover.presented(),
|
||||
"at COVER_PRESENT_FRAMES the cover is provably on the glass"
|
||||
);
|
||||
assert!(!cover.owes_a_present());
|
||||
|
||||
// A reveal (or leaving the world) drops it back to the uncovered arm in one frame — the
|
||||
// next raise must not inherit the last one's credit.
|
||||
cover.tick(false);
|
||||
assert_eq!(cover.frames(), 0);
|
||||
assert!(cover.presented());
|
||||
cover.tick(true);
|
||||
assert!(!cover.presented(), "the next raise starts its own count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ pub(super) fn apply_net_updates(
|
||||
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
|
||||
// and the cast-bar feed (decision 0137), plus the item-lock bookkeeping the inventory-failure
|
||||
// arm also drains (decision 0216 §4 / 0218 §3 — this apply site has no `UiScript` to fire
|
||||
// `ITEM_LOCK_CHANGED` through, so the transitioned slots queue in `LockClearedByFailure` for
|
||||
// `ITEM_LOCK_CHANGED` through, so the transitioned slots queue in `LockTransitions` for
|
||||
// the container feed to pick up).
|
||||
mut ui_actions: (
|
||||
ResMut<crate::ui_action::PlayerActions>,
|
||||
@@ -305,7 +305,7 @@ pub(super) fn apply_net_updates(
|
||||
ResMut<crate::ui_loot::LootErrors>,
|
||||
ResMut<crate::ui_cast::CastBarFeed>,
|
||||
ResMut<crate::pending_item_ops::PendingItemOps>,
|
||||
ResMut<crate::pending_item_ops::LockClearedByFailure>,
|
||||
ResMut<crate::pending_item_ops::LockTransitions>,
|
||||
// Nested pair (the tuple is at the ceiling): the two NPC-service windows' error queues,
|
||||
// each drained onto its window's red line by its own feed.
|
||||
(
|
||||
|
||||
@@ -13,7 +13,7 @@ use bevy::prelude::*;
|
||||
use super::super::SelfGuid;
|
||||
use crate::items::Items;
|
||||
use crate::net::ClientCommand;
|
||||
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use crate::ui_action::{UiError, UiErrorKeys};
|
||||
use crate::ui_items::{EquipError, EquipErrors};
|
||||
use crate::ui_loot::{LootErrors, LootLatch, LootState};
|
||||
@@ -250,7 +250,7 @@ pub(super) fn item_template(entry: u32, info: Option<ItemInfo>, items: &mut Item
|
||||
/// 0218 §3): every arrival here already has reason ≠ 0 (reason 0 is filtered before this event
|
||||
/// exists at all — `benilla_protocol::events`'s `if reason != 0` guard), so it always tries a
|
||||
/// [`PendingItemOps::clear_by_failure`]. This site has no `UiScript` to fire `ITEM_LOCK_CHANGED`
|
||||
/// through, so the transitioned slots queue in [`LockClearedByFailure`] for the container feed
|
||||
/// through, so the transitioned slots queue in [`LockTransitions`] for the container feed
|
||||
/// (`ui_items::feed::feed_containers`) to drain and fire next time it runs.
|
||||
#[allow(clippy::too_many_arguments)] // one dispatch arm's full input set
|
||||
pub(super) fn inventory_failure(
|
||||
@@ -260,7 +260,7 @@ pub(super) fn inventory_failure(
|
||||
bag_slot: u8,
|
||||
equip_errors: &mut EquipErrors,
|
||||
pending: &mut PendingItemOps,
|
||||
lock_cleared: &mut LockClearedByFailure,
|
||||
lock_cleared: &mut LockTransitions,
|
||||
latch: &mut LootLatch,
|
||||
) {
|
||||
debug!("net: inventory failure {reason:#04x} (item {item_guid:#x}, bag slot {bag_slot})");
|
||||
@@ -409,7 +409,7 @@ mod tests {
|
||||
const LOCKBOX: u64 = 0x4000_0000_0000_0007;
|
||||
let mut errs = EquipErrors::default();
|
||||
let mut pending = PendingItemOps::default();
|
||||
let mut cleared = LockClearedByFailure::default();
|
||||
let mut cleared = LockTransitions::default();
|
||||
let mut latch = LootLatch(Some(LOCKBOX));
|
||||
|
||||
// An unrelated item's failure must not end the session.
|
||||
|
||||
@@ -32,6 +32,11 @@ type PendingEntry = Vec<((i64, u32), (u64, u32))>;
|
||||
#[derive(Debug, Default, Resource)]
|
||||
pub(crate) struct PendingItemOps {
|
||||
entries: Vec<PendingEntry>,
|
||||
/// One step per change to [`Self::entries`] — the gate input a feed needs to notice that a
|
||||
/// lock **cleared**. `!is_empty()` alone cannot: the frame an op resolves, the set goes empty
|
||||
/// and every "is anything in flight?" gate closes, so a feed that had pushed `locked: true`
|
||||
/// never runs again to correct it. That is the stuck-dark bank bag button of 1771.
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
impl PendingItemOps {
|
||||
@@ -39,6 +44,7 @@ impl PendingItemOps {
|
||||
/// count_at_send_time)` quadruples. Called once per outbound move/split/destroy send,
|
||||
/// covering every slot that send touches.
|
||||
pub(crate) fn add(&mut self, slots: impl IntoIterator<Item = (i64, u32, u64, u32)>) {
|
||||
self.epoch += 1;
|
||||
self.entries.push(
|
||||
slots
|
||||
.into_iter()
|
||||
@@ -54,6 +60,12 @@ impl PendingItemOps {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// [`Self::epoch`] — the "did the lock set move?" counter a feed's gate watches beside
|
||||
/// `!is_empty()`.
|
||||
pub(crate) fn epoch(&self) -> u64 {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// Whether `(bag, slot)` is covered by any outstanding op — the container feed reads this
|
||||
/// building each pushed `ContainerSlot::locked`.
|
||||
pub(crate) fn contains(&self, bag: i64, slot: u32) -> bool {
|
||||
@@ -82,6 +94,9 @@ impl PendingItemOps {
|
||||
});
|
||||
unlocked.sort_unstable();
|
||||
unlocked.dedup();
|
||||
if !unlocked.is_empty() {
|
||||
self.epoch += 1;
|
||||
}
|
||||
unlocked
|
||||
}
|
||||
|
||||
@@ -113,6 +128,9 @@ impl PendingItemOps {
|
||||
});
|
||||
unlocked.sort_unstable();
|
||||
unlocked.dedup();
|
||||
if !unlocked.is_empty() {
|
||||
self.epoch += 1;
|
||||
}
|
||||
unlocked
|
||||
}
|
||||
|
||||
@@ -126,16 +144,28 @@ impl PendingItemOps {
|
||||
.collect();
|
||||
unlocked.sort_unstable();
|
||||
unlocked.dedup();
|
||||
if !unlocked.is_empty() {
|
||||
self.epoch += 1;
|
||||
}
|
||||
unlocked
|
||||
}
|
||||
}
|
||||
|
||||
/// `(bag, slot)` pairs whose app-lock cleared via a server failure this frame — queued here
|
||||
/// because the apply site (`net/apply/loot.rs::inventory_failure`, which owns the wire event) has
|
||||
/// no `UiScript` to fire `ITEM_LOCK_CHANGED` through; the container feed drains it and fires, the
|
||||
/// `(bag, slot)` pairs whose app-lock cleared this frame, from EITHER clear: the server failure
|
||||
/// (`net/apply/loot.rs::inventory_failure`, which owns the wire event but has no `UiScript` to
|
||||
/// fire `ITEM_LOCK_CHANGED` through) and the resolving field-update watch
|
||||
/// (`ui_items::feed::resolve_item_locks`, which runs ahead of every feed so that no feed can push
|
||||
/// a `locked` the clear has already invalidated). `feed_containers` drains this and fires — the
|
||||
/// exact shape `ui_items::EquipErrors` already uses for the same reason.
|
||||
///
|
||||
/// **Why the resolve is not just done inside the feed that fires** (1771): `feed_char` owns the
|
||||
/// doll's and the bank bags' `locked`, and is ordered `.before(feed_containers)` for an unrelated
|
||||
/// reason (Bagnon's `BAG_UPDATE` handlers read the inventory surface). With the resolve living in
|
||||
/// `feed_containers`, `feed_char` read a lock set that was about to be cleared *later in the same
|
||||
/// frame*, pushed `locked: true`, and then never ran again — leaving a bank bag button greyed
|
||||
/// until the window was reopened.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct LockClearedByFailure(pub Vec<(i64, u32)>);
|
||||
pub(crate) struct LockTransitions(pub Vec<(i64, u32)>);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -44,6 +44,7 @@ mod clock;
|
||||
mod gpu;
|
||||
mod hud;
|
||||
mod journal;
|
||||
mod phases;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod stall;
|
||||
mod stats;
|
||||
@@ -157,6 +158,8 @@ impl Plugin for PerfPlugin {
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
stall::plugin(app);
|
||||
// `WOW_FRAME_PHASES=<ms>` — which PHASE of a slow frame spent it (see the module doc).
|
||||
phases::plugin(app);
|
||||
// `WOW_GPU_MS=1` — the whole-frame GPU meter (its module doc owns the design and the
|
||||
// 1389 resolve-on-a-later-submission trap). Registers nothing when off, so campaign
|
||||
// anchors never carry its ~0.03 ms sentinel cost uninvited.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! The FRAME-PHASE breakdown (`WOW_FRAME_PHASES=<ms>`) — *which phase* of a slow frame spent it.
|
||||
//!
|
||||
//! The instruments we had bracket this question from both sides and answer neither half of it.
|
||||
//! `WOW_STREAM_TRACE` says a frame cost 411 ms and how many tiles/meshes/pipelines it touched;
|
||||
//! the stall sampler (0713) shells `/usr/bin/sample` at a *600 ms* main-thread stall and hands
|
||||
//! back a stack. Between them sits the whole class this project actually reports — the
|
||||
//! 40–500 ms world-entry and teleport hitches (0962, 1116, 1345) — where the diagnosis has each
|
||||
//! time been *"guess the suspect, then time it by hand"*. 1345 named that cost outright: the
|
||||
//! FrameXML burst was found by reading a log's wall-clock stamps and subtracting.
|
||||
//!
|
||||
//! So: stamp the frame at boundaries that are **exact by construction**, and print the
|
||||
//! breakdown for any frame over the threshold.
|
||||
//!
|
||||
//! - **Between the main schedules** the stamps are their own *marker schedules*, spliced into
|
||||
//! [`bevy::app::MainScheduleOrder`]. A schedule boundary is a hard sync point, so the stamp is
|
||||
//! the boundary — no ordering constraint to get wrong, no executor freedom to float in.
|
||||
//! - **Inside `Update`** the stamps ride the four [`WorldStage`] sets the frame contract already
|
||||
//! defines (0737's Net → Input → Stream → Present chain), as **exclusive** systems so each one
|
||||
//! is a sync point too. That is a real perturbation of `Update`'s parallelism — which is why it
|
||||
//! is opt-in, and why the printed line carries `total=` measured across the whole frame rather
|
||||
//! than summed from the parts.
|
||||
//!
|
||||
//! **The residue is the point, not an afterthought.** A frame is `Main` *plus* the render
|
||||
//! sub-app — extract, prepare, queue, render, present — and on macOS every first-sight pipeline
|
||||
//! variant is compiled with `block_on` **inline on the render thread** (`pipe_warm`'s header),
|
||||
//! which is invisible to every stamp inside `Main`. So the total is measured frame-start to
|
||||
//! frame-start and the unaccounted remainder is printed as its own span, `render+present`. The
|
||||
//! first cut of this instrument closed the tape at the end of `Last` and silently reported a
|
||||
//! 506 ms frame as fitting inside 20 ms of `Main`.
|
||||
//!
|
||||
//! One line per slow frame, spans in the order they closed:
|
||||
//!
|
||||
//! ```text
|
||||
//! [phase] frame 1972 total=60.5ms First=0.2 PreUpdate=1.1 StateTransition=8.9 … render+present=31.2
|
||||
//! ```
|
||||
//!
|
||||
//! Off unless `WOW_FRAME_PHASES` is set; `WOW_FRAME_PHASES=0` prints every frame.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use bevy::app::{First, Last, MainScheduleOrder, PostUpdate, PreUpdate, Update};
|
||||
use bevy::ecs::schedule::ScheduleLabel;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
/// The marker schedules spliced between the main ones. A tuple label rather than seven unit
|
||||
/// structs: the index IS the position, so the splice table below reads as the frame's shape.
|
||||
#[derive(ScheduleLabel, Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
struct PhaseMark(u8);
|
||||
|
||||
/// Where each mark is spliced, and what the *preceding* span is called. `None` = the frame's
|
||||
/// opening stamp (spliced before `First`), which names no span of its own.
|
||||
const MARKS: &[(u8, &str)] = &[
|
||||
(0, ""), // before First — the frame's t0
|
||||
(1, "First"),
|
||||
(2, "PreUpdate"),
|
||||
(3, "StateTransition"),
|
||||
(4, "Update"),
|
||||
(5, "PostUpdate"),
|
||||
(6, "Last"),
|
||||
];
|
||||
|
||||
/// The per-frame stamp tape. Read (and reported) at the NEXT frame's opening mark, so the
|
||||
/// render sub-app's share of this frame is inside the window — see the module doc.
|
||||
#[derive(Resource)]
|
||||
struct Phases {
|
||||
/// Print a frame whose total exceeds this (ms).
|
||||
threshold_ms: f32,
|
||||
/// `(span name, instant at its END)`, in frame order; the first entry is the frame's t0.
|
||||
marks: Vec<(&'static str, Instant)>,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
/// Is the instrument armed, and at what threshold? (Read once.)
|
||||
fn threshold() -> Option<f32> {
|
||||
static T: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
|
||||
*T.get_or_init(|| {
|
||||
std::env::var("WOW_FRAME_PHASES")
|
||||
.ok()
|
||||
.map(|v| v.trim().parse::<f32>().unwrap_or(25.0))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
let Some(threshold_ms) = threshold() else {
|
||||
return;
|
||||
};
|
||||
info!("frame phases: armed — printing any frame over {threshold_ms} ms (WOW_FRAME_PHASES)");
|
||||
app.insert_resource(Phases {
|
||||
threshold_ms,
|
||||
marks: Vec::with_capacity(16),
|
||||
frame: 0,
|
||||
});
|
||||
|
||||
// Splice the marker schedules. `insert_before(First, …)` opens the frame; every other mark
|
||||
// closes the schedule it is inserted after. `StateTransition` is inserted by `StatesPlugin`
|
||||
// after `PreUpdate`, so it is in the list by the time this plugin builds.
|
||||
{
|
||||
let mut order = app.world_mut().resource_mut::<MainScheduleOrder>();
|
||||
order.insert_before(First, PhaseMark(0));
|
||||
order.insert_after(First, PhaseMark(1));
|
||||
order.insert_after(PreUpdate, PhaseMark(2));
|
||||
order.insert_after(bevy::state::state::StateTransition, PhaseMark(3));
|
||||
order.insert_after(Update, PhaseMark(4));
|
||||
order.insert_after(PostUpdate, PhaseMark(5));
|
||||
order.insert_after(Last, PhaseMark(6));
|
||||
}
|
||||
for (i, name) in MARKS {
|
||||
let (i, name) = (*i, *name);
|
||||
app.add_systems(PhaseMark(i), move |mut p: ResMut<Phases>| {
|
||||
if i == 0 {
|
||||
// The previous frame closes HERE, not at the end of `Last`: everything between
|
||||
// `Last` and this stamp is the render sub-app and the present.
|
||||
report(&mut p);
|
||||
p.frame += 1;
|
||||
p.marks.clear();
|
||||
}
|
||||
p.marks.push((name, Instant::now()));
|
||||
});
|
||||
}
|
||||
|
||||
// The four `Update` stages, stamped as EXCLUSIVE systems so each stamp is a sync point and
|
||||
// the span either side of it is the stage's own (see the module doc's perturbation note).
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
stamp("Update/pre-Net").before(WorldStage::Net),
|
||||
stamp("Update/Net")
|
||||
.after(WorldStage::Net)
|
||||
.before(WorldStage::Input),
|
||||
stamp("Update/Input")
|
||||
.after(WorldStage::Input)
|
||||
.before(WorldStage::Stream),
|
||||
stamp("Update/Stream")
|
||||
.after(WorldStage::Stream)
|
||||
.before(WorldStage::Present),
|
||||
stamp("Update/Present").after(WorldStage::Present),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One exclusive stamp closing the named span.
|
||||
fn stamp(name: &'static str) -> impl IntoSystem<(), (), ()> {
|
||||
IntoSystem::into_system(move |world: &mut World| {
|
||||
if let Some(mut p) = world.get_resource_mut::<Phases>() {
|
||||
p.marks.push((name, Instant::now()));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Print the previous frame's tape if it was slow. Spans print in the order they closed, with
|
||||
/// the `Update` stages folded in where they fell — the marker-schedule `Update` entry then
|
||||
/// reports only what ran after `WorldStage::Present` — and the remainder between the end of
|
||||
/// `Last` and this call as `render+present`.
|
||||
fn report(p: &mut Phases) {
|
||||
let Some((_, t0)) = p.marks.first().copied() else {
|
||||
return;
|
||||
};
|
||||
let end = Instant::now();
|
||||
let total = (end - t0).as_secs_f32() * 1000.0;
|
||||
if total < p.threshold_ms {
|
||||
return;
|
||||
}
|
||||
let mut line = format!("[phase] frame {} total={total:.1}ms ", p.frame);
|
||||
let mut prev = t0;
|
||||
for (name, at) in p.marks.iter().skip(1) {
|
||||
let ms = (*at - prev).as_secs_f32() * 1000.0;
|
||||
prev = *at;
|
||||
if ms >= 0.05 {
|
||||
line.push_str(&format!(" {name}={ms:.1}"));
|
||||
}
|
||||
}
|
||||
let residue = (end - prev).as_secs_f32() * 1000.0;
|
||||
line.push_str(&format!(" render+present={residue:.1}"));
|
||||
println!("{line}");
|
||||
}
|
||||
@@ -311,8 +311,6 @@ pub(crate) struct WarmPass {
|
||||
/// The 1×1 stand-in texture [`warm_effect_lane`]'s draws bind while the pass runs (a strong
|
||||
/// handle so the asset lives exactly as long as the pass; `None` = the lane isn't warming).
|
||||
effect_tex: Option<Handle<Image>>,
|
||||
/// Consecutive covered+in-world frames seen while idle (see [`WARM_COVER_PRESENT_FRAMES`]).
|
||||
covered_frames: u32,
|
||||
/// **The menagerie has drained cleanly once in this process.** The warm set is a fixed
|
||||
/// variant cross built from `Startup`-populated stores (see `menagerie`) — not the resident
|
||||
/// map's art — and `PipelineCache` is process-global and never evicts, so a second pass
|
||||
@@ -397,22 +395,12 @@ type WarmRigVis<'w, 's> = Query<
|
||||
/// 0737's rule: never hold a cover unbounded. A timeout fires the tripwire-adjacent warn and
|
||||
/// releases; the remaining compiles land live (the pre-0837 world, once, with a named cause).
|
||||
const WARM_TIMEOUT_SECS: f32 = 10.0;
|
||||
/// Covered frames the pass waits before spawning the menagerie, so the compile burst lands
|
||||
/// behind a cover that is ON THE GLASS. On world entry the raise frame still renders the glue
|
||||
/// (the state flips a frame later), so the FIRST covered+in-world frame is also the first frame
|
||||
/// the cover can draw — and what stays on screen while the pass runs is otherwise the previous
|
||||
/// present: the frozen character screen (the director's report). Two extra frames ≈ 33 ms of
|
||||
/// cover; the burst is seconds cold. Since 1116 the burst is paced rather than paid in one
|
||||
/// frame, so this guards the pass's first slices rather than one monolithic stall.
|
||||
const WARM_COVER_PRESENT_FRAMES: u32 = 3;
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
|
||||
fn run_warm_pass(
|
||||
mut commands: Commands,
|
||||
mut warm: ResMut<WarmPass>,
|
||||
watch: Res<PipeWatch>,
|
||||
loading: Res<LoadingScreen>,
|
||||
state: Res<State<ClientState>>,
|
||||
cover: Res<crate::loading_screen::EntryCover>,
|
||||
time: Res<Time<Real>>,
|
||||
camera: Query<Entity, With<benilla_world::view::WorldCamera>>,
|
||||
rigs: Query<(Entity, Option<&ChildOf>), With<WarmRig>>,
|
||||
@@ -425,14 +413,14 @@ fn run_warm_pass(
|
||||
mut cache: Local<MaterialCache>,
|
||||
shared_light: Option<Res<benilla_world::lighting::SharedLightBuffer>>,
|
||||
) {
|
||||
let covering = loading.covering() && *state.get() == ClientState::InWorld;
|
||||
if !covering {
|
||||
// `EntryCover` already IS "a loading cover is up and we are in world", counted once for the
|
||||
// whole client (see its doc); this used to spell the pair out for itself.
|
||||
if !cover.covering() {
|
||||
// No world cover → nothing to hold; a leftover menagerie (timeout, teleport race)
|
||||
// despawns. `done` stays true so the gate never blocks an uncovered frame.
|
||||
warm.done = true;
|
||||
warm.spawned_at = None;
|
||||
warm.effect_tex = None;
|
||||
warm.covered_frames = 0;
|
||||
warm.showing.clear();
|
||||
despawn_rigs(&mut commands, &rigs);
|
||||
return;
|
||||
@@ -452,15 +440,14 @@ fn run_warm_pass(
|
||||
// The cover just rose (or the world just became live under one): raise the gate and
|
||||
// spawn the menagerie once the camera + shared light exist (both are entry-frame-early;
|
||||
// until they do, the gate holds the cover, which is exactly right) — and once the cover
|
||||
// has had [`WARM_COVER_PRESENT_FRAMES`] frames to reach the glass, so the burst is
|
||||
// actually hidden (the char-screen freeze, 0962).
|
||||
// has actually reached the glass ([`EntryCover`]), so the burst is hidden rather than
|
||||
// holding the frozen character screen (0962).
|
||||
warm.done = false;
|
||||
let Ok(cam) = camera.single() else { return };
|
||||
let Some(light) = shared_light.as_ref() else {
|
||||
return;
|
||||
};
|
||||
warm.covered_frames += 1;
|
||||
if warm.covered_frames < WARM_COVER_PRESENT_FRAMES {
|
||||
if !cover.presented() {
|
||||
return;
|
||||
}
|
||||
warm.spawned_at = Some(now);
|
||||
|
||||
@@ -162,12 +162,25 @@ pub(super) fn setup_player(
|
||||
/// This gate stays deliberately WIDER than [`benilla_world::schedule::world_is_live`], which decides
|
||||
/// whether the world is *loaded* at all (decision 0777): the camera must also render while the
|
||||
/// cover is up, which is exactly the window in which the world is streaming in.
|
||||
///
|
||||
/// **…but not on the frame the cover owes the glass.** 0962's rule, third consumer: on world
|
||||
/// entry the state flips a frame after the raise, so the flip frame is the first whose render can
|
||||
/// draw the loading screen — and this gate turned the world camera on for that exact frame, which
|
||||
/// then rendered a three-thousand-entity world with every pipeline cold *before* it could present
|
||||
/// the cover. Measured: 57 of that frame's 60 ms, and what stayed on the glass throughout was the
|
||||
/// character-select screen, frozen (the director's report). Held off until
|
||||
/// [`EntryCover::presented`], the camera loses three frames of warm-up out of the seconds it gets
|
||||
/// and the cover reaches the glass on the frame it was drawn for. Nothing is *shown* by the
|
||||
/// deferral — the loading root is an unconditional fullscreen black node, so a frame with no
|
||||
/// world camera is the same picture the cover would have drawn over anyway.
|
||||
pub(super) fn gate_world_camera(
|
||||
state: Res<State<crate::char_select::ClientState>>,
|
||||
loading: Res<crate::loading_screen::LoadingScreen>,
|
||||
cover: Res<crate::loading_screen::EntryCover>,
|
||||
mut cams: Query<&mut Camera, With<WorldCamera>>,
|
||||
) {
|
||||
let active = *state.get() == crate::char_select::ClientState::InWorld || loading.covering();
|
||||
let active = (*state.get() == crate::char_select::ClientState::InWorld || loading.covering())
|
||||
&& !cover.owes_a_present();
|
||||
for mut cam in &mut cams {
|
||||
if cam.is_active != active {
|
||||
cam.is_active = active;
|
||||
|
||||
@@ -254,8 +254,51 @@ pub(crate) struct CreateScene {
|
||||
/// Bumped on every scene spawn — folded into `sync_glue_booth`'s change key so the character
|
||||
/// re-lights the moment the scene (and its rig) lands.
|
||||
rev: u64,
|
||||
/// **The stage the selection has asked for but has not been given yet** — see
|
||||
/// [`PendingSwap`]. `None` = the standing scene IS the requested one.
|
||||
pending: Option<PendingSwap>,
|
||||
}
|
||||
|
||||
/// A requested stage swap, waiting for its half of the pair.
|
||||
///
|
||||
/// **The two halves of a glue booth swap independently, and that is the whole defect.** Clicking a
|
||||
/// row rewrites [`GluePreview`] in one frame; the background scene is an M2 that loads in one or
|
||||
/// two more, and the character is an *assembly* — body display, composited skin, every worn item's
|
||||
/// model — that [`crate::entities::attach::preview::build_glue_preview`] deliberately withholds
|
||||
/// until all of it is ready. Whichever half wins is what the screen shows in the meantime:
|
||||
///
|
||||
/// ```text
|
||||
/// t=8.484 click Orcshafour (orc) ← from a human
|
||||
/// t=8.487 create scene: UI_Orc up
|
||||
/// t=8.487 [booth] glue rebake display=49 ← the HUMAN, standing in the ORC stage, by=scene
|
||||
/// t=8.540 [booth] glue rebake display=51 ← 53 ms later, the orc
|
||||
/// ```
|
||||
///
|
||||
/// and the reverse order for a body whose model is already cached under a stage whose art is not
|
||||
/// (measured the same run, 34 ms of a human standing in the orc stage). That is the director's
|
||||
/// *"switching between chars that haven't been loaded before briefly shows a different model —
|
||||
/// a clear jump from something else to what it eventually turns out to look like"*.
|
||||
///
|
||||
/// The reference cannot express this state: `SelectCharacter` loads the background model and
|
||||
/// builds the character inside one blocking call, so the pair is atomic by construction. Ours
|
||||
/// makes it atomic by **holding the committed pair until the requested one is whole** — the loads
|
||||
/// still start the instant the row is clicked, so nothing is slower; only the *swap* waits.
|
||||
struct PendingSwap {
|
||||
/// The requested scene token (`UI_<token>`).
|
||||
token: &'static str,
|
||||
/// Its model, loading since the click — the hold is about when the stage swaps, never about
|
||||
/// when its art starts arriving.
|
||||
handle: Handle<M2Model>,
|
||||
/// `Time<Real>` at the click, for [`GLUE_SWAP_HOLD_SECS`].
|
||||
since: f32,
|
||||
}
|
||||
|
||||
/// 0737's never-hold-unbounded rule, applied to the swap above: a look whose assembly can never
|
||||
/// complete (a display the cache will not resolve, an item model that is not there) must not
|
||||
/// freeze the screen on the previous character. Generous, because the cost of waiting is a stale
|
||||
/// but COHERENT stage and the cost of releasing early is exactly the defect.
|
||||
const GLUE_SWAP_HOLD_SECS: f32 = 2.0;
|
||||
|
||||
/// The scene's model-name token: `Interface\Glues\Models\UI_<token>\UI_<token>.mdx`. The race
|
||||
/// mapping is the ref's (`GlueParent.lua`'s `SetBackgroundModel` HACK block); the main menu is
|
||||
/// `AccountLogin.xml`'s own model.
|
||||
@@ -273,6 +316,20 @@ fn scene_token(scene: GlueScene) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// **May the stage swap to the requested scene this frame?** — [`PendingSwap`]'s whole law, as one
|
||||
/// function, because *when a glue selection is allowed to become visible* is the thing that was
|
||||
/// wrong and it should be readable (and testable) without standing up a booth.
|
||||
///
|
||||
/// - `standing` — is there a coherent pair on the stage to protect? With nothing standing there is
|
||||
/// no mispairing to avoid and the swap is free (the login → select hop, an empty account).
|
||||
/// - `art_ready` / `body_ready` — the requested scene's model is loaded, and the requested look's
|
||||
/// assembly has landed.
|
||||
/// - `waited` — seconds held, against [`GLUE_SWAP_HOLD_SECS`]'s fail-open bound (0737's rule: a
|
||||
/// cover — or a hold — is never unbounded).
|
||||
fn may_swap(standing: bool, art_ready: bool, body_ready: bool, waited: f32) -> bool {
|
||||
!standing || (art_ready && body_ready) || waited >= GLUE_SWAP_HOLD_SECS
|
||||
}
|
||||
|
||||
/// A glue scene's authored M2 light rig, folded for the booth's light buffer under the
|
||||
/// byte-verified world law (wow-re `m2-dynamic-lights.md`): **directional** lights accumulate —
|
||||
/// ambient tracks summed into one ambient term, each diffuse×intensity an SH lobe with its *own*
|
||||
@@ -724,6 +781,7 @@ pub(super) fn spawn_glue_booth(
|
||||
light: None,
|
||||
variants: default(),
|
||||
rev: 0,
|
||||
pending: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -797,8 +855,13 @@ pub(super) fn sync_glue_scene(
|
||||
ResMut<Assets<Mesh>>,
|
||||
ResMut<benilla_world::instance_tint::InstanceTintMirrors>,
|
||||
),
|
||||
// The swap gate's two inputs ([`PendingSwap`]) — the character assembly this stage must land
|
||||
// beside, and the real clock its bound is measured on. One tuple param: the 16-SystemParam
|
||||
// ceiling, same reason as `particle_assets` above.
|
||||
swap: (Res<GluePreviewBake>, Res<Time<bevy::time::Real>>),
|
||||
) {
|
||||
let (mut palettes, mut mirrors, mut forms, mut mesh_assets, mut tint_mirrors) = particle_assets;
|
||||
let (bake, real) = swap;
|
||||
let Some(booth) = booths.0.get(GLUE_SLOT) else {
|
||||
return;
|
||||
};
|
||||
@@ -854,11 +917,47 @@ pub(super) fn sync_glue_scene(
|
||||
preview.look,
|
||||
Some(GlueLook::Select(l)) if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0
|
||||
);
|
||||
if scene.token == Some(token) {
|
||||
// The request came back to the stage already standing (a re-click, a same-race row) —
|
||||
// whatever swap was in flight is moot.
|
||||
scene.pending = None;
|
||||
}
|
||||
if scene.token != Some(token) {
|
||||
// **The pair swaps together, or not at all** — see [`PendingSwap`] for the defect this
|
||||
// exists to close and the measurements behind it. The load starts on the click; only the
|
||||
// commit waits, and only while there is a coherent pair on the stage to protect.
|
||||
let now = real.elapsed_secs();
|
||||
let pending = match scene.pending.take() {
|
||||
Some(p) if p.token == token => p,
|
||||
_ => PendingSwap {
|
||||
token,
|
||||
handle: asset_server.load(m2_url(&format!(
|
||||
"Interface\\Glues\\Models\\UI_{token}\\UI_{token}.mdx"
|
||||
))),
|
||||
since: now,
|
||||
},
|
||||
};
|
||||
// Both halves of the REQUESTED selection: this stage's art, and the assembly for the look
|
||||
// that asked for it (`build_glue_preview` publishes the look it actually built, so this
|
||||
// compares "what stands" against "what was clicked" without re-deriving either).
|
||||
let art_ready = m2s.get(&pending.handle).is_some();
|
||||
let body_ready = bake.look == preview.look;
|
||||
// An empty stage has no pair to break — the login → select hop, an empty account, a
|
||||
// capture booting onto a glue screen.
|
||||
let standing = bake.look.is_some() && !bake.parts.is_empty();
|
||||
let waited = now - pending.since;
|
||||
if !may_swap(standing, art_ready, body_ready, waited) {
|
||||
scene.pending = Some(pending);
|
||||
return; // hold the coherent pair already on the stage
|
||||
}
|
||||
if standing && waited >= GLUE_SWAP_HOLD_SECS {
|
||||
warn!(
|
||||
"glue scene: UI_{token} swapping after {waited:.1}s without its pair (art \
|
||||
{art_ready}, body {body_ready}) — the bound is GLUE_SWAP_HOLD_SECS"
|
||||
);
|
||||
}
|
||||
scene.token = Some(token);
|
||||
scene.handle = Some(asset_server.load(m2_url(&format!(
|
||||
"Interface\\Glues\\Models\\UI_{token}\\UI_{token}.mdx"
|
||||
))));
|
||||
scene.handle = Some(pending.handle);
|
||||
scene.spawned = false;
|
||||
scene.cam = None;
|
||||
scene.art = None;
|
||||
@@ -1223,7 +1322,15 @@ pub(super) fn sync_glue_booth(
|
||||
}
|
||||
});
|
||||
let (last_rev, last_yaw, last_scene) = last.unwrap_or((u64::MAX, f32::NAN, u64::MAX));
|
||||
let rebake = last_rev != bake.revision || last_scene != scene_rev;
|
||||
// **The other half of [`PendingSwap`]'s law**, for the arrival order it cannot catch: a body
|
||||
// whose models are already cached assembles in a frame, under a stage whose art is still
|
||||
// loading — measured at 34 ms of a human standing in the orc stage. `sync_glue_scene` runs
|
||||
// ahead of this system and commits both halves in the frame they are both ready, so "the
|
||||
// standing stage is the requested one" is exactly "this bake may be shown".
|
||||
let staged = scene
|
||||
.as_deref()
|
||||
.is_none_or(|s| s.token == preview.scene.map(scene_token));
|
||||
let rebake = staged && (last_rev != bake.revision || last_scene != scene_rev);
|
||||
let reyaw = last_yaw != preview.yaw;
|
||||
if !rebake && !reyaw {
|
||||
return;
|
||||
@@ -1347,6 +1454,21 @@ pub(super) fn sync_glue_booth(
|
||||
twins: relight_twins(&b.twins, &mut scene, &mut booth_light, &mut materials),
|
||||
})
|
||||
.collect();
|
||||
// `WOW_BOOTH_LOG=1` — **which body is standing, and what put it there.** The gate timeline
|
||||
// says the glue camera drew; only this says whose display it drew, and whether the re-bake
|
||||
// was the look changing (`look`) or the SCENE's rig landing under an unchanged look
|
||||
// (`scene`) — the second of which stands the OUTGOING character in the INCOMING race's
|
||||
// stage until the new assembly is ready.
|
||||
if super::booth_log() {
|
||||
println!(
|
||||
"[booth] glue rebake display={} parts={} riders={} bake_rev={} scene_rev={scene_rev} by={}",
|
||||
bake.display_id,
|
||||
bake.parts.len(),
|
||||
bake.riders.len(),
|
||||
bake.revision,
|
||||
if last_rev != bake.revision { "look" } else { "scene" },
|
||||
);
|
||||
}
|
||||
commands.entity(booth.root).despawn_related::<Children>();
|
||||
let mut booth_rig = spawn_booth_model(
|
||||
&mut commands,
|
||||
@@ -1427,23 +1549,30 @@ pub(super) fn sync_glue_booth(
|
||||
// framing here does not matter, because `sync_glue_scene` re-aims onto the scene's authored
|
||||
// camera the frame the art lands, and this rig only ever shows for those few loading
|
||||
// frames.
|
||||
// …and ONLY with no scene up. It always was "the fallback" in intent (a scene re-aims
|
||||
// onto its authored camera), but it used to rely on `sync_glue_scene` running *after* this
|
||||
// system to overwrite it — which is also what made the character read last frame's stage
|
||||
// and gave [`PendingSwap`] its one-frame gap. With the order corrected, the fallback has
|
||||
// to know it is one.
|
||||
let (t, _) = body_frame(&anchors, 1.0);
|
||||
let record_fov = anchors
|
||||
.pane_camera
|
||||
.map_or(super::framing::PANE_FIXED_FOV, |c| c.fov);
|
||||
aim(
|
||||
&mut cams,
|
||||
GLUE_SLOT,
|
||||
&(
|
||||
t,
|
||||
Projection::from(PerspectiveProjection {
|
||||
fov: diag_to_vert(record_fov, PORTRAIT_ASPECT),
|
||||
near: 0.02,
|
||||
far: 100.0,
|
||||
..default()
|
||||
}),
|
||||
),
|
||||
);
|
||||
if !scene.as_deref().is_some_and(|s| s.spawned) {
|
||||
aim(
|
||||
&mut cams,
|
||||
GLUE_SLOT,
|
||||
&(
|
||||
t,
|
||||
Projection::from(PerspectiveProjection {
|
||||
fov: diag_to_vert(record_fov, PORTRAIT_ASPECT),
|
||||
near: 0.02,
|
||||
far: 100.0,
|
||||
..default()
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
apply_yaw(
|
||||
&mut commands,
|
||||
@@ -1453,7 +1582,13 @@ pub(super) fn sync_glue_booth(
|
||||
stage_scale,
|
||||
preview.yaw,
|
||||
);
|
||||
*last = Some((bake.revision, preview.yaw, scene_rev));
|
||||
// A yaw-only pass must not record the bake/scene revisions it did NOT commit, or the held
|
||||
// pair above would never be re-tried once the stage lands.
|
||||
*last = Some((
|
||||
if rebake { bake.revision } else { last_rev },
|
||||
preview.yaw,
|
||||
if rebake { scene_rev } else { last_scene },
|
||||
));
|
||||
}
|
||||
|
||||
/// Stand the select screen's **pet** on scene attachment 1 — the hunter/warlock companion beside
|
||||
@@ -2012,4 +2147,35 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// **The glue booth swaps as a PAIR** ([`PendingSwap`], [`may_swap`]). The defect it closes,
|
||||
/// measured on a cold walk of a seven-character roster: clicking an orc from a human stood the
|
||||
/// human's display in the orc stage for 53 ms, and clicking a human back from an orc — a body
|
||||
/// already cached, a stage still loading — stood the human in the *orc* stage for 34 ms. The
|
||||
/// reference cannot express either state: `SelectCharacter` loads the background and builds
|
||||
/// the character inside one blocking call.
|
||||
#[test]
|
||||
fn a_stage_swaps_only_beside_the_character_that_asked_for_it() {
|
||||
// Nothing standing (the login → select hop, an empty account): no pair to break.
|
||||
assert!(may_swap(false, false, false, 0.0));
|
||||
|
||||
// A character IS standing: neither half alone may swap the stage.
|
||||
assert!(
|
||||
!may_swap(true, true, false, 0.0),
|
||||
"the stage's art landed first — swapping now stands the OUTGOING body in it"
|
||||
);
|
||||
assert!(
|
||||
!may_swap(true, false, true, 0.0),
|
||||
"the body assembled first — swapping now is the same mispairing, reversed"
|
||||
);
|
||||
assert!(
|
||||
may_swap(true, true, true, 0.0),
|
||||
"both halves ready: swap, in one frame"
|
||||
);
|
||||
|
||||
// 0737's rule: the hold is bounded. A look whose assembly can never complete must not
|
||||
// freeze the screen on the previous character.
|
||||
assert!(!may_swap(true, true, false, GLUE_SWAP_HOLD_SECS - 0.01));
|
||||
assert!(may_swap(true, true, false, GLUE_SWAP_HOLD_SECS));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,8 +1163,13 @@ impl Plugin for PortraitPlugin {
|
||||
// to flush its children) before the booth can mirror it.
|
||||
sync_stable_standin,
|
||||
sync_stable_booth,
|
||||
glue_booth::sync_glue_booth,
|
||||
// **The scene FIRST, then the character standing in it** — the two are one
|
||||
// committed pair (`glue_booth::PendingSwap`), and the booth reads the stage's
|
||||
// spot, facing and light rig off the scene. Running the booth first meant it
|
||||
// always baked against LAST frame's stage, which is one frame of the wrong
|
||||
// character in the new race's scene on every click.
|
||||
glue_booth::sync_glue_scene,
|
||||
glue_booth::sync_glue_booth,
|
||||
// The glue screens' FFX state, off the same look every screen already writes.
|
||||
glue_booth::sync_glue_ffx,
|
||||
// After the scene's framing: the viewport/clear its law decided (1619).
|
||||
@@ -2744,6 +2749,7 @@ fn gate_booth_cameras(
|
||||
panes: Res<BoothPanes>,
|
||||
images: Res<Assets<Image>>,
|
||||
warm: Res<crate::pipe_warm::WarmPass>,
|
||||
cover: Res<crate::loading_screen::EntryCover>,
|
||||
// The pipeline settle's input ([`Booth::pipes_settling`]) — is the render world still
|
||||
// building variants, i.e. is it still dropping draws on the floor.
|
||||
pipes: Res<crate::pipe_warm::PipeWatch>,
|
||||
@@ -2766,7 +2772,14 @@ fn gate_booth_cameras(
|
||||
let test = test_mode(&mut env_cache);
|
||||
// `satisfied()` is false exactly while the covered warm window runs (the loading screen
|
||||
// holds on it), so this term costs nothing outside that window.
|
||||
let warming = !warm.satisfied();
|
||||
//
|
||||
// **…and not before the cover has reached the glass.** The warm window opens on the
|
||||
// state-flip frame, so without the second term all fifteen booth cameras woke on the one
|
||||
// frame that owes the glass a loading screen — fifteen extra render passes in front of the
|
||||
// present the player is waiting for, and nothing to pick up yet (the menagerie has not
|
||||
// spawned; that is what these bakes are awake FOR). 0962's rule, held once in
|
||||
// [`crate::loading_screen::EntryCover`].
|
||||
let warming = !warm.satisfied() && cover.presented();
|
||||
for (cam_entity, BoothCam(token), mut cam, was_paced) in &mut cams {
|
||||
let Some(booth) = booths.0.get_mut(token.as_str()) else {
|
||||
continue;
|
||||
|
||||
@@ -43,7 +43,8 @@ use bevy::prelude::*;
|
||||
use benilla_protocol::messages::PLAYER_SKILL_SLOTS;
|
||||
use benilla_ui::script::{
|
||||
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, ScriptValue, SkillEntry,
|
||||
SkillsState, UiScript, UnitCombatStats, EQUIPMENT_BAG, SKILL_DEFENSE, SKILL_UNARMED,
|
||||
SkillsState, UiScript, UnitCombatStats, BANK_BAG_SLOT_COUNT, EQUIPMENT_BAG, SKILL_DEFENSE,
|
||||
SKILL_UNARMED,
|
||||
};
|
||||
|
||||
use crate::entities::ItemDisplays;
|
||||
@@ -78,7 +79,7 @@ fn fixed(v: f32) -> i64 {
|
||||
/// replaces the VM without despawning the avatar — re-pushes both snapshots and re-fires their
|
||||
/// events exactly as a fresh login does.
|
||||
#[derive(Resource, Default)]
|
||||
struct CharFeedState {
|
||||
pub(crate) struct CharFeedState {
|
||||
vm: crate::ui_script::VmMemo<CharFeedMemo>,
|
||||
}
|
||||
|
||||
@@ -96,6 +97,12 @@ struct CharFeedMemo {
|
||||
/// `Items::enchant_display_epoch` — one step per displayable countdown change (the slot
|
||||
/// views read second-floored countdowns), including the last elapse's collapsing push.
|
||||
enchant_deadlines: gate::Watch,
|
||||
/// `PendingItemOps::epoch` — one step per change to the in-flight lock set. Watched BESIDE
|
||||
/// `!is_empty()` and not instead of it: `is_empty()` holds the gate open *while* an op is in
|
||||
/// flight, and this catches the frame it goes away. Without it a clear that moves no object
|
||||
/// (`SMSG_INVENTORY_CHANGE_FAILURE`) closes the gate on the same frame it unlocks, and the
|
||||
/// last `locked: true` this feed pushed stands (1771).
|
||||
pending_epoch: gate::Watch,
|
||||
}
|
||||
|
||||
/// Adds the per-frame character-window feed. The bindings live in `benilla-ui`'s `char_stats`;
|
||||
@@ -555,6 +562,9 @@ fn slot_view(
|
||||
commands: &NetCommands,
|
||||
pending: &PendingItemOps,
|
||||
names: &mut crate::names::NameCache,
|
||||
// `ItemSubClass.dbc` — the count gate below reads its `DisplayFlags` bit 2. `None` (the DBC
|
||||
// failed to load) leaves every bag at 0, which is the plain-bag answer and the safe one.
|
||||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||||
guid: u64,
|
||||
live_id: u32,
|
||||
) -> Option<InvSlotView> {
|
||||
@@ -565,8 +575,12 @@ fn slot_view(
|
||||
let obj = items.object(guid)?;
|
||||
let entry = obj.object_entry()?;
|
||||
let count = obj.item_stack_count().unwrap_or(1).max(1);
|
||||
// A worn ammo container's contents, collected while the bag object is borrowed — applied
|
||||
// below once the template's class says quiver.
|
||||
// `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER` — the fork `GetInventoryItemCount` takes first
|
||||
// (`0x4c87a6`), read off the object exactly as the reference reads it rather than inferred
|
||||
// from the template.
|
||||
let is_container = obj.is_container();
|
||||
// A worn container's contents, collected while the bag object is borrowed — summed below if
|
||||
// its subclass row carries the count gate.
|
||||
let content_guids: Vec<u64> = {
|
||||
let slots = obj.container_num_slots().unwrap_or(0).min(36) as u8;
|
||||
(0..slots).filter_map(|s| obj.container_slot(s)).collect()
|
||||
@@ -606,7 +620,7 @@ fn slot_view(
|
||||
// roll's enchants are already in the slots read just above.
|
||||
let roll = obj.item_random_properties_id();
|
||||
let t = items.template(entry, guid, commands);
|
||||
let (name, quality, display, link, equip_slots, class, bar_placeable) = match t {
|
||||
let (name, quality, display, link, equip_slots, kind, bar_placeable) = match t {
|
||||
Some(t) => {
|
||||
let name = rolls.name(&t.name, roll);
|
||||
(
|
||||
@@ -617,26 +631,34 @@ fn slot_view(
|
||||
entry, 0, roll, 0, &name, t.quality,
|
||||
)),
|
||||
find_equip_slot(t.inventory_type),
|
||||
t.class,
|
||||
(t.class, t.subclass),
|
||||
t.placeable_on_action_bar(),
|
||||
)
|
||||
}
|
||||
None => (None, 0, 0, None, Vec::new(), 0, false),
|
||||
};
|
||||
// The quiver's bag-bar count is what's INSIDE it: the ref's `GetInventoryItemCount("player",
|
||||
// bagSlot)` returns the ammo left in a worn ammo container — the "162" on the bag bar — and
|
||||
// the item's own stack (1) for any other bag, which the count text then hides. Gated on
|
||||
// ITEM_CLASS_QUIVER 11 (INFERRED from the ref UI's observable behavior — regular bags never
|
||||
// show a contents sum; named in the decision record).
|
||||
let count = if class == 11 {
|
||||
content_guids
|
||||
.iter()
|
||||
.filter_map(|&g| items.object(g))
|
||||
.map(|o| o.item_stack_count().unwrap_or(1).max(1))
|
||||
.sum()
|
||||
} else {
|
||||
count
|
||||
None => (None, 0, 0, None, Vec::new(), (0, 0), false),
|
||||
};
|
||||
// **A container's count is not its stack count** — `GetInventoryItemCount 0x4c8680` forks on
|
||||
// the TYPEMASK bit and then on `ItemSubClass.dbc`'s `DisplayFlags & 0x4` (`0x4c881a`): set →
|
||||
// `[CGContainer vtbl+0x10]` → `0x622130`, the sum of the contents' stack counts; clear → 0.
|
||||
// In the shipped file that bit is on Soul Bag (1/1) and the Quiver class (11/0..3) only, so a
|
||||
// quiver shows its arrows on the bag bar and an ordinary bag shows nothing at all.
|
||||
//
|
||||
// This REPLACES a `class == 11` guess whose own comment admitted it was inferred from what the
|
||||
// reference UI looked like, and which was wrong twice over: it missed the soul bag, and it
|
||||
// answered a plain bag's stack count (1) where the client answers 0 — the digit the director
|
||||
// saw on `BankFrameBag1` after 1751's swap put a real `isBag` button in front of it.
|
||||
let counts_contents = sub_classes.is_some_and(|c| c.0.display_flags(kind.0, kind.1) & 0x4 != 0);
|
||||
let contents_count = is_container.then(|| {
|
||||
if counts_contents {
|
||||
content_guids
|
||||
.iter()
|
||||
.filter_map(|&g| items.object(g))
|
||||
.map(|o| o.item_stack_count().unwrap_or(1).max(1))
|
||||
.sum()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
let icon = icons
|
||||
.and_then(|i| i.catalog.get(display))
|
||||
.and_then(|d| d.icon.clone());
|
||||
@@ -644,6 +666,7 @@ fn slot_view(
|
||||
item_id: entry,
|
||||
icon,
|
||||
count,
|
||||
contents_count,
|
||||
quality,
|
||||
name,
|
||||
link,
|
||||
@@ -692,6 +715,7 @@ fn ammo_count(store: &ObjectStore, items: &Items, ammo_id: u32) -> u32 {
|
||||
/// the four equipped-bag icons (decision 0216 slice 2's bag bar — the bag ITEM occupying `INV_SLOT`
|
||||
/// 19..22, not its contents) — all the client's 1-based `GetInventorySlotInfo` ids over the
|
||||
/// 0-based inv-slot array.
|
||||
#[allow(clippy::too_many_arguments)] // [`slot_view`]'s read set, plus the descriptor it walks
|
||||
fn inventory_slots(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
@@ -700,6 +724,7 @@ fn inventory_slots(
|
||||
commands: &NetCommands,
|
||||
pending: &PendingItemOps,
|
||||
names: &mut crate::names::NameCache,
|
||||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||||
) -> InventorySlots {
|
||||
let mut inv: InventorySlots = Default::default();
|
||||
let ammo_id = store.0.player_ammo_id().unwrap_or(0);
|
||||
@@ -726,6 +751,9 @@ fn inventory_slots(
|
||||
item_id: ammo_id,
|
||||
icon,
|
||||
count: ammo_count(store, items, ammo_id),
|
||||
// Ammo is not a container — slot 0 is `GetInventoryItemCount`'s own `PLAYER_AMMO_ID`
|
||||
// leg, which never reaches the TYPEMASK fork.
|
||||
contents_count: None,
|
||||
quality,
|
||||
name,
|
||||
link,
|
||||
@@ -750,7 +778,17 @@ fn inventory_slots(
|
||||
let Some(guid) = store.0.player_inv_slot(live as u8 - 1).filter(|g| *g != 0) else {
|
||||
continue;
|
||||
};
|
||||
inv[live as usize] = slot_view(items, icons, rolls, commands, pending, names, guid, live);
|
||||
inv[live as usize] = slot_view(
|
||||
items,
|
||||
icons,
|
||||
rolls,
|
||||
commands,
|
||||
pending,
|
||||
names,
|
||||
sub_classes,
|
||||
guid,
|
||||
live,
|
||||
);
|
||||
}
|
||||
inv
|
||||
}
|
||||
@@ -772,6 +810,7 @@ fn bank_bag_slots(
|
||||
commands: &NetCommands,
|
||||
pending: &PendingItemOps,
|
||||
names: &mut crate::names::NameCache,
|
||||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||||
) -> BankBagSlots {
|
||||
let mut bags: BankBagSlots = Default::default();
|
||||
for (i, bag) in bags.iter_mut().enumerate() {
|
||||
@@ -785,6 +824,7 @@ fn bank_bag_slots(
|
||||
commands,
|
||||
pending,
|
||||
names,
|
||||
sub_classes,
|
||||
guid,
|
||||
BANK_BAG_LIVE_FIRST + i as u32,
|
||||
);
|
||||
@@ -900,7 +940,7 @@ pub(crate) fn fire_stat_transitions(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn feed_char(
|
||||
pub(crate) fn feed_char(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
|
||||
@@ -915,6 +955,8 @@ fn feed_char(
|
||||
mut booth: ResMut<PaperDollBooth>,
|
||||
pending: Res<PendingItemOps>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
// `ItemSubClass.dbc` — `GetInventoryItemCount`'s bag gate (`slot_view`'s own note).
|
||||
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -977,6 +1019,7 @@ fn feed_char(
|
||||
let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed())
|
||||
|| props.as_ref().is_some_and(|r| r.is_changed());
|
||||
let pending_held = !pending.is_empty();
|
||||
let pending_moved = memo.pending_epoch.moved(pending.epoch());
|
||||
gate::trace(
|
||||
"feed_char",
|
||||
&[
|
||||
@@ -989,6 +1032,7 @@ fn feed_char(
|
||||
("icons", icons_added),
|
||||
("enchants", enchants_changed),
|
||||
("pending", pending_held),
|
||||
("pending-epoch", pending_moved),
|
||||
],
|
||||
);
|
||||
let gate = gate::Gate::new(
|
||||
@@ -1000,7 +1044,8 @@ fn feed_char(
|
||||
|| self_changed
|
||||
|| icons_added
|
||||
|| enchants_changed
|
||||
|| pending_held,
|
||||
|| pending_held
|
||||
|| pending_moved,
|
||||
);
|
||||
if gate.skip() {
|
||||
return;
|
||||
@@ -1029,6 +1074,7 @@ fn feed_char(
|
||||
&commands,
|
||||
&pending,
|
||||
&mut names,
|
||||
sub_classes.as_deref(),
|
||||
);
|
||||
let bank_bags = bank_bag_slots(
|
||||
store,
|
||||
@@ -1041,23 +1087,70 @@ fn feed_char(
|
||||
&commands,
|
||||
&pending,
|
||||
&mut names,
|
||||
sub_classes.as_deref(),
|
||||
);
|
||||
// One transition for both bands: they are the same descriptor read at two offsets, and
|
||||
// `UNIT_INVENTORY_CHANGED` is the one repaint signal either has. Pushing them separately
|
||||
// would fire it twice for a single wire update.
|
||||
if memo.last_inv.as_ref() != Some(&inv) || memo.last_bank_bags.as_ref() != Some(&bank_bags) {
|
||||
gate.audit("feed_char", "the inventory snapshot");
|
||||
// Read off the OLD memo, before the push replaces it.
|
||||
let repainted: Vec<usize> = (0..BANK_BAG_SLOT_COUNT)
|
||||
.filter(|&i| {
|
||||
let was = memo.last_bank_bags.as_ref().and_then(|b| b[i].as_ref());
|
||||
!same_item(was, bank_bags[i].as_ref())
|
||||
})
|
||||
.collect();
|
||||
script.set_inventory_slots(inv.clone());
|
||||
script.set_bank_bag_slots(bank_bags.clone());
|
||||
script.fire_event(
|
||||
"UNIT_INVENTORY_CHANGED",
|
||||
vec![ScriptValue::Str("player".to_string())],
|
||||
);
|
||||
// The six bank BAG buttons' only repaint signal (1771). `BankItemButtonTemplate`'s
|
||||
// `<OnUpdate>` is `CursorOnUpdate()` — despite its name, `BankFrameItemButton_OnUpdate`
|
||||
// (the icon/count/lock repaint) is reached ONLY from `BankFrameItemButton_OnEvent`, and
|
||||
// only for `PLAYERBANKSLOTS_CHANGED` and `BANKFRAME_OPENED`. Without this the buttons
|
||||
// showed whatever was true at the last unrelated vault-slot event: a bag dropped in never
|
||||
// appeared, one moved between slots stayed drawn in its old one, and a lock left over from
|
||||
// the drop stood greyed until the window was reopened.
|
||||
//
|
||||
// The VAULT band's copy of this event is `ui_items::feed_containers`' — each feed
|
||||
// announces the band whose data it owns, and `feed_char` is ordered first, so both fire
|
||||
// after their own push.
|
||||
//
|
||||
// **It carries NO arguments** (CARVED — wow-re `system/object-layer/scratch/
|
||||
// bank-slot-event-law.md`). The descriptor watcher's fire site `0x5ddd6e` calls
|
||||
// `FrameScript_SignalEvent 0x703e50`, which is `__fastcall(ecx = id)` with a plain `ret`
|
||||
// and no vararg push at all — zero Lua values. (The image's only other fire site,
|
||||
// `0x4c728d` in the item-GUID→slot notifier, pushes the literal string `"player"`, not a
|
||||
// slot; nothing in FrameXML reads either, both consumers branching on `event` alone.) An
|
||||
// event is fired per changed slot, as the watcher does — the slot travels in *how many*
|
||||
// times it fires, never in an argument.
|
||||
for _ in repainted {
|
||||
script.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]);
|
||||
}
|
||||
memo.last_inv = Some(inv);
|
||||
memo.last_bank_bags = Some(bank_bags);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two bank-bag views hold the same ITEM — everything but `locked`, which is
|
||||
/// `ITEM_LOCK_CHANGED`'s business and must not masquerade as a slot change.
|
||||
fn same_item(a: Option<&InvSlotView>, b: Option<&InvSlotView>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
let unlocked = |v: &InvSlotView| InvSlotView {
|
||||
locked: false,
|
||||
..v.clone()
|
||||
};
|
||||
unlocked(a) == unlocked(b)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The client's own 0-based `EQUIPMENT_SLOT_*` ids for the two weapon slots — the numbers
|
||||
/// `GetWeaponEnchantInfo 0x4c9790` hands its container lookup (`push 0xf` at `0x4c97c3`,
|
||||
/// `push 0x10` at `0x4c988b`). The live-API `GetInventorySlotInfo` ids the *frames* use are one
|
||||
@@ -1093,3 +1186,163 @@ fn weapon_enchant(
|
||||
charges: obj.item_enchant_charges(TEMP_ENCHANTMENT_SLOT),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// `PLAYER_FIELD_BANK_BAG_SLOT_1` — bank bag slot 0's guid pair (protocol test `bank.rs`).
|
||||
const F_BANK_BAG_1: u16 = 612;
|
||||
/// `OBJECT_FIELD_ENTRY` on the item object.
|
||||
const F_OBJECT_ENTRY: u16 = 3;
|
||||
/// "Traveler's Backpack" — any container entry; the feed only needs it to resolve.
|
||||
const BAG_ENTRY: u32 = 4500;
|
||||
const BAG: u64 = 0x4000_0000_0000_0abc;
|
||||
|
||||
/// A world with the player carrying `bag` (or nothing) in the FIRST bank bag slot, and a
|
||||
/// recorder frame registered for the two events the reference's bank bag buttons repaint on.
|
||||
fn world_with_bank_bag(bag: Option<u64>) -> App {
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.init_resource::<CharFeedState>()
|
||||
.init_resource::<PaperDollBooth>()
|
||||
.init_resource::<PendingItemOps>()
|
||||
.init_resource::<Items>()
|
||||
.init_resource::<crate::names::NameCache>()
|
||||
.insert_resource(NetCommands(tx));
|
||||
let fields = match bag {
|
||||
Some(g) => ObjectFields::from_pairs(&[
|
||||
(F_BANK_BAG_1, g as u32),
|
||||
(F_BANK_BAG_1 + 1, (g >> 32) as u32),
|
||||
]),
|
||||
None => ObjectFields::from_pairs(&[(F_BANK_BAG_1, 0), (F_BANK_BAG_1 + 1, 0)]),
|
||||
};
|
||||
app.world_mut().spawn((SelfPlayer, ObjectStore(fields)));
|
||||
if let Some(g) = bag {
|
||||
let mut items = app.world_mut().resource_mut::<Items>();
|
||||
items.insert_object(g, ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]));
|
||||
items.insert_template(
|
||||
BAG_ENTRY,
|
||||
Some(crate::items::test_template("Traveler's Backpack")),
|
||||
);
|
||||
}
|
||||
let script = UiScript::new().unwrap();
|
||||
script
|
||||
.run(
|
||||
r#"
|
||||
SEEN = {}
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("PLAYERBANKSLOTS_CHANGED")
|
||||
f:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
||||
f:SetScript("OnEvent", function()
|
||||
table.insert(SEEN, event .. " " .. tostring(arg1))
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
app.insert_non_send_resource(script);
|
||||
app
|
||||
}
|
||||
|
||||
fn seen(app: &mut App) -> Vec<String> {
|
||||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
let out = script.eval::<Vec<String>>("return SEEN").unwrap();
|
||||
script.run("SEEN = {}").unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// **A bank bag slot's only repaint signal** (1771). `PLAYERBANKSLOTS_CHANGED` is the one
|
||||
/// event that reaches `BankFrameItemButton_OnUpdate` for the six bag buttons —
|
||||
/// `UNIT_INVENTORY_CHANGED`, which this feed already fired, is not registered by them at all,
|
||||
/// and `ITEM_LOCK_CHANGED` repaints the desaturation without ever touching the icon. Without
|
||||
/// this fire a bag dropped into the bank never appeared, and one moved between slots stayed
|
||||
/// drawn where it had been.
|
||||
#[test]
|
||||
fn a_bank_bag_slot_filling_fires_playerbankslots_changed() {
|
||||
let mut app = world_with_bank_bag(Some(BAG));
|
||||
app.world_mut().run_system_once(feed_char).unwrap();
|
||||
let events = seen(&mut app);
|
||||
assert!(
|
||||
events.contains(&"PLAYERBANKSLOTS_CHANGED nil".to_string()),
|
||||
"the repaint event, and it carries NO argument — `0x703e50` pushes none, and the \
|
||||
recorder stringifies the absent arg1 as nil. Got {events:?}"
|
||||
);
|
||||
assert!(
|
||||
events.contains(&"UNIT_INVENTORY_CHANGED player".to_string()),
|
||||
"and the doll's, unchanged, got {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same rule: a LOCK flip is not a slot change. The reference has a
|
||||
/// separate event for it (`ITEM_LOCK_CHANGED`, fired by `ui_items::feed_containers` off
|
||||
/// `LockTransitions`), and firing the repaint event for it too would announce a change to the
|
||||
/// item that did not happen.
|
||||
#[test]
|
||||
fn a_lock_flip_alone_does_not_fire_the_repaint_event() {
|
||||
let mut app = world_with_bank_bag(Some(BAG));
|
||||
app.world_mut().run_system_once(feed_char).unwrap();
|
||||
let _ = seen(&mut app);
|
||||
|
||||
// The same bag, now locked by an outbound move.
|
||||
app.world_mut().resource_mut::<PendingItemOps>().add([(
|
||||
EQUIPMENT_BAG,
|
||||
BANK_BAG_LIVE_FIRST,
|
||||
BAG,
|
||||
1,
|
||||
)]);
|
||||
app.world_mut().run_system_once(feed_char).unwrap();
|
||||
let events = seen(&mut app);
|
||||
assert!(
|
||||
events.contains(&"UNIT_INVENTORY_CHANGED player".to_string()),
|
||||
"the snapshot did move — `locked` is part of it, got {events:?}"
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| e.starts_with("PLAYERBANKSLOTS_CHANGED")),
|
||||
"…but the ITEM did not change, so the repaint event stays quiet, got {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The gate hole behind the stuck grey: the frame an op's lock CLEARS, `!pending.is_empty()`
|
||||
/// reads false and every "is anything in flight?" input closes — so a feed that had pushed
|
||||
/// `locked: true` must have some other reason to run, or the stale lock stands forever.
|
||||
/// [`PendingItemOps::epoch`] is that reason.
|
||||
#[test]
|
||||
fn the_frame_a_lock_clears_still_repushes_the_slot() {
|
||||
let mut app = world_with_bank_bag(Some(BAG));
|
||||
app.world_mut().resource_mut::<PendingItemOps>().add([(
|
||||
EQUIPMENT_BAG,
|
||||
BANK_BAG_LIVE_FIRST,
|
||||
BAG,
|
||||
1,
|
||||
)]);
|
||||
app.world_mut().run_system_once(feed_char).unwrap();
|
||||
let _ = seen(&mut app);
|
||||
assert!(
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.eval::<bool>(r#"return IsInventoryItemLocked(64) and true or false"#)
|
||||
.unwrap(),
|
||||
"in flight, the slot reads locked"
|
||||
);
|
||||
|
||||
// The server answers with a refusal: the lock clears without any object moving, which is
|
||||
// exactly the case `!is_empty()` cannot see.
|
||||
let cleared = app
|
||||
.world_mut()
|
||||
.resource_mut::<PendingItemOps>()
|
||||
.clear_by_failure(BAG);
|
||||
assert_eq!(cleared, vec![(EQUIPMENT_BAG, BANK_BAG_LIVE_FIRST)]);
|
||||
app.world_mut().run_system_once(feed_char).unwrap();
|
||||
assert!(
|
||||
!app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.eval::<bool>(r#"return IsInventoryItemLocked(64) and true or false"#)
|
||||
.unwrap(),
|
||||
"the unlock has to reach the VM in the same frame it happened"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use benilla_ui::script::{ContainerSlot, ContainerState, ScriptValue, UiScript};
|
||||
use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use crate::ui_script::gate;
|
||||
|
||||
use super::equip_error::equip_error_key;
|
||||
@@ -44,6 +44,12 @@ pub(crate) struct FeedMemory {
|
||||
/// `Items::enchant_display_epoch` — one step per displayable countdown change (the slot
|
||||
/// views read second-floored countdowns), including the last elapse's collapsing push.
|
||||
enchant_deadlines: gate::Watch,
|
||||
/// The item guid **of the bag itself** in each of the ten bag slots — container ids 1..=10
|
||||
/// (the four equipped, then the six bank bags), indexed `id − 1`. Diffed frame to frame for
|
||||
/// `BAG_CLOSED`, which is a fact about the BAG, not about its contents, and which the pushed
|
||||
/// [`ContainerState`] therefore cannot answer: two different empty 16-slot bags swapped
|
||||
/// between two slots produce identical container states and must still close both windows.
|
||||
bag_guids: [u64; 10],
|
||||
}
|
||||
|
||||
/// A spell's tooltip text: the $-substituted description (the real "Use: Restores 392 to 653
|
||||
@@ -724,6 +730,38 @@ fn bag_family_name(
|
||||
families.name(family).map(str::to_string)
|
||||
}
|
||||
|
||||
/// The pending-lock **resolving clear** (decision 0216 §4 / 0218 §3 "the field-update watcher"):
|
||||
/// release any outstanding op whose slots have moved on, and queue what unlocked for
|
||||
/// [`feed_containers`] to announce with `ITEM_LOCK_CHANGED`.
|
||||
///
|
||||
/// **Its own system, ordered ahead of BOTH feeds** (1771). It used to live inside
|
||||
/// `feed_containers`, which is ordered *after* `feed_char` — so `feed_char`, which owns the
|
||||
/// doll's and the bank bags' `locked`, read a lock set that the same frame was about to clear,
|
||||
/// pushed `locked: true`, and then went quiet: its gate closed the next frame (nothing in flight,
|
||||
/// no object moved) and the stale lock stood until something else forced a repush. On the bank's
|
||||
/// six bag buttons — whose only repaint signals are `PLAYERBANKSLOTS_CHANGED` and
|
||||
/// `BANKFRAME_OPENED` — that showed as a bag greyed out until the window was reopened.
|
||||
///
|
||||
/// Resolve first, then push, then fire: the house rule, now actually enforceable across the two
|
||||
/// feeds because neither of them owns the clear any more.
|
||||
pub(crate) fn resolve_item_locks(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
items: Res<Items>,
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
mut transitions: ResMut<LockTransitions>,
|
||||
) {
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let player = self_q.iter().next();
|
||||
if player.is_none() {
|
||||
return;
|
||||
}
|
||||
transitions
|
||||
.0
|
||||
.extend(pending.resolve(|bag, slot1| slot_guid_count(player, bag, slot1, &items)));
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // the param list IS the input set
|
||||
pub(crate) fn feed_containers(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
@@ -748,8 +786,8 @@ pub(crate) fn feed_containers(
|
||||
mut equip_errors: ResMut<EquipErrors>,
|
||||
// Reason 16's `%s` source (decision 0916); absent = every 16 keeps the generic line.
|
||||
bag_families: Option<Res<crate::ui_items::ItemBagFamilies>>,
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
mut lock_cleared: ResMut<LockClearedByFailure>,
|
||||
pending: Res<PendingItemOps>,
|
||||
mut lock_cleared: ResMut<LockTransitions>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
// Paired into one param (the 16-SystemParam ceiling this signature already sits at): the UI
|
||||
// clock, and the petition record cache a charter slot's tooltip lines read — `ResMut` because
|
||||
@@ -912,19 +950,15 @@ pub(crate) fn feed_containers(
|
||||
};
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
}
|
||||
// The pending-lock resolving clear (decision 0216 §4 / 0218 §3 "the field-update watcher"):
|
||||
// walk the SAME guids the slot loop below is about to read and release any op whose slots have
|
||||
// moved on. Folded in with the failure-driven clears `net/apply/loot.rs::inventory_failure`
|
||||
// queued (it has no `UiScript` to fire through) — both fire `ITEM_LOCK_CHANGED` here, the bag
|
||||
// windows' own repaint trigger (0218: the popup's No/ESC clear paths the bag never clicked
|
||||
// through, so only the event reaches it). Fired AFTER the slot loop below pushes the corrected
|
||||
// `.locked` state, so the repaint they trigger sees the unlocked slot, not stale data.
|
||||
let mut transitioned: Vec<(i64, u32)> = if player.is_some() {
|
||||
pending.resolve(|bag, slot1| slot_guid_count(player, bag, slot1, &items))
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
transitioned.append(&mut lock_cleared.0);
|
||||
// Both lock clears of the frame — the resolving field-update watch ([`resolve_item_locks`],
|
||||
// which runs ahead of EVERY feed) and the failure-driven clears
|
||||
// `net/apply/loot.rs::inventory_failure` queued (it has no `UiScript` to fire through). Both
|
||||
// fire `ITEM_LOCK_CHANGED` here, the bag windows' own repaint trigger (0218: the popup's
|
||||
// No/ESC clear paths the bag never clicked through, so only the event reaches it). Fired
|
||||
// AFTER the slot loop below pushes the corrected `.locked` state, so the repaint they trigger
|
||||
// sees the unlocked slot, not stale data — and after `feed_char`'s own push, for the same
|
||||
// reason on the doll and bank-bag bands (1771).
|
||||
let transitioned: Vec<(i64, u32)> = std::mem::take(&mut lock_cleared.0);
|
||||
|
||||
let mut fresh: HashMap<i64, ContainerState> = HashMap::new();
|
||||
if let Some(store) = player {
|
||||
@@ -1168,6 +1202,7 @@ pub(crate) fn feed_containers(
|
||||
&mut script,
|
||||
memory,
|
||||
player.is_some().then_some(fresh),
|
||||
std::array::from_fn(|i| player.map_or(0, |s| bag_slot_guid(s, i as i64 + 1))),
|
||||
transitioned,
|
||||
) {
|
||||
gate.audit("feed_containers", "a bag diff or a lock event");
|
||||
@@ -1196,6 +1231,11 @@ pub(crate) fn apply_container_source(
|
||||
script: &mut UiScript,
|
||||
memory: &mut FeedMemory,
|
||||
source: Option<HashMap<i64, ContainerState>>,
|
||||
// The ten bag slots' own guids ([`bag_slot_guid`]) — `BAG_CLOSED`'s diff. Rides beside the
|
||||
// source rather than inside it because it is read off the player descriptor, not built from
|
||||
// the containers, and the absent-source path must leave the memory alone rather than diff
|
||||
// against zeros (that path is the logout despawn window — see this function's own note).
|
||||
bag_guids: [u64; 10],
|
||||
transitioned: Vec<(i64, u32)>,
|
||||
) -> bool {
|
||||
// Diff whole bags; push + fire BAG_UPDATE per transition, one BAG_UPDATE_DELAYED per batch. A
|
||||
@@ -1204,7 +1244,7 @@ pub(crate) fn apply_container_source(
|
||||
// below rather than leaning on that invariant.
|
||||
let mut pushed = false;
|
||||
if let Some(fresh) = source {
|
||||
pushed |= diff_and_push(script, memory, fresh);
|
||||
pushed |= diff_and_push(script, memory, fresh, bag_guids);
|
||||
}
|
||||
// The lock-transition event (decision 0218: the bag windows' own repaint trigger) — after the
|
||||
// container push above, so a listener's repaint reads the corrected `.locked` state.
|
||||
@@ -1219,12 +1259,60 @@ pub(crate) fn apply_container_source(
|
||||
pushed
|
||||
}
|
||||
|
||||
/// The guid of the **bag itself** in container `id` (1..=10) — the four equipped bag slots, then
|
||||
/// the six bank bag slots. `0` = that slot is empty. Anything else (the backpack 0, the vault −1,
|
||||
/// the keyring −2) has no bag slot behind it and answers 0.
|
||||
///
|
||||
/// This is what `BAG_CLOSED`/`BAG_UPDATE`'s watcher reads: `0x4f8cc0`'s four install loops cover
|
||||
/// the descriptor bytes `0x540`–`0x55f` (equipped bags 19–22) and `0x6a0`–`0x6cf` (bank bags
|
||||
/// 63–68) with callback `0x4f8ec0`, and the backpack and keyring loops with `0x4f8db0`, which
|
||||
/// never reaches the event at all. So exactly these ten slots close a window.
|
||||
fn bag_slot_guid(store: &ObjectStore, id: i64) -> u64 {
|
||||
match id {
|
||||
1..=4 => store.0.player_inv_slot(BAG_SLOT_FIRST + (id as u8 - 1)),
|
||||
5..=10 => store.0.player_bank_bag_slot(id as u8 - 5),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The present-source half of [`apply_container_source`]: push every changed bag, announce each.
|
||||
fn diff_and_push(
|
||||
script: &mut UiScript,
|
||||
memory: &mut FeedMemory,
|
||||
fresh: HashMap<i64, ContainerState>,
|
||||
bag_guids: [u64; 10],
|
||||
) -> bool {
|
||||
// **`BAG_CLOSED` — the only thing in the image that hides an open bag window** (CARVED, wow-re
|
||||
// `system/ui/scratch/equipped-bag-slot-events.md`: one fire site, `0x4f92b5`, and
|
||||
// `ContainerFrame_OnEvent`'s `this:Hide()` is its one consumer; `CloseBag`/`CloseAllBags` are
|
||||
// not even strings in `WoW.exe`). Its condition is **not** "the bag went away" — it is
|
||||
// `new != old && old != 0` (`0x4f923d je` unchanged, `0x4f9247 je` old-was-empty), so a bag
|
||||
// SWAPPED for another fires `BAG_CLOSED(n)` and then `BAG_UPDATE(n)`.
|
||||
//
|
||||
// Diffed on the BAG's own guid rather than on the pushed `ContainerState` below, because it is
|
||||
// a fact about the bag and not about its contents: two identical empty bags exchanged between
|
||||
// two slots leave both container states equal and must still close both windows.
|
||||
let closed: Vec<i64> = (1..=10)
|
||||
.filter(|&id| {
|
||||
let (was, now) = (
|
||||
memory.bag_guids[id as usize - 1],
|
||||
bag_guids[id as usize - 1],
|
||||
);
|
||||
was != 0 && now != was
|
||||
})
|
||||
.collect();
|
||||
// The other half of the same branch: a bag REMOVED fires `BAG_CLOSED` and no `BAG_UPDATE`
|
||||
// (the unequip row of the carve's event table). Every other transition still announces.
|
||||
let emptied: Vec<i64> = closed
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| bag_guids[id as usize - 1] == 0)
|
||||
.collect();
|
||||
memory.bag_guids = bag_guids;
|
||||
for id in &closed {
|
||||
script.fire_event("BAG_CLOSED", vec![ScriptValue::Int(*id)]);
|
||||
}
|
||||
let changed: Vec<i64> = fresh
|
||||
.keys()
|
||||
.chain(memory.pushed.keys())
|
||||
@@ -1285,23 +1373,29 @@ fn diff_and_push(
|
||||
.join(" ")
|
||||
);
|
||||
for &bag in &changed {
|
||||
// The vault fires the reference's own event, per changed slot with the slot id
|
||||
// (`PLAYERBANKSLOTS_CHANGED(slot)` — BankFrame repaints the one button); everything
|
||||
// else — backpack, equipped bags, AND bank bags (ordinary container frames in the
|
||||
// reference) — fires BAG_UPDATE(bagID).
|
||||
// The vault fires the reference's own event, once per changed slot and **with no
|
||||
// arguments** (CARVED — `0x5ddd6e` calls `FrameScript_SignalEvent 0x703e50`, an
|
||||
// `__fastcall(ecx = id)` with a plain `ret` and no vararg push; wow-re
|
||||
// `system/object-layer/scratch/bank-slot-event-law.md`). It is a broadcast: every bank
|
||||
// button repaints from its own `GetInventorySlot()`, which is why no slot id is needed
|
||||
// and why the bank BAG band's copy of this event — fired by `ui_char::feed_char`, the
|
||||
// feed that owns that band — is indistinguishable from this one.
|
||||
//
|
||||
// Everything else — backpack, equipped bags, AND bank bags (ordinary container frames
|
||||
// in the reference) — fires BAG_UPDATE(bagID). The vault is the one band that fires
|
||||
// NEITHER `BAG_UPDATE` nor `BAG_CLOSED`: `0x4f8cc0` installs its container listener
|
||||
// over the six bank-bag fields (`0x6a0`–`0x6c8`) and over nothing in the 24 vault
|
||||
// fields (`0x5e0`–`0x698`).
|
||||
if bag == BANK_CONTAINER {
|
||||
let empty = HashMap::new();
|
||||
let now_slots = fresh.get(&bag).map(|c| &c.slots).unwrap_or(&empty);
|
||||
let was_slots = memory.pushed.get(&bag).map(|c| &c.slots).unwrap_or(&empty);
|
||||
for slot in 1..=u32::from(BANK_SLOTS) {
|
||||
if now_slots.get(&slot) != was_slots.get(&slot) {
|
||||
script.fire_event(
|
||||
"PLAYERBANKSLOTS_CHANGED",
|
||||
vec![ScriptValue::Int(i64::from(slot))],
|
||||
);
|
||||
script.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if !emptied.contains(&bag) {
|
||||
script.fire_event("BAG_UPDATE", vec![ScriptValue::Int(bag)]);
|
||||
}
|
||||
}
|
||||
@@ -1309,12 +1403,126 @@ fn diff_and_push(
|
||||
script.fire_event("BAG_UPDATE_DELAYED", vec![]);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
// A `BAG_CLOSED` with nothing else to say still went into the VM — two identical bags swapped
|
||||
// between two slots change no container state at all.
|
||||
!closed.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{apply_container_source, charges_count, FeedMemory};
|
||||
|
||||
/// No bag in any of the ten bag slots — every test below drives the backpack (container 0),
|
||||
/// which has no bag slot behind it and can never raise `BAG_CLOSED`.
|
||||
const NO_BAGS: [u64; 10] = [0; 10];
|
||||
|
||||
/// **`BAG_CLOSED`, the only thing that hides an open bag window** — and the three ways to get
|
||||
/// its condition wrong (CARVED, wow-re `system/ui/scratch/equipped-bag-slot-events.md`).
|
||||
///
|
||||
/// benilla never fired this event at all, so a `ContainerFrame` outlived the bag it belonged
|
||||
/// to: `ContainerFrame_OnEvent`'s `this:Hide()` arm is its one consumer in all of FrameXML,
|
||||
/// and `CloseBag`/`CloseAllBags` are not even strings in `WoW.exe` — there is no other
|
||||
/// mechanism, for either band.
|
||||
///
|
||||
/// The three traps, each asserted below:
|
||||
/// · the condition is `new != old && old != 0`, NOT "the bag went away" — a SWAP fires it;
|
||||
/// · an unequip fires `BAG_CLOSED` and **no** `BAG_UPDATE`, while a swap fires both;
|
||||
/// · the diff is on the BAG's own guid, not on the pushed container — two identical empty
|
||||
/// bags exchanged between two slots leave every container state equal, and both windows
|
||||
/// must still close.
|
||||
#[test]
|
||||
fn bag_closed_fires_on_the_bag_guid_moving_and_only_off_a_non_empty_slot() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
"SEEN = {} \
|
||||
local f = CreateFrame('Frame') \
|
||||
f:RegisterEvent('BAG_CLOSED') \
|
||||
f:RegisterEvent('BAG_UPDATE') \
|
||||
f:SetScript('OnEvent', function() \
|
||||
table.insert(SEEN, event .. ' ' .. tostring(arg1)) end)",
|
||||
)
|
||||
.unwrap();
|
||||
let seen = |s: &mut UiScript| {
|
||||
let out = s.eval::<Vec<String>>("return SEEN").unwrap();
|
||||
s.run("SEEN = {}").unwrap();
|
||||
out
|
||||
};
|
||||
let pouch = |n: u32| {
|
||||
HashMap::from([(
|
||||
1,
|
||||
ContainerState {
|
||||
name: Some("Small Brown Pouch".into()),
|
||||
num_slots: n,
|
||||
slots: HashMap::new(),
|
||||
},
|
||||
)])
|
||||
};
|
||||
let mut guids = NO_BAGS;
|
||||
let mut memory = FeedMemory::default();
|
||||
|
||||
// Equip a bag into the first equipped bag slot: BAG_UPDATE, and no BAG_CLOSED — the slot
|
||||
// was empty, which is the `0x4f9247 je` arm.
|
||||
guids[0] = 0xAAAA;
|
||||
apply_container_source(&mut s, &mut memory, Some(pouch(6)), guids, Vec::new());
|
||||
assert_eq!(seen(&mut s), vec!["BAG_UPDATE 1"]);
|
||||
|
||||
// Its contents change: the bag guid stands, so neither the close nor its suppression
|
||||
// applies and the ordinary announce runs.
|
||||
apply_container_source(&mut s, &mut memory, Some(pouch(6)), guids, Vec::new());
|
||||
assert!(
|
||||
seen(&mut s).is_empty(),
|
||||
"an unchanged container says nothing"
|
||||
);
|
||||
|
||||
// SWAP for a different bag: closed FIRST, then updated. This is the arm a "the bag went
|
||||
// away" reading gets wrong — the slot is still occupied.
|
||||
guids[0] = 0xBBBB;
|
||||
apply_container_source(&mut s, &mut memory, Some(pouch(10)), guids, Vec::new());
|
||||
assert_eq!(seen(&mut s), vec!["BAG_CLOSED 1", "BAG_UPDATE 1"]);
|
||||
|
||||
// UNEQUIP: closed, and **no** BAG_UPDATE.
|
||||
guids[0] = 0;
|
||||
apply_container_source(&mut s, &mut memory, Some(HashMap::new()), guids, Vec::new());
|
||||
assert_eq!(seen(&mut s), vec!["BAG_CLOSED 1"]);
|
||||
|
||||
// Two IDENTICAL empty bags exchanged between bank bag slots 5 and 6. Every container
|
||||
// state is equal, so the container diff sees nothing at all — and both windows must still
|
||||
// close. This is the case that makes the guid the right thing to watch.
|
||||
let mut memory = FeedMemory::default();
|
||||
let two = || {
|
||||
HashMap::from([
|
||||
(
|
||||
5,
|
||||
ContainerState {
|
||||
name: Some("Linen Bag".into()),
|
||||
num_slots: 6,
|
||||
slots: HashMap::new(),
|
||||
},
|
||||
),
|
||||
(
|
||||
6,
|
||||
ContainerState {
|
||||
name: Some("Linen Bag".into()),
|
||||
num_slots: 6,
|
||||
slots: HashMap::new(),
|
||||
},
|
||||
),
|
||||
])
|
||||
};
|
||||
let mut guids = NO_BAGS;
|
||||
guids[4] = 0x1111;
|
||||
guids[5] = 0x2222;
|
||||
apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new());
|
||||
let _ = seen(&mut s);
|
||||
guids.swap(4, 5);
|
||||
let pushed = apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new());
|
||||
assert_eq!(seen(&mut s), vec!["BAG_CLOSED 5", "BAG_CLOSED 6"]);
|
||||
assert!(
|
||||
pushed,
|
||||
"a BAG_CLOSED with no container change still went into the VM — the gate audit reads \
|
||||
this return"
|
||||
);
|
||||
}
|
||||
use benilla_protocol::messages::ItemSpellEntry;
|
||||
use benilla_ui::script::{ContainerState, UiScript};
|
||||
use std::collections::HashMap;
|
||||
@@ -1348,6 +1556,7 @@ mod tests {
|
||||
&mut s,
|
||||
&mut memory,
|
||||
Some(HashMap::from([(0, bag0)])),
|
||||
NO_BAGS,
|
||||
Vec::new(),
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 16);
|
||||
@@ -1355,7 +1564,7 @@ mod tests {
|
||||
|
||||
// The logout despawn frame: no store. The VM keeps its last-pushed bags and no event
|
||||
// fires — an addon reading its bags out of the PLAYER_LOGOUT edge sees them intact.
|
||||
apply_container_source(&mut s, &mut memory, None, Vec::new());
|
||||
apply_container_source(&mut s, &mut memory, None, NO_BAGS, Vec::new());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(),
|
||||
16,
|
||||
@@ -1368,7 +1577,13 @@ mod tests {
|
||||
);
|
||||
|
||||
// A PRESENT source without the bag is a genuine transition: push + announce.
|
||||
apply_container_source(&mut s, &mut memory, Some(HashMap::new()), Vec::new());
|
||||
apply_container_source(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
Some(HashMap::new()),
|
||||
NO_BAGS,
|
||||
Vec::new(),
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return BAG_EVENTS").unwrap(), 2);
|
||||
}
|
||||
@@ -1418,7 +1633,13 @@ mod tests {
|
||||
let mut memory = FeedMemory::default();
|
||||
|
||||
// The quiver arrives full — a create, not a restack. No lock event.
|
||||
apply_container_source(&mut s, &mut memory, Some(bag(arrows(200))), Vec::new());
|
||||
apply_container_source(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
Some(bag(arrows(200))),
|
||||
NO_BAGS,
|
||||
Vec::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
|
||||
"BAG_UPDATE:0,nil",
|
||||
@@ -1427,7 +1648,13 @@ mod tests {
|
||||
|
||||
// An arrow leaves the quiver.
|
||||
s.run("ORDER = {}").unwrap();
|
||||
apply_container_source(&mut s, &mut memory, Some(bag(arrows(199))), Vec::new());
|
||||
apply_container_source(
|
||||
&mut s,
|
||||
&mut memory,
|
||||
Some(bag(arrows(199))),
|
||||
NO_BAGS,
|
||||
Vec::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
|
||||
"ITEM_LOCK_CHANGED:0,1 BAG_UPDATE:0,nil",
|
||||
@@ -1439,7 +1666,7 @@ mod tests {
|
||||
s.run("ORDER = {}").unwrap();
|
||||
let mut other = arrows(20);
|
||||
other.item_id = 3033; // Razor Arrow
|
||||
apply_container_source(&mut s, &mut memory, Some(bag(other)), Vec::new());
|
||||
apply_container_source(&mut s, &mut memory, Some(bag(other)), NO_BAGS, Vec::new());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
|
||||
"BAG_UPDATE:0,nil",
|
||||
|
||||
@@ -43,7 +43,7 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::items::Items;
|
||||
use crate::net::{NetCommands, ObjectStore};
|
||||
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_unit::UnitFeed;
|
||||
|
||||
@@ -1074,7 +1074,7 @@ impl Plugin for UiItemsPlugin {
|
||||
// pending-equip array and its one bind-on-use cell.
|
||||
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
|
||||
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
|
||||
.init_resource::<LockClearedByFailure>()
|
||||
.init_resource::<LockTransitions>()
|
||||
// AFTER the chain opens — a bare Startup slot raced AssetSet::Open and, when it
|
||||
// won, silently skipped every item DBC (no ItemSets/ItemSubClasses resource for the
|
||||
// whole session: set tooltips lost their SET block, the crafting book its headers).
|
||||
@@ -1086,6 +1086,11 @@ impl Plugin for UiItemsPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// The pending-lock resolving clear, ahead of BOTH feeds that read the lock
|
||||
// set into a pushed `locked` (1771 — its own doc has the why).
|
||||
feed::resolve_item_locks
|
||||
.before(feed_containers)
|
||||
.before(crate::ui_char::feed_char),
|
||||
// `.before(CooldownEvents)`: the slot cooldown triples must be in the VM
|
||||
// before `feed_action_state`'s synchronous `BAG_UPDATE_COOLDOWN` makes the
|
||||
// bag handlers re-read them (the set's own doc — else a fresh cooldown's
|
||||
|
||||
@@ -1451,6 +1451,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
|
||||
item_id: 4496,
|
||||
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
|
||||
count: 1,
|
||||
contents_count: Some(0), // an equipped bag, whose subclass row carries no count gate
|
||||
quality: 1,
|
||||
name: Some("Small Brown Pouch".into()),
|
||||
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
|
||||
|
||||
@@ -1382,7 +1382,7 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
|
||||
// The logout boundary: the despawn frame (no self store) and then the shutdown's own events.
|
||||
// The record the write persists must be the bags the player actually had.
|
||||
let mut memory = crate::ui_items::feed::FeedMemory::default();
|
||||
crate::ui_items::feed::apply_container_source(&mut s, &mut memory, None, Vec::new());
|
||||
crate::ui_items::feed::apply_container_source(&mut s, &mut memory, None, [0; 10], Vec::new());
|
||||
s.fire_event("PLAYER_LEAVING_WORLD", Vec::new());
|
||||
s.fire_event("PLAYER_LOGOUT", Vec::new());
|
||||
let (size, item) = s
|
||||
|
||||
@@ -221,7 +221,7 @@ fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_cha
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![ScriptValue::Int(3)]);
|
||||
s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
s.resolve();
|
||||
assert!(
|
||||
@@ -247,7 +247,7 @@ fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_cha
|
||||
slots: slots2,
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![ScriptValue::Int(3)]);
|
||||
s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]);
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
assert!(
|
||||
@@ -564,3 +564,122 @@ fn clicking_item_slot_one_with_an_empty_cursor_queues_the_pickup() {
|
||||
"a bare pickup (nothing to swap into) queues no move yet"
|
||||
);
|
||||
}
|
||||
|
||||
/// The icon-texture quad whose path contains `needle`, for the desaturation assertions below.
|
||||
fn icon_quad<'a>(quads: &'a [ExtractedQuad], needle: &str) -> Option<&'a ExtractedQuad> {
|
||||
quads.iter().find(
|
||||
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether that icon is drawn desaturated (`SetItemButtonDesaturated`'s shader arm).
|
||||
fn is_greyed(quads: &[ExtractedQuad], needle: &str) -> bool {
|
||||
match icon_quad(quads, needle).map(|q| &q.content) {
|
||||
Some(QuadContent::Texture { desaturated, .. }) => *desaturated,
|
||||
_ => panic!("no icon quad matching {needle}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A bag view for one bank BAG slot — the drop the director made. `contents_count: Some(_)` is
|
||||
/// what makes it a CONTAINER to `GetInventoryItemCount`, and the value is deliberately non-zero:
|
||||
/// the binding's `0x16` short-circuit has to be what zeroes it, not an already-zero sum.
|
||||
fn a_bag(locked: bool) -> benilla_ui::script::InvSlotView {
|
||||
benilla_ui::script::InvSlotView {
|
||||
item_id: 4500,
|
||||
icon: Some(r"Interface\Icons\INV_Misc_Bag_08".into()),
|
||||
count: 1,
|
||||
contents_count: Some(3),
|
||||
name: Some("Traveler's Backpack".into()),
|
||||
locked,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// **The bug of 1771, both halves.** A bag dropped into a bank bag slot came up greyed and stayed
|
||||
/// greyed until the window was reopened, and a bag moved between two bag slots stayed drawn in the
|
||||
/// one it left.
|
||||
///
|
||||
/// Both are the same defect: the six bag buttons repaint from `BankFrameItemButton_OnUpdate`,
|
||||
/// which is reached ONLY from `BankFrameItemButton_OnEvent` on `PLAYERBANKSLOTS_CHANGED` or
|
||||
/// `BANKFRAME_OPENED` — despite the name, it is not an `OnUpdate` handler
|
||||
/// (`BankItemButtonTemplate`'s `<OnUpdate>` is `CursorOnUpdate()`). benilla fired that event only
|
||||
/// for the vault's 24 slots, so the bag buttons showed whatever had been true at the last
|
||||
/// *unrelated* vault change, and `ITEM_LOCK_CHANGED` — the one event that did reach them — repaints
|
||||
/// the desaturation only, never the icon.
|
||||
///
|
||||
/// This test drives the reference's own file through the exact sequence and asserts what the
|
||||
/// player sees at each step.
|
||||
#[test]
|
||||
fn a_bank_bag_button_paints_the_drop_and_lets_go_of_the_lock() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = setup();
|
||||
s.set_money(0);
|
||||
s.set_container(0, empty_bag("Backpack", 16));
|
||||
s.set_bank(Some(BankState {
|
||||
num_purchased: 2,
|
||||
next_cost: 10_000,
|
||||
}));
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
name: Some("Grumnus Steelshaper".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("BANKFRAME_OPENED", vec![]);
|
||||
assert!(
|
||||
icon_quad(&s.extract(), "INV_Misc_Bag_08").is_none(),
|
||||
"no bag in the slot yet"
|
||||
);
|
||||
|
||||
// The drop lands and the server has not answered: the send locks both ends.
|
||||
let mut bags: benilla_ui::script::BankBagSlots = Default::default();
|
||||
bags[0] = Some(a_bag(true));
|
||||
s.set_bank_bag_slots(bags.clone());
|
||||
// The event carries NO arguments (`0x703e50`, `__fastcall(ecx = id)`, no vararg push) — it is
|
||||
// a broadcast, and every bank button repaints from its own `GetInventorySlot()`.
|
||||
s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(
|
||||
is_greyed(&s.extract(), "INV_Misc_Bag_08"),
|
||||
"in flight, the button greys — BankFrameItemButton_UpdateLock's desaturate arm"
|
||||
);
|
||||
// **And no digit.** `BankFrameBagButton_OnLoad` sets `isBag = 1`, so `SetItemButtonCount`'s
|
||||
// `isBag and count > 0` arm would print any positive number in the corner — the "1" the
|
||||
// director saw. `GetInventoryItemCount 0x4c8680` short-circuits every container past 0-based
|
||||
// slot 0x16 to a literal 0, and the bank's six bag slots are all in that band.
|
||||
assert!(
|
||||
!s.eval::<bool>("return BankFrameBag1Count:IsShown()")
|
||||
.unwrap(),
|
||||
"a bank bag counts nothing — the count fontstring stays hidden"
|
||||
);
|
||||
|
||||
// The lock lets go. `ITEM_LOCK_CHANGED` is all the button gets, and it is enough — PROVIDED
|
||||
// the snapshot it reads has already been corrected, which is what moving the resolving clear
|
||||
// ahead of both feeds buys (`ui_items::feed::resolve_item_locks`).
|
||||
bags[0] = Some(a_bag(false));
|
||||
s.set_bank_bag_slots(bags.clone());
|
||||
s.fire_event("ITEM_LOCK_CHANGED", vec![]);
|
||||
assert!(
|
||||
!is_greyed(&s.extract(), "INV_Misc_Bag_08"),
|
||||
"the stuck grey: unlocked, the button must come back to full colour"
|
||||
);
|
||||
|
||||
// Moved to the next bag slot: the icon has to LEAVE the first button, which only the repaint
|
||||
// event can do — the lock event never touches the texture.
|
||||
bags[0] = None;
|
||||
bags[1] = Some(a_bag(false));
|
||||
s.set_bank_bag_slots(bags);
|
||||
s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]);
|
||||
s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
let quads = s.extract();
|
||||
let bag = icon_quad(&quads, "INV_Misc_Bag_08").expect("the bag is still drawn, once");
|
||||
let drawn = bag.rect.expect("the icon resolved a rect").left;
|
||||
let bag2_left = s.eval::<f32>("return BankFrameBag2:GetLeft()").unwrap();
|
||||
let bag1_left = s.eval::<f32>("return BankFrameBag1:GetLeft()").unwrap();
|
||||
assert!(
|
||||
(drawn - bag2_left).abs() < (drawn - bag1_left).abs(),
|
||||
"the bag is drawn on button 2, not the one it left ({drawn} vs {bag1_left}/{bag2_left})"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ fn inventory_with_head_item() -> InventorySlots {
|
||||
item_id: 1234,
|
||||
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
|
||||
count: 1,
|
||||
contents_count: None,
|
||||
quality: 2,
|
||||
name: Some("Test Helm".into()),
|
||||
link: Some("|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r".into()),
|
||||
|
||||
@@ -137,24 +137,20 @@ fn ui_wanted(world: &World) -> bool {
|
||||
/// reference sourcing + 55 files + addons + saved vars + `PLAYER_LOGIN` → 48.83) — the
|
||||
/// director's "frozen char for 1 sec" at every Enter World.
|
||||
///
|
||||
/// The counter counts covered+in-world frames, exactly like the warm pass: renders are serial,
|
||||
/// so at [`ENTRY_LOAD_COVER_FRAMES`] the two intermediate frames' renders — which draw the
|
||||
/// cover — have committed their presents before this frame's stall begins. The loading screen
|
||||
/// folds this resource's existence into its clear condition, so a reveal can never precede the
|
||||
/// UI it is supposed to reveal.
|
||||
/// The wait itself is [`crate::loading_screen::EntryCover`]'s — one count of covered+in-world
|
||||
/// frames for the whole client. This used to keep its own, beside the warm pass's identical one,
|
||||
/// and the duplication is not incidental: the third consumer of the same rule (the world camera,
|
||||
/// which renders the whole new world on that same frame) had no counter at all until it was
|
||||
/// measured. So the latch here is a *marker* — is the entry UI still owed? — and the timing
|
||||
/// question is answered in one place. The loading screen folds this resource's existence into
|
||||
/// its clear condition, so a reveal can never precede the UI it is supposed to reveal.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PendingEntryUiLoad {
|
||||
covered_frames: u32,
|
||||
}
|
||||
|
||||
/// Covered+in-world frames before the entry load runs — the same present proxy, and the same
|
||||
/// value, as `pipe_warm::WARM_COVER_PRESENT_FRAMES` (0962's argument, restated there).
|
||||
const ENTRY_LOAD_COVER_FRAMES: u32 = 3;
|
||||
pub(crate) struct PendingEntryUiLoad;
|
||||
|
||||
/// `OnEnter(InWorld)`: arm the deferred entry load. The load itself runs a few frames later —
|
||||
/// see [`PendingEntryUiLoad`].
|
||||
pub(crate) fn arm_entry_ui_load(mut commands: Commands) {
|
||||
commands.insert_resource(PendingEntryUiLoad::default());
|
||||
commands.insert_resource(PendingEntryUiLoad);
|
||||
}
|
||||
|
||||
/// **Is the in-game UI still owed for this world entry?** True from `OnEnter(InWorld)` until
|
||||
@@ -169,7 +165,7 @@ pub(crate) fn arm_entry_ui_load(mut commands: Commands) {
|
||||
/// the frames built moments later do their first paint with no first paint. That is not a
|
||||
/// hypothetical ordering — it is a RACE against the wire, which is why it took some logins and not
|
||||
/// others, and why the symptom moved between characters. The self descriptor arriving inside the
|
||||
/// [`ENTRY_LOAD_COVER_FRAMES`] deferral window is all it takes.
|
||||
/// deferral window is all it takes.
|
||||
///
|
||||
/// The reference has no such window: `UI_Init 0x48fbf0` loads all of FrameXML and *then* fires the
|
||||
/// world-enter cascade (`PLAYER_LOGIN` at `0x49094b`, `PLAYER_ENTERING_WORLD` at `0x490965`) from
|
||||
@@ -205,12 +201,15 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
|
||||
let covering = world
|
||||
.get_resource::<crate::loading_screen::LoadingScreen>()
|
||||
.is_some_and(|s| s.covering());
|
||||
if covering {
|
||||
let mut pending = world.resource_mut::<PendingEntryUiLoad>();
|
||||
pending.covered_frames += 1;
|
||||
if pending.covered_frames < ENTRY_LOAD_COVER_FRAMES {
|
||||
return;
|
||||
}
|
||||
// The wait is [`crate::loading_screen::EntryCover`]'s, not a second count of the same frames
|
||||
// (1345 wrote its own beside the warm pass's, and the third consumer — the world camera —
|
||||
// then had none at all). `presented()` is already true with no cover up, which is the
|
||||
// "nothing watching the previous present, load now" arm.
|
||||
if !world
|
||||
.get_resource::<crate::loading_screen::EntryCover>()
|
||||
.is_none_or(|c| c.presented())
|
||||
{
|
||||
return;
|
||||
}
|
||||
world.remove_resource::<PendingEntryUiLoad>();
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
@@ -545,7 +545,7 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
|
||||
)]))
|
||||
};
|
||||
let mut memory = FeedMemory::default();
|
||||
apply_container_source(&mut s, &mut memory, quiver(200), Vec::new());
|
||||
apply_container_source(&mut s, &mut memory, quiver(200), [0; 10], Vec::new());
|
||||
|
||||
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
|
||||
s.tick(0.1);
|
||||
@@ -554,7 +554,7 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
|
||||
|
||||
// The server tells us the stack is one lighter. That is a fired shot, and the only way the
|
||||
// addon can ever know it.
|
||||
apply_container_source(&mut s, &mut memory, quiver(199), Vec::new());
|
||||
apply_container_source(&mut s, &mut memory, quiver(199), [0; 10], Vec::new());
|
||||
s.tick(0.1);
|
||||
|
||||
let (_, reloading, reload_left, _) = shot_state(&mut s);
|
||||
|
||||
@@ -76,6 +76,7 @@ fn seed_items(s: &mut UiScript) {
|
||||
item_id: 1234,
|
||||
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
|
||||
count: 1,
|
||||
contents_count: None,
|
||||
quality: 2,
|
||||
name: Some("Test Helm".into()),
|
||||
link: Some("|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r".into()),
|
||||
@@ -280,6 +281,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
|
||||
item_id: 1234,
|
||||
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
|
||||
count: 1,
|
||||
contents_count: None,
|
||||
quality: 2,
|
||||
name: Some("Test Helm".into()),
|
||||
link: Some("|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r".into()),
|
||||
|
||||
@@ -589,11 +589,15 @@ fn the_entry_load_waits_for_the_cover_to_present() {
|
||||
let mut world = booted_world();
|
||||
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
world.insert_resource(crate::loading_screen::LoadingScreen::test_covering());
|
||||
world.insert_resource(crate::loading_screen::EntryCover::default());
|
||||
world.insert_resource(roster_named("Onehunter", 1));
|
||||
world.insert_resource(super::PendingEntryUiLoad::default());
|
||||
world.insert_resource(super::PendingEntryUiLoad);
|
||||
|
||||
// Covered frames 1 and 2: the cover has not provably presented yet — no load.
|
||||
for frame in 1..=2 {
|
||||
world
|
||||
.resource_mut::<crate::loading_screen::EntryCover>()
|
||||
.tick(true);
|
||||
super::lifecycle::run_pending_entry_load(&mut world);
|
||||
assert_eq!(
|
||||
probe_saw(&world),
|
||||
@@ -602,6 +606,9 @@ fn the_entry_load_waits_for_the_cover_to_present() {
|
||||
);
|
||||
}
|
||||
// Covered frame 3: two cover renders have committed — the burst is hidden. Load.
|
||||
world
|
||||
.resource_mut::<crate::loading_screen::EntryCover>()
|
||||
.tick(true);
|
||||
super::lifecycle::run_pending_entry_load(&mut world);
|
||||
assert_eq!(
|
||||
probe_saw(&world).as_deref(),
|
||||
@@ -630,7 +637,7 @@ fn no_cover_means_the_entry_load_runs_at_once() {
|
||||
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
world.insert_resource(crate::loading_screen::LoadingScreen::default());
|
||||
world.insert_resource(roster_named("Onehunter", 1));
|
||||
world.insert_resource(super::PendingEntryUiLoad::default());
|
||||
world.insert_resource(super::PendingEntryUiLoad);
|
||||
|
||||
super::lifecycle::run_pending_entry_load(&mut world);
|
||||
assert_eq!(
|
||||
@@ -658,8 +665,12 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
|
||||
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
world.insert_resource(crate::loading_screen::LoadingScreen::test_covering());
|
||||
world.insert_resource(roster_named("Onehunter", 1));
|
||||
world.insert_resource(super::PendingEntryUiLoad::default());
|
||||
world.insert_resource(crate::loading_screen::EntryCover::default());
|
||||
world.insert_resource(super::PendingEntryUiLoad);
|
||||
|
||||
world
|
||||
.resource_mut::<crate::loading_screen::EntryCover>()
|
||||
.tick(true);
|
||||
super::lifecycle::run_pending_entry_load(&mut world); // covered frame 1 — still pending
|
||||
super::end_ui_session(&mut world);
|
||||
|
||||
@@ -749,7 +760,7 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
|
||||
};
|
||||
|
||||
// …but the in-game UI is still owed. Three frames inside the deferral window.
|
||||
app.insert_resource(super::PendingEntryUiLoad::default());
|
||||
app.insert_resource(super::PendingEntryUiLoad);
|
||||
for frame in 1..=3 {
|
||||
app.update();
|
||||
assert_eq!(
|
||||
|
||||
@@ -171,9 +171,12 @@ impl ItemSubClassCatalog {
|
||||
}
|
||||
|
||||
/// This subclass's raw `DisplayFlags` (`0` for an unknown key). Bit 0 is [`Self::hides_name`];
|
||||
/// bit 1 marks a subclass the auction house's category filter does not offer. The bits are
|
||||
/// served raw rather than as named predicates because each consumer owns the meaning of the
|
||||
/// one it reads — the auction law lives in the auction module, not here.
|
||||
/// bit 1 marks a subclass the auction house's category filter does not offer; **bit 2 (`0x4`)
|
||||
/// is the "this bag counts what is inside it" gate** — `GetInventoryItemCount` (`0x4c881a`–
|
||||
/// `0x4c8826`) reads it before summing an equipped bag's contents, and in the shipped 1.12.1
|
||||
/// file it is set on Soul Bag (1/1) and the whole Quiver class (11/0..3) and nowhere else.
|
||||
/// The bits are served raw rather than as named predicates because each consumer owns the
|
||||
/// meaning of the one it reads — the auction law lives in the auction module, not here.
|
||||
pub fn display_flags(&self, class: u32, subclass: u32) -> u32 {
|
||||
self.rows
|
||||
.get(&(class, subclass))
|
||||
|
||||
@@ -909,6 +909,18 @@ impl ObjectFields {
|
||||
self.get_u32(FIELD_GAMEOBJECT_LEVEL).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER` — whether this object is a **bag**, not merely an
|
||||
/// item. The reference's own test, byte for byte: `mov ecx,[eax+8]` / `shr ecx,2` /
|
||||
/// `test cl,1` (`0x4c879d`–`0x4c87a6` in `GetInventoryItemCount`, repeated verbatim at
|
||||
/// `0x4c7dbb`–`0x4c7dc4` in `PutItemInBag`). A container's mask carries the ITEM bit too, so
|
||||
/// this is a widening test and not an alternative to it.
|
||||
///
|
||||
/// `false` when the field is absent — an item whose create block has not landed is not yet
|
||||
/// known to be a bag, and every caller's fallback is the ordinary-item answer.
|
||||
pub fn is_container(&self) -> bool {
|
||||
self.get_u32(FIELD_OBJECT_TYPE).unwrap_or(0) & 0x4 != 0
|
||||
}
|
||||
|
||||
/// The object's class from the type field (`OBJECT_FIELD_TYPE`), used for `Values`/`Movement`
|
||||
/// updates that carry no separate `TypeId`. Also the reference's own "is this a Player" test
|
||||
/// (`[obj->descriptorBlock[2] + 8] >> 4 & 1`), which is how `CanAttack`/`CanInteract` ask.
|
||||
|
||||
@@ -219,8 +219,20 @@ pub struct InvSlotView {
|
||||
pub bar_placeable: bool,
|
||||
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight.
|
||||
pub icon: Option<String>,
|
||||
/// Stack count (`GetInventoryItemCount` — 1 for equipment, the bag-summed count for ammo).
|
||||
/// `ITEM_FIELD_STACK_COUNT` — what `GetInventoryItemCount 0x4c8680` pushes for an ordinary
|
||||
/// item (1 for equipment; the ammo slot's own leg puts the carried total here).
|
||||
pub count: u32,
|
||||
/// What that same function pushes when the item is a **CONTAINER** (`OBJECT_FIELD_TYPE`'s
|
||||
/// `TYPEMASK_CONTAINER` bit — `0x4c87a6`), which is a different number entirely: the sum of
|
||||
/// its contents' stack counts when its `(class, subclass)` row carries `ItemSubClass.dbc`'s
|
||||
/// `DisplayFlags & 0x4` (`0x4c881a`, set on Soul Bag and the quivers only), and **0**
|
||||
/// otherwise. `None` = not a container, and [`Self::count`] answers.
|
||||
///
|
||||
/// The slot-dependent half of the law is NOT here — it is at the binding, where the reference
|
||||
/// keeps it: a container whose 0-based slot is past `0x16` (everything but the four equipped
|
||||
/// bag slots — the bank's six bag slots included) short-circuits to 0 at `0x4c87af` before any
|
||||
/// of this is read.
|
||||
pub contents_count: Option<u32>,
|
||||
/// Item quality 0..6 (`GetInventoryItemQuality`).
|
||||
pub quality: i32,
|
||||
/// The item's name, when known (tooltip/link consumers).
|
||||
@@ -432,12 +444,18 @@ impl super::UiScript {
|
||||
self.fire_event("UPDATE_INVENTORY_ALERTS", vec![]);
|
||||
}
|
||||
|
||||
/// Push the six bank-bag slots ([`BankBagSlots`] — live ids 64..=69). No event of its own:
|
||||
/// the app pushes it in the same step as [`Self::set_inventory_slots`], whose
|
||||
/// `UNIT_INVENTORY_CHANGED` is the repaint signal the reference's bank buttons already
|
||||
/// register for (`BankFrameBaseButton_OnLoad` takes `PLAYERBANKSLOTS_CHANGED` /
|
||||
/// `ITEM_LOCK_CHANGED` / `BANKFRAME_OPENED`, and `BankFrameItemButton_OnUpdate` re-reads
|
||||
/// every frame besides).
|
||||
/// Push the six bank-bag slots ([`BankBagSlots`] — live ids 64..=69).
|
||||
///
|
||||
/// **The repaint signal is `PLAYERBANKSLOTS_CHANGED`, and nothing else will do** (1771). The
|
||||
/// reference's bank buttons register exactly `BANKFRAME_OPENED`, `PLAYERBANKSLOTS_CHANGED`,
|
||||
/// `ITEM_LOCK_CHANGED` and `CURSOR_UPDATE` (`BankFrameBaseButton_OnLoad`); of those, only the
|
||||
/// first two reach `BankFrameItemButton_OnUpdate`, the icon/count/lock repaint. That function
|
||||
/// is NOT an `OnUpdate` handler despite the name — `BankItemButtonTemplate`'s `<OnUpdate>` is
|
||||
/// `CursorOnUpdate()`, and the repaint is only ever reached from
|
||||
/// `BankFrameItemButton_OnEvent`. `UNIT_INVENTORY_CHANGED`, which rides
|
||||
/// [`Self::set_inventory_slots`] beside this push, is not registered by these buttons at all.
|
||||
/// So `ui_char::feed_char` fires `PLAYERBANKSLOTS_CHANGED` for every bag slot whose ITEM
|
||||
/// changed, and leaves the lock to `ITEM_LOCK_CHANGED`.
|
||||
pub fn set_bank_bag_slots(&mut self, slots: BankBagSlots) {
|
||||
self.model_mut().bank_bag_slots = slots;
|
||||
}
|
||||
@@ -578,6 +596,23 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared inventory-slot reader's whitelist (`0x4c8520`), on the **0-based** value it hands
|
||||
/// back (`0x4c8546 dec eax`) — Lua ids are one higher. Anything outside it raises the calling
|
||||
/// binding's own "Invalid inventory slot in …", which is not the same thing as an empty answer.
|
||||
///
|
||||
/// The bands are the reader's own, kept apart rather than merged into one 39..=68 range because
|
||||
/// they are different stores: the ammo pseudo-slot, the doll (equipment + the four equipped bag
|
||||
/// icons), the bank's 24 vault slots, its 6 bag slots, and the keyring. Note what is NOT here —
|
||||
/// the backpack's own 16 item slots (0-based 23..=38) and buyback (69..=80): both are addressed
|
||||
/// through the container API instead, and a macro handing either to an inventory binding raises.
|
||||
fn inventory_slot_reader_accepts(slot0: i32) -> bool {
|
||||
slot0 == -1 // Lua 0 the ammo leg
|
||||
|| (0x00..=0x16).contains(&slot0) // Lua 1..=23 the doll + the four equipped bags
|
||||
|| (0x27..=0x3e).contains(&slot0) // Lua 40..=63 the bank vault
|
||||
|| (0x3f..=0x44).contains(&slot0) // Lua 64..=69 the bank bag slots
|
||||
|| (0x51..=0x70).contains(&slot0) // Lua 82..=113 the keyring
|
||||
}
|
||||
|
||||
/// The display name inside an item link — the text between `|h[` and `]|h`.
|
||||
fn link_item_name(link: &str) -> Option<String> {
|
||||
let (_, rest) = link.split_once("|h[")?;
|
||||
@@ -608,6 +643,13 @@ impl InvSlotView {
|
||||
locked: slot.locked,
|
||||
creator: slot.creator.clone(),
|
||||
enchants: slot.enchants.clone(),
|
||||
// Every band this mapper serves — the bank vault, Lua 40..=63 — sits past the
|
||||
// binding's `0x16` short-circuit, so a container met here answers 0 whatever is inside
|
||||
// it and only the `Some`/`None` distinction carries information. "Names Bag0Slot as a
|
||||
// place it could be worn" is the same set as TYPEMASK_CONTAINER (`INVTYPE_BAG` is the
|
||||
// only inventory type `FindEquipSlot` maps there — `cursor::bag_verbs::is_container`
|
||||
// states the equivalence in full), and it is what a container slot carries.
|
||||
contents_count: slot.equip_slots.contains(&20).then_some(0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -934,13 +976,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetInventoryItemCount("player", slot) → the stack count; **0 for an empty slot** (the
|
||||
// wiki's documented shape — warcraft.wiki.gg: "1 for most items", 0 when nothing's there;
|
||||
// the ammo slot carries the bag-summed count the app resolves).
|
||||
// The binding's own two `.data` literals, read out of the shipped 5875 image and NOT
|
||||
// paraphrased (the house rule `GetInventorySlotInfo` set: an addon may compare on the text).
|
||||
// Neither carries a period or a newline.
|
||||
//
|
||||
// `0x8489b4` arg 1 fails `is-number-or-string 0x6f3510`
|
||||
// `0x848984` arg 2 fails `lua_isnumber`, OR names a slot outside the reader's whitelist
|
||||
//
|
||||
// (`0x848b54` is the sibling `Invalid inventory slot in IsInventoryItemLocked`, unclaimed
|
||||
// here: that binding's own argument reader has not been carved, and guessing it is exactly
|
||||
// what this block exists to stop.)
|
||||
const USAGE_GET_INVENTORY_ITEM_COUNT: &str = "Usage: GetInventoryItemCount(unit, slot)";
|
||||
const INVALID_SLOT_GET_INVENTORY_ITEM_COUNT: &str =
|
||||
"Invalid inventory slot in GetInventoryItemCount";
|
||||
// GetInventoryItemCount("player", slot) — CARVED (`0x4c8680`, wow-re
|
||||
// `system/ui/scratch/inventory-item-count-law.md`), and it is nothing like the shape every
|
||||
// secondary source describes. Four answers, in the reference's own order:
|
||||
//
|
||||
// · an EMPTY slot pushes **1**, not 0 (`0x4c8797`). Both FrameXML callers gate on
|
||||
// `GetInventoryItemTexture` first, which is why nobody ever noticed.
|
||||
// · a non-container pushes `ITEM_FIELD_STACK_COUNT`.
|
||||
// · a CONTAINER (`OBJECT_FIELD_TYPE & 4`, `0x4c87a6`) in a slot whose 0-based id is **past
|
||||
// `0x16`** pushes a literal 0 (`0x4c87af cmp …,0x16` / `jg 0x4c8813`) — before any lookup.
|
||||
// The bank's six bag slots (Lua 64..69 → 0-based 63..68) are all in that band, which is the
|
||||
// whole reason `BankFrameBag1` shows no digit in the real client: `SetItemButtonCount`'s
|
||||
// `isBag and count > 0` arm never fires.
|
||||
// · a CONTAINER in one of the four equipped bag slots pushes the `ItemSubClass.dbc`-gated
|
||||
// sum of its contents — see [`InvSlotView::contents_count`].
|
||||
g.set(
|
||||
"GetInventoryItemCount",
|
||||
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
|
||||
Ok(player_inv_slot(lua, &token, slot).map_or(0i64, |v| i64::from(v.count)))
|
||||
lua.create_function(|lua, (unit, slot): (Value, Value)| {
|
||||
let token = super::binding_abi::string_arg(lua, unit, USAGE_GET_INVENTORY_ITEM_COUNT)?;
|
||||
let slot0 =
|
||||
super::binding_abi::number_arg(lua, slot, INVALID_SLOT_GET_INVENTORY_ITEM_COUNT)?;
|
||||
let slot0 = slot0.wrapping_sub(1);
|
||||
if !inventory_slot_reader_accepts(slot0) {
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
INVALID_SLOT_GET_INVENTORY_ITEM_COUNT.into(),
|
||||
));
|
||||
}
|
||||
let Some(v) = player_inv_slot(lua, &Some(token), i64::from(slot0) + 1) else {
|
||||
return Ok(1i64);
|
||||
};
|
||||
Ok(match v.contents_count {
|
||||
None => i64::from(v.count),
|
||||
Some(_) if slot0 > 0x16 => 0,
|
||||
Some(n) => i64::from(n),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -1618,6 +1700,96 @@ mod tests {
|
||||
assert!(s.eval::<bool>("return HasWandEquipped()").unwrap());
|
||||
}
|
||||
|
||||
/// **`GetInventoryItemCount`'s container fork** (`0x4c8680`, wow-re
|
||||
/// `system/ui/scratch/inventory-item-count-law.md`). Four answers, and three of them are not
|
||||
/// the stack count:
|
||||
///
|
||||
/// · an equipped QUIVER (its `ItemSubClass.dbc` row has `DisplayFlags & 0x4`) answers the sum
|
||||
/// of its arrows — the number that shows on the bag bar;
|
||||
/// · an equipped PLAIN BAG answers **0**, which is why the bar shows it no digit even though
|
||||
/// `BagSlotButtonTemplate` sets `isBag = 1` and `SetItemButtonCount` would print any
|
||||
/// positive number;
|
||||
/// · the SAME plain bag in a BANK BAG slot answers 0 too, but for a different reason and
|
||||
/// earlier: `cmp …,0x16 / jg` short-circuits every container past the four equipped bag
|
||||
/// slots before the DBC is ever consulted. This is the digit the director saw on
|
||||
/// `BankFrameBag1` (decision 1771's sibling), and asserting it here rather than only through
|
||||
/// the window is the point — the window can only ever show that the two agree.
|
||||
#[test]
|
||||
fn get_inventory_item_count_forks_on_the_container_bit_and_the_slot() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let bag = |contents: Option<u32>| {
|
||||
Some(InvSlotView {
|
||||
item_id: 4496,
|
||||
count: 1,
|
||||
contents_count: contents,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let mut slots: crate::script::InventorySlots = Default::default();
|
||||
slots[20] = bag(Some(162)); // a quiver: the gate is set, the sum is its arrows
|
||||
slots[21] = bag(Some(0)); // a plain bag: the gate is clear
|
||||
slots[22] = Some(InvSlotView {
|
||||
item_id: 2263,
|
||||
count: 7,
|
||||
..Default::default()
|
||||
}); // not a container at all
|
||||
s.set_inventory_slots(slots);
|
||||
let mut bank: crate::script::BankBagSlots = Default::default();
|
||||
bank[0] = bag(Some(162)); // the very same quiver, in a bank bag slot
|
||||
s.set_bank_bag_slots(bank);
|
||||
|
||||
let count = |slot: i64| {
|
||||
s.eval::<i64>(&format!(
|
||||
r#"return GetInventoryItemCount("player", {slot})"#
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(count(20), 162, "a quiver counts what is inside it");
|
||||
assert_eq!(
|
||||
count(21),
|
||||
0,
|
||||
"a plain bag counts nothing — not its own stack"
|
||||
);
|
||||
assert_eq!(count(22), 7, "an ordinary item is still its stack count");
|
||||
assert_eq!(
|
||||
count(64),
|
||||
0,
|
||||
"0-based 63 is past 0x16: every container short-circuits, quiver or not"
|
||||
);
|
||||
|
||||
// The two `.data` literals, and the fact that both arms RAISE rather than answer. A slot
|
||||
// outside the shared reader's whitelist is not an empty slot: the backpack's own item
|
||||
// slots (Lua 24..=39) are addressed through the container API, and handing one here
|
||||
// abandons the statement.
|
||||
let err = |code: &str| s.run(code).unwrap_err().to_string();
|
||||
assert!(
|
||||
err(r#"GetInventoryItemCount("player", 30)"#)
|
||||
.contains("Invalid inventory slot in GetInventoryItemCount"),
|
||||
"the backpack band raises"
|
||||
);
|
||||
assert!(
|
||||
err(r#"GetInventoryItemCount("player", 200)"#)
|
||||
.contains("Invalid inventory slot in GetInventoryItemCount"),
|
||||
"and so does anything past the keyring"
|
||||
);
|
||||
assert!(
|
||||
err(r#"GetInventoryItemCount("player", {})"#)
|
||||
.contains("Invalid inventory slot in GetInventoryItemCount"),
|
||||
"a non-number slot takes the SAME string, not the Usage one"
|
||||
);
|
||||
assert!(
|
||||
err("GetInventoryItemCount({}, 1)")
|
||||
.contains("Usage: GetInventoryItemCount(unit, slot)"),
|
||||
"…and only a bad unit takes the Usage string"
|
||||
);
|
||||
assert_eq!(
|
||||
count(0),
|
||||
1,
|
||||
"the ammo pseudo-slot (0-based -1) is in the whitelist, so it ANSWERS — and with no \
|
||||
ammo seated the answer is the empty one, which is 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_item_bindings_serve_occupied_empty_and_absent_shapes() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -1672,7 +1844,9 @@ mod tests {
|
||||
.unwrap(),
|
||||
200
|
||||
);
|
||||
// An empty slot: nil id/texture/quality, count 0 (the wiki's documented empty shape).
|
||||
// An empty slot: nil id/texture/quality — and **count 1, not 0** (`0x4c8797`). Every
|
||||
// secondary source says 0; the image pushes the literal 1, and both FrameXML callers gate
|
||||
// on `GetInventoryItemTexture` first, which is why nobody ever caught it.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return GetInventoryItemID("player", 5) == nil"#)
|
||||
.unwrap());
|
||||
@@ -1682,7 +1856,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return GetInventoryItemCount("player", 5)"#)
|
||||
.unwrap(),
|
||||
0
|
||||
1
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return GetInventoryItemQuality("player", 5) == nil"#)
|
||||
@@ -1694,7 +1868,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return GetInventoryItemCount("target", 1)"#)
|
||||
.unwrap(),
|
||||
0
|
||||
1,
|
||||
"no item behind the token is the empty answer, and the empty answer is 1"
|
||||
);
|
||||
// Out-of-range slots: the empty shape, no error.
|
||||
assert!(s
|
||||
|
||||
@@ -272,6 +272,7 @@ mod tests {
|
||||
item_id: 1234,
|
||||
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
|
||||
count: 1,
|
||||
contents_count: None,
|
||||
quality: 2,
|
||||
name: Some("Test Helm".into()),
|
||||
link: Some("|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r".into()),
|
||||
@@ -288,6 +289,7 @@ mod tests {
|
||||
item_id: 555,
|
||||
icon: Some("Interface\\Icons\\INV_Jewelry_Ring_01".into()),
|
||||
count: 1,
|
||||
contents_count: None,
|
||||
quality: 1,
|
||||
name: Some("Test Ring".into()),
|
||||
link: Some("|cffffffff|Hitem:555:0:0:0|h[Test Ring]|h|r".into()),
|
||||
@@ -304,6 +306,7 @@ mod tests {
|
||||
item_id: 999,
|
||||
icon: Some("Interface\\Icons\\INV_Shirt_White_01".into()),
|
||||
count: 1,
|
||||
contents_count: None,
|
||||
quality: 1,
|
||||
name: Some("Test Tabard".into()),
|
||||
link: Some("|cffffffff|Hitem:999:0:0:0|h[Test Tabard]|h|r".into()),
|
||||
@@ -470,6 +473,7 @@ mod tests {
|
||||
item_id: 4496,
|
||||
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
|
||||
count: 1,
|
||||
contents_count: None,
|
||||
quality: 1,
|
||||
name: Some("Small Brown Pouch".into()),
|
||||
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
|
||||
|
||||
Reference in New Issue
Block a user