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:
co-authored by
Claude Opus 5.5
parent
47794e489c
commit
8f6d337ad6
@@ -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();
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
Reference in New Issue
Block a user