Files
benilla-octo/crates/benilla-app/src/ui_chat.rs
T
SamandClaude Fable 5.1 b41acbf98a Linux and Windows get their sound back, and emotes refuse while moving
- every build off macOS has been silent since the 09-02 snapshot (B356): the non-macOS device layer was a stub that opened nothing, and nothing had ever compiled the client for another target; that layer is now the same output stream over cpal — one seam, not two stacks: the ring, the render thread and the rebuild-on-device-change loop are one code on every platform, only the device file differs; the audio thread never allocates, a lost or changed default device is noticed at kira's own cadence, and realtime is `SCHED_FIFO` on unix and MMCSS "Pro Audio" on Windows, either refusable, the ring turning a refusal into a degradation; and the two instruments this bug says were missing exist — a Linux and Windows cross-compile under `clippy -D warnings`, and a live output test that opens the real device and asserts the ring never ran dry, because compiling is not sounding (1920)
- an emote that requires standing still now refuses on the move: `CheckEmoteEligible`'s `0x4000` arm writes an out-param that `DoEmote` turns into a red "You can't do that while moving!" and aborts; 20 emotes of 78 can raise the line, read off the shipped `Emotes.dbc`; SWIMMING is not in the mask, so a swimmer holding still emotes fine, and `IsSelfControlled` is TRUE for an ordinary player, so a feared or confused player emotes away happily (1904); and the cast path gains its equipped-item rung — the button greyed and pressing it still sent: the reference short-circuits to "fits" on four gates, not one, the miss being a zero `EquippedItemSubClassMask`, which means no item required at all, and the rung sits above the crowd-control validator, so a player both stunned and missing the weapon is told about the weapon; that ladder has six arms, not three — CHARMED, FLEEING and CONFUSED join (1925)
- the quest reward's money is the reference's own widget: the tail of the shared `QuestFrameItems_Update` drives `questState.."MoneyFrame"` through `MoneyFrame_Update`, and the two hand-built coin trios become one `MoneyFrameTemplate` instance each at the reference's anchors, which our coins already used; the progress panel stays on coins, being the required-money flow, not the reward one (1928); and the flight master's `!` joins the questgiver sweep, because the green `!` and the gold `!` are the SAME slot, `unit+0xb2c`, torn down once before both queries re-issue; the mouseover re-ask is refuted — the only taxi-status sender has one live caller — and the two queries are not symmetric: the questgiver's goes out on every sweep, the taxi one only when the unit's key moves or on a full sweep, as the wire shows at Dungar Longdrink: 68 questgiver status queries against 1 taxi-node one (1918)
- `GetSpellTabInfo` out of range answers `nil, nil, 0, 0` — the reference returns four values on every path that returns (`0x4b3ce0`), and ours answered a single nil copied from another binding's shape; it matters because stock `SpellBookFrame_OnLoad` calls it unconditionally and then does `id > (offset + numSpells)`, which a nil raises on and (0+0) makes true, hiding every button on the page — the behaviour to match; the argument raises the reference's usage string when absent, and the index truncates toward zero THEN decrements (1931); the perf probe gains a `WOW_PROBE_UNCAP=vsync` arm, because the uncapped present mode is a request, not a schedule — measured, it taxes the number it was meant to pin (11.80 `cpu_ms` uncapped against 14.65 under it), so the default stays uncapped; and the last three test temp paths are keyed on the pid, after concurrent runs raced on one fixed shared file

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:52:22 +07:00

