From 11a0b68da6546adb283ec348a68672293416f7eb Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 25 Aug 2026 18:30:24 +0700 Subject: [PATCH] party members show on the world map, and the leader wears the crown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/benilla-app/assets/ui/UnitFrames.xml | 68 ++++++- .../benilla-app/assets/ui/WorldMapFrame.xml | 102 +++++++++- crates/benilla-app/src/capture/fixtures.rs | 11 ++ crates/benilla-app/src/capture/scenarios.rs | 17 ++ crates/benilla-app/src/minimap/blips/mod.rs | 2 +- crates/benilla-app/src/minimap/mod.rs | 3 + crates/benilla-app/src/net/apply.rs | 2 + crates/benilla-app/src/net/apply/group.rs | 99 ++++++++++ crates/benilla-app/src/portrait/mod.rs | 183 +++++++++++++----- crates/benilla-app/src/ui_inspect.rs | 33 +++- .../benilla-app/src/ui_script/death_tests.rs | 2 +- .../src/ui_script/honor_frame_tests.rs | 10 +- .../src/ui_script/inspect_tests.rs | 66 ++++++- .../src/ui_script/unit_frame_tests.rs | 83 ++++++++ .../src/ui_script/world_map_tests.rs | 75 +++++++ crates/benilla-app/src/ui_world_map.rs | 39 +++- crates/benilla-ui/src/script/inspect.rs | 82 ++++++-- crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/script/model.rs | 10 +- .../benilla-ui/src/script/tests/worldmap.rs | 32 ++- crates/benilla-ui/src/script/worldmap.rs | 24 ++- 21 files changed, 834 insertions(+), 111 deletions(-) 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> = 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. diff --git a/crates/benilla-ui/src/script/inspect.rs b/crates/benilla-ui/src/script/inspect.rs index d89eea21..dcf36788 100644 --- a/crates/benilla-ui/src/script/inspect.rs +++ b/crates/benilla-ui/src/script/inspect.rs @@ -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) { + /// 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) { 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) -> Option { +/// 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) -> Option { let token = token.as_deref()?; let model = lua.app_data_ref::().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| { 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, Option)| { - let thr = kind - .and_then(|k| usize::try_from(k).ok()) + lua.create_function(|lua, (unit, kind): (Option, Option)| { + 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 }) })?, diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index caf6277d..081a91fe 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index c34975d6..d20d1356 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1138,11 +1138,11 @@ pub(crate) struct Model { pub(crate) dressup_intents: Vec, /// 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, + /// 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, /// 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 diff --git a/crates/benilla-ui/src/script/tests/worldmap.rs b/crates/benilla-ui/src/script/tests/worldmap.rs index 543e2bba..d867bfbd 100644 --- a/crates/benilla-ui/src/script/tests/worldmap.rs +++ b/crates/benilla-ui/src/script/tests/worldmap.rs @@ -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::("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::("return GetCurrentMapContinent()").unwrap(), 0); } diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index 26e911e3..3b7d8482 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -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>, /// `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>, ) { 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::().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::().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))) })?,