fishing becomes whole: the bite, the line, the splash and the reel
- the whole loop plays: the line arcs rod tip to bobber, the bite splashes with its own sound, and clicking the bobber reels in - the verdicts read like 1.12: yellow "your fish got away" lines, the reel-in sound, the fishing loot window - "You feel rested." prints on the transition, rest states come from the client's own Exhaustion table, and level 60 trades the XP strip for the max-level rail - the Skills pane lists what the real client lists: the hidden rows, the sort, gray numberless single-rank lines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -592,6 +592,7 @@ dependencies = [
|
||||
"benilla-formats",
|
||||
"benilla-protocol",
|
||||
"benilla-ui",
|
||||
"benilla-wmo",
|
||||
"bevy",
|
||||
"bevy_egui",
|
||||
"bytemuck",
|
||||
|
||||
@@ -82,6 +82,12 @@ mint = "0.5"
|
||||
# source. Pinned to 0.8: the version `benilla-srp` already depends on, so this adds no crate.
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
# The WMO portal-cull audit's raw-face instruments (`WOW_PIN_COL`): the pin probe compares the
|
||||
# filtered walking-collision set against the group file's FULL face list with per-face MOPY flags,
|
||||
# which only the raw parser exposes — benilla-formats' gathers are exactly the filtered views.
|
||||
benilla-wmo = { path = "../benilla-wmo" }
|
||||
|
||||
# Linux/BSD: the Wayland clipboard (decision 0702). We build with bevy's `wayland` feature, so on a
|
||||
# Wayland session winit gives us a native Wayland window and `arboard` — X11-only without its
|
||||
# non-default `wayland-data-control` feature — finds no X11 CLIPBOARD owner and every paste fails.
|
||||
|
||||
@@ -648,13 +648,54 @@
|
||||
BenillaExhaustionTick.timer = nil
|
||||
end
|
||||
|
||||
-- ── The max-level rail (ref-ReputationFrame.lua, the max-level slice) ─────────────────
|
||||
-- ref l.14: the constant lives in ReputationFrame.lua, which isn't built yet — it moves
|
||||
-- there with the reputation system.
|
||||
MAX_PLAYER_LEVEL = 60
|
||||
|
||||
function BenillaMaxLevelBar_OnLoad(bar)
|
||||
bar:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
bar:RegisterEvent("PLAYER_LEVEL_UP")
|
||||
end
|
||||
|
||||
-- ref ReputationWatchBar_Update's else arm (ref-ReputationFrame.lua l.161-166 +
|
||||
-- l.236-244), the no-watched-faction branch only — the reputation branches wait for the
|
||||
-- reputation system: at MAX_PLAYER_LEVEL the XP strip gives way to the rail and the
|
||||
-- tick goes with it. Login seam: the ref initializes off UPDATE_FACTION (its OnEvent's
|
||||
-- else arm; the faction list lands at every login), an event benilla doesn't carry yet,
|
||||
-- so the rail listens to PLAYER_ENTERING_WORLD instead — same moment, same values.
|
||||
function BenillaMaxLevelBar_Update(newLevel)
|
||||
if not newLevel then
|
||||
newLevel = UnitLevel("player")
|
||||
end
|
||||
if newLevel == MAX_PLAYER_LEVEL then
|
||||
BenillaExpBar:Hide()
|
||||
BenillaMaxLevelBar:Show()
|
||||
BenillaExhaustionTick:Hide()
|
||||
else
|
||||
BenillaExpBar:Show()
|
||||
BenillaMaxLevelBar:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
-- The ref's event fan (ref-ReputationFrame.xml l.1002-1016): PLAYER_LEVEL_UP passes
|
||||
-- arg1 (the new level — the level FIELD may not have landed yet), everything else
|
||||
-- re-reads UnitLevel.
|
||||
function BenillaMaxLevelBar_OnEvent()
|
||||
if event == "PLAYER_LEVEL_UP" then
|
||||
BenillaMaxLevelBar_Update(arg1)
|
||||
else
|
||||
BenillaMaxLevelBar_Update()
|
||||
end
|
||||
end
|
||||
|
||||
-- ── The exhaustion tick (ref-MainMenuBar.lua ExhaustionTick_*) ────────────────────────
|
||||
-- The round marker riding the XP bar at the point where rested XP runs out, plus the
|
||||
-- pale fill between the bar's value and that point. Two transcription cuts, named:
|
||||
-- the ref's MainMenuBarMaxLevelBar / ReputationWatchBar guards reference max-level bar
|
||||
-- art and the faction watch bar, neither built — each is its own slice, and until then
|
||||
-- the guards' frames can't be shown, so the cuts are unreachable-code cuts, not behavior
|
||||
-- cuts. (A level-60 body simply keeps its inert XP bar for now.)
|
||||
-- pale fill between the bar's value and that point. One transcription cut left, named:
|
||||
-- the ref's two ReputationWatchBar guards (the max-level+rep tick hide, l.51-54, and
|
||||
-- the rep-bar-replaced-the-xp-bar hide, l.69-71) reference the faction watch bar, not
|
||||
-- built — its frame can't be shown, so the cut is an unreachable-code cut, not a
|
||||
-- behavior cut. The MainMenuBarMaxLevelBar guard is live since 1094 (the rail below).
|
||||
function BenillaExhaustionTick_OnLoad(tick)
|
||||
tick:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
tick:RegisterEvent("PLAYER_XP_UPDATE")
|
||||
@@ -683,7 +724,8 @@
|
||||
local exhaustionTickSet = max(((playerCurrXP + exhaustionThreshold)
|
||||
/ playerMaxXP) * BenillaExpBar:GetWidth(), 0)
|
||||
tick:ClearAllPoints()
|
||||
if exhaustionTickSet > BenillaExpBar:GetWidth() then
|
||||
if exhaustionTickSet > BenillaExpBar:GetWidth()
|
||||
or BenillaMaxLevelBar:IsShown() then
|
||||
tick:Hide()
|
||||
BenillaExhaustionLevelFillBar:Hide()
|
||||
else
|
||||
@@ -1049,6 +1091,58 @@
|
||||
<HighlightTexture name="BenillaExhaustionTickHighlight" file="Interface\MainMenuBar\UI-ExhaustionTickHighlight" alphaMode="ADD"/>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The max-level rail (ref-MainMenuBar.xml l.161-225 MainMenuBarMaxLevelBar): the
|
||||
flat brass rail that replaces the XP strip at MAX_PLAYER_LEVEL — four 256×7
|
||||
quarters of UI-MainMenuBar-MaxLevel, riding just above the frame's own 7 px rect
|
||||
(each quarter's BOTTOM sits on the frame's TOP, the ref's own geometry).
|
||||
Shown/hidden by BenillaMaxLevelBar_Update (decision 1094). The ref's OnShow/
|
||||
OnHide call UIParent_ManageFramePositions, which benilla doesn't build
|
||||
(DurabilityFrame.xml's note) — a named cut.
|
||||
-->
|
||||
<Frame name="BenillaMaxLevelBar" enableMouse="true" hidden="true">
|
||||
<Size><AbsDimension x="1024" y="7"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-11"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="BenillaMaxLevelBar0" file="Interface\MainMenuBar\UI-MainMenuBar-MaxLevel">
|
||||
<Size><AbsDimension x="256" y="7"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM" relativePoint="TOP"><Offset><AbsDimension x="-384" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<TexCoords left="0" right="1.0" top="0" bottom="0.21875"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMaxLevelBar1" file="Interface\MainMenuBar\UI-MainMenuBar-MaxLevel">
|
||||
<Size><AbsDimension x="256" y="7"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="BenillaMaxLevelBar0" relativePoint="RIGHT"/>
|
||||
</Anchors>
|
||||
<TexCoords left="0" right="1.0" top="0.25" bottom="0.46875"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMaxLevelBar2" file="Interface\MainMenuBar\UI-MainMenuBar-MaxLevel">
|
||||
<Size><AbsDimension x="256" y="7"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="BenillaMaxLevelBar1" relativePoint="RIGHT"/>
|
||||
</Anchors>
|
||||
<TexCoords left="0" right="1.0" top="0.5" bottom="0.71875"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMaxLevelBar3" file="Interface\MainMenuBar\UI-MainMenuBar-MaxLevel">
|
||||
<Size><AbsDimension x="256" y="7"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="BenillaMaxLevelBar2" relativePoint="RIGHT"/>
|
||||
</Anchors>
|
||||
<TexCoords left="0" right="1.0" top="0.75" bottom="0.96875"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaMaxLevelBar_OnLoad(self)</OnLoad>
|
||||
<OnEvent>BenillaMaxLevelBar_OnEvent()</OnEvent>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!--
|
||||
The art frame: fills the bar (ref-MainMenuBar.xml l.225-229). Carries the 4 dwarf
|
||||
strips (ARTWORK) + the 2 end caps and page number (OVERLAY); the buttons are its
|
||||
|
||||
@@ -258,15 +258,21 @@
|
||||
function BenillaLootFrame_OnShow()
|
||||
-- Open sound — the window-sound convention (decision 0090), wrapper form (this handler
|
||||
-- has real logic beyond the sound, so it can't be a bare inline PlaySound).
|
||||
-- (LootFrame.lua LootFrame_OnShow, l.131-141). ONLY the empty-roll fork is
|
||||
-- Lua-side: an empty loot plays LOOTWINDOWOPENEMPTY (l.135-136). The normal (non-empty)
|
||||
-- loot-open sound is C-side (the server-driven loot handler), so we stay silent for it —
|
||||
-- there is no kit to guess. The fishing fork (l.137-139, "FISHING REEL IN" + the
|
||||
-- FishingLoot-Icon portrait swap) is omitted: benilla ships no fishing feed. (DR 0086:
|
||||
-- vmangos masks the lootable flag for empty rolls, so a live empty window is rare — but
|
||||
-- the branch must exist.)
|
||||
-- (LootFrame.lua LootFrame_OnShow, l.131-141). ONLY the empty-roll fork and the fishing
|
||||
-- fork are Lua-side: an empty loot plays LOOTWINDOWOPENEMPTY (l.135-136); a fishing loot
|
||||
-- (IsFishingLoot(), decision 1086) plays "FISHING REEL IN" and swaps the portrait ring's
|
||||
-- skull for the FishingLoot-Icon (l.137-140) — reset to the skull first, exactly as the
|
||||
-- ref does (l.133), so a corpse loot after a fishing one gets its skull back. The normal
|
||||
-- (non-empty, non-fishing) loot-open sound is C-side (the server-driven loot handler),
|
||||
-- so we stay silent for it — there is no kit to guess. (DR 0086: vmangos masks the
|
||||
-- lootable flag for empty rolls, so a live empty window is rare — but the branch must
|
||||
-- exist.)
|
||||
getglobal("BenillaLootPortraitOverlay"):SetTexture("Interface\\TargetingFrame\\TargetDead")
|
||||
if GetNumLootItems() == 0 then
|
||||
PlaySound("LOOTWINDOWOPENEMPTY")
|
||||
elseif IsFishingLoot() then
|
||||
PlaySound("FISHING REEL IN")
|
||||
getglobal("BenillaLootPortraitOverlay"):SetTexture("Interface\\LootFrame\\FishingLoot-Icon")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -353,9 +359,10 @@
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Layers>
|
||||
<!-- The dead-target skull in the panel's portrait ring (ref LootFramePortraitOverlay,
|
||||
l.73-85): STATIC Interface\TargetingFrame\TargetDead, 58×58 at the window TOPLEFT
|
||||
l.73-85): Interface\TargetingFrame\TargetDead, 58×58 at the window TOPLEFT
|
||||
+(10,-8). BORDER (below the ARTWORK panel) so it shows through the panel's ring cut-out.
|
||||
The real client's fishing-loot swap to FishingLoot-Icon is omitted (no fishing feed). -->
|
||||
OnShow resets it to the skull and swaps a fishing loot to FishingLoot-Icon
|
||||
(decision 1086), exactly as the ref's OnShow does (l.133-140). -->
|
||||
<Layer level="BORDER">
|
||||
<Texture name="BenillaLootPortraitOverlay" file="Interface\TargetingFrame\TargetDead">
|
||||
<Size><AbsDimension x="58" y="58"/></Size>
|
||||
|
||||
@@ -111,19 +111,52 @@
|
||||
function BenillaSkillFrame_PaintBar(bar, skillName, skillRank, skillModifier, skillMaxRank)
|
||||
local nameText = getglobal(bar:GetName() .. "SkillName")
|
||||
local rankText = getglobal(bar:GetName() .. "SkillRank")
|
||||
-- The trough behind the fill (ref `$parentBackground`), recoloured per branch alongside
|
||||
-- the fill itself — ref SkillFrame.lua:158 (normal) and :167 (proficiency), and the
|
||||
-- detail pane's identical pair at :356/:376. The ref sets the normal-skill pair BEFORE
|
||||
-- its `skillMaxRank` gate and lets the proficiency arm overwrite it; setting each arm's
|
||||
-- own pair here is the same result with no dead write. The ref's THIRD writer of this
|
||||
-- texture — the `skillCostType == 1/2/3` green/yellow/red block at :96-111, and the
|
||||
-- `stepCost` "Learn <skill>" gray at :126 — is unreachable for us and would be
|
||||
-- overwritten here even if it weren't: `stepCost`/`rankCost` are always nil and
|
||||
-- `numTempPoints` always 0 (skills.rs's module doc — dead 1.12 data, deviation 1/5), so
|
||||
-- every entry row takes the ref's own `else -- Normal skill` arm.
|
||||
local bgTexture = getglobal(bar:GetName() .. "Background")
|
||||
nameText:SetText(skillName)
|
||||
nameText:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
|
||||
rankText:SetTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
|
||||
if skillMaxRank == 0 then
|
||||
-- A 1.12 proficiency line the app reports as 0/0 (SkillEntry's own doc) — barless
|
||||
-- plain text, deviation-free reading of the ref's own "skillMaxRank == 1" gray-bar
|
||||
-- intent, adapted to our app's 0/0 shape (this file's header comment, no-fillbar note).
|
||||
if skillMaxRank == 1 then
|
||||
-- ref SkillFrame.lua:161-167 verbatim: "If max rank in a skill is 1 assume that
|
||||
-- its a proficiency" — a FULL gray bar and NO rank text. Two kinds of line land
|
||||
-- here, and the ref can't tell them apart because it doesn't need to: the armor
|
||||
-- proficiencies, which the server really does report as 1/1, and the single-rank
|
||||
-- lines (class skills, Dual Wield, racials, the per-mount riding lines) whose
|
||||
-- `skillMaxRank` the engine already overrode to 1 off
|
||||
-- `SkillRaceClassInfo.flags & 0x400` (skills.rs's `SkillEntry::mono`) — which is
|
||||
-- why a hunter's `Beast Mastery 300/300` draws gray and numberless here, exactly
|
||||
-- as it does in the real client.
|
||||
bar:SetMinMaxValues(0, 1)
|
||||
bar:SetValue(1)
|
||||
bar:SetStatusBarColor(0.5, 0.5, 0.5)
|
||||
rankText:SetText("")
|
||||
bgTexture:SetVertexColor(1.0, 1.0, 1.0, 0.5)
|
||||
elseif skillMaxRank == 0 then
|
||||
-- Not reachable off a live server (a line the player HAS always carries a max ≥ 1)
|
||||
-- and the ref leaves this case to fall through its own if/elseif untouched —
|
||||
-- which, on a recycled row, would leave the PREVIOUS skill's bar and rank text
|
||||
-- sitting there. We blank instead: a stale-state hole is a quirk, not behaviour.
|
||||
bar:SetMinMaxValues(0, 1)
|
||||
bar:SetValue(0)
|
||||
bar:SetStatusBarColor(0, 0, 0, 0)
|
||||
rankText:SetText("")
|
||||
-- back to the template's own declared base, so a recycled row shows a neutral empty
|
||||
-- trough rather than the previous skill's blue or white. The base lives in the
|
||||
-- TEXEL (the template's `<Color 1,1,1,0.2>`), so restoring it means an IDENTITY
|
||||
-- vertex colour — writing 0.2 here would multiply it to 0.04 (decision 1101).
|
||||
bgTexture:SetVertexColor(1.0, 1.0, 1.0, 1.0)
|
||||
else
|
||||
bar:SetStatusBarColor(0.0, 0.0, 1.0, 0.5)
|
||||
bgTexture:SetVertexColor(0.0, 0.0, 0.75, 0.5)
|
||||
bar:SetMinMaxValues(0, skillMaxRank)
|
||||
bar:SetValue(skillRank)
|
||||
-- ref SkillFrame.lua:178-186, transcribed byte-exact (this file's header comment,
|
||||
|
||||
@@ -946,6 +946,28 @@
|
||||
</Anchors>
|
||||
<TexCoords left="1.0" right="0.09375" top="0" bottom="0.78125"/>
|
||||
</Texture>
|
||||
<!-- Name and level ride BACKGROUND with the ring art, like the ref's
|
||||
(ref-PlayerFrame.xml l.54-97) — NOT the OVERLAY the state icons sit
|
||||
in. The layer choice is load-bearing for the level number: the zzz /
|
||||
crossed-swords badge (OVERLAY, below) sits exactly on the level
|
||||
badge, and the client's bucket-wide texture-before-fontstring order
|
||||
(decision 0884) means a fontstring can never hide UNDER a texture
|
||||
of its own layer — the ref hides the number while resting by putting
|
||||
it in a LOWER layer than the badge. Shipped in OVERLAY by 1082, the
|
||||
number rode ON the badge (director report, 2026-08-07); decision 1093. -->
|
||||
<!-- Name 100×10 CENTER (50,19) (ref l.58-69). -->
|
||||
<FontString name="$parentNameText" inherits="GameFontNormalSmall" text="Unknown">
|
||||
<Size><AbsDimension x="100" y="10"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="50" y="19"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<!-- Level number CENTER (-63,-16) (ref l.70-78). -->
|
||||
<FontString name="$parentLevelText" inherits="GameFontNormalSmall">
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-63" y="-16"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
<Layer level="ARTWORK">
|
||||
<!-- The PvP flag icon, 64×64 TOPLEFT (18,-20) (ref PlayerPVPIcon,
|
||||
@@ -979,19 +1001,6 @@
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="OVERLAY">
|
||||
<!-- Name 100×10 CENTER (50,19) (ref l.58-69). -->
|
||||
<FontString name="$parentNameText" inherits="GameFontNormalSmall" text="Unknown">
|
||||
<Size><AbsDimension x="100" y="10"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="50" y="19"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<!-- Level number CENTER (-63,-16) (ref l.70-78). -->
|
||||
<FontString name="$parentLevelText" inherits="GameFontNormalSmall">
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-63" y="-16"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<!-- The portrait hit indicator (ref PlayerHitIndicator, decision 0576):
|
||||
CENTER at (73,-42) from the frame's TOPLEFT — the portrait circle's
|
||||
center. Hidden until a UNIT_COMBAT event lands; CombatFeedback drives
|
||||
|
||||
@@ -600,6 +600,7 @@ pub(super) fn seed_ui_fixture(
|
||||
items.insert_template(90_118, Some(template("Ruined Pelt", 1)));
|
||||
loot.open(
|
||||
NPC_GUID,
|
||||
benilla_protocol::messages::loot_type::CORPSE,
|
||||
4, // 4 copper — the coin row
|
||||
vec![
|
||||
benilla_protocol::messages::LootItem {
|
||||
|
||||
@@ -676,7 +676,9 @@ use driver::{drive_animations, drive_hand_grip};
|
||||
/// small concern, separate from the driver ([`driver`]) that advances the clips it scans.
|
||||
mod events;
|
||||
use events::fire_anim_events;
|
||||
pub(crate) use events::{footfall_side, is_footstep_sound, scan_events, AnimSoundEvent};
|
||||
pub(crate) use events::{
|
||||
advance_track, footfall_side, is_footstep_sound, scan_events, AnimSoundEvent,
|
||||
};
|
||||
|
||||
mod impact;
|
||||
use impact::route_swing_impacts;
|
||||
|
||||
@@ -134,7 +134,7 @@ pub(super) fn fire_anim_events(
|
||||
/// start** ([`FRESH_CLIP_HEAD`]) returns `-1.0` so the head window `[0, cur]` fires — we watched this
|
||||
/// clip begin, and `t = 0` keyframes are real (the emote voices carry `$CSD` at `0.000`). A clip
|
||||
/// change that *starts* deep in its timeline (the corpse settle's `seek_to(duration)`) stays silent.
|
||||
fn advance_track(
|
||||
pub(crate) fn advance_track(
|
||||
last: &mut bevy::ecs::entity::EntityHashMap<(AnimationNodeIndex, f32)>,
|
||||
entity: Entity,
|
||||
node: AnimationNodeIndex,
|
||||
|
||||
@@ -235,11 +235,14 @@ impl Creatures {
|
||||
self.catalog.model(display_id).map(|m| m.blood)
|
||||
}
|
||||
|
||||
/// A built display's portrait framing: the model's **authored portrait camera** (the real
|
||||
/// client's exact rig, wow-re portrait-render §4) when it has one, plus the heuristic anchors
|
||||
/// (head bone / neck height / footprint, model-local pre-scale) the booth falls back to for a
|
||||
/// camera-less model. `None` while the display's model is still loading (the booth's part
|
||||
/// source — the attach-spawned children — won't exist yet either).
|
||||
/// A built display's booth framing — the model's own **authored cameras**, which is what both
|
||||
/// booth families frame through: `camera` for the round portrait (`cameraLookup[0]`, wow-re
|
||||
/// portrait-render §4) and `pane_camera` for a `<PlayerModel>` body pane (raw index 1, wow-re
|
||||
/// `modelframe-camera-law.md`). Each carries the fallback data its own path needs when the model
|
||||
/// has no such camera: the heuristic anchors (head bone / neck height / footprint) for the
|
||||
/// portrait, the bbox centre for the pane's fixed camera. All model-local pre-scale. `None`
|
||||
/// while the display's model is still loading (the booth's part source — the attach-spawned
|
||||
/// children — won't exist yet either).
|
||||
pub(crate) fn display_anchors(
|
||||
&self,
|
||||
display_id: u32,
|
||||
@@ -248,6 +251,8 @@ impl Creatures {
|
||||
dm.parts.as_ref()?; // not yet built
|
||||
Some(crate::portrait::PortraitAnchors {
|
||||
camera: dm.portrait_camera,
|
||||
pane_camera: dm.pane_camera,
|
||||
bbox_center: dm.bake_center_local,
|
||||
head: crate::portrait::head_anchor(&dm.skeleton, &dm.attachments),
|
||||
pivot_height: dm.pivot_height_local,
|
||||
ground_radius: dm.ground_radius_local,
|
||||
|
||||
@@ -208,11 +208,21 @@ pub(super) struct DisplayModel {
|
||||
/// load — the exact rig the portrait booth frames through. `None` for WMO / model-less / the few
|
||||
/// camera-less M2s (the booth then falls back to heuristic framing).
|
||||
pub(super) portrait_camera: Option<benilla_assets::PortraitCamera>,
|
||||
/// The model's **model-frame pane camera** — raw camera-table index 1, the rig a 1.12
|
||||
/// `<PlayerModel>` widget renders through (wow-re `ui/scratch/modelframe-camera-law.md`;
|
||||
/// [`benilla_assets::M2Model::pane_camera`]). Captured with `parts` on load. `None` for WMO /
|
||||
/// model-less / a model with fewer than two cameras — the body booth then uses the client's own
|
||||
/// FIXED fallback camera instead.
|
||||
pub(super) pane_camera: Option<benilla_assets::PortraitCamera>,
|
||||
/// A bow display's `$WTT`/`$WTB` bowstring anchors (wow-re `nocked-ammo-cancel.md` §G2),
|
||||
/// captured with `parts`: `[top, bottom]` as `(bone, model-local Bevy position)`. The held-item
|
||||
/// attach marks the prop root with them so the string drawer can span the tips. `None` for
|
||||
/// every non-bow model.
|
||||
pub(super) string_anchors: Option<[(u16, Vec3); 2]>,
|
||||
/// The fishing pole's `$CCH` line anchor (wow-re `fishing-line.md`), mesh-frame Bevy space,
|
||||
/// captured with `parts`. The held-item attach marks the mainhand prop with it so the line
|
||||
/// drawer can span rod tip → bobber. `None` for every model that doesn't author it.
|
||||
pub(super) cch_marker: Option<Vec3>,
|
||||
/// The authored bbox z-extent (`maxZ − minZ`, model-local yards, pre-scale) — the overhead-anchor
|
||||
/// fallback's input (`0x608640`: a unit whose model has no PlayerName attachment anchors overhead
|
||||
/// text at `feet + scale × this × 1.25`). `0.0` for a bounds-less display.
|
||||
@@ -310,12 +320,14 @@ pub(super) fn empty_shell() -> DisplayModel {
|
||||
attachments: Vec::new(),
|
||||
markers: Vec::new(),
|
||||
string_anchors: None,
|
||||
cch_marker: None,
|
||||
inverse_bindposes: None,
|
||||
animations: None,
|
||||
first_seq_span: None,
|
||||
pivot_height_local: 0.0,
|
||||
ground_radius_local: 0.0,
|
||||
portrait_camera: None,
|
||||
pane_camera: None,
|
||||
bbox_z_local: 0.0,
|
||||
bake_center_local: Vec3::ZERO,
|
||||
terrain_tilt: 0,
|
||||
@@ -360,12 +372,14 @@ pub(super) fn build_parts(
|
||||
let mut attachments = Vec::new();
|
||||
let mut markers = Vec::new();
|
||||
let mut string_anchors = None;
|
||||
let mut cch_marker = None;
|
||||
let mut inverse_bindposes = None;
|
||||
let mut animations = None;
|
||||
let mut first_seq_span = None;
|
||||
let mut pivot_height_local = 0.0;
|
||||
let mut ground_radius_local = 0.0;
|
||||
let mut portrait_camera = None;
|
||||
let mut pane_camera = None;
|
||||
let mut bbox_z_local = 0.0;
|
||||
let mut bake_center_local = Vec3::ZERO;
|
||||
let parts = match &dm.handle {
|
||||
@@ -425,10 +439,12 @@ pub(super) fn build_parts(
|
||||
attachments = model.attachments.clone();
|
||||
markers = model.markers.clone();
|
||||
string_anchors = model.string_anchors;
|
||||
cch_marker = model.cch_marker;
|
||||
inverse_bindposes = Some(model.inverse_bindposes.clone());
|
||||
animations = model.animations.clone();
|
||||
first_seq_span = model.first_seq_span;
|
||||
portrait_camera = model.portrait_camera;
|
||||
pane_camera = model.pane_camera;
|
||||
if gameobject {
|
||||
collider = model.collision.as_ref().and_then(model_local_collider);
|
||||
}
|
||||
@@ -747,12 +763,14 @@ pub(super) fn build_parts(
|
||||
dm.attachments = attachments;
|
||||
dm.markers = markers;
|
||||
dm.string_anchors = string_anchors;
|
||||
dm.cch_marker = cch_marker;
|
||||
dm.inverse_bindposes = inverse_bindposes;
|
||||
dm.animations = animations;
|
||||
dm.first_seq_span = first_seq_span;
|
||||
dm.pivot_height_local = pivot_height_local;
|
||||
dm.ground_radius_local = ground_radius_local;
|
||||
dm.portrait_camera = portrait_camera;
|
||||
dm.pane_camera = pane_camera;
|
||||
dm.bbox_z_local = bbox_z_local;
|
||||
dm.bake_center_local = bake_center_local;
|
||||
dm.terrain_tilt = terrain_tilt;
|
||||
|
||||
@@ -308,6 +308,18 @@ fn spawn_slot(
|
||||
});
|
||||
}
|
||||
}
|
||||
// The fishing line's near anchor (wow-re `fishing-line.md`, decision 1099): a MAINHAND prop
|
||||
// whose model authors `$CCH` is the pole (the reference gates on ItemCache {class 2, subclass
|
||||
// 20} + the marker's presence; exactly one weapon model in the chain authors the marker, so
|
||||
// presence alone is data-equivalent — unlike the bow's trail-marker trap above, which is why
|
||||
// the bowstring can't key the same way). The drawer spans it to the owner's channel bobber.
|
||||
if slot_idx == 0 {
|
||||
if let Some(tip) = dm.cch_marker {
|
||||
commands
|
||||
.entity(root)
|
||||
.insert(crate::fishing_line::FishingPoleTip { owner: entity, tip });
|
||||
}
|
||||
}
|
||||
// **The item rig** (decisions 0841, withdrawn by 0847, RESTORED by 0854) — the one case an
|
||||
// attach model runs a joint palette. 0847 pulled it believing a spherical billboard swept the
|
||||
// spikes through the plate; that was wrong (0853: the spikes run ALONG their bone, worst vertex
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! The engine-drawn **fishing line** (wow-re `object-layer/scratch/fishing-line.md`, §5
|
||||
//! byte-verified, their `aed458c7`): rod tip → bobber while a unit channels Fishing.
|
||||
//!
|
||||
//! The reference draws this as a per-UNIT effect, not a GO one — one line object per unit
|
||||
//! (`[unit+0xb4c]`, ctor `0x61f490`), created when the unit's `UNIT_FIELD_CHANNEL_OBJECT`
|
||||
//! resolves to a live **FISHINGNODE** GameObject, `UNIT_CHANNEL_SPELL` ≠ 0, and the mainhand is a
|
||||
//! fishing pole whose model is loaded; torn down when any of that ceases (the server clears the
|
||||
//! channel fields at finish/interrupt). **No local-player gate** — every visible fisher shows a
|
||||
//! line. Benilla draws it immediate-mode per frame from exactly those conditions, so the
|
||||
//! reference's create/watcher/teardown lifecycle falls out as the condition holding or not.
|
||||
//!
|
||||
//! The geometry (`0x61f780`, bit-proven `PRIMITIVE:effect_beam_trail`): **65 vertices**, straight
|
||||
//! lerp near → far, then a fixed half-sine sag — `z −= 0.5 × sin(π·t)`, 0.5 world-units at the
|
||||
//! midpoint, not length- or physics-scaled. Near = the pole M2's `$CCH` event marker (the bobber
|
||||
//! authors one too and the reference NEVER reads it — its far end is the bobber's position with
|
||||
//! `z += scale × bboxHeight × 0.5`). One flat color for the whole strip = the pole's
|
||||
//! light-collector accumulated ambient, alpha opaque, GL_LIGHTING forced off, drawn as a plain
|
||||
//! line strip in scene state.
|
||||
//!
|
||||
//! Named deviations (same class as the bowstring's, decision 1099): the anchor rides the static
|
||||
//! prop frame (the pole's bone 1 is not posed — item props rest at bind pose here); the color
|
||||
//! samples the scene ambient ([`crate::lighting::WowLighting`]) rather than a per-model light
|
||||
//! collector; gizmo lines are unfogged. The reference's sheath side-trigger (`0x60d2f0` calls
|
||||
//! `SetSheatheState(1)` when the watcher finds the pole on the back) and the FishingCast→
|
||||
//! FishingLoop anim handoff (`0x5fc3f0` case 0x85) are the channel-anim family's, not drawn here.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::EntityKind;
|
||||
|
||||
use crate::entities::OverheadFallback;
|
||||
use crate::net::{GuidIndex, NetEntity, ObjectStore};
|
||||
|
||||
/// Marks a spawned **mainhand prop** whose model authors the `$CCH` line anchor — the fishing
|
||||
/// pole (exactly one weapon model in the 5875 chain authors it: the `scan_events` sweep, so
|
||||
/// presence is the reference's `{class 2, subclass 20}` ItemCache check data-equivalently).
|
||||
/// Inserted by the held-item attach; despawned with the prop, which is the clear.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct FishingPoleTip {
|
||||
/// The unit holding the pole — its channel fields decide whether a line draws.
|
||||
pub(crate) owner: Entity,
|
||||
/// `$CCH` in the prop's mesh frame (Bevy space).
|
||||
pub(crate) tip: Vec3,
|
||||
}
|
||||
|
||||
/// 64 segments / 65 vertices — the reference's `t = i/64` (const `0x80a92c` = 0.015625).
|
||||
const SEGMENTS: usize = 64;
|
||||
/// The fixed half-sine sag amplitude (const `0x80c9a8` = −0.5, applied to `sin(π·t)`).
|
||||
const SAG: f32 = 0.5;
|
||||
|
||||
/// Draw every visible fisher's line (per frame, post-propagation so the prop frame is current).
|
||||
fn draw_fishing_lines(
|
||||
poles: Query<(&FishingPoleTip, &GlobalTransform, &InheritedVisibility)>,
|
||||
owners: Query<&ObjectStore>,
|
||||
index: Res<GuidIndex>,
|
||||
bobbers: Query<(
|
||||
&ObjectStore,
|
||||
&NetEntity,
|
||||
&GlobalTransform,
|
||||
Option<&OverheadFallback>,
|
||||
)>,
|
||||
lighting: Option<Res<crate::lighting::WowLighting>>,
|
||||
mut gizmos: Gizmos,
|
||||
) {
|
||||
for (pole, prop, vis) in &poles {
|
||||
if !vis.get() {
|
||||
continue;
|
||||
}
|
||||
// The reference's create conditions, checked live: a channel spell, aimed at a streamed
|
||||
// FISHINGNODE. (`0x612650`; the GO-type read is the strategy's own `descr+0x3c`.)
|
||||
let Ok(store) = owners.get(pole.owner) else {
|
||||
continue;
|
||||
};
|
||||
if store.0.unit_channel_spell() == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(bobber) = store
|
||||
.0
|
||||
.unit_channel_object()
|
||||
.and_then(|g| index.0.get(&g).copied())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok((go_store, net, go_tf, height)) = bobbers.get(bobber) else {
|
||||
continue;
|
||||
};
|
||||
if net.kind != EntityKind::GameObject
|
||||
|| go_store.0.gameobject_type_id() != crate::target::cursor_mode::GO_TYPE_FISHINGNODE
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let near = prop.transform_point(pole.tip);
|
||||
// Far = bobber base + half its scaled bbox height (`0x5f9f50`: `z += scale × [go+0xbc] ×
|
||||
// 0.5`) — the float's waterline center, whatever the model's pivot does.
|
||||
let far = go_tf.translation() + Vec3::Y * (net.scale * height.map_or(0.0, |h| h.0) * 0.5);
|
||||
// The flat strip color: accumulated ambient clamped [0,1], opaque (the scene-ambient
|
||||
// stand-in for the pole's collector — module doc).
|
||||
let color = lighting.as_deref().map_or(Color::srgb(0.5, 0.5, 0.5), |l| {
|
||||
Color::srgb(
|
||||
l.ambient[0].clamp(0.0, 1.0),
|
||||
l.ambient[1].clamp(0.0, 1.0),
|
||||
l.ambient[2].clamp(0.0, 1.0),
|
||||
)
|
||||
});
|
||||
gizmos.linestrip(
|
||||
(0..=SEGMENTS).map(|i| {
|
||||
let t = i as f32 / SEGMENTS as f32;
|
||||
near.lerp(far, t) - Vec3::Y * (SAG * (std::f32::consts::PI * t).sin())
|
||||
}),
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the line drawer beside the bowstring's — after propagation, so both weapon-prop
|
||||
/// spans read this frame's frames.
|
||||
pub(crate) struct FishingLinePlugin;
|
||||
|
||||
impl Plugin for FishingLinePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
PostUpdate,
|
||||
draw_fishing_lines.in_set(crate::billboard::BillboardPlace),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The polyline the reference builds (`0x61f780`): 65 vertices, straight lerp, and the fixed
|
||||
/// half-sine sag — zero at both ends, exactly −0.5 in world Z (Bevy Y) at the midpoint,
|
||||
/// independent of the span's length.
|
||||
#[test]
|
||||
fn the_sag_is_a_fixed_half_sine() {
|
||||
let near = Vec3::new(0.0, 5.0, 0.0);
|
||||
let far = Vec3::new(20.0, 4.0, 0.0);
|
||||
let pts: Vec<Vec3> = (0..=SEGMENTS)
|
||||
.map(|i| {
|
||||
let t = i as f32 / SEGMENTS as f32;
|
||||
near.lerp(far, t) - Vec3::Y * (SAG * (std::f32::consts::PI * t).sin())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(pts.len(), 65);
|
||||
assert!((pts[0] - near).length() < 1e-6);
|
||||
assert!((pts[64] - far).length() < 1e-5);
|
||||
let mid = near.lerp(far, 0.5);
|
||||
assert!((pts[32].y - (mid.y - SAG)).abs() < 1e-4);
|
||||
// The sag never scales with length: a 200-yard span dips the same 0.5.
|
||||
let long = near.lerp(Vec3::new(200.0, 5.0, 0.0), 0.5);
|
||||
let dip = SAG * (std::f32::consts::PI * 0.5).sin();
|
||||
assert!((dip - SAG).abs() < 1e-6);
|
||||
assert!(long.y - dip < long.y);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ use bevy::animation::RepeatAnimation;
|
||||
use bevy::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::creature_anim::{advance_track, scan_events, AnimSoundEvent};
|
||||
use crate::net::{GuidIndex, ObjectStore};
|
||||
use crate::schedule::WorldStage;
|
||||
|
||||
@@ -86,6 +87,20 @@ pub(crate) struct GoAnim {
|
||||
/// *genuine* wire change — a chest's wire state is constant (its lid is driven by loot events, not the
|
||||
/// wire), so an unrelated values-update (a dyn-flag, a position) must not re-close an open lid.
|
||||
last_wire: Option<u32>,
|
||||
/// A pending one-shot **Custom** play (AnimationData id 153..156) from
|
||||
/// `SMSG_GAMEOBJECT_CUSTOM_ANIM` — the second, disjoint arm channel of wow-re
|
||||
/// `gameobject-anim-arm.md` §"one-shot channel" step 8 (substates 8..11, never the §243 lid
|
||||
/// family). Armed by [`queue_custom_anim`], consumed by [`drive_go_anim`] AFTER the state arm,
|
||||
/// so the bobber's same-frame pair (the forced `READY → ACTIVE` flip + the splash) resolves
|
||||
/// with the splash on top (decision 1086).
|
||||
custom: Option<u16>,
|
||||
/// The Custom clip currently ARMED (set when [`drive_go_anim`] actually plays one) — the
|
||||
/// completion-retire's watch (decision 1100, wow-re `go-display-sound-events.md` §6-8): the
|
||||
/// reference's per-model completion callback fires ONCE at the clip's window end (span ×
|
||||
/// replay-count, the loop bit ignored) and re-runs the GO state machine, which re-arms the
|
||||
/// state's own pose over the Custom block. [`retire_custom_anim`] models exactly that; without
|
||||
/// it the bobber's splash looped ~1.3 s forever (the director's 2-3 audible splashes).
|
||||
custom_active: Option<u16>,
|
||||
}
|
||||
|
||||
/// The GameObject's **stored** state — the binary's `go+0x27c`, which is what every consumer reads
|
||||
@@ -168,6 +183,17 @@ pub(crate) struct GoLidOpen {
|
||||
pub(crate) spell_id: u32,
|
||||
}
|
||||
|
||||
/// A GameObject's one-shot Custom animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`, decision 1086), bridged
|
||||
/// from the net apply layer. The wire's `anim_id` is the Custom index (0..3 → AnimationData ids
|
||||
/// 153..156); the reference rejects `anim_id >= 4` in the handler and this side keeps that gate.
|
||||
/// The load-bearing sender is the fishing bobber's bite (`anim_id 0` — the splash, arriving beside
|
||||
/// the forced state flip and the server's own `SMSG_PLAY_OBJECT_SOUND` splash kit).
|
||||
#[derive(Message, Clone, Copy)]
|
||||
pub(crate) struct GoCustomAnim {
|
||||
pub(crate) go_guid: u64,
|
||||
pub(crate) anim_id: u32,
|
||||
}
|
||||
|
||||
/// What to play for the current `GAMEOBJECT_STATE`: a held **rest** pose (first sight, or a state with no
|
||||
/// transition), or a one-shot transition **motion** (the swing) that lands on the new rest pose.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -335,6 +361,38 @@ fn open_go_lid(
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller 4 (the custom-anim opcode, decision 1086): queue the one-shot Custom play. This is the
|
||||
/// **disjoint** arm channel of wow-re `gameobject-anim-arm.md` §step 8 — it never touches
|
||||
/// [`GoAnim::state`] (the lid family), rejects `anim_id >= 4` exactly as the reference handler
|
||||
/// does, and maps the index to its AnimationData id (`153 + n`, Custom0..3). Ownership is judged
|
||||
/// at play time by [`drive_go_anim`] (the model components live there); a guid with no [`GoAnim`]
|
||||
/// — a non-animated GO type, or one still streaming in — drops the play, matching
|
||||
/// [`open_go_lid`]'s posture.
|
||||
fn queue_custom_anim(
|
||||
mut plays: MessageReader<GoCustomAnim>,
|
||||
index: Res<GuidIndex>,
|
||||
mut gos: Query<&mut GoAnim>,
|
||||
) {
|
||||
for GoCustomAnim { go_guid, anim_id } in plays.read().copied() {
|
||||
let Some(id) = custom_anim_id(anim_id) else {
|
||||
continue; // the reference handler's own reject (step 8)
|
||||
};
|
||||
let Some(&e) = index.0.get(&go_guid) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(mut anim) = gos.get_mut(e) {
|
||||
anim.custom = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire Custom index → its AnimationData id (`153 + n`, Custom0..3), or `None` for the
|
||||
/// reference handler's reject (`anim_id >= 4` — wow-re `gameobject-anim-arm.md` step 8's
|
||||
/// "opcode `0xb3` byte `b` (reject `b >= 4`), substate `8+b`").
|
||||
fn custom_anim_id(anim_id: u32) -> Option<u16> {
|
||||
(anim_id < 4).then(|| 153 + anim_id as u16)
|
||||
}
|
||||
|
||||
/// Caller 3 (the loot-release): close a chest lid when its loot window closes. The client sends
|
||||
/// `CMSG_LOOT_RELEASE` and immediately drops the state to READY(1) — no server round-trip. We watch the
|
||||
/// open loot source guid change (any close path: the player's close, or the server's release when the last
|
||||
@@ -363,6 +421,39 @@ fn close_go_lid(
|
||||
/// Play the §243 sequence for a change of the client-side [`GoAnim::state`] (written by any of the three
|
||||
/// callers). Mirrors the state-transition detection of [`crate::sound::gameobject`] (first sight silent),
|
||||
/// but points it at the model instead of the mixer — one system owns the visual, the other the audio.
|
||||
/// The **completion-driven retire** of an armed Custom clip (decision 1100; wow-re
|
||||
/// `go-display-sound-events.md` §6-8, §5-verified): the reference registers a per-model completion
|
||||
/// callback at GO model attach (`0x5f7d43` → `[M2+0x70]`) that fires ONCE when a sequence reaches
|
||||
/// its baked window end — span × replay-count, the **loop bit ignored** — and, for the transient
|
||||
/// substates (the Custom family 8..11 among them), re-runs the GO state machine at the current
|
||||
/// `GAMEOBJECT_STATE`, arming its pose through the playable-lookup fallback. For the bobber that
|
||||
/// resolves 149 → 151 → the lookup's all-fallback rows → **Stand**, landing one frame after the
|
||||
/// 1333 ms window — before the looping kernel's second `$GC0` crossing at 1533 ms. Net law: one
|
||||
/// splash per 0xB3, then the state pose.
|
||||
///
|
||||
/// Benilla's custom arm plays the clip `Never`-repeat (one window, the same endpoint), so "the
|
||||
/// window ended" is the player's finished flag; the retire then clears `shown`, which makes the
|
||||
/// state arm of [`drive_go_anim`] re-resolve the current state as a fresh rest pose — our
|
||||
/// state-machine re-run. Runs before [`drive_go_anim`] in the chain so the re-arm lands the same
|
||||
/// frame. Reads never deref-mut, so a quiet GO stays out of the Changed stream.
|
||||
fn retire_custom_anim(mut gos: Query<(&mut GoAnim, &AnimationPlayer, &ModelAnimations)>) {
|
||||
for (mut go, player, anims) in &mut gos {
|
||||
let Some(id) = go.custom_active else {
|
||||
continue;
|
||||
};
|
||||
let done = anims
|
||||
.find(id)
|
||||
.is_none_or(|clip| player.animation(clip.node).is_none_or(|a| a.is_finished()));
|
||||
if done {
|
||||
go.custom_active = None;
|
||||
// The completion's state-machine re-run: forget the shown pose so the state arm
|
||||
// re-resolves it (a silent rest snap — `resolve(None, state)`), exactly the
|
||||
// reference's re-arm-over-the-Custom-block.
|
||||
go.shown = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drive_go_anim(
|
||||
mut gos: Query<
|
||||
(
|
||||
@@ -375,46 +466,76 @@ fn drive_go_anim(
|
||||
>,
|
||||
) {
|
||||
for (mut go, mut player, mut tr, anims) in &mut gos {
|
||||
let Some(state) = go.state else {
|
||||
continue;
|
||||
};
|
||||
if go.shown == Some(state) {
|
||||
continue; // a caller touched us (e.g. `last_wire` bookkeeping) but the state didn't move
|
||||
// ── The §243 state arm ─────────────────────────────────────────────────────────────────
|
||||
if let Some(state) = go.state {
|
||||
if go.shown != Some(state) {
|
||||
let prev = go.shown;
|
||||
go.shown = Some(state);
|
||||
if let Some(play) = resolve(prev, state) {
|
||||
// Resolve the id to this model's clip (keyed by AnimationData.dbc id, as
|
||||
// `creature_anim` does), through the §2c remap for a model that doesn't author
|
||||
// it — that is what keeps a lidless model on a real pose instead of bind.
|
||||
let (want, frozen) = remap_missing(anims, play.anim_id());
|
||||
if let Some(clip) = anims.find(want) {
|
||||
// Snap a rest pose (blend 0 — a stream-in must not swing); ease a motion
|
||||
// over its authored blend.
|
||||
let blend = match play {
|
||||
Play::Rest(_) => 0.0,
|
||||
Play::Motion(_) => clip.blend_time.max(0.0),
|
||||
};
|
||||
let active =
|
||||
tr.play(&mut player, clip.node, Duration::from_secs_f32(blend));
|
||||
if frozen {
|
||||
// A motion standing in for a missing rest pose: the reference arms it
|
||||
// at playback rate 0, so it holds frame 0 forever — the pose that
|
||||
// motion departs from.
|
||||
active.seek_to(0.0);
|
||||
active.set_speed(0.0);
|
||||
active.set_repeat(RepeatAnimation::Never);
|
||||
} else {
|
||||
// Explicit, not defaulted: `AnimationTransitions::play` →
|
||||
// `AnimationPlayer::start` only `replay()`s the node, which rewinds the
|
||||
// clock but keeps `speed` — so re-arming a node a frozen leg previously
|
||||
// parked at rate 0 (the same Open clip serves both) would stay stuck.
|
||||
active.set_speed(1.0);
|
||||
active.set_repeat(if clip.looping {
|
||||
RepeatAnimation::Forever
|
||||
} else {
|
||||
RepeatAnimation::Never
|
||||
});
|
||||
}
|
||||
}
|
||||
// else: nothing playable even after the remap — hold whatever the loader
|
||||
// seed armed.
|
||||
}
|
||||
}
|
||||
}
|
||||
let prev = go.shown;
|
||||
go.shown = Some(state);
|
||||
let Some(play) = resolve(prev, state) else {
|
||||
continue;
|
||||
};
|
||||
// Resolve the id to this model's clip (keyed by AnimationData.dbc id, as `creature_anim` does),
|
||||
// through the §2c remap for a model that doesn't author it — that is what keeps a lidless
|
||||
// model on a real pose instead of bind.
|
||||
let (want, frozen) = remap_missing(anims, play.anim_id());
|
||||
let Some(clip) = anims.find(want) else {
|
||||
continue; // nothing playable even after the remap: hold whatever the loader seed armed
|
||||
};
|
||||
// Snap a rest pose (blend 0 — a stream-in must not swing); ease a motion over its authored blend.
|
||||
let blend = match play {
|
||||
Play::Rest(_) => 0.0,
|
||||
Play::Motion(_) => clip.blend_time.max(0.0),
|
||||
};
|
||||
let active = tr.play(&mut player, clip.node, Duration::from_secs_f32(blend));
|
||||
if frozen {
|
||||
// A motion standing in for a missing rest pose: the reference arms it at playback rate 0,
|
||||
// so it holds frame 0 forever — the pose that motion departs from.
|
||||
active.seek_to(0.0);
|
||||
active.set_speed(0.0);
|
||||
active.set_repeat(RepeatAnimation::Never);
|
||||
} else {
|
||||
// Explicit, not defaulted: `AnimationTransitions::play` → `AnimationPlayer::start` only
|
||||
// `replay()`s the node, which rewinds the clock but keeps `speed` — so re-arming a node a
|
||||
// frozen leg previously parked at rate 0 (the same Open clip serves both) would stay stuck.
|
||||
active.set_speed(1.0);
|
||||
active.set_repeat(if clip.looping {
|
||||
RepeatAnimation::Forever
|
||||
} else {
|
||||
RepeatAnimation::Never
|
||||
});
|
||||
// ── The one-shot Custom channel (step 8, decision 1086) — AFTER the state arm, so the
|
||||
// bobber's same-frame pair (forced READY→ACTIVE flip + splash) lands splash-on-top.
|
||||
// Gated on the model OWNING the id; no §2c remap on this channel — an unowned Custom
|
||||
// plays nothing. Armed for ONE window regardless of the sequence's loop flag (decision
|
||||
// 1099, correcting 1090's loop-forever): the kernel does loop a bit0-clear clip and
|
||||
// re-fires its events per pass, but the reference's COMPLETION callback fires at window
|
||||
// end (span × replay-count; the bobber's replay pair is 0..0 ⇒ one 1333 ms pass) and
|
||||
// re-runs the state machine, overwriting the Custom block before a second pass begins.
|
||||
// `RepeatAnimation::Never` + [`retire_custom_anim`] reproduce that endpoint exactly: one
|
||||
// pass, one `$GC0` splash, then the state pose — never a churning loop.
|
||||
// (`is_some` pre-check: an unconditional `take()` mut-derefs the `Mut` and re-marks the
|
||||
// component Changed every frame, keeping this Changed-filtered query hot forever.)
|
||||
if go.custom.is_some() {
|
||||
let id = go.custom.take().expect("checked is_some");
|
||||
if anims.owns(id) {
|
||||
if let Some(clip) = anims.find(id) {
|
||||
let active = tr.play(
|
||||
&mut player,
|
||||
clip.node,
|
||||
Duration::from_secs_f32(clip.blend_time.max(0.0)),
|
||||
);
|
||||
active.set_speed(1.0);
|
||||
active.set_repeat(RepeatAnimation::Never);
|
||||
go.custom_active = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,19 +583,70 @@ fn drive_go_collision(
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the event keyframes an animated GameObject's playing clip crossed this frame — the GO
|
||||
/// half of the M2 event-kernel surface (wow-re `go-display-sound-events.md`, the 1086 fold-back
|
||||
/// record): the reference registers an event callback per **family-A** GO at create
|
||||
/// (`0x5f7d1f` → vtable `+0x30` → dispatcher `0x5f3e20`), which is exactly the [`GoAnim`]
|
||||
/// population — a loader-idle family-B GO has no dispatcher and stays silent. The events flow
|
||||
/// into the same [`AnimSoundEvent`] stream the creature scanner feeds: the generic `$SND`/`$DSO`/
|
||||
/// `$DSL` audio arms apply as-is, and the GO-only display-slot family (`$GO0..5`/`$GC0..3` →
|
||||
/// `GameObjectDisplayInfo.Sound[0..9]`) is routed by [`crate::sound::gameobject`]. The
|
||||
/// load-bearing tenant: the fishing bobber's Custom0 clip authors `$GC0` at t≈3.87 s — the
|
||||
/// splash sound the real client plays from the *animation*, beside the server's explicit
|
||||
/// object-sound packet (the audible double, faithful to the reference on vmangos).
|
||||
///
|
||||
/// The playing clip is found as the creature scanner finds a variation track: of the clips with
|
||||
/// a live [`AnimationPlayer`] play, the smallest seek is the newest arm (a cross-fade's fading
|
||||
/// source is older by construction). The shared [`advance_track`]/[`scan_events`] helpers give
|
||||
/// the same arming rules — first sight silent, a watched clip-start fires its `t = 0` head, a
|
||||
/// loop wrap fires tail-then-head — and a frozen rate-0 leg never advances, so it never fires.
|
||||
fn fire_go_anim_events(
|
||||
gos: Query<(Entity, &ModelAnimations, &AnimationPlayer), With<GoAnim>>,
|
||||
mut last: Local<
|
||||
bevy::ecs::entity::EntityHashMap<(bevy::animation::graph::AnimationNodeIndex, f32)>,
|
||||
>,
|
||||
mut out: MessageWriter<AnimSoundEvent>,
|
||||
) {
|
||||
for (entity, anims, player) in &gos {
|
||||
let playing = anims
|
||||
.clips
|
||||
.iter()
|
||||
.filter_map(|c| player.animation(c.node).map(|a| (c, a.seek_time())))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
if let Some((clip, cur)) = playing {
|
||||
if let Some(prev) = advance_track(&mut last, entity, clip.node, cur) {
|
||||
scan_events(clip, entity, prev, cur, &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration hook, mirrored on [`crate::sound::gameobject`]'s: the three state callers write
|
||||
/// [`GoAnim::state`], then the animation + collision consumers act on it, after the Net drain wrote this
|
||||
/// frame's descriptor deltas + queued the open-lock [`GoLidOpen`].
|
||||
/// frame's descriptor deltas + queued the open-lock [`GoLidOpen`]. The event scanner runs last —
|
||||
/// it reads the clip/seek state the drive just settled.
|
||||
pub(crate) fn plugin(app: &mut App) {
|
||||
app.add_message::<GoLidOpen>().add_systems(
|
||||
Update,
|
||||
(
|
||||
(sync_wire_go_state, open_go_lid, close_go_lid),
|
||||
(drive_go_anim, drive_go_collision),
|
||||
)
|
||||
.chain()
|
||||
.in_set(WorldStage::Present),
|
||||
);
|
||||
app.add_message::<GoLidOpen>()
|
||||
.add_message::<GoCustomAnim>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
(
|
||||
sync_wire_go_state,
|
||||
open_go_lid,
|
||||
close_go_lid,
|
||||
queue_custom_anim,
|
||||
// The completion retire reads last frame's finished flags and must clear
|
||||
// `shown` BEFORE the drive, so its state re-arm lands this frame — the
|
||||
// reference's own one-frame-after-window-end timing.
|
||||
retire_custom_anim,
|
||||
),
|
||||
(drive_go_anim, drive_go_collision),
|
||||
fire_go_anim_events,
|
||||
)
|
||||
.chain()
|
||||
.in_set(WorldStage::Present),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -519,6 +691,20 @@ mod tests {
|
||||
assert!(matches!(resolve(Some(2), 1), Some(Play::Motion(0x98))));
|
||||
}
|
||||
|
||||
/// The custom-anim channel's wire mapping (step 8, decision 1086): indices 0..3 arm
|
||||
/// Custom0..3 (AnimationData 153..156); anything else is the reference handler's reject.
|
||||
/// The fishing bobber's bite is index 0 → 153 — exactly the second sequence
|
||||
/// `G_FishingBobber.m2` authors.
|
||||
#[test]
|
||||
fn custom_anim_maps_the_wire_index_and_rejects_past_3() {
|
||||
assert_eq!(custom_anim_id(0), Some(153)); // Custom0 — the bobber splash
|
||||
assert_eq!(custom_anim_id(1), Some(154));
|
||||
assert_eq!(custom_anim_id(2), Some(155));
|
||||
assert_eq!(custom_anim_id(3), Some(156));
|
||||
assert_eq!(custom_anim_id(4), None);
|
||||
assert_eq!(custom_anim_id(u32::MAX), None);
|
||||
}
|
||||
|
||||
/// A `ModelAnimations` that owns exactly `ids` — only the lookup table matters to the remap.
|
||||
fn owning(ids: &[u16]) -> ModelAnimations {
|
||||
let hi = ids.iter().copied().max().unwrap_or(0) as usize;
|
||||
|
||||
@@ -52,6 +52,22 @@ type EntityLightReadout = (
|
||||
Has<crate::interior::ContainmentAttach>,
|
||||
);
|
||||
|
||||
/// Everything the identity card reads off the **net entity** under the cursor, as one named
|
||||
/// [`SystemParam`] — the descriptor store and the coarse kind the line gates go by, the GameObject
|
||||
/// collision readout (decision 0763), the light readout, and the two remaining inputs of the
|
||||
/// **interact gate** ([`crate::target::cursor_mode::go_highlightable`]): the faction catalog and our
|
||||
/// own store. A bundle because `inspect_ui` sits at Bevy's 16-param ceiling; a named struct rather
|
||||
/// than the tuple it grew out of, so each member says what it is at the point of use.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(super) struct InspectStores<'w, 's> {
|
||||
stores: Query<'w, 's, &'static ObjectStore>,
|
||||
kinds: Query<'w, 's, &'static crate::net::NetEntity>,
|
||||
collision: Query<'w, 's, GoCollisionReadout>,
|
||||
lit: Query<'w, 's, EntityLightReadout>,
|
||||
factions: Option<Res<'w, crate::target::ring::Factions>>,
|
||||
self_store: Query<'w, 's, &'static ObjectStore, With<crate::net::SelfPlayer>>,
|
||||
}
|
||||
|
||||
/// The inspector overlay, drawn only while armed: a weak top-centre "armed" pill (so it's obvious the
|
||||
/// mode is on and how to leave it) and, whenever the cursor is over an identified object, a compact
|
||||
/// identity card pinned to the cursor. No chrome, no panel — its own lightweight surface.
|
||||
@@ -64,16 +80,7 @@ pub(super) fn inspect_ui(
|
||||
// The pickable mesh is a child of the net entity; its descriptor store (`ObjectStore`) lives on the
|
||||
// parent, so the readout hops child → parent.
|
||||
parents: Query<&ChildOf>,
|
||||
// Bundled into one param (the same arity ceiling as `click_input` below): the descriptor
|
||||
// store + the coarse kind the line gates go by.
|
||||
stores: (
|
||||
Query<&ObjectStore>,
|
||||
Query<&crate::net::NetEntity>,
|
||||
// The GameObject collision readout (decision 0763): whether a hull exists, whether it is
|
||||
// currently disabled, and the client's stored state the gate reads.
|
||||
Query<GoCollisionReadout>,
|
||||
Query<EntityLightReadout>,
|
||||
),
|
||||
stores: InspectStores,
|
||||
guids: Query<&crate::net::Guid>,
|
||||
castings: Query<&crate::creature_anim::Casting>,
|
||||
drivers: Query<&crate::creature_anim::AnimDriver>,
|
||||
@@ -131,7 +138,13 @@ pub(super) fn inspect_ui(
|
||||
.entity
|
||||
.and_then(|e| parents.get(e).ok())
|
||||
.map(|c| c.parent());
|
||||
let (stores, kinds, collision, lit) = (&stores.0, &stores.1, &stores.2, &stores.3);
|
||||
let (factions, self_store) = (stores.factions.as_deref(), stores.self_store.single().ok());
|
||||
let (stores, kinds, collision, lit) = (
|
||||
&stores.stores,
|
||||
&stores.kinds,
|
||||
&stores.collision,
|
||||
&stores.lit,
|
||||
);
|
||||
let store = net_entity.and_then(|p| stores.get(p).ok());
|
||||
// The unit's server name through the query cache — asks on first hover, fills on a later frame
|
||||
// (the same ask-once path the unit frames use).
|
||||
@@ -189,8 +202,28 @@ pub(super) fn inspect_ui(
|
||||
} else {
|
||||
format!(" [{}]", named.join("|"))
|
||||
};
|
||||
// The **interact gate**, stated rather than inferred (wow-re cursor-system §4a): the
|
||||
// strategy vtable's `+0x14` **highlightable** slot is the single predicate behind the
|
||||
// cursor, the +64 brighten, the right-click USE and the pick priority — so a GO reading
|
||||
// `interact ✗` is saying all four are off *by design*, and one reading `interact ✓` while
|
||||
// showing no gear says the fault is downstream in the cursor naming. That distinction is
|
||||
// exactly what the type-8 anvil report cost a hand-derivation to make: an anvil hovers
|
||||
// (this card is up) and must still read `interact ✗`, because SPELL_FOCUS is one of the
|
||||
// types whose `+0x14` is a constant `xor al,al`.
|
||||
let reaction =
|
||||
crate::target::cursor_mode::go_reaction(factions, s.0.gameobject_faction(), self_store);
|
||||
let channel_owned = crate::target::cursor_mode::fishing_channel_owned(
|
||||
self_store,
|
||||
net_entity.and_then(|p| guids.get(p).ok()).map(|g| g.0),
|
||||
);
|
||||
let interact =
|
||||
if crate::target::cursor_mode::go_highlightable(s, reaction, channel_owned) {
|
||||
"interact ✓"
|
||||
} else {
|
||||
"interact ✗"
|
||||
};
|
||||
format!(
|
||||
"go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text}",
|
||||
"go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text} · {interact}",
|
||||
s.0.gameobject_type_id()
|
||||
)
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ mod entities;
|
||||
mod entity_shade;
|
||||
mod exterior_cull;
|
||||
mod ffx_glow;
|
||||
mod fishing_line;
|
||||
mod footprints;
|
||||
mod glue;
|
||||
mod glue_strings;
|
||||
@@ -177,6 +178,7 @@ use debug_panel::DebugPanelPlugin;
|
||||
use doodad_anim::DoodadAnimPlugin;
|
||||
use entities::EntitiesPlugin;
|
||||
use entity_shade::EntityShadePlugin;
|
||||
use fishing_line::FishingLinePlugin;
|
||||
use footprints::FootprintsPlugin;
|
||||
use interact::InteractPlugin;
|
||||
use interior::InteriorPlugin;
|
||||
@@ -523,6 +525,7 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// state kit's CharProc-1 colour, uploaded to its own region of the shared light buffer.
|
||||
.add_plugins(instance_tint::plugin)
|
||||
.add_plugins(BowstringPlugin)
|
||||
.add_plugins(FishingLinePlugin)
|
||||
.add_plugins(QuestMarkersPlugin)
|
||||
// Frame-time HUD + diagnostics — the performance standard.
|
||||
// After DebugPanelPlugin so the egui plugin/context it sets up already exists. Toggle: P.
|
||||
|
||||
@@ -126,13 +126,15 @@ pub(crate) enum MinimapBlipHover {
|
||||
/// The blip layer's system inputs, tupled so `emit_minimap` stays under Bevy's 16-param
|
||||
/// ceiling: the quest-status store, the guid→entity index + unit positions, the cursor's
|
||||
/// window, the hover-out slot, the party state, and the tracking-dot inputs (candidates,
|
||||
/// our own descriptor's masks, the creature/GO template caches, `Lock.dbc`).
|
||||
/// our own descriptor's masks, the creature/GO template caches, `Lock.dbc`), plus the
|
||||
/// `uiScale` dial the tooltip's cursor seat converts through.
|
||||
pub(super) type BlipInputs<'w, 's> = (
|
||||
Res<'w, crate::ui_quest::QuestGiver>,
|
||||
Res<'w, GuidIndex>,
|
||||
Query<'w, 's, &'static GlobalTransform, With<NetEntity>>,
|
||||
Query<'w, 's, &'static Window, With<PrimaryWindow>>,
|
||||
ResMut<'w, MinimapBlipHover>,
|
||||
Res<'w, crate::ui_script::UiScaleCvar>,
|
||||
Res<'w, crate::ui_party::GroupState>,
|
||||
TrackedCandidates<'w, 's>,
|
||||
Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
|
||||
|
||||
@@ -269,6 +269,7 @@ fn emit_minimap(
|
||||
unit_pos,
|
||||
window,
|
||||
mut blip_hover,
|
||||
ui_scale,
|
||||
group,
|
||||
tracked,
|
||||
self_store,
|
||||
@@ -534,8 +535,12 @@ fn emit_minimap(
|
||||
wy,
|
||||
wz: wow[2],
|
||||
cursor,
|
||||
// The same point in UI space (y-up) — the tooltip's cursor seat.
|
||||
cursor_ui: cursor.zip(win).map(|(c, w)| Vec2::new(c.x, w.height() - c.y)),
|
||||
// The same point in UI space (y-up, ÷s through the 0582/0584 seam — the tooltip's
|
||||
// anchor resolves in the VM's 768-virtual units, not window px): the cursor seat.
|
||||
cursor_ui: cursor.zip(win).map(|(c, w)| {
|
||||
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
|
||||
Vec2::new(c.x / s, (w.height() - c.y) / s)
|
||||
}),
|
||||
}
|
||||
});
|
||||
let mut hover = blips::MinimapBlipHover::None;
|
||||
|
||||
@@ -254,6 +254,9 @@ pub(super) fn apply_net_updates(
|
||||
MessageWriter<crate::ui_unit::UnitCombatFeedback>,
|
||||
MessageWriter<crate::ui_unit::CombatTextEvent>,
|
||||
MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
// The GO one-shot Custom play (`SMSG_GAMEOBJECT_CUSTOM_ANIM` — the bobber's bite
|
||||
// splash, decision 1086), the step-8 sibling of the `GoLidOpen` writer above.
|
||||
MessageWriter<crate::go_anim::GoCustomAnim>,
|
||||
),
|
||||
),
|
||||
// The aura feed's duration side-table + the clock to stamp arrivals (decisions 0255/0257): the
|
||||
@@ -636,6 +639,11 @@ pub(super) fn apply_net_updates(
|
||||
} => {
|
||||
objects::gameobject_info(entry, type_id, display_id, name, &data, &mut go_templates)
|
||||
}
|
||||
SessionEvent::GameObjectCustomAnim { guid, anim_id } => {
|
||||
objects::gameobject_custom_anim(guid, anim_id, &mut audio.15 .3)
|
||||
}
|
||||
SessionEvent::FishNotHooked => loot::fish_verdict(false, &mut ui_error_keys),
|
||||
SessionEvent::FishEscaped => loot::fish_verdict(true, &mut ui_error_keys),
|
||||
SessionEvent::PlaySound { sound_id } => world::play_sound(sound_id, &mut audio.0),
|
||||
SessionEvent::PlayMusic { music_id } => world::play_music(music_id, &mut audio.0),
|
||||
SessionEvent::PlayObjectSound { sound_id, guid } => {
|
||||
|
||||
@@ -13,6 +13,7 @@ use bevy::prelude::*;
|
||||
use super::super::SelfGuid;
|
||||
use crate::items::Items;
|
||||
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
|
||||
use crate::ui_action::{UiError, UiErrorKeys};
|
||||
use crate::ui_items::{EquipError, EquipErrors};
|
||||
use crate::ui_loot::{LootErrors, LootLatch, LootState};
|
||||
use crate::ui_loot_roll::LootRolls;
|
||||
@@ -26,12 +27,30 @@ pub(super) fn loot_response(
|
||||
loot: &mut LootState,
|
||||
) {
|
||||
// The loot window opens (decision 0084): fill LootState from the wire; the feed
|
||||
// ([`crate::ui_loot`]) resolves rows + fires LOOT_OPENED next frame.
|
||||
// ([`crate::ui_loot`]) resolves rows + fires LOOT_OPENED next frame. `loot_type` rides along
|
||||
// for `IsFishingLoot()` (decision 1086).
|
||||
debug!(
|
||||
"net: loot response {guid:#x} type {loot_type} gold {gold} {} item(s)",
|
||||
items.len()
|
||||
);
|
||||
loot.open(guid, gold, items);
|
||||
loot.open(guid, loot_type, gold, items);
|
||||
}
|
||||
|
||||
/// A fishing verdict with no loot window (`SMSG_FISH_ESCAPED` / `SMSG_FISH_NOT_HOOKED`, both
|
||||
/// empty-bodied; decision 1086): the **yellow** toast by GlobalStrings key — `ERR_FISH_ESCAPED`
|
||||
/// ("Your fish got away!") when the skill roll failed on the click, `ERR_FISH_NOT_HOOKED`
|
||||
/// ("No fish are hooked.") when the bobber expired or was clicked before the splash. Yellow, not
|
||||
/// red: the reference handlers (`0x5e3fc5`/`0x5e3fe2` → `DisplayError` ids `0x13e`/`0x13f`) are
|
||||
/// **type-1** registry entries, which fire `UI_INFO_MESSAGE` — byte-verified in wow-re
|
||||
/// `fish-msg-handlers.md`, correcting 1086's shipped guess (the fold-back record).
|
||||
pub(super) fn fish_verdict(escaped: bool, errors: &mut UiErrorKeys) {
|
||||
let key = if escaped {
|
||||
"ERR_FISH_ESCAPED"
|
||||
} else {
|
||||
"ERR_FISH_NOT_HOOKED"
|
||||
};
|
||||
debug!("net: fishing verdict {key}");
|
||||
errors.0.push(UiError::info_key(key));
|
||||
}
|
||||
|
||||
/// The server refused to open the loot window (`SMSG_LOOT_RESPONSE`'s error shape — didn't kill
|
||||
@@ -194,6 +213,39 @@ pub(super) fn inventory_failure(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Both fish-verdict keys queue as **yellow** (type-1 / `UI_INFO_MESSAGE`) entries — the
|
||||
/// byte-verified arm, wow-re `fish-msg-handlers.md` — and both resolve to the exact 1.12
|
||||
/// strings in the shipped `GlobalStrings.lua` (the equip-error test's runtime pattern —
|
||||
/// a typo'd key would silently swallow the toast). Skips without client data.
|
||||
#[test]
|
||||
fn fish_verdict_keys_resolve_in_the_real_global_strings() {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
fish_verdict(false, &mut errors);
|
||||
fish_verdict(true, &mut errors);
|
||||
assert_eq!(
|
||||
errors.0,
|
||||
vec![
|
||||
UiError::info_key("ERR_FISH_NOT_HOOKED"),
|
||||
UiError::info_key("ERR_FISH_ESCAPED"),
|
||||
]
|
||||
);
|
||||
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let src = chain
|
||||
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||||
.expect("GlobalStrings.lua in the chain");
|
||||
let s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
let g = |key: &str| s.lua().globals().get::<String>(key).expect(key);
|
||||
assert_eq!(g("ERR_FISH_NOT_HOOKED"), "No fish are hooked.");
|
||||
assert_eq!(g("ERR_FISH_ESCAPED"), "Your fish got away!");
|
||||
}
|
||||
|
||||
const ME: u64 = 0x0000_0000_0000_002A;
|
||||
const THEM: u64 = 0x0000_0000_0000_00FF;
|
||||
|
||||
|
||||
@@ -23,6 +23,22 @@ use super::super::{
|
||||
SpeedChangeMessage, Spline, UnitSpeeds,
|
||||
};
|
||||
|
||||
/// A GameObject plays its one-shot **Custom** animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`, decision
|
||||
/// 1086) — bridged to the GO animation machine ([`crate::go_anim`]), which owns the reject
|
||||
/// (`anim_id >= 4`), the id mapping (153..156) and the model-ownership gate. The load-bearing
|
||||
/// sender: the fishing bobber's bite splash (`anim_id 0`).
|
||||
pub(super) fn gameobject_custom_anim(
|
||||
guid: u64,
|
||||
anim_id: u32,
|
||||
plays: &mut MessageWriter<crate::go_anim::GoCustomAnim>,
|
||||
) {
|
||||
debug!("net: gameobject {guid:#x} custom anim {anim_id}");
|
||||
plays.write(crate::go_anim::GoCustomAnim {
|
||||
go_guid: guid,
|
||||
anim_id,
|
||||
});
|
||||
}
|
||||
|
||||
/// An object entered range / was created (`SMSG_UPDATE_OBJECT` create block): spawn or refresh the
|
||||
/// entity, warm the ask-once caches, and seed its descriptor store via the per-drain `pending` map.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -196,6 +196,7 @@ pub(super) fn quest_giver_failed(
|
||||
key: crate::ui_quest::questgiver_failed_key(reason),
|
||||
fill_s: Some(title),
|
||||
fill_d: None,
|
||||
info: false,
|
||||
},
|
||||
);
|
||||
if matches!(reason, 4 | 50) {
|
||||
@@ -261,6 +262,7 @@ mod tests {
|
||||
key: "ERR_QUEST_FAILED_BAG_FULL_S",
|
||||
fill_s: Some("A Threat Within".into()),
|
||||
fill_d: None,
|
||||
info: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(msgs[1].0, MsgSurface::Error);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! Portrait **framing** — how the booth camera is placed for a model.
|
||||
//!
|
||||
//! The primary path is the model's **authored portrait camera** (the mechanism — see the module
|
||||
//! docs in [`super`]): [`frame`] takes the rig verbatim, transform + projection. The rest of this
|
||||
//! file is the heuristic fallback for camera-less models (head-anchor closeup) and the bind-pose
|
||||
//! bone walk [`head_anchor`] anchors the fallback with (the posed booth seats riders on real joints).
|
||||
//! Both booth families frame through the model's **own authored camera**, and neither fits anything
|
||||
//! (the mechanism — see the module docs in [`super`]):
|
||||
//!
|
||||
//! - [`frame`] — a ROUND portrait: `cameraLookup[0]`, verbatim (wow-re portrait-render §4). Its
|
||||
//! fallback for a camera-less model is a heuristic head closeup, anchored by the bind-pose bone
|
||||
//! walk [`head_anchor`] (which the posed booth also uses to seat riders on real joints).
|
||||
//! - [`body_frame`] — a `<PlayerModel>` body PANE: raw `cameras[1]`, verbatim, else the client's own
|
||||
//! synthesized *fixed* rig (wow-re `modelframe-camera-law.md`, decision 1089). No fit, no
|
||||
//! normalization, no bone anchor anywhere on this path.
|
||||
|
||||
use bevy::camera::{CameraProjection, PerspectiveProjection, Projection, SubCameraView};
|
||||
use bevy::math::Vec3A;
|
||||
@@ -14,17 +19,27 @@ use bevy::prelude::*;
|
||||
/// camera brings its own FOV (see [`frame`]).
|
||||
pub(super) const PORTRAIT_FOV: f32 = 0.5;
|
||||
|
||||
/// `1/√(aspect²+1)` at the diagonal-FOV convention's aspect 4/3 = 3/5 exactly — the record-fov →
|
||||
/// vertical-half-angle factor (the community's "fov × 0.6" legend, emergent not prescaled). The
|
||||
/// 4/3 enters ONLY this crop factor: the projection matrix itself carries no anamorphic squeeze
|
||||
/// (the director's ref A/B falsified it on screen; wow-re §4 reconciliation dispatched) — its
|
||||
/// `aspect` is the DESTINATION PANE's, and exists purely to cancel the UI's stretch (1069).
|
||||
pub(super) const DIAG_TO_VERT: f32 = 0.6;
|
||||
/// The aspect the **portrait bake's** own diagonal→vertical crop is computed at: the client feeds
|
||||
/// its portrait projection a fixed `4/3` (wow-re portrait-render §4), which makes the crop factor
|
||||
/// `1/√((4/3)²+1)` = `3/5` exactly — the community's "fov × 0.6" legend, emergent, not prescaled.
|
||||
///
|
||||
/// It is a *separate number* from the projection's `aspect` (see [`WowPortraitProjection::aspect`]):
|
||||
/// this one is the client's own, fixed by its bake; that one is our destination pane's, and exists
|
||||
/// purely to cancel the UI's stretch (1069). They coincide on the **model-pane** path, where the
|
||||
/// client renders straight into the pane and its crop aspect IS the pane's.
|
||||
pub(super) const PORTRAIT_CROP_ASPECT: f32 = 4.0 / 3.0;
|
||||
|
||||
/// A camera record's *diagonal* fov → the **vertical** opening angle, at a given crop aspect:
|
||||
/// `fov/√(a²+1)`, the client's own `0x5c3cc0` relation (`t = tan((fov/2)/√(a²+1))`, `m11 = 1/t`, so
|
||||
/// `tan(fovy/2) = t` exactly). The one place a plain Bevy `PerspectiveProjection` needs it.
|
||||
pub(super) fn diag_to_vert(fov: f32, crop_aspect: f32) -> f32 {
|
||||
fov / (crop_aspect * crop_aspect + 1.0).sqrt()
|
||||
}
|
||||
|
||||
/// The real client's portrait **projection** (wow-re portrait-render §4, corrected `aa186e79`):
|
||||
/// gxumath `0x5c3cc0` is a *diagonal-FOV* perspective — half-angle `θ = (fov/2)/√(aspect²+1)`,
|
||||
/// `m11 = 1/tan θ`, `m00 = m11/aspect` — and the portrait bake feeds it `aspect = 4/3`
|
||||
/// ([`PORTRAIT_ASPECT`]). Net: vertical half-angle **`0.3·fov`** (the refs' tight crop; 1.72–1.75×
|
||||
/// ([`PORTRAIT_CROP_ASPECT`]). Net: vertical half-angle **`0.3·fov`** (the refs' tight crop; 1.72–1.75×
|
||||
/// tighter than a naive `tan(fov/2)` read) plus a deliberate **3:4 anamorphic squeeze** (spheres
|
||||
/// render as 4:3-tall ellipses in every real portrait).
|
||||
///
|
||||
@@ -50,12 +65,23 @@ pub(super) struct WowPortraitProjection {
|
||||
/// 1.0 for a round portrait: its region is square by construction (the circular mask is
|
||||
/// inscribed in it), so there is nothing to cancel.
|
||||
pub(super) aspect: f32,
|
||||
/// The aspect the **client** computes its diagonal→vertical crop at — `fovy = fov/√(a²+1)`.
|
||||
///
|
||||
/// Not the same question as [`Self::aspect`], and the two genuinely differ by path. The portrait
|
||||
/// bake hardcodes `4/3` ([`PORTRAIT_CROP_ASPECT`]) while our destination region is square, so
|
||||
/// there it is `4/3` against an `aspect` of `1.0`. A `<PlayerModel>` body pane renders *straight
|
||||
/// into the pane* in the real client (`0x5c3cc0` with the frame's plain pixel width/height), so
|
||||
/// there the two are the same number — and the crop genuinely moves with the pane: `318×224`
|
||||
/// crops to `0.576·fov`, `233×224` to `0.693·fov`. Hardcoding `0.6` was only ever right at 4/3.
|
||||
pub(super) crop_aspect: f32,
|
||||
}
|
||||
|
||||
impl WowPortraitProjection {
|
||||
/// Full vertical opening angle: `2θ = 0.6·fov`.
|
||||
/// Full vertical opening angle — `2θ` where `θ = (fov/2)/√(crop_aspect²+1)` is the client's own
|
||||
/// half-angle (`0x5c3cc0`: `t = tan θ`, `m11 = 1/t`, so `tan(fovy/2) = tan θ` exactly, no
|
||||
/// small-angle step anywhere).
|
||||
fn fovy(&self) -> f32 {
|
||||
DIAG_TO_VERT * self.fov
|
||||
diag_to_vert(self.fov, self.crop_aspect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +134,18 @@ impl CameraProjection for WowPortraitProjection {
|
||||
/// ([`Creatures::display_anchors`]). All **model-local at scale 1** (the ref bakes with root scale
|
||||
/// reset, RE `0x47a230`), matching the booth's identity-transform parts.
|
||||
pub(crate) struct PortraitAnchors {
|
||||
/// The model's **authored portrait camera** — the real client's exact framing rig (module
|
||||
/// docs). `None` for a camera-less model → the heuristic fields below take over.
|
||||
/// The model's **authored portrait camera** — the real client's exact framing rig for a ROUND
|
||||
/// portrait (`cameraLookup[0]`, module docs). `None` for a camera-less model → the heuristic
|
||||
/// fields below take over.
|
||||
pub(crate) camera: Option<benilla_assets::PortraitCamera>,
|
||||
/// The model's **authored pane camera** — raw camera-table index 1, the rig a `<PlayerModel>`
|
||||
/// body pane renders through ([`benilla_assets::M2Model::pane_camera`], decision 1089). `None`
|
||||
/// for a model with fewer than two cameras → [`body_frame`]'s fixed fallback.
|
||||
pub(crate) pane_camera: Option<benilla_assets::PortraitCamera>,
|
||||
/// The MD20 header bbox **centre** (Bevy model-local) — the look-at target of the client's fixed
|
||||
/// fallback camera, and the ONLY model-derived quantity on the body-pane path when there is no
|
||||
/// authored camera to use.
|
||||
pub(crate) bbox_center: Vec3,
|
||||
/// The bind-pose **head anchor** (Bevy space): the KeyBone-6 head-bone pivot, else the helm
|
||||
/// attach point (id 11) — [`head_anchor`]. The heuristic fallback framing looks here; `None`
|
||||
/// (a head-less model — props) falls back to a pivot-derived guess.
|
||||
@@ -200,6 +235,9 @@ pub(super) fn frame(a: &PortraitAnchors) -> (Transform, Projection) {
|
||||
// A round portrait's sampling region is square by construction (the mask is the
|
||||
// inscribed circle) — nothing to cancel.
|
||||
aspect: 1.0,
|
||||
// …but the CROP is the client's own, and its portrait bake computes it at a fixed
|
||||
// 4/3. Two different aspects, two different jobs.
|
||||
crop_aspect: PORTRAIT_CROP_ASPECT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -227,82 +265,127 @@ pub(super) fn frame(a: &PortraitAnchors) -> (Transform, Projection) {
|
||||
)
|
||||
}
|
||||
|
||||
/// The paper-doll pane's **body framing** feeds [`WowPortraitProjection`] this fov (a *diagonal*
|
||||
/// angle in that projection's convention — the on-screen vertical opening is `0.6·BODY_FOV`, so the
|
||||
/// vertical half-angle is `0.3·BODY_FOV ≈ 14.6°`). Mild on purpose: a standing figure should read
|
||||
/// with little head-to-feet perspective divergence. NOT the portrait path's authored per-model fov —
|
||||
/// the paper doll frames the *whole* body from bounds (decision 0208 §5), never camera 0.
|
||||
pub(super) const BODY_FOV: f32 = 0.85;
|
||||
/// The client's **renormalize-to-4:3** model-root factor — `G48·(5/3)` where
|
||||
/// `G48 = [0x832a48] = 1/√(a²+1)` (`0x41ad10 SetScale`) and the `5/3` is the `.rdata` literal
|
||||
/// `[0x80655c]`. Since `f32(5/3) == f32(√((4/3)²+1))` exactly (3·4·5), it is
|
||||
/// `√((4/3)²+1)/√(a²+1)`: **1.0 at 4:3**, 0.8833 at 16:10, **0.8171 at 16:9**, 1.0412 at 5:4.
|
||||
///
|
||||
/// `a` is the **`gxResolution` CVar's width/height**, gated by the `widescreen` CVar (registered at
|
||||
/// `0x63a74f` with default `"1"` — on by default); with `widescreen = 0` the client uses `4/3` on
|
||||
/// any monitor. (wow-re `ui/scratch/modelframe-camera-law.md` §11.)
|
||||
///
|
||||
/// **It applies to exactly one path, and this is the subtle part.** The widget's model root is
|
||||
/// `T(pos)·R(facing)·S(s)`, and `0x71439b` composes the *camera's* publish through that same root
|
||||
/// (`0x718960`) — so on the AUTHORED path `s` **cancels exactly** between camera and geometry, and a
|
||||
/// client that scaled the geometry without the camera would be wrong by `1/s` (+22% at 16:9). The
|
||||
/// fixed fallback camera is the exception: `0x505890`'s synth leg writes its eye/target as
|
||||
/// model-local literals onto a fresh `CCamera` (`0x7ac930`) that is *not* in the model's camera
|
||||
/// array, so the publish never reaches it. There the geometry shrinks and the camera does not — and
|
||||
/// because it still looks at the *unscaled* bbox centre, the model also sits low by
|
||||
/// `(1−s)·centre.y`, which falls out of the transform rather than being coded.
|
||||
pub(super) fn pane_model_scale(display_aspect: f32) -> f32 {
|
||||
const REF: f32 = 4.0 / 3.0;
|
||||
((REF * REF + 1.0) / (display_aspect * display_aspect + 1.0)).sqrt()
|
||||
}
|
||||
|
||||
/// The framed window's **top**, as a multiple of the figure's head/neck height signal. The signals
|
||||
/// (neck pivot / authored-camera target / head bone) all land around the throat/face — ~0.9 of the
|
||||
/// standing height — so `> 1.0` clears the crown with air above it.
|
||||
const BODY_HEADROOM: f32 = 1.24;
|
||||
/// The framed window's **bottom**, a small fraction of the figure height *below* the feet plane
|
||||
/// (Y=0), so the boots have ground room and never touch the frame edge.
|
||||
const BODY_FOOTROOM: f32 = 0.10;
|
||||
/// Side safety: how much of the footprint half-width to keep framed when width would bind (a wide
|
||||
/// stance / a held weapon poking sideways). Height binds for a normal humanoid; this only floors it.
|
||||
const BODY_WIDTH_MARGIN: f32 = 1.15;
|
||||
/// The client's **synthesized fallback camera** for a model frame whose model carries fewer than two
|
||||
/// cameras — a *fixed* rig, not a fit (wow-re `ui/scratch/modelframe-camera-law.md`, VERIFIED
|
||||
/// `0x505890`): eye at a constant point in WoW model space, look-at the MD20 header bbox centre, and
|
||||
/// its own fov/near/far. The only model-derived quantity anywhere in it is the target.
|
||||
///
|
||||
/// A small model therefore renders *small* in the pane and a large one overflows it — there is no
|
||||
/// normalization step. Both of the numbers here are literals in the binary.
|
||||
/// `(200/36, 0, 87/36)` — the same `/36` family as the clip planes below, which is what the
|
||||
/// binary's literals decode to (`5.5555558` / `2.4166667` as f32).
|
||||
const PANE_FIXED_EYE_WOW: [f32; 3] = [200.0 / 36.0, 0.0, 87.0 / 36.0];
|
||||
/// The fixed camera's field of view, radians — a *diagonal* angle in the client's convention
|
||||
/// ([`WowPortraitProjection`]). Also the fov the pipeline-warm pass compiles with.
|
||||
pub(super) const PANE_FIXED_FOV: f32 = 0.5;
|
||||
/// `1/36` and `5000.0` — the fixed camera's clip planes, verbatim.
|
||||
const PANE_FIXED_NEAR: f32 = 1.0 / 36.0;
|
||||
const PANE_FIXED_FAR: f32 = 5000.0;
|
||||
|
||||
/// The **paper-doll** booth camera rig (decision 0208 §5) — a full-body framing derived from the
|
||||
/// model's own bounds, *not* the authored portrait camera. Yaw is applied to the model root by the
|
||||
/// caller (the ref's `Model:SetRotation`), so this rig is yaw-independent and the fit is pure /
|
||||
/// testable.
|
||||
/// The **body pane** booth camera rig — a 1.12 `<PlayerModel>` widget's own camera, verbatim
|
||||
/// (decision 1089; wow-re `ui/scratch/modelframe-camera-law.md`).
|
||||
///
|
||||
/// **Why not `cameraLookup[0]`.** On a player model the authored portrait camera is the face bust:
|
||||
/// HumanMale's eye/target both sit at Z ≈ 1.87 (head height) with a ~0.66yd standoff (verified from
|
||||
/// the M2 bytes — `benilla-formats` `m2_portrait_camera` regression pin; wow-re portrait-render §4 /
|
||||
/// decision 0113). Aiming through it would crop the body to the face. The real client's paper-doll
|
||||
/// pane frames a `PlayerModel` through the engine's default bounds-fit camera, not camera 0 — so we
|
||||
/// do the same.
|
||||
/// **There is no fit.** The widget renders through a frozen snapshot of the model's *authored*
|
||||
/// camera at **raw table index 1** — the `type == 1` "characterinfo" camera — and when the model has
|
||||
/// fewer than two cameras it synthesizes the fixed rig above. Nothing on that path reads a bone, an
|
||||
/// attachment, a bounding sphere or a look-target height for camera purposes; the only model-derived
|
||||
/// quantity is the fixed camera's bbox-centre target. So the pane is **not** normalized: the panes
|
||||
/// merely *look* normalized because Blizzard authored per-model standoffs — `GnomeFemale` eye
|
||||
/// x = 2.16 · `HumanMale` 3.66 · `TaurenMale` 4.43 · `Boar` 4.86.
|
||||
///
|
||||
/// **The fit.** The figure's vertical span is `[−footroom, headroom·head_signal]` (model-local Bevy
|
||||
/// yards, feet at Y=0), where `head_signal` is the tallest of the neck pivot / authored-camera look
|
||||
/// target / head-bone anchor — all ~throat/face height. The camera sits in front of the model
|
||||
/// (−Z — WoW +X front maps to Bevy −Z under `wow_to_bevy`) at the distance that fits that span
|
||||
/// through [`WowPortraitProjection`]'s vertical opening (half-angle `0.3·BODY_FOV`, computed the
|
||||
/// same way here so the fit is exact), looking at the span's centre. Projection reused from the
|
||||
/// portrait path for module consistency — square-true, no anamorphic squeeze (decision 0116).
|
||||
/// **What this replaces, and why it was wrong.** 0208 §5 built a bounds fit off a *head/neck height
|
||||
/// signal* on the stated premise that the client uses "the engine's default bounds-fit camera". Both
|
||||
/// halves are refuted: there is no bounds fit, and the head signal is a humanoid assumption. It read
|
||||
/// plausibly on every biped we had looked at and came out too large and sitting too high the first
|
||||
/// time a quadruped went in a pane — the director's boar (2026-08-07).
|
||||
///
|
||||
/// `aspect` is the **destination pane's** width ÷ height ([`WowPortraitProjection::aspect`]): it
|
||||
/// widens the horizontal opening, so the figure keeps its proportions once the UI stretches the
|
||||
/// square bake into a non-square pane (decision 1069).
|
||||
/// Yaw is still applied to the model *root* by the caller (the ref's `Model:SetRotation`, which
|
||||
/// writes the model's facing, not the camera), so this rig stays yaw-independent and pure.
|
||||
///
|
||||
/// `aspect` is the destination pane's width ÷ height, and on this path it is **both** of the
|
||||
/// projection's aspects: the client renders straight into the pane rect, so the same number sets the
|
||||
/// matrix and the diagonal→vertical crop (`318×224 → 0.576·fov`).
|
||||
///
|
||||
/// The **model-root scale** that goes with it is [`pane_root_scale`] — `1.0` whenever the model has
|
||||
/// its own camera, and the display-aspect renormalize factor when it does not.
|
||||
pub(super) fn body_frame(a: &PortraitAnchors, aspect: f32) -> (Transform, Projection) {
|
||||
// Head/neck height signal (feet at Y=0). The neck pivot (attach-17.z, every character carries
|
||||
// it), the authored bust camera's look target, and the head-bone anchor all land near the
|
||||
// throat/face — take the tallest, floored off zero for a hypothetical bounds-less display.
|
||||
let head_signal = a
|
||||
.pivot_height
|
||||
.max(a.camera.map_or(0.0, |c| c.target.y))
|
||||
.max(a.head.map_or(0.0, |h| h.y))
|
||||
.max(0.1);
|
||||
let top = BODY_HEADROOM * head_signal;
|
||||
let bottom = -BODY_FOOTROOM * head_signal;
|
||||
// The horizontal opening is `aspect ×` the vertical, so a width that must fit costs
|
||||
// `width / aspect` of vertical window — floor the fitted height by it, so a wide model can't
|
||||
// out-reach the sides (and a *narrow* pane, aspect < 1, gets the extra room it needs).
|
||||
let window = (top - bottom).max(2.0 * BODY_WIDTH_MARGIN * a.ground_radius / aspect.max(1e-3));
|
||||
let center_y = 0.5 * (top + bottom);
|
||||
|
||||
// Distance that exactly fits `window` through the projection's vertical opening. Half-angle is
|
||||
// `0.3·BODY_FOV` — `WowPortraitProjection::fovy()/2` — so the geometry matches the matrix.
|
||||
let half_angle = 0.5 * DIAG_TO_VERT * BODY_FOV;
|
||||
let dist = (0.5 * window) / half_angle.tan();
|
||||
|
||||
let target = Vec3::new(0.0, center_y, 0.0);
|
||||
let eye = target + Vec3::new(0.0, 0.0, -dist); // in front of the model (−Z)
|
||||
let cam = pane_camera(a);
|
||||
// `lookAt(eye, target, up)` with up = +Y rolled about the view axis — every vanilla camera
|
||||
// track holds a single key of (0,0,0), so roll is 0 in practice and the eye/target ARE the
|
||||
// record's base vectors (transcribed for fidelity, not observed nonzero).
|
||||
let fwd = (cam.target - cam.eye).normalize_or_zero();
|
||||
let up = Quat::from_axis_angle(fwd, cam.roll) * Vec3::Y;
|
||||
(
|
||||
Transform::from_translation(eye).looking_at(target, Vec3::Y),
|
||||
Projection::custom(WowPortraitProjection {
|
||||
fov: BODY_FOV,
|
||||
near: 0.02,
|
||||
far: 100.0,
|
||||
aspect,
|
||||
}),
|
||||
Transform::from_translation(cam.eye).looking_at(cam.target, up),
|
||||
Projection::custom(pane_projection(&cam, aspect)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The **model-root scale** a body pane applies alongside the yaw — the other half of
|
||||
/// [`body_frame`], kept a separate call because the booth re-latches it on a window resize without
|
||||
/// re-baking. `1.0` on the authored path: not because the client omits the factor there, but because
|
||||
/// it CANCELS against the camera's own publish through the same root. Only the fixed fallback camera
|
||||
/// escapes that composition, and there it is real. See [`pane_model_scale`].
|
||||
pub(super) fn pane_root_scale(a: &PortraitAnchors, display_aspect: f32) -> f32 {
|
||||
if a.pane_camera.is_some() {
|
||||
1.0
|
||||
} else {
|
||||
pane_model_scale(display_aspect)
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera a body pane renders through: the model's authored `cameras[1]`, else the client's
|
||||
/// synthesized fixed rig aimed at the bbox centre. See [`body_frame`].
|
||||
pub(super) fn pane_camera(a: &PortraitAnchors) -> benilla_assets::PortraitCamera {
|
||||
a.pane_camera.unwrap_or(benilla_assets::PortraitCamera {
|
||||
eye: benilla_assets::coords::wow_to_bevy(PANE_FIXED_EYE_WOW),
|
||||
target: a.bbox_center,
|
||||
roll: 0.0,
|
||||
fov: PANE_FIXED_FOV,
|
||||
near: PANE_FIXED_NEAR,
|
||||
far: PANE_FIXED_FAR,
|
||||
})
|
||||
}
|
||||
|
||||
/// A pane camera's projection: the record's scalars, with the pane's aspect in BOTH roles (the
|
||||
/// client renders straight into the pane rect, so its matrix aspect and its diagonal→vertical crop
|
||||
/// aspect are the same number). See [`WowPortraitProjection::crop_aspect`].
|
||||
pub(super) fn pane_projection(
|
||||
cam: &benilla_assets::PortraitCamera,
|
||||
aspect: f32,
|
||||
) -> WowPortraitProjection {
|
||||
WowPortraitProjection {
|
||||
fov: cam.fov,
|
||||
near: cam.near,
|
||||
far: cam.far,
|
||||
aspect,
|
||||
crop_aspect: aspect,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -318,53 +401,216 @@ mod tests {
|
||||
ndc(transform, proj, p).y
|
||||
}
|
||||
|
||||
/// The bounds→camera fit must keep both **feet and crown** inside the frame with air to spare,
|
||||
/// across the player size range (gnome → tauren). Locks the sign (feet below centre, crown above)
|
||||
/// and the distance math against a regression (a flipped axis or a wrong half-angle would crop).
|
||||
#[test]
|
||||
fn body_frame_never_crops_feet_or_crown() {
|
||||
// Every pane aspect the shipped windows use: the paper doll's 233×224, the dressing room's
|
||||
// 316×351, and the square default a booth runs at before its pane has been seen.
|
||||
for &aspect in &[1.0_f32, 233.0 / 224.0, 316.0 / 351.0] {
|
||||
for &signal in &[0.88_f32, 1.90, 2.60] {
|
||||
let anchors = PortraitAnchors {
|
||||
camera: None,
|
||||
head: None,
|
||||
pivot_height: signal,
|
||||
ground_radius: 0.35,
|
||||
};
|
||||
let (transform, projection) = body_frame(&anchors, aspect);
|
||||
let proj = WowPortraitProjection {
|
||||
fov: BODY_FOV,
|
||||
near: 0.02,
|
||||
far: 100.0,
|
||||
aspect,
|
||||
};
|
||||
// Feet at the origin; a conservatively-high crown estimate (throat/face signal is
|
||||
// ~0.9 of standing height, so the crown sits a little above `signal`).
|
||||
let feet = ndc_y(&transform, &proj, Vec3::ZERO);
|
||||
let crown = ndc_y(&transform, &proj, Vec3::new(0.0, 1.12 * signal, 0.0));
|
||||
assert!(
|
||||
feet < 0.0,
|
||||
"feet should sit below frame centre, got ndc_y {feet}"
|
||||
);
|
||||
assert!(
|
||||
crown > 0.0,
|
||||
"crown should sit above frame centre, got ndc_y {crown}"
|
||||
);
|
||||
assert!(
|
||||
feet.abs() < 0.95,
|
||||
"feet cropped / no ground room (ndc_y {feet}) at signal {signal}, aspect {aspect}"
|
||||
);
|
||||
assert!(
|
||||
crown < 0.95,
|
||||
"crown cropped / no headroom (ndc_y {crown}) at signal {signal}, aspect {aspect}"
|
||||
);
|
||||
let _ = projection; // the rig carries the same projection the test rebuilds
|
||||
}
|
||||
/// The M2 `cameras[1]` records the RE reports, in Bevy space — `wow_to_bevy` is `(-y, z, -x)`.
|
||||
fn cam(eye_wow: [f32; 3], target_wow: [f32; 3], fov: f32) -> benilla_assets::PortraitCamera {
|
||||
benilla_assets::PortraitCamera {
|
||||
eye: benilla_assets::coords::wow_to_bevy(eye_wow),
|
||||
target: benilla_assets::coords::wow_to_bevy(target_wow),
|
||||
roll: 0.0,
|
||||
fov,
|
||||
near: 0.222_222,
|
||||
far: 27.777_8,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchors_with(
|
||||
pane_camera: Option<benilla_assets::PortraitCamera>,
|
||||
bbox_center: Vec3,
|
||||
) -> PortraitAnchors {
|
||||
PortraitAnchors {
|
||||
// Every heuristic field the RETIRED fit read is left deliberately populated and
|
||||
// deliberately unused: if any of them ever reaches the rig again, these tests are the
|
||||
// tripwire.
|
||||
camera: Some(cam(
|
||||
[0.6335, -0.3879, 1.8867],
|
||||
[0.0627, 0.0343, 1.8636],
|
||||
0.785,
|
||||
)),
|
||||
pane_camera,
|
||||
bbox_center,
|
||||
head: Some(Vec3::new(0.0, 1.75, 0.0)),
|
||||
pivot_height: 1.90,
|
||||
ground_radius: 0.35,
|
||||
}
|
||||
}
|
||||
|
||||
/// **The authored pane camera is taken verbatim** (decision 1089): eye, look-at and all three
|
||||
/// projection scalars are the record's, at every pane aspect. Nothing is fitted, nothing is
|
||||
/// derived from the model's size.
|
||||
#[test]
|
||||
fn a_pane_takes_the_models_own_camera_untouched() {
|
||||
// HumanMale's `cameras[1]`, per the RE.
|
||||
let human = cam([3.6585, 0.0338, 0.9227], [-0.3644, 0.0291, 0.9873], 0.97991);
|
||||
for &aspect in &[1.0_f32, 318.0 / 224.0, 233.0 / 224.0, 316.0 / 351.0] {
|
||||
let (transform, projection) =
|
||||
body_frame(&anchors_with(Some(human), Vec3::ZERO), aspect);
|
||||
assert!(
|
||||
transform.translation.distance(human.eye) < 1e-5,
|
||||
"eye moved: {:?} vs {:?}",
|
||||
transform.translation,
|
||||
human.eye
|
||||
);
|
||||
// Looking AT the record's target: the forward axis points from eye to target.
|
||||
let want = (human.target - human.eye).normalize();
|
||||
assert!(
|
||||
transform.forward().dot(want) > 0.9999,
|
||||
"not aimed at the record's target (dot {})",
|
||||
transform.forward().dot(want)
|
||||
);
|
||||
// The projection is the record's scalars with the pane's aspect in both roles — the
|
||||
// client renders straight into the pane rect, so the same number sets the matrix AND
|
||||
// the diagonal→vertical crop. Compared as the CLIP MATRIX, which is what the
|
||||
// rasteriser sees (and what a wrong field would move).
|
||||
let want = pane_projection(&human, aspect);
|
||||
assert_eq!(
|
||||
(want.fov, want.near, want.far),
|
||||
(human.fov, human.near, human.far)
|
||||
);
|
||||
assert_eq!((want.aspect, want.crop_aspect), (aspect, aspect));
|
||||
assert_eq!(
|
||||
projection.get_clip_from_view(),
|
||||
want.get_clip_from_view(),
|
||||
"the rig's projection is not the record's"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The correction itself: the pane does NOT normalize.** A boar's authored camera stands off
|
||||
/// 4.86yd and a gnome's 2.16, so a small model renders small and a large one large — the panes
|
||||
/// only *look* normalized because Blizzard calibrated each standoff by hand. The retired fit
|
||||
/// solved a distance from a head-height signal instead, which is why a quadruped came out too
|
||||
/// big and sitting too high (director, 2026-08-07).
|
||||
///
|
||||
/// Measured as apparent height: the on-screen span of one model yard at the subject.
|
||||
#[test]
|
||||
fn a_pane_is_not_normalized_across_models() {
|
||||
let aspect = 318.0 / 224.0; // the pet pane
|
||||
let bodies = [
|
||||
// (name, cameras[1] eye, target, fov, the model's own standing height)
|
||||
("GnomeFemale", [2.1591_f32, 0.0, 0.6136], 0.6136, 0.85_f32),
|
||||
("HumanMale", [3.6585, 0.0338, 0.9227], 0.9227, 1.85),
|
||||
("TaurenMale", [4.4317, -0.0213, 1.0861], 1.0861, 2.60),
|
||||
];
|
||||
let mut apparent = Vec::new();
|
||||
for (name, eye, target_z, _height) in bodies {
|
||||
let c = cam(eye, [-0.2, 0.0, target_z], 0.9);
|
||||
let (transform, _) = body_frame(&anchors_with(Some(c), Vec3::ZERO), aspect);
|
||||
let proj = WowPortraitProjection {
|
||||
fov: c.fov,
|
||||
near: c.near,
|
||||
far: c.far,
|
||||
aspect,
|
||||
crop_aspect: aspect,
|
||||
};
|
||||
// One yard of height at the subject's own standing point.
|
||||
let base = Vec3::new(0.0, c.target.y, 0.0);
|
||||
let span =
|
||||
(ndc_y(&transform, &proj, base + Vec3::Y) - ndc_y(&transform, &proj, base)).abs();
|
||||
apparent.push((name, span));
|
||||
}
|
||||
// A yard subtends LESS of the frame the further out the artist put the camera — which is
|
||||
// exactly "no normalization". Under the retired fit every model was solved to the same
|
||||
// window, so this ordering was flat.
|
||||
for w in apparent.windows(2) {
|
||||
assert!(
|
||||
w[0].1 > w[1].1,
|
||||
"{} should show a yard larger than {} ({} vs {})",
|
||||
w[0].0,
|
||||
w[1].0,
|
||||
w[0].1,
|
||||
w[1].1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The **fixed fallback** for a model with fewer than two cameras: the client's own literal eye,
|
||||
/// aimed at the MD20 bbox centre, with its own fov/clips — and, the point of the test, *the same
|
||||
/// camera whatever the model*. Only the look-at target moves.
|
||||
#[test]
|
||||
fn a_camera_less_model_gets_the_clients_fixed_rig() {
|
||||
let want_eye = Vec3::new(0.0, 87.0 / 36.0, -200.0 / 36.0);
|
||||
let mut eyes = Vec::new();
|
||||
for centre in [
|
||||
Vec3::ZERO,
|
||||
Vec3::new(0.0, 0.44, 0.0),
|
||||
Vec3::new(0.1, 1.3, 0.0),
|
||||
] {
|
||||
let (transform, projection) = body_frame(&anchors_with(None, centre), 318.0 / 224.0);
|
||||
assert!(
|
||||
transform.translation.distance(want_eye) < 1e-4,
|
||||
"fixed eye drifted: {:?}",
|
||||
transform.translation
|
||||
);
|
||||
let want = (centre - want_eye).normalize();
|
||||
assert!(
|
||||
transform.forward().dot(want) > 0.9999,
|
||||
"the fixed camera must look at the bbox centre"
|
||||
);
|
||||
let fixed = pane_camera(&anchors_with(None, centre));
|
||||
assert_eq!(fixed.fov, PANE_FIXED_FOV);
|
||||
assert!((fixed.near - 1.0 / 36.0).abs() < 1e-6 && fixed.far == 5000.0);
|
||||
assert_eq!(
|
||||
projection.get_clip_from_view(),
|
||||
pane_projection(&fixed, 318.0 / 224.0).get_clip_from_view()
|
||||
);
|
||||
eyes.push(transform.translation);
|
||||
}
|
||||
assert!(
|
||||
eyes.windows(2).all(|w| w[0] == w[1]),
|
||||
"the fallback camera is FIXED — the model's size must not move it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The **renormalize-to-4:3** model-root factor, against the values the RE reports —
|
||||
/// `√((4/3)²+1)/√(a²+1)`, which is `G48·(5/3)` with `G48 = 1/√(a²+1)`.
|
||||
#[test]
|
||||
fn the_model_root_renormalizes_to_four_thirds() {
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 1e-5;
|
||||
assert!(
|
||||
close(pane_model_scale(4.0 / 3.0), 1.0),
|
||||
"the reference aspect"
|
||||
);
|
||||
assert!(close(pane_model_scale(16.0 / 10.0), 0.883_332));
|
||||
assert!(close(pane_model_scale(16.0 / 9.0), 0.817_102));
|
||||
assert!(close(pane_model_scale(5.0 / 4.0), 1.041_158));
|
||||
}
|
||||
|
||||
/// **Which path the factor applies to, which is the whole subtlety.** The widget composes the
|
||||
/// camera's publish through the SAME model root, so on the authored path `s` cancels exactly and
|
||||
/// scaling the geometry alone would be wrong by `1/s` (+22% at 16:9). Only the fixed fallback
|
||||
/// camera — written model-local onto a `CCamera` outside the model's array — escapes that.
|
||||
#[test]
|
||||
fn the_root_factor_applies_only_where_it_does_not_cancel() {
|
||||
let human = cam([3.6585, 0.0338, 0.9227], [-0.3644, 0.0291, 0.9873], 0.97991);
|
||||
for &a in &[4.0 / 3.0, 16.0 / 9.0, 16.0 / 10.0, 5.0 / 4.0] {
|
||||
assert_eq!(
|
||||
pane_root_scale(&anchors_with(Some(human), Vec3::ZERO), a),
|
||||
1.0,
|
||||
"an authored camera cancels the factor at aspect {a}"
|
||||
);
|
||||
assert_eq!(
|
||||
pane_root_scale(&anchors_with(None, Vec3::new(0.0, 0.62, 0.0)), a),
|
||||
pane_model_scale(a),
|
||||
"the fixed fallback camera does not"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The client's diagonal→vertical crop is **aspect-dependent**, and hardcoding the 4/3 value
|
||||
/// (0.6) was only right at 4/3. `fovy = fov/√(a²+1)` — so the pet pane's 318×224 crops to
|
||||
/// 0.576·fov and the character pane's 233×224 to 0.693·fov.
|
||||
#[test]
|
||||
fn the_diagonal_crop_follows_the_pane_aspect() {
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 5e-4;
|
||||
assert!(close(diag_to_vert(1.0, 4.0 / 3.0), 0.6), "the 4/3 legend");
|
||||
assert!(close(diag_to_vert(1.0, 318.0 / 224.0), 0.575_86));
|
||||
assert!(close(diag_to_vert(1.0, 233.0 / 224.0), 0.693_05));
|
||||
assert!(close(
|
||||
diag_to_vert(1.0, 1.0),
|
||||
std::f32::consts::FRAC_1_SQRT_2
|
||||
));
|
||||
}
|
||||
|
||||
/// **The 1069 defect, pinned.** A booth bakes into a *square* target that the UI stretches into
|
||||
/// the pane's rect, so a figure only keeps its proportions when the projection runs at the
|
||||
/// pane's aspect. Measured the way the eye does: screen pixels per model yard, sideways vs
|
||||
@@ -374,18 +620,14 @@ mod tests {
|
||||
fn a_pane_shows_the_figure_unstretched_at_any_aspect() {
|
||||
for &(w, h) in &[(316.0_f32, 351.0_f32), (233.0, 224.0), (512.0, 512.0)] {
|
||||
let aspect = w / h;
|
||||
let anchors = PortraitAnchors {
|
||||
camera: None,
|
||||
head: None,
|
||||
pivot_height: 1.90,
|
||||
ground_radius: 0.35,
|
||||
};
|
||||
let (transform, _) = body_frame(&anchors, aspect);
|
||||
let c = cam([3.6585, 0.0338, 0.9227], [-0.3644, 0.0291, 0.9873], 0.97991);
|
||||
let (transform, _) = body_frame(&anchors_with(Some(c), Vec3::ZERO), aspect);
|
||||
let proj = WowPortraitProjection {
|
||||
fov: BODY_FOV,
|
||||
near: 0.02,
|
||||
far: 100.0,
|
||||
fov: c.fov,
|
||||
near: c.near,
|
||||
far: c.far,
|
||||
aspect,
|
||||
crop_aspect: aspect,
|
||||
};
|
||||
// A small cross at the framing centre — the on-screen size of each arm, in pane pixels.
|
||||
let mid = Vec3::new(0.0, transform.translation.y, 0.0);
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::entities::Creatures;
|
||||
use crate::model_render::m2_url;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
|
||||
use super::framing::{attachment_point, DIAG_TO_VERT};
|
||||
use super::framing::{attachment_point, diag_to_vert, PORTRAIT_CROP_ASPECT};
|
||||
use super::{
|
||||
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
|
||||
BoothBillboardSpec, BoothCam, BoothEffects, BoothLight, BoothMotion, BoothPart, BoothRider,
|
||||
@@ -954,14 +954,24 @@ pub(super) fn sync_glue_booth(
|
||||
// Aspect 1.0 into `body_frame`: only its TRANSFORM is kept here — the projection
|
||||
// beside it is the window-sized one this booth needs (decision 1069's aspect rides on
|
||||
// the discarded half).
|
||||
//
|
||||
// Since 1089 that transform is the model's own `<PlayerModel>` pane camera (raw index 1),
|
||||
// which is the client's full-body character framing — the right stand-in, and the fov below
|
||||
// now comes off the same record rather than from the retired body-fit constant. The crop
|
||||
// aspect is the portrait bake's 4/3 rather than this window's: exact framing here does not
|
||||
// matter, because `sync_glue_scene` re-aims onto the scene's authored camera the frame the
|
||||
// art lands, and this rig only ever shows for those few loading frames.
|
||||
let (t, _) = body_frame(&anchors, 1.0);
|
||||
let record_fov = anchors
|
||||
.pane_camera
|
||||
.map_or(super::framing::PANE_FIXED_FOV, |c| c.fov);
|
||||
aim(
|
||||
&mut cams,
|
||||
GLUE_SLOT,
|
||||
&(
|
||||
t,
|
||||
Projection::from(PerspectiveProjection {
|
||||
fov: DIAG_TO_VERT * super::framing::BODY_FOV,
|
||||
fov: diag_to_vert(record_fov, PORTRAIT_CROP_ASPECT),
|
||||
near: 0.02,
|
||||
far: 100.0,
|
||||
..default()
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
//! Each portrait slot (`"player"`/`"target"`) gets its own render layer + camera rendering into a
|
||||
//! [`PortraitImages`] entry. A third slot — `"paperdoll"` (decision 0208 §5) — reuses the exact same
|
||||
//! bake pipeline for the character window's **full-body** model pane: it mirrors the *player's*
|
||||
//! dressed look like the `"player"` slot, but frames the whole standing figure from the model's
|
||||
//! bounds ([`framing::body_frame`], not the authored bust camera), bakes at 512², and spins the
|
||||
//! dressed look like the `"player"` slot, but frames it through the model's own `<PlayerModel>`
|
||||
//! camera ([`framing::body_frame`] — raw `cameras[1]`, not the authored bust camera the round
|
||||
//! portraits use; decision 1089), bakes at 512², and spins the
|
||||
//! model to a live yaw ([`PaperDollBooth`], the ref's `Model:SetRotation`). The UI samples it
|
||||
//! *square*, not through the circular mask.
|
||||
//!
|
||||
@@ -471,10 +472,13 @@ fn log_bake(
|
||||
/// with `camera: None` inside, which the framing heuristics are built to handle.
|
||||
///
|
||||
/// Both bake sites used to paper over that with `unwrap_or(PortraitAnchors { .., pivot_height: 0.0,
|
||||
/// .. })`. That is not a neutral default: [`framing::body_frame`] floors the head signal at `0.1`
|
||||
/// for "a hypothetical bounds-less display", so zero anchors aim the camera at a 0.1-unit-tall
|
||||
/// subject — the paper doll "zoomed into the max" and the wrong-size portrait of `#bugs` B106. And
|
||||
/// it latches: the camera is aimed once per bake, and the parts key cannot change afterwards
|
||||
/// .. })`. That is not a neutral default: the retired body fit floored the head signal at `0.1` for
|
||||
/// "a hypothetical bounds-less display", so zero anchors aimed the camera at a 0.1-unit-tall
|
||||
/// subject — the paper doll "zoomed into the max" and the wrong-size portrait of `#bugs` B106. (1089
|
||||
/// retired that fit, and zero anchors are no longer a *zoom* — but they are still the wrong camera:
|
||||
/// an unbuilt display has no `cameras[1]` to read, so the pane would latch the fixed fallback rig
|
||||
/// aimed at a zero bbox centre.) And it latches: the camera is aimed once per bake, and the parts
|
||||
/// key cannot change afterwards
|
||||
/// (handle ids are stable from `load()`, so mesh/model arrival is invisible to it), so the frame
|
||||
/// stays wrong until some unrelated content edge forces a re-bake.
|
||||
///
|
||||
@@ -487,6 +491,8 @@ fn booth_anchors(
|
||||
let Some(display_id) = display_id else {
|
||||
return Some(PortraitAnchors {
|
||||
camera: None,
|
||||
pane_camera: None,
|
||||
bbox_center: Vec3::ZERO,
|
||||
head: None,
|
||||
pivot_height: 0.0,
|
||||
ground_radius: 0.0,
|
||||
@@ -570,6 +576,57 @@ pub(crate) struct BoothBridge<'w> {
|
||||
#[derive(Component)]
|
||||
pub(crate) struct BoothCam(pub(crate) String);
|
||||
|
||||
/// **The display aspect the client's `screencoord` scale is computed from** — its `gxResolution`
|
||||
/// CVar's `width/height`, gated by the `widescreen` CVar (registered with default `"1"`, so on by
|
||||
/// default; with it off the client uses `4/3` on any monitor). Its one consumer is
|
||||
/// [`framing::pane_model_scale`], the model-root renormalize factor a `<PlayerModel>` pane applies
|
||||
/// to a model with no camera of its own.
|
||||
///
|
||||
/// **A stated deviation:** benilla has neither CVar yet, so this reads the primary window's own
|
||||
/// aspect. That is the same number whenever the client runs at its display's native resolution, and
|
||||
/// ours always does — we expose no resolution mode list to disagree with. When the graphics CVars
|
||||
/// land, this resource is the one place to repoint.
|
||||
///
|
||||
/// Defaults to `4/3` — the client's own `.data` default pair, and the aspect at which the factor is
|
||||
/// exactly `1.0`, so a booth that bakes before the window is measured is unscaled rather than wrong.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct GxAspect(pub(crate) f32);
|
||||
|
||||
impl Default for GxAspect {
|
||||
fn default() -> Self {
|
||||
Self(4.0 / 3.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the primary window's aspect into [`GxAspect`] (see its deviation note). Cheap enough to run
|
||||
/// every frame; the booths latch it, so a resize re-scales the standing bake exactly once.
|
||||
fn feed_gx_aspect(
|
||||
mut gx: ResMut<GxAspect>,
|
||||
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
) {
|
||||
let Ok(w) = window.single() else {
|
||||
return;
|
||||
};
|
||||
let (px_w, px_h) = (w.resolution.width(), w.resolution.height());
|
||||
if px_h > 0.0 {
|
||||
let a = px_w / px_h;
|
||||
if (a - gx.0).abs() > 1e-4 {
|
||||
gx.0 = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two **framing inputs** a body booth reads, in one param: the pane geometry the UI extract
|
||||
/// publishes ([`BoothPanes`]) and the display aspect ([`GxAspect`]).
|
||||
///
|
||||
/// Bundled rather than passed side by side because these systems sit on Bevy's 16-parameter ceiling
|
||||
/// — the same reason [`BoothBridge`] exists on the other side of the seam.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct BoothFraming<'w> {
|
||||
pub(crate) panes: Res<'w, BoothPanes>,
|
||||
pub(crate) gx: Res<'w, GxAspect>,
|
||||
}
|
||||
|
||||
/// Owns the portrait bake pipeline: the [`PortraitImages`] bridge + the per-slot off-screen booths.
|
||||
pub(crate) struct PortraitPlugin;
|
||||
|
||||
@@ -585,11 +642,12 @@ impl Plugin for PortraitPlugin {
|
||||
.init_resource::<dressup::DressUpBake>()
|
||||
.init_resource::<Booths>()
|
||||
.init_resource::<BoothPanes>()
|
||||
.init_resource::<GxAspect>()
|
||||
.init_resource::<BoothLight>()
|
||||
.init_resource::<crate::ui_session::InteractNpc>()
|
||||
.add_systems(Startup, setup_booths)
|
||||
// The variant-cache reaper: booth twins die with their world source material.
|
||||
.add_systems(Update, light::reap_dead_variants)
|
||||
.add_systems(Update, (light::reap_dead_variants, feed_gx_aspect))
|
||||
// `feed_interact_npc` resolves the `"npc"` token's entity first; then the test bake owns
|
||||
// the booths when its env is set (the live syncs yield to it). The paper-doll sync runs
|
||||
// last (it shares the camera/booth/image resources, so the chain keeps the access ordered).
|
||||
@@ -708,11 +766,12 @@ pub(crate) fn spawn_warm_booth(
|
||||
RenderTarget::Image(image.into()),
|
||||
crate::ffx_glow::FfxGlow::BOOTH,
|
||||
Projection::custom(framing::WowPortraitProjection {
|
||||
fov: framing::BODY_FOV,
|
||||
fov: framing::PANE_FIXED_FOV,
|
||||
near: 0.02,
|
||||
far: 100.0,
|
||||
// The warm pass compiles PIPELINES, which the aspect does not key.
|
||||
// The warm pass compiles PIPELINES, which neither aspect keys.
|
||||
aspect: 1.0,
|
||||
crop_aspect: 1.0,
|
||||
}),
|
||||
layer.clone(),
|
||||
))
|
||||
@@ -1216,9 +1275,9 @@ fn sync_paperdoll(
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||||
mut env_cache: Local<Option<bool>>,
|
||||
mut last_yaw: Local<Option<f32>>,
|
||||
mut last_pose: Local<Option<(f32, f32)>>,
|
||||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||||
panes: Res<BoothPanes>,
|
||||
framing_in: BoothFraming,
|
||||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||||
) {
|
||||
if test_mode(&mut env_cache) {
|
||||
@@ -1229,7 +1288,8 @@ fn sync_paperdoll(
|
||||
PAPERDOLL_SLOT,
|
||||
self_q.single().ok(),
|
||||
paperdoll.yaw,
|
||||
&mut last_yaw,
|
||||
&mut last_pose,
|
||||
framing_in.gx.0,
|
||||
&mut commands,
|
||||
&mut booths,
|
||||
&mut portraits,
|
||||
@@ -1240,7 +1300,7 @@ fn sync_paperdoll(
|
||||
&mut wow_mats,
|
||||
&mut cams,
|
||||
anim_data.as_deref(),
|
||||
&panes,
|
||||
&framing_in.panes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1259,9 +1319,9 @@ fn sync_inspect_booth(
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||||
mut env_cache: Local<Option<bool>>,
|
||||
mut last_yaw: Local<Option<f32>>,
|
||||
mut last_pose: Local<Option<(f32, f32)>>,
|
||||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||||
panes: Res<BoothPanes>,
|
||||
framing_in: BoothFraming,
|
||||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||||
) {
|
||||
if test_mode(&mut env_cache) {
|
||||
@@ -1272,7 +1332,8 @@ fn sync_inspect_booth(
|
||||
INSPECT_SLOT,
|
||||
inspect.unit,
|
||||
inspect.yaw,
|
||||
&mut last_yaw,
|
||||
&mut last_pose,
|
||||
framing_in.gx.0,
|
||||
&mut commands,
|
||||
&mut booths,
|
||||
&mut portraits,
|
||||
@@ -1283,7 +1344,7 @@ fn sync_inspect_booth(
|
||||
&mut wow_mats,
|
||||
&mut cams,
|
||||
anim_data.as_deref(),
|
||||
&panes,
|
||||
&framing_in.panes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1302,9 +1363,9 @@ fn sync_petdoll_booth(
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||||
mut env_cache: Local<Option<bool>>,
|
||||
mut last_yaw: Local<Option<f32>>,
|
||||
mut last_pose: Local<Option<(f32, f32)>>,
|
||||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||||
panes: Res<BoothPanes>,
|
||||
framing_in: BoothFraming,
|
||||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||||
) {
|
||||
if test_mode(&mut env_cache) {
|
||||
@@ -1315,7 +1376,8 @@ fn sync_petdoll_booth(
|
||||
PETDOLL_SLOT,
|
||||
petdoll.unit,
|
||||
petdoll.yaw,
|
||||
&mut last_yaw,
|
||||
&mut last_pose,
|
||||
framing_in.gx.0,
|
||||
&mut commands,
|
||||
&mut booths,
|
||||
&mut portraits,
|
||||
@@ -1326,7 +1388,7 @@ fn sync_petdoll_booth(
|
||||
&mut wow_mats,
|
||||
&mut cams,
|
||||
anim_data.as_deref(),
|
||||
&panes,
|
||||
&framing_in.panes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1339,7 +1401,9 @@ fn sync_body_booth(
|
||||
slot: &str,
|
||||
unit: Option<Entity>,
|
||||
yaw: f32,
|
||||
last_yaw: &mut Option<f32>,
|
||||
last_pose: &mut Option<(f32, f32)>,
|
||||
// The primary window's aspect ([`GxAspect`]) — only the model-root scale reads it.
|
||||
display_aspect: f32,
|
||||
commands: &mut Commands,
|
||||
booths: &mut Booths,
|
||||
portraits: &mut PortraitImages,
|
||||
@@ -1377,7 +1441,7 @@ fn sync_body_booth(
|
||||
if booth.baked.is_some() {
|
||||
commands.entity(booth.root).despawn_related::<Children>();
|
||||
booth.baked = None;
|
||||
*last_yaw = None;
|
||||
*last_pose = None;
|
||||
// Render the emptied stage before sleeping (decision 0540) — and the emptied stage has
|
||||
// no emitters left, so the pane stops being live.
|
||||
booth.wake = BOOTH_SETTLE_FRAMES;
|
||||
@@ -1388,15 +1452,24 @@ fn sync_body_booth(
|
||||
}
|
||||
let unit = unit.expect("unit present — parts came from its descendants");
|
||||
let key = LookKey::build(&parts, &riders, &billboards, &effects);
|
||||
// A changed pane aspect re-runs the same path: the fit and the projection both depend on it,
|
||||
// and it only ever moves once — the first frame the window is drawn.
|
||||
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
|
||||
// Anchors first, before any teardown — a still-loading display must not be framed from
|
||||
// fabricated zero bounds (see the portrait site, and `booth_anchors`). Resolved out here rather
|
||||
// than inside the bake because the model-root scale below needs them on an otherwise-idle frame
|
||||
// too (a window resize moves the scale without touching the bake).
|
||||
let anchors_now = booth_anchors(creatures, display_id);
|
||||
// The model-root scale, taken while the anchors are still in hand (the bake below consumes
|
||||
// them). `1.0` until they resolve — an unscaled root is the harmless default, and the 4:3
|
||||
// factor is 1.0 anyway.
|
||||
let model_scale = anchors_now
|
||||
.as_ref()
|
||||
.map_or(1.0, |a| framing::pane_root_scale(a, display_aspect));
|
||||
// A changed pane aspect re-runs the same path: the camera's projection depends on it, and it
|
||||
// only ever moves once — the first frame the window is drawn.
|
||||
let parts_changed = booth.baked.as_ref() != Some(&key) || booth.aspect != aspect;
|
||||
if parts_changed {
|
||||
booth.aspect = aspect;
|
||||
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
|
||||
// Anchors first, before any teardown — a still-loading display must not be framed from
|
||||
// fabricated zero bounds (see the portrait site, and `booth_anchors`).
|
||||
let Some(anchors) = booth_anchors(creatures, display_id) else {
|
||||
let Some(anchors) = anchors_now else {
|
||||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||||
log_bake(slot, "wait-anchors", &parts, &riders, &billboards, &effects);
|
||||
return;
|
||||
@@ -1505,14 +1578,22 @@ fn sync_body_booth(
|
||||
);
|
||||
booth.baked = Some(key);
|
||||
}
|
||||
// Yaw → the model root's rotation (the ref's `Model:SetRotation`; a spin about WoW +Z-up
|
||||
// conjugates to a spin about Bevy +Y-up). Applied on a fresh bake (new root children) or
|
||||
// whenever the pane's yaw moves — never touched on an otherwise-idle frame.
|
||||
if parts_changed || *last_yaw != Some(yaw) {
|
||||
commands
|
||||
.entity(booth.root)
|
||||
.insert(Transform::from_rotation(Quat::from_rotation_y(yaw)));
|
||||
*last_yaw = Some(yaw);
|
||||
// The model root: **yaw → rotation, plus the pane's model scale** — the widget's own
|
||||
// `T(pos)·R(facing)·S(s)` with `pos` at the origin (the ref's `Model:SetRotation` writes the
|
||||
// facing, and a spin about WoW +Z-up conjugates to a spin about Bevy +Y-up).
|
||||
//
|
||||
// `s` is [`framing::pane_root_scale`]: `1.0` for a model with its own camera and the
|
||||
// display-aspect renormalize factor for one without. It rides the same latch as the yaw because
|
||||
// it moves for the same reason — a window resize — and the reference re-snapshots on exactly
|
||||
// that (`DISPLAY_SIZE_CHANGED → RefreshUnit()`, which both panes register). Cheap enough to
|
||||
// re-derive here rather than key a whole re-bake on: the CAMERA does not depend on the display
|
||||
// aspect, only the root does.
|
||||
if parts_changed || *last_pose != Some((yaw, model_scale)) {
|
||||
commands.entity(booth.root).insert(
|
||||
Transform::from_rotation(Quat::from_rotation_y(yaw))
|
||||
.with_scale(Vec3::splat(model_scale)),
|
||||
);
|
||||
*last_pose = Some((yaw, model_scale));
|
||||
// A spin is a content edge too (decision 0540): render the new pose, then sleep.
|
||||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,14 @@ fn bake_test(
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let anchors = PortraitAnchors {
|
||||
camera: model.portrait_camera,
|
||||
pane_camera: model.pane_camera,
|
||||
bbox_center: model.bounds.map_or(Vec3::ZERO, |b| {
|
||||
benilla_assets::coords::wow_to_bevy([
|
||||
(b.bbox_min[0] + b.bbox_max[0]) * 0.5,
|
||||
(b.bbox_min[1] + b.bbox_max[1]) * 0.5,
|
||||
(b.bbox_min[2] + b.bbox_max[2]) * 0.5,
|
||||
])
|
||||
}),
|
||||
head: head_anchor(&model.skeleton, &model.attachments),
|
||||
pivot_height,
|
||||
ground_radius,
|
||||
|
||||
@@ -101,9 +101,108 @@ fn go_state_sounds(
|
||||
}
|
||||
}
|
||||
|
||||
/// The GO display-slot an M2 animation-event tag addresses — the reference's GO event dispatcher
|
||||
/// `0x5f3e20` (wow-re `go-display-sound-events.md`, byte-verified; the 1086 fold-back): `$GO0..5`
|
||||
/// → `Sound[0..5]`, `$GC0..3` → the Custom slots `Sound[6..9]`. Every other tag is not this
|
||||
/// channel's (`$SND`/`$DSO`/`$DSL` carry a literal kit id and ride the generic
|
||||
/// [`crate::sound::anim_events`] arms; `$SHK` is camera shake, no audio).
|
||||
fn go_event_slot(ident: &[u8; 4]) -> Option<usize> {
|
||||
match ident {
|
||||
[b'$', b'G', b'O', d @ b'0'..=b'5'] => Some((d - b'0') as usize),
|
||||
[b'$', b'G', b'C', d @ b'0'..=b'3'] => Some(6 + (d - b'0') as usize),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Play the display-slot kits a GameObject's animation events name — the audio half of the GO
|
||||
/// M2 event kernel ([`crate::go_anim`]'s scanner feeds the stream; wow-re
|
||||
/// `go-display-sound-events.md`: `0x5f4010` is the binary's ONLY reader of the display row's
|
||||
/// sound columns, reached solely from the anim-event dispatcher). The load-bearing tenant is the
|
||||
/// fishing bobber's bite: Custom0's `$GC0` at t≈3.87 s → display 668 `Sound6` = kit 3355
|
||||
/// "Fishing Hooked" — fired **once per 0xB3** (the completion retire re-arms Stand before a
|
||||
/// second pass, decision 1100), beside the server's explicit `SMSG_PLAY_OBJECT_SOUND(3355)` ~200
|
||||
/// ms earlier — the client issues exactly two starts of the kit, then silence (no same-kit dedup
|
||||
/// exists anywhere in the reference's play chain).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn go_event_sounds(
|
||||
mut events: MessageReader<crate::creature_anim::AnimSoundEvent>,
|
||||
gos: Query<(&NetEntity, &Transform)>,
|
||||
go_sounds: Option<Res<GoSounds>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
config: Res<SoundConfig>,
|
||||
listener: Res<AudioListener>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (Some(go_sounds), Some(mut kits), Some(assets)) = (go_sounds, kits, assets) else {
|
||||
return;
|
||||
};
|
||||
let listener = listener.pos;
|
||||
for ev in events.read() {
|
||||
let Some(slot) = go_event_slot(&ev.ident) else {
|
||||
continue;
|
||||
};
|
||||
// A creature clip authoring a `$GO*`/`$GC*` tag doesn't resolve here: the query wants a
|
||||
// GameObject's display row, and only GO entities carry one in the display-slot table.
|
||||
let Ok((net, transform)) = gos.get(ev.entity) else {
|
||||
continue;
|
||||
};
|
||||
if net.kind != EntityKind::GameObject {
|
||||
continue;
|
||||
}
|
||||
let kit = net
|
||||
.display_id
|
||||
.and_then(|d| go_sounds.0.slots(d))
|
||||
.map(|s| s[slot])
|
||||
.unwrap_or(0);
|
||||
if kit == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = play_kit(
|
||||
&mut kits,
|
||||
&assets,
|
||||
&mut out,
|
||||
&config,
|
||||
listener,
|
||||
KitRef::Id(kit),
|
||||
Some(transform.translation),
|
||||
SoundCategory::Sfx,
|
||||
) {
|
||||
warn!("GO event sound (slot {slot}, kit {kit}): {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.add_systems(Startup, load_go_sounds.after(AssetSet::Open))
|
||||
// After the Net drain wrote this frame's descriptor deltas.
|
||||
.add_systems(Update, go_state_sounds.in_set(WorldStage::Present));
|
||||
// After the Net drain wrote this frame's descriptor deltas (the state watcher), and the
|
||||
// event consumer after the GO scanner's write in the same frame's Present set.
|
||||
.add_systems(
|
||||
Update,
|
||||
(go_state_sounds, go_event_sounds).in_set(WorldStage::Present),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::go_event_slot;
|
||||
|
||||
/// The dispatcher's slot table (wow-re `go-display-sound-events.md`): `$GO0..5` are the
|
||||
/// first six display slots, `$GC0..3` the four Custom slots 6..9 — the bobber's splash is
|
||||
/// `$GC0` → slot 6. Out-of-range digits and other families are not this channel.
|
||||
#[test]
|
||||
fn event_tags_map_to_display_slots() {
|
||||
assert_eq!(go_event_slot(b"$GO0"), Some(0));
|
||||
assert_eq!(go_event_slot(b"$GO5"), Some(5));
|
||||
assert_eq!(go_event_slot(b"$GC0"), Some(6)); // the bobber splash
|
||||
assert_eq!(go_event_slot(b"$GC3"), Some(9));
|
||||
assert_eq!(go_event_slot(b"$GO6"), None);
|
||||
assert_eq!(go_event_slot(b"$GC4"), None);
|
||||
assert_eq!(go_event_slot(b"$SND"), None);
|
||||
assert_eq!(go_event_slot(b"$FSD"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,6 +583,7 @@ fn route_lock_refusal(
|
||||
key: "ERR_USE_LOCKED_WITH_ITEM_S",
|
||||
fill_s: Some(name),
|
||||
fill_d: None,
|
||||
info: false,
|
||||
}),
|
||||
},
|
||||
benilla_formats::LOCK_KEY_SKILL => {
|
||||
@@ -593,12 +594,14 @@ fn route_lock_refusal(
|
||||
key: "ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI",
|
||||
fill_s: Some(name),
|
||||
fill_d: Some(required),
|
||||
info: false,
|
||||
})
|
||||
} else {
|
||||
Some(UiError {
|
||||
key: "ERR_USE_LOCKED_WITH_SPELL_S",
|
||||
fill_s: Some(name),
|
||||
fill_d: None,
|
||||
info: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,24 @@ pub(crate) const GO_TYPE_GENERIC: i32 = 5;
|
||||
const GO_TYPE_TRANSPORT: i32 = 11;
|
||||
const GO_TYPE_MAP_OBJECT: i32 = 14;
|
||||
const GO_TYPE_MO_TRANSPORT: i32 = 15;
|
||||
/// The **marker set** — SPELL_FOCUS(8), DUEL_ARBITER(16), FISHINGHOLE(25), AURA_GENERATOR(30).
|
||||
/// Their strategy vtables are the mirror image of the transports': `+0xc` (mouseover eligibility) is
|
||||
/// a constant `b0 01 c3` = `mov al,1; ret` and `+0x14` (**highlightable**) a constant
|
||||
/// `32 c0 c3` = `xor al,al; ret` (wow-re `object-layer/scratch/go-render-gate.md`, "the per-type
|
||||
/// handler vtables": vtables `0x80b8a8`/`0x80bb68`/`0x80bbf8`/`0x80c2a0`, `+0xc` targets
|
||||
/// `0x5f57e0`/`0x5f65f0`/`0x5f6660`/`0x5f6ea0`, `+0x14` targets
|
||||
/// `0x5f57f0`/`0x5f6600`/`0x5f6670`/`0x5f6eb0` — const bytes read for all four).
|
||||
///
|
||||
/// So they hover but never *interact*: the anvil, the forge, the campfire, the duel flag, the fishing
|
||||
/// school publish a mouseover (gold-name tooltip) and then show **cursor mode 0** — the plain
|
||||
/// pointer, not the Interact gear — take no `+64` brighten, and swallow the right-click
|
||||
/// (`OnUse 0x5f8660` gates on the same `+0x14`). Without this arm they fell through to
|
||||
/// [`highlightable_flags`], whose flag/faction terms a SPELL_FOCUS passes trivially (`flags 0x0`),
|
||||
/// and every anvil in the world wore the gear.
|
||||
const GO_TYPE_SPELL_FOCUS: i32 = 8;
|
||||
const GO_TYPE_DUEL_ARBITER: i32 = 16;
|
||||
const GO_TYPE_FISHINGHOLE: i32 = 25;
|
||||
const GO_TYPE_AURA_GENERATOR: i32 = 30;
|
||||
/// `GAMEOBJECT_TYPE_TEXT` (9) — a readable plaque/sign; its per-type behavior shows the **Inspect**
|
||||
/// magnifier (wow-re cursor-system §4, `0x5f5890`), not the gear.
|
||||
const GO_TYPE_TEXT: i32 = 9;
|
||||
@@ -205,6 +223,23 @@ const GO_TYPE_28: i32 = 28;
|
||||
/// Interim GameObject interact-range gray (decision 0236): reuse the ~5.56 yd service reach until the
|
||||
/// size-dependent GO interact distance is byte-pinned. Squared, boundary-inclusive like the unit gates.
|
||||
const GO_INTERACT_RANGE_SQ: f32 = SERVICE_RANGE_SQ;
|
||||
/// `GAMEOBJECT_TYPE_FISHINGNODE` (17) — the fishing bobber. Its strategy (vtable `0x80bc80`, wow-re
|
||||
/// `fishing-bobber-interaction.md`) overrides exactly one consumed slot: highlightable is the
|
||||
/// channel-ownership gate ([`highlightable_flags`]'s type-17 arm); everything else is the shared base.
|
||||
pub(crate) const GO_TYPE_FISHINGNODE: i32 = 17;
|
||||
/// The per-type interact range the shared `usable 0x5f3130` compares — the strategy ctor's
|
||||
/// `[strat+0xc]` constant, **squared at the compare**, boundary-inclusive (wow-re
|
||||
/// `fishing-bobber-interaction.md` §3). FISHINGNODE's ctor sets **100.0 yd** (`0x5f66b0`,
|
||||
/// `[0x80b0b0]`) — the bobber is effectively un-range-gated for any real cast, never the ~5.56 yd
|
||||
/// reach. Other types stay on the 0236 interim (the base default is 5.0 and several per-type ctors
|
||||
/// override it — 5.5556/10.0/…; their population is a later byte-pin).
|
||||
fn go_interact_range_sq(type_id: i32) -> f32 {
|
||||
if type_id == GO_TYPE_FISHINGNODE {
|
||||
100.0 * 100.0
|
||||
} else {
|
||||
GO_INTERACT_RANGE_SQ
|
||||
}
|
||||
}
|
||||
/// `GameObjectFlags` bits consulted by the highlightable gate (decision 0243, wow-re cursor-system §4a):
|
||||
/// `0x1` IN_USE (busy) and `0x10` NO_INTERACT both suppress interaction; their union is the fast reject.
|
||||
const GO_FLAG_IN_USE_OR_NO_INTERACT: u32 = 0x11;
|
||||
@@ -215,20 +250,56 @@ const GO_FLAG_INTERACT_COND: u32 = 0x4;
|
||||
/// server sets from `GameObject::ActivateToQuest` (sparkle). Consulted only under `INTERACT_COND`.
|
||||
const GO_DYNFLAG_ACTIVATE: u32 = 0x1;
|
||||
|
||||
/// The GameObject types whose strategy vtable **overrides** the highlightable slot (`+0x14`) with a
|
||||
/// constant `xor al,al` — so `0x5f2f80` is never reached for them and they are never highlightable,
|
||||
/// whatever their flags, faction or activate bit say. Three families, all byte-read off the 5875
|
||||
/// vtables: GENERIC(5) decoration (`0x5f47f0`), the transports (see [`GO_TYPE_TRANSPORT`]), and the
|
||||
/// marker set (see [`GO_TYPE_SPELL_FOCUS`]).
|
||||
///
|
||||
/// This is the whole per-type half of the predicate, kept as one named list because the reference
|
||||
/// keeps it as one vtable slot: adding a type here removes its cursor, its brighten, its right-click
|
||||
/// USE and its pick priority together, exactly as the binary does.
|
||||
fn strategy_never_highlightable(type_id: i32) -> bool {
|
||||
matches!(
|
||||
type_id,
|
||||
GO_TYPE_GENERIC
|
||||
| GO_TYPE_TRANSPORT
|
||||
| GO_TYPE_MAP_OBJECT
|
||||
| GO_TYPE_MO_TRANSPORT
|
||||
| GO_TYPE_SPELL_FOCUS
|
||||
| GO_TYPE_DUEL_ARBITER
|
||||
| GO_TYPE_FISHINGHOLE
|
||||
| GO_TYPE_AURA_GENERATOR
|
||||
)
|
||||
}
|
||||
|
||||
/// The client's GameObject **highlightable** predicate (decision 0243, wow-re cursor-system §4a,
|
||||
/// `0x5f2f80`) over its wire flags: whether the object shows an interact cursor / is clickable at all.
|
||||
/// GENERIC decoration and the transport family are never highlightable (their strategy vtables
|
||||
/// override the slot with constant-false); a busy (IN_USE) or NO_INTERACT object isn't; and an
|
||||
/// **INTERACT_COND** object (the quest gate) is highlightable only when the server has set its per-player
|
||||
/// **activate** dyn-flag — so a quest chest sparkles and opens only once the quest is held, while an
|
||||
/// ordinary door (no INTERACT_COND) is always highlightable regardless of its (zero) activate bit. The
|
||||
/// client's faction-reaction>1 term is INFERRED and not the quest gate, so it is not modeled here;
|
||||
/// `usable` (lock / range / player-state → the grayed twin) rides on top and is a later refinement.
|
||||
fn highlightable_flags(type_id: i32, flags: u32, dyn_flags: u32, reaction: Option<u8>) -> bool {
|
||||
if matches!(
|
||||
type_id,
|
||||
GO_TYPE_GENERIC | GO_TYPE_TRANSPORT | GO_TYPE_MAP_OBJECT | GO_TYPE_MO_TRANSPORT
|
||||
) {
|
||||
/// The types whose vtable constant-falses the slot never get here at all
|
||||
/// ([`strategy_never_highlightable`]); of the rest, a busy (IN_USE) or NO_INTERACT object isn't
|
||||
/// highlightable, and an **INTERACT_COND** object (the quest gate) is highlightable only when the
|
||||
/// server has set its per-player **activate** dyn-flag — so a quest chest sparkles and opens only once
|
||||
/// the quest is held, while an ordinary door (no INTERACT_COND) is always highlightable regardless of
|
||||
/// its (zero) activate bit. `usable` (lock / range / player-state → the grayed twin) rides on top and
|
||||
/// is a later refinement.
|
||||
///
|
||||
/// `channel_owned` is the FISHINGNODE override's one input (`0x5f6710`, wow-re
|
||||
/// `fishing-bobber-interaction.md` §2): whether the active player's `UNIT_FIELD_CHANNEL_OBJECT`
|
||||
/// equals **this** GO's guid. The bobber is highlightable only to the player currently channeling
|
||||
/// at exactly it — NOT a `CREATED_BY` compare — then tail-jumps into this shared gate. Someone
|
||||
/// else's bobber (or yours after the channel drops) produces nothing at all: no cursor, no
|
||||
/// tooltip/brighten (the `+0xc` thunk), and a silent dead right-click. Ignored for every other type.
|
||||
fn highlightable_flags(
|
||||
type_id: i32,
|
||||
flags: u32,
|
||||
dyn_flags: u32,
|
||||
reaction: Option<u8>,
|
||||
channel_owned: bool,
|
||||
) -> bool {
|
||||
if strategy_never_highlightable(type_id) {
|
||||
return false;
|
||||
}
|
||||
if type_id == GO_TYPE_FISHINGNODE && !channel_owned {
|
||||
return false;
|
||||
}
|
||||
// The FACTION term (`0x5f2f80` @ `0x5f3026/29`, decision 0764). `reaction` is the GameObject's
|
||||
@@ -302,9 +373,15 @@ pub(crate) fn go_reaction(
|
||||
/// |---|---|
|
||||
/// | `0x5f9db0` = `jmp [+0x14]` = **[`highlightable_flags`] itself** | 0 DOOR · 1 BUTTON · 2 QUESTGIVER · 3 CHEST · 4 BINDER · 6 TRAP · 7 CHAIR · 9 TEXT · 10 GOOBER · 12 AREADAMAGE · 13 CAMERA · 17 FISHINGNODE · 18 RITUAL · 19 MAILBOX · 20 AUCTIONHOUSE · 22 SPELLCASTER · 23 MEETINGSTONE · 24 FLAGSTAND · 26 FLAGDROP · 27 MINI_GAME · 28 LOTTERY_KIOSK · + the default |
|
||||
/// | `0x5f4830` = **`template.data[1] != 0`** (the vanilla "highlight" column) | 5 GENERIC |
|
||||
/// | `mov al,1` — always | 8 SPELL_FOCUS · 16 DUEL_ARBITER · 25 FISHINGHOLE |
|
||||
/// | `mov al,1` — always | 8 SPELL_FOCUS · 16 DUEL_ARBITER · 25 FISHINGHOLE · 30 AURA_GENERATOR |
|
||||
/// | `xor al,al` — never | 11 TRANSPORT · 14 MAP_OBJECT · 15 MO_TRANSPORT |
|
||||
///
|
||||
/// **This slot is not the cursor's.** For the marker set the two slots point opposite ways — `+0xc`
|
||||
/// constant-TRUE, `+0x14` constant-FALSE ([`GO_TYPE_SPELL_FOCUS`]) — so an anvil is *fully* hovered
|
||||
/// (mouseover published, gold-name tooltip) and *not at all* interactable (plain pointer, no
|
||||
/// brighten, no USE). Eligibility is never a stand-in for [`highlightable_flags`], and neither is
|
||||
/// its negation.
|
||||
///
|
||||
/// **This refutes the old "the tooltip is not gated by highlightable" reading** — that note verified
|
||||
/// the publisher correctly and then generalised **GENERIC's** behaviour to all 31 types. The
|
||||
/// signpost half survives (GENERIC really does answer from `data[1]`, and 1387 of 1870 shipped
|
||||
@@ -319,39 +396,58 @@ pub(crate) fn mouseover_eligible(
|
||||
dyn_flags: u32,
|
||||
generic_highlight: Option<bool>,
|
||||
reaction: Option<u8>,
|
||||
channel_owned: bool,
|
||||
) -> bool {
|
||||
match type_id {
|
||||
GO_TYPE_TRANSPORT | GO_TYPE_MAP_OBJECT | GO_TYPE_MO_TRANSPORT => false,
|
||||
GO_TYPE_SPELL_FOCUS | GO_TYPE_DUEL_ARBITER | GO_TYPE_FISHINGHOLE => true,
|
||||
GO_TYPE_SPELL_FOCUS
|
||||
| GO_TYPE_DUEL_ARBITER
|
||||
| GO_TYPE_FISHINGHOLE
|
||||
| GO_TYPE_AURA_GENERATOR => true,
|
||||
GO_TYPE_GENERIC => generic_highlight.unwrap_or(true),
|
||||
_ => highlightable_flags(type_id, flags, dyn_flags, reaction),
|
||||
_ => highlightable_flags(type_id, flags, dyn_flags, reaction, channel_owned),
|
||||
}
|
||||
}
|
||||
|
||||
/// The three types whose eligibility slot is a constant `mov al,1` — always the mouseover, whatever
|
||||
/// their flags say.
|
||||
const GO_TYPE_SPELL_FOCUS: i32 = 8;
|
||||
const GO_TYPE_DUEL_ARBITER: i32 = 16;
|
||||
const GO_TYPE_FISHINGHOLE: i32 = 25;
|
||||
|
||||
/// [`highlightable_flags`] read off a hovered GameObject's descriptor store. An absent
|
||||
/// `GAMEOBJECT_TYPE_ID` is the wire default `0` = DOOR (vmangos omits the zero field), so a door
|
||||
/// resolves to a highlightable type rather than being wrongly rejected as "unknown". Gates the
|
||||
/// CURSOR (and, via it, the click) only — the mouseover tooltip is *not* coupled to it
|
||||
/// (§5-VERIFIED, wow-re 2026-07-20): the pick registers every GO regardless of highlightable and
|
||||
/// the publisher dispatches the tooltip by kind, so a GENERIC(5) signpost tooltips (0349's
|
||||
/// reference close-up) while showing no cursor. Also the GO pick's **pass-2 priority** (decision
|
||||
/// 1071): the reference's classify `0x480c90` ranks a highlightable GameObject `1` (via
|
||||
/// `0x5f8800`, this same strategy `+0x14` predicate), else `0`.
|
||||
pub(super) fn go_highlightable(store: &ObjectStore, reaction: Option<u8>) -> bool {
|
||||
/// resolves to a highlightable type rather than being wrongly rejected as "unknown".
|
||||
///
|
||||
/// Gates the **cursor**, the `+64` **brighten**, the right-click **USE** (`OnUse 0x5f8660` calls this
|
||||
/// same `+0x14` first) and the GO pick's **pass-2 priority** (decision 1071: classify `0x480c90`
|
||||
/// ranks a highlightable GameObject `1` via `0x5f8800`, else `0`) — the four consumers of one vtable
|
||||
/// slot. It is **not** the mouseover publish, which is the sibling slot `+0xc`
|
||||
/// ([`mouseover_eligible`]): the two agree for most types and deliberately disagree for GENERIC(5)
|
||||
/// (a signpost tooltips while showing no cursor — 0349's reference close-up) and for the marker set
|
||||
/// (an anvil tooltips while showing no cursor either).
|
||||
pub(crate) fn go_highlightable(
|
||||
store: &ObjectStore,
|
||||
reaction: Option<u8>,
|
||||
channel_owned: bool,
|
||||
) -> bool {
|
||||
highlightable_flags(
|
||||
store.0.gameobject_type_id(),
|
||||
store.0.gameobject_flags(),
|
||||
store.0.gameobject_dynamic_flags(),
|
||||
reaction,
|
||||
channel_owned,
|
||||
)
|
||||
}
|
||||
|
||||
/// The FISHINGNODE highlightable override's input (`0x5f6710`): is the local player currently
|
||||
/// channeling at exactly this GameObject — `self.UNIT_FIELD_CHANNEL_OBJECT == go_guid`. `false`
|
||||
/// when the self store hasn't streamed or the guid is unknown: an unverifiable bobber shows
|
||||
/// nothing, matching the reference's no-active-player early-false.
|
||||
pub(crate) fn fishing_channel_owned(
|
||||
self_store: Option<&ObjectStore>,
|
||||
go_guid: Option<u64>,
|
||||
) -> bool {
|
||||
match (self_store, go_guid) {
|
||||
(Some(s), Some(g)) => s.0.unit_channel_object() == Some(g),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A `LockType.dbc` **CursorName** stem → the [`CursorKind`] it names — the client's
|
||||
/// `CursorModeFromName` step (wow-re cursor-system.md §4, `0x523d40`) over the only three cursor-
|
||||
/// bearing lock kinds in 5875. An unknown/empty name resolves to `None`, which the base GO path
|
||||
@@ -520,7 +616,8 @@ pub(super) fn classify_cursor(
|
||||
store.0.gameobject_faction(),
|
||||
Some(self_store),
|
||||
);
|
||||
if !go_highlightable(store, reaction) {
|
||||
let channel_owned = fishing_channel_owned(Some(self_store), hovered_object.guid);
|
||||
if !go_highlightable(store, reaction, channel_owned) {
|
||||
return None;
|
||||
}
|
||||
// The type's own cursor wins; a base type reads its lock's data-named cursor (needs the
|
||||
@@ -557,7 +654,8 @@ pub(super) fn classify_cursor(
|
||||
)
|
||||
.blocks_usable(facts.flag_locked)
|
||||
});
|
||||
let unable = kind != CursorKind::PickLock && (lock_unmet || dist_sq > GO_INTERACT_RANGE_SQ);
|
||||
let unable = kind != CursorKind::PickLock
|
||||
&& (lock_unmet || dist_sq > go_interact_range_sq(store.0.gameobject_type_id()));
|
||||
Some((kind, unable))
|
||||
};
|
||||
// The reference makes one pick over all CGObjects and switches on type; benilla picks unit and
|
||||
@@ -675,88 +773,211 @@ mod tests {
|
||||
/// becomes the mouseover at all, so a false here is "no tooltip, no brighten, no cursor".
|
||||
#[test]
|
||||
fn mouseover_eligibility_matches_the_per_type_slot_table() {
|
||||
// The three constant-TRUE slots (`mov al,1`) ignore their flags entirely.
|
||||
// The constant-TRUE slots (`mov al,1`) ignore their flags entirely.
|
||||
for t in [
|
||||
GO_TYPE_SPELL_FOCUS,
|
||||
GO_TYPE_DUEL_ARBITER,
|
||||
GO_TYPE_FISHINGHOLE,
|
||||
GO_TYPE_AURA_GENERATOR,
|
||||
] {
|
||||
assert!(mouseover_eligible(t, GO_FLAG_INTERACT_COND, 0, None, None));
|
||||
assert!(mouseover_eligible(t, 0x10, 0, None, None));
|
||||
assert!(mouseover_eligible(
|
||||
t,
|
||||
GO_FLAG_INTERACT_COND,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
false
|
||||
));
|
||||
assert!(mouseover_eligible(t, 0x10, 0, None, None, false));
|
||||
}
|
||||
// The three constant-FALSE slots (`xor al,al`) — the transport family is never a mouseover.
|
||||
for t in [GO_TYPE_TRANSPORT, GO_TYPE_MAP_OBJECT, GO_TYPE_MO_TRANSPORT] {
|
||||
assert!(!mouseover_eligible(t, 0, GO_DYNFLAG_ACTIVATE, None, None));
|
||||
assert!(!mouseover_eligible(
|
||||
t,
|
||||
0,
|
||||
GO_DYNFLAG_ACTIVATE,
|
||||
None,
|
||||
None,
|
||||
false
|
||||
));
|
||||
}
|
||||
// GENERIC answers from its template's `data[1]` alone — the signpost that hovers vs the
|
||||
// scenery beside it that does not. Not from `highlightable_flags`, which rejects GENERIC.
|
||||
assert!(mouseover_eligible(GO_TYPE_GENERIC, 0, 0, Some(true), None));
|
||||
assert!(mouseover_eligible(
|
||||
GO_TYPE_GENERIC,
|
||||
0,
|
||||
0,
|
||||
Some(true),
|
||||
None,
|
||||
false
|
||||
));
|
||||
assert!(!mouseover_eligible(
|
||||
GO_TYPE_GENERIC,
|
||||
0,
|
||||
0,
|
||||
Some(false),
|
||||
None
|
||||
None,
|
||||
false
|
||||
));
|
||||
assert!(
|
||||
mouseover_eligible(GO_TYPE_GENERIC, 0, 0, None, None),
|
||||
mouseover_eligible(GO_TYPE_GENERIC, 0, 0, None, None, false),
|
||||
"template not answered yet reads eligible, so a signpost isn't blank while it queries"
|
||||
);
|
||||
// Everything else IS highlightable — which is the whole correction. A pre-quest
|
||||
// INTERACT_COND object (the Stone of Binding, a Stratholme portcullis at flags 0x24) and an
|
||||
// IN_USE / NO_INTERACT object show NOTHING, where we used to tooltip them.
|
||||
assert!(!mouseover_eligible(0, GO_FLAG_INTERACT_COND, 0, None, None));
|
||||
assert!(!mouseover_eligible(
|
||||
0,
|
||||
GO_FLAG_INTERACT_COND,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
false
|
||||
));
|
||||
assert!(mouseover_eligible(
|
||||
0,
|
||||
GO_FLAG_INTERACT_COND,
|
||||
GO_DYNFLAG_ACTIVATE,
|
||||
None,
|
||||
None
|
||||
None,
|
||||
false
|
||||
));
|
||||
assert!(!mouseover_eligible(3, 0x10, 0, None, None)); // NO_INTERACT chest
|
||||
assert!(!mouseover_eligible(3, 0x1, 0, None, None)); // IN_USE chest
|
||||
// The FACTION term (decision 0764): a door whose template is hostile to us is not
|
||||
// eligible — no cursor, no tooltip, no brighten. This is Deadmines' Factory Door
|
||||
// (faction 114, flags 0x20), which the director could still hover and open.
|
||||
assert!(!mouseover_eligible(0, 0x20, 0, None, Some(1)));
|
||||
assert!(mouseover_eligible(0, 0x20, 0, None, Some(3)));
|
||||
assert!(mouseover_eligible(0, 0x20, 0, None, Some(4)));
|
||||
assert!(!mouseover_eligible(3, 0x10, 0, None, None, false)); // NO_INTERACT chest
|
||||
assert!(!mouseover_eligible(3, 0x1, 0, None, None, false)); // IN_USE chest
|
||||
// The FACTION term (decision 0764): a door whose template is hostile to us is not
|
||||
// eligible — no cursor, no tooltip, no brighten. This is Deadmines' Factory Door
|
||||
// (faction 114, flags 0x20), which the director could still hover and open.
|
||||
assert!(!mouseover_eligible(0, 0x20, 0, None, Some(1), false));
|
||||
assert!(mouseover_eligible(0, 0x20, 0, None, Some(3), false));
|
||||
assert!(mouseover_eligible(0, 0x20, 0, None, Some(4), false));
|
||||
// TRAP inverts it: hostile-to-us is exactly the case a trap DOES highlight for.
|
||||
assert!(mouseover_eligible(GO_TYPE_TRAP, 0, 0, None, Some(1)));
|
||||
assert!(!mouseover_eligible(GO_TYPE_TRAP, 0, 0, None, Some(3)));
|
||||
assert!(mouseover_eligible(GO_TYPE_TRAP, 0, 0, None, Some(1), false));
|
||||
assert!(!mouseover_eligible(
|
||||
GO_TYPE_TRAP,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
Some(3),
|
||||
false
|
||||
));
|
||||
// An unresolvable reaction never blanks the world.
|
||||
assert!(mouseover_eligible(0, 0x20, 0, None, None));
|
||||
assert!(mouseover_eligible(0, 0x20, 0, None, None, false));
|
||||
// A plain door is still eligible — the everyday case must not regress.
|
||||
assert!(mouseover_eligible(0, 0, 0, None, None));
|
||||
assert!(mouseover_eligible(19, 0, 0, None, None)); // mailbox
|
||||
assert!(mouseover_eligible(0, 0, 0, None, None, false));
|
||||
assert!(mouseover_eligible(19, 0, 0, None, None, false)); // mailbox
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlightable_gates_the_quest_object_but_not_the_plain_door() {
|
||||
// An ordinary unlocked door (no INTERACT_COND) is highlightable regardless of its zero activate
|
||||
// bit — plain doors must never gray out.
|
||||
assert!(highlightable_flags(0, 0, 0, None)); // DOOR, no flags
|
||||
// A GENERIC decoration is never highlightable.
|
||||
assert!(!highlightable_flags(GO_TYPE_GENERIC, 0, 0, None));
|
||||
assert!(highlightable_flags(0, 0, 0, None, false)); // DOOR, no flags
|
||||
// A GENERIC decoration is never highlightable.
|
||||
assert!(!highlightable_flags(GO_TYPE_GENERIC, 0, 0, None, false));
|
||||
// Neither is the transport family (the byte-dumped constant-false +0x14 slots): no gear,
|
||||
// no tooltip, no USE on a boat / elevator / map object — flags can't make them so.
|
||||
for t in [GO_TYPE_TRANSPORT, GO_TYPE_MAP_OBJECT, GO_TYPE_MO_TRANSPORT] {
|
||||
assert!(!highlightable_flags(t, 0, GO_DYNFLAG_ACTIVATE, None));
|
||||
assert!(!highlightable_flags(t, 0, GO_DYNFLAG_ACTIVATE, None, false));
|
||||
}
|
||||
// Nor the marker set, whose `+0x14` is the same byte-dumped `xor al,al` — an anvil / forge /
|
||||
// brazier (SPELL_FOCUS, flags 0x0, faction 0 ⇒ NEUTRAL, every flag term passing) is exactly
|
||||
// the case that used to reach the predicate and come back true, wearing the Interact gear.
|
||||
for t in [
|
||||
GO_TYPE_SPELL_FOCUS,
|
||||
GO_TYPE_DUEL_ARBITER,
|
||||
GO_TYPE_FISHINGHOLE,
|
||||
GO_TYPE_AURA_GENERATOR,
|
||||
] {
|
||||
assert!(!highlightable_flags(t, 0, 0, None, false));
|
||||
assert!(!highlightable_flags(
|
||||
t,
|
||||
0,
|
||||
GO_DYNFLAG_ACTIVATE,
|
||||
Some(3),
|
||||
false
|
||||
));
|
||||
}
|
||||
// And the two slots point OPPOSITE ways for the marker set: hovered (tooltip) but never
|
||||
// interactable (no cursor, no brighten, no USE). The reported anvil, both halves at once.
|
||||
assert!(mouseover_eligible(
|
||||
GO_TYPE_SPELL_FOCUS,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
Some(3),
|
||||
false
|
||||
));
|
||||
assert!(!highlightable_flags(
|
||||
GO_TYPE_SPELL_FOCUS,
|
||||
0,
|
||||
0,
|
||||
Some(3),
|
||||
false
|
||||
));
|
||||
// A busy (IN_USE) or NO_INTERACT object is not highlightable.
|
||||
assert!(!highlightable_flags(3, 0x1, 0, None)); // CHEST, IN_USE
|
||||
assert!(!highlightable_flags(3, 0x10, 0, None)); // CHEST, NO_INTERACT
|
||||
// The quest gate: an INTERACT_COND object is highlightable only with the activate bit set — the
|
||||
// exact bug, a quest chest without the quest.
|
||||
assert!(!highlightable_flags(3, GO_FLAG_INTERACT_COND, 0, None)); // quest chest, no quest → clear
|
||||
assert!(!highlightable_flags(3, 0x1, 0, None, false)); // CHEST, IN_USE
|
||||
assert!(!highlightable_flags(3, 0x10, 0, None, false)); // CHEST, NO_INTERACT
|
||||
// The quest gate: an INTERACT_COND object is highlightable only with the activate bit set — the
|
||||
// exact bug, a quest chest without the quest.
|
||||
assert!(!highlightable_flags(
|
||||
3,
|
||||
GO_FLAG_INTERACT_COND,
|
||||
0,
|
||||
None,
|
||||
false
|
||||
)); // quest chest, no quest → clear
|
||||
assert!(highlightable_flags(
|
||||
3,
|
||||
GO_FLAG_INTERACT_COND,
|
||||
GO_DYNFLAG_ACTIVATE,
|
||||
None
|
||||
None,
|
||||
false
|
||||
)); // quest chest, quest held → usable
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bobber_is_channel_gated_and_reaches_a_hundred_yards() {
|
||||
// The FISHINGNODE highlightable override (`0x5f6710`, wow-re fishing-bobber-interaction.md):
|
||||
// only the player whose UNIT_FIELD_CHANNEL_OBJECT names exactly this GO passes — someone
|
||||
// else's bobber (or yours after the channel drops) shows nothing at all.
|
||||
assert!(highlightable_flags(GO_TYPE_FISHINGNODE, 0, 0, None, true));
|
||||
assert!(!highlightable_flags(GO_TYPE_FISHINGNODE, 0, 0, None, false));
|
||||
// The pass tail-jumps the SHARED gate, so the flags still apply on top.
|
||||
assert!(!highlightable_flags(
|
||||
GO_TYPE_FISHINGNODE,
|
||||
0x10,
|
||||
0,
|
||||
None,
|
||||
true
|
||||
));
|
||||
// The mouseover thunk (`+0xc` → `+0x14`) rides the same predicate: no tooltip either.
|
||||
assert!(!mouseover_eligible(
|
||||
GO_TYPE_FISHINGNODE,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
false
|
||||
));
|
||||
assert!(mouseover_eligible(
|
||||
GO_TYPE_FISHINGNODE,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
true
|
||||
));
|
||||
// Every other type ignores the channel input entirely.
|
||||
assert!(highlightable_flags(0, 0, 0, None, false));
|
||||
// The per-type range: the bobber's ctor constant is 100.0 yd (squared at the compare) —
|
||||
// effectively un-range-gated — while everything else stays on the interim reach.
|
||||
assert_eq!(go_interact_range_sq(GO_TYPE_FISHINGNODE), 100.0 * 100.0);
|
||||
assert_eq!(go_interact_range_sq(0), GO_INTERACT_RANGE_SQ);
|
||||
// The channel compare itself: owned iff self's channel object is exactly this guid.
|
||||
assert!(!fishing_channel_owned(None, Some(7)));
|
||||
assert!(!fishing_channel_owned(None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn melee_reach_floors_at_five() {
|
||||
// The byte-verified floor (`0x80a1e8` is a max, not a min): a typical player-vs-mob pair
|
||||
|
||||
@@ -427,7 +427,15 @@ pub(super) fn update_hovered_object(
|
||||
s.0.gameobject_faction(),
|
||||
self_store,
|
||||
);
|
||||
u32::from(crate::target::cursor_mode::go_highlightable(s, reaction))
|
||||
let channel_owned = crate::target::cursor_mode::fishing_channel_owned(
|
||||
self_store,
|
||||
guids.get(net).ok().map(|g| g.0),
|
||||
);
|
||||
u32::from(crate::target::cursor_mode::go_highlightable(
|
||||
s,
|
||||
reaction,
|
||||
channel_owned,
|
||||
))
|
||||
})
|
||||
};
|
||||
let wins =
|
||||
@@ -476,6 +484,7 @@ pub(super) fn update_hovered_object(
|
||||
store.0.gameobject_dynamic_flags(),
|
||||
generic_highlight,
|
||||
reaction,
|
||||
crate::target::cursor_mode::fishing_channel_owned(self_store, Some(guid.0)),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,14 +46,19 @@ use crate::schedule::WorldStage;
|
||||
|
||||
mod by_name;
|
||||
mod click;
|
||||
mod cursor_mode;
|
||||
// `pub(crate)` for the hover inspector's interact-gate line (`interact/inspect.rs`): the overlay
|
||||
// states the very predicate the cursor runs ([`cursor_mode::go_highlightable`]) rather than a
|
||||
// re-derivation of it, so the readout can never drift from the behaviour it is reporting on.
|
||||
pub(crate) mod cursor_mode;
|
||||
mod flash;
|
||||
mod highlight;
|
||||
mod hover;
|
||||
pub(crate) mod lock;
|
||||
mod relations;
|
||||
mod reticle;
|
||||
mod ring;
|
||||
// `pub(crate)` for the same reason as `cursor_mode`: the faction catalog is one of
|
||||
// [`cursor_mode::go_highlightable`]'s three inputs, so the inspector needs it to run the real gate.
|
||||
pub(crate) mod ring;
|
||||
mod scan;
|
||||
|
||||
pub(crate) use cursor_mode::{CursorKind, WorldCursor, GO_TYPE_GENERIC, SERVICE_RANGE_SQ};
|
||||
|
||||
@@ -44,6 +44,13 @@ pub(crate) struct UiError {
|
||||
pub key: &'static str,
|
||||
pub fill_s: Option<String>,
|
||||
pub fill_d: Option<u32>,
|
||||
/// The message record's **type arm** (wow-re `fish-msg-handlers.md`, byte-proven — the
|
||||
/// registry entry's `+4` type, last written `mov edx,1` at `0x486235`): the reference's
|
||||
/// `DisplayError` pipeline is one, and the type selects the UIErrorsFrame event — type 1
|
||||
/// fires the **yellow** `UI_INFO_MESSAGE` (0xe1), type 2 the **red** `UI_ERROR_MESSAGE`
|
||||
/// (0xe0). `true` = the yellow info arm (the fishing verdicts are the first tenants);
|
||||
/// `false` = the red error arm, the default.
|
||||
pub info: bool,
|
||||
}
|
||||
|
||||
impl UiError {
|
||||
@@ -54,6 +61,15 @@ impl UiError {
|
||||
key,
|
||||
fill_s: None,
|
||||
fill_d: None,
|
||||
info: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill-less **type-1** message — the yellow `UI_INFO_MESSAGE` arm (see [`UiError::info`]).
|
||||
pub(crate) fn info_key(key: &'static str) -> Self {
|
||||
Self {
|
||||
info: true,
|
||||
..Self::key(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,6 +319,7 @@ mod ui_error_tests {
|
||||
key,
|
||||
fill_s: s.map(String::from),
|
||||
fill_d: d,
|
||||
info: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,15 +176,26 @@ pub(super) fn feed_actions(
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
}
|
||||
|
||||
// Client-local by-key refusals (the `DisplayError` route — [`UiErrorKeys`]) ride the same
|
||||
// red line; the key IS the GlobalStrings lookup, no code table between.
|
||||
let key_texts: Vec<String> = ui_error_keys
|
||||
// Client-local by-key refusals (the `DisplayError` route — [`UiErrorKeys`]); the key IS the
|
||||
// GlobalStrings lookup, no code table between. The entry's type arm picks the line: type 1
|
||||
// (`UiError::info`) is the yellow `UI_INFO_MESSAGE`, type 2 the red `UI_ERROR_MESSAGE` —
|
||||
// the reference's one pipeline forking on the registry type (wow-re `fish-msg-handlers.md`;
|
||||
// the fishing verdicts are the yellow tenants).
|
||||
let key_texts: Vec<(String, bool)> = ui_error_keys
|
||||
.0
|
||||
.drain(..)
|
||||
.filter_map(|e| ui_error_text(&e, &|key| script.lua().globals().get::<String>(key).ok()))
|
||||
.filter_map(|e| {
|
||||
ui_error_text(&e, &|key| script.lua().globals().get::<String>(key).ok())
|
||||
.map(|t| (t, e.info))
|
||||
})
|
||||
.collect();
|
||||
for text in key_texts {
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
for (text, info) in key_texts {
|
||||
let event = if info {
|
||||
"UI_INFO_MESSAGE"
|
||||
} else {
|
||||
"UI_ERROR_MESSAGE"
|
||||
};
|
||||
script.fire_event(event, vec![ScriptValue::Str(text)]);
|
||||
}
|
||||
|
||||
// The ENGINE's own by-key refusals ride the very same line. `benilla_ui` is engine-free and
|
||||
|
||||
@@ -186,15 +186,19 @@ fn watch_skill_ups(
|
||||
}
|
||||
}
|
||||
|
||||
/// The skills-pane feed (decision 0437 phase 4): every known `PLAYER_SKILL_INFO` line resolved to
|
||||
/// a flat [`SkillEntry`] — name off `SkillLine.dbc`, group off the line's `categoryId` ×
|
||||
/// `SkillLineCategory.dbc` (name + displayOrder) — pushed via `set_skills` on change; the ENGINE
|
||||
/// groups/sorts/folds (the trainer-tree pattern). Hidden faithfully: the `Not Displayed` category
|
||||
/// (12) and any line without a `SkillLine.dbc` row. Group order and the within-group name sort
|
||||
/// are still an INTERIM — but not the 0437 §5 dispatch's: none of its six TUs covered the
|
||||
/// Skills-pane's own grouping (that dispatch resolved as decision 0446, adjudicating the
|
||||
/// crafting-book windows only). This law is unpinned, awaiting its own dispatch, named as a
|
||||
/// follow-up in decision 0530.
|
||||
/// The skills-pane feed (decision 0437 phase 4, corrected by 1091): every `PLAYER_SKILL_INFO` line
|
||||
/// the real client would LIST, resolved to a flat [`SkillEntry`] — name off `SkillLine.dbc`, group
|
||||
/// off the line's `categoryId` × `SkillLineCategory.dbc` (name + displayOrder) — pushed via
|
||||
/// `set_skills` on change; the ENGINE groups/sorts/folds.
|
||||
///
|
||||
/// The inclusion predicate is the client's own list build (`0x4d2cb0`, wow-re
|
||||
/// `system/tradeskill/scratch/skillframe-display-list.md`), transcribed in its order: a line needs
|
||||
/// a `SkillLine.dbc` row, an admitting `SkillRaceClassInfo` row and a real `SkillLineCategory` row;
|
||||
/// `flags & 0x2` drops it outright; and a line held at **rank 0** appears only if its flags admit
|
||||
/// it at this player's level. Note what is NOT a filter: the `Not Displayed` category (12). We used
|
||||
/// to hide on it, which happened to hide `GENERIC (DND)` for the right-looking reason — the client
|
||||
/// drops that line by its `0x2` bit like `Dual Wield` and the racials, and lists a category-12 line
|
||||
/// without `0x2` under its own header (decision 1091).
|
||||
fn feed_skills(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
@@ -207,45 +211,22 @@ fn feed_skills(
|
||||
let (Ok(store), Some(skill_lines)) = (self_store.single(), skill_lines.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
// The unlearn-button predicate needs the player's race/class (SkillRaceClassInfo row-match —
|
||||
// the spellbook's own General-collapse inputs, `ui_spellbook::build_book`).
|
||||
// The display predicate reads the player's own race/class (the SkillRaceClassInfo row-match —
|
||||
// the spellbook's own General-collapse inputs, `ui_spellbook::build_book`) and level (the
|
||||
// untrained gate).
|
||||
let (race, class) = (
|
||||
store.0.unit_race().unwrap_or(0),
|
||||
store.0.unit_class().unwrap_or(0),
|
||||
);
|
||||
let level = store.0.unit_level().unwrap_or(0);
|
||||
let mut entries = Vec::new();
|
||||
for i in 0..PLAYER_SKILL_SLOTS {
|
||||
let Some(slot) = store.0.player_skill(i) else {
|
||||
continue;
|
||||
};
|
||||
if slot.skill_id == 0 {
|
||||
continue;
|
||||
if let Some(e) = skills_row(&slot, &skill_lines.catalog, race, class, level) {
|
||||
entries.push(e);
|
||||
}
|
||||
let Some(line) = skill_lines.catalog.line(u32::from(slot.skill_id)) else {
|
||||
continue; // no SkillLine row — nothing to name it by, the client shows nothing
|
||||
};
|
||||
if line.category_id == benilla_formats::SKILL_CATEGORY_NOT_DISPLAYED {
|
||||
continue;
|
||||
}
|
||||
let (category_name, category_order) = skill_lines
|
||||
.catalog
|
||||
.category(line.category_id)
|
||||
.map(|(n, o)| (n.to_string(), o))
|
||||
.unwrap_or_else(|| ("Other".to_string(), u32::MAX));
|
||||
entries.push(SkillEntry {
|
||||
skill_id: u32::from(slot.skill_id),
|
||||
name: line.name.clone(),
|
||||
value: u32::from(slot.value),
|
||||
max: u32::from(slot.max),
|
||||
modifier: i32::from(slot.temp_bonus) + i32::from(slot.perm_bonus),
|
||||
category_id: line.category_id,
|
||||
category_name,
|
||||
category_order,
|
||||
description: line.description.clone(),
|
||||
abandonable: skill_lines
|
||||
.catalog
|
||||
.abandonable(u32::from(slot.skill_id), race, class),
|
||||
});
|
||||
}
|
||||
let fresh = SkillsState { entries };
|
||||
if last.as_ref() == Some(&fresh) {
|
||||
@@ -255,6 +236,55 @@ fn feed_skills(
|
||||
*last = Some(fresh);
|
||||
}
|
||||
|
||||
/// One `PLAYER_SKILL_INFO` slot as the Skills tab's row — or `None` when the real client's list
|
||||
/// build would not list it at all ([`feed_skills`]'s doc for the citations; the order of the tests
|
||||
/// is the client's own, `0x4d2cb0`). Pure, so the whole display predicate is testable against real
|
||||
/// DBC data and a real character's skill block (`ui_script::skills_frame_tests`).
|
||||
pub(crate) fn skills_row(
|
||||
slot: &benilla_protocol::messages::PlayerSkillSlot,
|
||||
catalog: &benilla_formats::SkillLineCatalog,
|
||||
race: u8,
|
||||
class: u8,
|
||||
level: u32,
|
||||
) -> Option<SkillEntry> {
|
||||
if slot.skill_id == 0 {
|
||||
return None;
|
||||
}
|
||||
let id = u32::from(slot.skill_id);
|
||||
// No SkillLine row — nothing to name it by, and the client lists nothing.
|
||||
let line = catalog.line(id)?;
|
||||
// No row admitting this race/class → the client drops the line (its `!srci → continue`).
|
||||
let rc = catalog.race_class(id, race, class)?;
|
||||
// A category that must really exist (the client's own null-row test) — and note the category
|
||||
// ITSELF is never a filter: `Not Displayed` (12) is a header like any other.
|
||||
let (category_name, category_order) = catalog.category(line.category_id)?;
|
||||
// The hide bit: `Dual Wield`, the racials, the mount lines, `GENERIC (DND)`.
|
||||
if rc.hidden() {
|
||||
return None;
|
||||
}
|
||||
// An untrained line shows only where the flags admit it at this level.
|
||||
if slot.value == 0 && !rc.displays_untrained(level) {
|
||||
return None;
|
||||
}
|
||||
Some(SkillEntry {
|
||||
skill_id: id,
|
||||
name: line.name.clone(),
|
||||
value: u32::from(slot.value),
|
||||
max: u32::from(slot.max),
|
||||
temp_bonus: i32::from(slot.temp_bonus),
|
||||
perm_bonus: i32::from(slot.perm_bonus),
|
||||
min_level: rc.min_level,
|
||||
cost_index: rc.cost_index,
|
||||
category_id: line.category_id,
|
||||
category_name: category_name.to_string(),
|
||||
category_order,
|
||||
description: line.description.clone(),
|
||||
// The client ANDs the DBC's unlearnable bit with a nonzero skill STEP (`0x4d3953`).
|
||||
abandonable: slot.step != 0 && rc.unlearnable(),
|
||||
mono: rc.mono(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The abandon drain (the skills pane's unlearn button → the `UNLEARN_SKILL` popup's accept →
|
||||
/// the engine's `AbandonSkill` queue): each queued skill line id becomes one
|
||||
/// `CMSG_UNLEARN_SKILL`. Nothing else happens client-side — the server's `SetSkill(id, 0, 0)`
|
||||
|
||||
@@ -149,6 +149,11 @@ pub(crate) struct LootState {
|
||||
/// never at open — an empty-at-open window stays up (the reference `LootFrame_OnShow` even has a
|
||||
/// dedicated `LOOTWINDOWOPENEMPTY` sound for it). Drained by [`drain_loot`].
|
||||
auto_release: bool,
|
||||
/// Whether the open loot came from fishing (the wire `loot_type == 3` — vmangos folds
|
||||
/// `FISHINGHOLE`/`FISHING_FAIL` into `LOOT_FISHING` before sending). Carried into the Lua
|
||||
/// snapshot as `IsFishingLoot()`, which `LootFrame_OnShow` keys the "FISHING REEL IN" sound
|
||||
/// and the FishingLoot portrait overlay on (decision 1086).
|
||||
fishing: bool,
|
||||
}
|
||||
|
||||
/// A resolved loot-row pick: the coin pile, or an item at a concrete **wire** loot slot (carrying
|
||||
@@ -162,11 +167,12 @@ impl LootState {
|
||||
/// Open (or replace) the window with a fresh loot response (`SMSG_LOOT_RESPONSE`). Quest items
|
||||
/// ride the same `items` list (the wire appends them with `slot = items.len()+i`), so they need no
|
||||
/// special handling here.
|
||||
pub(crate) fn open(&mut self, source: u64, gold: u32, items: Vec<LootItem>) {
|
||||
pub(crate) fn open(&mut self, source: u64, loot_type: u8, gold: u32, items: Vec<LootItem>) {
|
||||
self.source = Some(source);
|
||||
self.gold = gold;
|
||||
self.items = items;
|
||||
self.auto_release = false; // empty-at-open stays open — only a removal auto-closes
|
||||
self.fishing = loot_type == benilla_protocol::messages::loot_type::FISHING;
|
||||
}
|
||||
|
||||
/// A row was taken by anyone (`SMSG_LOOT_REMOVED`, keyed by the **wire** slot): drop it. The
|
||||
@@ -223,6 +229,7 @@ impl LootState {
|
||||
self.gold = 0;
|
||||
self.items.clear();
|
||||
self.auto_release = false;
|
||||
self.fishing = false;
|
||||
}
|
||||
|
||||
/// Disconnect: drop the open window **and** any pending receive lines (mirrors the merchant/gossip
|
||||
@@ -458,7 +465,10 @@ fn snapshot(
|
||||
for it in &loot.items {
|
||||
rows.push(resolve_item(it, items, icons, commands));
|
||||
}
|
||||
Some(LootSnapshot { rows })
|
||||
Some(LootSnapshot {
|
||||
rows,
|
||||
fishing: loot.fishing,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compose one `CHAT_MSG_LOOT` receive line — the whole of `CGGameUI::OnItemPush`'s self branch,
|
||||
@@ -712,6 +722,7 @@ fn drain_loot(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::loot_type;
|
||||
|
||||
fn item(slot: u8, entry: u32, count: u32) -> LootItem {
|
||||
LootItem {
|
||||
@@ -752,7 +763,12 @@ mod tests {
|
||||
let mut loot = LootState::default();
|
||||
assert!(loot.source.is_none());
|
||||
// gold + two items at wire slots 0 and 1.
|
||||
loot.open(0x42, 1234, vec![item(0, 117, 1), item(1, 2589, 5)]);
|
||||
loot.open(
|
||||
0x42,
|
||||
loot_type::CORPSE,
|
||||
1234,
|
||||
vec![item(0, 117, 1), item(1, 2589, 5)],
|
||||
);
|
||||
assert!(loot.source.is_some());
|
||||
// Row 1 is the coin pile; rows 2/3 are the items (wire slots 0/1). The item pick carries the
|
||||
// row's display id (here `1000 + entry`) so the pickup sound resolves without a second lookup.
|
||||
@@ -778,7 +794,12 @@ mod tests {
|
||||
#[test]
|
||||
fn action_maps_items_directly_when_no_coin() {
|
||||
let mut loot = LootState::default();
|
||||
loot.open(0x42, 0, vec![item(3, 117, 1), item(7, 2589, 5)]);
|
||||
loot.open(
|
||||
0x42,
|
||||
loot_type::CORPSE,
|
||||
0,
|
||||
vec![item(3, 117, 1), item(7, 2589, 5)],
|
||||
);
|
||||
// No coin → row 1 is the first item (wire slot 3), row 2 the second (wire slot 7).
|
||||
assert!(matches!(
|
||||
loot.action_at(1),
|
||||
@@ -796,6 +817,7 @@ mod tests {
|
||||
let mut loot = LootState::default();
|
||||
loot.open(
|
||||
0x42,
|
||||
loot_type::CORPSE,
|
||||
0,
|
||||
vec![item(0, 117, 1), item(1, 2589, 5), item(2, 4306, 2)],
|
||||
);
|
||||
@@ -815,7 +837,7 @@ mod tests {
|
||||
#[test]
|
||||
fn clear_money_drops_the_coin_row() {
|
||||
let mut loot = LootState::default();
|
||||
loot.open(0x42, 500, vec![item(0, 117, 1)]);
|
||||
loot.open(0x42, loot_type::CORPSE, 500, vec![item(0, 117, 1)]);
|
||||
assert!(loot.has_coin());
|
||||
assert!(matches!(loot.action_at(1), Some(LootAction::Money)));
|
||||
loot.clear_money();
|
||||
@@ -831,7 +853,12 @@ mod tests {
|
||||
fn auto_release_arms_only_on_the_transition_to_empty() {
|
||||
let mut loot = LootState::default();
|
||||
// Coin + two items: taking rows one by one arms the auto-close only at the last removal.
|
||||
loot.open(0x42, 500, vec![item(0, 117, 1), item(1, 2589, 5)]);
|
||||
loot.open(
|
||||
0x42,
|
||||
loot_type::CORPSE,
|
||||
500,
|
||||
vec![item(0, 117, 1), item(1, 2589, 5)],
|
||||
);
|
||||
loot.remove_slot(0);
|
||||
assert!(!loot.take_auto_release(), "items + coin remain");
|
||||
loot.clear_money();
|
||||
@@ -844,7 +871,7 @@ mod tests {
|
||||
#[test]
|
||||
fn auto_release_arms_when_the_coin_line_is_the_last_row() {
|
||||
let mut loot = LootState::default();
|
||||
loot.open(0x42, 500, vec![]);
|
||||
loot.open(0x42, loot_type::CORPSE, 500, vec![]);
|
||||
loot.clear_money();
|
||||
assert!(
|
||||
loot.take_auto_release(),
|
||||
@@ -856,12 +883,12 @@ mod tests {
|
||||
fn empty_at_open_does_not_auto_release() {
|
||||
let mut loot = LootState::default();
|
||||
// The reference keeps an empty-at-open window up (LOOTWINDOWOPENEMPTY); only a removal closes.
|
||||
loot.open(0x42, 0, vec![]);
|
||||
loot.open(0x42, loot_type::CORPSE, 0, vec![]);
|
||||
assert!(!loot.take_auto_release());
|
||||
// …and a fresh open clears a stale armed edge.
|
||||
loot.open(0x43, 500, vec![]);
|
||||
loot.open(0x43, loot_type::CORPSE, 500, vec![]);
|
||||
loot.clear_money();
|
||||
loot.open(0x44, 0, vec![item(0, 117, 1)]);
|
||||
loot.open(0x44, loot_type::CORPSE, 0, vec![item(0, 117, 1)]);
|
||||
assert!(
|
||||
!loot.take_auto_release(),
|
||||
"open() disarms the previous window's edge"
|
||||
@@ -879,10 +906,28 @@ mod tests {
|
||||
assert_eq!(latch.0, None, "the matching release drops it");
|
||||
}
|
||||
|
||||
/// The `IsFishingLoot()` source (decision 1086): wire `loot_type` 3 flags the open, any other
|
||||
/// type doesn't, and every close path drops the flag (a stale `true` would reel-in-sound the
|
||||
/// next corpse loot).
|
||||
#[test]
|
||||
fn fishing_loot_type_sets_and_clears_the_flag() {
|
||||
let mut loot = LootState::default();
|
||||
loot.open(0x42, loot_type::FISHING, 0, vec![item(0, 117, 1)]);
|
||||
assert!(loot.fishing);
|
||||
loot.clear();
|
||||
assert!(!loot.fishing);
|
||||
loot.open(0x42, loot_type::CORPSE, 0, vec![item(0, 117, 1)]);
|
||||
assert!(!loot.fishing);
|
||||
// A fishing open REPLACED by an ordinary one drops the flag too (no clear in between).
|
||||
loot.open(0x42, loot_type::FISHING, 0, vec![]);
|
||||
loot.open(0x43, loot_type::CORPSE, 0, vec![]);
|
||||
assert!(!loot.fishing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_closes_but_keeps_receives() {
|
||||
let mut loot = LootState::default();
|
||||
loot.open(0x42, 0, vec![item(0, 117, 1)]);
|
||||
loot.open(0x42, loot_type::CORPSE, 0, vec![item(0, 117, 1)]);
|
||||
loot.push_receive(&push(117, 1, false, false));
|
||||
loot.clear();
|
||||
assert!(loot.source.is_none());
|
||||
@@ -996,7 +1041,7 @@ mod tests {
|
||||
let commands = NetCommands(tx);
|
||||
let mut loot = LootState::default();
|
||||
// A pure-copper drop: name reads "4 Copper", icon is the copper coin pile (_05).
|
||||
loot.open(0x42, 4, vec![]);
|
||||
loot.open(0x42, loot_type::CORPSE, 4, vec![]);
|
||||
let snap = snapshot(&loot, &mut items, None, &commands).expect("open");
|
||||
assert_eq!(snap.rows.len(), 1, "coin row only");
|
||||
assert!(snap.rows[0].is_coin);
|
||||
@@ -1012,7 +1057,7 @@ mod tests {
|
||||
let mut loot = LootState::default();
|
||||
// Closed → no snapshot.
|
||||
assert!(snapshot(&loot, &mut items, None, &commands).is_none());
|
||||
loot.open(0x42, 12_345, vec![item(0, 117, 3)]);
|
||||
loot.open(0x42, loot_type::CORPSE, 12_345, vec![item(0, 117, 3)]);
|
||||
let snap = snapshot(&loot, &mut items, None, &commands).expect("open");
|
||||
assert_eq!(snap.rows.len(), 2, "coin + one item");
|
||||
assert!(snap.rows[0].is_coin);
|
||||
|
||||
@@ -921,6 +921,7 @@ mod tests {
|
||||
key: questgiver_failed_key(4),
|
||||
fill_s: Some("A Threat Within".into()),
|
||||
fill_d: None,
|
||||
info: false,
|
||||
},
|
||||
&g
|
||||
)
|
||||
|
||||
@@ -24,8 +24,8 @@ fn shipped_action_bar_drives_end_to_end() {
|
||||
);
|
||||
if file == "ActionBar.xml" {
|
||||
assert_eq!(
|
||||
report.frames, 33,
|
||||
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button"
|
||||
report.frames, 34,
|
||||
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,25 +689,33 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
|
||||
name: "Defense".into(),
|
||||
value: 12,
|
||||
max: 60,
|
||||
modifier: 0,
|
||||
temp_bonus: 0,
|
||||
perm_bonus: 0,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id: 6,
|
||||
category_name: "Weapon Skills".into(),
|
||||
category_order: 1,
|
||||
description: "Defensive expertise.".into(),
|
||||
abandonable: false,
|
||||
mono: false,
|
||||
},
|
||||
SkillEntry {
|
||||
skill_id: 164,
|
||||
name: "Blacksmithing".into(),
|
||||
value: 62,
|
||||
max: 75,
|
||||
modifier: 0,
|
||||
temp_bonus: 0,
|
||||
perm_bonus: 0,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id: 11,
|
||||
category_name: "Professions".into(),
|
||||
category_order: 2,
|
||||
description: "Working with metals.".into(),
|
||||
// The real 5875 split: primary professions carry SkillRaceClassInfo 0x20.
|
||||
abandonable: true,
|
||||
mono: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
|
||||
// Open the bag and the loot window; drain the open kits (not under test here).
|
||||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: vec![LootRow {
|
||||
item_id: 0,
|
||||
name: Some("Wool Cloth".into()),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! action-button machinery. Same file, two concerns: the strip along the bar's top is the
|
||||
//! player-progress readout; the buttons are the input surface.
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
|
||||
|
||||
/// The XP bar's load set: the manifest prefix it actually needs — fonts, UIParent, the
|
||||
/// tooltip, `TextStatusBar.xml` (the numerals machinery `BenillaExpBar_OnLoad` wires in),
|
||||
@@ -101,7 +101,8 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
s.resolve();
|
||||
|
||||
// The binding trio underneath (the app-feed shape the XML consumes).
|
||||
// The binding trio underneath (the app-feed shape the XML consumes) — the byte-verified
|
||||
// contract (wow-re rested-xp-bindings.md, decision 1087).
|
||||
let (id, name, mult) = s
|
||||
.eval::<(i64, String, f64)>("return GetRestState()")
|
||||
.unwrap();
|
||||
@@ -109,7 +110,7 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
|
||||
Some(1400),
|
||||
"the pool is served ×2 — bar-XP, not wire units"
|
||||
"the pool × Exhaustion.dbc row 1's factor (2.0) — bar-XP, not wire units"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return IsResting()").unwrap(),
|
||||
@@ -151,6 +152,33 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
|
||||
.unwrap();
|
||||
assert!(ok, "a dry pool hides the tick and returns the purple bar");
|
||||
|
||||
// The nil law is the BYTE's, not the pool's (0x48d3f0's `dec/jne`, decision 1087): a normal-
|
||||
// state player with a nonzero pool — vmangos's 0 < bonus ≤ 10 hysteresis window — still reads
|
||||
// nil, and a rested player with a drained pool reads the NUMBER 0 (the tick then parks at the
|
||||
// bar's current fill). An unmapped byte (0 here) is the binary's nil-triple fail path.
|
||||
s.set_rest_state(2, 5, false);
|
||||
s.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
|
||||
None,
|
||||
"normal state hides the pool even while it holds a remnant"
|
||||
);
|
||||
s.set_rest_state(1, 0, true);
|
||||
s.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
|
||||
Some(0),
|
||||
"rested with a drained pool is the number 0, not nil"
|
||||
);
|
||||
s.set_rest_state(0, 0, false);
|
||||
assert!(
|
||||
s.eval::<bool>("local a, b, c = GetRestState() return a == nil and b == nil and c == nil")
|
||||
.unwrap(),
|
||||
"an unmapped byte is the binary's (nil, nil, nil) fail path"
|
||||
);
|
||||
s.set_rest_state(2, 0, false);
|
||||
s.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||||
|
||||
// A pool past the level's end (6000×2 span from 1000/10000): off the strip → hidden tick,
|
||||
// still rested blue (the ref's exhaustionTickSet > width branch).
|
||||
s.set_rest_state(1, 6000, false);
|
||||
@@ -172,6 +200,57 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The max-level rail (decision 1094, ref `ReputationWatchBar_Update`'s no-watched-faction arm):
|
||||
/// at MAX_PLAYER_LEVEL the XP strip and its tick give way to the flat brass rail; below it the
|
||||
/// strip is back. Walked over a live ding — 59 → 60 via `PLAYER_LEVEL_UP`'s arg1, the value the
|
||||
/// ref reads (not the level field, which may not have landed yet). The tick's own handler runs
|
||||
/// first (it loaded first) and re-shows the tick off the rested pool; the rail's handler then
|
||||
/// hides it — the ref's own load order, ending hidden.
|
||||
#[test]
|
||||
fn the_max_level_rail_replaces_the_xp_bar_at_60() {
|
||||
let mut s = exp_bar_harness();
|
||||
s.set_player_xp(1000, 10000);
|
||||
s.set_rest_state(1, 700, true);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 59,
|
||||
..UnitState::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
s.resolve();
|
||||
|
||||
let ok: bool = s
|
||||
.eval(
|
||||
r#"
|
||||
return BenillaExpBar:IsShown() and not BenillaMaxLevelBar:IsShown()
|
||||
and BenillaExhaustionTick:IsShown()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ok,
|
||||
"below 60 the strip and tick show, the rail stays hidden"
|
||||
);
|
||||
|
||||
s.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(60)]);
|
||||
let ok: bool = s
|
||||
.eval(
|
||||
r#"
|
||||
return not BenillaExpBar:IsShown() and BenillaMaxLevelBar:IsShown()
|
||||
and not BenillaExhaustionTick:IsShown()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ok,
|
||||
"at 60 the rail replaces the strip and the tick goes with it"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The hover numerals (decision 1082, ref TextStatusBar.lua over `MainMenuBarExpText`): entering
|
||||
/// the strip shows "curr / max" centered on the bar, leaving hides it (the lockShow refcount's
|
||||
/// 0↔1 edge — the `statusBarText` cvar that pins it on permanently reads OFF here).
|
||||
|
||||
@@ -243,6 +243,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
|
||||
s.set_container(0, Some(backpack()));
|
||||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: vec![LootRow {
|
||||
item_id: 0,
|
||||
name: Some("Wool Cloth".into()),
|
||||
|
||||
@@ -63,6 +63,7 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
|
||||
|
||||
fn coin_and_two_items() -> LootState {
|
||||
LootState {
|
||||
fishing: false,
|
||||
rows: vec![
|
||||
LootRow {
|
||||
item_id: 0,
|
||||
@@ -219,6 +220,7 @@ fn shipped_loot_frame_drives_end_to_end() {
|
||||
|
||||
// LOOT_UPDATE with the coin row gone (looted) repaints to two rows, keeping the window open.
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: coin_and_two_items().rows[1..].to_vec(),
|
||||
}));
|
||||
s.fire_event("LOOT_UPDATE", vec![]);
|
||||
@@ -272,7 +274,10 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
|
||||
assert!(s.take_sounds().is_empty(), "loot close is silent (C-side)");
|
||||
|
||||
// Re-open with an EMPTY roll: OnShow's numItems==0 fork queues exactly LOOTWINDOWOPENEMPTY.
|
||||
s.set_loot(Some(LootState { rows: vec![] }));
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: vec![],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
@@ -282,6 +287,58 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The fishing fork (LootFrame.lua LootFrame_OnShow l.137-140; decision 1086): a fishing loot open
|
||||
/// plays "FISHING REEL IN" (SoundEntries "Fishing Reel in", kit 3407 — the name lookup is
|
||||
/// case-insensitive on both sides) and swaps the portrait ring's skull for the FishingLoot-Icon;
|
||||
/// the next ordinary open resets the skull (the ref re-stamps TargetDead at every show, l.133).
|
||||
#[test]
|
||||
fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "LootFrame.xml");
|
||||
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
|
||||
quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
|
||||
})
|
||||
};
|
||||
|
||||
let mut fished = coin_and_two_items();
|
||||
fished.fishing = true;
|
||||
s.set_loot(Some(fished));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![SoundRequest::KitName("FISHING REEL IN".into())],
|
||||
"a fishing loot open plays the reel-in kit"
|
||||
);
|
||||
let quads = s.extract();
|
||||
assert!(
|
||||
has_icon(&quads, "FishingLoot-Icon"),
|
||||
"the portrait ring shows the fishing icon"
|
||||
);
|
||||
assert!(
|
||||
!has_icon(&quads, "TargetDead"),
|
||||
"…instead of the dead-target skull"
|
||||
);
|
||||
|
||||
// Close, then an ordinary corpse loot: silent again, and the skull is back.
|
||||
s.fire_event("LOOT_CLOSED", vec![]);
|
||||
let _ = s.take_sounds();
|
||||
s.set_loot(Some(coin_and_two_items()));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(
|
||||
s.take_sounds().is_empty(),
|
||||
"a non-fishing loot open is silent (its open kit is C-side)"
|
||||
);
|
||||
let quads = s.extract();
|
||||
assert!(has_icon(&quads, "TargetDead"), "the skull is restored");
|
||||
assert!(!has_icon(&quads, "FishingLoot-Icon"));
|
||||
}
|
||||
|
||||
/// Paging (LootFrame.lua l.70-73,105-118): 5 items ⇒ 3 rows + a Down pager on page 1, 2 rows + an Up
|
||||
/// pager on page 2 — the real shipped XML driving the render, not just the arithmetic.
|
||||
#[test]
|
||||
@@ -303,7 +360,10 @@ fn shipped_loot_frame_pages_five_items() {
|
||||
link: None,
|
||||
})
|
||||
.collect();
|
||||
s.set_loot(Some(LootState { rows }));
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows,
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
@@ -488,6 +548,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
|
||||
|
||||
// A coin row + one resolved item: the item's link is fed exactly as `ui_loot.rs` builds it.
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
rows: vec![
|
||||
LootRow {
|
||||
item_id: 0,
|
||||
|
||||
@@ -721,6 +721,9 @@ mod questlog_tests;
|
||||
#[cfg(test)]
|
||||
mod character_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod skills_frame_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pet_paperdoll_tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! The shipped **Skills tab** (`assets/ui/SkillFrame.xml`) driven end-to-end, engine-only (no
|
||||
//! Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
|
||||
//! out of `character_tests.rs` (which owns the paperdoll + the tab round-trip) so the skills-pane
|
||||
//! paint law has a home of its own.
|
||||
//!
|
||||
//! What it pins is the **proficiency row**: `SkillFrame.lua`'s own `skillMaxRank == 1` gate draws
|
||||
//! a full GRAY bar with NO rank text, and two different kinds of line land in it — the armor
|
||||
//! proficiencies, which the server really does report as `1/1`, and the **single-rank** lines
|
||||
//! (class skills, Dual Wield, racials, the per-mount riding lines), whose `skillMaxRank` the
|
||||
//! engine overrides to `1` off `SkillRaceClassInfo.flags & 0x400` however high the server's own
|
||||
//! descriptor is (`benilla-ui`'s `SkillEntry::mono`; wow-re `0x4d3610`'s `4d38b1` branch). A
|
||||
//! hunter's `Beast Mastery` on vmangos arrives as `300/300` and must still read gray and
|
||||
//! numberless, exactly as it does in the real client.
|
||||
|
||||
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript};
|
||||
|
||||
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the
|
||||
/// character/spellbook tests' loader, duplicated so this file is self-contained).
|
||||
fn load_xml(s: &UiScript, file: &str) {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"{file}: loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
fn skill(
|
||||
skill_id: u32,
|
||||
name: &str,
|
||||
value: u32,
|
||||
max: u32,
|
||||
mono: bool,
|
||||
(category_id, category_name, category_order): (u32, &str, u32),
|
||||
) -> SkillEntry {
|
||||
SkillEntry {
|
||||
skill_id,
|
||||
name: name.into(),
|
||||
value,
|
||||
max,
|
||||
temp_bonus: 0,
|
||||
perm_bonus: 0,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id,
|
||||
category_name: category_name.into(),
|
||||
category_order,
|
||||
description: String::new(),
|
||||
abandonable: false,
|
||||
mono,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Skills page, shown, with one line of each shape — a hunter's real vmangos numbers:
|
||||
/// `Beast Mastery 300/300` (single-rank, `SkillRaceClassInfo` 0x410), `Defense 12/60` (a normal
|
||||
/// weapon line), `Cloth 1/1` (an armor proficiency the server itself caps).
|
||||
fn shown_skills_page() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in [
|
||||
"Fonts.xml",
|
||||
"UiPanels.xml",
|
||||
"GameTooltip.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"CharacterFrame.xml",
|
||||
"SkillFrame.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
s.set_skills(SkillsState {
|
||||
entries: vec![
|
||||
skill(50, "Beast Mastery", 300, 300, true, (7, "Class Skills", 2)),
|
||||
skill(95, "Defense", 12, 60, false, (6, "Weapon Skills", 5)),
|
||||
skill(415, "Cloth", 1, 1, false, (8, "Armor Proficiencies", 6)),
|
||||
],
|
||||
});
|
||||
s.run(r#"ToggleCharacter("BenillaSkillFrame")"#).unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
/// Row slot `i`'s rank text and bar color — the two things the proficiency gate decides.
|
||||
fn row(s: &mut UiScript, i: u32) -> (String, [f32; 4]) {
|
||||
let text = s
|
||||
.eval::<String>(&format!(
|
||||
"return BenillaSkillRankFrame{i}SkillRank:GetText() or \"\""
|
||||
))
|
||||
.unwrap();
|
||||
let c = s
|
||||
.eval::<(f32, f32, f32, f32)>(&format!(
|
||||
"return BenillaSkillRankFrame{i}:GetStatusBarColor()"
|
||||
))
|
||||
.unwrap();
|
||||
(text, [c.0, c.1, c.2, c.3])
|
||||
}
|
||||
|
||||
/// Row slot `i`'s **trough** colour — the `$parentBackground` texture behind the fill, which the
|
||||
/// ref recolours per branch alongside the fill (`SkillFrame.lua:158` normal / `:167` proficiency).
|
||||
fn row_bg(s: &mut UiScript, i: u32) -> [f32; 4] {
|
||||
let c = s
|
||||
.eval::<(f32, f32, f32, f32)>(&format!(
|
||||
"return BenillaSkillRankFrame{i}Background:GetVertexColor()"
|
||||
))
|
||||
.unwrap();
|
||||
[c.0, c.1, c.2, c.3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_rank_line_paints_gray_with_no_rank_text() {
|
||||
let mut s = shown_skills_page();
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaSkillFrame:IsVisible()")
|
||||
.unwrap(),
|
||||
"the Skills page is up"
|
||||
);
|
||||
|
||||
// Visible rows: 1 header "Class Skills", 2 Beast Mastery, 3 header "Weapon Skills",
|
||||
// 4 Defense, 5 header "Armor Proficiencies", 6 Cloth — one list slot each.
|
||||
let (text, color) = row(&mut s, 2);
|
||||
assert_eq!(
|
||||
text, "",
|
||||
"Beast Mastery is single-rank: no rank text, though the server said 300/300"
|
||||
);
|
||||
assert_eq!(color, [0.5, 0.5, 0.5, 1.0], "and a gray bar");
|
||||
assert_eq!(
|
||||
row_bg(&mut s, 2),
|
||||
[1.0, 1.0, 1.0, 0.5],
|
||||
"over the proficiency branch's WHITE trough (ref SkillFrame.lua:167)"
|
||||
);
|
||||
|
||||
// The armor proficiency reaches the same branch by the server's own 1/1.
|
||||
let (text, color) = row(&mut s, 6);
|
||||
assert_eq!(text, "", "Cloth is 1/1: no rank text");
|
||||
assert_eq!(color, [0.5, 0.5, 0.5, 1.0], "gray too");
|
||||
assert_eq!(row_bg(&mut s, 6), [1.0, 1.0, 1.0, 0.5], "white trough too");
|
||||
|
||||
// The control: a normal weapon line still reads its numbers, in blue.
|
||||
let (text, color) = row(&mut s, 4);
|
||||
assert_eq!(text, "12/60", "Defense keeps its rank text");
|
||||
assert_eq!(color, [0.0, 0.0, 1.0, 0.5], "and the blue fill");
|
||||
assert_eq!(
|
||||
row_bg(&mut s, 4),
|
||||
[0.0, 0.0, 0.75, 0.5],
|
||||
"over the normal branch's DARK BLUE trough (ref SkillFrame.lua:158)"
|
||||
);
|
||||
|
||||
// And what those troughs actually DRAW at. The template declares the texture `<Color 1,1,1,0.2>`
|
||||
// — a real texel — and the vertex colour MULTIPLIES it, alpha included (the composition law,
|
||||
// `benilla-ui` `script::tests::regions`): the proficiency's white reaches the screen at
|
||||
// `0.2 x 0.5 = 0.1`, not 0.5. Every solid-colour quad this page emits is one of these troughs.
|
||||
let solids: Vec<[f32; 4]> = s
|
||||
.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: None,
|
||||
color: Some(c),
|
||||
..
|
||||
} => Some(*c),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
solids.contains(&[1.0, 1.0, 1.0, 0.1]),
|
||||
"a proficiency trough draws white at 0.1; got {solids:?}"
|
||||
);
|
||||
assert!(
|
||||
solids.contains(&[0.0, 0.0, 0.75, 0.1]),
|
||||
"a normal row's trough draws dark blue at 0.1; got {solids:?}"
|
||||
);
|
||||
assert!(
|
||||
!solids.iter().any(|c| c[3] == 0.5),
|
||||
"nothing draws at the raw vertex alpha — that was the replace bug; got {solids:?}"
|
||||
);
|
||||
|
||||
// Selecting the single-rank row paints the detail pane the same way (the shared PaintBar).
|
||||
s.run("SetSelectedSkill(2) BenillaSkillFrame_Update()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaSkillDetailBarSkillRank:GetText() or \"\"")
|
||||
.unwrap(),
|
||||
"",
|
||||
"the detail pane's own bar is a proficiency too"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return BenillaSkillDetailBarBackground:GetVertexColor()")
|
||||
.unwrap(),
|
||||
(1.0, 1.0, 1.0, 0.5),
|
||||
"including its trough (the shared PaintBar; ref SkillFrame.lua:376)"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The director's reference screenshot, reproduced row for row** (decision 1091). A real
|
||||
/// level-60 tauren hunter's `PLAYER_SKILL_INFO` block — read straight out of the live vmangos
|
||||
/// `character_skills` rows the A/B was taken on — fed through the app's own display predicate
|
||||
/// ([`crate::ui_char::skills_row`]) and the engine's grouping, against the REAL shipped DBCs.
|
||||
///
|
||||
/// What the reference client shows for exactly this block is the expectation below: four headers,
|
||||
/// fourteen rows — and **no** `Dual Wield`, `Tauren Racial` or `GENERIC (DND)`, and no `Secondary
|
||||
/// Skills` header, though the server sends all three lines at 300/300 like any other. Skips
|
||||
/// without client data.
|
||||
#[test]
|
||||
fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
|
||||
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let catalog = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines");
|
||||
|
||||
// (skill line, value, max) — Twohunter's rows, verbatim. Tauren (race 6) hunter (class 3), 60.
|
||||
const BLOCK: &[(u16, u16, u16)] = &[
|
||||
(44, 300, 300), // Axes
|
||||
(46, 300, 300), // Guns
|
||||
(50, 300, 300), // Beast Mastery — single-rank
|
||||
(51, 300, 300), // Survival — single-rank
|
||||
(95, 300, 300), // Defense
|
||||
(109, 300, 300), // Language: Orcish
|
||||
(115, 300, 300), // Language: Taurahe
|
||||
(118, 300, 300), // Dual Wield — HIDDEN
|
||||
(124, 300, 300), // Tauren Racial — HIDDEN
|
||||
(162, 300, 300), // Unarmed
|
||||
(163, 300, 300), // Marksmanship — single-rank
|
||||
(173, 300, 300), // Daggers
|
||||
(183, 300, 300), // GENERIC (DND) — HIDDEN
|
||||
(226, 300, 300), // Crossbows
|
||||
(413, 1, 1), // Mail
|
||||
(414, 1, 1), // Leather
|
||||
(415, 1, 1), // Cloth
|
||||
];
|
||||
let entries: Vec<SkillEntry> = BLOCK
|
||||
.iter()
|
||||
.filter_map(|&(skill_id, value, max)| {
|
||||
let slot = benilla_protocol::messages::PlayerSkillSlot {
|
||||
skill_id,
|
||||
step: 0,
|
||||
value,
|
||||
max,
|
||||
temp_bonus: 0,
|
||||
perm_bonus: 0,
|
||||
};
|
||||
crate::ui_char::skills_row(&slot, &catalog, 6, 3, 60)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_skills(SkillsState { entries });
|
||||
|
||||
let n = s.eval::<i64>("return GetNumSkillLines()").unwrap();
|
||||
let rows: Vec<(String, bool)> = (1..=n)
|
||||
.map(|i| {
|
||||
s.eval::<(String, Option<i64>)>(&format!(
|
||||
"local n,h = GetSkillLineInfo({i}) return n,h"
|
||||
))
|
||||
.map(|(name, header)| (name, header.is_some()))
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let expected: Vec<(&str, bool)> = vec![
|
||||
("Class Skills", true),
|
||||
("Beast Mastery", false),
|
||||
("Marksmanship", false),
|
||||
("Survival", false),
|
||||
("Weapon Skills", true),
|
||||
("Axes", false),
|
||||
("Crossbows", false),
|
||||
("Daggers", false),
|
||||
("Defense", false),
|
||||
("Guns", false),
|
||||
("Unarmed", false),
|
||||
("Armor Proficiencies", true),
|
||||
("Cloth", false),
|
||||
("Leather", false),
|
||||
("Mail", false),
|
||||
("Languages", true),
|
||||
("Language: Orcish", false),
|
||||
("Language: Taurahe", false),
|
||||
];
|
||||
let got: Vec<(&str, bool)> = rows.iter().map(|(n, h)| (n.as_str(), *h)).collect();
|
||||
assert_eq!(got, expected, "the pane, row for row");
|
||||
|
||||
// The three the server sends and the client never lists.
|
||||
for hidden in ["Dual Wield", "Tauren Racial", "GENERIC (DND)"] {
|
||||
assert!(
|
||||
!got.iter().any(|(n, _)| *n == hidden),
|
||||
"{hidden} must not appear"
|
||||
);
|
||||
}
|
||||
// And the class lines still read as proficiencies despite their 300/300 descriptor.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return (select(7, GetSkillLineInfo(2)))")
|
||||
.unwrap(),
|
||||
1,
|
||||
"Beast Mastery's skillMaxRank"
|
||||
);
|
||||
}
|
||||
@@ -1389,3 +1389,59 @@ fn the_player_frame_flashes_zzz_while_resting() {
|
||||
assert!(clear, "neither state → no status dressing at all");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The zzz badge paints OVER the level number (decision 1093): the ref keeps the number in the
|
||||
/// texture frame's BACKGROUND layer and the state icons up in OVERLAY, so the opaque badge covers
|
||||
/// it while resting. The layer split is the only mechanism that CAN hide it — a fontstring never
|
||||
/// ducks under a texture of its own layer (0884's bucket-wide quads-then-text law) — which is
|
||||
/// exactly how 1082's transcription slipped: it put the number in OVERLAY beside the icons, and
|
||||
/// the number rode on the badge (director report, 2026-08-07).
|
||||
#[test]
|
||||
fn the_rest_badge_covers_the_level_number() {
|
||||
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("Prober".into()),
|
||||
health: 100,
|
||||
max_health: 100,
|
||||
level: 12,
|
||||
power_type: 0,
|
||||
power: 80,
|
||||
max_power: 80,
|
||||
dead: false,
|
||||
reaction: 0,
|
||||
..UnitState::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
s.set_rest_state(1, 500, true);
|
||||
s.fire_event("PLAYER_UPDATE_RESTING", vec![]);
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
|
||||
// Every UI-StateIcon quad (the badge AND its ADD glow — the glow rides a later frame and
|
||||
// sits higher still): even the LOWEST must clear the number.
|
||||
let badge = quads
|
||||
.iter()
|
||||
.filter(|q| {
|
||||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
||||
if p.ends_with("UI-StateIcon"))
|
||||
})
|
||||
.map(|q| q.z)
|
||||
.min()
|
||||
.expect("the zzz badge");
|
||||
let level = quads
|
||||
.iter()
|
||||
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "12"))
|
||||
.expect("the level text");
|
||||
assert!(
|
||||
badge > level.z,
|
||||
"the badge must cover the level number (badge z={badge:#x}, level z={:#x})",
|
||||
level.z
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -587,6 +587,8 @@ fn drive_mouseover_tooltip(
|
||||
mut go_inputs: crate::target::lock::GoLockInputs,
|
||||
// The known-spell set the resolver's SKILL arm scans.
|
||||
player_actions: Res<crate::ui_action::PlayerActions>,
|
||||
// The cursor seat crosses the VM seam (0582/0584): the anchor below is UI units, not px.
|
||||
ui_scale: Res<crate::ui_script::UiScaleCvar>,
|
||||
mut last: Local<LastHover>,
|
||||
mut last_lines: Local<Option<UnitState>>,
|
||||
) {
|
||||
@@ -647,12 +649,16 @@ fn drive_mouseover_tooltip(
|
||||
// settle it. Flagged INTERIM in 0766 rather than presented as verified.
|
||||
let cursor_seated =
|
||||
stores.get(entity).map(|s| s.0.gameobject_type_id()) == Ok(GO_TYPE_GENERIC);
|
||||
// Window px → the VM's y-up 768-virtual units (÷s, the input seam's own conversion) —
|
||||
// the anchor this point seats is resolved in UI units, so a raw-px point lands the
|
||||
// plate (s−1)× the cursor's distance from the bottom-left corner away from it.
|
||||
let cursor_ui = cursor_seated
|
||||
.then(|| {
|
||||
window
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|w| w.cursor_position().map(|c| (c.x, w.height() - c.y)))
|
||||
window.iter().next().and_then(|w| {
|
||||
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
|
||||
w.cursor_position()
|
||||
.map(|c| (c.x / s, (w.height() - c.y) / s))
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
if *last == LastHover::Go(guid) {
|
||||
|
||||
@@ -97,10 +97,15 @@ struct UnitFeedState {
|
||||
/// The last `(PLAYER_XP, PLAYER_NEXT_LEVEL_XP)` pair pushed, for the `PLAYER_XP_UPDATE` trigger —
|
||||
/// the XP bar's feed is a player-global (like coinage), not a per-unit-token field.
|
||||
last_xp: Option<(u32, u32)>,
|
||||
/// The last `(restState, restPool, resting)` triple pushed, for the `UPDATE_EXHAUSTION` and
|
||||
/// `PLAYER_UPDATE_RESTING` triggers — player-globals like the XP pair (decision 1082). Pushed
|
||||
/// as one snapshot so `GetRestState`/`GetXPExhaustion`/`IsResting` never read it half-updated.
|
||||
last_rest: Option<(u8, u32, bool)>,
|
||||
/// The last `(restState, restPool, PLAYER_FLAGS)` triple pushed, for the `UPDATE_EXHAUSTION`
|
||||
/// and `PLAYER_UPDATE_RESTING` triggers — player-globals like the XP pair (decisions
|
||||
/// 1082/1087). The whole flags dword, not just the resting bit: the client's `0x5ee990`
|
||||
/// fires `PLAYER_UPDATE_RESTING` on any PLAYER_FLAGS delta. Pushed as one snapshot so
|
||||
/// `GetRestState`/`GetXPExhaustion`/`IsResting` never read it half-updated.
|
||||
last_rest: Option<(u8, u32, u32)>,
|
||||
/// Our avatar's last-seen `UNIT_FIELD_LEVEL`, for the `PLAYER_LEVEL_UP` trigger (decision
|
||||
/// 1094). `None` until first seen — the first sighting is the login descriptor, not a ding.
|
||||
last_level: Option<u32>,
|
||||
/// The last `(count, banked-target guid)` pair pushed, for the `PLAYER_COMBO_POINTS` trigger —
|
||||
/// player-globals (`PLAYER_FIELD_BYTES` byte 1 + `PLAYER_FIELD_COMBO_TARGET`), not per-unit-
|
||||
/// token fields. Diffed as a pair because the server writes them as one (decision 0875).
|
||||
@@ -143,7 +148,37 @@ impl Plugin for UiUnitPlugin {
|
||||
.in_set(UnitFeed)
|
||||
.before(UiInput),
|
||||
)
|
||||
.add_systems(Update, drain_pvp_toggles.after(UiInput));
|
||||
.add_systems(Update, drain_pvp_toggles.after(UiInput))
|
||||
.add_systems(PostStartup, load_exhaustion_rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the VM's Exhaustion.dbc table once at startup — the rest bindings' data
|
||||
/// ([`benilla_ui::script::UiScript::set_exhaustion_rows`]; the ui_macro icon-catalog shape).
|
||||
/// A failed load keeps the model's shipped-table fallback, so the rest surface still behaves
|
||||
/// like the shipped enUS client rather than going dark.
|
||||
fn load_exhaustion_rows(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
assets: Option<Res<crate::assets::WorldAssets>>,
|
||||
) {
|
||||
let (Some(mut script), Some(assets)) = (script, assets) else {
|
||||
return;
|
||||
};
|
||||
let loaded = {
|
||||
use crate::assets::LockRecover;
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
benilla_formats::load_exhaustion(&mut chain)
|
||||
};
|
||||
match loaded {
|
||||
Ok(rows) => {
|
||||
info!("ui_unit: {} Exhaustion.dbc rest states", rows.len());
|
||||
script.set_exhaustion_rows(
|
||||
rows.into_iter()
|
||||
.map(|r| (r.id as u8, r.name, f64::from(r.factor)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Err(e) => error!("ui_unit: Exhaustion.dbc failed — shipped-table fallback holds: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +494,10 @@ fn drain_pvp_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetComm
|
||||
/// the icon draws: the preference clears instantly, the flag lingers for the server's timer.
|
||||
const PLAYER_FLAGS_PVP_DESIRED: u32 = 0x200;
|
||||
|
||||
/// `PLAYER_FLAGS_RESTING` — inside a rest area now (vmangos `Player.h:320`); the bit
|
||||
/// `IsResting 0x516ea0` tests (`shr 5; test 1` — wow-re rested-xp-bindings.md §3).
|
||||
const PLAYER_FLAGS_RESTING: u32 = 0x20;
|
||||
|
||||
/// The PvP-preference announcement rule (decision 0652): `(toast, verbose)` on a real change of the
|
||||
/// bit, `None` otherwise.
|
||||
///
|
||||
@@ -484,6 +523,27 @@ fn pvp_announcement(was: Option<bool>, now: bool) -> Option<(&'static str, &'sta
|
||||
})
|
||||
}
|
||||
|
||||
/// The rest-state chat line (decision 1098; wow-re rested-xp-bindings.md §§6-10, byte-verified
|
||||
/// §5): the rest-state BYTE watcher `0x5de4e0` messages only on a real old≠new transition (the
|
||||
/// dispatcher's `rep cmpsb` mirror diff at `0x4655bb`), through the hard-coded 3×2 pair table
|
||||
/// `0x80af50` — state 1 → `ERR_EXHAUSTION_RESTED`, state 2 → `ERR_EXHAUSTION_NORMAL`, state 0 →
|
||||
/// the table's deliberate no-message sentinel (id 0x1d1), states ≥ 3 gated off before the table
|
||||
/// (`cmp esi,3; jae`), so the beta tiers never speak even though their strings ship. The line is
|
||||
/// a plain yellow SYSTEM chat message (`CHAT_MSG_SYSTEM`) — never UIErrorsFrame, no sound.
|
||||
/// enUS literals like every app-side chat line (`level_up_lines`' shape); the keys above are the
|
||||
/// GlobalStrings homes. Entering rested also arms a one-shot tutorial popup (id 0x19) — the
|
||||
/// tutorial system isn't built, a named cut.
|
||||
fn rest_state_message(prev: u8, new: u8) -> Option<&'static str> {
|
||||
if prev == new {
|
||||
return None;
|
||||
}
|
||||
match new {
|
||||
1 => Some("You feel rested."),
|
||||
2 => Some("You feel normal."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit's PvP faction group — `UnitFactionGroup`'s pair, as the icon law reads it (decision
|
||||
/// 0646 §1/§3): `UNIT_FIELD_FACTIONTEMPLATE` → `FactionTemplate.dbc`'s group mask → the
|
||||
/// `FactionGroup.dbc` name of its **side** bit.
|
||||
@@ -661,8 +721,88 @@ fn feed_units(
|
||||
script.set_unit("player", player.clone());
|
||||
script.set_unit("target", target.clone());
|
||||
|
||||
// Initial pull: fire PLAYER_ENTERING_WORLD once so frames do their first paint on their own.
|
||||
if !feed.entered_world {
|
||||
// The XP bar's feed: push our own avatar's PLAYER_XP / PLAYER_NEXT_LEVEL_XP (both PRIVATE, only
|
||||
// ever streamed for self) and fire PLAYER_XP_UPDATE when either changes — the coinage feed's
|
||||
// shape. Absent fields read 0 (a fresh descriptor's zero default; the bar shows empty until XP
|
||||
// streams in). BEFORE the PLAYER_ENTERING_WORLD fire below, so the first paint reads real
|
||||
// values (1087 — the tick's handler divides by UnitXPMax).
|
||||
if let Some((store, _)) = self_q.iter().next() {
|
||||
let xp = store.0.player_xp().unwrap_or(0);
|
||||
let next = store.0.player_next_level_xp().unwrap_or(0);
|
||||
if feed.last_xp != Some((xp, next)) {
|
||||
feed.last_xp = Some((xp, next));
|
||||
script.set_player_xp(xp, next);
|
||||
script.fire_event("PLAYER_XP_UPDATE", vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
// The rest feed (decisions 1082/1087): the `PLAYER_BYTES_2` rest-state byte, the
|
||||
// `PLAYER_REST_STATE_EXPERIENCE` pool and PLAYER_FLAGS, pushed as one snapshot. Two watches,
|
||||
// the byte-verified grain (wow-re rested-xp-bindings.md §5): `UPDATE_EXHAUSTION` on a
|
||||
// state-or-pool change (the client installs a watcher on each — `0x5de4e0` on the byte,
|
||||
// `0x5de4b0` on the pool field), `PLAYER_UPDATE_RESTING` on **any PLAYER_FLAGS delta**
|
||||
// (`0x5ee990` fires it beside PLAYER_FLAGS_CHANGED without testing which bit moved — the
|
||||
// resting bit is just its loudest consumer). Runs before the PLAYER_ENTERING_WORLD fire
|
||||
// below, like the XP push: in the real client the descriptor always lands before that
|
||||
// event, so the first paint reads real state — the model's byte-2 default (its doc) is the
|
||||
// backstop, this ordering is the guarantee itself.
|
||||
if let Some((store, _)) = self_q.iter().next() {
|
||||
let rest = (
|
||||
store.0.player_rest_state().unwrap_or(0),
|
||||
store.0.player_rest_state_experience().unwrap_or(0),
|
||||
store.0.player_flags(),
|
||||
);
|
||||
if feed.last_rest != Some(rest) {
|
||||
let prev = feed.last_rest;
|
||||
feed.last_rest = Some(rest);
|
||||
script.set_rest_state(rest.0, rest.1, rest.2 & PLAYER_FLAGS_RESTING != 0);
|
||||
if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) {
|
||||
script.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||||
}
|
||||
// "You feel rested." / "You feel normal." (decision 1098): the BYTE watcher alone
|
||||
// messages, and only on a real transition — `prev` None is the login descriptor,
|
||||
// which the real client's fresh-CREATE path never runs through the notify pass
|
||||
// (byte-verified: login is structurally silent). The pool watcher never messages.
|
||||
if let Some(p) = prev {
|
||||
if let Some(text) = rest_state_message(p.0, rest.0) {
|
||||
chat.push_event(ChatEvent::text_only(
|
||||
ChatEventKind::System,
|
||||
text.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if prev.map(|p| p.2) != Some(rest.2) {
|
||||
script.fire_event("PLAYER_UPDATE_RESTING", vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ding feed: `PLAYER_LEVEL_UP` (arg1 = the new level) when our avatar's
|
||||
// `UNIT_FIELD_LEVEL` rises — the event the exhaustion tick (1082) and the max-level rail
|
||||
// (1094) register; it was a dead registration until 1094. Trigger PROVISIONAL (0578's
|
||||
// pattern): fired off the descriptor diff, which lands in the same update batch as the
|
||||
// ding's XP fields, so consumers read a coherent picture. The real client plausibly fires
|
||||
// it from its `SMSG_LEVELUP_INFO` handler instead, with the packet's gain tuple as arg2+ —
|
||||
// unpinned, and no 1.12 FrameXML consumer reads past arg1 (`ReputationWatchBar_Update`
|
||||
// takes arg1; the tick's handler takes none), so the extra args wait for a consumer.
|
||||
// A DROP (a GM `.level` down) fires nothing, like a first sighting — only a rise dings.
|
||||
if let Some((store, _)) = self_q.iter().next() {
|
||||
if let Some(level) = store.0.unit_level() {
|
||||
let prev = feed.last_level.replace(level);
|
||||
if prev.is_some_and(|p| level > p) {
|
||||
script.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(i64::from(level))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial pull: fire PLAYER_ENTERING_WORLD once so frames do their first paint on their own —
|
||||
// gated on our avatar's descriptor EXISTING. 1087 stated the real client's guarantee (the
|
||||
// player object lands before this event) and moved the XP/rest pushes above the fire, but the
|
||||
// fire itself still went out on frame 1, seconds before login: every one-shot first-paint
|
||||
// read empty state, and only consumers with their own diff events recovered. The 1094 live
|
||||
// probe caught the one that couldn't — a level-60 login read UnitLevel()=0 at the fire and
|
||||
// kept the XP strip. With the gate the guarantee is structural for every consumer.
|
||||
if !feed.entered_world && self_pair.is_some() {
|
||||
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
feed.entered_world = true;
|
||||
}
|
||||
@@ -731,45 +871,6 @@ fn feed_units(
|
||||
feed.pvp_desired = Some(desired);
|
||||
}
|
||||
|
||||
// The XP bar's feed: push our own avatar's PLAYER_XP / PLAYER_NEXT_LEVEL_XP (both PRIVATE, only
|
||||
// ever streamed for self) and fire PLAYER_XP_UPDATE when either changes — the coinage feed's
|
||||
// shape. Absent fields read 0 (a fresh descriptor's zero default; the bar shows empty until XP
|
||||
// streams in).
|
||||
if let Some((store, _)) = self_q.iter().next() {
|
||||
let xp = store.0.player_xp().unwrap_or(0);
|
||||
let next = store.0.player_next_level_xp().unwrap_or(0);
|
||||
if feed.last_xp != Some((xp, next)) {
|
||||
feed.last_xp = Some((xp, next));
|
||||
script.set_player_xp(xp, next);
|
||||
script.fire_event("PLAYER_XP_UPDATE", vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
// The rest feed (decision 1082): the `PLAYER_BYTES_2` rest-state byte, the
|
||||
// `PLAYER_REST_STATE_EXPERIENCE` pool and the `PLAYER_FLAGS_RESTING` bit, pushed as one
|
||||
// snapshot. Two watches, the reference's own grain: `UPDATE_EXHAUSTION` on a state-or-pool
|
||||
// change (the exhaustion tick and the bar's rested/normal color both register it),
|
||||
// `PLAYER_UPDATE_RESTING` on the flag toggling (the player frame's zzz). Absent fields read
|
||||
// 0 / not-resting — a fresh descriptor's zero default, which `GetRestState` renders as Normal.
|
||||
if let Some((store, _)) = self_q.iter().next() {
|
||||
let rest = (
|
||||
store.0.player_rest_state().unwrap_or(0),
|
||||
store.0.player_rest_state_experience().unwrap_or(0),
|
||||
store.0.player_is_resting(),
|
||||
);
|
||||
if feed.last_rest != Some(rest) {
|
||||
let prev = feed.last_rest;
|
||||
feed.last_rest = Some(rest);
|
||||
script.set_rest_state(rest.0, rest.1, rest.2);
|
||||
if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) {
|
||||
script.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||||
}
|
||||
if prev.map(|p| p.2) != Some(rest.2) {
|
||||
script.fire_event("PLAYER_UPDATE_RESTING", vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The combo-point feed: `PLAYER_FIELD_BYTES` byte 1 and the `PLAYER_FIELD_COMBO_TARGET` GUID
|
||||
// it is banked against, both PRIVATE, pushed as the pair the server writes (0869, 0875).
|
||||
//
|
||||
@@ -996,4 +1097,36 @@ mod tests {
|
||||
"the OFF sentence is what tells the player the flag lingers: {verbose}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The rest-state chat law (decision 1098, wow-re §§6-10): a message needs a real byte
|
||||
/// TRANSITION, and only states 1/2 speak — state 0 is the pair table's no-message sentinel,
|
||||
/// the beta tiers (≥3) are gated off before the table, and a re-send of the same byte is
|
||||
/// swallowed by the dispatcher's mirror diff.
|
||||
#[test]
|
||||
fn rest_state_message_speaks_only_on_a_real_transition() {
|
||||
assert_eq!(rest_state_message(2, 1), Some("You feel rested."));
|
||||
assert_eq!(rest_state_message(1, 2), Some("You feel normal."));
|
||||
assert_eq!(
|
||||
rest_state_message(0, 1),
|
||||
Some("You feel rested."),
|
||||
"0→1 IS a transition"
|
||||
);
|
||||
assert_eq!(
|
||||
rest_state_message(1, 1),
|
||||
None,
|
||||
"same byte re-sent — the mirror diff eats it"
|
||||
);
|
||||
assert_eq!(rest_state_message(2, 2), None);
|
||||
assert_eq!(
|
||||
rest_state_message(1, 0),
|
||||
None,
|
||||
"state 0 is the 0x1d1 sentinel: no message"
|
||||
);
|
||||
assert_eq!(
|
||||
rest_state_message(2, 3),
|
||||
None,
|
||||
"beta tiers are gated off (cmp esi,3; jae)"
|
||||
);
|
||||
assert_eq!(rest_state_message(1, 5), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,23 @@ const UNDERCITY: Site = Site {
|
||||
uid: 239598,
|
||||
};
|
||||
|
||||
/// Ironforge — B65's site: the lava chasm under the Great Forge walkways. The walkable metal
|
||||
/// grates at lava level (world z≈421, e.g. `.go xyz -4845 -995 421 0`) live in cavern group g89
|
||||
/// (one portal, p114→g91); a camera over a grate/bed column seeds that group and the scene draws,
|
||||
/// while a column with nothing walkable below (the shaft-mouth sliver, the moat's bed gaps) reads
|
||||
/// "outside" and blanks the interior — which decision 1096 pins as the CLIENT'S OWN behaviour
|
||||
/// (wow-re `wmo-downray-liquid-audit.md`: no liquid leg, no fallback; the asset's patchy beds are
|
||||
/// the only thing keeping retail drawn over lava). The 2026-08-07 pins: `WOW_PIN_EYE=-4990,-960,470`
|
||||
/// reproduces a faithful blank column; `WOW_PIN_EYE=-4830,-1090,480` seeds g91 and draws; the
|
||||
/// still-open half of B65 (partial grate loss from seeded rim poses) is 1096's "NOT settled" leaf.
|
||||
#[allow(dead_code)] // pinned for `WOW_PIN_*` runs: the B65 repro coordinates live here
|
||||
const IRONFORGE: Site = Site {
|
||||
wmo: r"world\wmo\khazmodan\cities\ironforge\ironforge.wmo",
|
||||
map: "Azeroth",
|
||||
tile: (34, 41),
|
||||
uid: 7706,
|
||||
};
|
||||
|
||||
/// Eye height above a floor point for both the standing player and the seated camera samples.
|
||||
const EYE_HEIGHT: f32 = 1.7;
|
||||
|
||||
@@ -182,10 +199,14 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
|
||||
let mut walk_pos = Vec::new();
|
||||
let mut walk_idx = Vec::new();
|
||||
let mut names = vec![String::new(); n];
|
||||
let mut group_liquids: Vec<Option<benilla_formats::LiquidMesh>> = vec![None; n];
|
||||
for gi in 0..n {
|
||||
let Ok(gbytes) = chain.read(&format!("{stem}_{gi:03}.wmo")) else {
|
||||
continue;
|
||||
};
|
||||
// The group's MLIQ surface, exactly as the asset loader stores it — the down-ray's liquid
|
||||
// leg (B65) needs it, so the pin probe must carry it too or an over-lava eye can't replay.
|
||||
group_liquids[gi] = benilla_formats::wmo_group_liquid_mesh(&gbytes);
|
||||
group_footprints[gi] = wmo_group_footprint_tris(&gbytes);
|
||||
if let (Some(h), Some(nav)) = (wmo_group_header(&gbytes), group_nav.get_mut(gi)) {
|
||||
nav.flags = h.flags;
|
||||
@@ -260,7 +281,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
|
||||
group_footprint_grids: benilla_assets::footprint_tri_grids(&group_footprints),
|
||||
group_footprints,
|
||||
group_light_refs: Vec::new(),
|
||||
group_liquids: Vec::new(),
|
||||
group_liquids,
|
||||
doodad_base: Vec::new(),
|
||||
doodad_owner: Vec::new(),
|
||||
doodad_groups: Vec::new(),
|
||||
|
||||
@@ -9,6 +9,215 @@ use bevy::math::{Mat4, Vec3};
|
||||
|
||||
use super::{load_subject, Site, EXTERIOR, UNDERCITY};
|
||||
|
||||
/// The pin family's shared env targeting (`WOW_PIN_WMO`/`_MAP`/`_TILE`/`_UID`, default
|
||||
/// [`UNDERCITY`]).
|
||||
fn site_from_env() -> Site {
|
||||
Site {
|
||||
wmo: Box::leak(
|
||||
std::env::var("WOW_PIN_WMO")
|
||||
.unwrap_or_else(|_| UNDERCITY.wmo.to_string())
|
||||
.into_boxed_str(),
|
||||
),
|
||||
map: Box::leak(
|
||||
std::env::var("WOW_PIN_MAP")
|
||||
.unwrap_or_else(|_| UNDERCITY.map.to_string())
|
||||
.into_boxed_str(),
|
||||
),
|
||||
tile: std::env::var("WOW_PIN_TILE").map_or(UNDERCITY.tile, |s| {
|
||||
let (a, b) = s.split_once(',').expect("WOW_PIN_TILE wants tx,ty");
|
||||
(a.trim().parse().unwrap(), b.trim().parse().unwrap())
|
||||
}),
|
||||
uid: std::env::var("WOW_PIN_UID").map_or(UNDERCITY.uid, |s| s.trim().parse().unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
/// **The group census** — the pin probe's gazetteer: every group's flags, portal-edge count, and
|
||||
/// bbox in BOTH frames (model-local and WoW world, via the same placement transform the pin uses),
|
||||
/// so a "which group is that thing on my screen" question becomes a grep instead of a guess. Same
|
||||
/// `WOW_PIN_*` targeting as the pin probe.
|
||||
#[test]
|
||||
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
|
||||
fn wmo_group_census() {
|
||||
let site = site_from_env();
|
||||
let subject = load_subject(site.wmo, Some(&site));
|
||||
let placed = subject
|
||||
.placed
|
||||
.as_ref()
|
||||
.expect("the census needs a placement");
|
||||
// The MLIQ leg the harness's nav skips: re-read each group file for its liquid grid, so the
|
||||
// census can say "this group owns the lava" (local-space z range of its wet vertices).
|
||||
let data = std::env::var("WOW_DATA")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
|
||||
});
|
||||
let chain = benilla_formats::open_chain(&data).expect("open MPQ chain (set WOW_DATA)");
|
||||
let stem = site.wmo.strip_suffix(".wmo").unwrap_or(site.wmo);
|
||||
let liquid: Vec<Option<(f32, f32)>> = (0..subject.model.group_nav.len())
|
||||
.map(|gi| {
|
||||
let bytes = chain.read(&format!("{stem}_{gi:03}.wmo")).ok()?;
|
||||
let mesh = benilla_formats::wmo_group_liquid_mesh(&bytes)?;
|
||||
let zs = mesh.positions.iter().map(|p| p[2]);
|
||||
let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
for z in zs {
|
||||
lo = lo.min(z);
|
||||
hi = hi.max(z);
|
||||
}
|
||||
(lo <= hi).then_some((lo, hi))
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"== group census: {} ({} groups) ==",
|
||||
site.wmo,
|
||||
subject.model.group_nav.len()
|
||||
);
|
||||
// `WOW_PIN_COL=<world-x,world-y>`: every walking-collision face crossing that column, per group,
|
||||
// as world z — the down-ray's Leg A candidate list for one spot, without running the flood.
|
||||
if let Some((cx, cy)) = std::env::var("WOW_PIN_COL").ok().and_then(|v| {
|
||||
let c: Vec<f32> = v.split(',').filter_map(|p| p.trim().parse().ok()).collect();
|
||||
(c.len() == 2).then(|| (c[0], c[1]))
|
||||
}) {
|
||||
let local = bevy_to_wow(
|
||||
placed
|
||||
.local_from_world
|
||||
.transform_point3(wow_to_bevy([cx, cy, 0.0])),
|
||||
);
|
||||
println!(
|
||||
"-- column ({cx:.1},{cy:.1}) = local ({:.2},{:.2}) --",
|
||||
local[0], local[1]
|
||||
);
|
||||
for (gi, tris) in subject.model.group_collision_tris.iter().enumerate() {
|
||||
for t in tris {
|
||||
if let Some(z) = benilla_formats::triangle_z_at(t, local[0], local[1]) {
|
||||
let w = bevy_to_wow(
|
||||
placed
|
||||
.world_from_local
|
||||
.transform_point3(wow_to_bevy([local[0], local[1], z])),
|
||||
);
|
||||
println!(" under g{gi:03}: local z {z:.2} = world z {:.1}", w[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The unfiltered contrast: every face in the FILE crossing this column, with its MOPY
|
||||
// flags — a face listed here but not above is one our walking gather dropped, which
|
||||
// against the client's mask (`0x84`, a strict superset of our `0x04` skip) is a parse
|
||||
// divergence, not a filter choice.
|
||||
println!("-- raw file faces at the column (all MOPY flags) --");
|
||||
for gi in 0..subject.model.group_nav.len() {
|
||||
let Ok(gbytes) = chain.read(&format!("{stem}_{gi:03}.wmo")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(benilla_wmo::ParsedWmo::Group(group)) =
|
||||
benilla_wmo::parse_wmo(&mut std::io::Cursor::new(gbytes.as_slice()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (t, mopy) in group.material_info.iter().enumerate() {
|
||||
let idx = |k: usize| group.vertex_indices.get(t * 3 + k).copied();
|
||||
let (Some(a), Some(b), Some(c)) = (idx(0), idx(1), idx(2)) else {
|
||||
continue;
|
||||
};
|
||||
let pos = |i: u16| {
|
||||
group
|
||||
.vertex_positions
|
||||
.get(i as usize)
|
||||
.map(|p| [p.x, p.y, p.z])
|
||||
};
|
||||
let (Some(a), Some(b), Some(c)) = (pos(a), pos(b), pos(c)) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(z) = benilla_formats::triangle_z_at(&[a, b, c], local[0], local[1]) {
|
||||
println!(
|
||||
" raw g{gi:03}: local z {z:.2} mopy {:#04x} material {}",
|
||||
mopy.flags, mopy.material_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `WOW_PIN_FLOOR=<local-z>`: dump every walking-collision face whose centroid sits within 3 yd
|
||||
// of that height, as world x/y — the "where exactly is the walkable metalwork at lava level"
|
||||
// map a screenshot-pose reconstruction needs.
|
||||
if let Some(floor_z) = std::env::var("WOW_PIN_FLOOR")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
{
|
||||
for (gi, tris) in subject.model.group_collision_tris.iter().enumerate() {
|
||||
for t in tris {
|
||||
let c = [
|
||||
(t[0][0] + t[1][0] + t[2][0]) / 3.0,
|
||||
(t[0][1] + t[1][1] + t[2][1]) / 3.0,
|
||||
(t[0][2] + t[1][2] + t[2][2]) / 3.0,
|
||||
];
|
||||
if (c[2] - floor_z).abs() > 3.0 {
|
||||
continue;
|
||||
}
|
||||
let w = bevy_to_wow(placed.world_from_local.transform_point3(wow_to_bevy(c)));
|
||||
println!(
|
||||
"floor g{gi:03} world ({:.1}, {:.1}, {:.1})",
|
||||
w[0], w[1], w[2]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (gi, g) in subject.model.group_nav.iter().enumerate() {
|
||||
// World bbox: transform all 8 local corners, take the WoW-frame min/max.
|
||||
let mut wmin = [f32::INFINITY; 3];
|
||||
let mut wmax = [f32::NEG_INFINITY; 3];
|
||||
for c in 0..8 {
|
||||
let local = [
|
||||
if c & 1 == 0 {
|
||||
g.bbox_min[0]
|
||||
} else {
|
||||
g.bbox_max[0]
|
||||
},
|
||||
if c & 2 == 0 {
|
||||
g.bbox_min[1]
|
||||
} else {
|
||||
g.bbox_max[1]
|
||||
},
|
||||
if c & 4 == 0 {
|
||||
g.bbox_min[2]
|
||||
} else {
|
||||
g.bbox_max[2]
|
||||
},
|
||||
];
|
||||
let w = bevy_to_wow(placed.world_from_local.transform_point3(wow_to_bevy(local)));
|
||||
for k in 0..3 {
|
||||
wmin[k] = wmin[k].min(w[k]);
|
||||
wmax[k] = wmax[k].max(w[k]);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"g{gi:03} flags {:#07x}{} edges {:2} world x[{:.0},{:.0}] y[{:.0},{:.0}] z[{:.0},{:.0}] local z[{:.1},{:.1}] walk {:5} camonly {:4}{}",
|
||||
g.flags,
|
||||
if g.flags & EXTERIOR != 0 { " EXT" } else { " " },
|
||||
g.ref_count,
|
||||
wmin[0],
|
||||
wmax[0],
|
||||
wmin[1],
|
||||
wmax[1],
|
||||
wmin[2],
|
||||
wmax[2],
|
||||
g.bbox_min[2],
|
||||
g.bbox_max[2],
|
||||
subject
|
||||
.model
|
||||
.group_collision_tris
|
||||
.get(gi)
|
||||
.map_or(0, Vec::len),
|
||||
subject
|
||||
.model
|
||||
.group_camera_only_tris
|
||||
.get(gi)
|
||||
.map_or(0, Vec::len),
|
||||
liquid[gi]
|
||||
.map(|(lo, hi)| format!(" LIQUID local z[{lo:.1},{hi:.1}]"))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The pin probe** — a director's `.go xyz` / `/shot` report, replayed through the real flood.
|
||||
///
|
||||
/// A bug report names a *place*, and until now turning that place into evidence meant launching the
|
||||
@@ -33,23 +242,7 @@ fn wmo_pin_probe() {
|
||||
let v: Vec<f32> = s.split(',').filter_map(|p| p.trim().parse().ok()).collect();
|
||||
<[f32; 3]>::try_from(&v[..]).unwrap_or_else(|_| panic!("{var} wants x,y,z; got {s:?}"))
|
||||
}
|
||||
let site = Site {
|
||||
wmo: Box::leak(
|
||||
std::env::var("WOW_PIN_WMO")
|
||||
.unwrap_or_else(|_| UNDERCITY.wmo.to_string())
|
||||
.into_boxed_str(),
|
||||
),
|
||||
map: Box::leak(
|
||||
std::env::var("WOW_PIN_MAP")
|
||||
.unwrap_or_else(|_| UNDERCITY.map.to_string())
|
||||
.into_boxed_str(),
|
||||
),
|
||||
tile: std::env::var("WOW_PIN_TILE").map_or(UNDERCITY.tile, |s| {
|
||||
let (a, b) = s.split_once(',').expect("WOW_PIN_TILE wants tx,ty");
|
||||
(a.trim().parse().unwrap(), b.trim().parse().unwrap())
|
||||
}),
|
||||
uid: std::env::var("WOW_PIN_UID").map_or(UNDERCITY.uid, |s| s.trim().parse().unwrap()),
|
||||
};
|
||||
let site = site_from_env();
|
||||
let subject = load_subject(site.wmo, Some(&site));
|
||||
let placed = subject
|
||||
.placed
|
||||
@@ -146,6 +339,42 @@ fn wmo_pin_probe() {
|
||||
);
|
||||
}
|
||||
|
||||
// The portal geometry behind every hop verdict above: plane + vertex extent, for each portal
|
||||
// referenced by a group that contains the eye or the look point — the "which side is the eye
|
||||
// actually on" question needs the plane, not the verdict.
|
||||
println!("-- portals of the groups containing eye/look --");
|
||||
for (gi, g) in model.group_nav.iter().enumerate() {
|
||||
let holds = |p: [f32; 3]| (0..3).all(|k| p[k] >= g.bbox_min[k] && p[k] <= g.bbox_max[k]);
|
||||
if !holds(eye) && !holds(look) {
|
||||
continue;
|
||||
}
|
||||
let start = g.ref_start as usize;
|
||||
let end = (start + g.ref_count as usize).min(model.portal_refs.len());
|
||||
for r in &model.portal_refs[start..end] {
|
||||
let Some(info) = model.portal_infos.get(r.portal as usize) else {
|
||||
continue;
|
||||
};
|
||||
let [nx, ny, nz, d] = info.plane;
|
||||
let de = nx * eye[0] + ny * eye[1] + nz * eye[2] + d;
|
||||
let vs = (info.start_vertex as usize)
|
||||
..(info.start_vertex as usize + info.count as usize)
|
||||
.min(model.portal_vertices.len());
|
||||
let mut vmin = [f32::INFINITY; 3];
|
||||
let mut vmax = [f32::NEG_INFINITY; 3];
|
||||
for v in &model.portal_vertices[vs] {
|
||||
for k in 0..3 {
|
||||
vmin[k] = vmin[k].min(v[k]);
|
||||
vmax[k] = vmax[k].max(v[k]);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" g{gi:02} p{} ->g{}(side {:+}) plane n({:.3},{:.3},{:.3}) d {:.2} | d(eye) {:+.3} | verts x[{:.1},{:.1}] y[{:.1},{:.1}] z[{:.1},{:.1}]",
|
||||
r.portal, r.group, r.side, nx, ny, nz, d, de,
|
||||
vmin[0], vmax[0], vmin[1], vmax[1], vmin[2], vmax[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Which room is the director looking *at*? Report every group whose MOGI bbox contains the look
|
||||
// point, with its verdict — the culled one there is the bug.
|
||||
println!("-- groups containing the look point --");
|
||||
|
||||
@@ -101,10 +101,24 @@ pub struct M2Model {
|
||||
/// create/select background scenes; their camera *lookup* is the 0xffff none sentinel, so
|
||||
/// `portrait_camera` sees nothing there). Same Bevy-space conversion as `portrait_camera`.
|
||||
pub camera0: Option<PortraitCamera>,
|
||||
/// The camera table's **second** record — what a 1.12 `<PlayerModel>` UI widget (the paper doll,
|
||||
/// the inspect pane, the pet page) renders through (VERIFIED, wow-re
|
||||
/// `ui/scratch/modelframe-camera-law.md`: `0x505b30` → the chooser `0x505890` selects **raw
|
||||
/// index 1**, the `type == 1` "characterinfo" camera, and freezes it at `0x7acf10`).
|
||||
///
|
||||
/// **Raw, not through `cameraLookup`** — that array is the portrait bake's path and is not
|
||||
/// consulted here; the index is a literal 1 whatever the record's `type` says. `None` for a
|
||||
/// model with fewer than two cameras, where the client synthesizes a *fixed* camera instead
|
||||
/// (transcribed at the framing site). Same Bevy-space conversion as `portrait_camera`.
|
||||
pub pane_camera: Option<PortraitCamera>,
|
||||
/// A bow's `$WTT`/`$WTB` bowstring anchors (wow-re `nocked-ammo-cancel.md` §G2), baked to Bevy
|
||||
/// space: `[top, bottom]` as `(bone index, model-local position)` — the two limb-tip points the
|
||||
/// engine-drawn string spans. `None` for every non-bow model.
|
||||
pub string_anchors: Option<[(u16, Vec3); 2]>,
|
||||
/// The fishing pole's `$CCH` line anchor (wow-re `fishing-line.md` §2), baked to Bevy space in
|
||||
/// the mesh frame — the rod-tip point the engine-drawn fishing line starts from. `None` for
|
||||
/// every model that doesn't author it (exactly one weapon model in the chain does).
|
||||
pub cch_marker: Option<Vec3>,
|
||||
/// MD20 header `GlobalModelFlags` (`+0x10`). Bits `&3` are the **terrain-conform gate**
|
||||
/// (wow-re `terrain-tilt.md`, §5 byte-verified): `1` = pitch to the ground slope (every
|
||||
/// mount + most quadrupeds), `3` = pitch **and** roll (kodo/crab/spider), `0`/`2` = level.
|
||||
@@ -710,6 +724,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
};
|
||||
let portrait_camera = parse_m2_portrait_camera(&bytes).map(to_bevy);
|
||||
let camera0 = benilla_formats::parse_m2_camera(&bytes, 0).map(to_bevy);
|
||||
let pane_camera = benilla_formats::parse_m2_camera(&bytes, 1).map(to_bevy);
|
||||
|
||||
// The bowstring anchors (bows only): raw WoW positions → Bevy, the meshes' frame.
|
||||
let string_anchors = benilla_formats::parse_m2_string_anchors(&bytes).map(|a| {
|
||||
@@ -719,6 +734,9 @@ impl AssetLoader for M2ModelLoader {
|
||||
]
|
||||
});
|
||||
|
||||
// The fishing line's near anchor (mesh frame, like the bowstring anchors).
|
||||
let cch_marker = benilla_formats::parse_m2_cch_marker(&bytes).map(|(_, p)| wow_to_bevy(p));
|
||||
|
||||
// The header GlobalModelFlags word sits at a fixed offset (MD20 magic @0, +0x10) — a
|
||||
// bare scalar the sectioned parsers never touch.
|
||||
let global_flags = bytes
|
||||
@@ -740,7 +758,9 @@ impl AssetLoader for M2ModelLoader {
|
||||
markers,
|
||||
portrait_camera,
|
||||
camera0,
|
||||
pane_camera,
|
||||
string_anchors,
|
||||
cch_marker,
|
||||
global_flags,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Which models author a given M2 animation event? `cargo run -p benilla-formats --example
|
||||
//! scan_events -- '$CCH' weapon` — scans every `.m2` in the 5875 chain whose path contains the
|
||||
//! (optional, case-insensitive) substring and prints each model authoring the 4CC, with the
|
||||
//! event's bone and raw WoW position. The sibling of `dump_attach`: that answers "what does THIS
|
||||
//! model author", this answers "who else authors THIS event" — the question every event-keyed
|
||||
//! consumer (bowstring `$WTT`, fishing line `$CCH`, GO sound slots `$GC0`) eventually asks when
|
||||
//! deciding whether the ident alone is a safe gate.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
fn le_u32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn le_f32(b: &[u8], o: usize) -> f32 {
|
||||
f32::from_le_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let ident = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or_else(|| anyhow::anyhow!("usage: scan_events <4cc, e.g. $CCH> [path-substring]"))?;
|
||||
anyhow::ensure!(ident.len() == 4, "the event ident is exactly 4 bytes");
|
||||
let pat = std::env::args().nth(2).unwrap_or_default().to_lowercase();
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
let chain = benilla_formats::open_chain(&data)?;
|
||||
let (mut scanned, mut hits) = (0usize, 0usize);
|
||||
for e in chain.list()? {
|
||||
let lower = e.name.to_lowercase();
|
||||
if !lower.ends_with(".m2") || !lower.contains(&pat) {
|
||||
continue;
|
||||
}
|
||||
let Ok(b) = chain.read(&e.name) else { continue };
|
||||
if b.len() < 0x11c || &b[0..4] != b"MD20" {
|
||||
continue;
|
||||
}
|
||||
scanned += 1;
|
||||
let (count, ofs) = (le_u32(&b, 0x114) as usize, le_u32(&b, 0x118) as usize);
|
||||
for i in 0..count {
|
||||
let rec = ofs + i * 44;
|
||||
if rec + 44 > b.len() {
|
||||
break;
|
||||
}
|
||||
if &b[rec..rec + 4] == ident.as_bytes() {
|
||||
hits += 1;
|
||||
println!(
|
||||
"{} bone {} pos ({:+.3},{:+.3},{:+.3})",
|
||||
e.name,
|
||||
le_u32(&b, rec + 8),
|
||||
le_f32(&b, rec + 12),
|
||||
le_f32(&b, rec + 16),
|
||||
le_f32(&b, rec + 20),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("-- {scanned} models scanned, {hits} authoring {ident}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Throwaway: dump `SkillRaceClassInfo.dbc` rows admitting a race/class, with the flags word in
|
||||
//! hex — the table the client's `GetSkillLineInfo` reads for its 0x400 single-rank cap and 0x20
|
||||
//! unlearnable bit. Usage: `cargo run -p benilla-formats --example srci_dump -- <race> <class>`.
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let race: u32 = args.next().unwrap_or_else(|| "4".into()).parse()?;
|
||||
let class: u32 = args.next().unwrap_or_else(|| "3".into()).parse()?;
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
|
||||
// Skill line names, so the dump reads.
|
||||
let lines = chain.read_file("DBFilesClient\\SkillLine.dbc")?;
|
||||
let name_of = |id: u32| -> String {
|
||||
let g = |b: &[u8], o: usize| u32::from_le_bytes(b[o..o + 4].try_into().unwrap());
|
||||
let (rows, _fields, rec) = (g(&lines, 4), g(&lines, 8), g(&lines, 12) as usize);
|
||||
let sb = 20 + rows as usize * rec;
|
||||
for r in 0..rows as usize {
|
||||
let base = 20 + r * rec;
|
||||
if g(&lines, base) == id {
|
||||
let off = sb + g(&lines, base + 3 * 4) as usize;
|
||||
let end = lines[off..].iter().position(|&c| c == 0).unwrap_or(0) + off;
|
||||
return format!(
|
||||
"{} (cat {})",
|
||||
String::from_utf8_lossy(&lines[off..end]),
|
||||
g(&lines, base + 4)
|
||||
);
|
||||
}
|
||||
}
|
||||
"?".into()
|
||||
};
|
||||
|
||||
let bytes = chain.read_file("DBFilesClient\\SkillRaceClassInfo.dbc")?;
|
||||
let g = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||
let (rows, _fields, rec) = (g(4), g(8), g(12) as usize);
|
||||
let (rmask, cmask) = (
|
||||
1u32.checked_shl(race.wrapping_sub(1)).unwrap_or(0),
|
||||
1u32.checked_shl(class.wrapping_sub(1)).unwrap_or(0),
|
||||
);
|
||||
println!("race {race} (mask {rmask:#x}) class {class} (mask {cmask:#x})");
|
||||
let mut seen: Vec<u32> = Vec::new();
|
||||
for r in 0..rows as usize {
|
||||
let base = 20 + r * rec;
|
||||
let (skill, race_mask, class_mask, flags, req_level, tier) = (
|
||||
g(base + 4),
|
||||
g(base + 8),
|
||||
g(base + 12),
|
||||
g(base + 16),
|
||||
g(base + 20),
|
||||
g(base + 24),
|
||||
);
|
||||
// race/class 0 = dump EVERY row (masks unfiltered, duplicates kept).
|
||||
let all = race == 0 || class == 0;
|
||||
if !all && (race_mask & rmask == 0 || class_mask & cmask == 0 || seen.contains(&skill)) {
|
||||
continue;
|
||||
}
|
||||
seen.push(skill);
|
||||
println!(
|
||||
"skill {skill:>4} flags {flags:#07x} (0x400={}) reqLevel {req_level} tier {tier} {}",
|
||||
flags & 0x400 != 0,
|
||||
name_of(skill)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Exhaustion.dbc — the rest-state table behind the client's rested-XP surface.
|
||||
//!
|
||||
//! The whole client contract is byte-carved in wow-re `system/ui/scratch/rested-xp-bindings.md`
|
||||
//! (decision 1087 is the benilla fold-back): `GetRestState 0x48d350` indexes this table
|
||||
//! **directly by the `PLAYER_BYTES_2` rest-state byte** (an ID→row-ptr array, `[0xc0dd78]`) and
|
||||
//! returns `(row.ID, row.name[locale], row.factor)`; `GetXPExhaustion 0x48d3f0` multiplies the
|
||||
//! rested pool by **row ID 1's factor, hard-coded** (2.0 in the shipped data — the "rested XP is
|
||||
//! double" law is this one f32, not a client constant). The names come from this file's string
|
||||
//! block, **not** GlobalStrings — which is why "Rested"/"Normal" localize with the install.
|
||||
//!
|
||||
//! Row layout (fieldCount 0xf / recordSize 0x3c, validated by the client loader `0x544da0`):
|
||||
//! `ID@0`, `Xp@1`, `Factor@2` (f32), `OutdoorHours@3`, `InnHours@4` (both unread by the
|
||||
//! bindings), `Name_Lang@5..12`, `NameFlags@13`, `Threshold@14`. Shipped 5875 rows: 1 "Rested"
|
||||
//! 2.0 · 2 "Normal" 1.0 · 3/4 "XXXTired" 1.0/0.5 · 5 "XXXExhausted" 0.25 — rows 3..5 are the
|
||||
//! never-shipped beta tiers FrameXML still carries dead branches for.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{f32_at, parse, str_at, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const EXHAUSTION: &str = "DBFilesClient\\Exhaustion.dbc";
|
||||
|
||||
/// One Exhaustion.dbc row as the rest bindings consume it.
|
||||
pub struct ExhaustionRow {
|
||||
/// The row ID — also exactly the wire's rest-state byte (the client indexes by it).
|
||||
pub id: u32,
|
||||
/// The localized state name (`GetRestState`'s second return; enUS slot of this install).
|
||||
pub name: String,
|
||||
/// The XP multiplier (`GetRestState`'s third return; row 1's value is `GetXPExhaustion`'s
|
||||
/// scale).
|
||||
pub factor: f32,
|
||||
}
|
||||
|
||||
fn exhaustion_schema() -> Schema {
|
||||
let mut s = Schema::new("Exhaustion");
|
||||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new("Xp", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new("Factor", FieldType::Float32));
|
||||
s.add_field(SchemaField::new("OutdoorHours", FieldType::Float32));
|
||||
s.add_field(SchemaField::new("InnHours", FieldType::Float32));
|
||||
for i in 0..8 {
|
||||
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
|
||||
}
|
||||
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new("Threshold", FieldType::UInt32));
|
||||
s
|
||||
}
|
||||
|
||||
/// Load Exhaustion.dbc from the patch chain — the rows for `UiScript::set_exhaustion_rows`.
|
||||
pub fn load_exhaustion(chain: &mut Chain) -> Result<Vec<ExhaustionRow>> {
|
||||
let bytes = chain
|
||||
.read_file(EXHAUSTION)
|
||||
.with_context(|| format!("reading {EXHAUSTION}"))?;
|
||||
let rs = parse(&bytes, exhaustion_schema(), "Exhaustion")?;
|
||||
let mut rows = Vec::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let (Some(id), Some(factor)) = (u32_at(r, 0), f32_at(r, 2)) else {
|
||||
continue;
|
||||
};
|
||||
let name = str_at(&rs, r, 5).unwrap_or_default();
|
||||
rows.push(ExhaustionRow { id, name, factor });
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::load_exhaustion;
|
||||
|
||||
/// The shipped 5875 table, read as data rather than assumed — the same five rows the wow-re
|
||||
/// dispatch extracted from the MPQs independently (rested-xp-bindings.md): the rested factor
|
||||
/// really is 2.0-as-data, and the beta tiers really ship with placeholder names. Skips
|
||||
/// without client data.
|
||||
#[test]
|
||||
fn the_shipped_rest_states_carry_the_rested_double() {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let rows = load_exhaustion(&mut chain).expect("Exhaustion.dbc");
|
||||
let by_id: std::collections::HashMap<u32, (&str, f32)> = rows
|
||||
.iter()
|
||||
.map(|r| (r.id, (r.name.as_str(), r.factor)))
|
||||
.collect();
|
||||
assert_eq!(by_id[&1], ("Rested", 2.0), "the ×2 is this row's data");
|
||||
assert_eq!(by_id[&2], ("Normal", 1.0));
|
||||
assert_eq!(by_id[&3], ("XXXTired", 1.0), "beta tier, never sent");
|
||||
assert_eq!(by_id[&4], ("XXXTired", 0.5));
|
||||
assert_eq!(by_id[&5], ("XXXExhausted", 0.25));
|
||||
assert_eq!(rows.len(), 5, "the whole shipped table");
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ pub use gameobjects::{
|
||||
};
|
||||
mod durability;
|
||||
pub use durability::{load_durability_tables, DurabilityTables};
|
||||
mod exhaustion;
|
||||
pub use exhaustion::{load_exhaustion, ExhaustionRow};
|
||||
mod bank_bag_slot_prices;
|
||||
pub use bank_bag_slot_prices::{load_bank_bag_slot_prices, BankBagSlotPrices};
|
||||
mod stationery;
|
||||
@@ -140,7 +142,7 @@ pub use spells::{
|
||||
};
|
||||
mod skill_lines;
|
||||
pub use skill_lines::{
|
||||
load_skill_line_catalog, SkillLineCatalog, SkillLineInfo, SlaInfo, SKILL_CATEGORY_NOT_DISPLAYED,
|
||||
load_skill_line_catalog, SkillLineCatalog, SkillLineInfo, SkillRaceClass, SlaInfo,
|
||||
};
|
||||
mod spell_focus;
|
||||
pub use spell_focus::{load_spell_focus_catalog, SpellFocusCatalog};
|
||||
@@ -172,19 +174,19 @@ pub use models::{
|
||||
m2_texture_transform_count, non_separable_billboard_bones, owner_last_rung,
|
||||
owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary,
|
||||
parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera,
|
||||
parse_m2_collision_hull, parse_m2_event_markers, parse_m2_global_sequence_bones,
|
||||
parse_m2_lights, parse_m2_playable_animation_lookup, parse_m2_portrait_camera,
|
||||
parse_m2_render_submeshes, parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs,
|
||||
parse_wmo_lights, parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs,
|
||||
wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs,
|
||||
wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim,
|
||||
AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim, CharSkinSlot,
|
||||
CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone,
|
||||
GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera,
|
||||
ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim,
|
||||
ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad,
|
||||
WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef,
|
||||
WmoPortals, WmoRoot, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS,
|
||||
parse_m2_cch_marker, parse_m2_collision_hull, parse_m2_event_markers,
|
||||
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
|
||||
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
|
||||
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
|
||||
wmo_group_doodad_refs, wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header,
|
||||
wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes,
|
||||
wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
|
||||
CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris,
|
||||
GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light,
|
||||
M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim,
|
||||
RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim,
|
||||
WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight,
|
||||
WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS,
|
||||
};
|
||||
mod terrain;
|
||||
pub use terrain::{
|
||||
|
||||
@@ -211,6 +211,33 @@ pub fn parse_m2_string_anchors(b: &[u8]) -> Option<StringAnchors> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a model's first `$CCH` marker — the fishing line's near anchor on the pole (wow-re
|
||||
/// `fishing-line.md` §2: the line builder scans the M2 **events** table for `$CCH` and composes
|
||||
/// its position through the bone's pose; the bobber authors one too but the reference never reads
|
||||
/// it). Returns `(bone index, raw WoW model-space position)`; `None` for a model with no `$CCH`.
|
||||
/// Exactly one weapon model in the 5875 chain authors it (`Misc_2H_FishingPole_A_01.m2` — the
|
||||
/// `scan_events` sweep), which is what lets the held-item attach key on presence alone.
|
||||
pub fn parse_m2_cch_marker(b: &[u8]) -> Option<(u16, [f32; 3])> {
|
||||
let (ev_count, ev_ofs) = (le_u32(b, 0x114) as usize, le_u32(b, 0x118) as usize);
|
||||
for e in 0..ev_count {
|
||||
let erec = ev_ofs + e * 44;
|
||||
if erec + 44 > b.len() {
|
||||
break;
|
||||
}
|
||||
if b.get(erec..erec + 4)? == b"$CCH" {
|
||||
return Some((
|
||||
le_u32(b, erec + 8) as u16,
|
||||
[
|
||||
le_f32(b, erec + 12),
|
||||
le_f32(b, erec + 16),
|
||||
le_f32(b, erec + 20),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One row of the M2's baked **PlayableAnimationLookup** table (decision 0082 — missing-animation-clip
|
||||
/// resolution): re-exposed from `benilla-m2`'s [`benilla_m2::M2PlayableAnim`] (identical shape) as the
|
||||
/// bytes-in entry point alongside [`parse_m2_skeleton`]/[`parse_m2_attachments`], so callers that only
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
//!
|
||||
//! `SkillRaceClassInfo.dbc` — `SkillRaceClassInfofmt = "diiiiiix"` (8 fields, 32 B/record):
|
||||
//! `id`(0) · **`skillId` = column 1** · **`raceMask` = column 2** · **`classMask` = column 3** ·
|
||||
//! **`flags` = column 4** · `reqLevel`(5) · `skillTierId`(6) · `skillCostID`(7, unused). This is
|
||||
//! **`flags` = column 4** · **`reqLevel` = column 5** · `skillTierId`(6) ·
|
||||
//! **`skillCostID` = column 7**. This is
|
||||
//! the table the client's spellbook tab classifier routes through (decision 0228): a spell's skill
|
||||
//! line is looked up here for the player's race+class, and if the matching row's `flags` bit `0x80`
|
||||
//! (`SKILL_FLAG_DISPLAY_SORTED`, cmangos `DBCEnums.h`) is set — or no row matches — the spell's tab
|
||||
@@ -52,9 +53,11 @@
|
||||
//! 11 fields, 44 B/record): `id`(0) · the 8-locale `name` block (enUS ⇒ **column 1**) + flags(9) ·
|
||||
//! **`displayOrder` = column 10** — the skills pane's header vocabulary and group order (decision
|
||||
//! 0437 phase 4): Class Skills(7, order 2) · Professions(11, 3) · Secondary(9, 4) · Weapon(6, 5) ·
|
||||
//! Armor(8, 6) · Languages(10, 7); `Attributes`(5, 1) never carries player rows and
|
||||
//! `Not Displayed`(12, 8) is the hide bucket. A skill line's own `categoryId` is `SkillLine.dbc`
|
||||
//! column 1 (the `SkillLinefmt` layout above).
|
||||
//! Armor(8, 6) · Languages(10, 7); `Attributes`(5, 1) never carries player rows, and
|
||||
//! `Not Displayed`(12, 8) is a header like any other — **not** a hide bucket, whatever its name
|
||||
//! suggests: the client drops `GENERIC (DND)` by its `SkillRaceClassInfo.flags & 0x2`, never by its
|
||||
//! category (decision 1091). A skill line's own `categoryId` is `SkillLine.dbc` column 1 (the
|
||||
//! `SkillLinefmt` layout above).
|
||||
//!
|
||||
//! Skill line ids are stable, well-known constants across the whole classic tool ecosystem
|
||||
//! (vmangos `SharedDefines.h`'s `SkillType` enum, itself commented "Data from SpellLine.dbc (1.12.1
|
||||
@@ -95,14 +98,14 @@ const SKILL_LINE_CATEGORY: &str = "DBFilesClient\\SkillLineCategory.dbc";
|
||||
const SKILL_LINE_CATEGORY_FIELDS: usize = 11;
|
||||
const COL_SLC_NAME_ENUS: usize = 1;
|
||||
const COL_SLC_ORDER: usize = 10;
|
||||
/// `SkillLineCategory` id 12 — "Not Displayed": the skills pane hides lines in this bucket.
|
||||
pub const SKILL_CATEGORY_NOT_DISPLAYED: u32 = 12;
|
||||
|
||||
const SKILL_RACE_CLASS_INFO_FIELDS: usize = 8;
|
||||
const COL_SRCI_SKILL_ID: usize = 1;
|
||||
const COL_SRCI_RACE_MASK: usize = 2;
|
||||
const COL_SRCI_CLASS_MASK: usize = 3;
|
||||
const COL_SRCI_FLAGS: usize = 4;
|
||||
const COL_SRCI_MIN_LEVEL: usize = 5;
|
||||
const COL_SRCI_COST_INDEX: usize = 7;
|
||||
|
||||
/// `SkillRaceClassInfo.flags` bit `0x80` — cmangos `DBCEnums.h`'s `SKILL_FLAG_DISPLAY_SORTED`. The
|
||||
/// spellbook tab classifier reads it as the low byte's sign (`(int8) < 0`): set ⇒ the skill line's
|
||||
@@ -117,6 +120,35 @@ const SKILL_FLAG_DISPLAY_SORTED: u32 = 0x80;
|
||||
/// for a line without it is dropped and anticheat-flagged, so the client must never offer it).
|
||||
const SKILL_FLAG_UNLEARNABLE: u32 = 0x20;
|
||||
|
||||
/// `SkillRaceClassInfo.flags` bit `0x1` — a line the Skills tab lists even at **rank 0** (the list
|
||||
/// build's `0x4d2cb0` untrained gate). Unnamed in the mangos enums; named here for what the bytes
|
||||
/// do.
|
||||
const SKILL_FLAG_ALWAYS_DISPLAY: u32 = 0x1;
|
||||
|
||||
/// `SkillRaceClassInfo.flags` bit `0x2` — the Skills tab **drops the line entirely**
|
||||
/// (`4d2d9f test dl,0x2`, wow-re `skillframe-display-list.md`). mangos names this bit
|
||||
/// `SKILL_FLAG_NO_SKILLUP_MESSAGE` from a different call site; in the display list it is a hide
|
||||
/// bit, and it is what keeps `Dual Wield`, the racial lines, the per-mount riding lines and
|
||||
/// `GENERIC (DND)` off the real client's pane.
|
||||
const SKILL_FLAG_HIDDEN: u32 = 0x2;
|
||||
|
||||
/// `SkillRaceClassInfo.flags` bit `0x4` — an untrained (rank 0) line becomes visible once the
|
||||
/// player reaches the row's `reqLevel`. Also one of the two bits gating the client's step-cost
|
||||
/// lookup; unnamed in the mangos enums.
|
||||
const SKILL_FLAG_TRAINABLE_AT_LEVEL: u32 = 0x4;
|
||||
|
||||
/// `SkillRaceClassInfo.flags` bit `0x400` — vmangos `DBCEnums.h`'s `SKILL_FLAG_MONO_VALUE` (a
|
||||
/// single-rank line). The real client's `GetSkillLineInfo` **overrides** its `skillMaxRank` return
|
||||
/// to `1` whenever the admitting row carries this bit, whatever the player's own skill descriptor
|
||||
/// says (wow-re `system/tradeskill/scratch/skillframe-seed-abandon.md`: `0x4d3610`, the
|
||||
/// `4d38b1 test ah,0x4` branch). That override is why a class skill the server reports as `300/300`
|
||||
/// draws as `SkillFrame.lua`'s gray, rank-text-less "proficiency" bar in the real client — the Lua
|
||||
/// gate is `skillMaxRank == 1`, and the DBC, not the wire, is what puts it there. Real build-5875
|
||||
/// data for a night-elf hunter: set on `Beast Mastery`/`Marksmanship`/`Survival` (0x410), `Dual
|
||||
/// Wield`/`Night Elf Racial`/the per-mount riding lines (0x492); clear on every weapon line, the
|
||||
/// armor proficiencies, the languages, `Riding` and the professions.
|
||||
const SKILL_FLAG_MONO_VALUE: u32 = 0x400;
|
||||
|
||||
/// One skill line's display identity (`SkillLine.dbc`) — a spellbook tab's name + icon, and the
|
||||
/// skills pane's grouping key.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -136,13 +168,64 @@ pub struct SkillLineInfo {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// One `SkillRaceClassInfo.dbc` row's tab-classification inputs: which race/class it admits and
|
||||
/// whether its skill line's spells sort into General ([`SKILL_FLAG_DISPLAY_SORTED`]).
|
||||
/// One `SkillRaceClassInfo.dbc` row: which race/class it admits, plus everything the client reads
|
||||
/// off it ([`SkillRaceClass`], the caller-facing half).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct SrciRow {
|
||||
race_mask: u32,
|
||||
class_mask: u32,
|
||||
flags: u32,
|
||||
row: SkillRaceClass,
|
||||
}
|
||||
|
||||
/// The `SkillRaceClassInfo.dbc` row the client resolved for a given skill line × race × class —
|
||||
/// the whole of what its Skills-tab display law reads (wow-re
|
||||
/// `system/tradeskill/scratch/skillframe-display-list.md`: the list build `0x4d2cb0` and
|
||||
/// `GetSkillLineInfo 0x4d3610`). Copy-cheap; obtained from
|
||||
/// [`SkillLineCatalog::race_class`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SkillRaceClass {
|
||||
/// `flags` (column 4) — the bit field the predicates below read.
|
||||
pub flags: u32,
|
||||
/// `reqLevel` (column 5) — the player level at which a not-yet-trained line starts showing
|
||||
/// (only consulted with [`SKILL_FLAG_TRAINABLE_AT_LEVEL`]).
|
||||
pub min_level: u32,
|
||||
/// `skillCostID` (column 7) — `GetSkillLineInfo`'s 12th return is this **plus one**
|
||||
/// (`0x4d3a06`). Inert on this build: every branch that would paint from it is repainted by
|
||||
/// the normal-skill/proficiency branches after it.
|
||||
pub cost_index: u32,
|
||||
}
|
||||
|
||||
impl SkillRaceClass {
|
||||
/// Whether the Skills tab drops this line **entirely** — `flags & 0x2`, the client's own
|
||||
/// `4d2d9f test dl,0x2 / jne` in the list build. This is what makes `Dual Wield`, the racial
|
||||
/// lines, the per-mount riding lines and `GENERIC (DND)` invisible in the real client even
|
||||
/// though the server sends them like any other skill.
|
||||
pub fn hidden(self) -> bool {
|
||||
self.flags & SKILL_FLAG_HIDDEN != 0
|
||||
}
|
||||
|
||||
/// Whether this is a **single-rank** line — `flags & 0x400`, which makes the client's
|
||||
/// `GetSkillLineInfo` report `skillMaxRank = 1` whatever the descriptor says
|
||||
/// ([`SKILL_FLAG_MONO_VALUE`]).
|
||||
pub fn mono(self) -> bool {
|
||||
self.flags & SKILL_FLAG_MONO_VALUE != 0
|
||||
}
|
||||
|
||||
/// Whether the line can be unlearned — `flags & 0x20` ([`SKILL_FLAG_UNLEARNABLE`]). The
|
||||
/// client ANDs this with a nonzero skill **step**; that half lives with the descriptor, at
|
||||
/// the feed.
|
||||
pub fn unlearnable(self) -> bool {
|
||||
self.flags & SKILL_FLAG_UNLEARNABLE != 0
|
||||
}
|
||||
|
||||
/// Whether a line the player holds at **rank 0** still gets a row, for a player at
|
||||
/// `player_level` — the list build's own gate (`0x4d2cb0`): shown outright with
|
||||
/// [`SKILL_FLAG_ALWAYS_DISPLAY`], else only when it is [`SKILL_FLAG_TRAINABLE_AT_LEVEL`] and
|
||||
/// the player has reached [`Self::min_level`]. A line at rank ≥ 1 never consults this.
|
||||
pub fn displays_untrained(self, player_level: u32) -> bool {
|
||||
self.flags & SKILL_FLAG_ALWAYS_DISPLAY != 0
|
||||
|| (self.flags & SKILL_FLAG_TRAINABLE_AT_LEVEL != 0 && player_level >= self.min_level)
|
||||
}
|
||||
}
|
||||
|
||||
/// One spell's `SkillLineAbility.dbc` row (module doc columns; first row wins across race/class
|
||||
@@ -267,7 +350,7 @@ impl SkillLineCatalog {
|
||||
}
|
||||
match self.srci_row(line, race, class) {
|
||||
// A matching row without the sort flag keeps the line's own tab.
|
||||
Some(r) if r.flags & SKILL_FLAG_DISPLAY_SORTED == 0 => line,
|
||||
Some(r) if r.row.flags & SKILL_FLAG_DISPLAY_SORTED == 0 => line,
|
||||
// The sort flag, or no admitting row for this race/class → General.
|
||||
_ => 0,
|
||||
}
|
||||
@@ -296,8 +379,27 @@ impl SkillLineCatalog {
|
||||
/// `SkillHandler.cpp`). `false` with no routing data, unknown race/class, or no admitting
|
||||
/// row — a missing button beats offering an unlearn the server would anticheat-flag.
|
||||
pub fn abandonable(&self, line_id: u32, race: u8, class: u8) -> bool {
|
||||
self.srci_row(line_id, race, class)
|
||||
.is_some_and(|r| r.flags & SKILL_FLAG_UNLEARNABLE != 0)
|
||||
self.race_class(line_id, race, class)
|
||||
.is_some_and(SkillRaceClass::unlearnable)
|
||||
}
|
||||
|
||||
/// The `SkillRaceClassInfo` row the client resolves for `line_id` × `race`/`class` (1-based
|
||||
/// unit bytes) — [`SkillRaceClass`], the whole of what the Skills tab's display law reads.
|
||||
/// `None` when no row admits this character: the real client's list build drops such a line
|
||||
/// outright (`0x4d2cb0`'s `!srci → continue`), so a caller building the pane must too.
|
||||
pub fn race_class(&self, line_id: u32, race: u8, class: u8) -> Option<SkillRaceClass> {
|
||||
self.srci_row(line_id, race, class).map(|r| r.row)
|
||||
}
|
||||
|
||||
/// Whether `line_id` is a **single-rank** line for `race`/`class` (1-based unit bytes): the
|
||||
/// admitting `SkillRaceClassInfo` row carries [`SKILL_FLAG_MONO_VALUE`] (`0x400`), so the
|
||||
/// client's `GetSkillLineInfo` reports its `skillMaxRank` as `1` no matter what the server's
|
||||
/// descriptor holds — and the skills pane draws it as a proficiency (gray bar, no rank text).
|
||||
/// `false` with no routing data, unknown race/class, or no admitting row: a line we can't
|
||||
/// classify keeps the server's own numbers rather than being silently blanked.
|
||||
pub fn mono_value(&self, line_id: u32, race: u8, class: u8) -> bool {
|
||||
self.race_class(line_id, race, class)
|
||||
.is_some_and(SkillRaceClass::mono)
|
||||
}
|
||||
|
||||
/// A skill line's display (name + tab icon), by id.
|
||||
@@ -411,7 +513,11 @@ fn load_race_class_info(chain: &mut Chain) -> HashMap<u32, Vec<SrciRow>> {
|
||||
map.entry(skill).or_default().push(SrciRow {
|
||||
race_mask: u32_at(r, COL_SRCI_RACE_MASK).unwrap_or(0),
|
||||
class_mask: u32_at(r, COL_SRCI_CLASS_MASK).unwrap_or(0),
|
||||
flags: u32_at(r, COL_SRCI_FLAGS).unwrap_or(0),
|
||||
row: SkillRaceClass {
|
||||
flags: u32_at(r, COL_SRCI_FLAGS).unwrap_or(0),
|
||||
min_level: u32_at(r, COL_SRCI_MIN_LEVEL).unwrap_or(0),
|
||||
cost_index: u32_at(r, COL_SRCI_COST_INDEX).unwrap_or(0),
|
||||
},
|
||||
});
|
||||
}
|
||||
map
|
||||
@@ -672,10 +778,9 @@ mod tests {
|
||||
assert_eq!(cat.category(9), Some(("Secondary Skills", 4)));
|
||||
assert_eq!(cat.category(6), Some(("Weapon Skills", 5)));
|
||||
assert_eq!(cat.category(10), Some(("Languages", 7)));
|
||||
assert_eq!(
|
||||
cat.category(SKILL_CATEGORY_NOT_DISPLAYED),
|
||||
Some(("Not Displayed", 8))
|
||||
);
|
||||
// Category 12 is a real row like any other — NOT a hide bucket: the client's list build
|
||||
// drops `GENERIC (DND)` by its `flags & 0x2`, never by its category (decision 1091).
|
||||
assert_eq!(cat.category(12), Some(("Not Displayed", 8)));
|
||||
assert_eq!(cat.category(0), None);
|
||||
|
||||
// The join: Tailoring (197) is a Profession; First Aid (129) is Secondary; the Fire
|
||||
@@ -717,6 +822,81 @@ mod tests {
|
||||
assert!(!cat.abandonable(164, 0, 0));
|
||||
}
|
||||
|
||||
/// `SkillRaceClassInfo.flags & 0x400` on the real build-5875 file (a night-elf hunter, race 4
|
||||
/// / class 3): the class talent lines, Dual Wield, the racial and the per-mount riding lines
|
||||
/// are MONO — the client reports their `skillMaxRank` as 1 and the pane draws them as gray
|
||||
/// proficiencies — while every weapon line, armor proficiency, language, `Riding` and
|
||||
/// profession keeps its real rank. Skips without client data.
|
||||
#[test]
|
||||
fn real_mono_value_split_class_lines_yes_weapons_no() {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_skill_line_catalog(&mut chain).expect("load skill lines");
|
||||
|
||||
// Beast Mastery / Survival / Marksmanship (0x410), Dual Wield / Night Elf Racial / the
|
||||
// Tiger Riding mount line / GENERIC (DND) (0x492).
|
||||
for line in [50u32, 51, 163, 118, 126, 150, 183] {
|
||||
assert!(cat.mono_value(line, 4, 3), "line {line} is single-rank");
|
||||
}
|
||||
// Weapon lines (Axes/Bows/Daggers/Crossbows/Unarmed/Defense), armor proficiencies
|
||||
// (Cloth/Leather/Mail), languages (Common/Darnassian), Riding, First Aid.
|
||||
for line in [
|
||||
44u32, 45, 173, 226, 162, 95, 415, 414, 413, 98, 113, 762, 129,
|
||||
] {
|
||||
assert!(!cat.mono_value(line, 4, 3), "line {line} keeps its rank");
|
||||
}
|
||||
// Unknown race/class → keep the server's numbers (the conservative arm).
|
||||
assert!(!cat.mono_value(50, 0, 0));
|
||||
}
|
||||
|
||||
/// `SkillRaceClassInfo.flags & 0x2` on the real build-5875 file — the bit that keeps a line
|
||||
/// off the Skills tab entirely (decision 1091; wow-re `0x4d2cb0`'s `4d2d9f test dl,0x2`), plus
|
||||
/// the `reqLevel` column the untrained gate reads. A night-elf hunter, race 4 / class 3.
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn real_hidden_lines_are_the_ones_the_reference_client_never_lists() {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_skill_line_catalog(&mut chain).expect("load skill lines");
|
||||
let row = |line: u32| cat.race_class(line, 4, 3).expect("an admitting row");
|
||||
|
||||
// Dual Wield, Night Elf Racial, Tiger Riding, GENERIC (DND) — all 0x492, all absent from
|
||||
// the real client's pane however high the server ranks them.
|
||||
for line in [118u32, 126, 150, 183] {
|
||||
assert!(row(line).hidden(), "line {line} never gets a row");
|
||||
}
|
||||
// Everything the ref does list: the class lines, weapons, armor, languages, Riding.
|
||||
for line in [50u32, 51, 163, 44, 95, 162, 413, 414, 415, 98, 762, 129] {
|
||||
assert!(!row(line).hidden(), "line {line} is listed");
|
||||
}
|
||||
|
||||
// reqLevel (column 5) reads for real: Mail 40, Dual Wield 20.
|
||||
assert_eq!(row(413).min_level, 40, "Mail");
|
||||
assert_eq!(row(118).min_level, 20, "Dual Wield");
|
||||
// The untrained gate is all but inert on this build's data, and the test says so: NO row
|
||||
// in the whole file carries 0x1, and exactly ONE carries 0x4 (line 493, reqLevel 0 — a
|
||||
// line `SkillLine.dbc` doesn't even have). So a rank-0 line is simply never listed; a
|
||||
// `reqLevel` on its own does nothing without 0x4.
|
||||
assert!(
|
||||
!row(413).displays_untrained(60),
|
||||
"Mail at rank 0 stays off the pane at any level — 0x80 carries neither gate bit"
|
||||
);
|
||||
assert!(
|
||||
row(493).displays_untrained(0),
|
||||
"the single 0x4 row shows from level 0 (its reqLevel is 0)"
|
||||
);
|
||||
// No admitting row at all → the caller drops the line (the client's own `!srci → continue`).
|
||||
assert_eq!(cat.race_class(118, 0, 0), None);
|
||||
}
|
||||
|
||||
/// The `forward_spellid` rank graph on the real build-5875 file — the action bar's
|
||||
/// rank-normalization source (decision 0883). Skips without client data.
|
||||
#[test]
|
||||
|
||||
@@ -78,3 +78,82 @@ fn too_short_yields_no_camera() {
|
||||
// No MD20 camera array header → None, no panic.
|
||||
assert!(parse_m2_portrait_camera(&[0u8; 16]).is_none());
|
||||
}
|
||||
|
||||
/// **The model-frame pane camera** — raw `cameras[1]`, the rig a 1.12 `<PlayerModel>` widget renders
|
||||
/// through (wow-re `system/ui/scratch/modelframe-camera-law.md`: `0x505b30` → the chooser `0x505890`
|
||||
/// takes a literal index 1, NOT `cameraLookup`; decision 1089).
|
||||
///
|
||||
/// Byte-level regression pin against the numbers the RE reports, read here independently through our
|
||||
/// own parser — the two agreeing is the cross-check. The universal clips (`near = 8/36`,
|
||||
/// `far = 1000/36`) come along because a wrong stride would land on neither.
|
||||
#[test]
|
||||
fn pane_cameras_match_the_authored_records() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let reader = Chain::open(&data).expect("open vanilla patch chain");
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 1e-3;
|
||||
for (path, eye, target, fov) in [
|
||||
(
|
||||
"Character\\Human\\Male\\HumanMale.m2",
|
||||
[3.6585_f32, 0.0338, 0.9227],
|
||||
[-0.3644_f32, 0.0291, 0.9873],
|
||||
0.97991_f32,
|
||||
),
|
||||
(
|
||||
"Character\\Tauren\\Male\\TaurenMale.m2",
|
||||
[4.4317, -0.0213, 1.0861],
|
||||
[0.2520, -0.0210, 1.0086],
|
||||
0.87991,
|
||||
),
|
||||
(
|
||||
"Creature\\Boar\\Boar.m2",
|
||||
[4.8611, 0.0, 1.8056],
|
||||
[-0.1389, 0.0, 0.9722],
|
||||
0.76101,
|
||||
),
|
||||
] {
|
||||
let bytes = reader.read(path).expect("read model");
|
||||
let cam = benilla_formats::parse_m2_camera(&bytes, 1)
|
||||
.unwrap_or_else(|| panic!("{path}: no cameras[1]"));
|
||||
eprintln!("{path}: {cam:?}");
|
||||
assert!(close(cam.fov, fov), "{path}: fov {} vs {fov}", cam.fov);
|
||||
assert!(
|
||||
close(cam.near_clip, 8.0 / 36.0),
|
||||
"{path}: near {}",
|
||||
cam.near_clip
|
||||
);
|
||||
assert!(
|
||||
close(cam.far_clip, 1000.0 / 36.0),
|
||||
"{path}: far {}",
|
||||
cam.far_clip
|
||||
);
|
||||
for (got, want) in cam
|
||||
.position
|
||||
.iter()
|
||||
.zip(eye)
|
||||
.chain(cam.target.iter().zip(target))
|
||||
{
|
||||
assert!(close(*got, want), "{path}: eye/target {got} vs {want}");
|
||||
}
|
||||
assert!(close(cam.roll, 0.0), "{path}: roll {}", cam.roll);
|
||||
}
|
||||
|
||||
// The standoff is AUTHORED per model, which is the whole point: nothing normalizes a pane, so a
|
||||
// gnome's camera sits ~2.2yd out and a boar's ~4.9. If these ever converged, some engine-side
|
||||
// fit would have crept back in.
|
||||
let x = |path: &str| {
|
||||
let bytes = reader.read(path).expect("read model");
|
||||
benilla_formats::parse_m2_camera(&bytes, 1)
|
||||
.unwrap_or_else(|| panic!("{path}: no cameras[1]"))
|
||||
.position[0]
|
||||
};
|
||||
let gnome = x("Character\\Gnome\\Female\\GnomeFemale.m2");
|
||||
let tauren = x("Character\\Tauren\\Male\\TaurenMale.m2");
|
||||
assert!(
|
||||
gnome < 2.5 && tauren > 4.0,
|
||||
"authored standoffs collapsed: gnome {gnome}, tauren {tauren}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,6 +374,18 @@ pub enum SessionEvent {
|
||||
name: String,
|
||||
data: [i32; 24],
|
||||
},
|
||||
/// A GameObject plays a one-shot **Custom** animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`):
|
||||
/// the client arms GO substate `8 + anim_id` — AnimationData ids 153..156 (Custom0..3),
|
||||
/// `anim_id >= 4` rejected at the consumer (wow-re `gameobject-anim-arm.md` §step 8).
|
||||
/// The load-bearing sender is the fishing bobber's bite (`anim_id 0`, the splash;
|
||||
/// decision 1086).
|
||||
GameObjectCustomAnim { guid: u64, anim_id: u32 },
|
||||
/// The fishing channel ended with nothing hooked (`SMSG_FISH_NOT_HOOKED`, empty body):
|
||||
/// the red `ERR_FISH_NOT_HOOKED` toast (decision 1086).
|
||||
FishNotHooked,
|
||||
/// The hooked fish got away — the fishing-skill roll failed on the bobber click
|
||||
/// (`SMSG_FISH_ESCAPED`, empty body): the red `ERR_FISH_ESCAPED` toast (decision 1086).
|
||||
FishEscaped,
|
||||
/// A server-pushed 2D sound kit (`SMSG_PLAY_SOUND`): BG events, quest/zone scripts.
|
||||
PlaySound { sound_id: u32 },
|
||||
/// A server-pushed music kit for the music channel (`SMSG_PLAY_MUSIC`).
|
||||
|
||||
@@ -669,6 +669,11 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
};
|
||||
vec![event]
|
||||
}
|
||||
ServerPacket::GameObjectCustomAnim { guid, anim_id } => {
|
||||
vec![SessionEvent::GameObjectCustomAnim { guid, anim_id }]
|
||||
}
|
||||
ServerPacket::FishNotHooked => vec![SessionEvent::FishNotHooked],
|
||||
ServerPacket::FishEscaped => vec![SessionEvent::FishEscaped],
|
||||
// The keepalive echo: the io layer matches the sequence against its ping clock to compute
|
||||
// the round-trip time (the codec is stateless, so the timing lives with the sender).
|
||||
ServerPacket::Pong { sequence } => vec![SessionEvent::Pong { sequence }],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::wire::{read_cstring, read_i32_le, read_u32_le};
|
||||
use crate::wire::{read_cstring, read_i32_le, read_u32_le, read_u64_le};
|
||||
|
||||
/// Body of `CMSG_GAMEOBJ_USE` (opcode `0xB1`/177 — VERIFIED vmangos
|
||||
/// `Server/Protocol/Opcodes_1_12_1.h`): one full `u64` guid, the GameObject to use. Read server-side
|
||||
@@ -83,6 +83,17 @@ pub(super) fn read_gameobject_query_response(
|
||||
))
|
||||
}
|
||||
|
||||
/// Read `SMSG_GAMEOBJECT_CUSTOM_ANIM` → `(guid, anim_id)`. VERIFIED vmangos
|
||||
/// `GameObject::SendGameObjectCustomAnim`: a full `u64` guid then `u32 animId`. The client-side
|
||||
/// meaning (wow-re `gameobject-anim-arm.md` §"one-shot channel" step 8): arm the GO's Custom
|
||||
/// substate `8 + animId` — AnimationData ids 153..156 — with `animId >= 4` rejected; the
|
||||
/// consumer applies that gate, this layer parses verbatim.
|
||||
pub(super) fn read_gameobject_custom_anim(r: &mut &[u8]) -> io::Result<(u64, u32)> {
|
||||
let guid = read_u64_le(r)?;
|
||||
let anim_id = read_u32_le(r)?;
|
||||
Ok((guid, anim_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -144,6 +155,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gameobject_custom_anim_decodes() {
|
||||
// A full little-endian guid + u32 animId (VERIFIED vmangos
|
||||
// `GameObject::SendGameObjectCustomAnim`). The bobber's bite is animId 0.
|
||||
let body = hx("f0debc9a7856341200000000");
|
||||
match parse_server(opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM, &body).unwrap() {
|
||||
ServerPacket::GameObjectCustomAnim { guid, anim_id } => {
|
||||
assert_eq!(guid, 0x1234_5678_9abc_def0);
|
||||
assert_eq!(anim_id, 0);
|
||||
}
|
||||
other => panic!("expected GameObjectCustomAnim, got {}", other.name()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fish_verdicts_decode_from_empty_bodies() {
|
||||
// Both fishing verdicts are size-0 sends (vmangos `GameObject::Update`/`Use`).
|
||||
assert!(matches!(
|
||||
parse_server(opcode::SMSG_FISH_NOT_HOOKED, &[]).unwrap(),
|
||||
ServerPacket::FishNotHooked
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_server(opcode::SMSG_FISH_ESCAPED, &[]).unwrap(),
|
||||
ServerPacket::FishEscaped
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gameobject_query_response_miss_decodes() {
|
||||
// A miss is the lone entry echoed with its top bit set (VERIFIED vmangos
|
||||
|
||||
@@ -297,6 +297,13 @@ pub const CMSG_OPEN_ITEM: u16 = 0x00AC; // 172
|
||||
/// GameObject to use (decision 0236). Not interchangeable with `CMSG_LOOT`: the server rejects a
|
||||
/// GameObject guid on `CMSG_LOOT`, so a chest opens its loot through this opcode.
|
||||
pub const CMSG_GAMEOBJ_USE: u16 = 0x00B1; // 177
|
||||
/// A GameObject plays a one-shot **custom** animation (VERIFIED vmangos `Opcodes_1_12_1.h`: 179;
|
||||
/// body in [`super::read_gameobject_custom_anim`] — `u64 guid + u32 animId`, from
|
||||
/// `GameObject::SendGameObjectCustomAnim`). The client arms GO substate `8 + animId`
|
||||
/// (Custom0..3, AnimationData ids 153..156), rejecting `animId >= 4` — wow-re
|
||||
/// `gameobject-anim-arm.md` §"one-shot channel" step 8. The load-bearing sender: the fishing
|
||||
/// bobber's bite (`animId 0` — the splash; decision 1086).
|
||||
pub const SMSG_GAMEOBJECT_CUSTOM_ANIM: u16 = 0x00B3; // 179
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h:183`: 180 — and the reference writes this literal
|
||||
/// (`0x5e2110`, its area-trigger check, builds `0xb4` + the trigger id). Body in
|
||||
/// [`super::area_trigger`]: the `AreaTrigger.dbc` id the player just walked into.
|
||||
@@ -343,6 +350,15 @@ pub const SMSG_CHAT_PLAYER_NOT_FOUND: u16 = 0x02A9; // 681
|
||||
/// A cross-faction whisper was refused (VERIFIED vmangos `Opcodes_1_12_1.h`: 537); empty body
|
||||
/// (`WorldPackets::Chat::ChatWrongFaction::AppendBodyTo`, `Server/Packets/Chat.cpp:16-18`).
|
||||
pub const SMSG_CHAT_WRONG_FACTION: u16 = 0x0219; // 537
|
||||
/// The fishing channel ended with nothing on the hook (VERIFIED vmangos `Opcodes_1_12_1.h`: 456;
|
||||
/// empty body — `GameObject::Update`'s bobber-expiry arm and `Use(FISHINGNODE)`'s
|
||||
/// clicked-too-early arm both send size 0). The red `ERR_FISH_NOT_HOOKED` toast — "No fish are
|
||||
/// hooked." (decision 1086).
|
||||
pub const SMSG_FISH_NOT_HOOKED: u16 = 0x01C8; // 456
|
||||
/// The hooked fish got away — the fishing-skill roll failed on the click (VERIFIED vmangos
|
||||
/// `Opcodes_1_12_1.h`: 457; empty body — `GameObject::Use`'s FISHINGNODE failure arm). The red
|
||||
/// `ERR_FISH_ESCAPED` toast — "Your fish got away!" (decision 1086).
|
||||
pub const SMSG_FISH_ESCAPED: u16 = 0x01C9; // 457
|
||||
/// A server notice the real client flashes in the red UIErrorsFrame (VERIFIED vmangos
|
||||
/// `Opcodes_1_12_1.h`: 459): one cstring (`WorldSession::SendNotification`,
|
||||
/// `Server/WorldSession.cpp:900-915`) — "You do not know that language", trade refusals, and kin.
|
||||
|
||||
@@ -260,6 +260,22 @@ pub enum ServerPacket {
|
||||
entry: u32,
|
||||
info: Option<GameObjectQueryInfo>,
|
||||
},
|
||||
/// `SMSG_GAMEOBJECT_CUSTOM_ANIM` — a GameObject plays a one-shot Custom animation. Payload
|
||||
/// VERIFIED vmangos `GameObject::SendGameObjectCustomAnim`: `u64 guid, u32 animId`. The
|
||||
/// client arms GO substate `8 + animId` (AnimationData 153..156, `animId >= 4` rejected) —
|
||||
/// the fishing bobber's bite splash is `animId 0` (decision 1086).
|
||||
GameObjectCustomAnim {
|
||||
guid: u64,
|
||||
anim_id: u32,
|
||||
},
|
||||
/// `SMSG_FISH_NOT_HOOKED` — the fishing channel ended (expiry, or clicked before the splash)
|
||||
/// with nothing hooked. Empty body (VERIFIED vmangos `GameObject::Update`/`Use`); the red
|
||||
/// `ERR_FISH_NOT_HOOKED` toast (decision 1086).
|
||||
FishNotHooked,
|
||||
/// `SMSG_FISH_ESCAPED` — the hooked fish got away (the skill roll failed on the click).
|
||||
/// Empty body (VERIFIED vmangos `GameObject::Use`); the red `ERR_FISH_ESCAPED` toast
|
||||
/// (decision 1086).
|
||||
FishEscaped,
|
||||
/// `SMSG_PLAY_SOUND` — a 2D sound-kit id, map/zone-wide (BG events, scripts). Payload
|
||||
/// VERIFIED vmangos `Map::PlayDirectSoundToMap`: one `u32 soundId` (SoundEntries).
|
||||
PlaySound {
|
||||
@@ -1094,6 +1110,9 @@ impl ServerPacket {
|
||||
ServerPacket::CreatureQueryResponse { .. } => "SMSG_CREATURE_QUERY_RESPONSE".into(),
|
||||
ServerPacket::PetNameQueryResponse { .. } => "SMSG_PET_NAME_QUERY_RESPONSE".into(),
|
||||
ServerPacket::GameObjectQueryResponse { .. } => "SMSG_GAMEOBJECT_QUERY_RESPONSE".into(),
|
||||
ServerPacket::GameObjectCustomAnim { .. } => "SMSG_GAMEOBJECT_CUSTOM_ANIM".into(),
|
||||
ServerPacket::FishNotHooked => "SMSG_FISH_NOT_HOOKED".into(),
|
||||
ServerPacket::FishEscaped => "SMSG_FISH_ESCAPED".into(),
|
||||
ServerPacket::PlaySound { .. } => "SMSG_PLAY_SOUND".into(),
|
||||
ServerPacket::PlayMusic { .. } => "SMSG_PLAY_MUSIC".into(),
|
||||
ServerPacket::PlayObjectSound { .. } => "SMSG_PLAY_OBJECT_SOUND".into(),
|
||||
|
||||
@@ -808,6 +808,13 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
let (entry, info) = gameobject::read_gameobject_query_response(&mut r)?;
|
||||
ServerPacket::GameObjectQueryResponse { entry, info }
|
||||
}
|
||||
opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM => {
|
||||
let (guid, anim_id) = gameobject::read_gameobject_custom_anim(&mut r)?;
|
||||
ServerPacket::GameObjectCustomAnim { guid, anim_id }
|
||||
}
|
||||
// Both fishing verdicts are empty-bodied (the opcode IS the message).
|
||||
opcode::SMSG_FISH_NOT_HOOKED => ServerPacket::FishNotHooked,
|
||||
opcode::SMSG_FISH_ESCAPED => ServerPacket::FishEscaped,
|
||||
opcode::SMSG_LOGOUT_COMPLETE => ServerPacket::LogoutComplete,
|
||||
// `{u32 reason, u8 instant}` — the `instant` byte is what the CAMP/QUIT countdown hangs
|
||||
// on (see the variant's own doc); it used to be dropped on the floor here.
|
||||
|
||||
@@ -8,6 +8,10 @@ use super::movement::ObjectType;
|
||||
// ObjectFields descriptor-field indices (build 5875), shared across object types.
|
||||
const FIELD_OBJECT_TYPE: u16 = 2;
|
||||
const FIELD_OBJECT_SCALE_X: u16 = 4;
|
||||
// `OBJECT_FIELD_CREATED_BY` = OBJECT_END(6) + 0x0..0x1 (vmangos `UpdateFields_1_12_1.h`): the
|
||||
// GameObject block's creator guid pair — the summoning player of a spell-spawned object
|
||||
// (fishing bobber, ritual portal); zero/absent for a world spawn.
|
||||
const FIELD_GAMEOBJECT_CREATED_BY: u16 = 6;
|
||||
const FIELD_GAMEOBJECT_DISPLAYID: u16 = 8;
|
||||
const FIELD_GAMEOBJECT_FLAGS: u16 = 9;
|
||||
// `GAMEOBJECT_ROTATION` = OBJECT_END(6) + 0x4 .. +0x7 (vmangos `UpdateFields_1_12_1.h`): the
|
||||
|
||||
@@ -501,6 +501,17 @@ impl ObjectFields {
|
||||
pub fn player_bank_bag_slots_purchased(&self) -> Option<u8> {
|
||||
self.player_bytes_2().map(|b| ((b >> 16) & 0xff) as u8)
|
||||
}
|
||||
/// `OBJECT_FIELD_CREATED_BY` (the GameObject block's creator guid, fields 6-7 in 1.12): the
|
||||
/// summoning player of a spell-spawned object — the fishing bobber's owner. `None` when absent
|
||||
/// or zero (a world-spawned GO has no creator). The reference's ONE reader on the interaction
|
||||
/// path is the faction resolver `0x5f7fd0`, which prefers the creator's reaction over
|
||||
/// `GAMEOBJECT_FACTION` — the bobber's ownership *gate* is NOT this field but the player's own
|
||||
/// `UNIT_FIELD_CHANNEL_OBJECT` (wow-re `fishing-bobber-interaction.md` §2, the byte-proven
|
||||
/// negative: `0x5f6710` never reads CREATED_BY).
|
||||
pub fn gameobject_created_by(&self) -> Option<u64> {
|
||||
self.get_guid(FIELD_GAMEOBJECT_CREATED_BY)
|
||||
.filter(|&g| g != 0)
|
||||
}
|
||||
/// `GAMEOBJECT_DISPLAYID`.
|
||||
pub fn gameobject_displayid(&self) -> Option<i32> {
|
||||
self.get_i32(FIELD_GAMEOBJECT_DISPLAYID)
|
||||
|
||||
@@ -199,18 +199,19 @@ impl Loader<'_> {
|
||||
let color = children_named(region, "Color").next().map(color_of);
|
||||
if is_texture {
|
||||
if let Some(file) = region.attr("file") {
|
||||
// A `<Texture file=…>` with a `<Color>` child DISCARDS the colour — it is not a
|
||||
// tint (wow-re `system/ui/scratch/texture-color-composition.md`, VERIFIED).
|
||||
// `CSimpleTexture::LoadXML 0x76fe20` runs its child loop (`0x76fec1`-`0x7700fc`,
|
||||
// where `<Color>` lands in `SetTexture(const CImVector*)` `0x770360`) to completion
|
||||
// BEFORE it reads `file=` at `0x770102`, and a successful load overwrites the very
|
||||
// same `+0xcc` slot (`0x7702cb`/`0x7702dc`) — the generated solid is released
|
||||
// unused. Even the load-FAILURE fallback ignores it, hard-coding `0xff00ff00`
|
||||
// (`0x77016a`). So: load the art, drop the colour.
|
||||
self.call_region(region_wrapper, "SetTexture", file.to_string(), dbg);
|
||||
if let Some(c) = color {
|
||||
// A texture with both a file and a <Color> is a tint (vertex color).
|
||||
self.call_region(
|
||||
region_wrapper,
|
||||
"SetVertexColor",
|
||||
(c[0], c[1], c[2], c[3]),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
} else if let Some(c) = color {
|
||||
// No file: a solid colour fill (SetTexture(r,g,b,a)).
|
||||
// No file: a solid colour fill (SetTexture(r,g,b,a)) — the client really generates
|
||||
// an 8×8 texture out of it, which is why its alpha later MULTIPLIES with any
|
||||
// SetVertexColor rather than being replaced by it (`RegionData::fill`).
|
||||
self.call_region(region_wrapper, "SetTexture", (c[0], c[1], c[2], c[3]), dbg);
|
||||
}
|
||||
// `<TexCoords left right top bottom>`: the UV sub-rect this texture samples (decision
|
||||
|
||||
@@ -37,10 +37,10 @@ impl Loader<'_> {
|
||||
(file.to_string(), layer.clone()),
|
||||
dbg,
|
||||
);
|
||||
if let Some(c) = color {
|
||||
// File + <Color> = a tint on the bar texture.
|
||||
self.call(wrapper, "SetStatusBarColor", (c[0], c[1], c[2], c[3]), dbg);
|
||||
}
|
||||
// File + `<Color>` DISCARDS the colour, it does not tint — the same
|
||||
// `CSimpleTexture::LoadXML` ordering `regions.rs`'s `<Texture>` arm cites
|
||||
// (`0x76fe20`: the child loop runs first, then `file=` overwrites the same `+0xcc`).
|
||||
// `<BarColor>` below is this widget's real tint.
|
||||
} else if let Some(c) = color {
|
||||
// No file: a solid-color bar (the SetStatusBarTexture(r,g,b,a) form).
|
||||
self.call(
|
||||
|
||||
@@ -149,17 +149,23 @@ fn set_slot_texture(lua: &Lua, this: &Table, slot: Slot, args: &MultiValue) -> m
|
||||
// plain region.
|
||||
Some(Value::String(s)) if s.to_str()?.is_empty() => {
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
}
|
||||
Some(Value::String(s)) => data.texture = Some(s.to_str()?.to_string()),
|
||||
Some(Value::String(s)) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
// The colour form generates a solid texture into the same slot the path form loads into
|
||||
// ([`RegionData::fill`]) — never a tint, so each clears the other.
|
||||
Some(v @ (Value::Number(_) | Value::Integer(_))) => {
|
||||
let arg = |i: usize| args.get(i).map(as_f32);
|
||||
data.color = Some([
|
||||
data.fill = Some([
|
||||
as_f32(v),
|
||||
arg(1).unwrap_or(0.0),
|
||||
arg(2).unwrap_or(0.0),
|
||||
arg(3).unwrap_or(1.0),
|
||||
]);
|
||||
data.texture = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.region_data
|
||||
.entry(rh)
|
||||
.or_default()
|
||||
.color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
|
||||
@@ -149,8 +149,8 @@ impl UiScript {
|
||||
data.font_height = fo.height.or(data.font_height);
|
||||
data.font_shadow = fo.shadow.or(data.font_shadow);
|
||||
data.outline = fo.outline;
|
||||
if data.color.is_none() {
|
||||
data.color = fo.color;
|
||||
if data.vertex_color.is_none() {
|
||||
data.vertex_color = fo.color;
|
||||
}
|
||||
// The object's own justify (`<NormalFont inherits=… justifyH="LEFT"/>` —
|
||||
// how the ref left-aligns a ButtonText).
|
||||
@@ -164,7 +164,7 @@ impl UiScript {
|
||||
// The button-level state color wins over the font object AND the region's own
|
||||
// explicit color — the client's Button color slots repaint the label outright.
|
||||
if let Some(c) = state_color {
|
||||
data.color = Some(c);
|
||||
data.vertex_color = Some(c);
|
||||
}
|
||||
// Region-rect precedence (decision 0068 v1): anchored regions resolved in
|
||||
// [`resolve`] → their owner-relative rect; else an explicitly-sized region draws
|
||||
@@ -198,7 +198,7 @@ impl UiScript {
|
||||
let content = if is_text {
|
||||
QuadContent::Text {
|
||||
text: data.text,
|
||||
color: data.color,
|
||||
color: data.vertex_color,
|
||||
justify_h: data.justify_h,
|
||||
justify_v: data.justify_v,
|
||||
font: data.font_path,
|
||||
@@ -211,7 +211,7 @@ impl UiScript {
|
||||
} else {
|
||||
QuadContent::Texture {
|
||||
path: data.texture,
|
||||
color: data.color,
|
||||
color: texture_color(data.fill, data.vertex_color),
|
||||
additive: data.additive,
|
||||
tex_coords: data.tex_coords,
|
||||
circular: data.circular,
|
||||
@@ -263,6 +263,26 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// The single colour a Texture region draws with: **`texel × vertexColour`**, per channel and
|
||||
/// **alpha included** (wow-re `system/ui/scratch/texture-color-composition.md`, VERIFIED — the
|
||||
/// stage-0 combine's `MODULATE(TEXTURE, DIFFUSE)` for both colour and alpha).
|
||||
///
|
||||
/// `fill` is the region's own solid-colour texture ([`RegionData::fill`] — the client generates a
|
||||
/// real 8×8 texel block from it), so where it is set it IS the texel and the product is the drawn
|
||||
/// colour. Where it isn't, the texel comes from `path`'s art and this returns the tint alone for the
|
||||
/// renderer to modulate the sample by; `None` means untinted.
|
||||
///
|
||||
/// The correction this encodes: a `<Color 1,1,1,0.2>` trough later `SetVertexColor(0,0,0.75,0.5)`'d
|
||||
/// draws at alpha **0.1**. benilla used to store one colour slot and let the second call *replace*
|
||||
/// the first, which drew it at 0.5.
|
||||
fn texture_color(fill: Option<[f32; 4]>, vertex: Option<[f32; 4]>) -> Option<[f32; 4]> {
|
||||
match (fill, vertex) {
|
||||
(Some(f), Some(v)) => Some([f[0] * v[0], f[1] * v[1], f[2] * v[2], f[3] * v[3]]),
|
||||
(Some(c), None) | (None, Some(c)) => Some(c),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A StatusBar's bar-fill rect: the frame rect scaled by the value fraction — rightward from the
|
||||
/// left edge (horizontal) or upward from the bottom (vertical), the documented fill directions
|
||||
/// (reverse-fill is an Era extension, not modeled yet).
|
||||
|
||||
@@ -65,6 +65,10 @@ pub struct LootRow {
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LootState {
|
||||
pub rows: Vec<LootRow>,
|
||||
/// Whether this loot came from fishing (`IsFishingLoot()` — the wire `SMSG_LOOT_RESPONSE`
|
||||
/// `loot_type == 3`, decision 1086). `LootFrame_OnShow` keys the "FISHING REEL IN" sound and
|
||||
/// the FishingLoot portrait overlay on it (`LootFrame.lua:137-140`).
|
||||
pub fishing: bool,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
@@ -188,6 +192,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// IsFishingLoot() → whether the open loot came from fishing (false when none is open).
|
||||
// `LootFrame_OnShow` keys the reel-in sound + the fishing portrait overlay on it
|
||||
// (`LootFrame.lua:137-140`; decision 1086).
|
||||
g.set(
|
||||
"IsFishingLoot",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.loot.as_ref().is_some_and(|l| l.fishing))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// LootSlot(slot) — queue the 1-based row pick; the app maps it to the coin or the item wire slot.
|
||||
g.set(
|
||||
"LootSlot",
|
||||
@@ -252,6 +267,7 @@ mod tests {
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
fishing: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +316,15 @@ mod tests {
|
||||
assert!(s.eval::<bool>("return GetLootSlotLink(1) == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return GetLootSlotLink(3) == nil").unwrap());
|
||||
|
||||
// IsFishingLoot: false on an ordinary loot, true when the snapshot says fishing, false
|
||||
// again with no loot open (decision 1086).
|
||||
assert!(s.eval::<bool>("return not IsFishingLoot()").unwrap());
|
||||
let mut fished = loot();
|
||||
fished.fishing = true;
|
||||
s.set_loot(Some(fished));
|
||||
assert!(s.eval::<bool>("return IsFishingLoot()").unwrap());
|
||||
s.set_loot(Some(loot()));
|
||||
|
||||
// Out of range → nil.
|
||||
assert!(s.eval::<bool>("return GetLootSlotInfo(9) == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return GetLootSlotLink(9) == nil").unwrap());
|
||||
|
||||
@@ -702,14 +702,26 @@ pub(crate) struct Model {
|
||||
/// The player's rest snapshot, pushed together each frame any part moves
|
||||
/// ([`UiScript::set_rest_state`]) — player-globals like `money`. `rest_state` is the raw
|
||||
/// `PLAYER_BYTES_2` byte 3 (1 = rested, 2 = normal — the server writes it with hysteresis
|
||||
/// off the pool; `0` until the feed lands, read as normal), `rest_pool` the raw
|
||||
/// off the pool). **Defaults to 2, not 0**: every live 1.12 descriptor carries 2 from
|
||||
/// character creation on, and the tick's faithful FrameXML compares `GetRestState() >= 3`
|
||||
/// unguarded — a pre-feed 0 would render the binary's nil-triple fail path into an event
|
||||
/// handler the real client can only ever run with the byte present. Byte 0 stays reachable
|
||||
/// by explicit push, and the fail path stays faithful there. `rest_pool` is the raw
|
||||
/// `PLAYER_REST_STATE_EXPERIENCE` value in **base kill-XP units**, `resting` the
|
||||
/// `PLAYER_FLAGS_RESTING (0x20)` bit (inside an inn/city). `GetRestState`/`GetXPExhaustion`/
|
||||
/// `IsResting` read these; the ×2 display scaling of the pool lives in the `GetXPExhaustion`
|
||||
/// binding, where the real client applies it (decision 1082).
|
||||
/// `IsResting` read these; the pool's display scaling is `exhaustion` row 1's factor,
|
||||
/// applied in the `GetXPExhaustion` binding, where the real client applies it (decisions
|
||||
/// 1082/1087).
|
||||
pub(crate) rest_state: u8,
|
||||
pub(crate) rest_pool: u32,
|
||||
pub(crate) resting: bool,
|
||||
/// Exhaustion.dbc as the rest bindings consume it — rest-state byte → (localized name,
|
||||
/// factor), the table `GetRestState` indexes directly and whose row 1 scales
|
||||
/// `GetXPExhaustion` (wow-re rested-xp-bindings.md; decision 1087). Seeded with the shipped
|
||||
/// 5875 enUS rows so the engine tests and a failed DBC read behave like the shipped client
|
||||
/// (the GlobalStrings-fallback posture); the app overwrites it with the install's real —
|
||||
/// localized — rows at startup ([`UiScript::set_exhaustion_rows`]).
|
||||
pub(crate) exhaustion: HashMap<u8, (String, f64)>,
|
||||
|
||||
/// The paper doll's combat-stats snapshot (`None` until the app's feed lands), the
|
||||
/// equipment/ammo slot views, and the model pane's persistent bake yaw — the character-window
|
||||
@@ -1005,9 +1017,18 @@ impl Model {
|
||||
player_next_level_xp: 0,
|
||||
combo_points: 0,
|
||||
combo_target: 0,
|
||||
rest_state: 0,
|
||||
rest_state: 2,
|
||||
rest_pool: 0,
|
||||
resting: false,
|
||||
exhaustion: [
|
||||
(1, ("Rested".to_string(), 2.0)),
|
||||
(2, ("Normal".to_string(), 1.0)),
|
||||
(3, ("XXXTired".to_string(), 1.0)),
|
||||
(4, ("XXXTired".to_string(), 0.5)),
|
||||
(5, ("XXXExhausted".to_string(), 0.25)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
player_combat_stats: None,
|
||||
pet_combat_stats: None,
|
||||
inventory_slots: Default::default(),
|
||||
|
||||
@@ -61,7 +61,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.portrait_unit = Some(unit);
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
data.circular = true;
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -81,7 +81,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.portrait_unit = Some(token);
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
data.circular = false;
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -209,11 +209,28 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.region_data.entry(rh).or_default().color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
model.region_data.entry(rh).or_default().vertex_color =
|
||||
Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
// GetVertexColor — the setter's own pair, a real 5875 binding (`0x79aa50`, wow-re
|
||||
// `system/ui/ledger.tsv`; it sits directly above `SetVertexColor 0x79abd0` in the same region
|
||||
// method family). Never set = the untinted white every region draws at by default.
|
||||
m.set(
|
||||
"GetVertexColor",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let c = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.vertex_color)
|
||||
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||||
Ok((c[0], c[1], c[2], c[3]))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetTexture",
|
||||
lua.create_function(
|
||||
@@ -225,29 +242,35 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
// and any live-unit-portrait binding.
|
||||
data.circular = false;
|
||||
data.portrait_unit = None;
|
||||
// Both forms write the SAME `+0xcc` texture slot — the path form loads a file
|
||||
// there (`0x770200`), the colour form generates an 8×8 solid into it
|
||||
// (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at
|
||||
// `+0xb8`: a tint outlives the art it was tinting.
|
||||
match &arg {
|
||||
// SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state
|
||||
// art with the empty string (QuestLogFrame.lua:165-166).
|
||||
Value::String(s) if s.to_str()?.is_empty() => {
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
}
|
||||
Value::String(s) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
Value::Number(_) | Value::Integer(_) => {
|
||||
data.color = Some([
|
||||
data.fill = Some([
|
||||
as_f32(&arg),
|
||||
g.unwrap_or(0.0),
|
||||
b.unwrap_or(0.0),
|
||||
a.unwrap_or(1.0),
|
||||
]);
|
||||
data.texture = None;
|
||||
}
|
||||
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
|
||||
// texture region draws nothing.
|
||||
Value::Nil => {
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.fill = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -681,7 +704,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
d.outline = font.outline;
|
||||
d.font_shadow = font.shadow;
|
||||
if let Some(c) = font.color {
|
||||
d.color = Some(c);
|
||||
d.vertex_color = Some(c);
|
||||
}
|
||||
if let Some(j) = font.justify_h {
|
||||
d.justify_h = j;
|
||||
@@ -773,8 +796,9 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok((path, height, flags))
|
||||
})?,
|
||||
)?;
|
||||
// SetTextColor(r, g, b [, a]) — a FontString's text color (distinct name from SetVertexColor,
|
||||
// same storage in v1: the resolved text color the renderer draws with).
|
||||
// SetTextColor(r, g, b [, a]) — a FontString's text color. A different binding name for the
|
||||
// same `+0xb8` vertex-colour slot `SetVertexColor` writes: a FontString has no texel of its own
|
||||
// to multiply against, so its vertex colour IS the colour it draws.
|
||||
m.set(
|
||||
"SetTextColor",
|
||||
lua.create_function(
|
||||
@@ -785,7 +809,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
.region_data
|
||||
.entry(rh)
|
||||
.or_default()
|
||||
.color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
|
||||
@@ -9,32 +9,47 @@
|
||||
//! id doing double duty as both the row's identity and the group key (here the GROUP key is the
|
||||
//! category, the row's identity is the skill id).
|
||||
//!
|
||||
//! ## The engine grouping law (INTERIM — the 0437 §5 dispatch resolved (decision 0446), but never
|
||||
//! adjudicated this law; it stays unpinned, its own follow-up named in decision 0530)
|
||||
//! ## The engine grouping law (PINNED at the bytes — decision 1091; wow-re
|
||||
//! `system/tradeskill/scratch/skillframe-display-list.md`, the list build `0x4d2cb0` + its
|
||||
//! comparator `0x4d3070`. The 0530 follow-up this once carried is closed.)
|
||||
//!
|
||||
//! Groups ordered by `category_order` ascending (`category_id` breaks a tie, for determinism);
|
||||
//! one header row per non-empty group (text = `category_name`); entries within a group sorted by
|
||||
//! name ascending (the trainer's own [`collate`] — case-insensitive, raw-byte tie-break). Every
|
||||
//! group starts EXPANDED (the trainer's own default-expanded rule). Visible rows are headers
|
||||
//! always, plus the entries of expanded groups; every index the Lua API takes/returns is 1-based
|
||||
//! into that visible list.
|
||||
//! one header row per non-empty group (text = `category_name`); within a group, **untrained lines
|
||||
//! (rank 0) sink below trained ones**, then name ascending ([`collate`] — case-insensitive,
|
||||
//! raw-byte tie-break; the client's own `stricmp`). Every group starts EXPANDED and is **re-expanded
|
||||
//! on every push** ([`UiScript::set_skills`] — the client's own `expandedMask = 0xFFFFFFFF`).
|
||||
//! Visible rows are headers always, plus the entries of expanded groups; every index the Lua API
|
||||
//! takes/returns is 1-based into that visible list.
|
||||
//!
|
||||
//! **Which lines reach the engine at all is the app's half** of the same law (`ui_char.rs`'s
|
||||
//! `feed_skills`): the client's list build drops a line with no `SkillLine`/`SkillRaceClassInfo`/
|
||||
//! `SkillLineCategory` row, one whose `SkillRaceClassInfo.flags` carries `0x2`, and an untrained
|
||||
//! one that no flag admits. The engine sorts what survives.
|
||||
//!
|
||||
//! ## The Era API shape (matched to the real `SkillFrame.lua`, transcribed onto this engine)
|
||||
//!
|
||||
//! `GetNumSkillLines()` → the visible row count. `GetSkillLineInfo(i)` → the ref's own 13-tuple
|
||||
//! `GetNumSkillLines()` → the visible row count. `GetSkillLineInfo(i)` → the ref's own tuple
|
||||
//! (`name, isHeader, isExpanded, skillRank, numTempPoints, skillModifier, skillMaxRank,
|
||||
//! isAbandonable, stepCost, rankCost, minLevel, skillCostType, skillDescription`): a header row
|
||||
//! shapes `(category_name, 1, expanded, 0, 0, 0, 0, nil, 0, 0, 0, nil, nil)`, an entry row shapes
|
||||
//! `(name, nil, nil, value, 0, modifier, max, abandonable, 0, 0, 0, nil, description)`.
|
||||
//! `numTempPoints` is always `0` (1.12 training points are dead data — `PLAYER_SKILL_INFO`
|
||||
//! carries no temp/perm split the client would animate through a "buy a rank" flow);
|
||||
//! `skillDescription` is REAL (`SkillLine.dbc` col 12 through the app feed — the detail pane's
|
||||
//! body text); `isAbandonable` is REAL too ([`SkillEntry::abandonable`], the unlearn button's
|
||||
//! gate — and `AbandonSkill(i)` is its outbound half, a VISIBLE index queued out by skill id for
|
||||
//! the app's `CMSG_UNLEARN_SKILL`, [`UiScript::take_skill_abandons`]); the remaining
|
||||
//! `stepCost`/`rankCost`/`minLevel`/`skillCostType` are vestigial stubs backing the ref's
|
||||
//! *training-up* branches (a trainer-taught skill step), which never apply to a line that only
|
||||
//! ever changes as a server descriptor delta.
|
||||
//! isAbandonable, stepCost, rankCost, minLevel, skillCostType, skillDescription`) — **13 values on
|
||||
//! a skill row, 12 on a header** (`0x4d3a20` vs `0x4d3768`; a header stops after `skillCostType`
|
||||
//! and carries no description slot at all). A header shapes
|
||||
//! `(category_name, 1, expanded, 0, 0, 0, 0, nil, nil, nil, 0, 0)`, an entry
|
||||
//! `(name, nil, nil, rank, 0, tempBonus, max, abandonable, nil, nil, minLevel, costIndex+1,
|
||||
//! description)`, with `rank`/`max` computed by [`displayed_ranks`] — the permanent bonus folded
|
||||
//! in, then `max` forced to `1` on a **single-rank** line whatever the server said
|
||||
//! ([`SkillEntry::mono`]; the pane's proficiency gate is `skillMaxRank == 1`).
|
||||
//!
|
||||
//! `numTempPoints` is always `0`: its only writer in the real client is `AddSkillUp`, wired solely
|
||||
//! to the training-up arrow this pane doesn't ship. `stepCost`/`rankCost` are always **nil** —
|
||||
//! for DATA reasons, not code ones (`SkillLine.skillCostsID` is 0 in all 123 rows, and the
|
||||
//! step-cost gate's flag bits are set on no line a player can hold) — and nil, not `0`, is what
|
||||
//! the ref's `if (stepCost)` / `elseif (rankCost or …)` branches read, since `0` is truthy in Lua.
|
||||
//! `minLevel` and `skillCostType` are REAL numbers off the `SkillRaceClassInfo` row (visually inert
|
||||
//! on this build: every branch they colour is repainted by the normal-skill/proficiency branches
|
||||
//! after it). `skillDescription` is REAL (`SkillLine.dbc` col 12 through the app feed — the detail
|
||||
//! pane's body text); `isAbandonable` is REAL too ([`SkillEntry::abandonable`], the unlearn
|
||||
//! button's gate — and `AbandonSkill(i)` is its outbound half, a VISIBLE index queued out by skill
|
||||
//! id for the app's `CMSG_UNLEARN_SKILL`, [`UiScript::take_skill_abandons`]).
|
||||
//!
|
||||
//! `ExpandSkillHeader(i)`/`CollapseSkillHeader(i)` take a header's VISIBLE index (`0` = all
|
||||
//! groups, the trainer's own collapse-all shape). `SetSelectedSkill(i)`/`GetSelectedSkill()` are a
|
||||
@@ -50,7 +65,7 @@
|
||||
//! sites nothing in [`SkillFrame.xml`] ever reaches. (The `UNLEARN_SKILL` popup, once in that
|
||||
//! list, ships for real now — the abandon slice above.)
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
@@ -63,12 +78,38 @@ pub struct SkillEntry {
|
||||
pub skill_id: u32,
|
||||
/// `SkillLine.dbc` name ("First Aid").
|
||||
pub name: String,
|
||||
/// Current rank.
|
||||
/// Current rank, raw off the descriptor. `0` = **untrained**, which is a sort key of its own
|
||||
/// (untrained lines sink under their category's trained ones — the client's comparator
|
||||
/// `0x4d3070`).
|
||||
pub value: u32,
|
||||
/// Max rank (a 1.12 proficiency line can be `0/0` — render barless, the ref's own shape).
|
||||
/// Max rank, as the server's own `PLAYER_SKILL_INFO` descriptor holds it — see [`Self::mono`]
|
||||
/// for the DBC override the API return goes through.
|
||||
pub max: u32,
|
||||
/// Temp+perm bonus (the green "+n"; negative possible).
|
||||
pub modifier: i32,
|
||||
/// `SkillRaceClassInfo.flags & 0x400` (`SKILL_FLAG_MONO_VALUE`) for the player's race/class:
|
||||
/// a **single-rank** line. The real client's `GetSkillLineInfo` overrides `skillMaxRank` to
|
||||
/// `1` for these, whatever the descriptor says (wow-re
|
||||
/// `system/tradeskill/scratch/skillframe-seed-abandon.md`: `0x4d3610`, `4d38b1 test ah,0x4`) —
|
||||
/// so a hunter's `Beast Mastery`, which vmangos happily reports as `300/300`, reads as a
|
||||
/// proficiency and `SkillFrame.lua`'s `skillMaxRank == 1` branch draws it gray with no rank
|
||||
/// text. The raw [`Self::max`] stays untouched here; the override lives at the API return,
|
||||
/// exactly where the binary puts it.
|
||||
pub mono: bool,
|
||||
/// The **temporary** bonus (auras/consumables/enchants; negative possible) — and only that:
|
||||
/// `GetSkillLineInfo`'s `skillModifier` is a signed read of the descriptor's temp half alone
|
||||
/// (`+0x850`), the green "+n" in the rank text. The permanent half is [`Self::perm_bonus`],
|
||||
/// which the client folds into the numbers instead.
|
||||
pub temp_bonus: i32,
|
||||
/// The **permanent** bonus (talents; `+0x852`, negative possible). Not a return of its own:
|
||||
/// the client adds it into BOTH `skillRank` and `skillMaxRank` before the mono override
|
||||
/// (`0x4d380c`/`0x4d385d`), and only when the raw value is nonzero — so a line the player
|
||||
/// doesn't have stays at a flat `0`, never `0 + bonus`.
|
||||
pub perm_bonus: i32,
|
||||
/// `SkillRaceClassInfo.reqLevel` — `GetSkillLineInfo`'s 11th return, pushed as a real number
|
||||
/// (`0x4d39ef`; nonzero in practice: Mail 40, Dual Wield 20).
|
||||
pub min_level: u32,
|
||||
/// `SkillRaceClassInfo.skillCostID` — `GetSkillLineInfo`'s 12th return is this **plus one**
|
||||
/// (`0x4d3a06`), which is why the engine adds the 1, not the feed.
|
||||
pub cost_index: u32,
|
||||
/// `SkillLine.dbc` categoryId.
|
||||
pub category_id: u32,
|
||||
/// Resolved category name ("Professions") — the header text.
|
||||
@@ -80,9 +121,10 @@ pub struct SkillEntry {
|
||||
/// format renders it verbatim).
|
||||
pub description: String,
|
||||
/// Whether the line can be unlearned — `GetSkillLineInfo`'s 8th return (`isAbandonable`), the
|
||||
/// detail pane's unlearn-button gate. App-resolved from `SkillRaceClassInfo.flags & 0x20`
|
||||
/// detail pane's unlearn-button gate. App-resolved, and a **conjunction**: the descriptor's
|
||||
/// skill **step** must be nonzero AND `SkillRaceClassInfo.flags & 0x20` must be set
|
||||
/// (`SKILL_FLAG_UNLEARNABLE` — the server's own `CMSG_UNLEARN_SKILL` gate, vmangos
|
||||
/// `SkillHandler.cpp`).
|
||||
/// `SkillHandler.cpp`). Both halves are the client's (`0x4d3953`–`0x4d3975`).
|
||||
pub abandonable: bool,
|
||||
}
|
||||
|
||||
@@ -110,15 +152,18 @@ pub(crate) struct SkillGroup {
|
||||
|
||||
impl super::UiScript {
|
||||
/// Replace the skills snapshot (0437 phase 4). Builds the display tree ([`build_groups`]) from
|
||||
/// the flat entries, prunes the collapsed set to categories that still exist in the new push
|
||||
/// (the trainer's own collapse-survives-an-update rule, [`super::trainer::UiScript::set_trainer`]),
|
||||
/// and re-anchors the selection to the SAME skill id if it's still present — else clears it
|
||||
/// (the tradeskill's own by-spell-id selection-persistence precedent).
|
||||
/// the flat entries, **re-expands every group**, and re-anchors the selection to the SAME
|
||||
/// skill id if it's still present — else clears it (the tradeskill's own by-spell-id
|
||||
/// selection-persistence precedent).
|
||||
///
|
||||
/// The re-expand is the client's, not a convenience: its list rebuild writes
|
||||
/// `expandedMask = 0xFFFFFFFF` unconditionally (`0x4d2cb0`, store at `0x4d2ce2`), so a fold
|
||||
/// survives only until the next skill-field change — the trainer's collapse-survives-an-update
|
||||
/// rule this pane once borrowed is simply a different window's law (decision 1091).
|
||||
pub fn set_skills(&mut self, state: SkillsState) {
|
||||
let mut model = self.model_mut();
|
||||
let groups = build_groups(&state.entries);
|
||||
let live: HashSet<u32> = groups.iter().map(|g| g.category_id).collect();
|
||||
model.skills_collapsed.retain(|c| live.contains(c));
|
||||
model.skills_collapsed.clear();
|
||||
if let Some(sid) = model.skills_selected {
|
||||
if !state.entries.iter().any(|e| e.skill_id == sid) {
|
||||
model.skills_selected = None;
|
||||
@@ -171,8 +216,14 @@ fn build_groups(entries: &[SkillEntry]) -> Vec<SkillGroup> {
|
||||
}
|
||||
let mut groups: Vec<SkillGroup> = map.into_values().collect();
|
||||
for g in &mut groups {
|
||||
g.entries
|
||||
.sort_by(|&a, &b| collate(&entries[a].name, &entries[b].name));
|
||||
// Within a category the client sorts UNTRAINED (rank 0) lines below trained ones, then by
|
||||
// name (`0x4d3070`: the `untrained` byte set at build time, `0x4d2e19 sete`, compared
|
||||
// before the `stricmp` at `0x4d318d`).
|
||||
g.entries.sort_by(|&a, &b| {
|
||||
(entries[a].value == 0)
|
||||
.cmp(&(entries[b].value == 0))
|
||||
.then_with(|| collate(&entries[a].name, &entries[b].name))
|
||||
});
|
||||
}
|
||||
groups.sort_by(|a, b| {
|
||||
a.order
|
||||
@@ -261,6 +312,35 @@ fn selected_index(model: &Model) -> u32 {
|
||||
.map_or(0, |p| (p + 1) as u32)
|
||||
}
|
||||
|
||||
/// `GetSkillLineInfo`'s `(skillRank, skillMaxRank)` pair for one entry — the client's own
|
||||
/// arithmetic, in its own order (`0x4d380c`–`0x4d38cb`):
|
||||
///
|
||||
/// ```text
|
||||
/// skillRank = rank > 0 ? rank + permBonus : rank // a line at 0 stays flat 0
|
||||
/// skillMaxRank = max > 0 ? max + permBonus : max
|
||||
/// if mono { skillMaxRank = 1; if skillRank > 1 { skillRank = 1 } } // override, then a MIN
|
||||
/// ```
|
||||
///
|
||||
/// The mono arm is an unconditional **override** of the max (not a clamp) and a **min** on the
|
||||
/// rank — the asymmetry is the binary's, and it is what makes a `300/300` class line read as the
|
||||
/// `1/1` proficiency `SkillFrame.lua` draws gray. Saturating at 0 keeps a negative permanent
|
||||
/// malus from wrapping the unsigned descriptor values.
|
||||
fn displayed_ranks(e: &SkillEntry) -> (i64, i64) {
|
||||
let fold = |v: u32| {
|
||||
if v > 0 {
|
||||
(i64::from(v) + i64::from(e.perm_bonus)).max(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let (mut rank, mut max) = (fold(e.value), fold(e.max));
|
||||
if e.mono {
|
||||
max = 1;
|
||||
rank = rank.min(1);
|
||||
}
|
||||
(rank, max)
|
||||
}
|
||||
|
||||
/// A `bool` as the Era `1`/`nil` shape (the trainer/tradeskill's own helper, duplicated per the
|
||||
/// established per-module convention).
|
||||
fn era_bool(b: bool) -> Value {
|
||||
@@ -284,8 +364,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetSkillLineInfo(index) → the ref's own 13-tuple (module doc). `index` 1-based into the
|
||||
// visible tree; out of range → a single nil.
|
||||
// GetSkillLineInfo(index) → the ref's own tuple (module doc): 13 values on a skill row
|
||||
// (`0x4d3a20`), 12 on a header (`0x4d3768`). `index` 1-based into the visible tree; out of
|
||||
// range → a single nil.
|
||||
g.set(
|
||||
"GetSkillLineInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -309,28 +390,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Value::Integer(0), // skillModifier
|
||||
Value::Integer(0), // skillMaxRank
|
||||
Value::Nil, // isAbandonable
|
||||
Value::Integer(0), // stepCost
|
||||
Value::Integer(0), // rankCost
|
||||
Value::Nil, // stepCost
|
||||
Value::Nil, // rankCost
|
||||
Value::Integer(0), // minLevel
|
||||
Value::Nil, // skillCostType
|
||||
Value::Nil, // skillDescription
|
||||
Value::Integer(0), // skillCostType — the 12th and LAST header return
|
||||
]))
|
||||
}
|
||||
Row::Entry(ei) => {
|
||||
let e = &model.skills.entries[ei];
|
||||
let (rank, max) = displayed_ranks(e);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&e.name)?),
|
||||
Value::Nil, // isHeader
|
||||
Value::Nil, // isExpanded
|
||||
Value::Integer(i64::from(e.value)),
|
||||
Value::Integer(rank),
|
||||
Value::Integer(0), // numTempPoints — always 0 (module doc)
|
||||
Value::Integer(i64::from(e.modifier)),
|
||||
Value::Integer(i64::from(e.max)),
|
||||
Value::Integer(i64::from(e.temp_bonus)), // skillModifier — TEMP only
|
||||
Value::Integer(max),
|
||||
era_bool(e.abandonable), // isAbandonable
|
||||
Value::Integer(0), // stepCost
|
||||
Value::Integer(0), // rankCost
|
||||
Value::Integer(0), // minLevel
|
||||
Value::Nil, // skillCostType
|
||||
// stepCost/rankCost are nil on this build for DATA reasons, not code
|
||||
// ones (module doc) — and nil is load-bearing: `0` is truthy in Lua, so
|
||||
// returning it would send SkillFrame.lua down its "learnable skill"
|
||||
// branch on every row.
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
Value::Integer(i64::from(e.min_level)), // minLevel — a real number
|
||||
// skillCostType — SkillCostIndex + 1, the client's own `0x4d3a06`.
|
||||
Value::Integer(i64::from(e.cost_index) + 1),
|
||||
Value::String(lua.create_string(&e.description)?), // skillDescription
|
||||
]))
|
||||
}
|
||||
@@ -412,7 +498,7 @@ mod tests {
|
||||
name: &str,
|
||||
value: u32,
|
||||
max: u32,
|
||||
modifier: i32,
|
||||
temp_bonus: i32,
|
||||
category_id: u32,
|
||||
category_name: &str,
|
||||
category_order: u32,
|
||||
@@ -422,7 +508,10 @@ mod tests {
|
||||
name: name.into(),
|
||||
value,
|
||||
max,
|
||||
modifier,
|
||||
temp_bonus,
|
||||
perm_bonus: 0,
|
||||
min_level: 0,
|
||||
cost_index: 0,
|
||||
category_id,
|
||||
category_name: category_name.into(),
|
||||
category_order,
|
||||
@@ -430,6 +519,9 @@ mod tests {
|
||||
// Fixture rule: the Professions category (id 2) is abandonable, weapons are not —
|
||||
// the real SkillRaceClassInfo 0x20 split's shape.
|
||||
abandonable: category_id == 2,
|
||||
// Fixture rule: the Class Skills category (id 3) is single-rank, the real 0x400
|
||||
// split's shape (a hunter's Beast Mastery).
|
||||
mono: category_id == 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,8 +633,10 @@ mod tests {
|
||||
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 6);
|
||||
}
|
||||
|
||||
/// A re-push keeps the SELECTION (by skill id) but throws every fold away — the client's list
|
||||
/// rebuild re-expands unconditionally (`expandedMask = 0xFFFFFFFF`, decision 1091).
|
||||
#[test]
|
||||
fn selection_and_expansion_persist_across_a_repush() {
|
||||
fn a_repush_keeps_the_selection_and_re_expands_every_group() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_skills(state());
|
||||
|
||||
@@ -556,12 +650,12 @@ mod tests {
|
||||
s.run("CollapseSkillHeader(4)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 4);
|
||||
|
||||
// A re-push (values ticked up) keeps BOTH the fold and the selection's skill identity —
|
||||
// Swords is still row 3, Professions is still collapsed.
|
||||
// A re-push (values ticked up): the fold is GONE — all 6 rows are back — while the
|
||||
// selection still points at Swords, which is still row 3.
|
||||
let mut ticked = state();
|
||||
ticked.entries[0].value = 201; // Swords
|
||||
s.set_skills(ticked);
|
||||
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 4);
|
||||
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 6);
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedSkill()").unwrap(), 3);
|
||||
let (name, rank) = s
|
||||
.eval::<(String, i64)>("local n,_,_,r = GetSkillLineInfo(3) return n,r")
|
||||
@@ -580,7 +674,8 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_skills(state());
|
||||
|
||||
// Header row 1 ("Weapon Skills"): (name, 1, expanded, 0, 0, 0, 0, nil, 0, 0, 0, nil, nil).
|
||||
// Header row 1 ("Weapon Skills"): 12 values — (name, 1, expanded, 0, 0, 0, 0, nil, nil,
|
||||
// nil, 0, 0), and NO 13th (the client's header path pushes 12, `0x4d3768`).
|
||||
let (name, is_header, is_expanded, rank, temp, modifier, max) = s
|
||||
.eval::<(String, i64, Option<i64>, i64, i64, i64, i64)>(
|
||||
"local n,h,e,r,t,m,mx = GetSkillLineInfo(1) return n,h,e,r,t,m,mx",
|
||||
@@ -598,19 +693,22 @@ mod tests {
|
||||
),
|
||||
("Weapon Skills", 1, Some(1), 0, 0, 0, 0)
|
||||
);
|
||||
let (abandon_nil, step, rank_cost, min_level, cost_type_nil, desc_nil) = s
|
||||
.eval::<(bool, i64, i64, i64, bool, bool)>(
|
||||
"local _,_,_,_,_,_,_,a,st,rc,ml,ct,d = GetSkillLineInfo(1) \
|
||||
return a==nil, st, rc, ml, ct==nil, d==nil",
|
||||
let (abandon_nil, step_nil, rank_cost_nil, min_level, cost_type, count) = s
|
||||
.eval::<(bool, bool, bool, i64, i64, i64)>(
|
||||
"local a,st,rc,ml,ct = select(8, GetSkillLineInfo(1)) \
|
||||
return a==nil, st==nil, rc==nil, ml, ct, select('#', GetSkillLineInfo(1))",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(abandon_nil);
|
||||
assert_eq!((step, rank_cost, min_level), (0, 0, 0));
|
||||
assert!(cost_type_nil);
|
||||
assert!(desc_nil);
|
||||
assert!(
|
||||
step_nil && rank_cost_nil,
|
||||
"stepCost/rankCost are nil, not 0"
|
||||
);
|
||||
assert_eq!((min_level, cost_type), (0, 0));
|
||||
assert_eq!(count, 12, "a header row returns 12 values, not 13");
|
||||
|
||||
// Entry row 2 ("Defense", value 180, max 300, modifier +5): (name, nil, nil, 180, 0, 5,
|
||||
// 300, nil, 0, 0, 0, nil, nil).
|
||||
// Entry row 2 ("Defense", value 180, max 300, temp bonus +5): (name, nil, nil, 180, 0, 5,
|
||||
// 300, nil, nil, nil, 0, 1, description) — 13 values.
|
||||
let (name, is_header, is_expanded, rank, temp, modifier, max) = s
|
||||
.eval::<(String, Option<i64>, Option<i64>, i64, i64, i64, i64)>(
|
||||
"local n,h,e,r,t,m,mx = GetSkillLineInfo(2) return n,h,e,r,t,m,mx",
|
||||
@@ -628,12 +726,103 @@ mod tests {
|
||||
),
|
||||
("Defense", None, None, 180, 0, 5, 300)
|
||||
);
|
||||
// The 13th return is the REAL description now (SkillLine.dbc col 12 through the feed) —
|
||||
// a string on an entry row, still nil on a header (asserted above).
|
||||
// The 13th return is the REAL description (SkillLine.dbc col 12 through the feed) — an
|
||||
// entry row's alone: a header stops at 12 (asserted above).
|
||||
let (count, desc) = s
|
||||
.eval::<(i64, String)>(
|
||||
"return select('#', GetSkillLineInfo(2)), select(13, GetSkillLineInfo(2))",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((count, desc.as_str()), (13, "About Defense."));
|
||||
// minLevel and skillCostType are real NUMBERS on an entry — and the cost type is the
|
||||
// row's index PLUS ONE (the client's own `0x4d3a06`), so a fixture at index 0 reads 1.
|
||||
let (min_level, cost_type) = s
|
||||
.eval::<(i64, i64)>("local ml,ct = select(11, GetSkillLineInfo(2)) return ml,ct")
|
||||
.unwrap();
|
||||
assert_eq!((min_level, cost_type), (0, 1));
|
||||
}
|
||||
|
||||
/// The permanent bonus folds into BOTH numbers, the temporary one is the `skillModifier`
|
||||
/// return on its own, and a line at 0 stays flat 0 (the client's `rank > 0 ?` guard).
|
||||
#[test]
|
||||
fn the_permanent_bonus_folds_into_the_numbers_and_the_temporary_one_does_not() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
st.entries[1].perm_bonus = 10; // Defense 180/300, temp +5
|
||||
st.entries
|
||||
.push(entry(182, "Herbalism", 0, 0, 0, 2, "Professions", 2));
|
||||
st.entries.last_mut().unwrap().perm_bonus = 10;
|
||||
s.set_skills(st);
|
||||
|
||||
// Defense (row 2): 190/310, modifier still the temp +5 alone.
|
||||
let (rank, modifier, max) = s
|
||||
.eval::<(i64, i64, i64)>("local _,_,_,r,_,m,mx = GetSkillLineInfo(2) return r,m,mx")
|
||||
.unwrap();
|
||||
assert_eq!((rank, modifier, max), (190, 5, 310));
|
||||
|
||||
// Herbalism 0/0 (row 7, an untrained line sorted under the trained ones): a flat 0/0 —
|
||||
// the bonus is not added to a zero.
|
||||
let (name, rank, max) = s
|
||||
.eval::<(String, i64, i64)>("local n,_,_,r,_,_,mx = GetSkillLineInfo(7) return n,r,mx")
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), rank, max), ("Herbalism", 0, 0));
|
||||
}
|
||||
|
||||
/// Within a category, untrained (rank 0) lines sink below the trained ones, whatever their
|
||||
/// name — the client's comparator (`0x4d3070`), which tests `untrained` before `stricmp`.
|
||||
#[test]
|
||||
fn untrained_lines_sort_under_the_trained_ones_of_their_category() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
// "Alchemy" would sort first in Professions by name; at rank 0 it goes last.
|
||||
st.entries
|
||||
.push(entry(171, "Alchemy", 0, 300, 0, 2, "Professions", 2));
|
||||
s.set_skills(st);
|
||||
let names: Vec<String> = (5..=7)
|
||||
.map(|i| {
|
||||
s.eval::<String>(&format!("return (GetSkillLineInfo({i}))"))
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, ["First Aid", "Fishing", "Alchemy"]);
|
||||
}
|
||||
|
||||
/// A single-rank line reports `1/1` however high the server's descriptor is (the client's own
|
||||
/// `SkillRaceClassInfo.flags & 0x400` arm: an override on the max, a min on the rank) — the
|
||||
/// pane's proficiency gate. A normal line is unaffected.
|
||||
#[test]
|
||||
fn a_mono_line_reports_max_rank_one_whatever_the_server_said() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut st = state();
|
||||
// Beast Mastery: category 3 ⇒ mono by the fixture rule, server-side 300/300.
|
||||
st.entries.push(entry(
|
||||
50,
|
||||
"Beast Mastery",
|
||||
300,
|
||||
300,
|
||||
0,
|
||||
3,
|
||||
"Class Skills",
|
||||
0,
|
||||
));
|
||||
s.set_skills(st);
|
||||
|
||||
// Row 1/2 = the Class Skills header + its single entry (category_order 0 sorts first).
|
||||
let (name, rank, modifier, max) = s
|
||||
.eval::<(String, i64, i64, i64)>(
|
||||
"local n,_,_,r,_,m,mx = GetSkillLineInfo(2) return n,r,m,mx",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return select(13, GetSkillLineInfo(2))")
|
||||
(name.as_str(), rank, modifier, max),
|
||||
("Beast Mastery", 1, 0, 1),
|
||||
"the descriptor's 300/300 reads 1/1 — max overridden, rank clamped under it"
|
||||
);
|
||||
// Defense (a weapon line, not mono) still reports its real 300.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return (select(7, GetSkillLineInfo(4)))")
|
||||
.unwrap(),
|
||||
"About Defense."
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -200,9 +200,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
match &a1 {
|
||||
Value::String(s) => data.texture = Some(s.to_str()?.to_string()),
|
||||
Value::String(s) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
// A solid thumb writes the same slot the path form does — each clears the other.
|
||||
Value::Number(_) | Value::Integer(_) => {
|
||||
data.color = Some([
|
||||
data.texture = None;
|
||||
data.fill = Some([
|
||||
num_f32(&a1),
|
||||
num_f32(&a2),
|
||||
num_f32(&a3),
|
||||
|
||||
@@ -172,9 +172,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
match &a1 {
|
||||
Value::String(s) => data.texture = Some(s.to_str()?.to_string()),
|
||||
Value::String(s) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
// A solid bar writes the same slot the path form does — each clears the other.
|
||||
// `SetStatusBarColor` is the TINT (the fill region's vertex colour) and is a
|
||||
// separate slot entirely: a bar can carry art AND a colour, and they multiply.
|
||||
Value::Number(_) | Value::Integer(_) => {
|
||||
data.color = Some([
|
||||
data.texture = None;
|
||||
data.fill = Some([
|
||||
num_f32(&a1),
|
||||
num_f32(&a2),
|
||||
num_f32(&a3),
|
||||
@@ -211,7 +218,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let id = ensure_bar(lua, &this, None)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let rh = *model.id_to_region.get(&id).expect("bar region id");
|
||||
model.region_data.entry(rh).or_default().color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
model.region_data.entry(rh).or_default().vertex_color =
|
||||
Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
@@ -223,7 +231,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let c = bar
|
||||
.and_then(|rh| model.region_data.get(&rh))
|
||||
.and_then(|d| d.color)
|
||||
.and_then(|d| d.vertex_color)
|
||||
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||||
Ok((c[0], c[1], c[2], c[3]))
|
||||
})?,
|
||||
|
||||
@@ -450,3 +450,79 @@ fn region_alpha_is_its_own_and_multiplies_the_owner_frames() {
|
||||
"a hidden region emits no quad"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The texture-colour composition law** (wow-re `system/ui/scratch/texture-color-composition.md`,
|
||||
/// VERIFIED): a region's own solid colour is a real *texel* (`SetTexture(r,g,b,a)` generates an 8×8
|
||||
/// block at `+0xcc`), its vertex colour is a *separate* slot (`+0xb8`), and the draw
|
||||
/// **multiplies** them per channel, alpha included — it does not replace.
|
||||
///
|
||||
/// This is the reference `SkillFrame` row trough, verbatim: declared `<Color 1,1,1,0.2>`, then
|
||||
/// `SetVertexColor(0, 0, 0.75, 0.5)`'d. It draws at alpha `0.2 × 0.5 = 0.1`. benilla stored ONE
|
||||
/// colour slot until this test, which made the second call replace the first and drew it at 0.5.
|
||||
#[test]
|
||||
fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
f:SetWidth(100) f:SetHeight(50)
|
||||
trough = f:CreateTexture("Trough", "BACKGROUND")
|
||||
trough:SetTexture(1, 1, 1, 0.2)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
let solid = |s: &UiScript| {
|
||||
s.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: None, color, ..
|
||||
} => Some(*color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("solid-colour quad")
|
||||
};
|
||||
assert_eq!(
|
||||
solid(&s),
|
||||
Some([1.0, 1.0, 1.0, 0.2]),
|
||||
"untinted, the texel draws as declared"
|
||||
);
|
||||
|
||||
s.run("trough:SetVertexColor(0, 0, 0.75, 0.5)").unwrap();
|
||||
assert_eq!(
|
||||
solid(&s),
|
||||
Some([0.0, 0.0, 0.75, 0.1]),
|
||||
"texel x vertex, alpha included: 0.2 x 0.5 = 0.1, NOT 0.5"
|
||||
);
|
||||
// The API readback is the vertex slot itself, not the product — the two are distinct storage.
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return trough:GetVertexColor()")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 0.75, 0.5)
|
||||
);
|
||||
|
||||
// Art and a solid colour share the `+0xcc` slot: setting a path releases the generated texel,
|
||||
// and what is left is the tint alone for the renderer to modulate the sample by.
|
||||
s.run(r#"trough:SetTexture("Interface\\Bar.blp")"#).unwrap();
|
||||
let tinted = s
|
||||
.extract()
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(p),
|
||||
color,
|
||||
..
|
||||
} if p.contains("Bar") => Some(*color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("art quad");
|
||||
assert_eq!(
|
||||
tinted,
|
||||
Some([0.0, 0.0, 0.75, 0.5]),
|
||||
"the vertex colour outlives the texel it was multiplying"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ fn apply_font(d: &mut RegionData, name: &str, fo: Option<&FontObject>) {
|
||||
d.outline = f.outline;
|
||||
d.font_shadow = f.shadow;
|
||||
if let Some(c) = f.color {
|
||||
d.color = Some(c);
|
||||
d.vertex_color = Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
|
||||
d.font_height = src.font_height;
|
||||
d.outline = src.outline;
|
||||
d.font_shadow = src.font_shadow;
|
||||
d.color = src.color;
|
||||
d.vertex_color = src.vertex_color;
|
||||
}
|
||||
None => {
|
||||
let font = if i == 1 {
|
||||
@@ -262,7 +262,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
|
||||
fn write_cell(model: &mut Model, rh: crate::widget::RegionHandle, text: &str, color: [f32; 4]) {
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.text = Some(text.to_string());
|
||||
d.color = Some(color);
|
||||
d.vertex_color = Some(color);
|
||||
d.hidden = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -314,16 +314,35 @@ pub(crate) struct RegionData {
|
||||
/// the owner's `+0xc8` at draw). [`UiScript::extract`](super::UiScript::extract) folds this into
|
||||
/// [`ExtractedQuad::alpha`] alongside that owner alpha.
|
||||
///
|
||||
/// **Open (wow-re gap):** the findings verify a two-factor draw multiply
|
||||
/// (`ownColorAlpha × frameAlpha`) but never pin whether `SetAlpha` writes a *distinct* field or
|
||||
/// simply the alpha channel of [`Self::color`]. We keep it distinct so `SetAlpha` on a
|
||||
/// FontString cannot clobber its font object's color. The two readings agree on every reference
|
||||
/// call site (none sets both on one region); a site that does is the trigger to settle it.
|
||||
/// **Settled for a Texture, kept distinct anyway:** `Texture:SetAlpha 0x79b580` is
|
||||
/// `SetVertexColor(r, g, b, a)` on the *same* `+0xa8`/`+0xb8` slot as [`Self::vertex_color`], so
|
||||
/// in the real client the two clobber each other. We keep them separate fields because the draw
|
||||
/// result is identical either way — both readings end in one product,
|
||||
/// `texel × ownColorAlpha × frameAlpha` — and no reference call site sets both on one region;
|
||||
/// separate storage additionally keeps `SetAlpha` on a FontString from wiping its font object's
|
||||
/// colour, which the CSimpleFontString half of that binding was never pinned for. The
|
||||
/// divergence is now *known* rather than open: a call site that sets both is where it bites.
|
||||
pub(crate) alpha: Option<f32>,
|
||||
/// A texture path (`SetTexture("Interface\\...")`).
|
||||
pub(crate) texture: Option<String>,
|
||||
/// A color: a solid fill (`SetTexture(r,g,b,a)`) or a vertex tint (`SetVertexColor`).
|
||||
pub(crate) color: Option<[f32; 4]>,
|
||||
/// The **solid-colour texture** (`SetTexture(r, g, b, a)`, and XML `<Texture><Color/></Texture>`
|
||||
/// with no `file=`) — the `+0xcc` slot [`Self::texture`] also occupies, so the two are mutually
|
||||
/// exclusive by construction: every writer of one clears the other. Not a tint. The real client
|
||||
/// really does *generate an 8×8 texture* here (`CSimpleTexture::SetTexture(const CImVector*)`
|
||||
/// `0x770360` → `0x44a9c0` → the `rep stos` generator `0x5c5350` fills 64 texels with the packed
|
||||
/// ARGB), which is why the XML alpha rides in the **texel** and multiplies with
|
||||
/// [`Self::vertex_color`] rather than being replaced by it.
|
||||
pub(crate) fill: Option<[f32; 4]>,
|
||||
/// The **vertex colour** (`SetVertexColor 0x79abd0` → `0x77f750`, writing `+0xb8`; a StatusBar's
|
||||
/// `SetStatusBarColor`; a FontString's `SetTextColor` and its font object's colour) — storage
|
||||
/// distinct from [`Self::fill`]/[`Self::texture`]'s `+0xcc`.
|
||||
///
|
||||
/// **Draw law** (wow-re `system/ui/scratch/texture-color-composition.md`, VERIFIED):
|
||||
/// `drawn = texel × vertexColour`, per channel, **alpha included**. `None` = never set = the
|
||||
/// untinted white every region draws at by default. This is why the reference `SkillFrame`'s
|
||||
/// row trough — declared `<Color 1,1,1,0.2>`, then `SetVertexColor(0, 0, 0.75, 0.5)`'d — draws
|
||||
/// at alpha `0.2 × 0.5 = 0.1`, not `0.5`. [`super::extract`] does that multiply.
|
||||
pub(crate) vertex_color: Option<[f32; 4]>,
|
||||
/// This region is a **portrait**: draw its texture masked to the inscribed circle (set by
|
||||
/// `SetPortraitToTexture`, and by `SetPortraitTexture`'s round unit binding). WoW portraits
|
||||
/// are circular — the frame ring is a thin band whose transparent corners would otherwise
|
||||
|
||||
@@ -540,46 +540,57 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The rest-state trio (decision 1082) — player-level globals over the app's rest feed
|
||||
// The rest-state trio (decisions 1082/1087) — player-level globals over the app's rest feed
|
||||
// ([`UiScript::set_rest_state`]), the MainMenuBar exhaustion tick's and the player frame's
|
||||
// whole wire. INTERIM pending the dispatched wow-re verdict on `GetRestState 0x48d350` /
|
||||
// `GetXPExhaustion 0x48d3f0` / `IsResting 0x516ea0`; each claim below is flagged at its site.
|
||||
// whole wire. Byte-VERIFIED, wow-re `system/ui/scratch/rested-xp-bindings.md` (a §5 pair +
|
||||
// orchestrator arbitration): the surface is Exhaustion.dbc DATA, not client constants — the
|
||||
// rows live in the model ([`UiScript::set_exhaustion_rows`]; shipped-table fallback).
|
||||
//
|
||||
// GetRestState() → (stateID, stateName, multiplier). The id IS the wire byte
|
||||
// (`PLAYER_BYTES_2` byte 3): 1 = Rested (earn 200% of base kill XP), 2 = Normal (100%) — the
|
||||
// only two the live server ever writes (vmangos `SetRestBonus`; the beta tired/exhausted
|
||||
// tiers 3..5 survive as dead FrameXML branches). 0-before-feed reads as Normal. The names are
|
||||
// the live client's own returns (INTERIM: enUS spellings, source string table unverified);
|
||||
// the multiplier is what `EXHAUST_TOOLTIP1` renders ×100 ("200% of normal experience").
|
||||
// GetRestState() → (stateID, stateName, multiplier) — `0x48d350`: the raw `PLAYER_BYTES_2`
|
||||
// byte 3 indexes Exhaustion.dbc DIRECTLY (the `[0xc0dd78]` ID→row array) and the triple is
|
||||
// `(row.ID, row.name[locale], row.factor)`: 1 → (1, "Rested", 2.0), 2 → (2, "Normal", 1.0),
|
||||
// and FrameXML's dead 3..5 branches map to the real beta rows (XXXTired 1.0/0.5,
|
||||
// XXXExhausted 0.25). Every failure — byte 0 (pre-feed), byte past the table, no row —
|
||||
// returns (nil, nil, nil), the binary's own fail path. The multiplier is what
|
||||
// `EXHAUST_TOOLTIP1` renders ×100 ("200% of normal experience").
|
||||
g.set(
|
||||
"GetRestState",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(if model.rest_state == 1 {
|
||||
(1i64, "Rested", 2.0f64)
|
||||
} else {
|
||||
(2i64, "Normal", 1.0f64)
|
||||
Ok(match model.exhaustion.get(&model.rest_state) {
|
||||
Some((name, factor)) => (
|
||||
Value::Number(f64::from(model.rest_state)),
|
||||
Value::String(lua.create_string(name)?),
|
||||
Value::Number(*factor),
|
||||
),
|
||||
None => (Value::Nil, Value::Nil, Value::Nil),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// GetXPExhaustion() → the rested span in BAR-XP units, or nil once the pool is dry — the tick
|
||||
// parks at `currXP + this` and the fill bar spans up to it (`ExhaustionTick_Update`). ×2 of
|
||||
// the wire pool: the server drains `PLAYER_REST_STATE_EXPERIENCE` 1:1 against BASE kill XP
|
||||
// while granting +100% (vmangos `GetXPRestBonus`), so the bar advances two for every one the
|
||||
// pool loses (INTERIM: the ×2-in-client and the nil-when-zero shape are the dispatch's C1).
|
||||
// GetXPExhaustion() → the rested span in BAR-XP units — `0x48d3f0`: the u32 pool × the f32
|
||||
// factor of **Exhaustion.dbc row ID 1, hard-coded** (`[[0xc0dd78]+4]`, whatever the current
|
||||
// state) — 2.0 in the shipped data, which is the whole "rested XP is double" law: the server
|
||||
// drains the pool 1:1 against BASE kill XP while granting +100% (vmangos `GetXPRestBonus`),
|
||||
// and the client scales by exactly this row's factor. **nil is decided by the rest-state
|
||||
// byte, never by the pool** (`0x48d43b dec/jne`): byte ≠ 1 → nil whatever the pool holds
|
||||
// (vmangos's 0 < bonus ≤ 10 hysteresis window sends byte 2 with a nonzero pool — nil there),
|
||||
// and a rested byte with pool 0 → the NUMBER 0. The tick parks at `currXP + this`
|
||||
// (`ExhaustionTick_Update`).
|
||||
g.set(
|
||||
"GetXPExhaustion",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(if model.rest_pool == 0 {
|
||||
Ok(if model.rest_state != 1 {
|
||||
Value::Nil
|
||||
} else {
|
||||
Value::Integer(i64::from(model.rest_pool) * 2)
|
||||
let factor = model.exhaustion.get(&1).map_or(2.0, |(_, f)| *f);
|
||||
Value::Number(f64::from(model.rest_pool) * factor)
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// IsResting() → 1/nil: inside a rest area (inn/city) right now — `PLAYER_FLAGS_RESTING
|
||||
// (0x20)`. The player frame's flashing zzz reads exactly this.
|
||||
// IsResting() → 1/nil: inside a rest area (inn/city) right now — `0x516ea0`, byte-VERIFIED:
|
||||
// PLAYER_FLAGS `shr 5; test 1` = bit 0x20, pushed as the NUMBER 1.0 or nil (the Lua-vanilla
|
||||
// predicate shape, not a boolean). The player frame's flashing zzz reads exactly this.
|
||||
g.set(
|
||||
"IsResting",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -591,10 +602,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// GetTimeToWellRested() → nil, always: nothing on the 1.12 wire carries a time-to-full
|
||||
// estimate, and `ExhaustionToolTipText` nil-guards the call (its EXHAUST_TOOLTIP4 line simply
|
||||
// never renders). INTERIM: the dispatch's Q5 — if the binary computes one client-side, this
|
||||
// grows the real formula.
|
||||
// GetTimeToWellRested() → nil, always — `0x48d4b0`, byte-VERIFIED: the whole binding is 11
|
||||
// bytes, `pushnil; return 1`. FrameXML's EXHAUST_TOOLTIP4 countdown branch is dead in 5875.
|
||||
g.set(
|
||||
"GetTimeToWellRested",
|
||||
lua.create_function(|_, ()| Ok(Value::Nil))?,
|
||||
|
||||
@@ -289,6 +289,19 @@ impl super::UiScript {
|
||||
model.resting = resting;
|
||||
}
|
||||
|
||||
/// Push Exhaustion.dbc — `(rest-state byte, localized name, factor)` rows for the
|
||||
/// `GetRestState`/`GetXPExhaustion` bindings (they read the table exactly as `0x48d350` /
|
||||
/// `0x48d3f0` read the client's own copy; decision 1087). Called once at startup off the
|
||||
/// patch chain; an empty push is ignored so a failed DBC read keeps the shipped-table
|
||||
/// fallback the model seeds.
|
||||
pub fn set_exhaustion_rows(&mut self, rows: Vec<(u8, String, f64)>) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut model = self.model_mut();
|
||||
model.exhaustion = rows.into_iter().map(|(id, n, f)| (id, (n, f))).collect();
|
||||
}
|
||||
|
||||
/// Push the player's banked combo points (`PLAYER_FIELD_BYTES` byte 1) and the unit they sit
|
||||
/// on (`PLAYER_FIELD_COMBO_TARGET`) — the pair `Player::SetComboPoints` writes together, taken
|
||||
/// together so they can never be read half-updated. Player-level PRIVATE fields, same shape as
|
||||
|
||||
Reference in New Issue
Block a user