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