the Combat Log tab exists, and black portraits get their faces

- the chat dock's second tab, Combat Log, exists — 44 combat chat types had been routed into a window with no tab, no rect and no border art; both tab labels resolve from the install's GlobalStrings, the docked window keeps its anchors out of UIParent's managed-position pass, and the reveal fade reads whichever window is selected
- the Fire Roc and Dredge Crusher portraits show a face instead of black: a skinned booth part was frustum-tested against its bind-pose box while the camera is aimed at its Stand — a flat-authored bird posed itself six units above the box every frame; eleven creature models (harpies, wind serpents, the battle standards) had the same empty bake, and `WOW_BOOTH_LOG` now says where the posed skeleton is, not only the camera
- you cannot sit down while swimming: the client's one stand-state setter reads the live movement flags against `0x20000f` (the four direction bits plus SWIMMING) and returns before the packet is built; standing up never reads it, so the water stays escapable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:37:10 +07:00
co-authored by Claude Fable 5
parent 5c44704ac4
commit 55b2713f24
8 changed files with 710 additions and 4 deletions
+136 -2
View File
@@ -355,7 +355,12 @@
-- (FCF_OnUpdate: MouseIsOver(chatFrame, 45, -10, -5, 5) — 45 up into the tab zone,
-- 10 down past the edit box seam, 5 out each side).
local function overDock(x, y)
local f = ChatFrame1
-- **The SELECTED frame, not ChatFrame1.** The two dock windows are mutually exclusive
-- — selecting one hides the other — so reading a fixed frame here meant that the
-- instant the Combat Log was selected the hover test measured a hidden window, went
-- false, and drove the reveal fade to 0: the tabs and the chat background disappeared
-- together (director-reported). The dock's rect is whichever window is up.
local f = getglobal("ChatFrame" .. BenillaFCF.selected) or ChatFrame1
local left, right = f:GetLeft(), f:GetRight()
local top, bottom = f:GetTop(), f:GetBottom()
if not left then return false end
@@ -885,6 +890,60 @@
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="2" y="-6"/></Offset></Anchor>
</Anchors>
</Texture>
<!-- The ChatFrameBorder ring (ref ChatFrameBorder slices; texcoords quoted from
the ref resize buttons). Corners 16×16, edges stretched between them. -->
<Texture name="$parentBorderTopLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBackground"><Offset><AbsDimension x="-2" y="2"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="0.125"/>
</Texture>
<Texture name="$parentBorderTopRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativeTo="$parentBackground"><Offset><AbsDimension x="2" y="2"/></Offset></Anchor></Anchors>
<TexCoords left="0.75" right="1.0" top="0" bottom="0.125"/>
</Texture>
<Texture name="$parentBorderBottomLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativeTo="$parentBackground"><Offset><AbsDimension x="-2" y="-3"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.25" top="0.7265625" bottom="0.8515625"/>
</Texture>
<Texture name="$parentBorderBottomRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT" relativeTo="$parentBackground"><Offset><AbsDimension x="2" y="-3"/></Offset></Anchor></Anchors>
<TexCoords left="0.75" right="1.0" top="0.7265625" bottom="0.8515625"/>
</Texture>
<Texture name="$parentBorderTop" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBorderTopLeft" relativePoint="RIGHT"/>
<Anchor point="RIGHT" relativeTo="$parentBorderTopRight" relativePoint="LEFT"/>
</Anchors>
<TexCoords left="0.25" right="0.75" top="0" bottom="0.125"/>
</Texture>
<Texture name="$parentBorderBottom" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBorderBottomLeft" relativePoint="RIGHT"/>
<Anchor point="RIGHT" relativeTo="$parentBorderBottomRight" relativePoint="LEFT"/>
</Anchors>
<TexCoords left="0.25" right="0.75" top="0.7265625" bottom="0.8515625"/>
</Texture>
<Texture name="$parentBorderLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="$parentBorderTopLeft" relativePoint="BOTTOM"/>
<Anchor point="BOTTOM" relativeTo="$parentBorderBottomLeft" relativePoint="TOP"/>
</Anchors>
<TexCoords left="0" right="0.25" top="0.125" bottom="0.7265625"/>
</Texture>
<Texture name="$parentBorderRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="$parentBorderTopRight" relativePoint="BOTTOM"/>
<Anchor point="BOTTOM" relativeTo="$parentBorderBottomRight" relativePoint="TOP"/>
</Anchors>
<TexCoords left="0.75" right="1.0" top="0.125" bottom="0.7265625"/>
</Texture>
</Layer>
</Layers>
<Scripts>
@@ -1061,7 +1120,7 @@
BenillaFCF_TabClick(1)
</OnClick>
</Scripts>
<ButtonText name="$parentText" text="General">
<ButtonText name="$parentText" text="GENERAL">
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
@@ -1082,6 +1141,81 @@
</HighlightTexture>
</Button>
<!--
ChatFrame2Tab — the "Combat Log" dock tab. **This is the tab that was missing**: every
piece of machinery around it already existed and was written to expect it — `BenillaFCF`'s
fade, resize and flash loops all run `for i = 1, 2` over
`getglobal("ChatFrame"..i.."Tab")`, `selected = 1` is commented "2 = Combat Log", and
`FCF_SelectDockFrame` errors on any id that is not 1 or 2 — but the button itself was never
authored, and each of those loops has an `if tab then` guard that swallowed its absence
without a word. ChatFrame2 was therefore reachable only from Lua: it is `hidden`, as a
non-selected dock frame should be, and nothing on screen could select it.
Labels are the GlobalString KEYS (`GENERAL`, `COMBAT_LOG`), resolved out of the player's own
`GlobalStrings.lua` by the loader's ALL-CAPS key rule — the same shape the combat log's own
format strings use (1571), and the reason neither word is quoted in this file.
Anchored LEFT-to-RIGHT off tab 1; `BenillaFCF_TabResize` sizes both to their measured label
widths, so the abutment survives a locale whose words are longer.
-->
<Button name="ChatFrame2Tab" frameStrata="LOW" alpha="0">
<Size><AbsDimension x="70" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="ChatFrame1Tab" relativePoint="RIGHT"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeft" file="Interface\ChatFrame\ChatFrameTab">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0.0" right="0.25" top="0.0" bottom="1.0"/>
</Texture>
<Texture name="$parentMiddle" file="Interface\ChatFrame\ChatFrameTab">
<Size><AbsDimension x="38" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.75" top="0.0" bottom="1.0"/>
</Texture>
<Texture name="$parentRight" file="Interface\ChatFrame\ChatFrameTab">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentMiddle" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.75" right="1.0" top="0.0" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
<Frames>
<Frame name="$parentFlash" hidden="true">
<Size><AbsDimension x="5" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="$parentRight"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Tab-Highlight" alphaMode="ADD" setAllPoints="true"/>
</Layer>
</Layers>
</Frame>
</Frames>
<Scripts>
<OnClick>
BenillaFCF_TabClick(2)
</OnClick>
</Scripts>
<ButtonText name="$parentText" text="COMBAT_LOG">
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightTexture file="Interface\PaperDollInfoFrame\UI-Character-Tab-Highlight" alphaMode="ADD">
<Size><AbsDimension x="0" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="$parentRight"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
</Anchors>
</HighlightTexture>
</Button>
<!-- ── The UIMenu kit (ref UIMenu.xml, names kept for the addon surface): a row button
with a right-aligned shortcut string, and the tooltip-backdrop menu holding the ref's
fixed pool of 32 rows (hidden until AddButton). The label is authored LEFT-anchored:
+9 -1
View File
@@ -241,7 +241,15 @@
FramerateLabel = {baseY = 64, bottomEither = 42, pet = 42, reputation = 9},
CastingBarFrame = {baseY = 60, bottomEither = 40, pet = 40, reputation = 9},
ChatFrame1 = {baseY = 85, bottomLeft = 17, pet = 17, reputation = 9, maxLevel = -5, anchorTo = "UIParent", point = "BOTTOMLEFT", rpoint = "BOTTOMLEFT", xOffset = 32},
ChatFrame2 = {baseY = 85, bottomRight = 17, rightLeft = -88, rightRight = -43, reputation = 9, maxLevel = -5, anchorTo = "UIParent", point = "BOTTOMRIGHT", rpoint = "BOTTOMRIGHT", xOffset = -32},
-- ChatFrame2 is NOT managed here, and its absence is the fix for the latent
-- decision 1499 flagged in the ClearAllPoints comment below. The reference's row
-- seats an UNDOCKED ChatFrame2 against the screen's bottom-right; ours is DOCKED to
-- ChatFrame1, and a docked frame takes its rect from the dock, never from this pass.
-- With the row present the pass cleared ChatFrame2's authored anchors and re-seated it
-- with a single BOTTOMRIGHT point and no size — an unresolvable rect, so the window
-- rendered nothing at all. That was invisible while ChatFrame2 had no tab; the moment
-- one existed (1575) it became "the Combat Log tab shows an empty screen".
-- Re-add this row the day windows can be undocked, which is chat *settings* (0288 §2).
ShapeshiftBarFrame = {baseY = 0, bottomLeft = 45, reputation = 9, maxLevel = -5, anchorTo = "MainMenuBar", point = "BOTTOMLEFT", rpoint = "TOPLEFT", xOffset = 30},
-- Globals to write
+21
View File
@@ -1106,7 +1106,28 @@ fn control(
{
request_stand = Some(0);
}
// The sit-down gate — the client's own, inside the ONE setter `0x5ed430`
// ([`state::stand_state_refused`], bug B155): a body the movement layer is already driving
// cannot be seated, and **swimming is one of the driving states**, so the press is refused
// for as long as we are in the water. Silently, and before the packet — like the reference,
// which returns from `SetStandState` without building `CMSG_STANDSTATECHANGE` at all.
// Placed on the shared commit below rather than on the X key, so it covers the posture
// emotes (`/sit`, `/sleep`, `/kneel`) in the same stroke — their own `Emotes.dbc` gate
// does NOT carry the swim bit (`ui_chat::tests::the_posture_emotes_carry_no_swim_suppression_flag`).
// The word is the live outbound one, a frame old — the same `[[this+0x118]+0x40]` the cast
// gates read (decision 1056), so all three refusals can never disagree about "am I moving".
if let Some(s) =
request_stand.filter(|&s| state::stand_state_refused(player.move_flags(), s))
{
debug!(
"stand state {s} refused (move flags {:#x} — the client's `0x5ed430` gate)",
player.move_flags()
);
move_trace::posture("REFUSED", s, stand_state, player.move_flags());
request_stand = None;
}
if let Some(s) = request_stand.filter(|&s| s != stand_state) {
move_trace::posture("commit", s, stand_state, player.move_flags());
player.stand_pending = Some(s);
let _ = net.0 .0.send(ClientCommand::StandStateChange {
state: u32::from(s),
@@ -11,6 +11,13 @@
//! latch are exactly the ones it drops (decision 0644).
//! - **`mvr`** — one line per mover-claim packet ([`mover_claim`]): who we told the server we are
//! driving, which is what decides whether anything under `snd` is accepted.
//! - **`sit`** — one line per **stand-state** decision ([`posture`]): every commit, and every press
//! the client's own sit-down gate refused, each with the movement word that decided it. B155's
//! reading instrument ("you can sit while swimming"): the refusal is silent in the reference and
//! therefore silent in ours, so without this the *whole* mechanism is invisible from a live run —
//! a granted sit and a refused one look identical on screen, and the granted-underwater bug and a
//! gate that refuses everything are the same picture. Covers the `X` key and the `/sit` family
//! alike, because both land on the one commit it is emitted from.
//! - **`snd`** — one line per outbound `MSG_MOVE_*` ([`sent`]), the send-side twin of `net::motion`'s
//! `rly`: it makes **our own wire cadence measurable** (decision 0617), which is the only way to
//! compare it against the reference's — the 1.12.1 sniff's client stream is a list of exactly these
@@ -66,6 +73,35 @@ pub(super) fn sent(kind: crate::net::MoveKind, flags: u32, facing: f32, pos: [f3
);
}
/// One `sit` line per **stand-state decision** — the commits and the refusals, with the movement
/// word that decided each.
///
/// `what` is `commit` or `REFUSED`; `state` is the `UnitStandStateType` asked for (0 STAND · 1 SIT ·
/// 2 SIT_CHAIR · 3 SLEEP · 8 KNEEL), `from` the state it was asked from, and `flags` the live
/// `CMovement` word the client's gate reads (`[[this+0x118]+0x40]`) — with its two decisive
/// sub-readings spelled out rather than left to be masked by eye, because "is `0x220000f` swimming?"
/// is exactly the question a trace exists to stop anyone answering from memory.
///
/// Emitted for **both** outcomes on purpose. The reference refuses a sit-down silently — no error
/// text, no sound — so a refusal has no observable of its own; and a *granted* sit is equally
/// unreadable, since the pose that follows is identical whether the gate ran or was never there.
/// One line per decision is what makes B155's fix falsifiable in a live run instead of assertable
/// from a screenshot of a seated character.
pub(super) fn posture(what: &str, state: u8, from: u8, flags: u32) {
if !trace::enabled() {
return;
}
use crate::creature_anim::move_flags as f;
trace::line(
"sit",
&format!(
"{what} state={state} from={from} flags={flags:#x} swim={} moving={}",
u8::from(flags & f::SWIMMING != 0),
u8::from(flags & f::ANY_MOVE != 0),
),
);
}
/// One `mvr` line per **mover-claim** packet — `CMSG_SET_ACTIVE_MOVER` and
/// `CMSG_MOVE_NOT_ACTIVE_MOVER`. Neither is a `MSG_MOVE_*`, so neither appears under `snd`, and yet
/// the pair decides whether every packet that *does* is accepted at all: vmangos matches each
+120
View File
@@ -897,6 +897,126 @@ pub(super) fn autorun_cancelled(
fwd_down || back_down || both_engaged || lost_mover
}
/// Does the reference **refuse** this stand-state change? The sit-down gate inside the client's one
/// `SetStandState` (`0x5ed430`) — bug B155, *"you can sit fully underwater"*.
///
/// The client will not seat a body the movement layer is already driving. Sitting down reads the
/// live `CMovement` flags word `[[this+0x118]+0x40]` — the same word we stream and the cast gates
/// read (decision 1056) — against a per-target-state mask, and **returns before the packet is
/// built** when any masked bit is set: no `CMSG_STANDSTATECHANGE`, no local apply, no message.
/// Standing up is the asymmetry: `newState == 0` jumps straight to the send and never consults the
/// word at all, so movement can always stand you (VERIFIED `0x5ed4d8`–`0x5ed501`, wow-re
/// `object-layer/scratch/standstate-movement-trigger.md` §1).
///
/// Because the refusal lives in the **one setter**, it covers every caller at once — the `X`
/// keybind (`SitOrStand`, Lua `0x48b920`) and the posture emotes (`/sit`, `/sleep`, `/kneel`,
/// `/lay`, which reach the same `0x5ed430` through `DoEmote`'s `EmoteSpecProc == 1` arm). That is
/// the only thing covering them: the emote layer's own swim suppression
/// ([`crate::ui_chat::input::emote_send_eligible`]'s `EmoteFlags & 0x0080`) does **not** fire here
/// — the shipped `Emotes.dbc` gives STATE_SIT (13), STATE_SLEEP (12) and STATE_STAND (26) flags
/// `0x6202`, with that bit clear — read off the 5875 data by
/// `ui_chat::tests::the_posture_emotes_carry_no_swim_suppression_flag`.
///
/// The masks are the reference's own two:
///
/// | target state | mask | means |
/// |---|---|---|
/// | 0 STAND | — | never refused |
/// | 3 SLEEP | `0x30` | refused while **turning** |
/// | 1 SIT · 2 SIT_CHAIR · 8 KNEEL · … | `0x20000f` | refused while **translating or swimming** |
///
/// `0x20000f` is not a mask invented here: it is byte-verified at a second, unrelated site as the
/// client's own "am I moving" test — the stationary-cast pin's `[9e8] & 0x20000f`
/// ([`crate::creature_anim::move_flags::CAST_PIN_MOVE`]) — the four direction bits plus
/// [`SWIMMING`](crate::creature_anim::move_flags::SWIMMING), turn bits deliberately absent. The
/// swim bit in it is the whole of B155: a swimmer is *always* carrying it, so the press is refused
/// for as long as they are in the water, whether or not they are stroking.
pub(super) fn stand_state_refused(move_flags: u32, new_state: u8) -> bool {
use crate::creature_anim::move_flags as f;
// The stand-up asymmetry: never gated.
if new_state == 0 {
return false;
}
let mask = if new_state == 3 {
f::TURN_LEFT | f::TURN_RIGHT
} else {
f::ANY_MOVE | f::SWIMMING
};
move_flags & mask != 0
}
#[cfg(test)]
mod stand_state_tests {
use super::stand_state_refused;
use crate::creature_anim::move_flags as f;
/// B155's exact press: swimming, X pressed, the client refuses — and the same swimmer standing
/// up is not refused, which is the asymmetry that makes the water escapable.
#[test]
fn a_swimmer_cannot_sit_but_can_always_stand() {
// Floating still, no stroke: SWIMMING alone is enough.
assert!(stand_state_refused(f::SWIMMING, 1), "sit refused mid-swim");
assert!(
stand_state_refused(f::SWIMMING | f::FORWARD, 1),
"swimming forward too"
);
// …and every seat shape the posture emotes can ask for.
for state in [1u8, 2, 8] {
assert!(
stand_state_refused(f::SWIMMING, state),
"state {state} refused mid-swim"
);
}
// Standing up is never gated — `newState == 0` skips the word entirely.
assert!(!stand_state_refused(f::SWIMMING | f::FORWARD, 0));
assert!(!stand_state_refused(f::ANY_MOVE | f::SWIMMING, 0));
}
/// On dry land the gate is the *translation* test, not a blanket "any flag": sitting while
/// walking is refused, sitting while turning in place is not — the turn bits are outside
/// `0x20000f`. (The control that says this is a real mask rather than a swim special-case.)
#[test]
fn sitting_is_refused_while_translating_and_allowed_while_merely_turning() {
assert!(!stand_state_refused(0, 1), "standing still: sit granted");
for bit in [f::FORWARD, f::BACKWARD, f::STRAFE_LEFT, f::STRAFE_RIGHT] {
assert!(stand_state_refused(bit, 1), "translating: sit refused");
}
for bit in [f::TURN_LEFT, f::TURN_RIGHT] {
assert!(
!stand_state_refused(bit, 1),
"turning in place: sit granted"
);
}
// Mode bits are not movement: a rooted or water-walking body may still sit.
for bit in [f::ROOT, f::WATER_WALKING, f::FALLING, f::WALK_MODE] {
assert!(
!stand_state_refused(bit, 1),
"mode bit {bit:#x} is not a move"
);
}
}
/// SLEEP (3) takes the *other* mask — the turn bits — so `/sleep` and `/sit` genuinely disagree
/// about which inputs block them. Asserted so the reference's odd pair is recorded rather than
/// quietly "corrected" into one mask.
#[test]
fn sleep_reads_the_turn_mask_not_the_translate_mask() {
assert!(
stand_state_refused(f::TURN_LEFT, 3),
"turning: /sleep refused"
);
assert!(stand_state_refused(f::TURN_RIGHT, 3));
assert!(
!stand_state_refused(f::FORWARD, 3),
"walking: /sleep granted"
);
assert!(
!stand_state_refused(f::SWIMMING, 3),
"swimming: /sleep granted"
);
}
}
#[cfg(test)]
mod autorun_tests {
use super::{autorun_cancelled, forward_axis};
+131
View File
@@ -530,6 +530,20 @@ pub(super) fn spawn_booth_model(
}
if use_rig {
child.insert(benilla_world::rig_palette::RigPart(root));
// **A skinned booth part is never frustum-culled** — the same law the world's dress
// path states (`entities::attach::dress`, 0648/1270/1473), and for a sharper reason
// here. The part's vertices are moved by the GPU joint palette; the only bound Bevy
// has for them is `calculate_bounds`' box over the mesh's own **bind-pose** vertices,
// and this camera is the artist's *portrait* camera — calibrated against the model in
// its **Stand** pose. On any model whose rest pose is not its Stand the two are
// nowhere near each other, so the bind box falls outside the frustum and every batch
// is culled while the posed geometry is dead centre of frame. `Creature\CarrionBird`
// is the case: bind-pose z tops out at 1.19, its Stand box runs 0.54..6.23, and the
// authored camera sits at z = 3.03 looking at z = 2.97 — nothing drew, and the
// portrait baked the empty booth (decision 1577, report B92). `Creature\Worm` the
// same. There is no cull to lose: a booth holds one model that its camera was
// authored to frame.
child.insert(bevy::camera::visibility::NoFrustumCulling);
}
}
let mut booth_rig = BoothRig {
@@ -1198,6 +1212,123 @@ mod tests {
);
}
/// **A skinned booth part is spawned exempt from the frustum test** — decision 1577, report B92.
///
/// The part's vertices live in the GPU joint palette; the only bound Bevy can build for it is
/// `calculate_bounds`' box over the mesh's own **bind-pose** vertices, and the booth camera is
/// the artist's portrait camera, aimed at the model's **Stand**. `Creature\CarrionBird` bakes
/// a bind box topping out at z = 1.19 while that camera sits at z = 3.03 looking at z = 2.97:
/// every batch culled, an opaque-black portrait, and the posed bird dead centre of a frustum
/// nothing was ever tested against. The world's dress path states the same exemption
/// (`entities::attach::dress`); this is the booth half, and it is what the marker guards.
///
/// The unskinned twin is the control: a static part draws its bind-pose mesh *at* bind pose, so
/// its own box is the truth and it keeps the ordinary test.
#[test]
fn a_skinned_booth_part_is_never_frustum_culled() {
let mut app = App::new();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
app.init_resource::<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>();
// A two-bone rest skeleton — enough for `RigPose::new` and a real palette slot.
let skeleton = benilla_assets::ModelSkeleton {
joints: vec![
benilla_assets::ModelJoint {
parent: -1,
local_translation: Vec3::ZERO,
billboard: None,
parent_arm: None,
},
benilla_assets::ModelJoint {
parent: 0,
local_translation: Vec3::Y,
billboard: None,
parent_arm: None,
},
],
spine_bone: None,
head_bone: None,
};
let ibp = Handle::default();
// One skinned batch and one static one, through the same call.
let parts = vec![
BoothPart {
skinned: Some(Handle::default()),
static_mesh: Handle::default(),
material: Handle::default(),
alpha_anim: None,
},
BoothPart {
skinned: None,
static_mesh: Handle::default(),
material: Handle::default(),
alpha_anim: None,
},
];
let root = app.world_mut().spawn(Transform::IDENTITY).id();
let mut palettes = app
.world_mut()
.remove_resource::<benilla_world::rig_palette::RigPalettes>()
.expect("just inserted");
let mut queue = bevy::ecs::world::CommandQueue::default();
{
let mut commands = Commands::new(&mut queue, app.world());
spawn_booth_model(
&mut commands,
&mut palettes,
root,
RenderLayers::layer(9),
&parts,
&[],
Some((&skeleton, &ibp, None)),
None,
BoothMotion::Frozen,
[false, false],
&[],
)
.finish(&mut commands);
}
queue.apply(app.world_mut());
app.world_mut().insert_resource(palettes);
let children: Vec<Entity> = app
.world()
.entity(root)
.get::<Children>()
.expect("the bake spawned its parts")
.iter()
.collect();
let rigged: Vec<Entity> = children
.iter()
.copied()
.filter(|e| {
app.world()
.entity(*e)
.contains::<benilla_world::rig_palette::RigPart>()
})
.collect();
assert_eq!(rigged.len(), 1, "one of the two batches skins");
assert!(
app.world()
.entity(rigged[0])
.contains::<bevy::camera::visibility::NoFrustumCulling>(),
"a palette-skinned booth part must not be tested against its bind-pose bound"
);
let statics: Vec<Entity> = children
.iter()
.copied()
.filter(|e| !rigged.contains(e))
.collect();
assert!(
statics.iter().all(|e| !app
.world()
.entity(*e)
.contains::<bevy::camera::visibility::NoFrustumCulling>()),
"…and the static twin keeps the ordinary test — its own box IS where it draws"
);
}
/// An unmarked part is not ours to write: the world lane's parts carry `MatAnim` too, and their
/// alpha is composed by the visibility authority against the fade and the interior classifier.
#[test]
+52 -1
View File
@@ -645,6 +645,54 @@ fn log_frame(token: &str, a: &PortraitAnchors, cam: &Transform) {
}
}
/// `WOW_BOOTH_LOG=1` — where each booth's **posed skeleton** actually ended up, logged whenever it
/// moves (quantised to a centimetre, so a still bake prints once).
///
/// [`log_frame`] says where the *camera* went; this says where the *model* went, and the pair is the
/// whole diagnosis of a bake that came back empty. Decision 1577's carrion bird posed its bones up
/// to y = 4.70 with its authored camera sitting at y = 3.03 — a camera aimed correctly at geometry
/// Bevy was culling against a **bind-pose** bound that stopped at 1.19. Either half alone reads as
/// "looks fine"; only the two together say the camera and the model were in the same place and the
/// pixels still were not.
fn log_booth_pose(
booths: Res<Booths>,
rigs: Query<&benilla_world::rig_anim::RigPose>,
// Last-logged extent per booth, in centimetres — `[min xyz, max xyz]`.
mut last: Local<HashMap<String, [i32; 6]>>,
) {
if !booth_log() {
return;
}
for (token, booth) in &booths.0 {
let Ok(pose) = rigs.get(booth.root) else {
last.remove(token);
continue;
};
let (mut mn, mut mx) = (Vec3::MAX, Vec3::MIN);
for m in &pose.model {
let t = Vec3::from(m.translation);
mn = mn.min(t);
mx = mx.max(t);
}
let cm = |v: Vec3| [v.x, v.y, v.z].map(|c| (c * 100.0).round() as i32);
let ([a, b, c], [d, e, f]) = (cm(mn), cm(mx));
let key = [a, b, c, d, e, f];
if last.insert(token.clone(), key) == Some(key) {
continue;
}
eprintln!(
"[booth] {token} posed-bones n={} min=({:.3},{:.3},{:.3}) max=({:.3},{:.3},{:.3})",
pose.model.len(),
mn.x,
mn.y,
mn.z,
mx.x,
mx.y,
mx.z,
);
}
}
/// Arm `booth` after a content edge: render the settle window, plus every frame until each twin
/// material's texture is resident. `twins` = the material handles the bake just installed.
fn wake_booth<'a>(
@@ -863,7 +911,10 @@ impl Plugin for PortraitPlugin {
// `WOW_BOOTH_DUMP=<token>:<path>:<secs>` — photograph a booth's render target to disk
// (the headless eye on "what is the paperdoll actually showing right now"; the
// first-login black-pane hunt). Inert without the env.
.add_systems(Update, test_bake::dump_booth_target);
.add_systems(Update, test_bake::dump_booth_target)
// `WOW_BOOTH_LOG=1` — the model half of the framing instrument (see `log_frame`).
// Inert without the env.
.add_systems(Update, log_booth_pose);
}
}
+205
View File
@@ -1854,3 +1854,208 @@ fn the_combat_log_lands_in_window_two_only() {
assert!(!windows.wants(1, kind));
}
}
/// **Both dock tabs exist, and clicking one selects its window.**
///
/// This is the test that was missing when 1571 shipped the combat log's chat lines: the lines
/// routed correctly into ChatFrame2 and no player could ever see them, because `ChatFrame2Tab` had
/// never been authored. Every piece of machinery around it *did* exist and was written for two
/// tabs — `BenillaFCF`'s fade/resize/flash loops all run `for i = 1, 2` — but each carries an
/// `if tab then` guard, so the absence was swallowed silently for as long as it existed. The
/// director found it by looking at the screen.
///
/// So the assertion is deliberately about the tab BUTTON and the selection it drives, not about
/// routing (which `the_combat_log_lands_in_window_two_only` already covers and which was never
/// the broken half).
#[test]
fn both_dock_tabs_exist_and_select_their_window() {
let s = chat_vm();
for id in [1, 2] {
assert!(
s.eval::<bool>(&format!("return ChatFrame{id}Tab ~= nil"))
.unwrap(),
"ChatFrame{id}Tab is missing — its window is unreachable from the screen"
);
}
// The default dock: General selected and shown, Combat Log hidden behind its tab.
assert_eq!(s.eval::<i64>("return BenillaFCF.selected").unwrap(), 1);
assert!(s.eval::<bool>("return ChatFrame1:IsShown()").unwrap());
assert!(!s.eval::<bool>("return ChatFrame2:IsShown()").unwrap());
// Clicking the Combat Log tab swaps them — the path a player takes to read the combat log.
s.run("BenillaFCF_TabClick(2)").unwrap();
assert_eq!(s.eval::<i64>("return BenillaFCF.selected").unwrap(), 2);
assert!(s.eval::<bool>("return ChatFrame2:IsShown()").unwrap());
assert!(!s.eval::<bool>("return ChatFrame1:IsShown()").unwrap());
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// The two dock tabs are labelled from the install's own `GlobalStrings.lua` (`GENERAL`,
/// `COMBAT_LOG`) rather than from words quoted into our XML — the loader's ALL-CAPS key rule, and
/// the same shape the combat log's format strings use (1571). Skips without client data.
#[test]
fn the_dock_tab_labels_come_from_the_install() {
let data = benilla_formats::wow_data_or_skip!();
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");
for key in ["GENERAL", "COMBAT_LOG"] {
let text: String = s.lua().globals().get(key).unwrap_or_default();
assert!(!text.is_empty(), "{key} is not a GlobalString");
}
}
/// **The Combat Log window has a real rect, and it is the dock's.**
///
/// This is the test whose absence let 1575 ship a tab onto a window that rendered nothing:
/// `ChatFrame2` carries no `<Size>` and derives its whole rect from two anchors onto ChatFrame1,
/// and every check we had asked only whether lines *routed* into it. They did — 21 of them — into a
/// frame measuring 0×0, so `GetNumMessages()` was 21 and the screen was empty.
///
/// Asserting the rect (not the size attribute — `GetWidth` reports the explicit field, which is 0
/// here by design and told us nothing) is what makes "the window exists" mean "the window has
/// pixels".
#[test]
fn the_combat_log_window_has_the_docks_rect() {
let s = chat_vm();
let edge = |frame: &str, get: &str| {
s.eval::<Option<f64>>(&format!("return {frame}:{get}()"))
.unwrap()
};
for get in ["GetLeft", "GetRight", "GetTop", "GetBottom"] {
let one = edge("ChatFrame1", get);
let two = edge("ChatFrame2", get);
assert!(
one.is_some(),
"ChatFrame1:{get}() is nil — the dock has no rect"
);
assert_eq!(
two, one,
"ChatFrame2:{get}() must equal ChatFrame1's — a docked window shares the dock's rect"
);
}
assert_eq!(
s.eval::<i64>("return ChatFrame2:GetNumPoints()").unwrap(),
2,
"both authored anchors must survive — one alone cannot size the frame"
);
}
/// Both dock windows carry the SAME chrome. `BenillaFCF_Textures(id)` looks each piece up by
/// `ChatFrame<id><suffix>`, which is the reference's own per-frame `CHAT_FRAME_TEXTURES` shape —
/// and ChatFrame2 had only a background, no borders. Selecting it therefore hid ChatFrame1's
/// chrome (its textures are ChatFrame1's children) and put nothing in its place: the background
/// and border vanished, which is what the director saw.
#[test]
fn both_dock_windows_carry_the_same_chrome() {
let s = chat_vm();
for suffix in [
"Background",
"BorderTopLeft",
"BorderTopRight",
"BorderBottomLeft",
"BorderBottomRight",
"BorderTop",
"BorderBottom",
"BorderLeft",
"BorderRight",
] {
for id in [1, 2] {
assert!(
s.eval::<bool>(&format!("return ChatFrame{id}{suffix} ~= nil"))
.unwrap(),
"ChatFrame{id}{suffix} is missing — the dock's chrome dies when window {id} is up"
);
}
}
assert_eq!(
s.eval::<i64>("return table.getn(BenillaFCF_Textures(2))")
.unwrap(),
s.eval::<i64>("return table.getn(BenillaFCF_Textures(1))")
.unwrap(),
);
}
/// **No docked chat window may appear in `UIParent.xml`'s managed-position table.**
///
/// The structural guard on the root cause. That pass owns a frame's whole seat — it
/// `ClearAllPoints()` first, by design (decision 1499) — so a row naming a *docked* window wipes
/// the anchors that tie it to the dock and re-seats it with a single point and no size: an
/// unresolvable rect. The reference's own ChatFrame2 row is for an UNDOCKED window, which is chat
/// settings and not built (0288 §2); carrying it here cost B297 a visible fix.
///
/// A string check because that is the level the bug lives at — the row's mere presence is the
/// defect, whatever it contains.
#[test]
fn no_docked_chat_window_is_managed_by_uiparent() {
let xml = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/UIParent.xml"),
)
.expect("UIParent.xml");
for line in xml.lines() {
let code = line.trim_start();
if code.starts_with("--") {
continue; // the comment explaining why the row is gone names it, deliberately
}
assert!(
!code.starts_with("ChatFrame2 ="),
"ChatFrame2 is DOCKED — a managed row clears its dock anchors: {code}"
);
}
assert!(
xml.contains("ChatFrame1 = {baseY = 85"),
"ChatFrame1 IS managed (it is the dock's own seat) — this test must not pass vacuously"
);
}
/// **What does NOT gate a `/sit` underwater** — the negative that sends B155's refusal to the
/// stand-state setter instead of here (`player::state::stand_state_refused`).
///
/// [`emote_send_eligible`] carries the client's own swim suppression (`EmoteFlags & 0x0080` at
/// `0x47db7d`), so it is the obvious suspect for "the reference won't let me `/sit` in water" — and
/// it is the wrong one. Read off the shipped `Emotes.dbc`: every posture emote (`/sit`, `/sleep`,
/// `/kneel`, `/stand`) carries `0x6202`, and `0x0080` is clear in it, so the emote layer passes them
/// straight through while swimming. This asserts that on the real data, because it is the fact that
/// decides *where* the fix belongs: if a later data read made these rows carry `0x0080`, the two
/// gates would double up and this test is what says so. Skips without client data.
#[test]
fn the_posture_emotes_carry_no_swim_suppression_flag() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cat = benilla_formats::load_emote_sound_catalog(&mut chain).expect("emote catalog");
// Every posture row in the shipped table, found by scanning rather than by hardcoded id.
let posture: Vec<(u32, u32, u32)> = (0..600u32)
.filter_map(|id| Some((id, cat.posture_state(id)?, cat.emote_flags(id)?)))
.collect();
let states: Vec<u32> = posture.iter().map(|&(_, s, _)| s).collect();
for state in [0u32, 1, 3, 8] {
assert!(
states.contains(&state),
"the shipped table has a posture emote for stand state {state}; found {states:?}"
);
}
for (id, state, flags) in posture {
assert_eq!(
flags & 0x0080,
0,
"posture emote {id} (state {state}) carries the swim-suppress bit: {flags:#x}"
);
// STATE_DEAD (7, emote 65, `0x6602`) is the one posture row the client kills outright —
// `0x0400`, unconditional suppress, in water or out. It is not reachable from the slash
// grammar and it is not what B155 is about; every posture a player can actually ask for
// passes the emote gate mid-swim, which is the point.
if flags & 0x0400 != 0 {
assert_eq!(state, 7, "only STATE_DEAD is unconditionally suppressed");
continue;
}
// …so the emote gate lets it through mid-swim. (`stand_state` here is the performer's
// CURRENT state; a standing swimmer pressing `/sit` is the reported case.)
assert!(
super::input::emote_send_eligible(flags, 0, true),
"posture emote {id} passes the emote gate while swimming"
);
}
}