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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user