NPC greetings from the latch; death and shapeshift close the window (#1320)

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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-15 17:46:23 +07:00
committed by GitHub
parent 358731f95a
commit cf1aa41e3d
7 changed files with 514 additions and 145 deletions
+62 -130
View File
@@ -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<u64> {
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<u64>, active: Option<u64>) -> 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<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
@@ -142,6 +108,12 @@ fn load_npc_greetings(mut commands: Commands, assets: Option<Res<WorldAssets>>)
}
}
/// 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<MerchantOpen>,
gossip: Res<GossipState>,
quest: Res<QuestGiver>,
trainer: Res<TrainerOpen>,
mut lines: MessageReader<LatchVocal>,
index: Res<GuidIndex>,
mut prev_active: Local<Option<u64>>,
mut select_seq: ResMut<GreetSeq>,
units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
@@ -330,46 +298,41 @@ fn play_window_greetings(
config: Res<SoundConfig>,
cam: Query<&Transform, (With<WorldCamera>, Without<NetEntity>)>,
) {
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())
+10 -1
View File
@@ -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::<InteractLatch>()
.add_message::<LatchVocal>()
.init_resource::<InteractNpc>()
.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.
+55 -8
View File
@@ -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<Entry> {
}
/// `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::<InteractLatch>();
if world.resource::<InteractLatch>().guid.is_some() {
let before = world.resource::<InteractLatch>().guid;
if before.is_some() {
dispatch(world);
}
world.resource_mut::<InteractLatch>().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::<InteractLatch>()
.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::<InteractLatch>().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<LatchVocal>>() {
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.
@@ -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<crate::net::ClientCo
data[0] = 1;
books.insert(0, 9, "Book".into(), &data);
app.insert_resource(NetCommands(tx))
.add_message::<LatchVocal>()
.insert_resource(books)
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::items::Items>()
@@ -108,7 +109,7 @@ fn harness_with_wire() -> (App, crossbeam_channel::Receiver<crate::net::ClientCo
(app, rx)
}
fn latch(app: &App) -> Option<u64> {
pub(super) fn latch(app: &App) -> Option<u64> {
app.world().resource::<InteractLatch>().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::<ItemTextOpen>().object(), None);
}
/// The lines the latch asked for since the last call.
pub(super) fn vocals(app: &mut App) -> Vec<LatchVocal> {
app.world_mut()
.resource_mut::<Messages<LatchVocal>>()
.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"
);
}
}
@@ -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<u32, ShapeshiftForm>, 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<FieldChanged>,
me: Query<Entity, With<SelfPlayer>>,
spells: Option<Res<Spells>>,
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));
}
}
}
@@ -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::<FieldChanged>()
.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}");
}
}
@@ -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.