From 8f6d337ad673eacc7387e3506a4cf0820714398f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:10:56 +0700 Subject: [PATCH] party and raid tokens: UnitIsConnected answers 1 for a held member whatever the roster status byte says, and reads the record's online bit only with no object (0x517d50, 0x517daf, 0x517dd3) (#686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `member_unit_state` built a `partyN` or `raidN` view, then replaced `is_connected` with the roster status byte's online bit on both legs, the held leg included. So a member who went link-dead while still in view read disconnected at once, and their frame's power bar went grey. `UnitIsConnected 0x517d50` looks the unit up in the object manager (`0x517da6`). A held unit answers 1 (`0x517daf`). Only with no object does it read the record's `+0x08 & 1` (`0x517dd3`), which `SMSG_GROUP_LIST` sets from the member's status (`0x4e836e`-`0x4e837b`). The held leg keeps `snapshot`'s value, and only the no-object leg reads the roster's online bit. Tests (through `feed_party` and the Lua getter): - a held member reads connected whatever the status byte says, for `party1` and `raid1`; - a held member going offline reads connected until the object leaves, then nil; - an unheld member with the bit clear reads nil, and with it set reads 1. Fixes #647 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/ui_party/feed.rs | 114 ++++++++++++++++++++++-- crates/benilla-app/src/ui_party/pets.rs | 16 ++-- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 8994ab70..83658bb7 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -743,11 +743,13 @@ fn member_unit_state( // Divided as on the live leg (`UnitMana`'s record path, `0x517744`-`0x51775e`). power: stats.map_or(0, PartyMemberStatsInfo::shown_power), max_power: stats.map_or(0, PartyMemberStatsInfo::shown_max_power), + // Connected is the roster byte's online bit, the one `0x4e82d0` writes into the + // record's bit 0 (`0x4e836e`-`0x4e837b`) for `UnitIsConnected` to read (`0x517dd3`); + // a held member never gets here, its 1 is `snapshot`'s (`0x517daf`). + is_connected: m.status & member_status::ONLINE != 0, // Dead and ghost from the record, as the reference's `UnitIsDead` (`0x517b5d`, // `+0x08 & 4`) and `UnitIsGhost` (`0x517c32`, `& 8`) read it: fresher than the roster - // byte, which only `SMSG_GROUP_LIST` moves. Connected stays the roster's, though the - // reference reads the record's bit 0 (`0x517dd3`); its no-object PvP and FFA reads are - // untraced, and 1.12 has no AFK or DND predicate to feed. + // byte, which only `SMSG_GROUP_LIST` moves. 1.12 has no AFK or DND predicate to feed. dead: stats.is_some_and(|s| s.status.unwrap_or(0) & member_status::DEAD != 0), ghost: stats.is_some_and(|s| s.status.unwrap_or(0) & member_status::GHOST != 0), ..Default::default() @@ -759,8 +761,7 @@ fn member_unit_state( s.raid_target = group.raid_target_index(m.guid); s.reaction = 5; s.faction_group = own_group; - // The roster status byte overlays both legs. - s.is_connected = m.status & member_status::ONLINE != 0; + // The roster status byte overlays both legs, but for `is_connected`, which is the record leg's. s.is_pvp_ffa = m.status & member_status::PVP_FFA != 0; s.pvp = s.pvp || m.status & member_status::PVP != 0; s.ghost = s.ghost || m.status & member_status::GHOST != 0; @@ -1404,6 +1405,7 @@ pub(crate) fn synthetic_raid( #[cfg(test)] mod tests { + use super::super::pets::tests::{app, flag, frame, member, party, stream, ME}; use super::*; #[test] @@ -1522,6 +1524,108 @@ mod tests { assert!(s.dead); } + /// Seat member 1 alone in the party or raid roster with `status`, on the wire and in its + /// record, as [`party`] does. + fn seat(app: &mut App, group_type: u8, status: u8) { + let mut group = app.world_mut().resource_mut::(); + group.apply_list( + group_type, + 0, + vec![GroupMemberEntry { + name: "M1".into(), + guid: member(1), + status, + flags: 0, + }], + ME, + None, + Some(ME), + ); + group.stats.get_mut(&member(1)).unwrap().status = Some(status); + } + + /// `UnitIsConnected` (`0x517d50`) answers 1 for any unit the object manager holds + /// (`0x517daf`), whatever the roster's status byte says; the record is not read. + #[test] + fn a_held_member_reads_connected_whatever_the_roster_status_says() { + let mut app = app(); + party(&mut app, 1, false); + stream(&mut app, member(1), &[]); + frame(&mut app); + assert_eq!( + flag(&mut app, "UnitIsConnected('party1')"), + Some(1.0), + "held, roster and record without the online bit" + ); + + seat(&mut app, GROUPTYPE_RAID, member_status::OFFLINE); + frame(&mut app); + assert_eq!( + flag(&mut app, "UnitIsConnected('raid1')"), + Some(1.0), + "the raid token reads the same view" + ); + } + + /// The reported shape: a member goes link-dead while still in view. The roster marks them + /// offline and the reference keeps them connected until the object leaves (`0x517daf`), then + /// reads the record's online bit (`0x517dd3`). + #[test] + fn a_held_member_going_offline_reads_disconnected_only_once_the_object_leaves() { + let mut app = app(); + party(&mut app, 1, true); + let held = stream(&mut app, member(1), &[]); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitIsConnected('party1')"), Some(1.0)); + + seat(&mut app, 0, member_status::OFFLINE); + frame(&mut app); + assert_eq!( + flag(&mut app, "UnitIsConnected('party1')"), + Some(1.0), + "link-dead, still in view" + ); + + app.world_mut().despawn(held); + app.world_mut() + .resource_mut::() + .0 + .remove(&member(1)); + frame(&mut app); + assert_eq!( + flag(&mut app, "UnitIsConnected('party1')"), + None, + "out of view, the record's clear online bit decides" + ); + } + + /// With no object, the record's online bit (`+0x08 & 1`) is the answer: clear is nil + /// (`0x517dfd`). + #[test] + fn an_unheld_member_with_the_online_bit_clear_reads_nil() { + let mut app = app(); + party(&mut app, 1, false); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitIsConnected('party1')"), None); + + seat(&mut app, GROUPTYPE_RAID, member_status::OFFLINE); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitIsConnected('raid1')"), None); + } + + /// Set, it is 1 (`0x517dd7`). + #[test] + fn an_unheld_member_with_the_online_bit_set_reads_connected() { + let mut app = app(); + party(&mut app, 1, true); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitIsConnected('party1')"), Some(1.0)); + + seat(&mut app, GROUPTYPE_RAID, member_status::ONLINE); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitIsConnected('raid1')"), Some(1.0)); + } + #[test] fn a_second_empty_answer_is_still_a_saved_instance_edge() { let mut fed = FedParty::default(); diff --git a/crates/benilla-app/src/ui_party/pets.rs b/crates/benilla-app/src/ui_party/pets.rs index cc3adf17..4109aff4 100644 --- a/crates/benilla-app/src/ui_party/pets.rs +++ b/crates/benilla-app/src/ui_party/pets.rs @@ -228,7 +228,7 @@ pub(super) fn feed_token( } #[cfg(test)] -mod tests { +pub(in crate::ui_party) mod tests { use super::*; use crate::net::{FieldChanged, Guid}; use benilla_protocol::messages::{ @@ -242,10 +242,10 @@ mod tests { const MAXHEALTH: u16 = 28; const PETNUMBER: u16 = 139; - const ME: u64 = 0x10; + pub(in crate::ui_party) const ME: u64 = 0x10; /// The `i`-th party member, and its pet. - fn member(i: u64) -> u64 { + pub(in crate::ui_party) fn member(i: u64) -> u64 { 0x1000 + i } fn pet(i: u64) -> u64 { @@ -294,12 +294,12 @@ mod tests { app } - fn app() -> App { + pub(in crate::ui_party) fn app() -> App { app_with(UiScript::new().unwrap()) } /// A party of `n`, each `online` on the wire and in its record, the record naming pet `i`. - fn party(app: &mut App, n: u64, online: bool) { + pub(in crate::ui_party) fn party(app: &mut App, n: u64, online: bool) { let status = if online { member_status::ONLINE } else { @@ -337,7 +337,7 @@ mod tests { } /// Hold `guid` in the object manager. - fn stream(app: &mut App, guid: u64, pairs: &[(u16, u32)]) -> Entity { + pub(in crate::ui_party) fn stream(app: &mut App, guid: u64, pairs: &[(u16, u32)]) -> Entity { let e = app.world_mut().spawn((Guid(guid), store(pairs))).id(); app.world_mut() .resource_mut::() @@ -356,7 +356,7 @@ mod tests { } /// Run a frame and take the pet events it fired; `UNIT_PET`, or a unit event naming a pet. - fn frame(app: &mut App) -> Vec { + pub(in crate::ui_party) fn frame(app: &mut App) -> Vec { app.update(); let mut s = app.world_mut().non_send_resource_mut::(); s.resolve(); @@ -384,7 +384,7 @@ mod tests { } /// A predicate answers the number 1 or nil, never a boolean. - fn flag(app: &mut App, code: &str) -> Option { + pub(in crate::ui_party) fn flag(app: &mut App, code: &str) -> Option { app.world_mut() .non_send_resource::() .eval(&format!("return {code}"))