Files
benilla-octo/crates/benilla-app/src/ui_chat.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

214 lines
9.8 KiB
Rust

//! The chat system: [`event`] is the internal `CHAT_MSG_*` currency, [`feed`] turns every source
//! into events with names resolved once, [`frames`] routes them through the `ChatFrame_OnEvent`
//! composer to the docked windows, and [`input`] is the outbound side over [`commands`]' table of
//! every `/command`, built from the reference's own alias strings.
use bevy::prelude::*;
use crate::ui_script::{UiFeed, UiInput};
#[cfg(test)]
mod ace_gate_tests;
/// `/afk` and `/dnd`: the optimistic AFK mirror `[0xb6e5cc]` and the implicit clear that every
/// other chat send and every movement press carries.
mod away;
/// The world broadcasts: `SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`.
mod broadcast;
mod channels;
/// The combat log's chat lines: classification, chat type and each sentence's GlobalString key.
pub(crate) mod combat;
pub(crate) mod commands;
/// The edit box's send path: the chat-type token passed to `SendChatMessage` and its wire kind.
/// `pub(crate)` for `ui_script::chat_tests`, which joins the stock file's token to this resolve.
pub(crate) mod edit;
mod event;
mod feed;
mod frames;
/// The idle handler: the 5-minute auto-sit and auto-AFK, and the 30-minute camp.
pub(crate) mod idle;
mod input;
/// The language gate: the exemptions and fluency lookup behind the chat garble.
mod language;
/// `LoggingChat`/`LoggingCombat`: the two log files `/chatlog` and `/combatlog` toggle.
mod logging;
mod net;
/// The `AUTO_JOIN_GUILD_CHANNEL` cascade, the one place the client joins or leaves
/// `GuildRecruitment - City` on its own.
mod recruitment;
/// The chat windows' saved look: tab tint, alpha and font size, read at login, saved at logout.
pub(crate) mod settings;
#[cfg(test)]
mod tests;
pub(crate) use away::AfkMirror;
pub(crate) use broadcast::Broadcast;
/// The zone-channel catalog's seed, called from the world-entry UI load: addons read the catalog
/// at file scope, before an `Update` push would land.
pub(crate) use channels::seed_zone_channel_catalog;
/// The joined-channel roster and the `ChatChannels.dbc` catalog.
pub(crate) use edit::ChannelState;
/// Test-only: `ui_script::chat_tests` checks every fired name against the live `ChatTypeInfo`.
#[cfg(test)]
pub(crate) use event::event_name;
pub(crate) use event::{default_color, ChatEvent, ChatEventKind};
pub(crate) use feed::ChatLog;
/// The per-character chat cache's restore, called from the world-entry UI load: its two events
/// must precede the session's first chat line and `PLAYER_LOGIN`.
pub(crate) use settings::restore_chat_looks;
pub(crate) struct UiChatPlugin;
impl Plugin for UiChatPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.add_observer(combat::on_cvar);
app.init_resource::<ChatLog>()
.init_resource::<away::AfkMirror>()
.init_resource::<away::AfkMirrorMemo>()
.init_resource::<idle::LastInput>()
.init_resource::<frames::ChatWindows>()
.init_resource::<edit::ChannelState>()
.init_resource::<channels::ZoneChannelWalk>()
.init_resource::<recruitment::GuildRecruitmentCascade>()
.init_resource::<language::ChatLanguages>()
.init_resource::<combat::CombatLogRanges>()
// `combat::on_cvar` writes this and the range set: a missing one panics on the first
// write, which only `scripts/smoke.sh` sees (the unit suites build their own worlds).
.init_resource::<combat::LogPeriodicSpells>()
// These DBC loads must follow `AssetSet::Open`: before it there is no chain, and each
// silently loads nothing.
.add_systems(
Startup,
(
channels::load_chat_channels,
feed::load_emote_texts,
broadcast::load_server_messages,
)
.after(benilla_assets::AssetSet::Open),
)
// The slash-command table needs the VM's globals and the emote catalog, both `Startup`.
.add_systems(PostStartup, commands::build_slash_commands)
// The language gate's feeds, ahead of the chat drain that reads them.
.add_systems(
Update,
(
language::load_language_words,
language::feed_language_skills,
language::feed_default_language,
)
.before(feed::feed_chat),
)
// Descriptor-diff combat-log lines, ahead of the drain so none lands a frame late.
.add_systems(
Update,
(
combat::watch::death_lines,
combat::watch::aura_lines,
combat::watch::pet_loyalty_lines,
)
.before(feed::feed_chat),
)
// Before the drain, so a broadcast lands on the frame it decodes.
.add_systems(
Update,
broadcast::feed_broadcasts
.in_set(crate::ui_script::UiFeed)
.before(feed::feed_chat),
)
// Only against the in-game VM: the drain takes the whole queue and fires every line,
// so a VM with no ChatFrame destroys them. After the unit and guild feeds, as in the
// reference: login-burst chat is queued (`0x49cae0`) behind the `[0x8435fc]` latch and
// drains at `0x490974`, after `PLAYER_LOGIN` (`0x490959`) and `PLAYER_ENTERING_WORLD`
// (`0x49096a`), while `GUILD_MOTD` fires on arrival (`0x5e7288`), so the guild line
// precedes the welcome.
.add_systems(
Update,
feed::feed_chat
.in_set(UiFeed)
.after(crate::ui_unit::UnitFeed)
.after(crate::ui_guild::GuildFeed)
.run_if(crate::ui_script::ingame_ui_up),
)
// The last-input stamp `[0xcf0bc8]`, ungated and ahead of the UI pass: the reference
// stamps raw input before dispatch, so a key the chat box swallows still counts.
.add_systems(Update, idle::stamp_input.before(UiInput))
// A fresh VM's joined-channel mirror, before the feed so its first line is numbered.
.add_systems(
Update,
channels::seed_channels
.in_set(crate::ui_script::UiFeed)
.before(feed::feed_chat),
)
// `RequestTimePlayed()` -> `CMSG_PLAYED_TIME`; `SMSG_PLAYED_TIME` -> `TIME_PLAYED_MSG`.
.add_systems(Update, feed::played_time_bridge.in_set(UiFeed))
.add_systems(
Update,
(
// First, so a `/afk` typed this frame reads the descriptor's settled state.
away::reconcile_afk_mirror,
away::movement_clears_afk,
// After the clears, so a press that stamps the clock and drops the flag is
// settled before the idle timer reads it.
idle::idle_handler,
input::drain_chat_input,
// After the box's drain, so a `SendChatMessage` or `SendAddonMessage` from a
// slash handler it just ran goes out this frame.
input::drain_addon_chat_sends,
input::drain_addon_message_sends,
)
.chain()
.after(UiInput)
// Before the zone-channel walk renumbers or renames a slot, so a `/N` resolves
// against the roster the script's `GetChannelName` read this frame.
.before(channels::end_session_channels_on_disconnect)
.in_set(crate::char_select::InWorldGated),
)
// The zone-channel auto-join, which vmangos leaves to the client. The disconnect clear
// runs first, so a same-frame drop cannot leave the walk diffing stale membership.
// After `AreaAuthoritySet`, since a stale area joins and leaves the wrong channels. The
// guild-recruitment cascade follows the walk: it is the tail of `ZoneChannelRefresh`.
.add_systems(
Update,
(
channels::end_session_channels_on_disconnect,
channels::auto_join_zone_channels.in_set(crate::char_select::InWorldGated),
recruitment::guild_recruitment_cascade.in_set(crate::char_select::InWorldGated),
)
.chain()
.after(benilla_world::terrain_stream::AreaAuthoritySet),
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
(channels::end_session_channels, end_session_chat),
);
settings::plugin(app);
logging::plugin(app);
}
}
/// The chat session end: the undrained feed, which outlives the Lua state, and both windows'
/// lines. The Lua state is itself replaced at the session end, as the reference destroys its own
/// at logout.
///
/// Deviation: not run on benilla's same-character reconnect, which the reference lacks (it
/// returns to the login screen), so the window keeps its scrollback there.
fn end_session_chat(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut log: ResMut<ChatLog>,
) {
end_chat_session(script.map(NonSendMut::into_inner), &mut log);
}
/// [`end_session_chat`]'s body, callable without a `World`.
pub(crate) fn end_chat_session(
script: Option<&mut benilla_ui::script::UiScript>,
log: &mut ChatLog,
) {
*log = ChatLog::default();
if let Some(script) = script {
for frame in ["ChatFrame1", "ChatFrame2"] {
crate::ui_script::run_or_warn(script, &format!("{frame}:Clear()"));
}
}
}