From cf1aa41e3d7fee605d7f9b79014cbe6f736f619f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:46:23 +0700 Subject: [PATCH] NPC greetings from the latch; death and shapeshift close the window (#1320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the interaction latch: - The NPC's hello and goodbye come from the latch: a window registering for a different non-player unit plays the hello (0x49318f), a release to nothing plays the goodbye (0x493210), and a swap or a walk-away (0x493305) plays none. Every window greets alike; a game object or a player says nothing. - Death and shapeshift close the window: the player's death releases with the goodbye (0x5e9a51), the latched unit's death releases without it (0x605950), and a shift into a form where IsShapeShifted holds and the row lacks flag 0x8 releases (0x5de07f). Tests pin the hello and goodbye per window, the swap and walk-away, and each release, and fail on a goodbye where none plays. Fixes #1287 Fixes #1288 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/benilla-app/src/sound/greeting.rs | 192 +++++--------- crates/benilla-app/src/ui_session.rs | 11 +- crates/benilla-app/src/ui_session/latch.rs | 63 ++++- .../benilla-app/src/ui_session/latch_tests.rs | 81 +++++- crates/benilla-app/src/ui_session/release.rs | 68 +++++ .../src/ui_session/release_tests.rs | 237 ++++++++++++++++++ crates/benilla-formats/src/spells/forms.rs | 7 + 7 files changed, 514 insertions(+), 145 deletions(-) create mode 100644 crates/benilla-app/src/ui_session/release.rs create mode 100644 crates/benilla-app/src/ui_session/release_tests.rs diff --git a/crates/benilla-app/src/sound/greeting.rs b/crates/benilla-app/src/sound/greeting.rs index 11101fef2..4ff00bf0c 100644 --- a/crates/benilla-app/src/sound/greeting.rs +++ b/crates/benilla-app/src/sound/greeting.rs @@ -22,12 +22,15 @@ //! //! ## The window hello and goodbye (`0x60c3b0`, right-click) //! -//! Opening any interaction window (`0x4930d0`, 14 call sites, none a mouseover) plays the NPC's -//! hello once with a free-picked take (`0x60c464`), the same `+0x4` slot. Closing to nothing plays -//! the goodbye (`+0x8`). A swap to another NPC's window plays the new hello and suppresses the old -//! goodbye (`0x4930fd`: `dl = (new_guid != 0)`). The active NPC is the union of the window sessions -//! ([`MerchantOpen`], [`GossipState`], [`QuestGiver`], [`TrainerOpen`]). Every such call also zeroes -//! the select greeting's tracked NPC (`0x60c3d1`), so the next left-click starts its sequence over. +//! The interaction latch speaks them ([`LatchVocal`]), not the windows: registering on it +//! (`0x4930d0`, 14 call sites, none a mouseover) for a different unit plays the NPC's hello once +//! with a free-picked take (`0x60c464`), the same `+0x4` slot, and releasing it to nothing +//! through `0x4931c0` plays the goodbye (`+0x8`), so every window on the latch greets alike. A +//! swap to another NPC's window plays the new hello and no goodbye for the old (`0x4930fd`: +//! `dl = (new_guid != 0)`), and a walk-away close (`0x493305`) plays none. The gate is a +//! non-player unit (`0x493159`, `0x493182`, `0x4931f8`-`0x493203`): a game object (a mailbox) +//! or a player speaks nothing. Every such call also zeroes the select greeting's tracked NPC +//! (`0x60c3d1`), so the next left-click starts its sequence over. use bevy::prelude::*; @@ -36,10 +39,7 @@ use benilla_protocol::EntityKind; use crate::net::{ActiveMover, GuidIndex, NetEntity, ObjectStore, Reputations, SelfPlayer}; use crate::target::{both_reactions_at_least_neutral, Factions}; -use crate::ui_gossip::GossipState; -use crate::ui_merchant::MerchantOpen; -use crate::ui_quest::QuestGiver; -use crate::ui_trainer::TrainerOpen; +use crate::ui_session::LatchVocal; use benilla_assets::{AssetSet, LockRecover, WorldAssets}; use benilla_world::schedule::WorldStage; use benilla_world::view::WorldCamera; @@ -93,40 +93,6 @@ fn greet_line(seq: usize, pissed_variations: usize) -> (GreetLine, usize) { } } -/// The vocal an active-NPC change produces (`SetActiveNPC`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WindowVocal { - Hello(u64), - Goodbye(u64), - None, -} - -/// The active interaction NPC, the union of the window sessions. Every NPC-bound window must be -/// in it: one left out makes a swap to it look like a close and plays a stray goodbye. -fn active_interaction_npc( - merchant: &MerchantOpen, - gossip: &GossipState, - quest: &QuestGiver, - trainer: &TrainerOpen, -) -> Option { - merchant - .vendor - .or(gossip.npc) - .or(quest.npc) - .or(trainer.trainer) -} - -/// Open or swap plays the new NPC's hello with no goodbye for the old (`dl = new_guid != 0`); -/// closing to nothing plays the goodbye. -fn window_vocal(prev: Option, active: Option) -> WindowVocal { - match (prev, active) { - (p, a) if p == a => WindowVocal::None, - (_, Some(new)) => WindowVocal::Hello(new), - (Some(old), None) => WindowVocal::Goodbye(old), - (None, None) => WindowVocal::None, - } -} - fn load_npc_greetings(mut commands: Commands, assets: Option>) { let Some(assets) = assets else { return }; let loaded = { @@ -142,6 +108,12 @@ fn load_npc_greetings(mut commands: Commands, assets: Option>) } } +/// Whether an object greets: a non-player unit (`0x493159`, `0x493182`, `0x4931f8`-`0x493203`). +/// A player, a game object such as a mailbox and a book have no NPC to speak. +fn speaks(kind: EntityKind) -> bool { + matches!(kind, EntityKind::Unit) +} + /// A live non-player unit (type-mask bit 3 set, bit 4 clear) whose display has an NPCSoundID row. fn resolve( npc: Entity, @@ -149,7 +121,7 @@ fn resolve( greetings: &NpcGreetings, ) -> Option<(benilla_formats::NpcGreeting, Vec3)> { let (net, transform, store) = units.get(npc).ok()?; - if !matches!(net.kind, EntityKind::Unit) { + if !speaks(net.kind) { return None; } if store.is_some_and(|s| s.0.unit_is_dead()) { @@ -269,7 +241,7 @@ fn play_select_greetings( let Ok((net, transform, npc_store)) = units.get(req.npc) else { continue; }; - if !matches!(net.kind, EntityKind::Unit) { + if !speaks(net.kind) { continue; } let allowed = select_greeting_allowed( @@ -312,14 +284,10 @@ fn play_select_greetings( } } -/// The window hello and goodbye, from the active NPC's change. +/// The window hello and goodbye, from the latch's lines. fn play_window_greetings( - merchant: Res, - gossip: Res, - quest: Res, - trainer: Res, + mut lines: MessageReader, index: Res, - mut prev_active: Local>, mut select_seq: ResMut, units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>, self_store: Query<&ObjectStore, With>, @@ -330,46 +298,41 @@ fn play_window_greetings( config: Res, cam: Query<&Transform, (With, Without)>, ) { - let active = active_interaction_npc(&merchant, &gossip, &quest, &trainer); - let vocal = window_vocal(*prev_active, active); - if matches!(vocal, WindowVocal::None) { + if lines.is_empty() { return; } - *prev_active = active; - let (Some(greetings), Some(mut kits), Some(assets)) = (greetings, kits, assets) else { + lines.clear(); return; }; let listener = cam.single().map(|t| t.translation).unwrap_or(Vec3::ZERO); - // A despawned NPC resolves nothing: the reference's teardown clear is silent (`0x605950`). - let (guid, hello) = match vocal { - WindowVocal::Hello(g) => (g, true), - WindowVocal::Goodbye(g) => (g, false), - WindowVocal::None => return, - }; - let Some(&npc) = index.0.get(&guid) else { - return; - }; - // The call zeroes the select greeting's tracked NPC for any unit, before the gates below. - if units - .get(npc) - .is_ok_and(|(net, ..)| matches!(net.kind, EntityKind::Unit)) - { - forget_tracked(&mut select_seq); + for &line in lines.read() { + let (guid, hello) = match line { + LatchVocal::Hello(g) => (g, true), + LatchVocal::Goodbye(g) => (g, false), + }; + // A despawned NPC resolves nothing: the reference's teardown clear is silent (`0x605950`). + let Some(&npc) = index.0.get(&guid) else { + continue; + }; + // The call zeroes the select greeting's tracked NPC for any unit, before the gates below. + if units.get(npc).is_ok_and(|(net, ..)| speaks(net.kind)) { + forget_tracked(&mut select_seq); + } + // No reaction gate here (`0x60c3b0`), only the player's: dead or a ghost hears nothing. + if !player_may_hear(self_store.single().ok()) { + continue; + } + let Some((row, pos)) = resolve(npc, &units, &greetings) else { + continue; + }; + let kit_id = if hello { row.hello } else { row.goodbye }; + // Both window lines free-pick their take (`variation = -1`). + play_line( + npc, kit_id, None, pos, listener, &mut kits, &assets, &mut out, &config, + ); } - // No reaction gate here (`0x60c3b0`), only the player's: dead or a ghost hears nothing. - if !player_may_hear(self_store.single().ok()) { - return; - } - let Some((row, pos)) = resolve(npc, &units, &greetings) else { - return; - }; - let kit_id = if hello { row.hello } else { row.goodbye }; - // Both window lines free-pick their take (`variation = -1`). - play_line( - npc, kit_id, None, pos, listener, &mut kits, &assets, &mut out, &config, - ); } /// A despawning unit stops its greeting (`0x5fbb6c`). @@ -413,52 +376,6 @@ mod tests { assert_eq!(greet_line(5, 0), (GreetLine::Hello, 0)); } - #[test] - fn window_vocal_opens_closes_and_suppresses_swap_goodbye() { - assert_eq!(window_vocal(None, Some(0x1)), WindowVocal::Hello(0x1)); // open - assert_eq!(window_vocal(Some(0x1), None), WindowVocal::Goodbye(0x1)); // close to nothing - assert_eq!(window_vocal(Some(0x1), Some(0x2)), WindowVocal::Hello(0x2)); // swap: no goodbye - assert_eq!(window_vocal(Some(0x1), Some(0x1)), WindowVocal::None); // no change - assert_eq!(window_vocal(None, None), WindowVocal::None); - } - - /// A gossip→trainer handoff on one NPC is a silent swap, not a close with a goodbye. - // The sessions have private fields, so they are built from `default()` and a field set. - #[test] - #[allow(clippy::field_reassign_with_default)] - fn gossip_to_trainer_on_one_npc_is_a_silent_swap() { - let npc = 0x1234_u64; - let (empty_merchant, empty_gossip, empty_quest, empty_trainer) = ( - MerchantOpen::default(), - GossipState::default(), - QuestGiver::default(), - TrainerOpen::default(), - ); - - assert_eq!( - active_interaction_npc(&empty_merchant, &empty_gossip, &empty_quest, &empty_trainer), - None, - ); - - // Frame 1: gossip open on `npc`. - let mut gossip = GossipState::default(); - gossip.npc = Some(npc); - let before = active_interaction_npc(&empty_merchant, &gossip, &empty_quest, &empty_trainer); - - // Frame 2: gossip closed, the trainer open on the same NPC. - let mut trainer = TrainerOpen::default(); - trainer.trainer = Some(npc); - let after = active_interaction_npc(&empty_merchant, &empty_gossip, &empty_quest, &trainer); - - assert_eq!(before, Some(npc), "gossip drove the active NPC"); - assert_eq!(after, Some(npc), "the trainer keeps the same NPC active"); - assert_eq!( - window_vocal(before, after), - WindowVocal::None, - "gossip→trainer on one NPC is a silent swap — no goodbye", - ); - } - use benilla_protocol::field::{ FIELD_PLAYER_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH, FIELD_UNIT_MAXHEALTH, @@ -563,6 +480,21 @@ mod tests { )); } + /// Only a non-player unit has a hello and a goodbye: a mailbox, a player and the rest do not. + #[test] + fn only_a_non_player_unit_speaks() { + assert!(speaks(EntityKind::Unit)); + for kind in [ + EntityKind::Player, + EntityKind::GameObject, + EntityKind::DynamicObject, + EntityKind::Corpse, + EntityKind::Other, + ] { + assert!(!speaks(kind), "{kind:?}"); + } + } + fn two_npcs() -> (Entity, Entity) { let mut world = World::new(); (world.spawn_empty().id(), world.spawn_empty().id()) diff --git a/crates/benilla-app/src/ui_session.rs b/crates/benilla-app/src/ui_session.rs index 37a4cc646..cb43e26de 100644 --- a/crates/benilla-app/src/ui_session.rs +++ b/crates/benilla-app/src/ui_session.rs @@ -17,10 +17,13 @@ use benilla_world::schedule::WorldStage; mod latch; #[cfg(test)] mod latch_tests; +mod release; +#[cfg(test)] +mod release_tests; pub(crate) use latch::{ dispatch, fire, note_closed, queue, register, release, with_script, CoreCloses, InteractLatch, - LatchApp, LatchedWindow, NpcWindow, + LatchApp, LatchVocal, LatchedWindow, NpcWindow, }; /// Owns [`InteractLatch`], its watchdog and [`InteractNpc`], read by the `"npc"` portrait and the @@ -30,6 +33,7 @@ pub(crate) struct UiSessionPlugin; impl Plugin for UiSessionPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .add_message::() .init_resource::() .net_handler( SessionEventKind::Disconnected, @@ -43,6 +47,11 @@ impl Plugin for UiSessionPlugin { ( // Before the feeds, so a walk-away's `*_CLOSED` lands the frame it happens. latch::watch_interaction_range.before(crate::ui_script::UiFeed), + // The unit events that release the latch, off the apply pass's field edges. + release::release_on_field_edges + .in_set(WorldStage::Net) + .after(crate::net::apply_net_updates) + .before(feed_interact_npc), // Inside the net chain, after the apply pass that opens and closes windows // and before the facing chain: a `MERCHANT_SHOW` handler reads // `UnitName("NPC")` the frame it opens. diff --git a/crates/benilla-app/src/ui_session/latch.rs b/crates/benilla-app/src/ui_session/latch.rs index 50dd30137..bc93a2ea2 100644 --- a/crates/benilla-app/src/ui_session/latch.rs +++ b/crates/benilla-app/src/ui_session/latch.rs @@ -18,6 +18,11 @@ //! `HideUIPanel` → `OnHide` does, re-enters that core in the reference before the fire returns; //! [`fire`] runs such a call before going on, as a core that fires before it clears its cell //! (trainer, bank, auction) is re-entered with the cell still set. +//! +//! The NPC's voice rides the same two calls ([`LatchVocal`]): `0x4930d0` greets a different +//! non-player unit it latches, and `0x4931c0` says goodbye to the unit it releases, unless its +//! caller's `dl` is set. A swap releases with `dl` set, and the watchdog and the leave-world sweep +//! call `0x493310` directly, so neither says goodbye. use bevy::prelude::*; @@ -79,6 +84,16 @@ impl NpcWindow { } } +/// What the latch asks the latched NPC to say. The unit's kind and state gate it where it is +/// played (`0x60c3b0`): a player, a game object or a book speaks nothing. +#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LatchVocal { + /// A different unit latched (`0x4930d0` → `0x49318f`, `0x60c3b0` with 1). + Hello(u64), + /// The latch released to nothing with `dl` clear (`0x4931c0` → `0x493210`, `0x60c3b0` with 0). + Goodbye(u64), +} + /// How deep [`fire`]'s answers to close verbs may nest. The reference recurses without bound, so /// a handler that calls its own close verb on every fire overflows its stack; three levels cover /// every stock route. @@ -194,27 +209,59 @@ fn entries(world: &World) -> Vec { } /// `CGGameUI::SetInteractNPC` (`0x4930d0`), at a window's open: with anything latched, `0x4931c0` -/// on the latch itself (`0x49310a`), which always matches and runs `0x493310`, so the holder -/// closes first, even when it is this window on this same guid; then `guid` is latched -/// (`0x493114`). The opener writes its cell after this, except the tabard designer's. +/// on the latch itself with `dl` set (`0x49310a`, `dl = guid != 0`), which always matches and +/// runs `0x493310` with no goodbye, so the holder closes first, even when it is this window on this +/// same guid; then `guid` is latched (`0x493114`). A guid that differs from the one latched +/// before is greeted (`0x49318f`, `bl` clear), so a second window on the same unit is silent. The +/// opener writes its cell after this, except the tabard designer's. pub(crate) fn register(world: &mut World, guid: u64) { world.init_resource::(); - if world.resource::().guid.is_some() { + let before = world.resource::().guid; + if before.is_some() { dispatch(world); } world.resource_mut::().guid = Some(guid); + if guid != 0 && before != Some(guid) { + say(world, LatchVocal::Hello(guid)); + } } -/// `0x4931c0` on a core's own cell: while the latch names `cell`, `0x493310`. +/// `0x4931c0` on a core's own cell with `dl` clear: while the latch names `cell`, the goodbye +/// (`0x493210`), then `0x493310`. pub(crate) fn release(world: &mut World, cell: u64) { - if world + release_cell(world, cell, true); +} + +/// `0x4931c0(cell, dl = !goodbye)`: while the latch names `cell`, the unit's goodbye unless `dl` +/// is set (`0x493208`), then `0x493310` whether or not the object is still there (`0x49321e`). +pub(crate) fn release_cell(world: &mut World, cell: u64, goodbye: bool) { + let latched = world .get_resource::() - .is_some_and(|l| l.guid == Some(cell)) - { + .and_then(|l| l.guid) + .filter(|g| *g == cell); + if latched.is_some() { + if goodbye { + say(world, LatchVocal::Goodbye(cell)); + } dispatch(world); } } +/// `0x4931c0` on the latch itself (`ecx = 0xb4e2d0`): whatever it holds is released, with the +/// goodbye unless `goodbye` is false. +pub(crate) fn release_latch(world: &mut World, goodbye: bool) { + if let Some(guid) = world.get_resource::().and_then(|l| l.guid) { + release_cell(world, guid, goodbye); + } +} + +/// Queue the NPC's line for whoever plays it; nothing reads it in a build with no sound. +fn say(world: &mut World, vocal: LatchVocal) { + if let Some(mut messages) = world.get_resource_mut::>() { + messages.write(vocal); + } +} + /// `0x493310`: clear the latch (`0x49334b`), then run the close core of the first window whose /// cell holds the old guid. The watchdog (`0x493305`) and the leave-world sweep (`0x490b5f`) /// call it, and so does every core that finds its own guid latched. diff --git a/crates/benilla-app/src/ui_session/latch_tests.rs b/crates/benilla-app/src/ui_session/latch_tests.rs index 743a0774a..9f95d6142 100644 --- a/crates/benilla-app/src/ui_session/latch_tests.rs +++ b/crates/benilla-app/src/ui_session/latch_tests.rs @@ -4,7 +4,7 @@ use bevy::prelude::*; -use super::{dispatch, InteractLatch, LatchApp, LatchedWindow, NpcWindow as W}; +use super::{dispatch, InteractLatch, LatchApp, LatchVocal, LatchedWindow, NpcWindow as W}; use crate::net::NetCommands; use crate::ui_auction::AuctionOpen; use crate::ui_bank::BankOpen; @@ -21,8 +21,8 @@ use crate::ui_taxi::TaxiState; use crate::ui_trade::TradeSession; use crate::ui_trainer::TrainerOpen; -const VENDOR: u64 = 0xF130_0000_0000_0001; -const BANKER: u64 = 0xF130_0000_0000_0002; +pub(super) const VENDOR: u64 = 0xF130_0000_0000_0001; +pub(super) const BANKER: u64 = 0xF130_0000_0000_0002; const NPC: u64 = 0xF130_0000_0000_0003; /// The windows this harness opens, in `0x493310`'s order: every window on the latch. @@ -63,7 +63,7 @@ fn event(window: W) -> &'static str { /// The windows on the latch with their cells, a wire, and a VM that records their closes. Every /// unit guid's entry is 0, which a type-9 template makes a book, so any NPC here can be read. -fn harness() -> App { +pub(super) fn harness() -> App { harness_with_wire().0 } @@ -75,6 +75,7 @@ fn harness_with_wire() -> (App, crossbeam_channel::Receiver() .insert_resource(books) .init_resource::() .init_resource::() @@ -108,7 +109,7 @@ fn harness_with_wire() -> (App, crossbeam_channel::Receiver Option { +pub(super) fn latch(app: &App) -> Option { app.world().resource::().guid() } @@ -148,7 +149,7 @@ fn greeting(npc: u64) -> QuestView { } /// Each window's open as its packet handler or click runs it, on `npc`. -fn open(app: &mut App, window: W, npc: u64) { +pub(super) fn open(app: &mut App, window: W, npc: u64) { let world = app.world_mut(); match window { W::Trade => { @@ -487,3 +488,71 @@ fn a_book_and_an_npc_window_close_each_other() { assert_eq!(seen_in(&app), ["MERCHANT_CLOSED", "ITEM_TEXT_CLOSED"]); assert_eq!(app.world().resource::().object(), None); } + +/// The lines the latch asked for since the last call. +pub(super) fn vocals(app: &mut App) -> Vec { + app.world_mut() + .resource_mut::>() + .drain() + .collect() +} + +/// Every window registers on the latch, so each greets the unit it latches (`0x4930d0` → +/// `0x49318f`) and says goodbye when its close core releases the latch to nothing (`0x4931c0` → +/// `0x493210`): a banker, a flight master or a stable master speaks as a vendor does. +#[test] +fn every_window_greets_on_open_and_says_goodbye_on_close() { + for window in WINDOWS { + let mut app = harness(); + open(&mut app, window, NPC); + assert_eq!( + vocals(&mut app), + [LatchVocal::Hello(NPC)], + "{window:?} open" + ); + close(&mut app, window); + assert_eq!( + vocals(&mut app), + [LatchVocal::Goodbye(NPC)], + "{window:?} close" + ); + } +} + +/// A swap greets the new unit and says no goodbye for the old (`0x4930fd`, `dl = guid != 0`); a +/// second window on the unit already latched greets nobody (`0x493187`, `bl` set). +#[test] +fn a_swap_greets_the_new_unit_with_no_goodbye_and_the_same_unit_is_silent() { + let mut app = harness(); + crate::ui_merchant::open_merchant(app.world_mut(), VENDOR, Vec::new()); + crate::ui_bank::open_bank(app.world_mut(), BANKER); + assert_eq!( + vocals(&mut app), + [LatchVocal::Hello(VENDOR), LatchVocal::Hello(BANKER)] + ); + + crate::ui_gossip::open_menu(app.world_mut(), BANKER, 0, Vec::new(), Vec::new()); + assert_eq!(vocals(&mut app), [], "the same unit, another window"); + // Closing to nothing still says goodbye to the one latched. + crate::ui_gossip::close_core(app.world_mut()); + assert_eq!(vocals(&mut app), [LatchVocal::Goodbye(BANKER)]); +} + +/// A walk-away close and the leave-world sweep run `0x493310` directly (`0x493305`, `0x490b5f`), +/// skipping `0x4931c0`: no goodbye. A later open greets again. +#[test] +fn a_walk_away_says_no_goodbye() { + for window in WINDOWS { + let mut app = harness(); + open(&mut app, window, NPC); + vocals(&mut app); + dispatch(app.world_mut()); + assert_eq!(vocals(&mut app), [], "{window:?}"); + open(&mut app, window, NPC); + assert_eq!( + vocals(&mut app), + [LatchVocal::Hello(NPC)], + "{window:?} again" + ); + } +} diff --git a/crates/benilla-app/src/ui_session/release.rs b/crates/benilla-app/src/ui_session/release.rs new file mode 100644 index 000000000..b43fd0cb3 --- /dev/null +++ b/crates/benilla-app/src/ui_session/release.rs @@ -0,0 +1,68 @@ +//! The unit events that release the interaction latch (`0x4931c0`), off the field edges the apply +//! pass emits, as the reference's mirror handlers see them. +//! +//! A unit whose health falls to 0 runs `CGUnit_C::OnDied` (`0x605860`, from the health watcher +//! `0x6046f0` at `0x6047a3`). For a player it calls `0x5e99b0` first, which for the active player +//! releases the latch with the goodbye (`0x5e9a51`, `dl = 0`); then, for any unit, it releases the +//! latch if it names the dying unit, without a goodbye (`0x605950`, `dl = 1`). A change of the +//! active player's shapeshift form (`UNIT_FIELD_BYTES_1` byte 2, watcher `0x5de010`) into a form +//! that is not a stance (`0x60e9f0`) and lacks flag `0x8` releases it with the goodbye +//! (`0x5de07f`, `dl = 0`). The release closes the window through `0x493310`; the goodbye is +//! [`super::LatchVocal`], which a dead player does not hear (`0x605f30`). + +use std::collections::HashMap; + +use bevy::prelude::*; + +use benilla_formats::ShapeshiftForm; +use benilla_protocol::field::{FIELD_UNIT_BYTES_1, FIELD_UNIT_HEALTH}; +use benilla_protocol::messages::ObjectType; + +use super::latch::{release_cell, release_latch}; +use crate::net::{FieldChanged, SelfPlayer}; +use crate::ui_action::Spells; + +/// `UNIT_FIELD_BYTES_1` byte 2, the shapeshift form (`[+0x110]+0x212`). +fn form_of(bytes_1: u32) -> u8 { + (bytes_1 >> 16) as u8 +} + +/// Whether taking `form` closes the open window (`0x5de010`): a form other than none whose row is +/// not a stance (`0x60e9f0`) and lacks the interact flag (`0x5de072`). A form with no row reads +/// nothing here, where the reference faults. +pub(super) fn form_releases(forms: &HashMap, form: u8) -> bool { + form != 0 + && forms + .get(&u32::from(form)) + .is_some_and(|row| !row.is_stance() && !row.allows_interaction()) +} + +/// The death of a unit and the active player's form change release the latch. The edges are read +/// after the apply pass, and the releases run in order at the next sync point, before the `"npc"` +/// feed. +pub(super) fn release_on_field_edges( + mut edges: MessageReader, + me: Query>, + spells: Option>, + mut commands: Commands, +) { + let me = me.single().ok(); + for e in edges.read() { + if e.unit_field(FIELD_UNIT_HEALTH) && e.old > 0 && e.new == 0 { + // `0x605860` → `0x5e99b0` for the active player, then its own guid for any unit. + if e.kind == ObjectType::Player && Some(e.entity) == me { + commands.queue(|world: &mut World| release_latch(world, true)); + } + let guid = e.guid; + commands.queue(move |world: &mut World| release_cell(world, guid, false)); + } else if e.unit_field(FIELD_UNIT_BYTES_1) + && Some(e.entity) == me + && form_of(e.old) != form_of(e.new) + && spells + .as_ref() + .is_some_and(|s| form_releases(&s.forms, form_of(e.new))) + { + commands.queue(|world: &mut World| release_latch(world, true)); + } + } +} diff --git a/crates/benilla-app/src/ui_session/release_tests.rs b/crates/benilla-app/src/ui_session/release_tests.rs new file mode 100644 index 000000000..c8c421ee9 --- /dev/null +++ b/crates/benilla-app/src/ui_session/release_tests.rs @@ -0,0 +1,237 @@ +//! The latch released by the death of the active player, a shapeshift into a form that cannot +//! interact, and the death of the latched unit. + +use bevy::prelude::*; + +use benilla_formats::ShapeshiftForm; +use benilla_protocol::field::{FIELD_UNIT_BYTES_1, FIELD_UNIT_HEALTH}; +use benilla_protocol::messages::ObjectType; + +use super::latch_tests::{harness, latch, open, vocals, BANKER, VENDOR}; +use super::release::{form_releases, release_on_field_edges}; +use super::{LatchVocal, NpcWindow}; +use crate::net::{FieldChanged, SelfPlayer}; +use crate::ui_action::Spells; +use crate::ui_script::sweep_test::seen_in; + +const ME: u64 = 0x0000_0000_0000_0001; +const OTHER: u64 = 0x0000_0000_0000_0009; + +/// A form row with `flags1` set as given; nothing else of it is read here. +fn form(flags: u32) -> ShapeshiftForm { + ShapeshiftForm { + bonus_bar: 0, + name: String::new(), + flags, + creature_type: 0, + attack_icon: None, + } +} + +/// The latch harness with the release system, the active player and `SpellShapeshiftForm` rows: 1 +/// and 5 plain forms, 7 a stance (flag 0x1), 8 a form that may still interact (flag 0x8). +fn app() -> (App, Entity) { + let mut app = harness(); + let mut spells = Spells::empty_for_tests(); + spells.forms.insert(1, form(0)); + spells.forms.insert(5, form(0x2)); + spells.forms.insert(7, form(0x1 | 0x2)); + spells.forms.insert(8, form(0x8)); + app.insert_resource(spells) + .add_message::() + .add_systems(Update, release_on_field_edges); + let me = app.world_mut().spawn(SelfPlayer).id(); + (app, me) +} + +fn edge( + entity: Entity, + guid: u64, + kind: ObjectType, + index: u16, + old: u32, + new: u32, +) -> FieldChanged { + FieldChanged { + entity, + guid, + kind, + index, + old, + new, + } +} + +fn shift(app: &mut App, entity: Entity, old: u8, new: u8) { + app.world_mut().write_message(edge( + entity, + ME, + ObjectType::Player, + FIELD_UNIT_BYTES_1, + u32::from(old) << 16 | 0x0102, + u32::from(new) << 16 | 0x0102, + )); + app.update(); +} + +/// `0x5e9a51`: the active player dying releases the latch with the goodbye (the player who is +/// dead hears none, `0x605f30`), closing the window; another player dying does not. +#[test] +fn the_active_players_death_closes_the_window() { + let (mut app, me) = app(); + let other = app.world_mut().spawn_empty().id(); + open(&mut app, NpcWindow::Merchant, VENDOR); + vocals(&mut app); + + // Health falls to 0 on another player, and rises on the active one: nothing. + app.world_mut().write_message(edge( + other, + OTHER, + ObjectType::Player, + FIELD_UNIT_HEALTH, + 50, + 0, + )); + app.world_mut() + .write_message(edge(me, ME, ObjectType::Player, FIELD_UNIT_HEALTH, 0, 50)); + app.world_mut() + .write_message(edge(me, ME, ObjectType::Player, FIELD_UNIT_HEALTH, 50, 20)); + app.update(); + assert_eq!(latch(&app), Some(VENDOR)); + assert!(seen_in(&app).is_empty()); + + app.world_mut() + .write_message(edge(me, ME, ObjectType::Player, FIELD_UNIT_HEALTH, 20, 0)); + app.update(); + assert_eq!(seen_in(&app), ["MERCHANT_CLOSED"]); + assert_eq!(latch(&app), None); + assert_eq!(vocals(&mut app), [LatchVocal::Goodbye(VENDOR)]); +} + +/// `0x5de07f`: a form without flag 0x8 closes the window with the goodbye; none, a stance, a form +/// that may interact, an unknown form, another unit's change and a move of another byte do not. +#[test] +fn a_shapeshift_into_a_form_that_cannot_interact_closes_the_window() { + let (mut app, me) = app(); + let other = app.world_mut().spawn_empty().id(); + open(&mut app, NpcWindow::Bank, BANKER); + vocals(&mut app); + + shift(&mut app, me, 1, 0); + shift(&mut app, me, 0, 7); + shift(&mut app, me, 0, 8); + shift(&mut app, me, 0, 99); + shift(&mut app, other, 0, 1); + // The form byte unchanged, the byte beside it moved. + app.world_mut().write_message(edge( + me, + ME, + ObjectType::Player, + FIELD_UNIT_BYTES_1, + 1 << 16, + 1 << 16 | 0x0100, + )); + app.update(); + assert_eq!(latch(&app), Some(BANKER)); + assert!(seen_in(&app).is_empty()); + + shift(&mut app, me, 0, 1); + assert_eq!(seen_in(&app), ["BANKFRAME_CLOSED"]); + assert_eq!(latch(&app), None); + assert_eq!(vocals(&mut app), [LatchVocal::Goodbye(BANKER)]); + + // A form on a plain, non-stance row with the interact flag clear, from another form. + open(&mut app, NpcWindow::Merchant, VENDOR); + shift(&mut app, me, 1, 5); + assert_eq!(latch(&app), None); +} + +/// `0x605950`: the latched unit dying closes its window with no goodbye (`dl = 1`); another +/// unit's death leaves it. +#[test] +fn the_latched_units_death_closes_its_window_without_a_goodbye() { + let (mut app, _) = app(); + let vendor = app.world_mut().spawn_empty().id(); + let bystander = app.world_mut().spawn_empty().id(); + open(&mut app, NpcWindow::Merchant, VENDOR); + vocals(&mut app); + + app.world_mut().write_message(edge( + bystander, + OTHER, + ObjectType::Unit, + FIELD_UNIT_HEALTH, + 50, + 0, + )); + app.update(); + assert_eq!(latch(&app), Some(VENDOR)); + + app.world_mut().write_message(edge( + vendor, + VENDOR, + ObjectType::Unit, + FIELD_UNIT_HEALTH, + 50, + 0, + )); + app.update(); + assert_eq!(seen_in(&app), ["MERCHANT_CLOSED"]); + assert_eq!(latch(&app), None); + assert_eq!(vocals(&mut app), [], "dl = 1: the unit says no goodbye"); +} + +/// A trade partner is a player: its death releases the latch through `0x605950` as any latched +/// unit's does. +#[test] +fn a_latched_players_death_closes_the_window_too() { + let (mut app, _) = app(); + let partner = app.world_mut().spawn_empty().id(); + open(&mut app, NpcWindow::Trade, OTHER); + vocals(&mut app); + app.world_mut().write_message(edge( + partner, + OTHER, + ObjectType::Player, + FIELD_UNIT_HEALTH, + 50, + 0, + )); + app.update(); + assert_eq!(seen_in(&app), ["TRADE_CLOSED"]); + assert_eq!(latch(&app), None); + assert_eq!(vocals(&mut app), []); +} + +/// The shipped `SpellShapeshiftForm.dbc` through the gate: every druid form that is not a stance +/// and Ghost Wolf close the window; the warrior stances, Stealth, Moonkin (all stances) and +/// Shadowform (stance, and flag 0x8) do not. +#[test] +fn the_shipped_forms_split_into_those_that_close_the_window_and_those_that_do_not() { + let Some(spells) = crate::ui_action::real_spells() else { + return; + }; + let closes = |id: u8| form_releases(&spells.forms, id); + for (id, name) in [ + (1, "Cat"), + (2, "Tree"), + (3, "Travel"), + (4, "Aquatic"), + (5, "Bear"), + (8, "Dire Bear"), + (16, "Ghost Wolf"), + ] { + assert!(closes(id), "{name} form {id}"); + } + for (id, name) in [ + (0, "no form"), + (17, "Battle Stance"), + (18, "Defensive Stance"), + (19, "Berserker Stance"), + (28, "Shadowform"), + (30, "Stealth"), + (31, "Moonkin"), + ] { + assert!(!closes(id), "{name} form {id}"); + } +} diff --git a/crates/benilla-formats/src/spells/forms.rs b/crates/benilla-formats/src/spells/forms.rs index b67ab0957..a82d82ee1 100644 --- a/crates/benilla-formats/src/spells/forms.rs +++ b/crates/benilla-formats/src/spells/forms.rs @@ -37,6 +37,13 @@ impl ShapeshiftForm { self.flags & 1 != 0 } + /// Flag bit `0x8` of `flags1`: the form may still talk to NPCs. Without it `0x5f0130` refuses + /// the interaction (`ERR_CANT_INTERACT_SHAPESHIFTED`) and a change into the form closes the + /// open window (`0x5de072`). + pub fn allows_interaction(&self) -> bool { + self.flags & 0x8 != 0 + } + /// Clicking the active form's button cancels its aura (`CMSG_CANCEL_AURA`) unless bit `0x2` /// makes it a silent no-op (`CastShapeshiftForm` `0x4b4810`, guard at `0x4b4963`); the /// warrior stances (`0x7`) set it on 5875.