diff --git a/crates/benilla-app/assets/ui/UnitFrames.xml b/crates/benilla-app/assets/ui/UnitFrames.xml
index 977c992d..7111ecce 100644
--- a/crates/benilla-app/assets/ui/UnitFrames.xml
+++ b/crates/benilla-app/assets/ui/UnitFrames.xml
@@ -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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/benilla-app/assets/ui/WorldMapFrame.xml b/crates/benilla-app/assets/ui/WorldMapFrame.xml
index 7dc02514..fb88fa99 100644
--- a/crates/benilla-app/assets/ui/WorldMapFrame.xml
+++ b/crates/benilla-app/assets/ui/WorldMapFrame.xml
@@ -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 @@
+
+
+
+
+
+
+
+
+
+
+ WorldMapParty_OnEnter(this)
+ GameTooltip:Hide()
+
+
+
+
+
+
+
+
+
+
+
+ WorldMapParty_OnEnter(this)
+ GameTooltip:Hide()
+
+
+
+
+
+
+
+
+
+
+
+ WorldMapParty_OnEnter(this)
+ GameTooltip:Hide()
+
+
+
+
+
+
+
+
+
+
+
+ WorldMapParty_OnEnter(this)
+ GameTooltip:Hide()
+
+
diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs
index 91f1b0c7..a55a4c0e 100644
--- a/crates/benilla-app/src/capture/fixtures.rs
+++ b/crates/benilla-app/src/capture/fixtures.rs
@@ -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;
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index 0b4c7d69..506edf4a 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -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
diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs
index 78757a62..fcccf13e 100644
--- a/crates/benilla-app/src/minimap/blips/mod.rs
+++ b/crates/benilla-app/src/minimap/blips/mod.rs
@@ -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,
diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs
index 69fce786..84b64dcd 100644
--- a/crates/benilla-app/src/minimap/mod.rs
+++ b/crates/benilla-app/src/minimap/mod.rs
@@ -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;
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index 05e7d834..855dcffb 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -889,6 +889,8 @@ pub(super) fn apply_net_updates(
members,
leader,
loot,
+ &mut names,
+ &net_commands,
),
SessionEvent::PartyCommandResult {
operation,
diff --git a/crates/benilla-app/src/net/apply/group.rs b/crates/benilla-app/src/net/apply/group.rs
index 2d863ccf..b74c1143 100644
--- a/crates/benilla-app/src/net/apply/group.rs
+++ b/crates/benilla-app/src/net/apply/group.rs
@@ -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,
leader: u64,
loot: Option,
+ 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 = 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)));
+ }
+}
diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs
index 43a44f51..431a9f28 100644
--- a/crates/benilla-app/src/portrait/mod.rs
+++ b/crates/benilla-app/src/portrait/mod.rs
@@ -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>,
interact_npc: Res,
- // 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,
- Res,
- ResMut,
- Res,
- ),
+ 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 = None;
let unit: Option = 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::().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::().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::();
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, sex: Option) -> 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",
+ );
+ }
}
diff --git a/crates/benilla-app/src/ui_inspect.rs b/crates/benilla-app/src/ui_inspect.rs
index 481dd7aa..31017413 100644
--- a/crates/benilla-app/src/ui_inspect.rs
+++ b/crates/benilla-app/src/ui_inspect.rs
@@ -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,
+ },
);
}
}
diff --git a/crates/benilla-app/src/ui_script/death_tests.rs b/crates/benilla-app/src/ui_script/death_tests.rs
index bf5c1773..3bbe31a3 100644
--- a/crates/benilla-app/src/ui_script/death_tests.rs
+++ b/crates/benilla-app/src/ui_script/death_tests.rs
@@ -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();
diff --git a/crates/benilla-app/src/ui_script/honor_frame_tests.rs b/crates/benilla-app/src/ui_script/honor_frame_tests.rs
index 25c3017c..9aba2b23 100644
--- a/crates/benilla-app/src/ui_script/honor_frame_tests.rs
+++ b/crates/benilla-app/src/ui_script/honor_frame_tests.rs
@@ -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")"#)
diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs
index 3096ccda..fabfc9e5 100644
--- a/crates/benilla-app/src/ui_script/inspect_tests.rs
+++ b/crates/benilla-app/src/ui_script/inspect_tests.rs
@@ -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/` 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::("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::(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::(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::(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::(r#"return CheckInteractDistance("party3", 1) == nil"#)
+ .unwrap(),
+ "a token with no live unit is out of range, not in"
+ );
+ assert!(
+ s.eval::(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::(&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::(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::(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
diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs
index 1a236f45..4c513863 100644
--- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs
+++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs
@@ -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::(&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, 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());
+}
diff --git a/crates/benilla-app/src/ui_script/world_map_tests.rs b/crates/benilla-app/src/ui_script/world_map_tests.rs
index 61fa5d20..95c7fd82 100644
--- a/crates/benilla-app/src/ui_script/world_map_tests.rs
+++ b/crates/benilla-app/src/ui_script/world_map_tests.rs
@@ -208,3 +208,78 @@ fn no_landmarks_draws_nothing() {
assert_eq!(s.eval::("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::(&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::(
+ 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());
+}
diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs
index c9ea5c6e..e7dc702d 100644
--- a/crates/benilla-app/src/ui_world_map.rs
+++ b/crates/benilla-app/src/ui_world_map.rs
@@ -257,6 +257,11 @@ fn feed_world_map(
areas: Option>,
death_net: Res,
poi_marker: Res,
+ // 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,
+ guids: Res,
+ unit_pos: Query<&GlobalTransform, With>,
mut last_explored: Local>>>,
) {
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