From 84d7bd7cfe379380697ef608eff46510847dbdba Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:25:49 +0700 Subject: [PATCH] Try-ons land in call order; ticket reads need a player (#2335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `TryOn` looks its item up when the verb is applied, in call order with `Undress` and `Dress`: a cached record lands there, as `0x504540` sets the components inside the call (`0x5045dd`-`0x5045ee`), and only a missing one waits for the callback `0x504ba0`. So `TryOn` then `Undress` in one chunk shows nothing. - `Dress` keeps a try-on still waiting for its record: the rebuild (`0x504cd0` → `0x505b50`) cancels no callback. - `GetGMTicket` and `DeleteGMTicket` send nothing without an active player (`0x48c9bd`, `0x48cb8d`). - More test fixtures build the client without an `Update` pass: about 25% less CPU for the `benilla-app` suite. Each fix has a test that fails without it. Fixes #2329 Fixes #2330 Fixes #2328 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/lanes/guard_tests.rs | 2 - crates/benilla-app/src/script_calls.rs | 2 +- .../src/script_calls/tests/systems.rs | 2 +- .../src/spell/net/tests/dispatch.rs | 2 +- .../benilla-app/src/spell/targeting/item.rs | 2 +- crates/benilla-app/src/target/click.rs | 2 +- .../src/target/click_loot_tests.rs | 2 +- crates/benilla-app/src/target/died.rs | 2 +- crates/benilla-app/src/target/flash.rs | 2 +- crates/benilla-app/src/target/front_tests.rs | 2 +- crates/benilla-app/src/target/ring.rs | 2 +- crates/benilla-app/src/ui_dressup.rs | 386 +++++++++++------- .../src/ui_script/help_frame_tests.rs | 8 + crates/benilla-ui/src/script/gm_ticket.rs | 17 +- .../benilla-ui/src/script/tests/gm_ticket.rs | 17 +- docs/DEVIATIONS.md | 2 +- 16 files changed, 293 insertions(+), 159 deletions(-) diff --git a/crates/benilla-app/src/lanes/guard_tests.rs b/crates/benilla-app/src/lanes/guard_tests.rs index ea2247f07..9b7cb15ec 100644 --- a/crates/benilla-app/src/lanes/guard_tests.rs +++ b/crates/benilla-app/src/lanes/guard_tests.rs @@ -499,8 +499,6 @@ const READS_FRAME_FINAL: &[(&str, &[&str])] = &[ "net::ObjectStore", "net::SelfGuid", "target::Selection", "ui_party::GroupState"]), ("::ui_honor::feed_honor", &["net::ObjectStore", "target::Selection", "ui_honor::InspectHonor", "ui_party::GroupState"]), - ("::ui_dressup::feed_dressup", &["items::Items", "net::ObjectStore", "ui_action::PlayerActions", - "ui_guild::GuildState"]), ("::ui_action::recount::recount_item_actions", &["items::Countdowns", "items::Items", "net::GuidIndex", "net::ObjectStore"]), ("::ui_action::feed::feed_actions", &["items::Countdowns", "items::Items", "net::GuidIndex", diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index 83faef43e..8f8d71909 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -1156,7 +1156,7 @@ mod tests { /// The whole client, headless, with us, `OLD` (selected when `selected`) and `NEW` as /// `party1`, and a VM whose book holds Heal. fn frame(selected: bool) -> Frame { - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); let world = app.world_mut(); diff --git a/crates/benilla-app/src/script_calls/tests/systems.rs b/crates/benilla-app/src/script_calls/tests/systems.rs index 95a58ecf8..fb2241e61 100644 --- a/crates/benilla-app/src/script_calls/tests/systems.rs +++ b/crates/benilla-app/src/script_calls/tests/systems.rs @@ -15,7 +15,7 @@ pub(super) const NPC: u64 = 0xF130_0000_0000_0042; /// The whole client, headless, with a VM and the write channel captured. pub(super) fn client() -> (App, Receiver) { - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); app.insert_non_send_resource(UiScript::new().expect("a VM")); diff --git a/crates/benilla-app/src/spell/net/tests/dispatch.rs b/crates/benilla-app/src/spell/net/tests/dispatch.rs index 6c3d40a1e..3e2838f72 100644 --- a/crates/benilla-app/src/spell/net/tests/dispatch.rs +++ b/crates/benilla-app/src/spell/net/tests/dispatch.rs @@ -11,7 +11,7 @@ const ME: u64 = 10; /// The whole client, headless, with us streamed in. fn client() -> (App, crossbeam_channel::Receiver) { - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); let world = app.world_mut(); diff --git a/crates/benilla-app/src/spell/targeting/item.rs b/crates/benilla-app/src/spell/targeting/item.rs index f4a02e0b6..6a8de4a58 100644 --- a/crates/benilla-app/src/spell/targeting/item.rs +++ b/crates/benilla-app/src/spell/targeting/item.rs @@ -410,7 +410,7 @@ mod tests { template: bool, spell: bool, ) -> (App, crossbeam_channel::Receiver) { - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); let world = app.world_mut(); diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index c954cf0b0..f78953876 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -2825,7 +2825,7 @@ mod tests { (true, true, Some(0x61)), ] { let label = format!("in flight {in_flight}, mounted {mounted}"); - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); // In world: the body we drive. app.insert_resource(NetCommands(tx)) diff --git a/crates/benilla-app/src/target/click_loot_tests.rs b/crates/benilla-app/src/target/click_loot_tests.rs index 0395c9bd7..32e45552f 100644 --- a/crates/benilla-app/src/target/click_loot_tests.rs +++ b/crates/benilla-app/src/target/click_loot_tests.rs @@ -47,7 +47,7 @@ fn fields(pairs: &[(u16, u32)]) -> ObjectStore { /// looted skinnable one, a chest and a lootable corpse, all in reach, Skinning known, with a VM /// noting `LOOT_OPENED` and `LOOT_CLOSED` in `SEEN`. fn client() -> Client { - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); // What the dispatchers and the cursor read that a headless build leaves to its plugins. diff --git a/crates/benilla-app/src/target/died.rs b/crates/benilla-app/src/target/died.rs index a0e20b465..295393688 100644 --- a/crates/benilla-app/src/target/died.rs +++ b/crates/benilla-app/src/target/died.rs @@ -406,7 +406,7 @@ mod tests { /// The whole client headless, our player and two live mobs streamed, the write channel /// captured. fn client() -> Client { - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, sent) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); let world = app.world_mut(); diff --git a/crates/benilla-app/src/target/flash.rs b/crates/benilla-app/src/target/flash.rs index 44257185b..6a5eac049 100644 --- a/crates/benilla-app/src/target/flash.rs +++ b/crates/benilla-app/src/target/flash.rs @@ -133,7 +133,7 @@ mod tests { const ME: u64 = 0x10; const MOB: u64 = 0xF130_0000_0000_0042; - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let world = app.world_mut(); world.resource_mut::().0 = Some(ME); let unit = |kind, extra: &[(u16, u32)]| { diff --git a/crates/benilla-app/src/target/front_tests.rs b/crates/benilla-app/src/target/front_tests.rs index 420e00c6c..092b94ab1 100644 --- a/crates/benilla-app/src/target/front_tests.rs +++ b/crates/benilla-app/src/target/front_tests.rs @@ -28,7 +28,7 @@ pub(super) struct Fix { /// The whole client, headless, with us, `OLD` selected and `MOB` beside; `charmed` has someone /// charm us (`UNIT_FIELD_CHARMEDBY`, a guid of two fields). pub(super) fn fix(charmed: bool) -> Fix { - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); let world = app.world_mut(); diff --git a/crates/benilla-app/src/target/ring.rs b/crates/benilla-app/src/target/ring.rs index 48aad5b61..bdcd023df 100644 --- a/crates/benilla-app/src/target/ring.rs +++ b/crates/benilla-app/src/target/ring.rs @@ -769,7 +769,7 @@ mod tests { /// The built client with our avatar and one selected live mob, the write channel captured. fn client_with_selected_mob() -> (App, Entity, Receiver) { - let mut app = crate::game_plugins::schedule_tests::headless_client(); + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); let world = app.world_mut(); diff --git a/crates/benilla-app/src/ui_dressup.rs b/crates/benilla-app/src/ui_dressup.rs index 79bcb1412..1eb15195a 100644 --- a/crates/benilla-app/src/ui_dressup.rs +++ b/crates/benilla-app/src/ui_dressup.rs @@ -1,7 +1,8 @@ -//! The dressing-room feed: applies the stock window's `DressUpModel` verbs ([`DressUpIntent`]) in -//! order, since `DressUpItem` can reset and try on in one call, and composes the booth's look +//! The dressing-room drain: applies the stock window's `DressUpModel` verbs ([`DressUpIntent`]) in +//! call order, since `DressUpItem` can reset and try on in one call, and composes the booth's look //! ([`DressUpPreview`]): the player's own gear with each substitution in its [`equip_slot`], the -//! held triple through [`held_lanes`]. A tried-on item waits on its template ([`Items::template`]). +//! held triple through [`held_lanes`]. A try-on whose template ([`Items::template`]) is cached +//! lands in its place in that order; one that is not lands when the record arrives. //! There is no fit check: the reference previews whatever it is handed (`DressUpFrame.lua:2-16`). use benilla_protocol::CharEnumItem; @@ -13,7 +14,7 @@ use crate::items::Items; use crate::net::{NetCommands, NetEntity, ObjectStore, SelfPlayer}; use crate::portrait::{DressUpLook, DressUpPreview}; use crate::query_cache::Ask; -use crate::ui_script::UiFeed; +use crate::ui_script::UiDrain; /// Every rendered equipment slot, the set [`equip_slot`] can map an item into. const LOOK_SLOTS: [u8; 14] = [0, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 16, 17, 18]; @@ -117,23 +118,22 @@ pub(crate) struct DressUpRoom { /// Held try-ons in landing order, latest last: each picks its lane from the lanes the earlier /// ones left ([`held_lanes`]). held: Vec, - /// Item ids still waiting on their template, read again every frame. + /// Try-ons whose record the cache did not hold at the call, each with the callback `0x504ba0` + /// registered (`0x504579`), read again every frame until the record lands. pending: Vec, - /// How many of `pending`, from the front, a resolve has looked up: the rest were tried on - /// since, each a lookup with the callback `0x504ba0` (`0x504579`). - pending_asked: usize, } impl DressUpRoom { - fn apply(&mut self, intent: DressUpIntent) { + /// One verb, as its call does it. Returns 1 for a try-on that raised `ERR_NOT_EQUIPPABLE`. + fn apply(&mut self, intent: DressUpIntent, items: &Items, commands: &NetCommands) -> u32 { match intent { + // The rebuild (`0x504cd0` → `0x505b50` → `0x5059a0`) cancels no cache callback: a + // try-on still waiting lands on the rebuilt model. DressUpIntent::Dress => { self.open = true; self.bare = false; self.worn = Default::default(); self.held.clear(); - self.pending.clear(); - self.pending_asked = 0; } DressUpIntent::Undress => { self.open = true; @@ -142,9 +142,14 @@ impl DressUpRoom { self.worn = Default::default(); self.held.clear(); } + // The lookup with the callback (`0x504579`): a cached record goes on inside the call + // (`0x5045dd`-`0x5045ee`), a missing one waits for `0x504ba0`. DressUpIntent::TryOn(item) => { self.open = true; - self.pending.push(item); + match items.template(item, 0, Ask::Event(commands)) { + Some(t) => return self.land(t), + None => self.pending.push(item), + } } DressUpIntent::Close => { self.open = false; @@ -152,53 +157,67 @@ impl DressUpRoom { self.worn = Default::default(); self.held.clear(); self.pending.clear(); - self.pending_asked = 0; } } + 0 } - /// An id the server never answers stays pending, and its slot shows the player's own gear. A - /// try-on's first resolve is its lookup; the later ones read. Returns how many resolved items - /// are not equippable, each an `ERR_NOT_EQUIPPABLE` to show ([`not_equippable`]). - fn resolve_pending(&mut self, items: &Items, commands: &NetCommands) -> u32 { - let mut pending = std::mem::take(&mut self.pending); - let asked = std::mem::take(&mut self.pending_asked); - let mut i = 0; - let mut refused = 0; - pending.retain(|item| { - let ask = Ask::at(i >= asked, commands); - i += 1; - let Some(t) = items.template(*item, 0, ask) else { - return true; // in flight - }; - // A record with no display does nothing (`0x50458b`-`0x504590`). - if t.display_info_id == 0 { - return false; - } - if not_equippable(t.inventory_type) { - refused += 1; - return false; - } - // Neck, finger, trinket and relic preview nothing (the map's 1 at `0x504b37`). - if matches!(t.inventory_type, 2 | 11 | 12 | 28) { - return false; - } - let item = CharEnumItem { - display_id: t.display_info_id, - inventory_type: t.inventory_type as u8, - }; - match equip_slot(item.inventory_type) { - // The held arms pick their lane as the look is composed. - Some(slot) if HELD_SLOTS.contains(&slot) => self.held.push(item), - Some(slot) => self.worn[slot] = Some(item), - None => {} - } - false - }); - self.pending = pending; - self.pending_asked = self.pending.len(); + /// One frame: the records that landed for the try-ons still waiting, whose callbacks + /// (`0x504ba0`) ran as each arrived, ahead of the verbs called since; then those verbs in call + /// order. Returns the `ERR_NOT_EQUIPPABLE` count to show. + fn step( + &mut self, + intents: impl IntoIterator, + items: &Items, + commands: &NetCommands, + ) -> u32 { + let mut refused = self.resolve_pending(items); + for intent in intents { + refused += self.apply(intent, items, commands); + } refused } + + /// The try-ons whose record has landed, in try-on order; an id the server never answers stays + /// pending, and its slot shows the player's own gear. Returns the refusal count, as + /// [`Self::land`]. + fn resolve_pending(&mut self, items: &Items) -> u32 { + let mut refused = 0; + for item in std::mem::take(&mut self.pending) { + match items.template(item, 0, Ask::Read) { + Some(t) => refused += self.land(t), + None => self.pending.push(item), + } + } + refused + } + + /// `0x504540` with the record in hand, by its inventory type. Returns 1 when it is not + /// equippable, an `ERR_NOT_EQUIPPABLE` to show ([`not_equippable`]). + fn land(&mut self, t: &benilla_protocol::ItemInfo) -> u32 { + // A record with no display does nothing (`0x50458b`-`0x504590`). + if t.display_info_id == 0 { + return 0; + } + if not_equippable(t.inventory_type) { + return 1; + } + // Neck, finger, trinket and relic preview nothing (the map's 1 at `0x504b37`). + if matches!(t.inventory_type, 2 | 11 | 12 | 28) { + return 0; + } + let item = CharEnumItem { + display_id: t.display_info_id, + inventory_type: t.inventory_type as u8, + }; + match equip_slot(item.inventory_type) { + // The held arms pick their lane as the look is composed. + Some(slot) if HELD_SLOTS.contains(&slot) => self.held.push(item), + Some(slot) => self.worn[slot] = Some(item), + None => {} + } + 0 + } } /// `TryOn`'s dispatch, `dec eax; cmp eax,0x1b; ja` then the byte map at `0x504b7c` @@ -212,12 +231,14 @@ pub(crate) struct DressUpUiPlugin; impl Plugin for DressUpUiPlugin { fn build(&self, app: &mut App) { + // A drain, the frame its verbs are called: a try-on's cache lookup reads the cache as it + // stood at the call, before the next frame's packets land. app.init_resource::() - .add_systems(Update, feed_dressup.in_set(UiFeed)); + .add_systems(Update, drain_dressup.in_set(UiDrain)); } } -fn feed_dressup( +fn drain_dressup( mut hosted_vm: crate::script_calls::HostedVm, mut room: ResMut, mut preview: ResMut, @@ -237,22 +258,18 @@ fn feed_dressup( let Some(script) = script else { return; }; - for intent in script.take_dressup_intents() { - room.apply(intent); - } + let mut intents = script.take_dressup_intents(); // The room empties when neither the dressing room nor the auction house's // `AuctionDressUpFrame` is shown, read off the frames (the stock files carry no hook of - // ours). The reference's widget keeps its state while hidden, but `DressUpItem` re-issues - // `SetUnit("player")` on a hidden frame, so the kept state is never seen. - if room.open - && !script.frame_visible("DressUpFrame") - && !script.frame_visible("AuctionDressUpFrame") - { - room.apply(DressUpIntent::Close); + // ours), and a verb on a hidden pane does nothing (`0x504596`). The reference's widget + // keeps its state while hidden, but `DressUpItem` re-issues `SetUnit("player")` on a + // hidden frame, so the kept state is never seen. + if !script.frame_visible("DressUpFrame") && !script.frame_visible("AuctionDressUpFrame") { + intents = vec![DressUpIntent::Close]; } - // The refusal shows as its record lands: at once when cached, else from the cache's + // A refusal shows as its record lands: in the call when cached, else from the cache's // callback (`0x504ba0`). - let refused = room.resolve_pending(&items, &commands); + let refused = room.step(intents, &items, &commands); let lines = (0..refused) .filter_map(|_| crate::ui_action::keyed_line(script, "ERR_NOT_EQUIPPABLE")) .collect::>(); @@ -386,20 +403,20 @@ mod tests { let (cmds, rx) = commands(); let mut items = Items::default(); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); assert_eq!(crate::query_cache::queries(&rx).len(), 1); items.insert_template(2000, None); for _ in 0..600 { - room.resolve_pending(&items, &cmds); + room.resolve_pending(&items); } assert!( crate::query_cache::queries(&rx).is_empty(), "no frame asks a refusal" ); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); assert_eq!( crate::query_cache::queries(&rx).len(), 1, @@ -419,9 +436,9 @@ mod tests { let store = player(&[(4, 1000), (15, 1500)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); let look = player_look(&store, &net(), &room, &items, true).expect("a look"); assert_eq!( @@ -448,8 +465,8 @@ mod tests { let store = player(&[(4, 1000)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); assert_eq!( player_look(&store, &net(), &room, &items, true) .unwrap() @@ -458,7 +475,7 @@ mod tests { 7000 ); - room.apply(DressUpIntent::Dress); + room.apply(DressUpIntent::Dress, &items, &cmds); assert_eq!( player_look(&store, &net(), &room, &items, true) .unwrap() @@ -468,7 +485,7 @@ mod tests { "Reset puts the player's own chest back on" ); - room.apply(DressUpIntent::Close); + room.apply(DressUpIntent::Close, &items, &cmds); assert!(!room.open, "closing empties the room (the booth goes dark)"); } @@ -499,10 +516,10 @@ mod tests { let store = player(&[(4, 1000), (15, 3000), (0, 4000)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); - room.apply(DressUpIntent::Undress); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); + room.apply(DressUpIntent::Undress, &items, &cmds); let look = player_look(&store, &net(), &room, &items, true).unwrap(); assert_eq!(look.equipment[4].display_id, 0, "the tried-on chest is off"); assert_eq!(look.equipment[0].display_id, 0, "the worn helm is off"); @@ -511,8 +528,8 @@ mod tests { "the sword is detached from the hand (0x5044ec)" ); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); let look = player_look(&store, &net(), &room, &items, true).unwrap(); assert_eq!( look.equipment[4].display_id, 7000, @@ -523,7 +540,7 @@ mod tests { "…which stays bare elsewhere" ); - room.apply(DressUpIntent::Dress); + room.apply(DressUpIntent::Dress, &items, &cmds); let look = player_look(&store, &net(), &room, &items, true).unwrap(); assert_eq!( look.equipment[4].display_id, 5000, @@ -547,14 +564,14 @@ mod tests { let store = player(&[(15, 1500), (16, 1600)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2500)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2500), &items, &cmds); + room.resolve_pending(&items); assert_eq!( look_dw(&store, &room, &items, true).equipment[17].display_id, 8500 ); - room.apply(DressUpIntent::Undress); + room.apply(DressUpIntent::Undress, &items, &cmds); let look = look_dw(&store, &room, &items, true); assert_eq!(hands(&look), (0, 0), "the player's own pair is off"); assert_eq!( @@ -563,11 +580,11 @@ mod tests { ); // Both lane records are zero, so a one-hander takes the main lane (`0x504935`). - room.apply(DressUpIntent::TryOn(2600)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::TryOn(2600), &items, &cmds); + room.resolve_pending(&items); assert_eq!(hands(&look_dw(&store, &room, &items, true)), (8600, 0)); - room.apply(DressUpIntent::Dress); + room.apply(DressUpIntent::Dress, &items, &cmds); assert_eq!( hands(&look_dw(&store, &room, &items, true)), (5500, 5600), @@ -589,9 +606,9 @@ mod tests { let tried = |held: &[(u8, u32)], dw: bool| { let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); hands(&look_dw(&player(held), &room, &items, dw)) }; assert_eq!(tried(&[(15, 1500)], true), (5500, 7000), "beside a 13"); @@ -618,11 +635,11 @@ mod tests { items.insert_template(2000, Some(worn("Sword A", 7000, 13))); items.insert_template(2001, Some(worn("Sword B", 7001, 13))); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); - room.apply(DressUpIntent::TryOn(2001)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); + room.apply(DressUpIntent::TryOn(2001), &items, &cmds); + room.resolve_pending(&items); assert_eq!( hands(&look_dw(&player(&[]), &room, &items, true)), (7000, 7001) @@ -643,9 +660,9 @@ mod tests { items.insert_template(1500, Some(worn("Worn Sword", 5500, 13))); items.insert_template(2000, Some(worn("Left Dagger", 7000, 22))); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); let store = player(&[(15, 1500)]); assert_eq!(hands(&look_dw(&store, &room, &items, true)), (5500, 7000)); assert_eq!(hands(&look_dw(&store, &room, &items, false)), (0, 7000)); @@ -663,11 +680,11 @@ mod tests { items.insert_template(2600, Some(worn("Dagger", 8600, 13))); let store = player(&[(15, 1500)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2500)); - room.resolve_pending(&items, &cmds); - room.apply(DressUpIntent::TryOn(2600)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2500), &items, &cmds); + room.resolve_pending(&items); + room.apply(DressUpIntent::TryOn(2600), &items, &cmds); + room.resolve_pending(&items); let look = look_dw(&store, &room, &items, true); assert_eq!( hands(&look), @@ -680,6 +697,84 @@ mod tests { ); } + /// `TryOn` of a cached record sets its slot inside the call (`0x504579` returns the record, + /// `0x5045dd`-`0x5045ee` set the component), so an `Undress` after it in the same chunk + /// (`0x504c00` → `0x504490`) takes it off again. + #[test] + fn a_cached_try_on_then_undress_in_one_chunk_leaves_the_body_bare() { + let (cmds, _rx) = commands(); + let mut items = Items::default(); + items.insert_template(2000, Some(worn("Shiny Chest", 7000, 5))); + items.insert_template(2500, Some(worn("Short Bow", 8500, 15))); + let store = player(&[]); + let mut room = DressUpRoom::default(); + room.step( + [ + DressUpIntent::Dress, + DressUpIntent::TryOn(2000), + DressUpIntent::TryOn(2500), + DressUpIntent::Undress, + ], + &items, + &cmds, + ); + let look = look_dw(&store, &room, &items, true); + assert_eq!(look.equipment[4].display_id, 0, "the chest came off again"); + assert_eq!(look.equipment[17].display_id, 0, "…and the bow (0x504530)"); + } + + /// The cache's callback `0x504ba0` lands a record when it arrives, ahead of the script calls + /// made after it: an `Undress` made once the record is in clears it. + #[test] + fn a_record_that_arrived_before_an_undress_is_cleared_by_it() { + let (cmds, _rx) = commands(); + let mut items = Items::default(); + let store = player(&[]); + let mut room = DressUpRoom::default(); + room.step( + [DressUpIntent::Dress, DressUpIntent::TryOn(2000)], + &items, + &cmds, + ); + items.insert_template(2000, Some(worn("Shiny Chest", 7000, 5))); + room.step([DressUpIntent::Undress], &items, &cmds); + assert_eq!( + look_dw(&store, &room, &items, true).equipment[4].display_id, + 0, + "the chest landed, then came off" + ); + } + + /// `Dress` (`0x504cd0` → `0x505b50`) rebuilds the model and cancels no cache callback: a + /// try-on still waiting for its record lands on the rebuilt model when it arrives + /// (`0x504ba0` → `0x504540`). + #[test] + fn a_try_on_waiting_for_its_record_survives_dress_and_lands_after_it() { + let (cmds, _rx) = commands(); + let mut items = Items::default(); + items.insert_template(1000, Some(worn("Worn Chest", 5000, 5))); + let store = player(&[(4, 1000)]); + let mut room = DressUpRoom::default(); + room.step( + [DressUpIntent::Dress, DressUpIntent::TryOn(2000)], + &items, + &cmds, + ); + room.step([DressUpIntent::Dress], &items, &cmds); + assert_eq!( + look_dw(&store, &room, &items, true).equipment[4].display_id, + 5000, + "the player's own chest until the record comes" + ); + items.insert_template(2000, Some(worn("Shiny Chest", 7000, 5))); + room.step([], &items, &cmds); + assert_eq!( + look_dw(&store, &room, &items, true).equipment[4].display_id, + 7000, + "the record lands on the rebuilt model" + ); + } + /// A chat-linked item usually misses the cache on the first click; the ask goes out once. #[test] fn an_unknown_item_waits_for_its_template_then_lands() { @@ -689,8 +784,8 @@ mod tests { let store = player(&[(4, 1000)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); assert_eq!(room.pending, vec![2000], "still waiting on the answer"); assert_eq!( player_look(&store, &net(), &room, &items, true) @@ -707,7 +802,7 @@ mod tests { assert_eq!(asks, 1); items.insert_template(2000, Some(worn("Shiny Chest", 7000, 5))); - room.resolve_pending(&items, &cmds); + room.resolve_pending(&items); assert!(room.pending.is_empty()); assert_eq!( player_look(&store, &net(), &room, &items, true) @@ -725,8 +820,8 @@ mod tests { let mut items = Items::default(); items.insert_template(3000, Some(worn("Healing Potion", 9000, 0))); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::TryOn(3000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::TryOn(3000), &items, &cmds); + room.resolve_pending(&items); assert!(room.pending.is_empty(), "resolved, just not worn anywhere"); assert!(room.worn.iter().all(Option::is_none)); } @@ -743,10 +838,16 @@ mod tests { items.insert_template(2100, Some(worn("Invisible Chest", 0, 5))); let store = player(&[(15, 1500), (16, 1600)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2000)); - room.apply(DressUpIntent::TryOn(2100)); - assert_eq!(room.resolve_pending(&items, &cmds), 0, "and no error"); + let refused = room.step( + [ + DressUpIntent::Dress, + DressUpIntent::TryOn(2000), + DressUpIntent::TryOn(2100), + ], + &items, + &cmds, + ); + assert_eq!(refused, 0, "and no error"); assert!(room.pending.is_empty(), "resolved"); let look = look_dw(&store, &room, &items, true); assert_eq!(hands(&look), (5500, 5600), "the two-hander evicted nothing"); @@ -765,9 +866,11 @@ mod tests { } let refused = |t: u32| { let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(3000 + t)); - let n = room.resolve_pending(&items, &cmds); + let n = room.step( + [DressUpIntent::Dress, DressUpIntent::TryOn(3000 + t)], + &items, + &cmds, + ); assert!(room.worn.iter().all(Option::is_none) && room.held.is_empty()); n }; @@ -792,9 +895,9 @@ mod tests { for (item, display, what) in [(2500u32, 8500u32, "bow"), (2600, 8600, "crossbow")] { let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(item)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(item), &items, &cmds); + room.resolve_pending(&items); let look = player_look(&store, &net(), &room, &items, true).expect("a look"); assert_eq!( @@ -812,13 +915,14 @@ mod tests { /// The booth is melee-drawn ([`held_lanes`]); a ranged weapon shows only while ranged-drawn. #[test] fn a_worn_ranged_weapon_is_not_shown_until_it_is_tried_on() { + let (cmds, _rx) = commands(); let mut items = Items::default(); items.insert_template(1500, Some(worn("Worn Sword", 5500, 21))); items.insert_template(1700, Some(worn("Worn Bow", 5700, 15))); let store = player(&[(15, 1500), (17, 1700)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); + room.apply(DressUpIntent::Dress, &items, &cmds); let look = player_look(&store, &net(), &room, &items, true).expect("a look"); assert_eq!( look.equipment[17].display_id, 0, @@ -837,11 +941,11 @@ mod tests { let store = player(&[]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2500)); - room.resolve_pending(&items, &cmds); - room.apply(DressUpIntent::TryOn(2700)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2500), &items, &cmds); + room.resolve_pending(&items); + room.apply(DressUpIntent::TryOn(2700), &items, &cmds); + room.resolve_pending(&items); let look = player_look(&store, &net(), &room, &items, true).expect("a look"); assert_eq!(look.equipment[15].display_id, 8700, "the axe is in hand"); @@ -857,9 +961,9 @@ mod tests { items.insert_template(1600, Some(worn("Bright Shield", 5600, 14))); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(1600)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(1600), &items, &cmds); + room.resolve_pending(&items); let look = player_look(&player(&[(15, 1500)]), &net(), &room, &items, true).unwrap(); assert_eq!(look.equipment[15].display_id, 5500, "the one-hander stays"); @@ -892,9 +996,9 @@ mod tests { let store = player(&[(15, 1500), (16, entry)]); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::Dress, &items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); let look = player_look(&store, &net(), &room, &items, true).expect("a look"); assert_eq!(look.equipment[7].display_id, 7000, "the boots went on"); @@ -923,7 +1027,7 @@ mod tests { .merge(ObjectFields::from_pairs(&[(190u16, 0x400 | 0x800)])); let mut room = DressUpRoom::default(); - room.apply(DressUpIntent::Dress); + room.apply(DressUpIntent::Dress, &items, &cmds); let look = player_look(&store, &net(), &room, &items, true).expect("a look"); assert_eq!( look.equipment[0].display_id, 0, @@ -936,8 +1040,8 @@ mod tests { ); // The reference's `TryOn` path never tests the flag. - room.apply(DressUpIntent::TryOn(2000)); - room.resolve_pending(&items, &cmds); + room.apply(DressUpIntent::TryOn(2000), &items, &cmds); + room.resolve_pending(&items); let look = player_look(&store, &net(), &room, &items, true).expect("a look"); assert_eq!( look.equipment[0].display_id, 7000, @@ -945,7 +1049,7 @@ mod tests { ); // Reset drops the substitution: bare-headed again. - room.apply(DressUpIntent::Dress); + room.apply(DressUpIntent::Dress, &items, &cmds); let look = player_look(&store, &net(), &room, &items, true).expect("a look"); assert_eq!( look.equipment[0].display_id, 0, diff --git a/crates/benilla-app/src/ui_script/help_frame_tests.rs b/crates/benilla-app/src/ui_script/help_frame_tests.rs index 3c7c9301b..910434298 100644 --- a/crates/benilla-app/src/ui_script/help_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/help_frame_tests.rs @@ -57,6 +57,14 @@ fn setup() -> UiScript { (9, "Account/Billing".into()), (10, "Character".into()), ]); + // In the world: `GetGMTicket` sends only with an active player (`0x48c9bd`). + s.set_unit( + "player", + Some(benilla_ui::script::UnitState { + exists: true, + ..Default::default() + }), + ); s } diff --git a/crates/benilla-ui/src/script/gm_ticket.rs b/crates/benilla-ui/src/script/gm_ticket.rs index 64115221e..b766f4681 100644 --- a/crates/benilla-ui/src/script/gm_ticket.rs +++ b/crates/benilla-ui/src/script/gm_ticket.rs @@ -70,14 +70,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // Sends: `GetGMTicket` is answered by `UPDATE_TICKET`, `GetGMStatus` by `UPDATE_GM_STATUS`. - for (name, intent) in [ - ("GetGMTicket", GmTicketIntent::Ask), - ("GetGMStatus", GmTicketIntent::AskStatus), - ("DeleteGMTicket", GmTicketIntent::Delete), + // With no active player `GetGMTicket` and `DeleteGMTicket` call nothing (`0x48c9bd`, + // `0x48cb8d`); `GetGMStatus` sends regardless (`0x48cfc0`). + for (name, intent, needs_player) in [ + ("GetGMTicket", GmTicketIntent::Ask, true), + ("GetGMStatus", GmTicketIntent::AskStatus, false), + ("DeleteGMTicket", GmTicketIntent::Delete, true), ] { g.set( name, lua.create_function(move |lua, ()| { + if needs_player + && !super::battlefield_queue::player_exists( + &lua.app_data_ref::().expect("model app_data"), + ) + { + return Ok(()); + } lua.app_data_mut::() .expect("model app_data") .gm_ticket_intents diff --git a/crates/benilla-ui/src/script/tests/gm_ticket.rs b/crates/benilla-ui/src/script/tests/gm_ticket.rs index 95c56908f..9d8a224c9 100644 --- a/crates/benilla-ui/src/script/tests/gm_ticket.rs +++ b/crates/benilla-ui/src/script/tests/gm_ticket.rs @@ -72,7 +72,7 @@ fn an_absent_catalog_returns_nothing_rather_than_erroring() { /// Two `GetGMTicket()` calls are two packets: `TicketStatus_OnUpdate` re-polls every 10 minutes. #[test] fn the_payload_free_verbs_queue_rather_than_latch() { - let mut s = script(); + let mut s = in_world(); s.run("GetGMTicket() GetGMTicket() GetGMStatus() DeleteGMTicket() Stuck() Stuck() Stuck()") .unwrap(); assert_eq!( @@ -178,3 +178,18 @@ fn a_ticket_write_with_no_player_does_nothing() { ); } } + +/// With no active player `GetGMTicket` and `DeleteGMTicket` send nothing: each finds the player +/// (`0x468550`, then `0x468460` with mask 0x10) and skips the sender on null (`0x48c9bd`, +/// `0x48cb8d`). `GetGMStatus` has no such gate (`0x48cfc0` builds message 0x21a straight away). +#[test] +fn ticket_get_and_delete_with_no_player_send_nothing() { + let mut s = script(); + s.run("GetGMTicket() DeleteGMTicket() GetGMStatus()") + .unwrap(); + assert_eq!( + s.take_gm_ticket_intents(), + vec![GmTicketIntent::AskStatus], + "only the status ask goes out" + ); +} diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index e701636e2..5e74ee623 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -327,7 +327,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### ui_dressup -- The base is the player's melee pair, held drawn, because the booth shares the select screen's assembly; the reference clones the live model with its attachments, drawn or sheathed (`0x5059a0`, `0x70ea00`). ([`src/ui_dressup.rs:42`](../crates/benilla-app/src/ui_dressup.rs#L42)) +- The base is the player's melee pair, held drawn, because the booth shares the select screen's assembly; the reference clones the live model with its attachments, drawn or sheathed (`0x5059a0`, `0x70ea00`). ([`src/ui_dressup.rs:43`](../crates/benilla-app/src/ui_dressup.rs#L43)) #### ui_duel