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)

`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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 07:10:56 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 47794e489c
commit 8f6d337ad6
2 changed files with 117 additions and 13 deletions
+109 -5
View File
@@ -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::<GroupState>();
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::<GuidIndex>()
.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();
+8 -8
View File
@@ -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::<GuidIndex>()
@@ -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<String> {
pub(in crate::ui_party) fn frame(app: &mut App) -> Vec<String> {
app.update();
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
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<f64> {
pub(in crate::ui_party) fn flag(app: &mut App, code: &str) -> Option<f64> {
app.world_mut()
.non_send_resource::<UiScript>()
.eval(&format!("return {code}"))