- the four world broadcasts decode and land — a shutdown countdown, the civilian-kill alarm, a defense-tower capture and the whisper-cap refusal all reached benilla as unhandled opcodes; `SMSG_SERVER_MESSAGE` carries a row id and a fill, never a finished line, so `ServerMessages.dbc` ships (the five sentences a shutdown has, plus the client's own `[%d]: %s` row-missing fallback, which is mechanism, not an error path); the two defense broadcasts are CHANNEL lines, not system lines — delivered once per joined channel whose row carries DEFENSE, the LocalDefense copy zone-matched against the PARENT area while the text names the packet's own — so a character in neither channel sees nothing, faithfully, and since nothing auto-joins WorldDefense in 1.12 an Eastern Plaguelands tower capture reaches only players standing there unless they typed /join; the wow-re cross-check then made the length prefix load-bearing — the client borrows a pointer into the receive buffer and composes NULL on an overrun, so an overrunning length is a refusal here too: the packet drops with a log line, nothing displayed - `BankButtonIDToInvSlotID` is carved: `isBag` is a TYPE test, not truthiness — `lua_isnumber` refuses every non-number tag without ever loading the payload, so `true` takes the same arm as `nil` while `0` and `"1"` take the bag arm — the function is TOTAL (our out-of-range 0 was an invention; the only compares in all 118 bytes are on `lua_isnumber`'s return), and the +59/+39 arithmetic stands confirmed, byte-identical to `ContainerIDToInventoryID`'s second arm - a deliberate right-drag turn no longer stands you while seated — the observation was right and our attribution was wrong: the body-facing commit is refused by two independent gates and the stand arm is skipped outright while the button is held; what stands you is the RELEASE, a press under 200 ms with under 2.25 degrees of yaw dispatched as a right-CLICK whose interact reaches `SetStandState(0)` — this lands the deletion half, with the click's own stand held for one more wow-re answer - a ghost walks through closed doors — the whole mechanism is one instruction image-wide, reading bit 15 through the sign flag to answer "not a collision candidate" for a DOOR when the traced body is a player in ghost form; the bit is a property of whose trace it is, not of the door, so the exclusion rides the two mover-only casts and the set is empty on every living frame — the particle snap, the precipitation probe, the mouse pick and the creature conform keep the door's collider untouched, exactly as the binary leaves them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
230 lines
12 KiB
Rust
230 lines
12 KiB
Rust
//! 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()"));
|
||
}
|
||
}
|
||
}
|