party members show on the world map, and the leader wears the crown

- party members appear on the world map at their position and hovering names them — the party wire has carried each member's zone and coordinates since the group arc; the blip and the minimap dot read one shared position so they can never disagree, and the member's zone picks which continent the position belongs to
- the player's own frame wears the leader crown and the master-looter icon, with the reference's own asymmetry (the crown asks IsPartyLeader, the member frames compare a leader index)
- a party member outside your local area gets the generic race/sex portrait instead of an empty circle — their race and sex ride the name query, and nothing had asked for a member we never see; the roster now warms that cache, which also fills the raid grid's class column
- right-clicking a member who is nowhere near grays Trade, Inspect, Duel and Follow: the reference's bindings answer nil for a unit the object manager does not hold (carved this session), where ours defaulted "absent = in range" for exactly the farthest member; and the group-invite popup's background is measured faithful — the plate draws at the BLP's own 60% alpha

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:30:24 +07:00
co-authored by Claude Fable 5
parent ef4cc8548d
commit 11a0b68da6
21 changed files with 834 additions and 111 deletions
+66 -2
View File
@@ -79,8 +79,11 @@
RE-VERIFIED in 0652, both legs: ref-GameTooltip.lua l.107-123 takes the `not noNormalText`
branch when tips are off (so `noNormalText=1` → nothing), and "newbie" does not appear
anywhere in WoW.exe's strings, so SHOW_NEWBIE_TIPS has no cvar producer to be on.
- OUT (no wire / future arcs): leader/master/rest/attack icons, group indicator,
hit indicators, raid target icon, playtime, target-of-target.
- The rest/attack state icons landed with decision 1082, the hit indicator with 0576, and
the leader / master-looter pair with report B317 — the wire those three waited on has
existed since 0434/0453, and the list below is what is still genuinely unbuilt.
- OUT (no wire / future arcs): group indicator, raid target icon, playtime,
target-of-target.
TARGET AURA ROWS (0255's deferred slice): 5 buffs / 16 debuffs under the frame, geometry +
law QUOTED from ref-TargetFrame.xml (button templates l.26-87, instances l.293-481) and
@@ -309,6 +312,17 @@
frame:RegisterEvent("PLAYER_REGEN_DISABLED")
frame:RegisterEvent("PLAYER_REGEN_ENABLED")
frame:RegisterEvent("PLAYER_UPDATE_RESTING")
-- The leader / master-looter icons and their four group wires (ref
-- PlayerFrame_OnLoad's remaining registrations; report B317). Handle-gated in the
-- shared Update exactly like statusTexture above — the target frame declares
-- neither texture, so it never runs the law, and the party member frames run
-- their own (PartyFrame.xml, a different predicate: an INDEX comparison).
frame.leaderIcon = getglobal(tf .. "LeaderIcon")
frame.masterIcon = getglobal(tf .. "MasterIcon")
frame:RegisterEvent("PARTY_MEMBERS_CHANGED")
frame:RegisterEvent("PARTY_LEADER_CHANGED")
frame:RegisterEvent("PARTY_LOOT_METHOD_CHANGED")
frame:RegisterEvent("RAID_ROSTER_UPDATE")
end
-- A plain Frame isn't mouse-enabled by construction (only Button/EditBox are); enable it so
-- the hover tooltip fires (the real client's unit frames take the mouse to target/tooltip).
@@ -792,6 +806,11 @@
PlayerFrame_UpdateStatus()
end
-- The leader / master-looter icons, player frame only (report B317).
if frame.leaderIcon then
BenillaPlayerFrame_UpdatePartyLeader(frame)
end
-- The aura rows (target only) — the ref's TargetFrame_Update →
-- TargetDebuffButton_Update: repainted on PLAYER_TARGET_CHANGED and UNIT_AURA alike.
if unit == "target" then
@@ -950,6 +969,31 @@
end
end
-- The leader / master-looter icons on the player's own frame (ref
-- PlayerFrame_UpdatePartyLeader, re-expressed onto frame-param style; report B317).
--
-- Two DIFFERENT predicates, and the asymmetry is the reference's own. The leader icon asks
-- IsPartyLeader() — a boolean about us — where the member frames ask whether the leader
-- INDEX equals their id, because the player has no index on that scale (0 is "the player
-- leads", not "party0"). The master icon then asks whether GetLootMethod's second return
-- is 0, which is that same scale reading "the player is master looter"; the group test
-- beside it is the reference's own guard, and it is not redundant with ours — a solo
-- player's master_looter is None, so `masterId == 0` is already false, but reproducing the
-- expression keeps us honest if that ever stops being true.
function BenillaPlayerFrame_UpdatePartyLeader(frame)
if IsPartyLeader() then
frame.leaderIcon:Show()
else
frame.leaderIcon:Hide()
end
local lootMethod, masterId = GetLootMethod()
if masterId == 0 and (GetNumPartyMembers() > 0 or GetNumRaidMembers() > 0) then
frame.masterIcon:Show()
else
frame.masterIcon:Hide()
end
end
-- ref PlayerFrame_OnUpdate: the status pulse — a 0.5 s triangle wave between alpha
-- 55/255 and 255/255 on the ADD-blend status ring and the icon glow, run only while the
-- ring is visible (resting or auto-attacking); the hit-indicator fade rides the same
@@ -1259,6 +1303,26 @@
<Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="73" y="-42"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- Leader + master-looter icons (ref PlayerLeaderIcon / PlayerMasterIcon,
ref-PlayerFrame.xml l.147-170): 16×16 at the frame's TOPLEFT (44,-10)
and (80,-10) — above the portrait ring, left to right. The reference
seats them in this same OVERLAY layer and in the same frame-filling
child the state icons use, so the offsets are directly comparable to
$parentRestIcon's below. Shown by BenillaPlayerFrame_UpdatePartyLeader.
Report B317: the member frames have carried these since 0434 phase 2;
the player frame was built before the party wire and listed them OUT. -->
<Texture name="$parentLeaderIcon" file="Interface\GroupFrame\UI-Group-LeaderIcon" hidden="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="44" y="-10"/></Offset></Anchor>
</Anchors>
</Texture>
<Texture name="$parentMasterIcon" file="Interface\GroupFrame\UI-Group-MasterLooter" hidden="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="80" y="-10"/></Offset></Anchor>
</Anchors>
</Texture>
<!-- The state icon pair under the portrait (ref PlayerRestIcon /
PlayerAttackIcon, ref-PlayerFrame.xml l.171-201): the two halves of
UI-StateIcon — zzz left, crossed swords right (decision 1082). -->
+100 -2
View File
@@ -22,7 +22,8 @@
OUT (later slices, per 0203): the AreaPOI.dbc landmarks (the POI pool IS here — the guard's
directions marker fills it today, and the DBC rows join through the same GetNumMapLandmarks);
party/raid/battlefield/ping children (no group wire); the magnifying-glass zoom-out
raid/battlefield/ping children (the PARTY blips landed with report B320 — the raid pool
waits on `GetPlayerMapPosition` answering a `raid` token); the magnifying-glass zoom-out
shortcut (redundant with the button); SetupFullscreenScale (the map renders at its native
1024×768 centered; a scale pass is a taste knob); WorldMapTooltip (only unit blips used it).
@@ -190,6 +191,37 @@
-- and every one of them wants the plate opening AWAY from the edge it sits nearest, so the
-- side pick lives here once instead of in each OnEnter. `subtitle` is the second line the
-- landmarks that carry a live status use; the rest pass nothing.
-- The party blips (ref WorldMapFrame.lua:391-403's party arm, report B320): the same
-- (0,0)-means-hide law as the player and the corpse, one frame per slot.
--
-- It rides the per-frame pass beside those two rather than WorldMapFrame_Update, where the
-- reference keeps it: a member's position moves continuously and the map has no event that
-- fires when it does, so placing it on the repaint alone would freeze every blip between
-- roster changes. Its own function so the live pass and the tests enter it identically.
--
-- The reference's RAID arm is not taken — see the blip frames' note.
function WorldMapParty_Update()
for i = 1, 4 do
local blip = getglobal("WorldMapParty" .. i)
local px, py = GetPlayerMapPosition("party" .. i)
if ( px == 0 and py == 0 ) then
blip:Hide()
else
px = px * WorldMapDetailFrame:GetWidth()
py = -py * WorldMapDetailFrame:GetHeight()
blip:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", px, py)
blip:Show()
end
end
end
-- A party blip's hover names the member (ref WorldMapUnit_OnEnter, which reads the
-- frame's own `unit`). The frame id IS the party slot, so the token needs no bookkeeping.
function WorldMapParty_OnEnter(blip)
local unit = "party" .. blip:GetID()
WorldMapMarker_ShowTooltip(blip, UnitName(unit) or UNKNOWN)
end
function WorldMapMarker_ShowTooltip(marker, title, subtitle)
local markerX = marker:GetCenter();
local sheetX = marker:GetParent():GetCenter();
@@ -312,7 +344,8 @@
end
-- The per-frame hover pass (spec: ref WorldMapButton_OnUpdate), reduced to the area
-- highlight + the player blip (party/raid/flags/ping are deferred with their wire).
-- highlight + the player blip (raid/flags/ping are still deferred; the party blips
-- landed with B320 and ride WorldMapFrame_Update, not this pass).
-- UpdateMapHighlight is a stub until the area-bitmap source lands (wow-re dispatch) — a
-- nil area/art takes the hide paths.
function WorldMapButton_OnUpdate(elapsed)
@@ -351,6 +384,7 @@
WorldMapPlayer:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", playerX, playerY);
WorldMapPlayer:Show();
end
WorldMapParty_Update()
-- Position corpse (ref WorldMapFrame.lua:443-452 — the law is that (0,0) means hide).
local corpseX, corpseY = GetCorpseMapPosition();
if ( corpseX == 0 and corpseY == 0 ) then
@@ -653,6 +687,70 @@
</OnLeave>
</Scripts>
</Frame>
<!-- The party blips (report B320) — ref WorldMapUnitTemplate
(WorldMapFrameTemplates.xml l.3-28) re-expressed: 16x16 centered,
WorldMapPartyIcon art, one frame per `party1..4` slot, placed by
GetPlayerMapPosition in the Update above. The reference hovers them into
its own WorldMapTooltip; this map ships none (header note), so they take
the shared GameTooltip through WorldMapMarker_ShowTooltip exactly as the
corpse marker beside them does.
The reference's RAID pool (25 more frames, its `GetNumRaidMembers() > 0`
arm) is NOT here: `GetPlayerMapPosition` answers no `raid` token yet, so
a raid would show these four filled from its own subgroup and nothing
else. That is the honest half-step, and it is named in the OUT list. -->
<Frame name="WorldMapParty1" hidden="true" enableMouse="true" id="1">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\WorldMapPartyIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>WorldMapParty_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="WorldMapParty2" hidden="true" enableMouse="true" id="2">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\WorldMapPartyIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>WorldMapParty_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="WorldMapParty3" hidden="true" enableMouse="true" id="3">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\WorldMapPartyIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>WorldMapParty_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="WorldMapParty4" hidden="true" enableMouse="true" id="4">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\WorldMapPartyIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>WorldMapParty_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="WorldMapFrameAreaFrame">
<Size><AbsDimension x="400" y="128"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
@@ -650,6 +650,17 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-worldmap seed failed to open the map: {e}");
}
}
UiFixture::PartyInvite => {
let Some(script) = script else {
return;
};
// Raised through the real registry entry, not a hand-built frame: the text comes from
// the chain's own `INVITATION` GlobalString and the two buttons from ACCEPT/DECLINE,
// so the capture exercises the same Show path a real invite takes.
if let Err(e) = script.run(r#"StaticPopup_Show("PARTY_INVITE", "Thalyn")"#) {
warn!("capture: ui-partyinvite seed failed to raise the dialog: {e}");
}
}
UiFixture::Tooltip => {
let Some(mut script) = script else {
return;
@@ -66,6 +66,13 @@ pub(super) enum UiFixture {
/// render): slot icons, the attribute/resistance panes with buff coloring, melee + ranged
/// blocks, the ammo count, the level line.
Character,
/// The shared **StaticPopup** plate, shown as the group-invite dialog (`PARTY_INVITE`).
///
/// Every other StaticPopup customer in the tree raises its dialog over another window's opaque
/// art, where a missing backdrop is invisible; the invite is the one that floats over the
/// WORLD, which is exactly why its interior is where the plate's presence can be read at all
/// (report B319). Nothing captured the StaticPopup plate before this.
PartyInvite,
/// A V-key nameplate over a synthetic Timber Wolf (the reference client's own screenshot
/// subject: entry 69, level 2, faction 32, display 604) — the plate look-pass instrument.
/// At the 1024×768 window this scenario forces, one gx unit = 1280 px, so the 0.1 × 0.025
@@ -884,6 +891,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
// The group-invite dialog over open world — the shared StaticPopup plate's only look-pass.
// Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-partyinvite`.
Scenario {
name: "ui-partyinvite",
map: MAP_AZEROTH,
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::PartyInvite),
},
// The re-skinned main action bar (no window fixture — the bar loads by default under
// WOW_CAPTURE_UI=1, and `demo_unit_feed` seeds the action slots + player XP). The bar is 1024
// wide + 128px end caps, so this fixture takes a WIDER, shorter window (see main.rs's per-capture
+1 -1
View File
@@ -477,7 +477,7 @@ fn push_rim_arrow(
/// A party member's blip position: the live streamed transform wins; out of visibility range the
/// `PARTY_MEMBER_STATS` snapshot position covers (the wire truncates to i16 — yard precision,
/// invisible at minimap scale). No source → no blip (offline members carry no position).
fn party_member_pos(
pub(crate) fn party_member_pos(
m: &benilla_protocol::messages::GroupMemberEntry,
group: &crate::ui_party::GroupState,
guids: &GuidIndex,
+3
View File
@@ -28,6 +28,9 @@
//! hover tooltip).
mod blips;
/// The party blip's position law, shared with the world map (report B320): one function decides
/// where a member is, so the two surfaces can never disagree about it.
pub(crate) use blips::party_member_pos;
mod composite;
mod interior;
+2
View File
@@ -889,6 +889,8 @@ pub(super) fn apply_net_updates(
members,
leader,
loot,
&mut names,
&net_commands,
),
SessionEvent::PartyCommandResult {
operation,
+99
View File
@@ -61,6 +61,15 @@ pub(super) fn leader_changed(
/// `SMSG_GROUP_LIST` — the roster echo (and the join/leave diff's line source). Roster changes move
/// shared-quest availability, so the questgiver sweep re-asks from here (0654).
///
/// **A roster entry is a sighting** (decision 1564): every member guid is warmed into the
/// [`NameCache`] here, the same ask-once discipline `net::apply::objects` applies the moment a unit
/// streams in. The roster wire carries a member's *name*, so this is not asked for the name — it is
/// asked for the `(race, class, gender)` triple that rides the same answer, and which is the ONLY
/// source of those three for a member we never see: their descriptor never arrives. Two surfaces
/// read them and both were empty for an out-of-area member before this — the raid grid's class
/// column (`ui_party::feed::raid_roster`, whose own-row twin of this hole 1549 §7 found live), and
/// the party frame's 2D portrait stand-in (`portrait::temporary_portrait`, report B315).
#[allow(clippy::too_many_arguments)]
pub(super) fn list(
group: &mut GroupState,
@@ -71,7 +80,12 @@ pub(super) fn list(
members: Vec<GroupMemberEntry>,
leader: u64,
loot: Option<GroupLootInfo>,
names: &mut NameCache,
net_commands: &NetCommands,
) {
for m in &members {
let _ = names.resolve(m.guid, net_commands);
}
let lines = group.apply_list(group_type, own_flags, members, leader, loot);
push_group_lines(chat_log, lines);
quest.bump_reask();
@@ -90,3 +104,88 @@ pub(super) fn command_result(
group.apply_command_result(operation, member, result),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::ClientCommand;
use benilla_protocol::guid;
fn member(g: u64, name: &str) -> GroupMemberEntry {
GroupMemberEntry {
name: name.into(),
guid: g,
status: 1, // ONLINE
flags: 0,
}
}
/// The roster edge is where a member we may never SEE becomes askable. Their descriptor is the
/// only other source of race/class/gender, and it never arrives while they are out of the local
/// area — so without this ask the raid grid's class column and the party frame's portrait
/// stand-in are both permanently blank for exactly the members that need them (B315).
///
/// Ask-ONCE: a re-sent roster (every join, leave, loot-method change re-sends the whole list)
/// must not re-ask, or a busy group would spam a query per member per packet.
#[test]
fn the_roster_warms_every_member_into_the_name_cache_once() {
let (tx, rx) = crossbeam_channel::unbounded();
let net = NetCommands(tx);
let (mut group, mut chat_log, mut quest) = (
GroupState::default(),
ChatLog::default(),
QuestGiver::default(),
);
let mut names = NameCache::default();
// A player guid: `counter | (high << 48)` — the shape `NameCache::resolve` routes on.
let player_guid = |counter: u64| counter | (u64::from(guid::HIGH_PLAYER) << 48);
let (leader, far) = (player_guid(7), player_guid(8));
list(
&mut group,
&mut chat_log,
&mut quest,
0,
0,
vec![member(leader, "Frostshake"), member(far, "Thalyn")],
leader,
None,
&mut names,
&net,
);
let asked: Vec<u64> = rx
.try_iter()
.filter_map(|c| match c {
ClientCommand::NameQuery { guid } => Some(guid),
_ => None,
})
.collect();
assert_eq!(
asked,
vec![leader, far],
"both members asked, in roster order"
);
// The answer lands for one of them; the re-sent roster asks for neither.
names.insert_player(leader, "Frostshake".into(), Some((1, 4, 1)));
list(
&mut group,
&mut chat_log,
&mut quest,
0,
0,
vec![member(leader, "Frostshake"), member(far, "Thalyn")],
leader,
None,
&mut names,
&net,
);
assert!(
rx.try_iter()
.all(|c| !matches!(c, ClientCommand::NameQuery { .. })),
"a re-sent roster re-asks nothing"
);
assert_eq!(names.player_traits(leader), Some((1, 4, 1)));
}
}
+134 -49
View File
@@ -649,6 +649,23 @@ pub(crate) struct BoothBridge<'w> {
pub(crate) panes: ResMut<'w, BoothPanes>,
}
/// The group-facing inputs [`sync_portraits`] needs, in one param: who is in the party
/// ([`crate::ui_party::GroupState`]), the guid→entity index that says which of them are actually
/// STREAMED, the skin-palette table (decision 0720), the pet bar (0990), and the name cache — the
/// only place a member we hold no object for has a race and sex at all (report B315). The index
/// serves the party slots and the pet alike.
///
/// One struct rather than five loose params because `sync_portraits` sits at Bevy's 16-parameter
/// ceiling; it was a bare tuple until the name cache made that tuple's type unreadable.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct PartyBooths<'w> {
roster: Res<'w, crate::ui_party::GroupState>,
index: Res<'w, crate::net::GuidIndex>,
palettes: ResMut<'w, benilla_world::rig_palette::RigPalettes>,
pet_bar: Res<'w, crate::ui_pet::PetBar>,
names: Res<'w, crate::names::NameCache>,
}
/// Tags a booth camera with its slot token, so the model-sync pass can re-frame it per model.
/// (`BoothCam`, not `PortraitCamera` — that name is the authored M2 rig, `benilla_assets::PortraitCamera`.)
/// (`pub(crate)` for the particle census, which reports draws per booth token — decision 0775.)
@@ -1169,42 +1186,59 @@ fn sync_portraits(
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
interact_npc: Res<crate::ui_session::InteractNpc>,
// The party slots' roster + entity index + the skin-palette table (decision 0720), and the pet
// bar (0990) — one tuple param (the 16-SystemParam ceiling). The index serves both.
party: (
Res<crate::ui_party::GroupState>,
Res<crate::net::GuidIndex>,
ResMut<benilla_world::rig_palette::RigPalettes>,
Res<crate::ui_pet::PetBar>,
),
mut party: PartyBooths,
) {
if test_mode(&mut env_cache) {
return; // the test bake owns the booths
}
let (party_roster, party_index, mut palettes, pet_bar) = party;
for token in SLOTS {
// A token can name a unit the object manager does not hold: a party member outside the
// local area, a pet whose object hasn't streamed yet. The reference does NOT leave that
// circle empty — RE C5 (`portrait-render.md`, CONFIRMED taxonomy) is explicit that a unit
// not held as a live UNIT object draws the 2D stand-in, and only "model/subsystem not
// ready" draws the blank. `unseen` carries that art for exactly those tokens; a token that
// names nobody at all leaves it `None` and the booth empties as before (report B315).
let mut unseen: Option<String> = None;
let unit: Option<Entity> = match token {
"player" => self_q.single().ok(),
"target" => selection.target,
// The pet, off the bar's cached guid — the same word its unit token and `UNIT_PET`
// read (`crate::ui_pet::feed_pet_unit`). An unstreamed pet empties the booth, exactly
// as an out-of-range party member does.
"pet" => (pet_bar.spells.pet_guid != 0)
.then(|| party_index.0.get(&pet_bar.spells.pet_guid))
.flatten()
.copied(),
// read (`crate::ui_pet::feed_pet_unit`).
"pet" => {
let guid = party.pet_bar.spells.pet_guid;
let entity = (guid != 0)
.then(|| party.index.0.get(&guid))
.flatten()
.copied();
if entity.is_none() && guid != 0 {
unseen = Some(creature_temporary_portrait());
}
entity
}
// The NPC an interaction window is bound to (gossip / quest / merchant / trainer),
// resolved to its live entity by `feed_interact_npc` — the same bake path as "target".
"npc" => interact_npc.0,
// A party member's slot bakes only while the member is streamed (in range); out of
// range there's no model to pose and the frame's circle stays empty (0434 phase 2 —
// whether the reference substitutes anything there is a phase-4 look question).
tok => tok
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|n| party_roster.party_slots().nth(n - 1))
.and_then(|m| party_index.0.get(&m.guid))
.copied(),
// A party member's slot bakes only while the member is streamed (in range). Out of
// range there is no model to pose — and no descriptor either, so the stand-in's
// race/sex come from the name cache's `SMSG_NAME_QUERY_RESPONSE` triple, warmed for
// every roster entry by `net::apply::group::list`.
tok => {
let member = tok
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|n| party.roster.party_slots().nth(n - 1));
let entity = member.and_then(|m| party.index.0.get(&m.guid)).copied();
if entity.is_none() {
unseen = member.map(|m| {
let traits = party.names.player_traits(m.guid);
player_temporary_portrait(
traits.map(|(race, _, _)| race),
traits.map(|(_, _, sex)| sex),
)
});
}
entity
}
};
let Some(booth) = booths.0.get_mut(token) else {
continue;
@@ -1226,9 +1260,14 @@ fn sync_portraits(
booth.rigged = false;
booth.parked = false;
}
let live = PortraitSource::Live(booth.target.clone());
if portraits.0.get(token) != Some(&live) {
portraits.0.insert(token.to_string(), live);
// A unit we simply hold no object for takes the ref's 2D stand-in; only a token that
// names nobody falls to the emptied render target.
let src = match unseen {
Some(file) => PortraitSource::File(file),
None => PortraitSource::Live(booth.target.clone()),
};
if portraits.0.get(token) != Some(&src) {
portraits.0.insert(token.to_string(), src);
}
continue;
};
@@ -1318,7 +1357,7 @@ fn sync_portraits(
commands.entity(booth.root).despawn_related::<Children>();
let booth_rig = spawn_booth_model(
&mut commands,
&mut palettes,
&mut party.palettes,
booth.root,
booth.layer.clone(),
&booth_parts,
@@ -1905,33 +1944,52 @@ fn gate_booth_cameras(
}
}
/// The stand-in art's folder + stem, shared by the three arms below.
const TEMPORARY_PORTRAIT: &str = "Interface\\CharacterFrame\\TemporaryPortrait";
/// The ref's 2D portrait stand-in for a not-yet-renderable unit (RE C5):
/// `TemporaryPortrait-{Male|Female}-{Race}` for a player body, `-Monster` otherwise.
fn temporary_portrait(net: Option<&NetEntity>, store: Option<&crate::net::ObjectStore>) -> String {
use benilla_protocol::EntityKind;
let base = "Interface\\CharacterFrame\\TemporaryPortrait";
if net.map(|n| n.kind) == Some(EntityKind::Player) {
if let Some(s) = store {
let sex = match s.0.unit_gender() {
Some(1) => "Female",
_ => "Male",
};
let race = match s.0.unit_race() {
Some(1) => "Human",
Some(2) => "Orc",
Some(3) => "Dwarf",
Some(4) => "NightElf",
Some(5) => "Scourge",
Some(6) => "Tauren",
Some(7) => "Gnome",
Some(8) => "Troll",
_ => return format!("{base}.blp"),
};
return format!("{base}-{sex}-{race}.blp");
}
return format!("{base}.blp");
return match store {
Some(s) => player_temporary_portrait(s.0.unit_race(), s.0.unit_gender()),
None => format!("{TEMPORARY_PORTRAIT}.blp"),
};
}
format!("{base}-Monster.blp")
creature_temporary_portrait()
}
/// The player half of [`temporary_portrait`], taking the two bytes rather than a descriptor — a
/// party member outside the local area HAS no descriptor, and reaches the same art through the
/// name cache's `SMSG_NAME_QUERY_RESPONSE` triple instead (report B315).
///
/// An unknown race falls to the plain `TemporaryPortrait.blp`, which is the ref's own behaviour
/// when its `%s-%s` format has no race string to substitute; an unknown sex reads Male, the
/// zero-byte default.
fn player_temporary_portrait(race: Option<u8>, sex: Option<u8>) -> String {
let sex = match sex {
Some(1) => "Female",
_ => "Male",
};
let race = match race {
Some(1) => "Human",
Some(2) => "Orc",
Some(3) => "Dwarf",
Some(4) => "NightElf",
Some(5) => "Scourge",
Some(6) => "Tauren",
Some(7) => "Gnome",
Some(8) => "Troll",
_ => return format!("{TEMPORARY_PORTRAIT}.blp"),
};
format!("{TEMPORARY_PORTRAIT}-{sex}-{race}.blp")
}
/// The creature stand-in — the Monster art rather than the ref's `-Pet` file (our pick; see
/// [`sync_portraits`]'s not-attached-yet arm).
fn creature_temporary_portrait() -> String {
format!("{TEMPORARY_PORTRAIT}-Monster.blp")
}
/// Set the named slot's camera to the rig `frame` built — transform AND projection (the authored
@@ -2128,4 +2186,31 @@ mod tests {
// The same look twice is the same key — the compare must not re-bake every frame.
assert!(LookKey::build(&parts, &riders, &[], &[&glow]) == after);
}
/// RE C5's player arm (`portrait-render.md`, CONFIRMED taxonomy): a unit the object manager
/// does not hold draws `TemporaryPortrait-{Sex}-{Race}`. The out-of-area party member reaches
/// it through the name cache's triple rather than a descriptor — same art, other source
/// (report B315).
#[test]
fn the_stand_in_names_the_sex_and_race_and_falls_back_without_them() {
assert_eq!(
player_temporary_portrait(Some(4), Some(1)),
"Interface\\CharacterFrame\\TemporaryPortrait-Female-NightElf.blp",
);
// Sex 0 is Male, and so is anything else the byte could hold.
assert_eq!(
player_temporary_portrait(Some(6), Some(0)),
"Interface\\CharacterFrame\\TemporaryPortrait-Male-Tauren.blp",
);
// No name answer yet (or a race id the client has no string for): the plain file, which is
// what the ref's `%s-%s` format degenerates to — NOT an empty circle, which is the bug.
assert_eq!(
player_temporary_portrait(None, None),
"Interface\\CharacterFrame\\TemporaryPortrait.blp",
);
assert_eq!(
player_temporary_portrait(Some(9), Some(1)),
"Interface\\CharacterFrame\\TemporaryPortrait.blp",
);
}
}
+23 -10
View File
@@ -211,13 +211,19 @@ fn feed_inspect(
// memo re-pushes the view into the VM that replaced the one which last saw it.
let memo = feed.vm.get(&script);
// The reach map: for every popup token that resolves to a live, inspectable player, its squared
// distance from us. A token is entered ONLY if it passes the two non-distance refusals vmangos
// makes — the target is a player (`sObjectMgr.GetPlayer`) and is not attackable
// The reach map: for every popup token that resolves to a **live unit object**, its squared
// distance from us plus whether it passes the two non-distance refusals vmangos makes for an
// inspect — the target is a player (`sObjectMgr.GetPlayer`) and is not attackable
// (`IsValidAttackTarget`, `MiscHandler.cpp:945-956`). That the *client* checks those two is
// INFERRED (the 348-byte `0x48a1b0`'s non-math part isn't in the RE record), but a wrong guess
// can only cost a request the server would drop. Absent from the map = the bindings' in-range
// default, so a token we simply can't resolve never grays a row.
// can only cost a request the server would drop.
//
// **Membership is the object lookup and nothing else** (report B316). An absent token is one
// the object manager holds no unit for, and the bindings answer `nil` there — the reference's
// own null-object arm. So the attackable refusal rides IN the entry as `inspectable` rather
// than keeping the token out of the map: dropping it would make `CheckInteractDistance` — a
// pure distance test in the binary — gray Follow and Duel on an enemy player the reference
// leaves live.
let self_pair = self_q.iter().next();
let mut reach = std::collections::HashMap::new();
if let Some((self_tf, self_store)) = self_pair {
@@ -225,18 +231,25 @@ fn feed_inspect(
let Some(guid) = crate::ui_unit::player_token_guid(token, &selection, &group) else {
continue;
};
// The object-manager miss: a party member outside the local area has a GUID (the
// roster wire carries it) and no object, which is exactly the case B316 reported.
let Some(&entity) = index.0.get(&guid) else {
continue;
};
let store = stores.get(entity).ok();
if crate::target::can_attack(store, factions.as_deref(), &reputations, Some(self_store))
{
continue;
}
let inspectable = !crate::target::can_attack(
store,
factions.as_deref(),
&reputations,
Some(self_store),
);
if let Ok(tf) = transforms.get(entity) {
reach.insert(
token.to_string(),
dist_sq(tf.translation, self_tf.translation),
benilla_ui::script::UnitReach {
dist_sq: dist_sq(tf.translation, self_tf.translation),
inspectable,
},
);
}
}
@@ -394,7 +394,7 @@ fn corpse_map_position_binding() {
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
assert_eq!((x, y), (0.0, 0.0), "no corpse ⇒ the (0,0) hide sentinel");
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)));
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new());
let (x, y) = s
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
@@ -332,6 +332,14 @@ fn the_real_global_strings_name_the_rank() {
use benilla_ui::script::InspectHonorData;
use std::collections::HashMap;
/// A live, inspectable unit at squared distance `d2` (see `inspect_tests`' twin).
fn reach(d2: f64) -> benilla_ui::script::UnitReach {
benilla_ui::script::UnitReach {
dist_sq: d2,
inspectable: true,
}
}
/// The reply for the inspected player, carrying the same figures as [`state`] so a row that took
/// its number from the wrong pane's feed is visible as a wrong *value*, not just a wrong layout.
fn inspect_reply() -> InspectHonorData {
@@ -383,7 +391,7 @@ fn shown_inspect_honor_page() -> UiScript {
s.set_unit("player", Some(alliance_player()));
// 4 yards away (d² = 16), inside the verified `CanInspect` 100.0 — the window refuses to open
// otherwise, and a silently-refused open would read as a broken pane.
s.set_inspect_reach(HashMap::from([("target".to_string(), 16.0)]));
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s.set_inspect_honor(Some(inspect_reply()));
s.run(r#"InspectUnit("target")"#).unwrap();
s.run(r#"ToggleInspect("BenillaInspectHonorFrame")"#)
@@ -21,9 +21,20 @@
use std::collections::HashMap;
use benilla_ui::script::{
InspectView, InvSlotView, InventorySlots, QuadContent, SoundRequest, UiScript, UnitState,
InspectView, InvSlotView, InventorySlots, QuadContent, SoundRequest, UiScript, UnitReach,
UnitState,
};
/// A reach entry for a live, inspectable unit at squared distance `d2` — the ordinary case. The
/// `inspectable` half is `CanInspect`'s alone (the app folds vmangos's non-distance refusals into
/// it); every test in this file is about distance, so it stays true.
fn reach(dist_sq: f64) -> UnitReach {
UnitReach {
dist_sq,
inspectable: true,
}
}
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
@@ -125,7 +136,7 @@ fn armed() -> UiScript {
s.set_unit("target", Some(target_unit()));
s.set_inspect(Some(inspect_view("target")));
// 4 yards away (d² = 16) — comfortably inside the verified 100.0.
s.set_inspect_reach(HashMap::from([("target".to_string(), 16.0)]));
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s
}
@@ -163,7 +174,7 @@ fn shipped_inspect_frame_loads_clean() {
fn inspect_unit_refuses_out_of_range() {
let mut s = armed();
// 11 yards (d² = 121) — past the verified 100.0 threshold.
s.set_inspect_reach(HashMap::from([("target".to_string(), 121.0)]));
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(121.0))]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
@@ -178,7 +189,7 @@ fn inspect_unit_refuses_out_of_range() {
);
// Step inside the threshold: the same call now opens and requests.
s.set_inspect_reach(HashMap::from([("target".to_string(), 99.9)]));
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(99.9))]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(
s.eval::<bool>("return BenillaInspectFrame:IsVisible()")
@@ -205,7 +216,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
(100.0, true, false),
(100.1, false, false),
] {
s.set_inspect_reach(HashMap::from([("target".to_string(), d2)]));
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(d2))]));
assert_eq!(
s.eval::<bool>(r#"return CanInspect("target") ~= nil"#)
.unwrap(),
@@ -220,7 +231,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
);
}
// Type 4 is the 30-yard row (900.0) — the table is indexed, not hardcoded to one distance.
s.set_inspect_reach(HashMap::from([("target".to_string(), 899.0)]));
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(899.0))]));
assert!(s
.eval::<bool>(r#"return CheckInteractDistance("target", 4) ~= nil"#)
.unwrap());
@@ -229,10 +240,45 @@ fn range_predicates_transcribe_the_verified_thresholds() {
.unwrap(),
"the same distance is out of range for the 10-yard type"
);
// An unknown token never grays a row (missing data must not gate).
assert!(s
.eval::<bool>(r#"return CheckInteractDistance("party3", 1) ~= nil"#)
.unwrap());
// **A token the object manager holds no unit for answers nil — the null-object arm** (report
// B316, wow-re `dist2-null-unit-arm.md` VERIFIED). This is the party member outside the local
// area: the roster wire gives them a GUID, so the token resolves, and the object lookup then
// misses silently. It used to answer 1, which lit every distance row for exactly the member
// who was furthest away.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("party3", 1) == nil"#)
.unwrap(),
"a token with no live unit is out of range, not in"
);
assert!(
s.eval::<bool>(r#"return CanInspect("party3") == nil"#)
.unwrap(),
"…and CanInspect agrees, through its own null-`this` tail"
);
// The `type` argument's three degenerate arms, each the binary's own answer.
s.set_inspect_reach(HashMap::from([("target".to_string(), reach(1.0))]));
for bad in ["0", "5", "-1", "0.5"] {
assert!(
s.eval::<bool>(&format!(
r#"return CheckInteractDistance("target", {bad}) == nil"#
))
.unwrap(),
"type {bad} is outside the table (unsigned compare on trunc(type) − 1)"
);
}
// …but a fractional type INSIDE the range truncates toward zero and answers its row.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("target", 1.9) ~= nil"#)
.unwrap(),
"1.9 chops to 1"
);
// A missing `type` is a usage ERROR, not nil — the reference's `luaL_error`, which longjmps.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("target") ~= nil"#)
.is_err(),
"no distIndex is a script error"
);
}
/// **The doll shows THEIR gear, not ours.** Both sources are fed, with different items in the same
@@ -1743,3 +1743,86 @@ fn the_raid_mark_helper_maps_each_index_to_its_cell() {
}
assert!(s.errors().is_empty(), "no errors: {:?}", s.errors());
}
/// B317 — the player's OWN frame wears the leader and master-looter icons. Every other frame that
/// can wear them has since 0434 phase 2; the player frame predated the party wire and listed them
/// OUT, so the one person who could not see who was leading was the leader.
///
/// The two predicates differ, and the asymmetry is the reference's (`PlayerFrame_UpdatePartyLeader`):
/// the leader icon asks `IsPartyLeader()`, the master icon asks whether `GetLootMethod`'s party
/// index is `0` — the player's own seat on that scale — *and* that we are grouped at all.
#[test]
fn the_player_frame_wears_the_leader_and_master_looter_icons() {
use benilla_ui::script::{PartyMemberInfo, PartyState};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Frostshake".into()),
health: 100,
max_health: 100,
level: 60,
..UnitState::default()
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
let shown = |s: &UiScript, region: &str| -> bool {
s.eval::<bool>(&format!("return {region}:IsVisible()"))
.unwrap()
};
let leader = "PlayerFrameTextureFrameLeaderIcon";
let master = "PlayerFrameTextureFrameMasterIcon";
// Solo: neither. A solo player "leads" nothing — `IsPartyLeader()` is nil without a group.
assert!(!shown(&s, leader), "solo: no leader icon");
assert!(!shown(&s, master), "solo: no master-looter icon");
let party = |leader_index: u32, master_looter: Option<u32>, method: &str| PartyState {
members: vec![PartyMemberInfo {
name: "Thalyn".into(),
guid: 0x7A17,
}],
leader_index,
raid: Vec::new(),
loot_method: method.into(),
master_looter,
loot_threshold: 2,
};
// Grouped, we lead, group loot: leader icon only.
s.set_party(party(0, None, "group"));
s.fire_event("PARTY_LEADER_CHANGED", vec![]);
assert!(shown(&s, leader), "we lead: the leader icon shows");
assert!(!shown(&s, master), "group loot: no master-looter icon");
// Master loot, and we are the master (index 0 — the player's own seat).
s.set_party(party(0, Some(0), "master"));
s.fire_event("PARTY_LOOT_METHOD_CHANGED", vec![]);
assert!(shown(&s, master), "we are master looter: the icon shows");
// The master looter is party1 instead: the icon is theirs, not ours.
s.set_party(party(0, Some(1), "master"));
s.fire_event("PARTY_LOOT_METHOD_CHANGED", vec![]);
assert!(!shown(&s, master), "somebody else masters: our icon hides");
assert!(shown(&s, leader), "…and we still lead");
// Leadership passes to party1: the crown goes with it.
s.set_party(party(1, Some(1), "master"));
s.fire_event("PARTY_LEADER_CHANGED", vec![]);
assert!(!shown(&s, leader), "we no longer lead: the crown hides");
// Leaving the group clears both, through PARTY_MEMBERS_CHANGED alone.
s.set_party(PartyState::default());
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
assert!(
!shown(&s, leader) && !shown(&s, master),
"ungrouped: both hide"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -208,3 +208,78 @@ fn no_landmarks_draws_nothing() {
assert_eq!(s.eval::<i64>("return NUM_WORLDMAP_POIS").unwrap(), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **B320 — party members are on the map.** One frame per `party1..4` slot, placed from
/// `GetPlayerMapPosition` through the same `(0,0)`-means-hide law the player and corpse blips
/// already obey, and hidden again the moment a slot stops answering.
///
/// The seating is the reference's: `CENTER` against `WorldMapDetailFrame`'s `TOPLEFT`, x scaled by
/// its width and y by **minus** its height — UV v runs down the sheet where frame y runs up, and
/// getting that sign wrong mirrors every blip about the top edge without failing anything else.
#[test]
fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
let mut s = harness();
// `IsShown`, not `IsVisible`: the map itself is closed in this harness (the POI test's own
// idiom), so every child would read invisible through its hidden ancestor.
let shown = |s: &UiScript, i: u32| -> bool {
s.eval::<bool>(&format!("return WorldMapParty{i}:IsShown()"))
.unwrap()
};
// Nobody in the party: every slot answers the hide sentinel.
s.run("WorldMapParty_Update()").unwrap();
for i in 1..=4 {
assert!(!shown(&s, i), "slot {i} hides while the party is empty");
}
// Two members on the displayed map, one of them off it (the None the app pushes for a member
// whose position projects outside the rect, or whom we hold no position for at all).
s.set_world_map_feed(
None,
Some((0.5, 0.5)),
0.0,
None,
vec![Some((0.25, 0.75)), None, Some((0.5, 0.125))],
);
s.run("WorldMapParty_Update()").unwrap();
let diag = s
.eval::<String>(
r#"return "p1="..tostring(GetPlayerMapPosition("party1")).." p3="..tostring(GetPlayerMapPosition("party3"))"#,
)
.unwrap();
assert!(
shown(&s, 1) && shown(&s, 3),
"the two placed members show ({diag})"
);
assert!(!shown(&s, 2), "a member with no position stays hidden");
assert!(!shown(&s, 4), "a slot past the roster's end stays hidden");
// The seating, read back against the detail frame's own box.
let (w, h) = s
.eval::<(f64, f64)>(
"return WorldMapDetailFrame:GetWidth(), WorldMapDetailFrame:GetHeight()",
)
.unwrap();
let (blip_x, blip_y) = s
.eval::<(f64, f64)>("return WorldMapParty1:GetCenter()")
.unwrap();
let (sheet_left, sheet_top) = s
.eval::<(f64, f64)>("return WorldMapDetailFrame:GetLeft(), WorldMapDetailFrame:GetTop()")
.unwrap();
assert!(
(blip_x - (sheet_left + 0.25 * w)).abs() < 0.5,
"u scales by the sheet's width"
);
assert!(
(blip_y - (sheet_top - 0.75 * h)).abs() < 0.5,
"v runs DOWN from the sheet's top"
);
// The party breaks up: the blips go with it.
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new());
s.run("WorldMapParty_Update()").unwrap();
for i in 1..=4 {
assert!(!shown(&s, i), "slot {i} hides when the party is gone");
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+38 -1
View File
@@ -257,6 +257,11 @@ fn feed_world_map(
areas: Option<Res<crate::area::AreaTableRes>>,
death_net: Res<crate::death::DeathNet>,
poi_marker: Res<crate::poi_marker::PoiMarker>,
// The party blips' two position sources (B320): the roster + the wire's per-member stats, and
// the streamed-entity index that beats them when a member is actually in the world with us.
group: Res<crate::ui_party::GroupState>,
guids: Res<crate::net::GuidIndex>,
unit_pos: Query<&GlobalTransform, With<crate::net::NetEntity>>,
mut last_explored: Local<crate::ui_script::VmMemo<Option<Vec<u32>>>>,
) {
let (Some(mut script), Some(data), Some(map), Some(areas)) = (script, data, map, areas) else {
@@ -352,7 +357,39 @@ fn feed_world_map(
.collect();
script.set_world_map_landmarks(landmarks);
script.set_world_map_feed(player_zone, uv, player.facing(), corpse_uv);
// The party slots' blips (report B320) — `party1..4` in the same order the frames and the
// unit tokens use (`GroupState::party_slots`).
//
// Two position sources, the minimap's own law (`minimap::blips::party_member_pos`): a member
// who is STREAMED has a real transform, and anyone else has the `(x, y)` their
// `SMSG_PARTY_MEMBER_STATS` carried — an `i16` pair, so a far member's blip is yard-accurate
// and no better, which is all the reference has for them either.
//
// Which MAP that position belongs to is the part the stats packet does not say: it carries a
// zone, not a map id. So the member's zone is looked up in the catalog and the continent that
// owns it supplies the map; a zone we cannot place (an instance, a zone missing from the
// catalog) falls back to ours, which is right for the overwhelmingly common case of a party
// spread across one continent and merely projects off-rect — the (0,0) hide — when it is not.
let party_uv: Vec<Option<(f32, f32)>> = group
.party_slots()
.map(|m| {
let (px, py) = crate::minimap::party_member_pos(m, &group, &guids, &unit_pos)?;
let member_map = group
.stats
.get(&m.guid)
.and_then(|st| st.zone)
.and_then(|zone| {
data.continents
.iter()
.find(|cont| cont.zones.iter().any(|z| z.area_id == u32::from(zone)))
.map(|cont| cont.map_id)
})
.unwrap_or(map.0);
project(member_map, px, py).filter(|uv| *uv != (0.0, 0.0))
})
.collect();
script.set_world_map_feed(player_zone, uv, player.facing(), corpse_uv, party_uv);
}
/// **Alt+click the world map to go there** — the dev jump.
+65 -17
View File
@@ -52,6 +52,25 @@ pub const INTERACT_DIST_SQ: [f64; 4] = [100.0, 123.45678, 100.0, 900.0];
/// as `INSPECT_DISTANCE` (`ObjectDefines.h:26`), so client and server agree exactly.
pub const CAN_INSPECT_DIST_SQ: f64 = 100.0;
/// What the app resolved about one popup token's unit this frame — the input both range predicates
/// read ([`UiScript::set_inspect_reach`]).
///
/// An entry exists **iff the token resolved to a live unit object**, which is the distinction the
/// reference's own bindings turn on: `0x515940` maps the token to a GUID and then asks the object
/// manager for it, and a NULL there is answered `nil` without any distance being computed. A party
/// member outside the local area is exactly that case — `0x4e81a0` reads their GUID straight out of
/// the roster array, so the GUID is non-zero and the object lookup misses silently.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UnitReach {
/// Squared distance from the player, in the binary's own accumulation shape.
pub dist_sq: f64,
/// The unit passes the two NON-distance refusals vmangos makes for an inspect
/// (`sObjectMgr.GetPlayer` and `!IsValidAttackTarget`, `MiscHandler.cpp:945-956`) — consulted
/// by `CanInspect` alone. `CheckInteractDistance` is a pure distance test in the binary and
/// must not read this, or following an enemy player would gray a row the reference leaves live.
pub inspectable: bool,
}
/// What the app has resolved for the unit currently being inspected (decision 0631).
#[derive(Clone, Debug, PartialEq)]
pub struct InspectView {
@@ -77,9 +96,13 @@ impl super::UiScript {
/// no `UnitState` (only `"player"`/`"target"`/`"mouseover"` are fed) — keying it off the unit
/// snapshot would have left the party menu's rows permanently dead.
///
/// A token absent from the map reads as **in range**, never out: missing data must not gray a
/// row that the reference would have left enabled.
pub fn set_inspect_reach(&mut self, reach: HashMap<String, f64>) {
/// A token absent from the map is a token the object manager holds **no unit for**, and both
/// predicates answer `nil` there — the reference's own null-object arm (wow-re
/// `system/ui/scratch/dist2-null-unit-arm.md`, VERIFIED: `0x48babe test ecx,ecx; je` →
/// `lua_pushnil`). It used to read as *in range*, on the reasoning that missing data must not
/// gray a row; the binary says the opposite, and that default was report B316 — every distance
/// row lit up for exactly the party member who was too far away to have an object at all.
pub fn set_inspect_reach(&mut self, reach: HashMap<String, UnitReach>) {
self.model_mut().inspect_reach = reach;
}
@@ -110,8 +133,9 @@ impl super::UiScript {
}
}
/// The squared distance to `token`, or `None` when the app fed none (→ treated as in range).
fn reach(lua: &Lua, token: &Option<String>) -> Option<f64> {
/// What the app resolved for `token`, or `None` when it resolved to no live unit at all — the
/// reference's NULL-object arm, which both predicates answer `nil` to.
fn reach(lua: &Lua, token: &Option<String>) -> Option<UnitReach> {
let token = token.as_deref()?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.inspect_reach.get(token).copied()
@@ -133,11 +157,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"CanInspect",
lua.create_function(|lua, unit: Option<String>| {
let ok = match reach(lua, &unit) {
Some(d2) => matches!(
d2.partial_cmp(&CAN_INSPECT_DIST_SQ),
Some(Ordering::Less | Ordering::Equal) | None
),
// No distance at all means we could not resolve the unit — nothing to inspect.
Some(r) => {
r.inspectable
&& matches!(
r.dist_sq.partial_cmp(&CAN_INSPECT_DIST_SQ),
Some(Ordering::Less | Ordering::Equal) | None
)
}
// No live unit object: the binary reaches its `lua_pushnil` tail through
// `0x4944a0`'s null-`this` guard rather than an early return, but the value it
// pushes is the same one — nothing to inspect.
None => false,
};
Ok(if ok { Value::Integer(1) } else { Value::Nil })
@@ -148,18 +177,37 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `PRIMITIVE:check_interact_dist2` @ `0x48ba00`): **in range iff `d² < table[type-1]`** —
// note the STRICT `<` here against `CanInspect`'s non-strict gate above, which is the
// binary's own asymmetry (`test ah,0x5; jp` takes the out path unless `d² < thr` ordered), not
// a transcription slip. The UnitPopup rows' `dist` field indexes it. An unknown token / out-of
// -range `type` answers 1 (in range): missing data must never gray a row.
// a transcription slip. The UnitPopup rows' `dist` field indexes it.
//
// The three degenerate arms are the binary's too (`dist2-null-unit-arm.md`, VERIFIED), and
// they are three DIFFERENT answers rather than one permissive default:
//
// - **No live unit for the token** → `nil` (`0x48babe test ecx,ecx; je`). This is the party
// member outside the local area, and answering `1` here was report B316.
// - **`type` numeric but outside 1..4** → `nil`. The compare is UNSIGNED on `trunc(type) − 1`
// (`0x48bac5 cmp esi,0x4; jae`), so `0`, negatives and `≥ 5` all take it, and a fractional
// type truncates toward zero first (`__ftol`: `1.9 → 1`, `0.5 → 0 → nil`).
// - **`type` missing or not a number** → a **script error**, not `nil`
// (`0x48bb48 call luaL_error`, which longjmps and never returns). Same for a non-string
// unit. A caller that passes neither argument is not asking a question the reference
// answers.
g.set(
"CheckInteractDistance",
lua.create_function(|lua, (unit, kind): (Option<String>, Option<i64>)| {
let thr = kind
.and_then(|k| usize::try_from(k).ok())
lua.create_function(|lua, (unit, kind): (Option<String>, Option<f64>)| {
let Some(kind) = kind else {
return Err(mlua::Error::RuntimeError(
"Usage: CheckInteractDistance(\"unit\", distIndex)".into(),
));
};
// `__ftol` chops toward zero; the index is then compared UNSIGNED, so anything that
// is not exactly 1..=4 falls out of the table lookup and answers nil.
let thr = usize::try_from(kind.trunc() as i64)
.ok()
.and_then(|k| k.checked_sub(1))
.and_then(|i| INTERACT_DIST_SQ.get(i).copied());
let ok = match (reach(lua, &unit), thr) {
(Some(d2), Some(thr)) => d2 < thr,
_ => true,
(Some(r), Some(thr)) => r.dist_sq < thr,
_ => false,
};
Ok(if ok { Value::Integer(1) } else { Value::Nil })
})?,
+1 -1
View File
@@ -168,7 +168,7 @@ pub use guild::{
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
};
pub use inspect::InspectView;
pub use inspect::{InspectView, UnitReach};
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
pub use item_text::ItemTextState;
pub use loot::{LootRow, LootState};
+5 -5
View File
@@ -1138,11 +1138,11 @@ pub(crate) struct Model {
pub(crate) dressup_intents: Vec<super::dressup::DressUpIntent>,
/// The dressing-room pane's bake yaw, the fourth of those scalars.
pub(crate) dressup_yaw: f32,
/// Unit token → squared distance from the player, for every popup token the app resolved to a
/// live inspectable player this frame — the input to both verified range predicates
/// (`CanInspect`, `CheckInteractDistance`). Absent token = in range (see
/// [`UiScript::set_inspect_reach`]).
pub(crate) inspect_reach: HashMap<String, f64>,
/// Unit token → what the app resolved about it this frame, for every popup token that named a
/// **live unit object** — the input to both verified range predicates (`CanInspect`,
/// `CheckInteractDistance`). An absent token is one the object manager holds nothing for, and
/// both answer `nil` there (see [`UiScript::set_inspect_reach`] and [`super::UnitReach`]).
pub(crate) inspect_reach: HashMap<String, super::UnitReach>,
/// The skills-pane snapshot the app pushes ([`skills::SkillsState`], decision 0437 phase 4) and
/// the synthesized display tree built from it ([`skills::UiScript::set_skills`]) — the skills
+24 -8
View File
@@ -150,13 +150,20 @@ fn worldmap_navigation_and_map_info() {
}
/// SetMapToCurrentZone lands on the app-fed player zone; the feed's projection + facing surface
/// through GetPlayerMapPosition/GetPlayerFacing (and only for "player" — party is a later arc).
/// through GetPlayerMapPosition/GetPlayerFacing — for `"player"` and, since report B320, for the
/// `party1..4` slots too. A `raid` token still answers the off-map sentinel.
#[test]
fn worldmap_current_zone_and_player_feed() {
let mut s = script();
push_catalog(&mut s);
s.set_world_map_feed(Some((1, 2)), Some((0.25, 0.75)), 1.5, None);
s.set_world_map_feed(
Some((1, 2)),
Some((0.25, 0.75)),
1.5,
None,
vec![Some((0.1, 0.2)), None],
);
s.run("SetMapToCurrentZone()").unwrap();
assert_eq!(
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
@@ -168,15 +175,24 @@ fn worldmap_current_zone_and_player_feed() {
.unwrap();
assert!((x - 0.25).abs() < 1e-6 && (y - 0.75).abs() < 1e-6);
assert!((s.eval::<f64>("return GetPlayerFacing()").unwrap() - 1.5).abs() < 1e-6);
assert_eq!(
s.eval::<(f64, f64)>(r#"return GetPlayerMapPosition("party1")"#)
.unwrap(),
(0.0, 0.0),
"non-player units are off-map until the party arc"
let (px, py) = s
.eval::<(f64, f64)>(r#"return GetPlayerMapPosition("party1")"#)
.unwrap();
assert!(
(px - 0.1).abs() < 1e-6 && (py - 0.2).abs() < 1e-6,
"a party slot reads its own projection (B320)"
);
for token in ["party2", "party5", "party0", "raid1", "nonsense"] {
assert_eq!(
s.eval::<(f64, f64)>(&format!(r#"return GetPlayerMapPosition("{token}")"#))
.unwrap(),
(0.0, 0.0),
"{token} answers the off-map sentinel"
);
}
// No feed → the world sheet (never an error).
s.set_world_map_feed(None, None, 0.0, None);
s.set_world_map_feed(None, None, 0.0, None, Vec::new());
s.run("SetMapToCurrentZone()").unwrap();
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
}
+21 -3
View File
@@ -208,6 +208,14 @@ pub struct WorldMapState {
/// `GetPlayerMapPosition("player")` for the CURRENT selection — projected app-side each frame
/// (`map_proj`), `None`/(0,0) = off this map (the reference hides the blip).
pub player_uv: Option<(f32, f32)>,
/// `GetPlayerMapPosition("party1".."party4")` for the CURRENT selection, in `party_slots`
/// order — projected app-side through the same law as [`Self::player_uv`] (report B320).
/// A slot is `None` when there is no member there, when we hold no position for them, or when
/// that position is off the displayed map; all three read as the reference's `(0,0)` hide
/// sentinel at the binding, which is the only answer its FrameXML consumer knows.
///
/// Shorter than 4 whenever the party is: a missing index is a `None`.
pub party_uv: Vec<Option<(f32, f32)>>,
/// `GetPlayerFacing()` — wow orientation radians (0 = north, counterclockwise-positive). Our
/// transcribed frame spins its arrow texture with it (`SetRotation`); the reference rotates
/// an engine-rendered arrow model instead (0203 flags the stand-in).
@@ -297,19 +305,22 @@ impl super::UiScript {
}
}
/// Push the per-frame feed (player zone/projection/facing — see [`WorldMapState`]).
/// Push the per-frame feed (player zone/projection/facing, the corpse marker, and the party
/// slots' projections — see [`WorldMapState`]).
pub fn set_world_map_feed(
&mut self,
player_zone: Option<(u32, u32)>,
player_uv: Option<(f32, f32)>,
player_facing: f32,
corpse_uv: Option<(f32, f32)>,
party_uv: Vec<Option<(f32, f32)>>,
) {
let mut model = self.model_mut();
model.worldmap.player_zone = player_zone;
model.worldmap.player_uv = player_uv;
model.worldmap.player_facing = player_facing;
model.worldmap.corpse_uv = corpse_uv;
model.worldmap.party_uv = party_uv;
}
/// The engine-owned selection `(continent, zone)` — the app reads it each frame to project
@@ -471,14 +482,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetPlayerMapPosition(unit) → x, y in [0,1] map UV; (0,0) = not on this map (hide the
// blip). Only "player" resolves — party/raid are the party arc's, not this one's (0203).
// blip). `"player"` and `"party1".."party4"` resolve (report B320); `"raid1".."raid40"` do
// not yet — the reference's raid arm draws a 25-frame pool this map has no children for, and
// its party arm is the one a five-man actually takes.
g.set(
"GetPlayerMapPosition",
lua.create_function(|lua, unit: String| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (x, y) = match unit.as_str() {
"player" => model.worldmap.player_uv.unwrap_or((0.0, 0.0)),
_ => (0.0, 0.0),
u => u
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| *n >= 1)
.and_then(|n| model.worldmap.party_uv.get(n - 1).copied().flatten())
.unwrap_or((0.0, 0.0)),
};
Ok((f64::from(x), f64::from(y)))
})?,