speech you don't know arrives scrambled, and a GPU crash dies

- opposite-faction speech garbles client-side now, the way 1.12.1 actually does it: the wire carries plaintext plus a language id, and the client substitutes per word — a word survives iff `SStrHash(word) % 300 < skill` (300 copies verbatim) — reimplemented from `0x49b560` over the LanguageWords pool, 35 golden vectors passing; a Horde character also stops seeing a spurious `[Orcish]` tag on every line of their own faction's chat, and the talk gesture reads the plaintext, not the garbled line
- a mid-session wgpu validation crash is gone: `NoIndirectDrawing` rides the camera's spawn instead of a per-frame sweep — 18 cameras were being flagged after spawn, and one whose first extraction beat the sweep latched indirect buffers forever; the census now reads zero late inserts with the direct-draw optimisation fully intact
- the 20-yard overhead-name cap is withdrawn: it read the V-key nameplate frame's law onto the overhead name lane, which the bytes show carries no distance term at all — a distant name just gets small; V-plates keep their 20 yd, which is correctly theirs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-21 18:06:27 +07:00
co-authored by Claude Fable 5
parent a8a7d85ca2
commit 80ddafd98b
13 changed files with 1271 additions and 101 deletions
+12 -26
View File
@@ -74,12 +74,6 @@ const SCALE_FLOOR: f32 = 0.2; // [0x80679c]
const SCALE_KNEE: f32 = 4.0; // [0x8112a8]
const SCALE_RATE: f32 = 1.5; // [0x8112ac]
/// The plate cull's squared comparand — the reference's `[0xc4d988] = 400.0 = 20²` yd², written
/// once from `.rdata 0x80c448 = 20.0`, which no CVar registrar references: **hardcoded**, and
/// not a distance shrink either (plate size never scales with range — verified by exhaustive
/// absence). wow-re `nameplate-vkey.md` §5 / `nameplate-offscreen-cull.md` §2.
const NAMEPLATE_RANGE_SQ: f32 = 400.0;
/// benilla's nameplate config (the client's `0xce8720` cvar mask, reduced to what streams today),
/// player-settable since 0992: 1.12's own `UnitNamePlayer`/`UnitNameNPC`/`UnitNameOwn` CVars over
/// these gates (the Options window's Nameplates page, through the 0954 store — the arms live in
@@ -371,9 +365,6 @@ pub(crate) fn drive_nameplates(
Option<&InheritedVisibility>,
)>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
// The 20-yard cap's anchor: the reference measures from the LOCAL PLAYER, not the camera
// (`0x60f600`'s 3-D squared distance).
self_tf: Query<&Transform, With<SelfPlayer>>,
// The show-gate inputs (one tuple param — Bevy's 16-param ceiling).
gates: (
Res<Selection>,
@@ -468,23 +459,18 @@ pub(crate) fn drive_nameplates(
if !drawn.is_none_or(|v| v.get()) {
continue;
}
// **The 20-yard cap** (wow-re `nameplate-offscreen-cull.md` §2, VERIFIED): `0x60f600`
// destroys the frame when the 3-D squared player→unit distance exceeds
// `[0xc4d988] = 400 = 20²` — hardcoded (`.rdata 0x80c448`, no CVar registrar ref),
// measured from the LOCAL PLAYER, never the camera, with no line-of-sight term. Beyond
// it the reference DESTROYS the plate rather than hiding it; skipping here keeps this
// unit out of `seen`, and the retain sweep below IS that destroy. The reference runs
// this gate LAST in its admission ladder (after the projection gate) — every rung is
// verdict-independent, so hoisting it changes no outcome, only how much of the ladder
// the far crowd pays. With no self body (a capture rig without a player) the gate
// stands down — the reference's "no local player ⇒ destroy" rung is the fixtures' call,
// not this one's (named residual, 1490).
if let Ok(anchor) = self_tf.single() {
if !is_self && tf.translation.distance_squared(anchor.translation) > NAMEPLATE_RANGE_SQ
{
continue;
}
}
// **There is NO distance cull here, and adding one is the mistake to not make twice.**
// The overhead NAME (`CGUnit+0xc7c`, a `PLAYERNAMEDESC`) and the V-key nameplate FRAME
// (`CGUnit+0xe60`) are two systems on the same unit, and only the FRAME carries the
// 20-yard cap (`0x60f600` vs `[0xc4d988] = 400`) that `vplates.rs` implements. This
// lane's own update/cull/build (`0x6c6d40`/`0x6c6e00`/`0x6c6e90`) holds **zero** distance
// compares — every early-out is identity/state-based, and the one FP compare is the
// height law above, not a depth term (wow-re `overhead-name.md` §Q5 + `playername.md`,
// both VERIFIED). A far name just gets small: it is a world billboard, apparent size ∝
// scale/depth. The effective range is the server's interest management — no CGUnit, no
// desc. 1490 item 7 read the frame's law onto this lane and capped it at 20 yd; the
// director's Elwynn shot (named wolves up the hill) is the reference's own behaviour, and
// it outranked the mis-scoped citation. Superseded by 1492.
// The ShouldShowName gate, in the client's own order (own-unit answers its cvar BEFORE
// the rescue; everyone else: the current-TARGET bypass — `[0xb4e2d8]` is the selection,
// not the mouseover — then the kind cvar). A unit carrying a V-key nameplate never also
+15
View File
@@ -21,6 +21,8 @@ mod event;
mod feed;
mod frames;
mod input;
/// The language gate — the exemptions and the fluency lookup behind the chat garble (B262).
mod language;
#[cfg(test)]
mod tests;
@@ -41,6 +43,7 @@ impl Plugin for UiChatPlugin {
.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`
@@ -60,6 +63,18 @@ impl Plugin for UiChatPlugin {
.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),
)
.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.
+8 -7
View File
@@ -163,13 +163,14 @@ impl ChatEventKind {
/// in the local list — see [`super::edit::ChannelState::stamp_channel`], which is the only place
/// they are written.
///
/// **KNOWN GAP — arg1 should be GARBLED, and ours is not.** The reference garbles the body
/// client-side (`0x49b560` from `0x49aa7c`) and passes the wire text through only when the language
/// is Universal, there is no local player, or the player's skill in that language is ≥ 300
/// (`0x49b599 cmp esi,0x12c`). benilla speaks Common and does not model language skill at all
/// (0288 §3 keeps garbling out with the language picker), so arg1 is always the ungarbled wire
/// text. An addon reading a foreign-language line therefore sees more than the reference would give
/// it. Also unmodeled: the profanity filter `0x4a1ca0` (`0x49ab23`), which in the reference can
/// **arg1 IS the garbled text** (B262, decision 1485). The reference fills `0x49a870`'s one buffer
/// exactly once — a plain `SStrCopy` at `0x49a9f0` or the garble `0x49b560` at `0x49aa7c` — and
/// never reads the raw wire pointer again, so every consumer downstream shares it: the chat line,
/// this event's arg1, and the bubble. **An addon receiving a foreign-language line cannot recover
/// the plaintext**, and no other slot in this ten-argument tuple carries the body. Ours now behaves
/// the same way ([`super::language`] owns the gate, [`benilla_formats::garble`] the substitution).
///
/// Still unmodelled: the profanity filter `0x4a1ca0` (`0x49ab23`), which in the reference can
/// suppress the whole event for non-whisper types.
///
/// **Corrections on record.** This comment previously listed args 1-6, 8 and 9 only — arg7 and
+50 -10
View File
@@ -526,6 +526,19 @@ pub(super) fn deliver(
}
}
/// What the **speaker** does when a line lands — the over-the-head bubble and the talk/laugh
/// gesture, bundled because the reference arms both from the one display path (`ChatFrame.cpp`) and
/// because a Bevy system takes at most sixteen parameters, which [`feed_chat`] had already reached.
///
/// They stay separate mechanisms inside the bundle: the bubble has a 20 yd range test and two CVars
/// ([`crate::chat_bubble`]), the gesture has neither (decision 1469).
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct SpeakerEffects<'w> {
bubbles: ResMut<'w, crate::chat_bubble::BubbleQueue>,
bubble_cfg: Res<'w, crate::chat_bubble::BubbleConfig>,
gestures: ResMut<'w, crate::creature_anim::GestureQueue>,
}
/// Drain [`ChatLog`]: resolve names (ask-once, bounded), build events, [`route`] them. Also ticks
/// the whisper-chime throttle.
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
@@ -536,11 +549,7 @@ pub(super) fn feed_chat(
mut edit: ResMut<super::edit::ChatEditState>,
mut channels: ResMut<ChannelState>,
mut names: ResMut<NameCache>,
mut bubbles: ResMut<crate::chat_bubble::BubbleQueue>,
bubble_cfg: Res<crate::chat_bubble::BubbleConfig>,
// The client-local talk gesture (decision 1469) — armed from this same display path, beside
// the bubble, exactly as the reference arms both from `ChatFrame.cpp`.
mut gestures: ResMut<crate::creature_anim::GestureQueue>,
mut speaker: SpeakerEffects,
commands: Res<NetCommands>,
time: Res<Time>,
// The text-emote sentence seam (decision 1274): the tables, plus the guid the composer's
@@ -552,6 +561,8 @@ pub(super) fn feed_chat(
guids: Res<GuidIndex>,
stores: Query<&ObjectStore>,
states: Res<crate::world_state::WorldStates>,
// The language gate (B262): the word pool + this character's fluency + the GM bit.
langs: Res<super::language::ChatLanguages>,
) {
let Some(mut script) = script else {
return;
@@ -655,7 +666,17 @@ pub(super) fn feed_chat(
} else {
None
};
let text = expanded.unwrap_or_else(|| msg.text.clone());
let plain = expanded.unwrap_or_else(|| msg.text.clone());
// **The language gate** (B262, wow-re `chat-language-scramble.md`). The wire always
// carries plaintext; whether this character can read it is entirely ours to decide,
// and the answer is one rewritten buffer that EVERY consumer below shares — the
// chat line, the Lua `arg1`, the bubble, the gesture. That is the reference's own
// shape: `0x49a870` fills `[ebp-0xd0c]` exactly once (either a plain `SStrCopy` at
// `0x49a9f0` or the garble at `0x49aa7c`) and never reads the raw wire pointer
// again, so an addon receiving a foreign-language line cannot recover the
// plaintext. Ours cannot either, deliberately.
let language = langs.effective_language(msg.chat_type, msg.language);
let text = langs.garble(language, &plain);
let mut event = ChatEvent {
kind,
text: text.clone(),
@@ -666,7 +687,11 @@ pub(super) fn feed_chat(
String::new()
}
}),
language: language_name(msg.language).to_string(),
// The EFFECTIVE language, not the wire's: a narration type and GM mode both
// force it to 0, which is one decision in the reference rather than two — the
// `[Language]` header keys off this same field, so suppressing the garble
// suppresses the header with it.
language: language_name(language).to_string(),
channel: channel_base,
flag: flag_of_tag(msg.chat_tag).to_string(),
..Default::default()
@@ -684,15 +709,30 @@ pub(super) fn feed_chat(
// The bubble shows the same expanded line the feed does — the reference's
// bubble spawn sits inside this same SMSG display path, downstream of the
// expander, so a `$n` must never survive into it either.
bubbles.push(&bubble_cfg, msg.sender_guid, kind, &text);
speaker
.bubbles
.push(&speaker.bubble_cfg, msg.sender_guid, kind, &text);
}
// …and the speaker gestures. Independent of the bubble in the reference too
// (different function, different gates: the bubble has a 20 yd range test and two
// CVars, the gesture has neither). The selector reads the RAW wire type and the
// expanded text, and takes its laugh words off the player's own FrameXML globals —
// the enumeration is the mechanism, the words are content (decision 1469).
//
// **It reads `plain`, NOT the garbled text, and that is byte-verified rather than
// reasoned** (wow-re `chat-language-scramble.md` §10.1). The selector is not on
// this display path at all: it lives in the *parser* `0x49d560` at
// `0x49d820`-`0x49d8ae`, and the slot it matches against — `[ebp-0x10]` — is the
// very buffer `0x49dbc2` then hands to `0x49a870` as its `src`. The garbled buffer
// is a local of a frame that does not exist yet.
//
// So the gesture is **language-independent**: a Horde player yelling `lol` laughs
// for every observer, Alliance included. We had this wired to the garbled text on
// an inference from §10's consumer census — that census was complete for
// `0x49a870` and could never have found a consumer reading the pre-garble value in
// the *caller's* frame.
if let Some(gesture) =
crate::creature_anim::select_gesture(msg.chat_type, &text, |n| {
crate::creature_anim::select_gesture(msg.chat_type, &plain, |n| {
script
.lua()
.globals()
@@ -700,7 +740,7 @@ pub(super) fn feed_chat(
.ok()
})
{
gestures.push(msg.sender_guid, gesture);
speaker.gestures.push(msg.sender_guid, gesture);
}
}
Pending::Notice {
+34 -3
View File
@@ -29,6 +29,15 @@ pub(crate) struct ChatWindows {
pub groups: [Vec<ChatGroup>; 2],
/// Seconds left before the next received whisper chimes again ([`TELL_ALERT_SECS`]).
pub tell_alert_left: f32,
/// The frame's own `this.defaultLanguage` — the name `GetDefaultLanguage()` answers, which is
/// the **faction** tongue (Common for every Alliance race, Orcish for every Horde one).
///
/// It lives here rather than being derived per line because that is where the reference keeps
/// it: `ChatFrame.lua` stores it on the frame and the language-header test reads it from there
/// ([`compose`]). Empty until the self descriptors and `Languages.dbc` are both up, which
/// suppresses no header the reference would show — an empty default only ever makes the test
/// *more* likely to print one.
pub default_language: String,
}
impl Default for ChatWindows {
@@ -55,6 +64,7 @@ impl Default for ChatWindows {
vec![ChatGroup::Money, ChatGroup::CombatXpGain],
],
tell_alert_left: 0.0,
default_language: String::new(),
}
}
}
@@ -111,7 +121,9 @@ pub(crate) fn route(
return;
};
// ── our own window: the transcribed ChatFrame_OnEvent, i.e. the first-registered listener ──
if let Some(line) = compose(event, kind) {
// Cloned once rather than borrowed, because `windows` goes on to be used mutably below.
let default_language = windows.default_language.clone();
if let Some(line) = compose(event, kind, &default_language) {
let color = resolved_color(event, kind);
for idx in 0..2 {
if windows.wants(idx, kind) {
@@ -149,7 +161,11 @@ fn add(script: &mut benilla_ui::script::UiScript, frame: &str, text: &str, color
/// `ChatFrame_OnEvent`'s composition, transcribed (ref ChatFrame.lua l.1369-1468 + the quoted
/// GlobalStrings). Returns `None` for a notice the 1.12 UI renders silently (MODE_CHANGE).
pub(crate) fn compose(event: &ChatEvent, kind: ChatEventKind) -> Option<String> {
pub(crate) fn compose(
event: &ChatEvent,
kind: ChatEventKind,
default_language: &str,
) -> Option<String> {
use ChatEventKind as K;
Some(match kind {
// Verbatim families (l.1395-1402): the text IS the line. COMBAT_XP_GAIN rides the same
@@ -198,7 +214,22 @@ pub(crate) fn compose(event: &ChatEvent, kind: ChatEventKind) -> Option<String>
};
// The language header (l.1442-1448): non-empty, non-Universal (mapped to "" by the
// bridge), and not our own default tongue.
let header = if !event.language.is_empty() && event.language != "Common" {
//
// **That last clause used to read `!= "Common"`** — the comment was already right and
// the code was not, which cost every Horde character a `[Orcish]` tag on ordinary
// faction chat and stripped the tag from Common. The reference's test is
// `arg3 ~= this.defaultLanguage` and `GetDefaultLanguage()` answers the **faction**
// tongue, so it reads Orcish for a Horde body (wow-re
// `system/ui/scratch/chat-language-scramble.md` §10/§12: the tag is FrameXML's and its
// condition is about the *default* language, never about whether the language is
// understood — a character who knows both Common and Dwarvish still sees `[Dwarvish]`
// on a line they read perfectly).
//
// The `~= "Universal"` clause of the reference's condition is deliberately absent:
// "Universal" is in neither `Languages.dbc` nor `WoW.exe` nor `GlobalStrings.lua`, so
// it is vestigial in 1.12 and the empty-string test is what actually suppresses
// language 0 (see [`super::event::ChatEvent`]'s arg3 note).
let header = if !event.language.is_empty() && event.language != default_language {
format!("[{}] ", event.language)
} else {
String::new()
+300
View File
@@ -0,0 +1,300 @@
//! **The language gate** — everything that decides *whether* a chat line is garbled, and by how
//! much. The substitution itself is [`benilla_formats::garble`] (the reference's `0x49b560`); this
//! module is `0x49a870`'s share of the work: the exemptions, and the "how well does this character
//! know that language" answer the routine takes as its second argument.
//!
//! Byte-verified in wow-re `system/ui/scratch/chat-language-scramble.md` §8. The chain is entirely
//! client-side over server-synced skill data — **nothing is pushed as a "language list"**:
//!
//! ```text
//! language id ──(the spell whose Effect_1 == 39 declares it, over the KNOWN spells)──▶ spell id
//! ──(that spell's SkillLineAbility row)──▶ SkillLine.dbc id
//! ──(the player's PLAYER_SKILL_INFO block)──▶ skill value
//! ```
//!
//! Then `skill >= 300` is fluent and the line passes through untouched; below that every *word*
//! runs its own `hash % 300 < skill` test, so partial skill is graduated rather than a switch.
//!
//! **Why the first hop reads spell → language and not the reverse** is the shipped data, and it is
//! recorded on [`benilla_formats::SpellCatalog::declared_language`]: five spells declare Common
//! rather than their own language, so a whole-DBC map inverted the other way makes *Common* resolve
//! to Old Tongue and garbles everyone's own faction chat. Folding spell → language over the
//! character's own known-spell set is what the reference does (`0x4b2656` runs on spell *add*) and
//! it is the only shape that survives that data.
use std::collections::HashMap;
use benilla_formats::LanguageWords;
use bevy::prelude::*;
use crate::net::{ObjectStore, SelfPlayer};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_spellbook::SkillLines;
/// `PLAYER_FLAGS_GM` — descriptor index 190, bit 3 (`0x49a9cc` reads `[[player+0xe68]+8] >> 3 & 1`).
const PLAYER_FLAGS_GM: u32 = 0x8;
/// The chat types that **force the language to 0** regardless of what the wire said
/// (`0x49a970`–`0x49a986`): EMOTE, SYSTEM, MONSTER_EMOTE and RAID_BOSS_EMOTE.
///
/// They are narration, not speech — "Thrall glares at you" is not spoken in Orcish — and forcing 0
/// suppresses both the garble and the `[Language]` header in one move, since the header keys off the
/// same field.
const ALWAYS_UNIVERSAL: [u8; 4] = [
benilla_protocol::messages::CHAT_MSG_EMOTE,
benilla_protocol::messages::CHAT_MSG_SYSTEM,
benilla_protocol::messages::CHAT_MSG_MONSTER_EMOTE,
benilla_protocol::messages::CHAT_MSG_RAID_BOSS_EMOTE,
];
/// The garble word pool plus the local character's fluency in each language — everything
/// [`effective_language`] and [`garble`] need, in one resource so the chat feed spends one system
/// param on the whole gate.
#[derive(Resource, Default)]
pub(crate) struct ChatLanguages {
/// `LanguageWords.dbc`, loaded once. `None` while the chain has not been read (or if it could
/// not be), in which case nothing garbles — a broken install renders chat plainly rather than
/// blanking it.
words: Option<LanguageWords>,
/// language id → this character's skill in it. Absent = 0 = never understood.
skill: HashMap<u32, u32>,
/// `PLAYER_FLAGS & 0x8`. A GM sees **everything** plain, and with an empty `[Language]` header
/// — the same bit also skips the profanity filter in the reference.
gm: bool,
/// Is there a local player object at all?
///
/// `0x49b560`'s **second** early-out (`0x49b590` → `0x49b597 test eax,eax; je`) is exactly this:
/// with no local player the line is copied verbatim. It matters because the alternative is
/// worse than a divergence — with no body we would find no skill row, read 0, and garble
/// *everything* rather than nothing. A line arriving before the self entity exists must render
/// plainly, not as gibberish.
have_player: bool,
}
impl ChatLanguages {
/// This character's skill in `language` — the second argument of `0x49b560`.
pub(crate) fn skill(&self, language: u32) -> u32 {
self.skill.get(&language).copied().unwrap_or(0)
}
/// The language this line is **rendered** as, after the exemptions `0x49a870` applies before it
/// ever reaches the garble routine. `0` means "render plain, and show no `[Language]` header" —
/// which is one decision in the reference, not two, because the header reads the same field.
///
/// The addon sentinel is *not* handled here: `language == -1` never reaches this path at all
/// (it is dropped upstream as addon traffic, decision 1029), and it is a `u32` by the time we
/// see it.
pub(crate) fn effective_language(&self, chat_type: u8, language: u32) -> u32 {
if self.gm || !self.have_player || ALWAYS_UNIVERSAL.contains(&chat_type) {
return 0;
}
language
}
/// Rewrite a line as this character hears it. Returns `text` unchanged for language 0, for a
/// fluent listener, and when the pool is unavailable.
pub(crate) fn garble(&self, language: u32, text: &str) -> String {
let Some(words) = self.words.as_ref() else {
return text.to_string();
};
benilla_formats::garble_chat(words, language, self.skill(language), text)
}
}
/// Load `LanguageWords.dbc` once at startup ([`crate::ui_unit`]'s `load_default_languages` shape).
pub(super) fn load_language_words(
mut langs: ResMut<ChatLanguages>,
assets: Option<Res<benilla_assets::WorldAssets>>,
) {
if langs.words.is_some() {
return;
}
let Some(assets) = assets else { return };
let loaded = {
use benilla_assets::LockRecover;
let mut chain = assets.chain.lock_recover();
benilla_formats::load_language_words(&mut chain)
};
match loaded {
Ok(words) => {
info!("ui_chat: {} language word pools", words.len());
langs.words = Some(words);
}
// Not fatal: with no pool every line renders plain, which is what benilla did before B262.
Err(e) => warn!("ui_chat: language word pools unavailable — {e:#}"),
}
}
/// Recompute the per-language skill map and the GM bit.
///
/// Cheap and unconditional: the walk is over the character's own language spells (at most a
/// handful) and one pass of the 128-slot skill block per language, and it only runs when the spell
/// book or the self descriptors actually changed. The reference maintains the same information
/// incrementally, at spell-add time; a rebuild is the same answer without a hook, and a language
/// cannot change often enough for the difference to matter.
pub(super) fn feed_language_skills(
mut langs: ResMut<ChatLanguages>,
actions: Option<Res<PlayerActions>>,
spells: Option<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
) {
let Ok(store) = self_q.single() else {
// No body — the reference's "no local player" edge, which copies verbatim.
if !langs.skill.is_empty() || langs.gm || langs.have_player {
langs.skill.clear();
langs.gm = false;
langs.have_player = false;
}
return;
};
let gm = store.0.player_flags() & PLAYER_FLAGS_GM != 0;
let mut skill = HashMap::new();
if let (Some(actions), Some(spells), Some(lines)) = (actions, spells, skill_lines) {
for &known in &actions.spells {
let Some(language) = spells.catalog.declared_language(known) else {
continue;
};
let Some(line) = lines.catalog.spell_to_line(known) else {
// The reference's own dead end: 25674 declares Draconic and has no
// `SkillLineAbility` row, so `0x6de040` finds nothing and the language stays
// unknown. Draconic is never understood in 1.12.1.
continue;
};
// Ascending spell id, and the later write wins — the reference's `[0xb700ac][lang] =
// spellId` is a plain store, and the initial spell batch arrives sorted.
skill.insert(language, skill_value(store, line));
}
}
if skill != langs.skill || gm != langs.gm || !langs.have_player {
// The instrument for this whole surface, and it earns its line: the gate is invisible from
// the outside — a wrong answer here looks like "chat renders fine", which is precisely the
// bug B262 reported. On change only, so it is one line per login, not per frame.
//
// **`gm=true` means nothing will EVER garble**, and GM mode is on by default on `probeN`
// accounts (0679), so a probe hunting this surface reads its own answer here rather than
// concluding the fix did not land.
let mut named: Vec<String> = skill.iter().map(|(l, v)| format!("{l}:{v}")).collect();
named.sort();
info!("ui_chat: language fluency {{{}}} gm={gm}", named.join(" "));
langs.skill = skill;
langs.gm = gm;
langs.have_player = true;
}
}
/// Keep the chat frame's own `this.defaultLanguage` in step with the body — the **faction** tongue
/// `GetDefaultLanguage()` answers, which is what suppresses the `[Language]` header on your own
/// side's chat ([`super::frames::compose`]).
///
/// The reference resolves it per call from the live player object; we resolve it on change, which
/// is the same answer without the per-line churn — a race cannot change without a new world entry.
pub(super) fn feed_default_language(
mut windows: ResMut<super::frames::ChatWindows>,
langs: Option<Res<crate::ui_unit::DefaultLanguagesRes>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
) {
let name = self_q
.iter()
.next()
.and_then(|store| store.0.unit_race())
.zip(langs.as_ref())
.and_then(|(race, langs)| langs.0.name(u32::from(race), 0))
.unwrap_or_default();
if windows.default_language != name {
windows.default_language = name.to_string();
}
}
/// A skill line's effective value out of `PLAYER_SKILL_INFO`, exactly as `0x5ec720` sums it:
/// the base, plus the permanent bonus **only when the base is non-zero**, plus the signed temporary
/// bonus. `0` when the character has no row for the line.
fn skill_value(store: &ObjectStore, line: u32) -> u32 {
for i in 0..benilla_protocol::messages::PLAYER_SKILL_SLOTS {
let Some(s) = store.0.player_skill(i) else {
continue;
};
if u32::from(s.skill_id) != line {
continue;
}
let mut base = i32::from(s.value);
if base != 0 {
base += i32::from(s.perm_bonus);
}
return (base + i32::from(s.temp_bonus)).max(0) as u32;
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{
CHAT_MSG_EMOTE, CHAT_MSG_MONSTER_EMOTE, CHAT_MSG_MONSTER_SAY, CHAT_MSG_RAID_BOSS_EMOTE,
CHAT_MSG_SAY, CHAT_MSG_SYSTEM,
};
fn langs(skill: &[(u32, u32)], gm: bool) -> ChatLanguages {
ChatLanguages {
words: None,
skill: skill.iter().copied().collect(),
gm,
have_player: true,
}
}
/// With no body, everything is Universal — the reference's second early-out. The failure this
/// prevents is the loud one: no body means no skill rows, so a naive gate would read 0 for every
/// language and garble the whole world instead of none of it.
#[test]
fn no_local_player_renders_every_line_plainly() {
let mut l = langs(&[(7, 300)], false);
l.have_player = false;
assert_eq!(l.effective_language(CHAT_MSG_SAY, 1), 0);
assert_eq!(l.effective_language(CHAT_MSG_MONSTER_SAY, 7), 0);
}
#[test]
fn the_narration_types_are_always_universal() {
let l = langs(&[], false);
// Speech keeps its language...
assert_eq!(l.effective_language(CHAT_MSG_SAY, 1), 1);
assert_eq!(l.effective_language(CHAT_MSG_MONSTER_SAY, 1), 1);
// ...narration does not, whatever the wire said.
for t in [
CHAT_MSG_EMOTE,
CHAT_MSG_SYSTEM,
CHAT_MSG_MONSTER_EMOTE,
CHAT_MSG_RAID_BOSS_EMOTE,
] {
assert_eq!(l.effective_language(t, 1), 0, "chat type {t:#x}");
}
}
/// GM mode turns the whole thing off — the line renders plain **and** loses its header, because
/// the reference expresses both by forcing the language field to 0.
#[test]
fn gm_mode_makes_every_line_universal() {
let l = langs(&[], true);
assert_eq!(l.effective_language(CHAT_MSG_SAY, 1), 0);
assert_eq!(l.effective_language(CHAT_MSG_MONSTER_SAY, 7), 0);
}
#[test]
fn an_unlisted_language_is_never_understood() {
let l = langs(&[(7, 300)], false);
assert_eq!(l.skill(7), 300);
assert_eq!(l.skill(1), 0, "Orcish, which this character never learned");
assert_eq!(l.skill(8), 0, "Demonic, which nobody can learn in 5875");
}
/// With no word pool nothing garbles — the pre-B262 behaviour, which is the right failure for a
/// broken install (readable chat beats blank chat).
#[test]
fn a_missing_pool_renders_plainly_rather_than_blanking_chat() {
let l = langs(&[], false);
assert_eq!(l.garble(1, "hello there"), "hello there");
}
}
+142 -21
View File
@@ -19,16 +19,21 @@ fn player_lines_link_the_name_except_emote() {
// The composer emits the REAL |Hplayer link now (ref ChatFrame.lua l.1451); the renderer
// strips the markers and spans the [Name] (the P2 markup law).
assert_eq!(
compose(&ev(K::Say, "hi there", "Bob"), K::Say).unwrap(),
compose(&ev(K::Say, "hi there", "Bob"), K::Say, "Common").unwrap(),
"|Hplayer:Bob|h[Bob]|h says: hi there"
);
assert_eq!(
compose(&ev(K::WhisperInform, "hey", "Bob"), K::WhisperInform).unwrap(),
compose(
&ev(K::WhisperInform, "hey", "Bob"),
K::WhisperInform,
"Common"
)
.unwrap(),
"To |Hplayer:Bob|h[Bob]|h: hey"
);
// EMOTE uses the bare name (l.1450 `type ~= "EMOTE"`).
assert_eq!(
compose(&ev(K::Emote, "dances.", "Bob"), K::Emote).unwrap(),
compose(&ev(K::Emote, "dances.", "Bob"), K::Emote, "Common").unwrap(),
"Bob dances."
);
}
@@ -36,15 +41,15 @@ fn player_lines_link_the_name_except_emote() {
#[test]
fn group_prefixed_kinds_wear_their_brackets() {
assert_eq!(
compose(&ev(K::Party, "inc 3", "Ann"), K::Party).unwrap(),
compose(&ev(K::Party, "inc 3", "Ann"), K::Party, "Common").unwrap(),
"[Party] |Hplayer:Ann|h[Ann]|h: inc 3"
);
assert_eq!(
compose(&ev(K::Guild, "gz", "Ann"), K::Guild).unwrap(),
compose(&ev(K::Guild, "gz", "Ann"), K::Guild, "Common").unwrap(),
"[Guild] |Hplayer:Ann|h[Ann]|h: gz"
);
assert_eq!(
compose(&ev(K::RaidWarning, "move", "Ann"), K::RaidWarning).unwrap(),
compose(&ev(K::RaidWarning, "move", "Ann"), K::RaidWarning, "Common").unwrap(),
"[Raid Warning] |Hplayer:Ann|h[Ann]|h: move"
);
}
@@ -54,12 +59,12 @@ fn flags_prefix_the_name_and_afk_uses_its_get() {
let mut e = ev(K::Say, "brb", "Bob");
e.flag = "GM".into();
assert_eq!(
compose(&e, K::Say).unwrap(),
compose(&e, K::Say, "Common").unwrap(),
"<GM>|Hplayer:Bob|h[Bob]|h says: brb"
);
// A received AFK auto-reply: CHAT_AFK_GET (whisper-pink family).
assert_eq!(
compose(&ev(K::Afk, "farming", "Bob"), K::Afk).unwrap(),
compose(&ev(K::Afk, "farming", "Bob"), K::Afk, "Common").unwrap(),
"|Hplayer:Bob|h[Bob]|h is Away From Keyboard: farming"
);
}
@@ -69,13 +74,13 @@ fn language_header_rides_non_default_tongues() {
let mut e = ev(K::Say, "throm-ka", "Grunk");
e.language = "Orcish".into();
assert_eq!(
compose(&e, K::Say).unwrap(),
compose(&e, K::Say, "Common").unwrap(),
"|Hplayer:Grunk|h[Grunk]|h says: [Orcish] throm-ka"
);
// Common (our default) and Universal (empty) render no header.
e.language = "Common".into();
assert_eq!(
compose(&e, K::Say).unwrap(),
compose(&e, K::Say, "Common").unwrap(),
"|Hplayer:Grunk|h[Grunk]|h says: throm-ka"
);
}
@@ -85,7 +90,8 @@ fn system_and_loot_lines_are_verbatim() {
assert_eq!(
compose(
&ChatEvent::text_only(K::System, "Additem: Wool Cloth added.".into()),
K::System
K::System,
"Common"
)
.unwrap(),
"Additem: Wool Cloth added."
@@ -98,7 +104,8 @@ fn system_and_loot_lines_are_verbatim() {
K::Loot,
"You receive loot: |cffffffff|Hitem:117:0:0:0|h[Tough Jerky]|h|r.".into()
),
K::Loot
K::Loot,
"Common"
)
.unwrap(),
"You receive loot: |cffffffff|Hitem:117:0:0:0|h[Tough Jerky]|h|r."
@@ -112,10 +119,13 @@ fn system_and_loot_lines_are_verbatim() {
#[test]
fn text_emote_lines_are_verbatim_and_never_wear_the_senders_name() {
let e = ev(K::TextEmote, "Bob waves at you.", "Bob");
assert_eq!(compose(&e, K::TextEmote).unwrap(), "Bob waves at you.");
assert_eq!(
compose(&e, K::TextEmote, "Common").unwrap(),
"Bob waves at you."
);
// The control: the same event as a SAY *does* get the bracketed link, so the assertion above
// is about the TEXT_EMOTE arm and not about `compose` having stopped decorating anything.
assert!(compose(&ev(K::Say, "hi", "Bob"), K::Say)
assert!(compose(&ev(K::Say, "hi", "Bob"), K::Say, "Common")
.unwrap()
.contains("[Bob]"));
}
@@ -297,14 +307,20 @@ fn exploration_lines_pick_the_reference_form() {
#[test]
fn monster_lines_use_the_bare_inline_name() {
assert_eq!(
compose(&ev(K::MonsterSay, "Intruders!", "Guard"), K::MonsterSay).unwrap(),
compose(
&ev(K::MonsterSay, "Intruders!", "Guard"),
K::MonsterSay,
"Common"
)
.unwrap(),
"Guard says: Intruders!"
);
// MONSTER_EMOTE embeds %s where the name goes (CHAT_MONSTER_EMOTE_GET = "").
assert_eq!(
compose(
&ev(K::MonsterEmote, "%s beckons you closer.", "Sentinel"),
K::MonsterEmote
K::MonsterEmote,
"Common"
)
.unwrap(),
"Sentinel beckons you closer."
@@ -316,7 +332,7 @@ fn channel_line_prefixes_the_stripped_channel() {
let mut e = ev(K::Channel, "wts boar livers", "Bob");
e.channel = "General - Elwynn Forest".into();
assert_eq!(
compose(&e, K::Channel).unwrap(),
compose(&e, K::Channel, "Common").unwrap(),
"[General] |Hplayer:Bob|h[Bob]|h: wts boar livers"
);
}
@@ -333,7 +349,7 @@ fn channel_notices_compose_by_the_notice_law() {
e.channel = "General - Elwynn Forest".into();
e.notice = "2".into(); // YOU_JOINED
assert_eq!(
compose(&e, K::ChannelNotice).unwrap(),
compose(&e, K::ChannelNotice, "Common").unwrap(),
"Joined Channel: [General - Elwynn Forest]"
);
let mut kick = ChatEvent::text_only(K::ChannelNotice, String::new());
@@ -342,14 +358,14 @@ fn channel_notices_compose_by_the_notice_law() {
kick.target = "Mod".into();
kick.notice = "18".into(); // PLAYER_KICKED 0x12
assert_eq!(
compose(&kick, K::ChannelNotice).unwrap(),
compose(&kick, K::ChannelNotice, "Common").unwrap(),
"[World] Player Ann kicked by Mod."
);
// A member join line is a CHANNEL_JOIN event, hyperlinked like any player line.
let mut join = ev(K::ChannelJoin, "", "Ann");
join.channel = "World".into();
assert_eq!(
compose(&join, K::ChannelJoin).unwrap(),
compose(&join, K::ChannelJoin, "Common").unwrap(),
"[World] |Hplayer:Ann|h[Ann]|h joined channel."
);
}
@@ -744,7 +760,7 @@ fn a_leave_notice_keeps_its_number_because_the_record_dies_after_the_line() {
"arg8 — what the color resolves through"
);
assert_eq!(
super::frames::compose(&e, K::ChannelNotice).unwrap(),
super::frames::compose(&e, K::ChannelNotice, "Common").unwrap(),
"Left Channel: [2. General - Elwynn Forest]"
);
assert_eq!(
@@ -1601,3 +1617,108 @@ fn an_addon_message_survives_its_own_send_and_receive() {
"what one half composed, the other must recover — tabs in the payload included"
);
}
/// **The `[Language]` header keys off the frame's DEFAULT tongue, not off "Common"** (B262).
///
/// `ChatFrame.lua`'s test is `strlen(arg3) > 0 and arg3 ~= "Universal" and arg3 ~= this.defaultLanguage`,
/// and `GetDefaultLanguage()` answers the **faction** language — Common for every Alliance race,
/// Orcish for every Horde one (wow-re `chat-language-scramble.md` §12, and benilla's own
/// `ChrRaces` field-8 join). The composer hardcoded `"Common"`, which is right for half the game
/// and exactly backwards for the other half: a Horde character saw `[Orcish]` on every ordinary
/// line of their own faction's chat, and no tag at all on the Common they cannot read.
///
/// The condition is about the default language, never about whether the listener understands it —
/// so a fully-understood foreign line still carries its tag.
#[test]
fn the_language_header_suppresses_only_the_frames_own_default_tongue() {
let orcish = ChatEvent {
language: "Orcish".into(),
..ev(K::Say, "lok'tar", "Grom")
};
let common = ChatEvent {
language: "Common".into(),
..ev(K::Say, "hello", "Ann")
};
// An Alliance body (default Common): Orcish is tagged, Common is not.
assert_eq!(
compose(&orcish, K::Say, "Common").unwrap(),
"|Hplayer:Grom|h[Grom]|h says: [Orcish] lok'tar"
);
assert_eq!(
compose(&common, K::Say, "Common").unwrap(),
"|Hplayer:Ann|h[Ann]|h says: hello"
);
// A Horde body (default Orcish): exactly the mirror. This is the assertion that fails against
// the hardcoded "Common".
assert_eq!(
compose(&orcish, K::Say, "Orcish").unwrap(),
"|Hplayer:Grom|h[Grom]|h says: lok'tar"
);
assert_eq!(
compose(&common, K::Say, "Orcish").unwrap(),
"|Hplayer:Ann|h[Ann]|h says: [Common] hello"
);
// Language 0 arrives as an empty arg3 and is never tagged, whatever the default is — which is
// also how a GM and the narration chat types come through, since all three force the field to 0.
let universal = ev(K::Say, "system", "Ann");
assert_eq!(
compose(&universal, K::Say, "Orcish").unwrap(),
"|Hplayer:Ann|h[Ann]|h says: system"
);
// A language the listener fully understands still carries its tag: the test is about the
// default tongue, not about comprehension. A dwarf reading Dwarvish sees the header.
let dwarvish = ChatEvent {
language: "Dwarvish".into(),
..ev(K::Say, "here we go", "Bran")
};
assert_eq!(
compose(&dwarvish, K::Say, "Common").unwrap(),
"|Hplayer:Bran|h[Bran]|h says: [Dwarvish] here we go"
);
}
/// **The talk/laugh gesture reads the PLAINTEXT, not the garbled line** — so it is
/// language-independent, and a Horde player yelling `lol` laughs for every observer, Alliance
/// included.
///
/// This is byte-verified rather than reasoned (wow-re `chat-language-scramble.md` §10.1), and it
/// corrects an inference we had already wired: the §5's consumer census of the display path
/// `0x49a870` found the chat line, the Lua `arg1` and the bubble all sharing the rewritten buffer,
/// and we concluded the gesture did too. It does not — the selector is not on that path at all. It
/// lives in the **parser** `0x49d560` at `0x49d820`-`0x49d8ae`, matching against `[ebp-0x10]`, which
/// is the very buffer `0x49dbc2` then hands to `0x49a870` as its `src`. The garbled buffer is a
/// local of a frame that does not exist yet, so the census could never have found this consumer.
///
/// The two inputs are observably different, which is the whole point of the test: feed the garbled
/// text here and the laugh silently becomes a plain talk.
#[test]
fn the_talk_gesture_reads_the_plaintext_not_the_garbled_line() {
use crate::creature_anim::{select_gesture, Gesture};
use benilla_protocol::messages::CHAT_MSG_SAY;
let Some(data) = benilla_formats::wow_data() else {
return; // no client data — the same skip every data-gated test here takes
};
let mut chain = benilla_formats::Chain::open(&data).expect("open patch chain");
let words = benilla_formats::load_language_words(&mut chain).expect("load word pools");
// An Orcish `lol` heard by someone with no Orcish at all.
let garbled = benilla_formats::garble_chat(&words, 1, 0, "lol");
assert_ne!(garbled, "lol", "the two inputs must actually differ");
let laugh_words = |n: u32| (n == 1).then(|| "lol".to_string());
assert_eq!(
select_gesture(CHAT_MSG_SAY, "lol", laugh_words),
Some(Gesture::Laugh),
"the plaintext laughs"
);
assert_eq!(
select_gesture(CHAT_MSG_SAY, &garbled, laugh_words),
Some(Gesture::Talk),
"the garbled form would NOT laugh — which is why the feed must pass the plaintext"
);
}
+510
View File
@@ -0,0 +1,510 @@
//! **The chat language scramble** — the reference's `0x49b560`, reimplemented.
//!
//! When someone speaks a language your character does not know, the *server* sends the sentence in
//! plaintext with a language id beside it and never rewrites it; turning it into gibberish is
//! entirely the client's job (B262 — opposite-faction speech rendered perfectly readable because
//! this step did not exist). This module is that step.
//!
//! **The mechanism is byte-verified** in wow-re `system/ui/scratch/chat-language-scramble.md` — a
//! §5 trio on the kernel plus two independent pairs, with an emulated oracle that runs the binary's
//! own bytes over the player's own `LanguageWords.dbc`. [`tests::the_reference_golden_vectors`]
//! carries all 35 of that note's golden vectors verbatim; they are the oracle this file is graded
//! against, and the note's own claim is that its prose is *sufficient* — a clean-room
//! implementation written from it reproduced every vector byte for byte. So did this one.
//!
//! The shape, in one paragraph. The line is walked as an alternating sequence of separator runs and
//! words. Each word is hashed once with `SStrHash` (case-folded, so `hello`/`Hello`/`HELLO` share a
//! hash and therefore a substitute); the *same* hash decides both whether that particular word is
//! understood (`hash % 300 < skill` — a **per-word, graduated** gate, not an all-or-nothing switch)
//! and, when it is not, which replacement is drawn from the `(language, byte length)` bucket of
//! `LanguageWords.dbc`. The source word's capitalisation is then stamped onto the replacement one
//! character at a time. Nothing is random and nothing is stateful: the mapping is a pure function
//! of the word's bytes, stable within a session and across sessions.
//!
//! **Three call sites in the reference, and the flags are what separate them** ([`Garble`]):
//! chat (`0x49aa7c`, both flags off), the NPC gossip greeting (`0x4e22ab`, `keep_punct`), and
//! readable item text (`0x4e35b0`, both). So the same language barrier covers gossip and books —
//! benilla only wires chat today, but the routine is the whole routine, not the chat slice of it.
use crate::LanguageWords;
/// `SStrHash`'s mix table — the **16-dword** table at `0x80e4e0`, not Storm's public 0x500-entry
/// crypt table. Both nibbles of a byte index these same sixteen entries and the mix is a
/// **subtraction**, `T[hi] - T[lo]`.
const HASH_TABLE: [u32; 16] = [
0x486e26ee, 0xdcaa16b3, 0xe1918eef, 0x202dafdb, 0x341c7dc7, 0x1c365303, 0x40ef2d37, 0x65fd5e49,
0xd6057177, 0x904ece93, 0x1c38024f, 0x98fd323b, 0xe3061ae7, 0xa39b0fa1, 0x9797f25f, 0xe4444563,
];
/// Full fluency (`0x49b599 cmp esi,0x12c`). At or above this the line is copied verbatim without
/// even being tokenized; below it every word runs the per-word gate.
pub const FLUENT_SKILL: u32 = 300;
/// The per-word understand test's modulus (`0x49b79c div ecx=0x12c`). It is the same 300 as
/// [`FLUENT_SKILL`] and that is not a coincidence — it makes the surviving fraction of a line
/// exactly `skill / 300`.
const UNDERSTAND_MODULUS: u32 = 300;
/// How many bytes of a word are copied into the reference's `0x110`-byte scratch buffer and
/// therefore hashed (`0x49b768`).
const MAX_HASHED_BYTES: usize = 0x100;
/// The **length key**'s clamp (`0x49b7c1`) — a separate quantity from [`MAX_HASHED_BYTES`], and a
/// re-implementation must keep them apart: a 45-byte word is hashed over all 45 bytes but looked up
/// as if it were 18 long. The shipped table's longest word is 17, so this never binds against
/// shipped content; it is the retry below that does the work for a long word.
const MAX_LENGTH_KEY: usize = 0x12;
/// The two flags `0x49b560` takes, plus the destination cap — i.e. which of the reference's three
/// call sites this is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Garble {
/// `keepAngle` — pass a `<…>` span through verbatim, opener and closer included. Set only on
/// readable item text (`0x4e3856`).
pub keep_angle: bool,
/// `keepPunct` — copy each separator's own bytes instead of collapsing the whole run to one
/// space. Set on gossip (`0x4e22ab`) and item text; **off on the chat path**, which is why
/// `"hello, world."` comes back `"kazum magan "` with a trailing space.
pub keep_punct: bool,
/// `dstSize` — the reference's destination buffer, **total size not remaining**. Output is
/// truncated to `dst_size - 1` bytes.
pub dst_size: usize,
}
impl Garble {
/// The chat path (`0x49aa7c` in the display chokepoint `0x49a870`): both flags off, `0x800`.
///
/// The cap cannot bind on real chat — vmangos refuses a `CMSG_MESSAGECHAT` body past 255 bytes
/// and no server-composed line approaches 2 KiB — but it is the reference's number and modelling
/// it costs nothing, so a hostile or merely odd line truncates where the real client truncates
/// instead of somewhere of our own invention.
pub const CHAT: Self = Self {
keep_angle: false,
keep_punct: false,
dst_size: 0x800,
};
/// The NPC gossip greeting (`0x4e22ab`, then `GOSSIP_SHOW`).
pub const GOSSIP: Self = Self {
keep_angle: false,
keep_punct: true,
dst_size: 0x800,
};
/// Readable item text — letters and books (`0x4e3856`, then `ITEM_TEXT_READY`).
pub const ITEM_TEXT: Self = Self {
keep_angle: true,
keep_punct: true,
dst_size: 0x1f40,
};
}
/// `SStrHash` (`0x64af90`) as `0x49b560` calls it: **case-folded** (`caseSensitive = 0`) with a zero
/// seed.
///
/// The fold is ASCII-only — `a`-`z` lift by 0x20 and `/` becomes `\` — so a Latin-1 accented capital
/// and its lowercase form hash differently, exactly as they do in the reference. The walk stops at a
/// NUL because the reference's does; a wire chat string cannot contain one, but a caller composing
/// text locally could.
fn sstr_hash_folded(bytes: &[u8]) -> u32 {
let mut s1: u32 = 0x7FED_7FED;
let mut s2: u32 = 0xEEEE_EEEE;
for &raw in bytes {
if raw == 0 {
break;
}
let mut c = raw;
if c.is_ascii_lowercase() {
c -= 0x20;
}
if c == b'/' {
c = b'\\';
}
let t = s1.wrapping_add(s2);
s1 = t ^ HASH_TABLE[usize::from(c >> 4)].wrapping_sub(HASH_TABLE[usize::from(c & 0xF)]);
s2 = s2
.wrapping_mul(0x21)
.wrapping_add(u32::from(c))
.wrapping_add(3)
.wrapping_add(s1);
}
if s1 != 0 {
s1
} else {
1
}
}
/// The sentinel `0x41aab0` returns for a malformed lead or continuation byte. It is `> 0xff`, so
/// [`is_word_char`] swallows a malformed run into the surrounding word rather than splitting on it —
/// a quirk worth preserving rather than "fixing", since it decides tokenization.
const MALFORMED: u32 = 0x8000_0000;
/// The reference's UTF-8 decoder `0x41aab0`, which predates RFC 3629 and so accepts the **1–6 byte**
/// forms. Returns `(codepoint, bytes consumed)`; consumes one byte on a malformed lead so the walk
/// always advances.
fn decode(bytes: &[u8]) -> (u32, usize) {
let b0 = bytes[0];
let (mut cp, n) = match b0 {
0x00..=0x7f => return (u32::from(b0), 1),
0xc0..=0xdf => (u32::from(b0 & 0x1f), 2),
0xe0..=0xef => (u32::from(b0 & 0x0f), 3),
0xf0..=0xf7 => (u32::from(b0 & 0x07), 4),
0xf8..=0xfb => (u32::from(b0 & 0x03), 5),
0xfc..=0xfd => (u32::from(b0 & 0x01), 6),
_ => return (MALFORMED, 1),
};
if bytes.len() < n {
return (MALFORMED, 1);
}
for &cont in &bytes[1..n] {
if cont & 0xc0 != 0x80 {
return (MALFORMED, 1);
}
cp = (cp << 6) | u32::from(cont & 0x3f);
}
(cp, n)
}
/// A Latin-1 letter as `0x6c9c60` classifies one.
///
/// Transcribed from the note's reading of the table rather than from what Latin-1 "should" say:
/// `0xDE` (thorn) is **not** in the accepted set even though `0xC0..=0xDD`, `0xDF` and
/// `0xE0..=0xFF` around it are. It cannot affect an ASCII line, and guessing the table is a worse
/// error than transcribing it.
fn is_latin1_letter(cp: u32) -> bool {
matches!(cp, 0x41..=0x5a | 0x61..=0x7a)
|| matches!(cp, 0xc0..=0xdd if cp != 0xd7)
|| cp == 0xdf
|| matches!(cp, 0xe0..=0xff if cp != 0xf7)
}
/// The word-character predicate `0x49b940`.
///
/// Three consequences that decide output and that a re-implementation guesses wrong: **digits are
/// word characters**, so `12345` is one token and gets substituted like a word; **an apostrophe
/// never splits a word**, so `don't` is one token; and **every codepoint above Latin-1 is
/// unconditionally a word character**, so CJK, Cyrillic and a malformed byte run are all swallowed
/// into words.
fn is_word_char(cp: u32) -> bool {
is_latin1_letter(cp) || (0x30..=0x39).contains(&cp) || cp == 0x27 || cp > 0xff
}
/// Rewrite `src` as `language` sounds to a listener with `skill` in it.
///
/// Returns the text unchanged when `language` is 0 (Universal — `Languages.dbc` has no row 0, so the
/// reference checks this twice, in the caller *and* here) or when `skill >= 300`. Everything else
/// goes word by word.
///
/// The caller owns the gates this routine does not: the addon sentinel, the chat types that force
/// the language to 0, and the GM flag. See `benilla-app`'s chat feed.
pub fn garble(words: &LanguageWords, language: u32, skill: u32, src: &str, mode: Garble) -> String {
let cap = mode.dst_size.saturating_sub(1);
if language == 0 || skill >= FLUENT_SKILL {
// `SStrCopy(dst, src, dstSize)` at `0x49b5a1` — the whole line, untokenized, so a
// fluent listener does not even get separator runs collapsed.
return truncate_utf8(src, cap).to_string();
}
let Some(pool) = words.pool(language) else {
// A language with no rows cannot be garbled — the reference would find no node at any
// length and drop every word, emitting nothing. We keep the line instead: a missing pool
// is a broken install, and silently blanking chat is the worse failure.
return truncate_utf8(src, cap).to_string();
};
let bytes = src.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
// --- separator run ---------------------------------------------------------------
// One space per *run* on the chat path, latched per outer iteration: a leading run, a
// trailing run, `" "`, `", "`, `"!!!"` and a tab all collapse to exactly one space.
let mut emitted_space = false;
while i < bytes.len() {
let (cp, n) = decode(&bytes[i..]);
if is_word_char(cp) {
break;
}
if mode.keep_angle && cp == 0x3c {
// Copy the `<…>` span verbatim, opener and closer included, or to end of string.
let start = i;
i += n;
while i < bytes.len() {
let (c2, n2) = decode(&bytes[i..]);
i += n2;
if c2 == 0x3e {
break;
}
}
push(&mut out, &bytes[start..i], cap);
continue;
}
if mode.keep_punct {
push(&mut out, &bytes[i..i + n], cap);
} else if !emitted_space {
push(&mut out, b" ", cap);
emitted_space = true;
}
i += n;
}
if i >= bytes.len() {
break;
}
// --- word ------------------------------------------------------------------------
let word_start = i;
while i < bytes.len() {
let (cp, n) = decode(&bytes[i..]);
if !is_word_char(cp) {
break;
}
i += n;
}
let word = &bytes[word_start..i.min(word_start + MAX_HASHED_BYTES)];
let hash = sstr_hash_folded(word);
// The per-word gate. This is the correction that matters most: partial skill is **not**
// "garbled like skill 0". A word survives exactly when `hash % 300 < skill`, so the
// surviving fraction of a line is `skill / 300` and *which* words survive is fixed by their
// bytes — at skill 150 a pangram comes back half-plain, the same half every time.
if hash % UNDERSTAND_MODULUS < skill {
push(&mut out, word, cap);
continue;
}
// The length key steps *down* on a miss, and a length-1 miss drops the word entirely —
// nothing is emitted for it, not even a space. Unreachable against shipped content (every
// language has a word at every length from 1 up to its own maximum), reachable against a
// trimmed table.
let mut key = word.len().min(MAX_LENGTH_KEY);
let substitute = loop {
if let Some(s) = pool.nth_of_len(key, hash) {
break Some(s);
}
if key <= 1 {
break None;
}
key -= 1;
};
let Some(substitute) = substitute else {
continue;
};
stamp_case(&mut out, word, substitute.as_bytes(), cap);
}
// Every substitute is ASCII and every verbatim span is a whole-codepoint slice of `src`, so the
// only way out of UTF-8 is the `0x100` hash clamp or the destination cap splitting a multi-byte
// character. Both are the reference's own truncations; we keep them and repair the encoding
// rather than moving the cut.
String::from_utf8_lossy(&out).into_owned()
}
/// The chat path, which is the one benilla wires today.
pub fn garble_chat(words: &LanguageWords, language: u32, skill: u32, src: &str) -> String {
garble(words, language, skill, src, Garble::CHAT)
}
/// Append, honouring the destination cap (`dstSize - 1` usable bytes).
fn push(out: &mut Vec<u8>, bytes: &[u8], cap: usize) {
let room = cap.saturating_sub(out.len());
out.extend_from_slice(&bytes[..room.min(bytes.len())]);
}
/// `0x49b8b0` — stamp the source word's capitalisation onto the substitute.
///
/// Two things a reader gets wrong if this is described as "capitalisation is preserved". The case is
/// decided **per character, from the source**, not first-letter-only and not from the substitute:
/// `hEllO` → `kAzuM`. And a non-letter in the source (a digit, an apostrophe) is not upper, so it
/// forces the substitute's character **lowercase**.
///
/// The emitted length is `min(len(source), len(substitute), budget)`. Those first two are usually
/// equal because the lookup is length-keyed — but not after a retry, which is exactly the long-word
/// case: a 20-byte word looked up at length 13 emits 13 bytes.
fn stamp_case(out: &mut Vec<u8>, source: &[u8], substitute: &[u8], cap: usize) {
for (i, &s) in source.iter().enumerate() {
if i >= substitute.len() || out.len() >= cap {
break;
}
out.push(if s.is_ascii_uppercase() {
substitute[i].to_ascii_uppercase()
} else {
substitute[i].to_ascii_lowercase()
});
}
}
/// Truncate to at most `cap` bytes without splitting a codepoint — the verbatim paths' `SStrCopy`,
/// which is a byte copy in the reference and cannot produce invalid UTF-8 for us.
fn truncate_utf8(s: &str, cap: usize) -> &str {
if s.len() <= cap {
return s;
}
let mut end = cap;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{load_language_words, Chain};
/// **The oracle.** Every one of the 35 golden vectors from wow-re
/// `system/ui/scratch/chat-language-scramble.md` §9, produced by emulating the reference's own
/// `0x49b560` over the shipped `LanguageWords.dbc` — not transcribed from a description of the
/// output, but the exact bytes the binary wrote.
///
/// `(language, skill, input, expected)`.
const GOLDEN: &[(u32, u32, &str, &str)] = &[
(1, 0, "hello", "kazum"),
(1, 0, "h", "o"),
(1, 0, "ab", "ha"),
(1, 0, "antidisestablishment", "khaz'rogg'ahn"),
(
1,
0,
"pneumonoultramicroscopicsilicovolcanoconiosis",
"khaz'rogg'ahn",
),
(
1,
0,
"the cat sat on the mat the cat",
"mog ruk ogg gi mog gul mog ruk",
),
(1, 0, "hello hello", "kazum kazum"),
(1, 0, "hello, world.", "kazum magan "),
(1, 0, "don't", "re'ka"),
(1, 0, "abc123", "moguna"),
(1, 0, "12345", "regas"),
(1, 0, "hello world", "kazum magan"),
(1, 0, " hello ", " kazum "),
(1, 0, "hello\tworld", "kazum magan"),
(1, 0, "Hello", "Kazum"),
(1, 0, "HELLO", "KAZUM"),
(1, 0, "hEllO", "kAzuM"),
(1, 0, "Hello There Friend", "Kazum No'ku Raznos"),
(1, 0, "", ""),
(1, 0, "!!!", " "),
(1, 0, "a b c", "g g o"),
(7, 0, "hello", "majis"),
(2, 0, "hello", "talah"),
(33, 0, "hello", "majis"),
(14, 0, "hello", "atuad"),
(1, 1, "hello", "kazum"),
(1, 75, "hello", "kazum"),
(1, 150, "hello", "hello"),
(1, 299, "hello", "hello"),
(1, 300, "hello", "hello"),
(0, 0, "hello", "hello"),
(1, 0, "Kek lol Rofl", "Ogg kek Ogar"),
(1, 0, "Thrall sends his regards", "No'gor zugas kil gul'rok"),
(7, 0, "For the Alliance!", "Nud ras Landowar "),
(
1,
150,
"the quick brown fox jumps over the lazy dog",
"the quick nogah fox re'ka nogu the maka kil",
),
];
#[test]
fn the_reference_golden_vectors() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let words = load_language_words(&mut chain).expect("load");
for &(lang, skill, input, want) in GOLDEN {
let got = garble_chat(&words, lang, skill, input);
assert_eq!(got, want, "lang={lang} skill={skill} input={input:?}");
}
}
/// No per-call state anywhere: the same call repeats identically, and so does the same word in
/// a different sentence. (The reference's own oracle proves the second half across separately
/// built emulator "sessions"; ours is a pure function, so within-process repetition is the
/// strongest form the property can take here.)
#[test]
fn the_mapping_is_a_pure_function_of_the_words_bytes() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let words = load_language_words(&mut chain).expect("load");
let first: Vec<String> = GOLDEN
.iter()
.map(|&(l, s, i, _)| garble_chat(&words, l, s, i))
.collect();
let again: Vec<String> = GOLDEN
.iter()
.map(|&(l, s, i, _)| garble_chat(&words, l, s, i))
.collect();
assert_eq!(first, again);
// The same word carries its substitute between sentences, not just within one — stated
// against the standalone result rather than a literal, so this asserts the *property*
// instead of re-recording whatever this file happens to produce.
let alone = garble_chat(&words, 1, 0, "hello");
assert_eq!(alone, "kazum", "the golden vector still anchors it");
let in_sentence = garble_chat(&words, 1, 0, "well hello there");
assert_eq!(in_sentence.split(' ').nth(1), Some(alone.as_str()));
}
/// `SStrHash` is case-folded, which is *why* `hello`/`Hello`/`HELLO` share a substitute — the
/// case stamp only decides how it is printed.
#[test]
fn the_hash_folds_case_and_is_seeded_the_storm_way() {
assert_eq!(sstr_hash_folded(b"hello"), sstr_hash_folded(b"HELLO"));
assert_eq!(sstr_hash_folded(b"hello"), sstr_hash_folded(b"hEllO"));
assert_ne!(sstr_hash_folded(b"hello"), sstr_hash_folded(b"world"));
// The empty string never reaches the table, so it returns the untouched seed.
assert_eq!(sstr_hash_folded(b""), 0x7FED_7FED);
}
/// The tokenizer's three surprises, stated as tests so they cannot be "cleaned up" later.
#[test]
fn digits_apostrophes_and_high_codepoints_are_word_characters() {
assert!(is_word_char(u32::from(b'7')));
assert!(is_word_char(0x27));
assert!(is_word_char(0x4e00)); // CJK
assert!(is_word_char(MALFORMED));
assert!(!is_word_char(u32::from(b' ')));
assert!(!is_word_char(u32::from(b'-')));
assert!(!is_word_char(u32::from(b'.')));
}
/// The flags the other two call sites set. Chat collapses `", "` to one space; gossip keeps
/// both bytes; item text additionally passes a `<…>` span straight through.
#[test]
fn the_gossip_and_item_text_flags_change_the_separators_only() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let words = load_language_words(&mut chain).expect("load");
assert_eq!(garble_chat(&words, 1, 0, "hello, world."), "kazum magan ");
assert_eq!(
garble(&words, 1, 0, "hello, world.", Garble::GOSSIP),
"kazum, magan."
);
assert_eq!(
garble(&words, 1, 0, "hello <Name> world", Garble::ITEM_TEXT),
"kazum <Name> magan"
);
// Without `keep_angle` the span is just separators and a word, so the name garbles too.
assert_ne!(
garble(&words, 1, 0, "hello <Name> world", Garble::GOSSIP),
"kazum <Name> magan"
);
}
/// The destination cap is the reference's, and it truncates rather than growing.
#[test]
fn the_destination_cap_truncates() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let words = load_language_words(&mut chain).expect("load");
let tiny = Garble {
dst_size: 4,
..Garble::CHAT
};
assert_eq!(garble(&words, 1, 0, "hello hello hello", tiny), "kaz");
// The verbatim paths honour it too.
assert_eq!(garble(&words, 0, 0, "hello hello", tiny), "hel");
}
}
+31 -12
View File
@@ -148,9 +148,14 @@ pub struct LanguageWords(HashMap<u32, LanguagePool>);
#[derive(Debug, Default, Clone)]
pub struct LanguagePool {
words: Vec<String>,
/// `by_len[n]` = indices into `words` of every word whose length is exactly `n`; slot 0 is
/// always empty (the table has no zero-length word) and the vector runs to the longest word
/// this language authored.
/// `by_len[n]` = indices into `words` of every word whose length is exactly `n` **bytes**; slot
/// 0 is always empty (the index build skips an empty word) and the vector runs to the longest
/// word this language authored.
///
/// **Bytes, because the reference's index build takes `strlen`** (`0x4982c0`, a plain byte
/// `0x64a6f0`), and the length key it later matches is likewise a byte count. Every shipped word
/// is ASCII so the two agree in this table — but the key is compared against a *source* word,
/// which is arbitrary player text, and there the distinction is real.
by_len: Vec<Vec<u32>>,
}
@@ -160,8 +165,8 @@ impl LanguagePool {
&self.words
}
/// The words of exactly `len` characters, in file order — empty when the language authored
/// none that long (and for `len` past its longest word).
/// The words of exactly `len` bytes, in file order — empty when the language authored none
/// that long (and for `len` past its longest word).
pub fn of_len(&self, len: usize) -> impl Iterator<Item = &str> {
self.by_len
.get(len)
@@ -171,15 +176,31 @@ impl LanguagePool {
.map(|&i| self.words[i as usize].as_str())
}
/// How many words of exactly `len` characters this language authored.
/// How many words of exactly `len` bytes this language authored.
pub fn count_of_len(&self, len: usize) -> usize {
self.by_len.get(len).map_or(0, Vec::len)
}
/// The longest word this language authored, in characters (0 for an empty pool).
/// The longest word this language authored, in bytes (0 for an empty pool).
pub fn max_len(&self) -> usize {
self.by_len.len().saturating_sub(1)
}
/// The substitute the reference picks for a word of `len` bytes whose hash is `hash`:
/// `node.words[hash % node.count]` (`0x49b885`), over the candidates **in DBC row order** —
/// which is the order the index builder appends them in, so it is the order this pool holds.
///
/// `None` means the language authored no word of that length, which is the caller's cue to step
/// the length key down and ask again.
pub fn nth_of_len(&self, len: usize, hash: u32) -> Option<&String> {
let bucket = self.by_len.get(len)?;
let count = u32::try_from(bucket.len()).ok()?;
if count == 0 {
return None;
}
let idx = bucket[(hash % count) as usize];
self.words.get(idx as usize)
}
}
impl LanguageWords {
@@ -210,10 +231,8 @@ fn language_words_schema() -> Schema {
/// Load the per-language substitution pools.
///
/// Word length is counted in **`char`s, not bytes**: every word in the shipped enUS table is
/// plain ASCII (a hyphen is the only non-letter), so the two agree today — but the count that
/// matters is the one a length rule compares against a *source* word, and a source word is
/// arbitrary player text.
/// Word length is counted in **bytes**, which is what the reference's index build uses
/// (`0x4982c0` takes `strlen`); see [`LanguagePool::by_len`].
pub fn load_language_words(chain: &mut Chain) -> Result<LanguageWords> {
let bytes = chain
.read_file(LANGUAGE_WORDS)
@@ -228,7 +247,7 @@ pub fn load_language_words(chain: &mut Chain) -> Result<LanguageWords> {
continue;
}
let pool = out.entry(lang).or_default();
let len = word.chars().count();
let len = word.len();
let idx = u32::try_from(pool.words.len()).unwrap_or(u32::MAX);
pool.words.push(word);
if pool.by_len.len() <= len {
+3
View File
@@ -51,6 +51,9 @@ pub use factions::{
};
mod creature_types;
pub use creature_types::{load_creature_type_flags, CreatureTypeFlags};
/// The chat language scramble — the reference's `0x49b560` (B262). Reads [`LanguageWords`].
mod garble;
pub use garble::{garble, garble_chat, Garble, FLUENT_SKILL};
mod languages;
pub use languages::{
load_default_languages, load_language_words, DefaultLanguages, LanguagePool, LanguageWords,
@@ -1101,3 +1101,96 @@ fn real_spell_catalog_cost_columns() {
"Dark Offering: the per-level term's health-lane row"
);
}
/// **The language join — and the shipped-data anomaly it uncovered.**
///
/// `Languages.dbc` carries no skill column and three of the thirteen name pairs do not match
/// (Dwarvish/Dwarven, Demonic/Demon Tongue, Kalimag/Old Tongue), so the only route from a chat
/// line's language id to the skill that gates it is the **spell** whose `Effect_1` is
/// `SPELL_EFFECT_LANGUAGE` (wow-re `chat-language-scramble.md` §8, `0x4b2656`).
///
/// The mechanism is byte-verified; what it *does against shipped 5875 content* is this test's
/// subject, and it is not what the mechanism alone suggests. **Fourteen spells declare a language
/// and they cover only nine of the thirteen**: 813/814/815/816/817 all declare **7 (Common)**
/// rather than their own, four of them named "(NYI)". So Demonic, Titan, Thalassian and Kalimag are
/// unreachable through the client's own join and render fully garbled to everyone — correct 1.12.1
/// behaviour, and the reason a language→spell map built over the whole DBC would be wrong (last row
/// wins, so language 7 would resolve to Old Tongue and every character's Common would garble).
///
/// Walking the gate's *second* hop finishes the picture: of the nine, Draconic's declaring spell has
/// no `SkillLineAbility` row, so the set a character can ever understand is exactly the **eight
/// player languages**. Every flavour language garbles for everyone, always.
///
/// The pairs below are cross-checked where an independent source exists: **vmangos's
/// `lang_description[]`** (`src/game/ObjectMgr.cpp`) hand-lists the same spell ids, so agreement on
/// the nine is a real control — and its disagreement on 813–817's *language* is exactly the point,
/// since the server picks a language to send while the client reads the DBC to decide what it
/// renders.
#[test]
fn the_language_declaring_spells_cover_nine_of_thirteen_languages() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let spells = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
let skills = crate::skill_lines::load_skill_line_catalog(&mut chain).expect("load skill lines");
// (spell, declared language, the spell's SkillLine.dbc id) — every SPELL_EFFECT_LANGUAGE row in
// 5875. Spell ids cross-check against vmangos `lang_description[]`; the languages are ours.
const DECLARED: &[(u32, u32, u32)] = &[
(668, 7, 98), // Common
(669, 1, 109), // Orcish
(670, 3, 115), // Taurahe
(671, 2, 113), // Darnassian
(672, 6, 111), // Dwarvish
(813, 7, 137), // "Thalassian (NYI)" — declares COMMON
(814, 7, 138), // "Draconic (NYI)" — declares COMMON
(815, 7, 139), // Demon Tongue — declares COMMON
(816, 7, 140), // "Titan (NYI)" — declares COMMON
(817, 7, 141), // "Old Tongue (NYI)" — declares COMMON
(7340, 13, 313), // Gnomish
(7341, 14, 315), // Troll
(17737, 33, 673), // Gutterspeak
];
// The fourteenth: "Lesser Draconic (Language)" declares Draconic (11) correctly — and has **no
// `SkillLineAbility` row at all**, so the gate's second hop dead-ends and Draconic can never be
// understood either. Language 11 is reachable in the table and still always garbled.
assert_eq!(spells.declared_language(25674), Some(11));
assert_eq!(skills.spell_to_line(25674), None);
assert_eq!(
spells.declared_languages().count(),
DECLARED.len() + 1,
"every SPELL_EFFECT_LANGUAGE row is accounted for (the +1 is 25674, below)"
);
for &(spell, language, line) in DECLARED {
assert_eq!(
spells.declared_language(spell),
Some(language),
"spell {spell} -> language"
);
// The second hop the garble gate walks: spell -> SkillLineAbility -> SkillLine.dbc id.
assert_eq!(
skills.spell_to_line(spell),
Some(line),
"spell {spell} -> skill line"
);
}
// **The set a character can ever understand** — what the chat gate actually sees once both hops
// are walked. It is exactly the eight *player* languages, which is the result that makes the
// whole shipped arrangement coherent: every flavour language (Demonic, Titan, Thalassian,
// Kalimag, Draconic) always renders garbled, to everyone, in 1.12.1.
let understandable: std::collections::BTreeSet<u32> = spells
.declared_languages()
.filter(|(spell, _)| skills.spell_to_line(*spell).is_some())
.map(|(_, l)| l)
.collect();
assert_eq!(
understandable,
[1, 2, 3, 6, 7, 13, 14, 33].into_iter().collect(),
"only the eight player languages have both a declaring spell and a skill line"
);
// An ordinary ability declares nothing.
assert_eq!(spells.declared_language(133), None); // Fireball
}
+55
View File
@@ -501,6 +501,11 @@ pub const SPELL_EFFECT_SKINNING: u32 = 95;
/// `SpellEffects` values `53`/`54` — `SPELL_EFFECT_ENCHANT_ITEM` (permanent) /
/// `SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY` (vmangos `SharedDefines.h`): the item-targeted craft
/// casts the CraftFrame sends with `TARGET_FLAG_ITEM` (0437 phase 3).
/// `SpellEffects` value `39` — `SPELL_EFFECT_LANGUAGE`: the effect that makes a spell *be* a
/// language. Its `EffectMiscValue_1` is the `Languages.dbc` id, which is how a language reaches a
/// skill line at all — see [`SpellCatalog::language_spell`].
pub const SPELL_EFFECT_LANGUAGE: u32 = 39;
pub const SPELL_EFFECT_ENCHANT_ITEM: u32 = 53;
pub const SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY: u32 = 54;
@@ -512,6 +517,9 @@ pub const SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY: u32 = 54;
pub struct SpellCatalog {
spells: HashMap<u32, SpellDisplay>,
learned_spell: HashMap<u32, u32>,
/// Spell id → the `Languages.dbc` id its `Effect_1` declares
/// ([`SpellCatalog::declared_language`]).
declared_language: HashMap<u32, u32>,
dispel_types: SpellDispelTypes,
}
@@ -523,6 +531,7 @@ impl SpellCatalog {
Self {
spells,
learned_spell: HashMap::new(),
declared_language: HashMap::new(),
dispel_types: SpellDispelTypes::default(),
}
}
@@ -551,6 +560,41 @@ impl SpellCatalog {
self.learned_spell.get(&id).copied()
}
/// The language a spell **declares** — its `Effect_1 == 39` (`SPELL_EFFECT_LANGUAGE`) slot's
/// `EffectMiscValue_1`, or `None` for a spell that is not a language.
///
/// This is the direction the reference works in, and the direction matters. `0x4b25b0` runs on
/// **spell add** and stores `[0xb700ac][EffectMiscValue_1] = spellId`, so the client's
/// language→spell table only ever holds languages *this character has learned*, and a later
/// learn overwrites an earlier one on the same language id (wow-re
/// `system/ui/scratch/chat-language-scramble.md` §8). Exposing spell→language lets the caller
/// fold that table over its own known-spell set and get the reference's answer; exposing
/// language→spell over the whole DBC would not, and the shipped data is why:
///
/// **Five of the fourteen language spells declare language 7 (Common), not their own.** In
/// 5875's `Spell.dbc`, 813 Thalassian, 814 Draconic, 815 Demon Tongue, 816 Titan and 817 Old
/// Tongue all carry `EffectMiscValue_1 = 7`, and four of the five are named "(NYI)". Two
/// consequences a re-implementation must not smooth over:
///
/// - **Languages 8, 9, 10 and 12 are unreachable** — no shipped spell declares them, so the
/// client's table never gets an entry and Demonic / Titan / Thalassian / Kalimag are *always*
/// fully garbled for every character. That is correct 1.12.1 behaviour, not a gap.
/// - **A warlock's Demon Tongue (815) overwrites Common's entry**, so their Common is gated on
/// their Demon Tongue skill. Unobservable — every language skill a character holds is 300 —
/// but it is the reference's behaviour and falls out of the fold for free.
///
/// Language 11 (Draconic) *is* reachable, via 25674 "Lesser Draconic (Language)", the one spell
/// that declares it correctly.
pub fn declared_language(&self, spell: u32) -> Option<u32> {
self.declared_language.get(&spell).copied()
}
/// Every `(spell id, language id)` pair a shipped spell declares — for the fold above, and for
/// the tests that pin the shipped anomaly.
pub fn declared_languages(&self) -> impl Iterator<Item = (u32, u32)> + '_ {
self.declared_language.iter().map(|(s, l)| (*s, *l))
}
pub fn len(&self) -> usize {
self.spells.len()
}
@@ -634,6 +678,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
let spells_set = parse(&spell_bytes, spell_schema(), "Spell.dbc")?;
let mut spells: HashMap<u32, SpellDisplay> = HashMap::new();
let mut learned_spell: HashMap<u32, u32> = HashMap::new();
let mut declared_language: HashMap<u32, u32> = HashMap::new();
for r in spells_set.records() {
let Some(id) = u32_at(r, 0) else { continue };
// The learn-spell hop (decision 0247): a SPELL_EFFECT_LEARN_SPELL effect's EffectTriggerSpell
@@ -647,6 +692,15 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
}
}
}
// The language declaration (wow-re `chat-language-scramble.md` §8). **Effect slot 0 only**
// — the reference dispatches on `Effect_1` alone (`[SpellRec+0xf4]`) and reads
// `EffectMiscValue_1` (`+0x1a8`); it does not scan the other two slots the way the learn
// hop above does.
if u32_at(r, COL_EFFECT_1) == Some(SPELL_EFFECT_LANGUAGE) {
if let Some(lang) = i32_at(r, COL_EFFECT_MISC_1).filter(|&l| l > 0) {
declared_language.insert(id, lang as u32);
}
}
let name = str_at(&spells_set, r, COL_NAME_ENUS).unwrap_or_default();
let rank = str_at(&spells_set, r, COL_NAME_SUBTEXT_ENUS);
let icon = u32_at(r, COL_ICON_ID)
@@ -785,6 +839,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
Ok(SpellCatalog {
spells,
learned_spell,
declared_language,
dispel_types,
})
}
+18 -22
View File
@@ -318,29 +318,25 @@ impl Plugin for WorldFoundation {
// shipped unclaimed for the campaign's whole run because 1364's knob under-measured it
// (the UI Camera2d leak, 1370). `WOW_INDIRECT=1` restores the upstream default for
// re-pricing. 1370's note that the lever must cover every camera, not just the world's,
// is why this is a sweep.
// is why this covers `Camera` itself rather than our own marker.
//
// **A REQUIRED COMPONENT, not a sweep** (decision 1488). 1374/1376 shipped this as an
// `Update` system that inserted the marker on any camera lacking it, and that is a latent
// GPU crash: bevy's own doc on `NoIndirectDrawing` says *"This component should only be
// added when initially spawning a camera. Adding or removing after spawn can result in
// unspecified behavior"*, and the unspecified behaviour is concrete —
// `get_or_create_work_item_buffer` latches Direct-vs-Indirect work-item buffers the FIRST
// time it sees a view (`Entry::Occupied` returns the existing one, forever), while the
// preprocessing node picks its pipeline from the LIVE `Has<NoIndirectDrawing>`. A camera
// extracted once before the sweep's deferred insert lands therefore keeps Indirect buffers
// under a direct pipeline for the rest of its life, and the next dispatch is a wgpu
// validation panic ("bind group … not compatible … Assigned entry with binding 7 not
// found"). Measured: the sweep was flagging 18 cameras late at startup and one 0.5 s in.
// `player::setup` already knew the rule for the world camera ("it must ride the SPAWN: the
// phase cache latches the preprocessing mode the first time it sees the view") — this
// gives every other camera the same guarantee, atomically, with no frame to lose.
if std::env::var_os("WOW_INDIRECT").is_none() {
app.add_systems(bevy::app::Update, force_direct_draws);
app.register_required_components::<bevy::camera::Camera, bevy::render::view::NoIndirectDrawing>();
}
}
}
/// Insert [`NoIndirectDrawing`] on any camera that lacks it — see the note at the registration
/// site. Runs every frame so late-spawned cameras (portrait booths, the glue booth) are covered;
/// the query is empty in steady state.
fn force_direct_draws(
mut commands: Commands,
cams: Query<
Entity,
(
With<bevy::camera::Camera>,
Without<bevy::render::view::NoIndirectDrawing>,
),
>,
) {
for e in &cams {
commands
.entity(e)
.insert(bevy::render::view::NoIndirectDrawing);
}
}