Files
benilla-octo/crates/benilla-app/src/ui_chat.rs
T
SamandClaude Fable 5 b8758d0b21 the server's broadcasts land, and a ghost walks through closed doors
- 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>
2026-08-31 20:19:32 +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()"));
}
}
}