230 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The chat system (decision 0288), the reference's own three-stage shape: [`event`] is the
//! internal currency (`CHAT_MSG_*` typed — kinds, groups, the shipped color table); [`feed`] turns
//! every source (wire lines, channel notices, rolls, client-composed loot/system lines) into
//! events with names resolved ask-once; [`frames`] is the router + the `ChatFrame_OnEvent`
//! composer fanning lines across the docked windows; [`input`] is the outbound side (the chat
//! EditBox, the slash grammar, the send-side emote posture gate) over [`commands`]'s table of every
//! `/command` the client answers (decision 0881, built from the reference's own alias strings).
//! This face wires them into [`UiChatPlugin`] and re-exports the crate-facing API (`ChatLog`,
//! `ChatEvent`, `ChatEventKind`).
use bevy::prelude::*;
use crate::ui_script::UiInput;
#[cfg(test)]
mod ace_gate_tests;
/// The world broadcasts (`SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`) — the
/// AreaTable/ServerMessages resolve and the joined-defense-channel walk they land on.
mod broadcast;
mod channels;
/// The combat log's chat lines (B297) — classification, chat type, and the GlobalString key each
/// combat packet's sentence is built from.
pub(crate) mod combat;
pub(crate) mod commands;
mod edit;
mod event;
mod feed;
mod frames;
mod input;
/// The language gate — the exemptions and the fluency lookup behind the chat garble (B262).
mod language;
/// The chat windows' saved look (B246, decision 1589) — where the tab menu's tint/alpha/font-size
/// picks are read from at login and written back at logout.
mod settings;
#[cfg(test)]
mod tests;
pub(crate) use broadcast::Broadcast;
/// The joined-channel roster + the `ChatChannels.dbc` catalog. Read outside this module by the
/// world-state readout ([`crate::world_state_ui`]), whose `Type == 1` gate is "has the player
/// joined a zone-dependent defense channel".
pub(crate) use edit::ChannelState;
/// Test-only: `ui_script::chat_tests` checks every name we fire against the live `ChatTypeInfo`
/// table, which lives on that side of the tree. The app itself calls it through `event::` — the
/// router is the only production caller and it is inside this module.
#[cfg(test)]
pub(crate) use event::event_name;
pub(crate) use event::{default_color, ChatEvent, ChatEventKind};
pub(crate) use feed::ChatLog;
pub(crate) struct UiChatPlugin;
impl Plugin for UiChatPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ChatLog>()
.init_resource::<frames::ChatWindows>()
.init_resource::<edit::ChatEditState>()
.init_resource::<edit::ChannelState>()
.init_resource::<channels::ZoneChannelWalk>()
.init_resource::<language::ChatLanguages>()
// `ChatChannels.dbc` — six rows, read once; the auto-join walk and every chat event's
// arg7 both come out of it. **`.after(AssetSet::Open)` is load-bearing**: without it
// this runs before the patch chain exists, takes its `assets: Option<Res<_>>` `None`
// arm, and silently loads nothing — no zone channels, ever, with no error. A live
// probe is what caught that; no unit test could, because the tests hand the catalog in.
// `crate::area`'s `AreaTable.dbc` load carries the same ordering for the same reason.
// `EmotesText.dbc` × `EmotesTextData.dbc` (decision 1274) rides the same ordering for
// the same reason: the sentence tables are read once, off the open patch chain.
// `ServerMessages.dbc` (the five shutdown/restart sentences) rides the same ordering
// for the same reason.
.add_systems(
Startup,
(
channels::load_chat_channels,
feed::load_emote_texts,
broadcast::load_server_messages,
)
.after(benilla_assets::AssetSet::Open),
)
// The slash-command table (decision 0881), built from the reference's own alias strings
// once the VM's globals and the emote catalog exist — both are `Startup`, so this runs
// at the next schedule rather than chasing an ordering constraint into two modules.
.add_systems(PostStartup, commands::build_slash_commands)
// Push before the input pass so a line is on screen the same frame it decodes (mirrors
// the loot/merchant feeds).
// The language gate's two feeds, both upstream of the chat drain that reads them: the
// word pool loads once (it retries until the chain is up), and the fluency map
// rebuilds off the spell book + the self descriptors.
.add_systems(
Update,
(
language::load_language_words,
language::feed_language_skills,
language::feed_default_language,
)
.before(feed::feed_chat),
)
// The three combat-log families that are NOT packet-driven (1703): the death reflex,
// the aura arrival/departure/stack callbacks, and the pet-loyalty byte. Each is a
// descriptor diff, so each runs before the drain that would otherwise show its line a
// frame late.
.add_systems(
Update,
(
combat::watch::death_lines,
combat::watch::aura_lines,
combat::watch::pet_loyalty_lines,
)
.before(feed::feed_chat),
)
// The world broadcasts' resolve pass — before the drain that renders what it produces,
// so an alarm or a shutdown countdown lands on the frame it decodes like every other
// chat source.
.add_systems(Update, broadcast::feed_broadcasts.before(feed::feed_chat))
.add_systems(Update, feed::feed_chat.before(UiInput))
// A fresh VM gets the joined-channel mirror re-pushed once (decision 1291) — before
// the feed, so the reload frame's first routed line already renders numbered.
.add_systems(Update, channels::seed_channels.before(feed::feed_chat))
// RequestTimePlayed() -> CMSG_PLAYED_TIME, and SMSG_PLAYED_TIME -> TIME_PLAYED_MSG.
// Beside the chat feed because /played is a chat command and the answer prints there
// too; before the input pass for the same reason feed_chat is.
.add_systems(Update, feed::played_time_bridge.before(UiInput))
// The input: open on ENTER (after the UI input pass has set UiKeyboardCapture, so we
// don't reopen the box that's already eating keys), then drain any submitted line. Both
// touch the single NonSend VM, so they chain. In-world only (decision 0193): at the
// character-select glue screen ENTER must not open a chat box behind the overlay.
.add_systems(
Update,
(
edit::open_chat_keys,
edit::open_tell_requests,
// An addon's `ChatFrame_OpenChat` — before the live parse in the same chain,
// so a box opened prefilled `/w Bob ` has its type switched on the very next
// frame rather than showing the raw slash to the user first.
edit::open_chat_requests,
edit::chat_edit_live,
edit::chat_tab_cycle,
input::drain_chat_input,
// An addon's own line into the wire (decision 1199). AFTER the box's drain
// and in the same chain, so a `SendChatMessage` fired from a slash handler
// that the box's drain just ran goes out on the same frame.
input::drain_addon_chat_sends,
// …and its addon-lane twin (decision 1235). Same position in the chain, for
// the same reason: a `SendAddonMessage` fired from a handler that ran earlier
// in this chain goes out on this frame rather than the next.
input::drain_addon_message_sends,
)
.chain()
.after(UiInput)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The zone-channel auto-join (0288 P6): the client half of a handshake vmangos
// deliberately leaves to us. In-world only, and it early-outs on an unchanged zone.
//
// Both session-end edges clear its state (1284): leaving the world at all, and a socket
// drop that stays in-world for the reconnect. The disconnect twin is chained BEFORE the
// walk so a drop and a walk landing on the same frame cannot re-diff against membership
// the drop just invalidated.
.add_systems(
Update,
(
channels::end_session_channels_on_disconnect,
channels::auto_join_zone_channels
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.chain(),
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
(channels::end_session_channels, end_session_chat),
);
// The per-character saved look (B246) — its own load/watch/save edges.
settings::plugin(app);
}
}
/// **The chat module's session end** — what the reference gets for free by destroying its Lua
/// state, and we have to do by hand until that teardown lands (1288).
///
/// `shutdown_ui_state`'s own doc carves the reference's logout tail: `PLAYER_LEAVING_WORLD` →
/// `PLAYER_LOGOUT` → the saved files → **destroy the Lua state**. That last step is the one
/// `crate::ui_script::IngameUiLoaded` exists to stand in for; while it does, every window in the
/// VM keeps its contents across a character switch. The director saw it as the previous
/// character's `Joined Channel:` lines still sitting under the new character's — chat scrollback
/// is simply the most visible tenant of a VM that should have been rebuilt.
///
/// So this clears what a fresh VM (and a fresh `ui_chat`) would have: both windows' lines, the
/// edit box's whole cross-open memory — sticky type, the `lastTell` ring, `toldTarget` — and the
/// undrained feed, whose queued lines were addressed to a character that is gone. It is the same
/// shape, and the same reasoning, as [`crate::ui_aura`]'s `end_session_aura_state` (0900).
///
/// **Not on a reconnect**, deliberately, which is why this is not in [`channels`]'s disconnect
/// twin: channel membership is *server* state and dies with the server's `Player` object however
/// the socket ended, but a seamless same-character reconnect (0065) is benilla's own affordance —
/// the reference has no such thing, it goes to the login screen — and inside it the window keeping
/// its scrollback is the whole point.
fn end_session_chat(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut windows: ResMut<frames::ChatWindows>,
mut edit: ResMut<edit::ChatEditState>,
mut log: ResMut<ChatLog>,
) {
end_chat_session(
script.map(NonSendMut::into_inner),
&mut windows,
&mut edit,
&mut log,
);
}
/// [`end_session_chat`]'s body, callable without a `World` — the clear is the law, the system is
/// the wiring.
pub(crate) fn end_chat_session(
script: Option<&mut benilla_ui::script::UiScript>,
windows: &mut frames::ChatWindows,
edit: &mut edit::ChatEditState,
log: &mut ChatLog,
) {
*log = ChatLog::default();
windows.tell_alert_left = 0.0;
// The channel target/number are [`channels::end_session_channels`]'s (1284); everything else
// the box remembers across an open is this session's too.
*edit = edit::ChatEditState::default();
if let Some(script) = script {
for frame in ["ChatFrame1", "ChatFrame2"] {
crate::ui_script::run_or_warn(script, &format!("{frame}:Clear()"));
}
}
}