Files
benilla-octo/crates/benilla-app/src/ui_chat/input/parse.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

360 lines
16 KiB
Rust

//! What a submitted slash line means: [`parse_line`] resolves it through the boot-built command
//! table ([`super::super::commands`], the shipped `GlobalStrings.lua` aliases) into a
//! [`ParsedChat`] for [`super`] to execute. A typed line reaches it only through benilla's own
//! `SlashCmdList` entries, once the stock `ChatEdit_ParseText` finds no built-in; a probe's line
//! reaches it directly.
use crate::ui_chat::commands::{Command, DevCmd, SlashCommands, SlashIndex};
/// Escape a typed string for a Lua double-quoted literal: `\` and `"` are escaped, and line
/// terminators are dropped, since one would end the statement.
pub(in crate::ui_chat) fn escape_lua_string(s: &str) -> String {
s.chars()
.filter(|c| *c != '\n' && *c != '\r')
.flat_map(|c| {
let escaped = matches!(c, '\\' | '"');
escaped
.then_some('\\')
.into_iter()
.chain(std::iter::once(c))
})
.collect()
}
/// A social slash line as the reference runs it: `SlashCmdList["<KEY>"](<arg>)`.
fn social_body(key: &str, args: &str) -> ParsedChat {
ParsedChat::Lua {
body: format!("SlashCmdList[\"{key}\"](\"{}\")", escape_lua_string(args)),
}
}
/// What a trimmed slash line resolves to.
#[derive(Debug, Clone, PartialEq)]
pub(in crate::ui_chat) enum ParsedChat {
/// `/r [text]`: reply to the last tell.
Reply { text: String },
/// `/join <name> [password]`.
Join { name: String, password: String },
/// `/leave <name>`.
Leave { name: String },
/// `/chatlist <name>`: the channel's member roster.
ChatList { name: String },
/// `/random [min] [max]`: bare is 1-100, one number is 1-N.
Random { min: u32, max: u32 },
/// `/played`: `CMSG_PLAYED_TIME`.
Played,
/// `/shot`: the camera pose as a capture `Scenario` line, to chat and `shots.txt`.
Shot,
/// `/liquid`: the interior claim, liquid verdict and candidate footprints at the feet.
Liquid,
/// `/reaction [name]`: every input and rung of the reaction ladder for one unit.
Reaction { name: Option<String> },
/// `/convertraid` (`CMSG_GROUP_RAID_CONVERT`), benilla's own: 1.12 converts only from the
/// RaidFrame's Convert button.
ConvertRaid,
/// `/help`.
Help,
/// An `EmotesText` command (`/wave` is 101), sent as `CMSG_TEXT_EMOTE` at the selection.
TextEmote { text_id: u32, arg: Option<String> },
/// A channel verb the stock handler parsed and the VM queued; never from [`parse_line`].
Channel(benilla_ui::script::ChannelCommand),
/// `/castvis`: a locally synthesized [`crate::creature_anim::CastEvent`], at the selection
/// or self.
CastVis {
spell_id: u32,
kind: crate::creature_anim::CastEventKind,
/// A pure dest cast (no hits, a point): the only shape that reaches the location fallback.
ground: bool,
},
/// A reference handler that is one Lua call (`InitiateTrade("target")`, `RunScript(msg)`, …).
Lua { body: String },
/// `/quit`: `Quit()`, through the game menu Exit button's queue and countdown.
Quit,
/// `/logout`: `Logout()`, the game menu's route back to character select.
Logout,
/// `/chattest`: one synthetic line of every kind, notice and link form.
ChatTest,
/// `/invite` (`CMSG_GROUP_INVITE`): bare falls back to a selected player's name, else does
/// nothing (`GetSlashCmdTarget`, `ChatFrame.lua:650-658`).
Invite { name: Option<String> },
/// `/uninvite` (`CMSG_GROUP_UNINVITE`), with the same fallback.
Uninvite { name: Option<String> },
/// `/promote` (`CMSG_GROUP_SET_LEADER`, a guid resolved from the roster), same fallback.
Promote { name: Option<String> },
/// `/duel [name]` (`StartDuel`), with the same fallback.
Duel { name: Option<String> },
/// `/forfeit`: `CancelDuel()`, one opcode for decline, cancel and forfeit.
Forfeit,
/// `/pvp`: `TogglePVP()`.
Pvp,
/// `/partytest [lead|raid|invite|mark|ping|off]`: a synthetic party or 25-member raid, a fake
/// invite, a local skull on the target or a member's minimap ping. While the roster is
/// synthetic, the popup's group-mutating rows apply locally
/// ([`crate::ui_party::GroupState::test`]).
PartyTest { arg: String },
/// `/target [name]` (`TargetByName`): creatures and players, case-insensitive, whole name or
/// longest common prefix, with no range, cone, liveness or hostility gate.
Target { name: Option<String> },
/// `/assist [name]`: a named basis is a player (typemask `0x10`); bare assists the target.
Assist { name: Option<String> },
/// `/follow [name]`: a named subject is a living, assistable player (filter mode 2); bare
/// follows the selection. Client-side, nothing on the wire.
Follow { name: Option<String> },
/// `/macrohelp`: `ChatFrame_DisplayMacroHelpText`'s five `MACRO_HELP_TEXT_LINE`s.
MacroHelp,
/// `/reload`, benilla's own: the in-world rebuild `ReloadUI()` queues.
ReloadUi,
/// A `/console` line the CVar store did not consume, for the `ConsoleCommand` registry
/// ([`crate::console::execute`]).
Console { line: String },
/// A slash line matching neither a command nor an `EmotesText` name.
Unknown,
}
/// The name half of `GetSlashCmdTarget` (`ChatFrame.lua:650-665`), shared by the stock commands
/// that take a name. Its gsub is a trim, not a tokenizer, so "Kobold Vermin" is one name; `args`
/// arrives trimmed. Empty defers to the caller's fallback (`target_player_name`). The reference
/// also expands `player`, `target`, `^party[1-4]` and `^raid[0-9]` through `UnitName`; this
/// does not, so a unit token passes through as a literal name.
fn slash_target_name(args: &str) -> Option<String> {
(!args.is_empty()).then(|| args.to_string())
}
/// Parse a trimmed slash line through the boot-built command table; a line with no slash is
/// [`ParsedChat::Unknown`]. The split is `ChatEdit_ParseText`'s (`ChatFrame.lua:2099-2105`): the
/// command is the first word and the argument the rest, so `/wave Bob` is `/wave` with an argument.
pub(in crate::ui_chat) fn parse_line(table: &SlashCommands, line: &str) -> ParsedChat {
let Some(rest) = line.strip_prefix('/') else {
return ParsedChat::Unknown;
};
let rest = rest.trim();
let (cmd, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let args = args.trim();
match table.lookup(cmd) {
Some(Command::Slash(index)) => slash_command(index, args),
// The alias table did the `DoEmote(token)` resolve at boot: `/lol` arrives as LAUGH's id.
Some(Command::Emote { text_id }) => ParsedChat::TextEmote {
text_id,
arg: (!args.is_empty()).then(|| args.to_string()),
},
Some(Command::Dev(dev)) => dev_command(dev, args),
// `HELP_TEXT_SIMPLE`'s case, after the drain offers the line to `SlashCmdList`.
None => ParsedChat::Unknown,
}
}
/// `s` as an escaped Lua short string literal. A long bracket cannot quote it: a `/console`
/// argument may hold `]]`, and the 1.12 lexer has no bracket levels (the `[` arm at `0x6ff771`
/// compares once, `read_long_string` `0x700010` reads `=` as content), so `[=[` fails in
/// `prefixexp` (`0x6fde40`). `\` and `"` escape themselves, `\n` `\r` `\t` take their names, and
/// any other control byte a three-digit `\ddd`, so a following digit is not swallowed.
pub(in crate::ui_chat) fn lua_quoted_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
out.push_str(&format!("\\{:03}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
/// The per-command argument grammar: each arm is the reference handler's body reduced to what it
/// does with `msg`.
fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
use SlashIndex as S;
match index {
S::Reply => ParsedChat::Reply {
text: args.to_string(),
},
S::Join => {
let (name, password) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
if name.is_empty() {
// Unknown hands the line to the stock handler, which prints `CHAT_JOIN_HELP`.
ParsedChat::Unknown
} else {
ParsedChat::Join {
name: name.to_string(),
password: password.trim().to_string(),
}
}
}
S::Leave => match args.split_whitespace().next() {
Some(name) => ParsedChat::Leave {
name: name.to_string(),
},
None => ParsedChat::Unknown,
},
S::ListChannel => match args.split_whitespace().next() {
Some(name) => ParsedChat::ChatList {
name: name.to_string(),
},
// Unknown hands a bare line to the stock handler, which runs `ListChannels()`.
None => ParsedChat::Unknown,
},
// The stock bodies: a probe's `/afk` takes the same `SendChatMessage` path, echo, default
// text and mirror included, as a typed one.
S::ChatAfk => social_body("CHAT_AFK", args),
S::ChatDnd => social_body("CHAT_DND", args),
S::Random => {
let mut nums = args
.split_whitespace()
.filter_map(|w| w.parse::<u32>().ok());
let (a, b) = (nums.next(), nums.next());
let (min, max) = match (a, b) {
(Some(a), Some(b)) => (a, b),
(Some(a), None) => (1, a),
_ => (1, 100),
};
ParsedChat::Random { min, max }
}
S::Played => ParsedChat::Played,
S::Help => ParsedChat::Help,
S::Logout => ParsedChat::Logout,
S::Quit => ParsedChat::Quit,
// A bare command falls back to the selected player (`GetSlashCmdTarget`).
S::Invite => ParsedChat::Invite {
name: slash_target_name(args),
},
S::Uninvite => ParsedChat::Uninvite {
name: slash_target_name(args),
},
S::Promote => ParsedChat::Promote {
name: slash_target_name(args),
},
S::Duel => ParsedChat::Duel {
name: slash_target_name(args),
},
// The whole trimmed argument is the name: `/target Kobold Vermin`.
S::Target => ParsedChat::Target {
name: slash_target_name(args),
},
S::Assist => ParsedChat::Assist {
name: slash_target_name(args),
},
S::Follow => ParsedChat::Follow {
name: slash_target_name(args),
},
S::DuelCancel => ParsedChat::Forfeit,
S::Pvp => ParsedChat::Pvp,
// The stock bodies, with the whole argument: `/who`'s is a filter expression.
S::Who => social_body("WHO", args),
S::Friends => social_body("FRIENDS", args),
S::RemoveFriend => social_body("REMOVEFRIEND", args),
S::Ignore => social_body("IGNORE", args),
S::Unignore => social_body("UNIGNORE", args),
// The reference's one-line bodies, run as Lua.
S::Trade => ParsedChat::Lua {
body: "InitiateTrade(\"target\")".into(),
},
S::Inspect => ParsedChat::Lua {
body: "InspectUnit(\"target\")".into(),
},
S::LootFfa => ParsedChat::Lua {
body: "SetLootMethod(\"freeforall\")".into(),
},
S::LootRoundRobin => ParsedChat::Lua {
body: "SetLootMethod(\"roundrobin\")".into(),
},
// `LOOT_MASTER` does nothing without a name or player target (`if GetSlashCmdTarget(msg)`).
S::LootMaster => match slash_target_name(args) {
Some(name) => ParsedChat::Lua {
body: format!(
"SetLootMethod(\"master\", \"{}\")",
escape_lua_string(&name)
),
},
None => ParsedChat::Unknown,
},
// `SlashCmdList["CAST"]` (`ChatFrame.lua:1120`): `CastSpellByName(msg)` unless empty.
S::Cast => {
if args.is_empty() {
ParsedChat::Unknown
} else {
ParsedChat::Lua {
body: format!("CastSpellByName(\"{}\")", escape_lua_string(args)),
}
}
}
// `SlashCmdList["MACRO"]` (`ChatFrame.lua:1112`): `ShowMacroFrame()`. Its `RunMacro`
// branch is commented out, and the 1.12 client registers no `RunMacro`.
S::MacroUi => ParsedChat::Lua {
body: "ShowMacroFrame()".into(),
},
S::MacroHelp => ParsedChat::MacroHelp,
// `/reload` is the rebuild `/console reloadUI` runs (`0x4035f0`, which reads no arguments).
S::ReloadUi => ParsedChat::ReloadUi,
S::ConvertRaid => ParsedChat::ConvertRaid,
// `/errors`, benilla's own: toggles the script error log, a FrameXML window;
// `/errors clear` empties it.
S::ScriptErrors => ParsedChat::Lua {
body: if args.trim().eq_ignore_ascii_case("clear") {
"BenillaScriptLog_Clear()".into()
} else {
"BenillaScriptLog_Toggle()".into()
},
},
// `SlashCmdList["CONSOLE"]` (`ChatFrame.lua:671`) is `ConsoleExec(msg)`. A typed line runs
// it; one that skipped the edit box calls the same verb, so a CVar line writes the CVar
// and anything else comes back through `engine_verbs`.
S::Console => ParsedChat::Lua {
body: format!("ConsoleExec({})", lua_quoted_string(args)),
},
// `RunScript(msg)`: the typed text is the chunk, unescaped.
S::Script => {
if args.is_empty() {
ParsedChat::Unknown
} else {
ParsedChat::Lua {
body: args.to_string(),
}
}
}
}
}
/// benilla's own instruments' grammar ([`DevCmd`]), in the command table only in a dev build.
fn dev_command(dev: DevCmd, args: &str) -> ParsedChat {
match dev {
// `/castvis <spell_id> [go|ground|fail]`: bare starts the precast, `go` releases at the
// selection, `ground` releases as a pure dest cast ahead of the player, `fail` reaps.
DevCmd::CastVis => {
use crate::creature_anim::CastEventKind;
let mut words = args.split_whitespace();
let Some(spell_id) = words.next().and_then(|w| w.parse::<u32>().ok()) else {
return ParsedChat::Unknown;
};
let (kind, ground) = match words.next() {
None => (CastEventKind::Start, false),
Some(w) if w.eq_ignore_ascii_case("go") => (CastEventKind::Go, false),
Some(w) if w.eq_ignore_ascii_case("ground") => (CastEventKind::Go, true),
Some(w) if w.eq_ignore_ascii_case("fail") => (CastEventKind::Fail, false),
Some(_) => return ParsedChat::Unknown,
};
ParsedChat::CastVis {
spell_id,
kind,
ground,
}
}
DevCmd::ChatTest => ParsedChat::ChatTest,
DevCmd::PartyTest => ParsedChat::PartyTest {
arg: args.to_ascii_lowercase(),
},
DevCmd::Shot => ParsedChat::Shot,
DevCmd::Liquid => ParsedChat::Liquid,
DevCmd::Reaction => ParsedChat::Reaction {
name: slash_target_name(args),
},
}
}