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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user