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:
Sam
2026-08-07 16:03:16 +07:00
co-authored by Claude Fable 5
parent 5a6119e57f
commit 9f4595597d
81 changed files with 4052 additions and 660 deletions
Generated
+1
View File
@@ -592,6 +592,7 @@ dependencies = [
"benilla-formats",
"benilla-protocol",
"benilla-ui",
"benilla-wmo",
"bevy",
"bevy_egui",
"bytemuck",
+6
View File
@@ -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.
+100 -6
View File
@@ -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
+16 -9
View File
@@ -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>
+37 -4
View File
@@ -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,
+22 -13
View File
@@ -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 {
+3 -1
View File
@@ -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,
+10 -5
View File
@@ -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
+156
View File
@@ -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);
}
}
+235 -49
View File
@@ -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;
+45 -12
View File
@@ -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()
)
});
+3
View File
@@ -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.
+3 -1
View File
@@ -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>>,
+7 -2
View File
@@ -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;
+8
View File
@@ -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 } => {
+54 -2
View File
@@ -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);
+377 -135
View File
@@ -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);
+12 -2
View File
@@ -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()
+118 -37
View File
@@ -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 -2
View File
@@ -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);
}
}
+3
View File
@@ -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,
})
}
}
+284 -63
View File
@@ -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
+10 -1
View File
@@ -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;
}
+7 -2
View File
@@ -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,
}
}
+17 -6
View File
@@ -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
+68 -38
View File
@@ -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)`
+58 -13
View File
@@ -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);
+1
View File
@@ -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 -2
View File
@@ -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,
+3
View File
@@ -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());
}
+10 -4
View File
@@ -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) {
+179 -46
View File
@@ -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);
}
}
+22 -1
View File
@@ -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(),
+246 -17
View File
@@ -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 --");
+20
View File
@@ -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(())
}
+96
View File
@@ -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");
}
}
+16 -14
View File
@@ -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::{
+27
View File
@@ -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
+197 -17
View File
@@ -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}"
);
}
+12
View File
@@ -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)
+11 -10
View File
@@ -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
+4 -4
View File
@@ -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(
+9 -3
View File
@@ -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(())
},
)?,
+25 -5
View File
@@ -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).
+25
View File
@@ -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());
+25 -4
View File
@@ -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(),
+34 -10
View File
@@ -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(())
},
)?,
+257 -68
View File
@@ -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
);
}
+7 -2
View File
@@ -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),
+12 -4
View File
@@ -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"
);
}
+3 -3
View File
@@ -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;
}
+26 -7
View File
@@ -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
+35 -26
View File
@@ -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))?,
+13
View File
@@ -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