guild lines are message ids, mail sends again, and the shadow fades in
- the guild lines are message ids: `ui_guild/lines.rs` carried 34 English constants and two hand-rolled fill helpers and pushed composed text onto the chat log — 2045's largest ratchet row and the one file its rule had no route for, because `UiError`'s doc claimed the 1.12 formats use at most one `%s` and one `%d` and the type was built to match, while `ERR_GUILD_PROMOTE_SSS` is "%s has promoted %s to %s." — a wrong sentence in a doc had become a wrong type, and the wrong type had made a file's worth of drift structurally necessary; `CGGameUI::DisplayError` (`0x496720`) is cdecl varargs — `add esp,4` bare, `add esp,8` with one string, `SMSG_GUILD_INVITE` pushing two with the stack check exact at `0x30 + 0x60 = 0x90`, and `SMSG_GUILD_EVENT`'s emitter tail `0x5e745f` passing one, two or three off `strCount` — so `UiError` carries an ordered heterogeneous `Vec<FillArg>` (`S(String) | D(i64)`, since `ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI` is String-then-Integer) behind four constructors naming the reference's call shapes (`key`, `s`, `strings`, `args`), twenty sites migrated; what the catalog knew and composed text could not carry: `ERR_GUILD_NAME_INVALID`/`ERR_GUILD_NAME_EXISTS_S` are kind 2 — the red line — and went to chat, `ERR_GUILD_CREATE_S`/`ERR_INVITED_TO_GUILD_SS` carry the LEVELUP cue and `ERR_GUILD_PERMISSIONS` (type_tag `0x3e`) is one the reference speaks (1815), all three silent; the default arm was missing outright — `0x5e745a` pushes `0x69` = `ERR_GUILD_INTERNAL` for `0x09` and every id past `0x0d`, catalog row 105 the whole time, so `TABARD_CHANGE` had been silent under a comment calling the key unsettled; a short `strCount` is passed SHORT (0 and ≥ 4 pass zero strings) so a starved specifier reads "…promoted Furor to %s.", not "…to ."; `/ginfo` deliberately stays off the queue — `GUILD_NAME_TEMPLATE`/`GUILD_INFO_TEMPLATE` are not catalog rows, `0x5e6fb0` resolves each token through the VM and emits chat itself, and a non-row key through `Shown::keyed` would turn a chat line red — so it waits on the guild feed and goes out unkeyed, the enUS month-day-year swap with it; the `_S` arity is asserted from the key names, 22 of 22; the ratchet row is deleted, 208 over 31 files remain (2054) - a unit's blob shadow asks the UNIT for its alpha: the lane gathered live `RenderFade`s off a unit's part entities and read "no part is fading" as 1.0 — true of a settled unit and exactly backwards for arrival, where a streamed unit holds `PendingAppearFade` with no `RenderFade` anywhere until the world is shown, so its parts were deliberately invisible while the shadow drew fully opaque; measured on a probe login: alpha 1.000 from t 0.61 to the arm at t 3.51, then 0, then the ramp — 2.9 s of dark oval under no creature, the symptom `model_render_alpha`'s own "pending ⇒ 0" law (0827) already named for emitters and ribbons; `model_fade::UnitRenderAlpha` is the one door, composed from the ROOT where `UnitAppearFade` tells pending from settled, published as one question rather than its three inputs so a caller cannot reassemble the mistake, and `DespawnFade::armed()` retires the `-1.0` sentinel from every caller; the world API wall goes 177 → 178; and wow-re settled what the shadow's alpha IS, four questions VERIFIED: it reads `CM2Model+0x180` through `0x710ca0`, not `+0x19c`; that slot carries the appear and despawn ramps, the self fade AND the CharProc-14 aura — `0x60d180` drives the same `StartAlphaFade` the appear fade does, so a stealthed unit's shadow now dims with its body — and not the per-batch M2 colour/weight tracks; and the reference draws nothing under a not-yet-shown object either (`+0x180 = 1.0 · 0.0`, written 30 bytes before the read), so this is fidelity, not taste; three module-doc claims corrected against it: the dead `0x683ea5` gate, the registration handoff (GameObjects and DynamicObjects never cast; units, players and corpses always do), and 1277's `InheritedVisibility` gate, which the reference lacks and stays ours on purpose (2055) - the mail stationery list was empty for the whole session, and every send with it: the five `dropping command — not connected` lines at login, once named, were `ItemQuery` for all five `Stationery.dbc` rows — `feed_mail` carried no run condition and asked from the login screen before the socket existed, the asks evaporated in the io thread, and `Items::template` had already latched all five in its ask-once `pending` set, cleared on disconnect and never on connect, so they were never re-asked and the stock `SendMailFrame_Reset`'s row-1 selection left every send silently unsent; the list now waits for a self player, and `release_ask_once_latches_on_enter` clears the pending sets at world enter, the counterpart the disconnect teardown never had — a redundant re-ask costs one packet, a wrong latch costs a dead feature nobody can see is dead; and three more log lines answer for themselves: a control character is answered before the shape — a newline is a line break the markup parser consumes, not a missing glyph — so the warn stays meaningful for a glyph a face genuinely lacks; the blend-check's one-frame run is the specialization tick catching up with a material swap, a real login showing four entries clear 26 ms later, so the warn waits for a run to SURVIVE the tick and a one-frame run is recorded at debug; and the audio starve line carries the wall span it covered beside the time it counted and the position it saw advance, because a 1.0 s window still closed 0.756 s of wall after the stream started on `Time<Real>` — if they disagree the caller's clock is still wrong, if they agree the starvation is real Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,13 @@
|
||||
//! row `0x6812b1`–`0x6812ca` inside `0x681070`, which the driver `0x483460` calls at
|
||||
//! `0x48361d` (wow-re `water-frame-straddle.md` §1 + `unit-blob-shadow.md` Q1). `0x683dd0` is
|
||||
//! the **M2 node drain**, and the same loop body ticks each node's object first
|
||||
//! (`0x48160c call [obj vt+0x38]` → the selection ring) and gates its shadow second
|
||||
//! (`0x48160c call [obj vt+0x38]` → the selection ring) and draws its shadow second
|
||||
//! (`0x683ec3`) — so per unit the additive ring goes down and this modulate darkens it, never
|
||||
//! the other way round (wow-re `decal-frame-slot.md`). So the shadow
|
||||
//! the other way round (wow-re `decal-frame-slot.md`). That second step is **not** a gate:
|
||||
//! both exits of the tick `0x481540` return 1, so `0x683ea5`'s `je` is dead — an earlier
|
||||
//! reading of it as "the callback suppresses a hidden object's shadow" was wrong at the bytes
|
||||
//! and is corrected in wow-re (2026-09-06). What the tick DOES do is write the alpha this draw
|
||||
//! is about to read, 30 bytes later — which is the whole of §"Appearance" below. So the shadow
|
||||
//! lands after terrain and WMO and **before** everything else: the footprint decals
|
||||
//! (`0x483654`), the M2 opaque pass (`0x4836a6`), the water surfaces (phase 3, drawn *between*
|
||||
//! the two M2 transparent passes) and both of those passes. Every transparent in the world
|
||||
@@ -40,16 +44,46 @@
|
||||
//! never appear). Full extents, no missing half/scale factor — the standing size IS the law.
|
||||
//! - **Appearance**: multiplicative darken — `GL_DST_COLOR/GL_ZERO` with the fade riding the
|
||||
//! combine, which is exactly the lane's `EffectBlend::Multiply` (`dst × lerp(1, src, α)`).
|
||||
//! Vertex diffuse is **white** with α = the model's fade alpha (`[model+0x180]` — spawn/despawn
|
||||
//! fades + the self first-person fade ride into the shadow); the darkness lives in the texture
|
||||
//! RGB. Unlit, no fog, no depth write. The texture loads as the default `WorldArt`
|
||||
//! Vertex diffuse is **white** with α = the model's **base** alpha — `CM2Model+0x180`, read
|
||||
//! through the accessor `0x710ca0` (`fld [ecx+0x180]`) at `0x6d7fd6`, then clamped to `[0,1]`,
|
||||
//! ×255, `__ftol`, packed `(a<<24)|0x00FFFFFF`. VERIFIED, and it is `+0x180` and not `+0x19c`:
|
||||
//! over the whole draw every `0x19c` operand is the stack local `[ebp-0x19c]`, and `this` is
|
||||
//! touched at exactly three `mov ecx,ebx; call` sites (box `0x711a20`, matrix `0x710600`,
|
||||
//! alpha `0x710ca0`), which makes that list the complete field census.
|
||||
//!
|
||||
//! **What rides in, at the bytes** (wow-re, 2026-09-06): the 2 s appear ramp and the despawn
|
||||
//! ramp (`obj+0xf4`, `0x613b1e`'s `0x7d0`), the self first-person fade, **and the CharProc-14
|
||||
//! aura transition** — stealth's `0.3`, ghost/invisibility's `0.5`. The aura is not a second
|
||||
//! channel: `0x60d180` drives the SAME `StartAlphaFade` (`0x614f80`) the appear fade uses, and
|
||||
//! `0x614a90` writes `model+0x180 = obj+0x100 · obj+0xf4` unconditionally given a model handle.
|
||||
//! A stealthed unit's blob shadow dims with its body, on the same 1000 ms cubic.
|
||||
//!
|
||||
//! **What does not**: the M2 animated colour / texture-weight tracks. The body's per-batch
|
||||
//! alpha is `+0x19c · M2Color[i].alpha · M2TextureWeight[j].value` (`0x707aea`–`0x707b33`);
|
||||
//! this draw reads `+0x180` raw, so an asset-authored transparency track fades the body and
|
||||
//! leaves its shadow at full strength. [`benilla_world::model_fade::UnitRenderAlpha`] is that
|
||||
//! same product and stops at the same place.
|
||||
//!
|
||||
//! The darkness lives in the texture RGB. Unlit, no fog, no depth write. The texture loads as the default `WorldArt`
|
||||
//! `Rgba8Unorm`, so the modulate multiplies raw bytes in the gamma lane — the reference's own
|
||||
//! arithmetic (0161).
|
||||
//! - **Gating**: the reference's `shadowLOD` cvar {0,1} is the master toggle (default on) — we are
|
||||
//! always-on; `shadowBias` (default 0.1) is its depth-bias knob — [`SHADOW_DEPTH_BIAS`] plays
|
||||
//! that role here. No dead/mount/kind test exists on the draw path; **which** objects register
|
||||
//! for shadows is an open RE item (`HANDOFF(-> object-layer)`) — v1 policy: every Player/Unit
|
||||
//! entity with a built animated model (GameObjects/doodads excluded).
|
||||
//! that role here. No dead/mount/kind test exists on the draw path, and **which** objects
|
||||
//! register is now settled (wow-re, 2026-09-06 — the old `HANDOFF(-> object-layer)` is closed):
|
||||
//! `[node+0x90]` bit `0x400` is `NOT(arg bit1)` (`0x670e94`), and `0x613e10` takes that arg off
|
||||
//! `OBJECT_FIELD_TYPE` — `0xb` GAMEOBJECT, `2` DYNAMICOBJECT, `0` otherwise. **GameObjects and
|
||||
//! DynamicObjects never cast a blob shadow; units, players and corpses always do.** Which is
|
||||
//! the v1 policy this lane already ran (every Player/Unit with a built animated model), now a
|
||||
//! byte-fact rather than a guess.
|
||||
//!
|
||||
//! **One benilla divergence, deliberate.** `0x6d78f0`/`0x6d7920` read no visibility state at
|
||||
//! all — the body's draw flag `[CM2Model+0x50]` comes from `ShouldRender` at `0x48174e`, which
|
||||
//! is consumed at `0x48161d`, *after* the tick — so the reference can and does draw a shadow
|
||||
//! under an undrawn body (a unit whose skin composite is still unbaked, `0x477860`). We gate on
|
||||
//! `InheritedVisibility` anyway, because benilla's exterior-scene election is not a mechanism
|
||||
//! the reference has and left Tanaris mobs' shadows on the Caverns of Time floor (decision
|
||||
//! 1277). Keeping that gate is the 1277 call; it is named here so it reads as a choice.
|
||||
//!
|
||||
//! One shadow record per unit, its projected triangles rebuilt only when the inputs move
|
||||
//! ([`ShadowKey`]) and pushed onto the effect stream every shown frame — an idle unit costs a
|
||||
@@ -59,14 +93,12 @@
|
||||
|
||||
use benilla_assets::ModelAnimations;
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::ecs::entity::{EntityHashMap, EntityHashSet};
|
||||
use bevy::ecs::entity::EntityHashSet;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::creature_anim::AnimData;
|
||||
use crate::net::{Embodied, NetEntity};
|
||||
use crate::player::CameraControl;
|
||||
use benilla_world::decal::{DecalFrame, WorldDecal};
|
||||
use benilla_world::model_fade::{fade_alpha, RenderFade};
|
||||
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
|
||||
use benilla_world::schedule::WorldStage;
|
||||
use benilla_world::view::WorldCamera;
|
||||
@@ -184,15 +216,11 @@ fn sync_shadows(
|
||||
fn update_shadows(
|
||||
time: Res<Time>,
|
||||
catalog: Option<Res<AnimData>>,
|
||||
rig: Option<Res<CameraControl>>,
|
||||
shadow_assets: Option<Res<ShadowAssets>>,
|
||||
images: Res<Assets<Image>>,
|
||||
decals: WorldDecal,
|
||||
// Spawn/despawn fades live on the model *part* entities; attribute each to its unit root so
|
||||
// the shadow can ride the same alpha the body renders with (O(#currently-fading parts) — zero
|
||||
// in the steady state).
|
||||
fades: Query<(Entity, &RenderFade)>,
|
||||
parents: Query<&ChildOf>,
|
||||
// The unit's render alpha, asked of the unit — see the alpha block below.
|
||||
unit_alpha: benilla_world::model_fade::UnitRenderAlpha,
|
||||
owners: Query<
|
||||
(
|
||||
&Transform,
|
||||
@@ -230,16 +258,6 @@ fn update_shadows(
|
||||
// and folding the two would make the census answer "why did it hide" with a lie. This is the
|
||||
// instrument's whole job (decision 1283).
|
||||
let mut n_undrawn = 0u32;
|
||||
let mut root_fade: EntityHashMap<f32> = EntityHashMap::default();
|
||||
for (part, fade) in &fades {
|
||||
let alpha = fade_alpha(fade.from, fade.to, (now - fade.started) / fade.duration);
|
||||
let mut root = part;
|
||||
while let Ok(child_of) = parents.get(root) {
|
||||
root = child_of.parent();
|
||||
}
|
||||
let slot = root_fade.entry(root).or_insert(1.0);
|
||||
*slot = slot.min(alpha);
|
||||
}
|
||||
let surface_count = decals.receiver_count();
|
||||
for (shadow, mut key, mut verts) in &mut shadows {
|
||||
n_total += 1;
|
||||
@@ -293,11 +311,22 @@ fn update_shadows(
|
||||
n_degen += 1;
|
||||
continue;
|
||||
}
|
||||
let mut alpha = root_fade.get(&shadow.owner).copied().unwrap_or(1.0);
|
||||
if is_self {
|
||||
// The self first-person fade rides the same model-fade slot in the reference.
|
||||
alpha *= rig.as_deref().map_or(1.0, CameraControl::self_fade);
|
||||
}
|
||||
// **The unit's render alpha, asked of the unit** — the shadow rides the model's fade slot
|
||||
// (module docs), so it takes the engine's answer rather than reconstructing one.
|
||||
//
|
||||
// It used to gather the live `RenderFade`s off this unit's *part* entities and read "no
|
||||
// part is fading" as `1.0`. That is true of a settled unit and exactly backwards for the
|
||||
// state a unit spends its whole arrival in: a streamed unit holds `PendingAppearFade` —
|
||||
// no `RenderFade` anywhere yet — until the world is actually shown, so its parts are
|
||||
// deliberately invisible while this walk found nothing to attribute and drew the shadow
|
||||
// **fully opaque**. A dark oval on the ground under no creature for the length of the
|
||||
// load (2.9 s on a probe login), snapping to zero the instant the ramp armed, then easing
|
||||
// back in with the body. `UnitRenderAlpha` asks the root, where `UnitAppearFade` tells
|
||||
// pending from settled — the distinction a part-side walk cannot make.
|
||||
//
|
||||
// The self first-person fade comes with it (the reference rides the same slot), which is
|
||||
// why there is no `is_self` term here any more.
|
||||
let alpha = unit_alpha.get(shadow.owner);
|
||||
if alpha <= 0.0 {
|
||||
hide(&mut key, &mut verts);
|
||||
continue;
|
||||
|
||||
@@ -698,18 +698,15 @@ fn char_result_text<'a>(strings: &'a GlueStrings, code: u8) -> &'a str {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// **Every char-create result resolves to the sentence 1.12 actually ships**, read off the
|
||||
/// player's own `GlueStrings.lua` — the regression for three bugs that literals had hidden
|
||||
/// (decision 2045). Skips without client data.
|
||||
/// player's own chain — `GlueStrings.lua` with `GlueLocalization.lua`'s `Localize()` patch over
|
||||
/// it, assembled by the loader's own helper so this cannot assert a sentence the running client
|
||||
/// would not show (2052). The regression for three bugs that literals had hidden (decision
|
||||
/// 2045). Skips without client data.
|
||||
#[test]
|
||||
fn every_char_create_result_resolves_in_the_real_glue_strings() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let src = chain
|
||||
.read_file("Interface\\GlueXML\\GlueStrings.lua")
|
||||
.expect("GlueStrings.lua in the chain");
|
||||
let strings = GlueStrings::from_map(crate::glue_strings::parse_glue_strings(
|
||||
&String::from_utf8_lossy(&src),
|
||||
));
|
||||
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
||||
|
||||
// `0x36` used to stop at "…temporarily disabled."; the shipped string carries a second
|
||||
// sentence, and a re-typed string is one nobody diffed.
|
||||
@@ -751,10 +748,7 @@ mod tests {
|
||||
fn the_reserved_name_code_names_the_reserved_key_not_the_in_use_one() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let src = chain
|
||||
.read_file("Interface\\GlueXML\\GlueStrings.lua")
|
||||
.expect("GlueStrings.lua in the chain");
|
||||
let map = crate::glue_strings::parse_glue_strings(&String::from_utf8_lossy(&src));
|
||||
let map = crate::glue_strings::table_from_chain(&mut chain).into_map();
|
||||
|
||||
// Both keys exist and agree in enUS — which is why the mix-up was invisible.
|
||||
let reserved = map.get("CHAR_NAME_RESERVED").expect("CHAR_NAME_RESERVED");
|
||||
|
||||
@@ -92,8 +92,6 @@ pub(super) fn refresh_list(
|
||||
t.0 = new;
|
||||
}
|
||||
}
|
||||
// The realm banner: "<name> (PVP)" per the realm type; "(Server down)" while unreachable
|
||||
// (the ref's SERVER_DOWN suffix); a plain connecting note before the first roster.
|
||||
if let Ok(mut t) = banner.single_mut() {
|
||||
let new = match &roster.realm {
|
||||
Some(realm) => {
|
||||
@@ -103,14 +101,9 @@ pub(super) fn refresh_list(
|
||||
8 => strings.text("RPPVP_PARENTHESES", "(RPPVP)"),
|
||||
_ => "",
|
||||
};
|
||||
let down = if status.last_reason.is_some() && roster.pending_pick.is_none() {
|
||||
format!("\n({})", strings.text("SERVER_DOWN", "Server down"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!("{} {}{down}", realm.name, suffix)
|
||||
.trim_end()
|
||||
.to_string()
|
||||
let down = (status.last_reason.is_some() && roster.pending_pick.is_none())
|
||||
.then(|| strings.text("SERVER_DOWN", "Server down"));
|
||||
realm_banner(&realm.name, suffix, down)
|
||||
}
|
||||
None => match &status.last_reason {
|
||||
Some(_) => strings.text("SERVER_DOWN", "Server down").to_string(),
|
||||
@@ -123,6 +116,29 @@ pub(super) fn refresh_list(
|
||||
}
|
||||
}
|
||||
|
||||
/// The realm banner, composed the way `CharacterSelect_OnShow` composes it (`CharacterSelect.lua`
|
||||
/// l.48-62): the down note is appended to the **name**, and the realm-type suffix goes after that
|
||||
/// whole thing.
|
||||
///
|
||||
/// ```text
|
||||
/// serverName = serverName.."\n("..TEXT(SERVER_DOWN)..")"; -- only while disconnected
|
||||
/// CharSelectRealmName:SetText(serverName.." "..serverType);
|
||||
/// ```
|
||||
///
|
||||
/// A separate function because the order is exactly what drifted: ours put the suffix first and the
|
||||
/// down note last, under a comment claiming the reference's shape. Nothing showed while the suffix
|
||||
/// was `"(PVP)"` — parentheses read plausibly at either end. Decision 2052 made the suffix the enGB
|
||||
/// patch's `"PVP"`, a bare word, and the order became legible. A comment could not fail; this can.
|
||||
fn realm_banner(name: &str, suffix: &str, down: Option<&str>) -> String {
|
||||
let name = match down {
|
||||
Some(reason) => format!("{name}\n({reason})"),
|
||||
None => name.to_string(),
|
||||
};
|
||||
// The reference appends `" "..serverType` unconditionally, leaving a trailing space on a realm
|
||||
// with no type; we trim it, since ours is a laid-out node rather than a Lua string.
|
||||
format!("{name} {suffix}").trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Per-frame interaction visuals + button states: the row highlight (hover ∪ selected — the ref's
|
||||
/// `LockHighlight` on the selected row), and the enabled states — Enter World / Delete disable on
|
||||
/// an empty list (the ref's `UpdateCharacterList`), Create hides at the 10-cap or disconnected,
|
||||
@@ -211,3 +227,26 @@ pub(super) fn feed_glue_preview(
|
||||
preview.look = look;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The composition order, pinned against `CharacterSelect.lua` l.48-62.
|
||||
#[test]
|
||||
fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() {
|
||||
assert_eq!(realm_banner("Kalimdor", "PVP", None), "Kalimdor PVP");
|
||||
assert_eq!(
|
||||
realm_banner("Kalimdor", "PVP", Some("Server down")),
|
||||
"Kalimdor\n(Server down) PVP",
|
||||
"the suffix follows the whole name+note, not the name alone"
|
||||
);
|
||||
// No realm type: the reference's unconditional " " is all that would be left, and a banner
|
||||
// does not end in whitespace.
|
||||
assert_eq!(realm_banner("Kalimdor", "", None), "Kalimdor");
|
||||
assert_eq!(
|
||||
realm_banner("Kalimdor", "", Some("Server down")),
|
||||
"Kalimdor\n(Server down)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -972,32 +972,58 @@ struct Knobs<'a> {
|
||||
fps_journal: &'a mut crate::perf::FpsJournalSetting,
|
||||
}
|
||||
|
||||
/// **The string-valued rows**, matched ahead of the numeric parse every other row goes through —
|
||||
/// which would reject them as bad values. `gxResolution` was the first (decision 1627) and its
|
||||
/// comment named this as the shape a second one would join rather than a second special case
|
||||
/// somewhere else; `realmList` (1667) is the second, `realmName` the third. Every arm shares the
|
||||
/// numeric miss's posture below: known key, bad value — consumed, with a warn, and the resource
|
||||
/// keeps its truth.
|
||||
///
|
||||
/// **Split out of [`apply_to_knobs`] so the table can be held to it.** The claim
|
||||
/// "a string row without an arm here is a CVar the client will never honour" was written beside
|
||||
/// [`the_string_valued_cvars_are_the_realm_and_the_windowed_size`] and then not enforced:
|
||||
/// `realmName` shipped with no arm, so every launch after the first connect warned
|
||||
/// `cvar realmName: unparseable value 'VMaNGOS' ignored` on the way past the numeric parse. As a
|
||||
/// separate `bool` this is something a test can call for every non-numeric row in the table, which
|
||||
/// is what [`every_string_valued_row_is_claimed_before_the_numeric_parse`] now does.
|
||||
fn apply_string_valued(key: &str, name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
if !is_string_valued(key) {
|
||||
return false;
|
||||
}
|
||||
match key {
|
||||
"gxresolution" => match crate::video::parse_resolution(value) {
|
||||
Some(size) => knobs.video.windowed = size,
|
||||
None => warn!("cvar {name}: unparseable value '{value}' ignored"),
|
||||
},
|
||||
"realmlist" => match crate::realmlist::normalize(value) {
|
||||
Some(address) => knobs.realmlist.set(&address),
|
||||
None => warn!("cvar {name}: unusable realmlist '{value}' ignored"),
|
||||
},
|
||||
// No host knob, and none wanted: the live realm name is written from the session
|
||||
// (`ui_script::addons::load_third_party`), and the persisted one reaches `GetCVar` through
|
||||
// `set_cvar_saved_base` without passing here at all. Claimed anyway — the `statusBarText`
|
||||
// posture — so the value is CONSUMED rather than falling to a numeric parse that can only
|
||||
// reject it, and so a toggle still dirties the config.
|
||||
"realmname" => {}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Which keys [`apply_string_valued`] claims — lowercased, and split out from the arms so a test
|
||||
/// can hold the TABLE to it without building a `Knobs`. The claim it makes possible: every
|
||||
/// registered row whose default does not parse as a number is named here
|
||||
/// ([`every_string_valued_row_is_claimed_before_the_numeric_parse`]).
|
||||
fn is_string_valued(key: &str) -> bool {
|
||||
matches!(key, "gxresolution" | "realmlist" | "realmname")
|
||||
}
|
||||
|
||||
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
|
||||
/// build knows (the caller decides whether that warns or rides through).
|
||||
fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
let key = name.to_ascii_lowercase();
|
||||
// **The string-valued rows**, matched ahead of the numeric parse every other row goes through
|
||||
// — which would reject them as bad values. `gxResolution` was the first (decision 1627) and
|
||||
// its comment named this as the shape a second one would join rather than a second special
|
||||
// case somewhere else; `realmList` (1667) is that second one, so this is now that shape.
|
||||
// Every arm shares the numeric miss's posture below: known key, bad value — consumed, with a
|
||||
// warn, and the resource keeps its truth.
|
||||
match key.as_str() {
|
||||
"gxresolution" => {
|
||||
match crate::video::parse_resolution(value) {
|
||||
Some(size) => knobs.video.windowed = size,
|
||||
None => warn!("cvar {name}: unparseable value '{value}' ignored"),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
"realmlist" => {
|
||||
match crate::realmlist::normalize(value) {
|
||||
Some(address) => knobs.realmlist.set(&address),
|
||||
None => warn!("cvar {name}: unusable realmlist '{value}' ignored"),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
if apply_string_valued(&key, name, value, knobs) {
|
||||
return true;
|
||||
}
|
||||
let Ok(v) = value.parse::<f32>() else {
|
||||
warn!("cvar {name}: unparseable value '{value}' ignored");
|
||||
@@ -2430,4 +2456,31 @@ mod tests {
|
||||
Some(crate::realmlist::DEFAULT_REALMLIST),
|
||||
);
|
||||
}
|
||||
|
||||
/// **The claim the test above only asserted in prose, now enforced.** Its doc says a string
|
||||
/// row that forgets its arm in [`apply_to_knobs`] "is a CVar the player can set and the client
|
||||
/// will never honour, and this is what makes adding one impossible to do quietly" — and then
|
||||
/// `realmName` was added and did exactly that. It reached the numeric parse, which can only
|
||||
/// reject it, so every launch after the first connect logged
|
||||
/// `cvar realmName: unparseable value 'VMaNGOS' ignored`.
|
||||
///
|
||||
/// It was the mild half of the failure — the persisted value still reaches `GetCVar` through
|
||||
/// `set_cvar_saved_base`, so nothing was actually lost, and the warn was libel rather than
|
||||
/// news. A string row that DID own a knob would have been silently dropped. Both directions
|
||||
/// are pinned: a new non-numeric row that skips [`is_string_valued`] fails here, and a key
|
||||
/// named there that stops being a registered string row fails here too.
|
||||
#[test]
|
||||
fn every_string_valued_row_is_claimed_before_the_numeric_parse() {
|
||||
for r in REGISTERED {
|
||||
let key = r.name.to_ascii_lowercase();
|
||||
assert_eq!(
|
||||
r.default.parse::<f32>().is_err(),
|
||||
is_string_valued(&key),
|
||||
"{}: a row's default parsing as a number and `is_string_valued` must agree — \
|
||||
a string row that misses the guard falls to the numeric parse, which only \
|
||||
rejects it",
|
||||
r.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +168,13 @@ pub(super) fn resolve_wmo_gameobject_props(
|
||||
})
|
||||
.collect();
|
||||
if !props.is_empty() {
|
||||
// Named by the ENTITY as well as the display: the resolver visits each entity once
|
||||
// (`WmoPropsResolved` is inserted before any `continue`), so two identical lines mean
|
||||
// two instances of one display — two ships of the same model — not a repeat of the
|
||||
// work. Without the instance in the message that is unreadable, and it read as a
|
||||
// caching bug on 2026-09-06.
|
||||
info!(
|
||||
"wmo props: {} set-0 doodads resolved for display {}",
|
||||
"wmo props: {} set-0 doodads resolved for display {} on {entity}",
|
||||
props.len(),
|
||||
net.display_id.unwrap_or_default()
|
||||
);
|
||||
@@ -481,9 +486,12 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
if hulls + emitters + ribbons + lights + interior > 0 {
|
||||
// The instrument line the probe greps: what this host's props actually authored
|
||||
// (zero anywhere is legal — collide-iff-hull, emit-iff-authored, indoor-iff-owned).
|
||||
// The host is in the message for the same reason as above — and doubly here, because
|
||||
// this line fires once per host per frame in which anything spawned, so ONE host's
|
||||
// 134 props land over several batches and emit several honest lines.
|
||||
info!(
|
||||
"wmo props: {hulls} cargo hulls, {emitters} emitters, {ribbons} ribbons, \
|
||||
{lights} lights, {interior} interior-lane props (riding the host)"
|
||||
{lights} lights, {interior} interior-lane props (riding host {entity})"
|
||||
);
|
||||
}
|
||||
if props.0.is_empty() {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
//! The 1.12 glue string table (`Interface\GlueXML\GlueStrings.lua`), read off the MPQ chain at
|
||||
//! startup — the localized text the glue screens quote: faction/race/class description paragraphs
|
||||
//! (`FACTION_INFO_*`, `RACE_INFO_*`, `ABILITY_INFO_*`, `CLASS_*`), the per-race customization dial
|
||||
//! labels (`HAIR_<tok>_STYLE`, `FACIAL_HAIR_<tok>`), and the button captions.
|
||||
//! The 1.12 glue string table, read off the MPQ chain at startup — the localized text the glue
|
||||
//! screens quote: faction/race/class description paragraphs (`FACTION_INFO_*`, `RACE_INFO_*`,
|
||||
//! `ABILITY_INFO_*`, `CLASS_*`), the per-race customization dial labels (`HAIR_<tok>_STYLE`,
|
||||
//! `FACIAL_HAIR_<tok>`), the login refusals, and the button captions.
|
||||
//!
|
||||
//! **It is two files, in the reference's own order** (decision 2052). `GlueXML.toc` sources
|
||||
//! `GlueStrings.lua` at line 1 and `GlueLocalization.xml` at line 3, and that XML exists only to
|
||||
//! load its script and call `Localize()` — a function of nothing but assignments, which *overwrite*
|
||||
//! the base table. In 1.12 enGB it rewrites 32 keys, among them every long login refusal
|
||||
//! (`AUTH_BANNED`, `LOGIN_UNKNOWN_ACCOUNT`, …) and the realm-type suffixes. Reading only the base
|
||||
//! file showed the player sentences their own client never shows. The in-game side already loads
|
||||
//! its exact twin — `Interface\FrameXML\Localization.xml`, same two-part shape, sourced from
|
||||
//! `benilla.toc` — so the glue side was the half that had been left behind.
|
||||
//!
|
||||
//! Runtime-read, never embedded: the paragraphs are Blizzard content, so they load from the
|
||||
//! player's own client data like every other asset (the repo's never-commit rule). The file is
|
||||
//! plain `KEY = "value";` Lua assignments, one per line — a full Lua VM would be theater here;
|
||||
//! [`parse_glue_strings`] handles exactly that shape (and skips everything else).
|
||||
//! player's own client data like every other asset (the repo's never-commit rule) — which is also
|
||||
//! what makes the locale *theirs* rather than ours to guess. Both files are plain `KEY = "value";`
|
||||
//! Lua assignments, one per line — a full Lua VM would be theater here; [`parse_glue_strings`]
|
||||
//! handles exactly that shape (and skips everything else).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -15,6 +25,7 @@ use bevy::prelude::*;
|
||||
use benilla_assets::{LockRecover, WorldAssets};
|
||||
|
||||
const GLUE_STRINGS: &str = "Interface\\GlueXML\\GlueStrings.lua";
|
||||
const GLUE_LOCALIZATION: &str = "Interface\\GlueXML\\GlueLocalization.lua";
|
||||
|
||||
/// The glue string table. Present but possibly empty (missing client data — the graceful-absence
|
||||
/// posture; callers fall back to their built-in captions).
|
||||
@@ -29,6 +40,12 @@ impl GlueStrings {
|
||||
Self(map)
|
||||
}
|
||||
|
||||
/// The pairs back out, for a test that has to alter one key and re-resolve.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_map(self) -> HashMap<String, String> {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// The string for a key, or `None` (unknown key / no data).
|
||||
pub(crate) fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key).map(String::as_str)
|
||||
@@ -41,23 +58,36 @@ impl GlueStrings {
|
||||
}
|
||||
|
||||
/// Startup: read + parse the glue string table off the chain (after the chain exists).
|
||||
///
|
||||
/// Two reads, and the **order is the whole point**: the base file, then the locale patch over it,
|
||||
/// exactly as `GlueXML.toc` sources them. A chain missing either one degrades on its own — no
|
||||
/// base means built-in captions, no patch means the base text.
|
||||
pub(crate) fn load_glue_strings(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
let mut table = GlueStrings::default();
|
||||
if let Some(assets) = assets {
|
||||
match assets.chain.lock_recover().read_file(GLUE_STRINGS) {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
match chain.read_file(GLUE_STRINGS) {
|
||||
Ok(bytes) => {
|
||||
table.0 = parse_glue_strings(&String::from_utf8_lossy(&bytes));
|
||||
info!("glue strings: {} entries", table.0.len());
|
||||
}
|
||||
Err(e) => warn!("glue strings unavailable ({e:#}) — built-in captions only"),
|
||||
}
|
||||
match chain.read_file(GLUE_LOCALIZATION) {
|
||||
Ok(bytes) => {
|
||||
let patch = parse_localize_overrides(&String::from_utf8_lossy(&bytes));
|
||||
info!("glue strings: {} locale overrides", patch.len());
|
||||
table.0.extend(patch);
|
||||
}
|
||||
Err(e) => warn!("glue localization unavailable ({e:#}) — unpatched glue strings"),
|
||||
}
|
||||
}
|
||||
commands.insert_resource(table);
|
||||
}
|
||||
|
||||
/// Parse the `KEY = "value";` assignments (one per line; `\n`/`\t`/`\"`/`\\` escapes unfolded).
|
||||
/// Anything else — comments, code, multi-line constructs — is skipped.
|
||||
pub(crate) fn parse_glue_strings(src: &str) -> HashMap<String, String> {
|
||||
fn parse_glue_strings(src: &str) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
for line in src.lines() {
|
||||
let Some((key, rest)) = line.split_once('=') else {
|
||||
@@ -98,6 +128,56 @@ pub(crate) fn parse_glue_strings(src: &str) -> HashMap<String, String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The assignments inside `GlueLocalization.lua`'s **`Localize()`** — the locale patch the
|
||||
/// reference lays over `GlueStrings.lua`.
|
||||
///
|
||||
/// Scoped to that one function deliberately, because the file defines two and only one of them is
|
||||
/// a string table:
|
||||
///
|
||||
/// - **`Localize()`** is run by `GlueLocalization.xml`'s own inline `<Script>` (l.6) the moment the
|
||||
/// file loads, which in `GlueXML.toc` is line 3 — after `GlueStrings.lua` at line 1. Its body is
|
||||
/// nothing but `KEY = "value";`, and it is what this reads.
|
||||
/// - **`LocalizeFrames()`** is called much later, from `GlueParent_OnEvent`'s `FRAMES_LOADED` arm
|
||||
/// (`GlueParent.lua` l.95), and is frame surgery rather than text: in 1.12 enGB, a texture swap
|
||||
/// and reposition of the login screen's `WorldOfWarcraftRating` logo — a frame benilla's native
|
||||
/// glue does not have at all. Nothing there to apply, so nothing here reads it.
|
||||
///
|
||||
/// The body runs from `function Localize()` to the first **unindented** `end`, which is Blizzard's
|
||||
/// own formatting: nested blocks are indented, a function's own terminator is not. A file whose
|
||||
/// shape doesn't match yields no overrides rather than the wrong ones — the loader logs the count,
|
||||
/// so a zero is visible rather than silent.
|
||||
fn parse_localize_overrides(src: &str) -> HashMap<String, String> {
|
||||
let mut body = String::new();
|
||||
let mut inside = false;
|
||||
for line in src.lines() {
|
||||
if !inside {
|
||||
inside = line.trim_start().starts_with("function Localize()");
|
||||
continue;
|
||||
}
|
||||
if line.trim_end() == "end" {
|
||||
return parse_glue_strings(&body);
|
||||
}
|
||||
body.push_str(line);
|
||||
body.push('\n');
|
||||
}
|
||||
// No terminator (or no `Localize()` at all): an unrecognised file patches nothing.
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
/// Build the table the way [`load_glue_strings`] does — base file, then the locale patch over it.
|
||||
/// Every test that resolves against the *real* chain goes through here, so no test can assert a
|
||||
/// sentence the running client would not show.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn table_from_chain(chain: &mut benilla_formats::Chain) -> GlueStrings {
|
||||
let base = chain.read_file(GLUE_STRINGS).expect("GlueStrings.lua");
|
||||
let mut map = parse_glue_strings(&String::from_utf8_lossy(&base));
|
||||
let patch = chain
|
||||
.read_file(GLUE_LOCALIZATION)
|
||||
.expect("GlueLocalization.lua");
|
||||
map.extend(parse_localize_overrides(&String::from_utf8_lossy(&patch)));
|
||||
GlueStrings::from_map(map)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -123,4 +203,77 @@ BROKEN = "no close
|
||||
assert!(!t.contains_key("BROKEN"));
|
||||
assert_eq!(t.len(), 3);
|
||||
}
|
||||
|
||||
/// `Localize()`'s body, and **only** its body: the second function in the file is frame
|
||||
/// surgery that the reference calls from somewhere else entirely, so an assignment there must
|
||||
/// not leak into the string table.
|
||||
#[test]
|
||||
fn the_locale_patch_reads_localize_and_stops_at_its_end() {
|
||||
let src = r#"function Localize()
|
||||
-- Put all locale specific string adjustments here
|
||||
AUTH_REJECT = "Login unavailable - contact support";
|
||||
PVP_PARENTHESES = "PVP";
|
||||
--SHOW_CONTEST_AGREEMENT = 1;
|
||||
end
|
||||
|
||||
function LocalizeFrames()
|
||||
NOT_A_STRING_TABLE = "must not leak";
|
||||
WorldOfWarcraftRating:SetTexture("Interface\\Glues\\Login\\Glues-FrenchRating");
|
||||
end
|
||||
"#;
|
||||
let t = parse_localize_overrides(src);
|
||||
assert_eq!(
|
||||
t.get("AUTH_REJECT").map(String::as_str),
|
||||
Some("Login unavailable - contact support")
|
||||
);
|
||||
assert_eq!(t.get("PVP_PARENTHESES").map(String::as_str), Some("PVP"));
|
||||
assert!(
|
||||
!t.contains_key("NOT_A_STRING_TABLE"),
|
||||
"LocalizeFrames leaked"
|
||||
);
|
||||
assert!(!t.contains_key("SHOW_CONTEST_AGREEMENT"), "comment parsed");
|
||||
assert_eq!(t.len(), 2);
|
||||
}
|
||||
|
||||
/// A file that is not the shape we understand patches **nothing** — never a partial or a
|
||||
/// guessed table. The two ways it can fail: no `Localize()` at all, and a body with no
|
||||
/// terminator to stop at.
|
||||
#[test]
|
||||
fn an_unrecognised_file_patches_nothing() {
|
||||
assert!(parse_localize_overrides("KEY = \"value\";\n").is_empty());
|
||||
assert!(parse_localize_overrides("function Localize()\n\tK = \"v\";\n").is_empty());
|
||||
}
|
||||
|
||||
/// **The real chain's patch, applied in the reference's own order.** Reading only the base
|
||||
/// file is what decision 2052 fixed, and this is the regression: three keys whose base text
|
||||
/// and patched text differ, asserted from both ends so neither a lost patch nor a lost base
|
||||
/// can pass. Skips without client data.
|
||||
#[test]
|
||||
fn the_real_chain_patches_the_login_refusals_and_the_realm_suffixes() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
|
||||
let base = chain.read_file(GLUE_STRINGS).expect("GlueStrings.lua");
|
||||
let base = parse_glue_strings(&String::from_utf8_lossy(&base));
|
||||
let table = table_from_chain(&mut chain);
|
||||
|
||||
for key in ["AUTH_BANNED", "LOGIN_UNKNOWN_ACCOUNT", "PVP_PARENTHESES"] {
|
||||
let before = base.get(key).expect("key in the base file").as_str();
|
||||
let after = table.get(key).expect("key in the patched table");
|
||||
assert_ne!(before, after, "{key} was not patched by Localize()");
|
||||
}
|
||||
|
||||
// The suffix is the one a player can read at a glance: the base file parenthesises it,
|
||||
// the enGB patch does not — `CharSelectRealmName` says "Realm PVP", not "Realm (PVP)".
|
||||
assert_eq!(
|
||||
base.get("PVP_PARENTHESES").map(String::as_str),
|
||||
Some("(PVP)")
|
||||
);
|
||||
assert_eq!(table.get("PVP_PARENTHESES"), Some("PVP"));
|
||||
|
||||
// Every key the base file never had still resolves, and every key it had is still there:
|
||||
// the patch overwrites, it does not replace the table.
|
||||
assert!(table.get("FACTION_INFO_HORDE").is_some(), "base lost");
|
||||
assert!(base.len() > 100 && table.0.len() >= base.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,6 +596,22 @@ impl Items {
|
||||
|
||||
/// Disconnect: drop the instances (the server re-streams inventory at login) and the in-flight
|
||||
/// asks (a query dropped by a dead writer must be re-askable); keep the templates (static).
|
||||
/// Release the ask-once latch without dropping what the cache LEARNED — the world-enter
|
||||
/// counterpart to [`Self::clear_session`]'s disconnect teardown.
|
||||
///
|
||||
/// `template` marks an entry pending *before* the send, and a send made while the io thread
|
||||
/// holds no writer evaporates. Nothing told the cache, so the entry stayed pending for the
|
||||
/// life of the process and was never re-asked. That is not hypothetical: `feed_mail` carries
|
||||
/// no run condition, so on 2026-09-06 it asked all five `Stationery.dbc` templates at the
|
||||
/// login screen, every one was dropped "not connected", and the send tab's stationery list
|
||||
/// was empty for the whole session — which the stock `SendMailFrame_Reset` turns into every
|
||||
/// send silently unsent. The ask site is fixed; this makes the CLASS harmless, because the
|
||||
/// cost of a wrong latch (a feature dead all session, in silence) is nothing like the cost of
|
||||
/// a redundant re-ask.
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
self.pending.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
self.objects.clear();
|
||||
self.enchant_deadlines.clear();
|
||||
|
||||
@@ -74,6 +74,28 @@ pub(crate) struct NetPlugin {
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct NetOffline;
|
||||
|
||||
/// Release the ask-once latches at world enter — the counterpart to the disconnect teardown in
|
||||
/// `apply::session`, which clears them when a socket DIES but never when one is born.
|
||||
///
|
||||
/// The asymmetry was load-bearing and wrong. `Items::template` (and `NameCache`'s resolvers) mark
|
||||
/// an id pending *before* sending the query, and a command sent while the io thread holds no
|
||||
/// writer evaporates with a warn — so an ask made before the first connect latched the id for the
|
||||
/// life of the process. On 2026-09-06 that emptied the mail send tab's stationery list for an
|
||||
/// entire session, in silence. The ask site that did it is fixed; this makes the class harmless,
|
||||
/// because a redundant re-ask costs one packet and a wrong latch costs a dead feature nobody can
|
||||
/// see is dead.
|
||||
fn release_ask_once_latches_on_enter(
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
mut items: ResMut<crate::items::Items>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
) {
|
||||
if entered.read().count() == 0 {
|
||||
return;
|
||||
}
|
||||
items.clear_pending();
|
||||
names.clear_pending();
|
||||
}
|
||||
|
||||
impl Plugin for NetPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let handles = io::spawn_net(io::NetConfig::from_env(), self.connect);
|
||||
@@ -134,6 +156,7 @@ impl Plugin for NetPlugin {
|
||||
Update,
|
||||
(
|
||||
apply_net_updates,
|
||||
release_ask_once_latches_on_enter,
|
||||
tag_self_player,
|
||||
sample_splines,
|
||||
// Derive each creature's swim state from the water over its feet (the wire never
|
||||
|
||||
@@ -1358,22 +1358,22 @@ pub(crate) fn apply_net_updates(
|
||||
// byte addresses are on `ui_guild::apply::event`).
|
||||
SessionEvent::GuildEvent(notice) => crate::ui_guild::apply::event(
|
||||
&mut guild,
|
||||
&mut chat_log,
|
||||
&mut ui_error_keys,
|
||||
&social,
|
||||
&guild_notify,
|
||||
self_guid.0,
|
||||
notice,
|
||||
),
|
||||
SessionEvent::GuildCommandResult(result) => {
|
||||
crate::ui_guild::apply::command_result(&mut guild, &mut chat_log, result)
|
||||
crate::ui_guild::apply::command_result(&mut guild, &mut ui_error_keys, result)
|
||||
}
|
||||
SessionEvent::GuildInvite { inviter, guild: g } => {
|
||||
crate::ui_guild::apply::invite(&mut guild, &mut chat_log, inviter, g)
|
||||
crate::ui_guild::apply::invite(&mut guild, &mut ui_error_keys, inviter, g)
|
||||
}
|
||||
SessionEvent::GuildDecline { name } => {
|
||||
crate::ui_guild::apply::decline(&mut chat_log, &name)
|
||||
crate::ui_guild::apply::decline(&mut ui_error_keys, &name)
|
||||
}
|
||||
SessionEvent::GuildInfo(info) => crate::ui_guild::apply::info(&mut chat_log, info),
|
||||
SessionEvent::GuildInfo(info) => crate::ui_guild::apply::info(&mut guild, info),
|
||||
// ── The petition family (decision 1672): founding a guild. The registrar half is an
|
||||
// NPC window, the charter half is item-bound, and they are two resources for that
|
||||
// reason — see `ui_petition`'s module doc.
|
||||
|
||||
@@ -236,11 +236,10 @@ pub(super) fn quest_giver_failed(
|
||||
.map(|t| t.title.clone())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
quest.push_message(UiError {
|
||||
key: crate::ui_quest::questgiver_failed_key(reason),
|
||||
fill_s: Some(title),
|
||||
fill_d: None,
|
||||
});
|
||||
quest.push_message(UiError::s(
|
||||
crate::ui_quest::questgiver_failed_key(reason),
|
||||
title,
|
||||
));
|
||||
if matches!(reason, 4 | 50) {
|
||||
quest.push_message(UiError::key("ERR_INV_FULL"));
|
||||
}
|
||||
@@ -377,11 +376,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
msgs[0],
|
||||
UiError {
|
||||
key: "ERR_QUEST_FAILED_BAG_FULL_S",
|
||||
fill_s: Some("A Threat Within".into()),
|
||||
fill_d: None,
|
||||
}
|
||||
UiError::s("ERR_QUEST_FAILED_BAG_FULL_S", "A Threat Within")
|
||||
);
|
||||
assert_eq!(
|
||||
benilla_ui::messages::kind_of(msgs[1].key),
|
||||
@@ -421,7 +416,7 @@ mod tests {
|
||||
quest_giver_failed(999, 17, &mut giver, &mut log, &commands);
|
||||
let msgs = giver.take_messages();
|
||||
assert_eq!(msgs[0].key, "ERR_QUEST_FAILED_MAX_COUNT_S");
|
||||
assert_eq!(msgs[0].fill_s.as_deref(), Some(""));
|
||||
assert_eq!(msgs[0].arg_s(), Some(""));
|
||||
}
|
||||
|
||||
// ── The share's BUSY refusal (decision 1738) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -799,11 +799,21 @@ fn writer_loop(
|
||||
// No live writer: the session is gone and this command evaporates. Traced
|
||||
// unconditionally — this is the state in which a client keeps *deciding* to send
|
||||
// movement (`snd` lines) that no one will ever receive (decision 0621).
|
||||
// **Both lines name the command.** They used to write a fixed string, so a
|
||||
// login that dropped five commands before the writer existed said only that
|
||||
// five of something went missing — and since `writer` is set exactly once and
|
||||
// never reset, this can only ever fire before the first `player_login`, which
|
||||
// makes the WHICH the entire question. A census of all 322 send sites could
|
||||
// not answer it from the source; `cmd` is owned and unused here and
|
||||
// `ClientCommand` derives `Debug`, so one login now answers it outright.
|
||||
if benilla_assets::trace::enabled() {
|
||||
benilla_assets::trace::line("wire", "DROPPED — no live session");
|
||||
benilla_assets::trace::line(
|
||||
"wire",
|
||||
&format!("DROPPED — no live session: {cmd:?}"),
|
||||
);
|
||||
}
|
||||
if warned < SEND_WARN_CAP {
|
||||
bevy::log::warn!("net: dropping command — not connected");
|
||||
bevy::log::warn!("net: dropping command — not connected: {cmd:?}");
|
||||
warned += 1;
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -46,6 +46,11 @@ pub(crate) struct AdditiveMaterials(pub(crate) Arc<HashSet<AssetId<WowModelMater
|
||||
#[derive(Resource, Clone)]
|
||||
pub(crate) struct BlendMismatchShared(pub(crate) Arc<AtomicU64>);
|
||||
|
||||
/// How many consecutive frames a mismatch must last before it is worth a warning — one frame is
|
||||
/// the pipeline specialization catching up with a material swap, which a 2026-09-06 login
|
||||
/// confirmed by ending four such runs at exactly one frame.
|
||||
const REPORT_AFTER_FRAMES: u32 = 2;
|
||||
|
||||
/// One mismatching draw, tracked across frames so a transient (one frame after a material
|
||||
/// swap, the specialization tick catching up) reads differently from a PERSISTING one — the
|
||||
/// director's strong halo holds until a relog, and that difference is the finding.
|
||||
@@ -59,6 +64,12 @@ pub(crate) struct Mismatch {
|
||||
/// The `frames` count the main world last reported at, so each entry logs on its first
|
||||
/// frame and then once every couple of seconds while it persists.
|
||||
pub(crate) reported: u32,
|
||||
/// The draw has left the transparent phase — the run is over, and its LENGTH is the fact the
|
||||
/// log could not previously state. Kept one more pass so the main world can narrate the end
|
||||
/// before the entry is dropped.
|
||||
pub(crate) ended: bool,
|
||||
/// …and narrated exactly once.
|
||||
pub(crate) end_reported: bool,
|
||||
}
|
||||
|
||||
/// The live mismatch table, by main-world entity — kept by the render world, read and
|
||||
@@ -142,15 +153,34 @@ fn check_blend_states(
|
||||
bound_add: is_add,
|
||||
frames: 0,
|
||||
reported: 0,
|
||||
ended: false,
|
||||
end_reported: false,
|
||||
});
|
||||
e.material = id;
|
||||
e.bound_add = is_add;
|
||||
e.frames += 1;
|
||||
e.ended = false; // back in the phase — the run is still running
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
table.retain(|e, _| seen.contains(e));
|
||||
// **A run's end is reported, not dropped.** This used to be a bare `retain`, so an entry
|
||||
// vanished the moment its entity left the transparent phase — despawned, culled, hidden, or
|
||||
// genuinely fixed — and the run's LENGTH went with it. That was half of why a mismatch line
|
||||
// was unanswerable: every run of 1..119 frames prints "1 frame(s)" (the report fires on
|
||||
// frame 1 and then only on multiples of 120), so a two-second visible defect and a
|
||||
// single-frame specialization hiccup were the same text. Entries are held one extra pass
|
||||
// marked `ended`, which is what lets the main world state the length it actually ran.
|
||||
table.retain(|e, m| {
|
||||
if seen.contains(e) {
|
||||
return true;
|
||||
}
|
||||
if m.ended {
|
||||
return false; // its end has been narrated — now it can go
|
||||
}
|
||||
m.ended = true;
|
||||
true
|
||||
});
|
||||
shared.0.store(mismatches, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -166,8 +196,29 @@ fn report_blend_mismatches(
|
||||
) {
|
||||
let mut table = live.0.lock().unwrap();
|
||||
for (entity, m) in table.iter_mut() {
|
||||
if m.reported == m.frames || (m.frames != 1 && m.frames % 120 != 0) {
|
||||
continue;
|
||||
// **A one-frame run is the specialization tick, and is no longer shouted about.** The
|
||||
// module always said so; it warned on frame 1 anyway, because nothing could tell a
|
||||
// one-frame run from a 119-frame one. Now something can, and a real login settled it:
|
||||
// four entries reported "1 frame(s) so far" and "ran 1 frame(s), now clear" 26 ms later
|
||||
// (2026-09-06). So the warn waits for a run to SURVIVE the tick, and a run that does not
|
||||
// is recorded at debug rather than as a warning about a defect nobody has.
|
||||
let ending = m.ended && !m.end_reported;
|
||||
if ending {
|
||||
m.end_reported = true;
|
||||
if m.reported == 0 {
|
||||
debug!(
|
||||
"blend mismatch: transient on entity {entity} — ran {} frame(s), never \
|
||||
survived the specialization tick",
|
||||
m.frames,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
let due = m.frames == REPORT_AFTER_FRAMES
|
||||
|| (m.frames > REPORT_AFTER_FRAMES && m.frames % 120 == 0);
|
||||
if !due || m.reported == m.frames {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
m.reported = m.frames;
|
||||
let who = objects.get(*entity).map_or_else(
|
||||
@@ -185,15 +236,28 @@ fn report_blend_mismatches(
|
||||
};
|
||||
warn!(
|
||||
"blend mismatch: {who} entity {entity} material {mat} tex {tex} — material \
|
||||
additive={} but bound pipeline {} — {} frame(s){}",
|
||||
additive={} but bound pipeline {} — {}{}",
|
||||
!m.bound_add,
|
||||
if m.bound_add {
|
||||
"ADD (One,One)"
|
||||
} else {
|
||||
"alpha-blend"
|
||||
},
|
||||
m.frames,
|
||||
if m.frames > 1 { " PERSISTING" } else { "" },
|
||||
// "1 frame(s)" was printed for every run from 1 to 119 frames long, so the line could
|
||||
// not distinguish a specialization hiccup from a two-second visible defect. An ended
|
||||
// run now states the length it actually ran.
|
||||
if ending {
|
||||
format!("ran {} frame(s), now clear", m.frames)
|
||||
} else {
|
||||
format!("{} frame(s) so far", m.frames)
|
||||
},
|
||||
// Only a run still going can be persisting; an ended one has already said how long
|
||||
// it lasted, and "now clear PERSISTING" is a contradiction.
|
||||
if !ending && m.frames > 1 {
|
||||
" PERSISTING"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,16 @@ fn hand_off_glue_music(
|
||||
/// back from the top — the login screen no longer falls silent for good after one play-through.
|
||||
/// **In the world it does not**, and that asymmetry is the point of [`start_glue_music`]'s run
|
||||
/// condition: a theme that outlives a music-less zone must run out, not loop under it (1550).
|
||||
fn watch_glue_music(mut music: NonSendMut<GlueMusic>, time: Res<Time>, config: Res<SoundConfig>) {
|
||||
fn watch_glue_music(
|
||||
mut music: NonSendMut<GlueMusic>,
|
||||
// **`Real`, not the generic clock** — the watch compares elapsed time against the audio
|
||||
// stream's own position, so it must be fed WALL time. `Res<Time>` is the paced virtual clock:
|
||||
// `frame_pace` median-snaps it and caps it at `max_delta`, so during a loading burst it
|
||||
// neither matches wall nor errs in a predictable direction, and the "ms of injected silence"
|
||||
// it produced was not a measurement of anything.
|
||||
time: Res<Time<bevy::time::Real>>,
|
||||
config: Res<SoundConfig>,
|
||||
) {
|
||||
let music = &mut *music;
|
||||
// The Music slider is live on this stream, as it is on the world's (wow-re §5 §5c: the
|
||||
// `MusicVolume` handler's re-apply walker `0x7a6660(ecx=2)` matches the glue wrapper, so moving
|
||||
|
||||
@@ -754,6 +754,18 @@ pub(crate) struct StreamWatch {
|
||||
last_pos: Option<f64>,
|
||||
expected: f64,
|
||||
advanced: f64,
|
||||
/// When the open window began, in WALL time — carried so the report can state the span it
|
||||
/// actually covered beside the time it counted.
|
||||
///
|
||||
/// **Because the two disagreed and nothing could say why.** A 2026-09-06 login closed a 1.0 s
|
||||
/// window 0.756 s of wall after the stream started, which is arithmetically impossible if the
|
||||
/// fed `dt` is the real frame delta — and the fix that put this watch on `Time<Real>` did not
|
||||
/// change it. Rather than reason further from log timestamps, the line now carries both
|
||||
/// numbers: if they disagree the caller's clock is still wrong, and if they agree the
|
||||
/// starvation is real and the timestamps were the misreading.
|
||||
window_start: Option<std::time::Instant>,
|
||||
/// The closing window's counted totals, kept for the report (`observe` zeroes them).
|
||||
closed: (f64, f64),
|
||||
}
|
||||
|
||||
impl StreamWatch {
|
||||
@@ -763,6 +775,8 @@ impl StreamWatch {
|
||||
last_pos: None,
|
||||
expected: 0.0,
|
||||
advanced: 0.0,
|
||||
window_start: None,
|
||||
closed: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,11 +784,24 @@ impl StreamWatch {
|
||||
pub(crate) fn feed(&mut self, handle: &StreamingSoundHandle<FromFileError>, dt: f64) {
|
||||
use kira::sound::PlaybackState as S;
|
||||
let audible = matches!(handle.state(), S::Playing | S::Stopping);
|
||||
if audible && self.expected == 0.0 {
|
||||
self.window_start = Some(std::time::Instant::now());
|
||||
}
|
||||
if let Some(lost) = self.observe(audible, handle.position(), dt) {
|
||||
let span = self
|
||||
.window_start
|
||||
.take()
|
||||
.map_or(f64::NAN, |t| t.elapsed().as_secs_f64());
|
||||
let (counted, advanced) = self.closed;
|
||||
// **No cause is named.** This used to say "(decode thread outrun)", which is one of
|
||||
// three mechanisms that freeze a stream's position identically — a starved decoder, a
|
||||
// render thread that did not run, or a closed/rebuilding device — and the meter cannot
|
||||
// tell them apart. Asserting one of the three in the line is how a log hands a reader
|
||||
// a conclusion the instrument never reached.
|
||||
warn!(
|
||||
"audio: {} stream starved — ~{:.0} ms of injected silence in the last \
|
||||
{STREAM_WATCH_WINDOW_SECS:.0} s (decode thread outrun) — this is what a \
|
||||
crackle sounds like",
|
||||
"audio: {} stream starved — ~{:.0} ms of injected silence over a {span:.2} s \
|
||||
window (counted {counted:.2} s, position advanced {advanced:.2} s) — this is \
|
||||
what a crackle sounds like",
|
||||
self.label,
|
||||
lost * 1000.0,
|
||||
);
|
||||
@@ -787,6 +814,7 @@ impl StreamWatch {
|
||||
self.last_pos = None;
|
||||
self.expected = 0.0;
|
||||
self.advanced = 0.0;
|
||||
self.window_start = None;
|
||||
}
|
||||
|
||||
/// The accounting core, pure so the tests below can drive it without a device. Returns
|
||||
@@ -808,6 +836,7 @@ impl StreamWatch {
|
||||
return None;
|
||||
}
|
||||
let lost = self.expected - self.advanced;
|
||||
self.closed = (self.expected, self.advanced);
|
||||
self.expected = 0.0;
|
||||
self.advanced = 0.0;
|
||||
(lost > STREAM_STARVED_MIN_SECS).then_some(lost)
|
||||
|
||||
@@ -275,6 +275,10 @@ fn zone_audio(
|
||||
config: Res<SoundConfig>,
|
||||
clock: Res<GameClock>,
|
||||
time: Res<Time>,
|
||||
// The stream watch's own clock — WALL, not the paced virtual one `time` carries (see
|
||||
// `watch_glue_music`). Kept as a second param because `time` above schedules zone audio and
|
||||
// wants the clock it already has.
|
||||
real: Res<Time<bevy::time::Real>>,
|
||||
world: benilla_world::world_point::WorldPoint,
|
||||
interior: Res<super::interior::CurrentInterior>,
|
||||
weather: Res<super::weather::WeatherAmbience>,
|
||||
@@ -477,7 +481,7 @@ fn zone_audio(
|
||||
// isn't watched — under the load bursts that starve a decoder, the *playing* slot's watch is
|
||||
// the indicator either way (every stream decoder shares the same scheduling class).
|
||||
if let Some(h) = &zone.music {
|
||||
zone.music_watch.feed(h, f64::from(time.delta_secs()));
|
||||
zone.music_watch.feed(h, f64::from(real.delta_secs()));
|
||||
}
|
||||
// Ambience runs the incoming leg of its 5.0 s crossfade as a per-frame fade-in envelope (the
|
||||
// per-frame feed would otherwise stomp a handle-level ramp to full); it clears itself at full.
|
||||
|
||||
@@ -1060,7 +1060,7 @@ fn route_lock_refusal(
|
||||
lock_type_name: Option<&str>,
|
||||
key: KeyFact,
|
||||
) -> Option<crate::ui_action::UiError> {
|
||||
use crate::ui_action::UiError;
|
||||
use crate::ui_action::{FillArg, UiError};
|
||||
if flag_locked {
|
||||
return Some(UiError::key(match go_type {
|
||||
0 => "ERR_DOOR_LOCKED",
|
||||
@@ -1071,27 +1071,19 @@ fn route_lock_refusal(
|
||||
match slot0.key_type {
|
||||
benilla_formats::LOCK_KEY_ITEM => match key {
|
||||
KeyFact::Unknown => None,
|
||||
KeyFact::Named(name) => Some(UiError {
|
||||
key: "ERR_USE_LOCKED_WITH_ITEM_S",
|
||||
fill_s: Some(name),
|
||||
fill_d: None,
|
||||
}),
|
||||
KeyFact::Named(name) => Some(UiError::s("ERR_USE_LOCKED_WITH_ITEM_S", name)),
|
||||
},
|
||||
benilla_formats::LOCK_KEY_SKILL => {
|
||||
let name = lock_type_name.unwrap_or("UNKNOWN").to_string();
|
||||
if opener_known {
|
||||
let required = super::lock::required_skill(slot0, go_level).max(0) as u32;
|
||||
Some(UiError {
|
||||
key: "ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI",
|
||||
fill_s: Some(name),
|
||||
fill_d: Some(required),
|
||||
})
|
||||
// String-then-Integer, the template's own order (cursor-system.md §8.8).
|
||||
Some(UiError::args(
|
||||
"ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI",
|
||||
vec![FillArg::S(name), FillArg::D(i64::from(required))],
|
||||
))
|
||||
} else {
|
||||
Some(UiError {
|
||||
key: "ERR_USE_LOCKED_WITH_SPELL_S",
|
||||
fill_s: Some(name),
|
||||
fill_d: None,
|
||||
})
|
||||
Some(UiError::s("ERR_USE_LOCKED_WITH_SPELL_S", name))
|
||||
}
|
||||
}
|
||||
_ => Some(UiError::key("ERR_USE_CANT_OPEN")),
|
||||
@@ -1541,7 +1533,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(e.key, e.fill_s.as_deref(), e.fill_d),
|
||||
(e.key, e.arg_s(), e.arg_d()),
|
||||
("ERR_USE_LOCKED_WITH_SPELL_S", Some("Herbalism"), None)
|
||||
);
|
||||
// Vein, Mining known but rank < 155 → 0xe0 "Requires %s %d" with the slot's Skill[0].
|
||||
@@ -1556,7 +1548,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(e.key, e.fill_s.as_deref(), e.fill_d),
|
||||
(e.key, e.arg_s(), e.arg_d()),
|
||||
(
|
||||
"ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI",
|
||||
Some("Mining"),
|
||||
@@ -1574,7 +1566,7 @@ mod tests {
|
||||
KeyFact::Unknown,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(e.fill_d, Some(100));
|
||||
assert_eq!(e.arg_d(), Some(100));
|
||||
// A missing LockType row fills the ref's literal fallback (`0x838044`).
|
||||
let e = route_lock_refusal(
|
||||
&skill_slot(9999, 0),
|
||||
@@ -1586,7 +1578,7 @@ mod tests {
|
||||
KeyFact::Unknown,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(e.fill_s.as_deref(), Some("UNKNOWN"));
|
||||
assert_eq!(e.arg_s(), Some("UNKNOWN"));
|
||||
// Key lock, key absent + named → 0xde "Requires %s" with the item name; the template
|
||||
// miss is silent, like the ref (a key we DO hold never reaches the toast at all — the
|
||||
// resolver returns `OpenByKey` and the click casts it, decision 0752).
|
||||
@@ -1607,7 +1599,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(e.key, e.fill_s.as_deref()),
|
||||
(e.key, e.arg_s()),
|
||||
("ERR_USE_LOCKED_WITH_ITEM_S", Some("Shadowforge Key"))
|
||||
);
|
||||
assert!(
|
||||
@@ -1631,7 +1623,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(e.key, key);
|
||||
assert_eq!(e.fill_s, None);
|
||||
assert_eq!(e.arg_s(), None);
|
||||
}
|
||||
// Slot-0 type neither key nor skill → 0xda "You can't open that."
|
||||
let odd = LockSlot {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
//! - [`UiErrorKeys`] — client-LOCAL refusals straight by GlobalStrings key, the
|
||||
//! `CGGameUI::DisplayError` route for errors with no wire code and no spell record:
|
||||
//! `ERR_ATTACK_MOUNTED` (decision 0481) and the GameObject lock-refusal toasts
|
||||
//! ("Requires Herbalism", decision 0545) — the latter carry [`UiError`]'s `%s`/`%d`
|
||||
//! argText fills, resolved by [`ui_error_text`].
|
||||
//! ("Requires Herbalism", decision 0545) and the guild lines (decision 2054) — the latter
|
||||
//! carry [`UiError`]'s ordered argText list, resolved by [`ui_error_text`].
|
||||
//!
|
||||
//! - [`UiErrorTexts`] — the same route for lines that arrive already resolved (the server's own
|
||||
//! `SMSG_NOTIFICATION` / `SMSG_AREA_TRIGGER_MESSAGE` text, the death durability notice), so
|
||||
@@ -143,8 +143,8 @@ pub(crate) struct MountErrors(pub Vec<(bool, u32)>);
|
||||
/// It cannot ride [`UiErrorKeys`] because its message is a *nested* lookup, which is exactly what
|
||||
/// the reference does: `0x6e6a20` resolves the reason's `PETTAME_*` key through the script VM
|
||||
/// (`0x703bf0`) and then passes that **string** as `DisplayError(0xee)`'s argText, filling
|
||||
/// `ERR_TAME_FAILED`'s lone `%s`. [`UiError`]'s `fill_s` is text the raise site already has; here
|
||||
/// the raise site has only a byte, and the VM is only reachable at the drain.
|
||||
/// `ERR_TAME_FAILED`'s lone `%s`. [`UiError`]'s arguments are text the raise site already has;
|
||||
/// here the raise site has only a byte, and the VM is only reachable at the drain.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PetTameFailures(pub Vec<u8>);
|
||||
|
||||
@@ -158,29 +158,87 @@ pub(crate) struct PetTameFailures(pub Vec<u8>);
|
||||
/// quest-share (1733) all ask the same table the same question.
|
||||
pub(crate) use benilla_ui::messages::MsgKind;
|
||||
|
||||
/// One `DisplayError` message: a GlobalStrings key plus the argText fills. The 1.12 error
|
||||
/// formats use at most one `%s` and one `%d` ("Requires %s", "Requires %s %d" — wow-re
|
||||
/// cursor-system.md §8.8's lock-refusal toasts, decision 0545); a key whose string carries no
|
||||
/// token ignores its fills. Not red-line-only: this is the payload of the reference's ONE
|
||||
/// `CGGameUI::DisplayError` (`0x496720`) whatever surface the message's record names — the
|
||||
/// quest refusals carry their chat lines in it too (decision 0669).
|
||||
/// One argument in a message's **argText list**.
|
||||
///
|
||||
/// Heterogeneous and ordered because the template's specifiers are: the lock-refusal toast
|
||||
/// `ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI` is String-then-Integer (wow-re cursor-system.md §8.8,
|
||||
/// decision 0545), and a list of strings beside a separate number cannot express that.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum FillArg {
|
||||
S(String),
|
||||
D(i64),
|
||||
}
|
||||
|
||||
/// One `DisplayError` message: a GlobalStrings key plus the **ordered argText list** the
|
||||
/// reference pushes with it. Not red-line-only: this is the payload of the reference's ONE
|
||||
/// `CGGameUI::DisplayError` (`0x496720`) whatever surface the message's record names — the quest
|
||||
/// refusals carry their chat lines in it too (decision 0669).
|
||||
///
|
||||
/// **`0x496720` is variadic** — cdecl, the catalog id first and the argText after it, `add esp,4`
|
||||
/// at a bare call site and `add esp,8` at a one-string one (wow-re
|
||||
/// `system/ui/scratch/staticpopup-dialog-bindings.md`; `guild-api-carve.md` §5's "22 of 22" arity
|
||||
/// control). Three strings is the most any call site pushes: `SMSG_GUILD_EVENT`'s shared emitter
|
||||
/// tail `0x5e745f` passes **1, 2 or 3** of them off the packet's `strCount`, feeding
|
||||
/// `ERR_GUILD_PROMOTE_SSS`. This field was a single `fill_s` until decision 2054, which is why
|
||||
/// the guild lines could not use this route at all and composed their own English instead.
|
||||
///
|
||||
/// A key whose string carries fewer specifiers than there are arguments ignores the rest; one
|
||||
/// that carries more shows the starved specifier verbatim, which is `SStrPrintf`'s own behaviour
|
||||
/// and the reason a short `strCount` is passed short rather than padded with empties.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct UiError {
|
||||
pub key: &'static str,
|
||||
pub fill_s: Option<String>,
|
||||
pub fill_d: Option<u32>,
|
||||
pub args: Vec<FillArg>,
|
||||
}
|
||||
|
||||
impl UiError {
|
||||
/// A fill-less message — the plain-key tenants (`ERR_ATTACK_MOUNTED`, the flag-locked
|
||||
/// strategy defaults).
|
||||
/// strategy defaults). The reference's `push <id>; call 0x496720; add esp,4`.
|
||||
pub(crate) fn key(key: &'static str) -> Self {
|
||||
Self {
|
||||
key,
|
||||
fill_s: None,
|
||||
fill_d: None,
|
||||
args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One string — the `_S` family, and the reference's `add esp,8`.
|
||||
pub(crate) fn s(key: &'static str, s: impl Into<String>) -> Self {
|
||||
Self {
|
||||
key,
|
||||
args: vec![FillArg::S(s.into())],
|
||||
}
|
||||
}
|
||||
|
||||
/// Several strings, in the order they are pushed — the `_SS`/`_SSS` family.
|
||||
pub(crate) fn strings(key: &'static str, args: &[&str]) -> Self {
|
||||
Self {
|
||||
key,
|
||||
args: args.iter().map(|s| FillArg::S((*s).to_string())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A mixed list, for a template whose specifiers are not all `%s`.
|
||||
pub(crate) fn args(key: &'static str, args: Vec<FillArg>) -> Self {
|
||||
Self { key, args }
|
||||
}
|
||||
|
||||
/// The first string argument — what a test asking "was the name filled in?" means.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn arg_s(&self) -> Option<&str> {
|
||||
self.args.iter().find_map(|a| match a {
|
||||
FillArg::S(s) => Some(s.as_str()),
|
||||
FillArg::D(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first integer argument.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn arg_d(&self) -> Option<i64> {
|
||||
self.args.iter().find_map(|a| match a {
|
||||
FillArg::D(d) => Some(*d),
|
||||
FillArg::S(_) => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-LOCAL refusals queued for the UI error line straight by GlobalStrings key — the
|
||||
@@ -224,11 +282,18 @@ impl UiErrorTexts {
|
||||
/// (the ref's own `[record+0x00]` null/empty guard at `0x4967bd`/`0x4967c5`).
|
||||
pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
// Through the one shared filler (2045). This used `str::replace`, which fills EVERY `%s`
|
||||
// with the same argument — latent only because no message on this queue carries two yet.
|
||||
// The order is the templates' own: `ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI` is String-then-Integer.
|
||||
let mut args: Vec<benilla_ui::strings::Arg<'_>> = Vec::new();
|
||||
args.extend(e.fill_s.as_deref().map(benilla_ui::strings::Arg::S));
|
||||
args.extend(e.fill_d.map(|d| benilla_ui::strings::Arg::D(i64::from(d))));
|
||||
// with the same argument — latent only because no message on this queue carried two yet.
|
||||
// The list's order is the reference's own push order, and a specifier with no argument left
|
||||
// is copied through rather than blanked, which is `SStrPrintf`'s behaviour and the whole
|
||||
// reason a short guild `strCount` is passed short (2054).
|
||||
let args: Vec<benilla_ui::strings::Arg<'_>> = e
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| match a {
|
||||
FillArg::S(s) => benilla_ui::strings::Arg::S(s),
|
||||
FillArg::D(d) => benilla_ui::strings::Arg::D(*d),
|
||||
})
|
||||
.collect();
|
||||
let text = benilla_ui::strings::fill(&get(e.key)?, &args);
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
@@ -675,14 +740,13 @@ mod mount_error_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod ui_error_tests {
|
||||
use super::{ui_error_text, UiError};
|
||||
use super::{ui_error_text, FillArg, UiError};
|
||||
|
||||
fn filled(key: &'static str, s: Option<&str>, d: Option<u32>) -> UiError {
|
||||
UiError {
|
||||
key,
|
||||
fill_s: s.map(String::from),
|
||||
fill_d: d,
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
args.extend(s.map(|s| FillArg::S(s.to_string())));
|
||||
args.extend(d.map(|d| FillArg::D(i64::from(d))));
|
||||
UiError::args(key, args)
|
||||
}
|
||||
|
||||
/// The DisplayError argText substitution against a fake getter: `%s` then `%d`, key-absent
|
||||
|
||||
@@ -67,7 +67,7 @@ pub(crate) use cast_send::{CastCommit, CastLadder};
|
||||
pub(crate) use cast_target::AutoSelfCast;
|
||||
pub(crate) use errors::{
|
||||
attack_actor_blocked, attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal,
|
||||
show_messages, ui_error_text, CastErrors, CastFail, Caster, MessageSink, MountErrors,
|
||||
show_messages, ui_error_text, CastErrors, CastFail, Caster, FillArg, MessageSink, MountErrors,
|
||||
PetTameFailures, Shown, UiError, UiErrorKeys, UiErrorTexts,
|
||||
};
|
||||
// `pub(crate)`: the requirement validator's mounted block is ONE gate in the reference
|
||||
|
||||
@@ -620,10 +620,9 @@ fn feed_auction(
|
||||
},
|
||||
};
|
||||
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
let err = UiError {
|
||||
key: msg.key,
|
||||
fill_s: fill,
|
||||
fill_d: None,
|
||||
let err = match fill {
|
||||
Some(s) => UiError::s(msg.key, s),
|
||||
None => UiError::key(msg.key),
|
||||
};
|
||||
if let Some(text) = ui_error_text(&err, &get) {
|
||||
lines.push(Shown::keyed(msg.key, text));
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use benilla_formats::AreaTableCatalog;
|
||||
use benilla_protocol::messages::{
|
||||
guild_presence, GuildRosterMember, GUILD_RANKS_MAX_COUNT, GUILD_RANKS_MIN_COUNT,
|
||||
guild_presence, GuildInfo, GuildRosterMember, GUILD_RANKS_MAX_COUNT, GUILD_RANKS_MIN_COUNT,
|
||||
GUILD_RANK_MAX_LENGTH,
|
||||
};
|
||||
use benilla_ui::script::{
|
||||
@@ -48,6 +48,7 @@ pub(super) fn feed_guild(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
mut fed: Local<crate::ui_script::VmMemo<FedGuild>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
@@ -173,6 +174,55 @@ pub(super) fn feed_guild(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `/ginfo`'s two lines, resolved here because their templates are not catalog rows.
|
||||
let info_lines: Vec<crate::ui_action::Shown> = std::mem::take(&mut guild.pending_info)
|
||||
.iter()
|
||||
.flat_map(|info| ginfo_lines(&script, info))
|
||||
.collect();
|
||||
if !info_lines.is_empty() {
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_guild", info_lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_INFO`'s two chat lines — `GUILD_NAME_TEMPLATE` then `GUILD_INFO_TEMPLATE`, in the
|
||||
/// order `0x5e6fb0` emits them (`0x5e700f`, then `0x5e706b`).
|
||||
///
|
||||
/// **Neither is a message record**, so neither can ride the by-key queue: the handler resolves each
|
||||
/// token through the script VM (`0x703bf0`) and formats it into a chat line itself, with no `kind`
|
||||
/// and no sound to read. `MsgKind::Chat` here is therefore the handler's own surface rather than a
|
||||
/// catalog lookup, and the absent key is silent — the same GlobalStrings data-suppression the keyed
|
||||
/// route wears (decision 2054).
|
||||
///
|
||||
/// **The date is month-day-year and the wire is day-month-year.** The handler's cdecl pushes
|
||||
/// (`0x5e704d`–`0x5e7061`) place the arguments **wire#2, wire#1, wire#3, wire#4, wire#5** — the
|
||||
/// first two swapped — which is exactly what an enUS `m-d-y` template does to vmangos's
|
||||
/// `createdDay, createdMonth, createdYear` order.
|
||||
fn ginfo_lines(script: &UiScript, info: &GuildInfo) -> Vec<crate::ui_action::Shown> {
|
||||
use benilla_ui::strings::Arg;
|
||||
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
let line = |template: Option<String>, args: &[Arg<'_>]| {
|
||||
let text = benilla_ui::strings::fill(&template?, args);
|
||||
(!text.is_empty())
|
||||
.then(|| crate::ui_action::Shown::unkeyed(benilla_ui::messages::MsgKind::Chat, text))
|
||||
};
|
||||
let d = |v: u32| Arg::D(i64::from(v));
|
||||
[
|
||||
line(get("GUILD_NAME_TEMPLATE"), &[Arg::S(&info.name)]),
|
||||
line(
|
||||
get("GUILD_INFO_TEMPLATE"),
|
||||
&[
|
||||
d(info.created_month),
|
||||
d(info.created_day),
|
||||
d(info.created_year),
|
||||
d(info.member_count),
|
||||
d(info.account_count),
|
||||
],
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The roster in display order — **every** member, sorted, never filtered (see [`super::sort`]).
|
||||
|
||||
@@ -1,105 +1,62 @@
|
||||
//! The guild session's **system lines** — the `ERR_GUILD_*` chat output the engine composes.
|
||||
//! The guild session's **system lines** — the `ERR_GUILD_*` messages the engine composes.
|
||||
//!
|
||||
//! Every guild event and every command verdict prints one line, and the *engine* composes it, not
|
||||
//! the FrameXML: `SMSG_GUILD_EVENT`'s handler `0x5e7180` routes its arms into
|
||||
//! `CGGameUI::DisplayError 0x496720` (ids `0x59`, `0x5a`, `0x57`, `0x5c`, `0x6a`, `0x6b`, `0x5d`,
|
||||
//! `0x6c`, `0x106`, `0x107` — wow-re RF-0077), and the invite / decline / info / command-result
|
||||
//! handlers do the same (`0x5e6f65`, `0x5e6f9a`, `0x5e706b`, `0x5e7520`). Not one of the
|
||||
//! `ERR_GUILD_*` keys those resolve to appears anywhere in the reference FrameXML, which is what
|
||||
//! identifies them as engine-composed — [`crate::ui_social`]'s own test, applied again.
|
||||
//! `CGGameUI::DisplayError 0x496720`, and the invite / decline / command-result handlers do the
|
||||
//! same (`0x5e6f65`, `0x5e6f9a`, `0x5e7520`). Not one of the `ERR_GUILD_*` keys those resolve to
|
||||
//! appears anywhere in the reference FrameXML, which is what identifies them as engine-composed —
|
||||
//! [`crate::ui_social`]'s own test, applied again.
|
||||
//!
|
||||
//! **The `GUILD_MOTD` line is deliberately not here.** The reference's `ChatFrame_OnEvent`
|
||||
//! composes `GUILD_MOTD_TEMPLATE` itself off the `GUILD_MOTD` event (`ChatFrame.lua:1335-1340`),
|
||||
//! so firing the event is the whole job and composing a line beside it would double it.
|
||||
//! **These are message ids, not sentences** (decision 2054). Each arm names a catalog key and the
|
||||
//! arguments the reference pushes beside it; the drain resolves the key against the player's own
|
||||
//! `GlobalStrings.lua` and the catalog row decides the surface and the sound. That is not a
|
||||
//! restatement of what was here before: this module used to carry 34 re-typed English constants and
|
||||
//! its own two `fill` helpers, which threw away three things the catalog knows —
|
||||
//! `ERR_GUILD_NAME_INVALID` and `ERR_GUILD_NAME_EXISTS_S` are `kind 2`, the **red** error line and
|
||||
//! not chat at all; `ERR_GUILD_CREATE_S` and `ERR_INVITED_TO_GUILD_SS` carry the `LEVELUP` cue; and
|
||||
//! `ERR_GUILD_PERMISSIONS` has `type_tag 0x3e`, under `VOCAL_UI_LINES`, so the reference *speaks* it
|
||||
//! (decision 1815).
|
||||
//!
|
||||
//! Which id maps to which GlobalStrings key is *inferred by name* (the catalog's own id→key table
|
||||
//! is uncited in wow-re), exactly as [`crate::ui_party`]'s party-result table is — but the
|
||||
//! sign-on/sign-off pair is better than inferred: vmangos annotates `GE_SIGNED_ON`/`GE_SIGNED_OFF`
|
||||
//! with the key names themselves (`Guild/Guild.h:134-135`), and the two ids the reference uses for
|
||||
//! them (`0x106`/`0x107`) sit inside the friend band `0x104..0x114`.
|
||||
//! **The `GUILD_MOTD` line is deliberately not here.** The reference's `ChatFrame_OnEvent` composes
|
||||
//! `GUILD_MOTD_TEMPLATE` itself off the `GUILD_MOTD` event (`ChatFrame.lua:1335-1340`), so firing
|
||||
//! the event is the whole job and composing a line beside it would double it.
|
||||
//!
|
||||
//! **`/ginfo` is not here either, and that is faithful.** Its two lines are `GUILD_NAME_TEMPLATE`
|
||||
//! and `GUILD_INFO_TEMPLATE` — *not* catalog rows: `0x5e6fb0` resolves each token through the
|
||||
//! script VM (`0x703bf0` at `0x5e700f`/`0x5e706b`), formats it, and emits chat directly, never
|
||||
//! passing a message record. A route with no record has no `kind` and no sound to read, so it is
|
||||
//! resolved where the VM is — [`super::feed`] — rather than queued as a message.
|
||||
//!
|
||||
//! The id → key mapping is **wow-re `system/ui/scratch/guild-api-carve.md` §5's own table**, §5
|
||||
//! cross-checked with the case-arm bytes quoted per row (`0x5e720a`→0x59 … `0x5e745a`→0x69), and
|
||||
//! the keys are matched against benilla's generated catalog rather than by name.
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
guild_command, guild_command_error, guild_event, GuildCommandResult, GuildEventNotice,
|
||||
GuildInfo,
|
||||
};
|
||||
|
||||
// ── The templates, quoted verbatim from the reference's own patch chain (decision 0246
|
||||
// extraction; the `GlobalStrings.lua` line is on each) ──────────────────────────────────────
|
||||
const ERR_GUILD_CREATE_S: &str = "%s created."; // GlobalStrings:1597
|
||||
const ERR_GUILD_INVITE_S: &str = "You have invited %s to join your guild."; // :1606
|
||||
const ERR_GUILD_QUIT_S: &str = "You are no longer a member of %s."; // :1629
|
||||
const ERR_GUILD_FOUNDER_S: &str = "Congratulations, you are a founding member of %s!"; // :1604
|
||||
const ERR_GUILD_INTERNAL: &str = "Internal guild error."; // :1605
|
||||
const ERR_ALREADY_IN_GUILD: &str = "You are already in a guild."; // :1466
|
||||
const ERR_ALREADY_IN_GUILD_S: &str = "%s is already in a guild."; // :1467
|
||||
const ERR_INVITED_TO_GUILD: &str = "You have already been invited into a guild."; // :1655
|
||||
const ERR_ALREADY_INVITED_TO_GUILD_S: &str = "%s has already been invited to a guild."; // :1464
|
||||
const ERR_GUILD_NAME_INVALID: &str = "That name contains invalid characters, Enter a new name."; // :1616
|
||||
const ERR_GUILD_NAME_EXISTS_S: &str = "There is already a guild named \"%s\"."; // :1615
|
||||
pub(super) const ERR_GUILD_LEADER_LEAVE: &str =
|
||||
"You must promote a new Guild Master using /gleader before leaving the guild."; // :1610
|
||||
pub(super) const ERR_GUILD_PERMISSIONS: &str = "You don't have permission to do that."; // :1624
|
||||
const ERR_GUILD_PLAYER_NOT_IN_GUILD: &str = "You are not in a guild."; // :1626
|
||||
const ERR_GUILD_PLAYER_NOT_IN_GUILD_S: &str = "%s is not in your guild."; // :1627
|
||||
const ERR_GUILD_PLAYER_NOT_FOUND_S: &str = "\"%s\" not found."; // :1625
|
||||
const ERR_GUILD_NOT_ALLIED: &str =
|
||||
"You cannot invite players from the opposing alliance into your guild"; // :1623
|
||||
const ERR_GUILD_RANK_TOO_HIGH_S: &str = "%s's rank is too high"; // :1632
|
||||
const ERR_GUILD_RANK_TOO_LOW_S: &str = "%s is already at the lowest rank"; // :1633
|
||||
const ERR_GUILD_RANKS_LOCKED: &str = "Temporary guild error. Please try again!"; // :1630
|
||||
const ERR_GUILD_RANK_IN_USE: &str = "That guild rank is currently in use."; // :1631
|
||||
const ERR_IGNORING_YOU_S: &str = "%s is ignoring you."; // :1643
|
||||
use crate::ui_action::UiError;
|
||||
|
||||
const ERR_GUILD_PROMOTE_SSS: &str = "%s has promoted %s to %s."; // :1628
|
||||
const ERR_GUILD_DEMOTE_SSS: &str = "%s has demoted %s to %s."; // :1599
|
||||
const ERR_GUILD_JOIN_S: &str = "%s has joined the guild."; // :1607
|
||||
const ERR_GUILD_LEAVE_S: &str = "%s has left the guild."; // :1614
|
||||
const ERR_GUILD_REMOVE_SS: &str = "%s has been kicked out of the guild by %s."; // :1635
|
||||
const ERR_GUILD_LEADER_IS_S: &str = "%s is the leader of your guild."; // :1609
|
||||
const ERR_GUILD_LEADER_CHANGED_SS: &str = "%s has made %s the new Guild Master."; // :1608
|
||||
const ERR_GUILD_DISBANDED: &str = "Guild has been disbanded."; // :1600
|
||||
const ERR_GUILD_DECLINE_S: &str = "%s declines your guild invitation."; // :1598
|
||||
const ERR_INVITED_TO_GUILD_SS: &str = "%s invites you join %s."; // :1656
|
||||
|
||||
/// `ERR_FRIEND_ONLINE_SS` / `ERR_FRIEND_OFFLINE_S` (`GlobalStrings:1576`, `:1575`) — a guildmate
|
||||
/// signing on and off prints the **friend-list** lines (module doc), which is also why
|
||||
/// [`crate::ui_social`] holds its own copy of the same two strings.
|
||||
const ERR_FRIEND_ONLINE_SS: &str = "|Hplayer:%s|h[%s]|h has come online.";
|
||||
/// See [`ERR_FRIEND_ONLINE_SS`].
|
||||
const ERR_FRIEND_OFFLINE_S: &str = "%s has gone offline.";
|
||||
|
||||
/// `GUILD_NAME_TEMPLATE` / `GUILD_INFO_TEMPLATE` (`GlobalStrings:2091`, `:2079`) — the two lines
|
||||
/// `/ginfo` prints, in this order: the handler `0x5e6fb0` formats the name through `0x8604f0` at
|
||||
/// `0x5e700f` and the counts through `0x8604dc` at `0x5e706b`.
|
||||
const GUILD_NAME_TEMPLATE: &str = "Guild: %s";
|
||||
/// See [`GUILD_NAME_TEMPLATE`]. **The template is month-day-year and the wire is day-month-year** —
|
||||
/// the handler's own cdecl pushes (`0x5e704d`–`0x5e7061`) swap the first two, which is exactly what
|
||||
/// an enUS `m-d-y` template does to vmangos's `createdDay, createdMonth, createdYear` order.
|
||||
const GUILD_INFO_TEMPLATE: &str = "Guild created %d-%d-%d, %d players, %d accounts";
|
||||
|
||||
/// Fill a `%s` template **positionally** — one argument per slot, in order.
|
||||
/// The strings `SMSG_GUILD_EVENT`'s shared emitter tail passes to `0x496720`.
|
||||
///
|
||||
/// Distinct from [`crate::ui_social`]'s replace-all `fill_line`, and it has to be: the guild
|
||||
/// templates take *different* values in their two and three slots ("%s has promoted %s to %s.")
|
||||
/// where the friend one takes the same name twice.
|
||||
fn fill(template: &str, args: &[&str]) -> String {
|
||||
let mut out = template.to_string();
|
||||
for arg in args {
|
||||
out = out.replacen("%s", arg, 1);
|
||||
/// **`strCount == 0` and `strCount >= 4` both pass ZERO strings** (`0x5e745f`, wow-re
|
||||
/// `guild-api-carve.md` §5) — the tail has three arms for 1, 2 and 3 and no other. That is not the
|
||||
/// same as padding the missing slots with empties, which is what this module did before: a template
|
||||
/// whose specifier runs out of arguments has the specifier **copied through verbatim**
|
||||
/// (`SStrPrintf`, and [`benilla_ui::strings::fill`] implements it), so a two-string promotion reads
|
||||
/// "Tigole has promoted Furor to %s." and not "…to .".
|
||||
///
|
||||
/// (The reference's read loop is *not* capped at three — a `strCount >= 4` writes past its three
|
||||
/// `0x100` stack buffers into the live locals. benilla's parser reads into a `Vec`, so the overflow
|
||||
/// has nothing to reproduce; the display rule is what carries over.)
|
||||
fn emitted_params(notice: &GuildEventNotice) -> Vec<&str> {
|
||||
match notice.params.len() {
|
||||
1..=3 => notice.params.iter().map(String::as_str).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`fill`] for the one `%d` template ([`GUILD_INFO_TEMPLATE`]).
|
||||
fn fill_d(template: &str, args: &[u32]) -> String {
|
||||
let mut out = template.to_string();
|
||||
for arg in args {
|
||||
out = out.replacen("%d", &arg.to_string(), 1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The line one `SMSG_GUILD_EVENT` prints, if any.
|
||||
/// The message one `SMSG_GUILD_EVENT` prints, if any.
|
||||
///
|
||||
/// `announce_signon` is the sign-on/sign-off pair's **whole display condition**, resolved by the
|
||||
/// caller because three of its four conjuncts need state this function does not see. See
|
||||
@@ -114,79 +71,103 @@ fn fill_d(template: &str, args: &[u32]) -> String {
|
||||
/// guildmate was silenced where the reference announces them, and a guildmate who is also a
|
||||
/// *friend* was announced twice, because `SMSG_FRIEND_STATUS` says the same thing with no gate of
|
||||
/// its own. The repo's own `system/net` ledger had `0x5ae810` right the whole time.
|
||||
pub(super) fn event_line(notice: &GuildEventNotice, announce_signon: bool) -> Option<String> {
|
||||
let p = |i: usize| notice.params.get(i).map(String::as_str).unwrap_or_default();
|
||||
pub(super) fn event_line(notice: &GuildEventNotice, announce_signon: bool) -> Option<UiError> {
|
||||
let args = emitted_params(notice);
|
||||
let shared = |key: &'static str| Some(UiError::strings(key, &args));
|
||||
match notice.event {
|
||||
guild_event::PROMOTION => Some(fill(ERR_GUILD_PROMOTE_SSS, &[p(0), p(1), p(2)])),
|
||||
guild_event::DEMOTION => Some(fill(ERR_GUILD_DEMOTE_SSS, &[p(0), p(1), p(2)])),
|
||||
guild_event::JOINED => Some(fill(ERR_GUILD_JOIN_S, &[p(0)])),
|
||||
guild_event::LEFT => Some(fill(ERR_GUILD_LEAVE_S, &[p(0)])),
|
||||
guild_event::REMOVED => Some(fill(ERR_GUILD_REMOVE_SS, &[p(0), p(1)])),
|
||||
guild_event::LEADER_IS => Some(fill(ERR_GUILD_LEADER_IS_S, &[p(0)])),
|
||||
guild_event::LEADER_CHANGED => Some(fill(ERR_GUILD_LEADER_CHANGED_SS, &[p(0), p(1)])),
|
||||
guild_event::DISBANDED => Some(ERR_GUILD_DISBANDED.to_string()),
|
||||
guild_event::PROMOTION => shared("ERR_GUILD_PROMOTE_SSS"),
|
||||
guild_event::DEMOTION => shared("ERR_GUILD_DEMOTE_SSS"),
|
||||
guild_event::JOINED => shared("ERR_GUILD_JOIN_S"),
|
||||
guild_event::LEFT => shared("ERR_GUILD_LEAVE_S"),
|
||||
guild_event::REMOVED => shared("ERR_GUILD_REMOVE_SS"),
|
||||
guild_event::LEADER_IS => shared("ERR_GUILD_LEADER_IS_S"),
|
||||
guild_event::LEADER_CHANGED => shared("ERR_GUILD_LEADER_CHANGED_SS"),
|
||||
guild_event::DISBANDED => shared("ERR_GUILD_DISBANDED"),
|
||||
// The sign-on/sign-off pair leaves the shared tail: each has its own emit with its own
|
||||
// argument shape, and `0x106` takes the ONE name TWICE — once for the `|Hplayer:%s|h`
|
||||
// link, once for the bracketed display name.
|
||||
guild_event::SIGNED_ON if announce_signon => {
|
||||
Some(fill(ERR_FRIEND_ONLINE_SS, &[p(0), p(0)]))
|
||||
let name = notice
|
||||
.params
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default();
|
||||
Some(UiError::strings("ERR_FRIEND_ONLINE_SS", &[name, name]))
|
||||
}
|
||||
guild_event::SIGNED_OFF if announce_signon => Some(fill(ERR_FRIEND_OFFLINE_S, &[p(0)])),
|
||||
// MOTD's line is the FrameXML's (module doc); UPDATE_RANK_NAME and UPDATE_ROSTER are
|
||||
// silent in the reference too; TABARD_CHANGE, a sign-on the condition refuses, and
|
||||
// whatever the server invents past 0x0d show nothing. (The reference's default arm displays catalog
|
||||
// id 0x69, whose GlobalStrings key is not settled — an unnamed key shows nothing, which is
|
||||
// the GlobalStrings data-suppression every other absent key in this client wears.)
|
||||
_ => None,
|
||||
guild_event::SIGNED_OFF if announce_signon => {
|
||||
let name = notice
|
||||
.params
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default();
|
||||
Some(UiError::strings("ERR_FRIEND_OFFLINE_S", &[name]))
|
||||
}
|
||||
// MOTD's line is the FrameXML's (module doc); `UPDATE_RANK_NAME` warms a rank name and
|
||||
// `UPDATE_ROSTER` only re-requests, and neither prints; a sign-on the condition refuses
|
||||
// shows nothing.
|
||||
guild_event::MOTD | guild_event::UPDATE_RANK_NAME | guild_event::UPDATE_ROSTER => None,
|
||||
guild_event::SIGNED_ON | guild_event::SIGNED_OFF => None,
|
||||
// **`0x09` and everything past `0x0d` fall to the SAME arm**, and it is not silence:
|
||||
// `0x5e745a` pushes `0x69` = `ERR_GUILD_INTERNAL` and jumps into the shared tail like the
|
||||
// rest (guild-api-carve.md §5). This module used to return `None` here under a comment
|
||||
// saying the key "is not settled" — it is settled, in wow-re's table and in benilla's own
|
||||
// generated catalog, and `TABARD_CHANGE` was silent for the same reason.
|
||||
_ => shared("ERR_GUILD_INTERNAL"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The line one `SMSG_GUILD_COMMAND_RESULT` prints, if any.
|
||||
/// The message one `SMSG_GUILD_COMMAND_RESULT` prints, if any.
|
||||
///
|
||||
/// `result == 0` is the success side — the reference's `0x5e7550 test eax,eax` selects on exactly
|
||||
/// that — and then the [`guild_command`] tag chooses the line. Otherwise the result code does,
|
||||
/// except for `0x08`, whose two meanings are told apart by that same tag.
|
||||
pub(super) fn command_line(result: &GuildCommandResult) -> Option<String> {
|
||||
///
|
||||
/// **Every `_S` key here is called with the name and every other with nothing** — the reference's
|
||||
/// `add esp,8` / `add esp,4` split, 22 of 22 (guild-api-carve.md §5's own internal control). That
|
||||
/// is a property a test can check against the key names, and one does.
|
||||
pub(super) fn command_line(result: &GuildCommandResult) -> Option<UiError> {
|
||||
let name = result.name.as_str();
|
||||
let named = |key: &'static str| Some(UiError::s(key, name));
|
||||
if result.result == guild_command_error::PLAYER_NO_MORE_IN_GUILD {
|
||||
return match result.command {
|
||||
guild_command::CREATE => Some(fill(ERR_GUILD_CREATE_S, &[name])),
|
||||
guild_command::INVITE => Some(fill(ERR_GUILD_INVITE_S, &[name])),
|
||||
guild_command::QUIT => Some(fill(ERR_GUILD_QUIT_S, &[name])),
|
||||
guild_command::FOUNDER => Some(fill(ERR_GUILD_FOUNDER_S, &[name])),
|
||||
// vmangos's own note on the rest of `Typecommand`: they "have no effect" here.
|
||||
guild_command::CREATE => named("ERR_GUILD_CREATE_S"),
|
||||
guild_command::INVITE => named("ERR_GUILD_INVITE_S"),
|
||||
guild_command::QUIT => named("ERR_GUILD_QUIT_S"),
|
||||
guild_command::FOUNDER => named("ERR_GUILD_FOUNDER_S"),
|
||||
// 19 and 20 re-request the roster and print nothing; vmangos's own note on the rest of
|
||||
// `Typecommand` is that they "have no effect" here.
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
match result.result {
|
||||
guild_command_error::INTERNAL => Some(ERR_GUILD_INTERNAL.to_string()),
|
||||
guild_command_error::ALREADY_IN_GUILD => Some(ERR_ALREADY_IN_GUILD.to_string()),
|
||||
guild_command_error::ALREADY_IN_GUILD_S => Some(fill(ERR_ALREADY_IN_GUILD_S, &[name])),
|
||||
guild_command_error::INVITED_TO_GUILD => Some(ERR_INVITED_TO_GUILD.to_string()),
|
||||
guild_command_error::ALREADY_INVITED_TO_GUILD_S => {
|
||||
Some(fill(ERR_ALREADY_INVITED_TO_GUILD_S, &[name]))
|
||||
}
|
||||
guild_command_error::NAME_INVALID => Some(ERR_GUILD_NAME_INVALID.to_string()),
|
||||
guild_command_error::NAME_EXISTS_S => Some(fill(ERR_GUILD_NAME_EXISTS_S, &[name])),
|
||||
guild_command_error::INTERNAL => Some(UiError::key("ERR_GUILD_INTERNAL")),
|
||||
guild_command_error::ALREADY_IN_GUILD => Some(UiError::key("ERR_ALREADY_IN_GUILD")),
|
||||
guild_command_error::ALREADY_IN_GUILD_S => named("ERR_ALREADY_IN_GUILD_S"),
|
||||
guild_command_error::INVITED_TO_GUILD => Some(UiError::key("ERR_INVITED_TO_GUILD")),
|
||||
guild_command_error::ALREADY_INVITED_TO_GUILD_S => named("ERR_ALREADY_INVITED_TO_GUILD_S"),
|
||||
guild_command_error::NAME_INVALID => Some(UiError::key("ERR_GUILD_NAME_INVALID")),
|
||||
guild_command_error::NAME_EXISTS_S => named("ERR_GUILD_NAME_EXISTS_S"),
|
||||
// The one collision: `0x08` is ERR_GUILD_LEADER_LEAVE under a QUIT and
|
||||
// ERR_GUILD_PERMISSIONS under anything else (benilla-protocol's own note on
|
||||
// `guild_command_error::LEADER_LEAVE`), which is why the command tag has to survive the
|
||||
// trip from the wire to here.
|
||||
guild_command_error::PERMISSIONS if result.command == guild_command::QUIT => {
|
||||
Some(ERR_GUILD_LEADER_LEAVE.to_string())
|
||||
Some(UiError::key("ERR_GUILD_LEADER_LEAVE"))
|
||||
}
|
||||
guild_command_error::PERMISSIONS => Some(ERR_GUILD_PERMISSIONS.to_string()),
|
||||
guild_command_error::PLAYER_NOT_IN_GUILD => Some(ERR_GUILD_PLAYER_NOT_IN_GUILD.to_string()),
|
||||
guild_command_error::PLAYER_NOT_IN_GUILD_S => {
|
||||
Some(fill(ERR_GUILD_PLAYER_NOT_IN_GUILD_S, &[name]))
|
||||
guild_command_error::PERMISSIONS => Some(UiError::key("ERR_GUILD_PERMISSIONS")),
|
||||
guild_command_error::PLAYER_NOT_IN_GUILD => {
|
||||
Some(UiError::key("ERR_GUILD_PLAYER_NOT_IN_GUILD"))
|
||||
}
|
||||
guild_command_error::PLAYER_NOT_FOUND_S => {
|
||||
Some(fill(ERR_GUILD_PLAYER_NOT_FOUND_S, &[name]))
|
||||
}
|
||||
guild_command_error::NOT_ALLIED => Some(ERR_GUILD_NOT_ALLIED.to_string()),
|
||||
guild_command_error::RANK_TOO_HIGH_S => Some(fill(ERR_GUILD_RANK_TOO_HIGH_S, &[name])),
|
||||
guild_command_error::RANK_TOO_LOW_S => Some(fill(ERR_GUILD_RANK_TOO_LOW_S, &[name])),
|
||||
guild_command_error::RANKS_LOCKED => Some(ERR_GUILD_RANKS_LOCKED.to_string()),
|
||||
guild_command_error::RANK_IN_USE => Some(ERR_GUILD_RANK_IN_USE.to_string()),
|
||||
guild_command_error::IGNORING_YOU_S => Some(fill(ERR_IGNORING_YOU_S, &[name])),
|
||||
// UNK20 ("for Typecommand 0x05 only") and anything unknown: nothing.
|
||||
guild_command_error::PLAYER_NOT_IN_GUILD_S => named("ERR_GUILD_PLAYER_NOT_IN_GUILD_S"),
|
||||
guild_command_error::PLAYER_NOT_FOUND_S => named("ERR_GUILD_PLAYER_NOT_FOUND_S"),
|
||||
guild_command_error::NOT_ALLIED => Some(UiError::key("ERR_GUILD_NOT_ALLIED")),
|
||||
guild_command_error::RANK_TOO_HIGH_S => named("ERR_GUILD_RANK_TOO_HIGH_S"),
|
||||
guild_command_error::RANK_TOO_LOW_S => named("ERR_GUILD_RANK_TOO_LOW_S"),
|
||||
guild_command_error::RANKS_LOCKED => Some(UiError::key("ERR_GUILD_RANKS_LOCKED")),
|
||||
guild_command_error::RANK_IN_USE => Some(UiError::key("ERR_GUILD_RANK_IN_USE")),
|
||||
guild_command_error::IGNORING_YOU_S => named("ERR_IGNORING_YOU_S"),
|
||||
// 15 and 16 are silent; `UNK20` re-requests the roster for command 5 and prints nothing;
|
||||
// anything unknown says nothing.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -194,182 +175,232 @@ pub(super) fn command_line(result: &GuildCommandResult) -> Option<String> {
|
||||
/// `SMSG_GUILD_INVITE`'s notice line — the one the reference prints *beside* the popup
|
||||
/// (`0x5e6f65 DisplayError(0x4f, inviter, guild)`; the popup is the separate
|
||||
/// `GUILD_INVITE_REQUEST` fire at `0x5e6f53`).
|
||||
pub(super) fn invite_line(inviter: &str, guild: &str) -> String {
|
||||
fill(ERR_INVITED_TO_GUILD_SS, &[inviter, guild])
|
||||
pub(super) fn invite_line(inviter: &str, guild: &str) -> UiError {
|
||||
UiError::strings("ERR_INVITED_TO_GUILD_SS", &[inviter, guild])
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_DECLINE` — our invitee said no (delivered to the inviter only).
|
||||
pub(super) fn decline_line(name: &str) -> String {
|
||||
fill(ERR_GUILD_DECLINE_S, &[name])
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_INFO` — the `/ginfo` answer, which is purely two chat lines: nothing in the packet
|
||||
/// overlaps the roster and no frame reads it.
|
||||
pub(super) fn info_lines(info: &GuildInfo) -> Vec<String> {
|
||||
vec![
|
||||
fill(GUILD_NAME_TEMPLATE, &[&info.name]),
|
||||
fill_d(
|
||||
GUILD_INFO_TEMPLATE,
|
||||
// month, day, year — the enUS swap, see [`GUILD_INFO_TEMPLATE`].
|
||||
&[
|
||||
info.created_month,
|
||||
info.created_day,
|
||||
info.created_year,
|
||||
info.member_count,
|
||||
info.account_count,
|
||||
],
|
||||
),
|
||||
]
|
||||
pub(super) fn decline_line(name: &str) -> UiError {
|
||||
UiError::s("ERR_GUILD_DECLINE_S", name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_ui::messages::{by_key, MsgKind};
|
||||
|
||||
/// The `%s` slots of a guild line take DIFFERENT values, unlike the friend list's repeated
|
||||
/// name — a replace-all substitution would print "Tigole has promoted Tigole to Tigole."
|
||||
#[test]
|
||||
fn the_multi_slot_lines_fill_positionally() {
|
||||
let notice = GuildEventNotice {
|
||||
event: guild_event::PROMOTION,
|
||||
params: vec!["Tigole".into(), "Furor".into(), "Officer".into()],
|
||||
fn notice(event: u8, params: &[&str]) -> GuildEventNotice {
|
||||
GuildEventNotice {
|
||||
event,
|
||||
params: params.iter().map(|s| (*s).to_string()).collect(),
|
||||
guid: None,
|
||||
};
|
||||
// Every other arm ignores the flag — a promotion prints whatever the condition says.
|
||||
assert_eq!(
|
||||
event_line(¬ice, false).as_deref(),
|
||||
Some("Tigole has promoted Furor to Officer.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign-on/sign-off lines are the friend-list ones, and they are the only two arms in the
|
||||
/// table with a display condition — which is the whole reason those two carry a guid.
|
||||
fn strings(e: &UiError) -> Vec<&str> {
|
||||
e.args
|
||||
.iter()
|
||||
.map(|a| match a {
|
||||
crate::ui_action::FillArg::S(s) => s.as_str(),
|
||||
crate::ui_action::FillArg::D(_) => unreachable!("guild lines push no integers"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A promotion names the catalog key and hands it the packet's three strings in order — the
|
||||
/// thing a re-typed sentence could not carry, since `%s` slots that take *different* values
|
||||
/// are what a replace-all substitution gets wrong ("Tigole has promoted Tigole to Tigole.").
|
||||
#[test]
|
||||
fn the_multi_slot_lines_name_the_key_and_pass_their_strings_in_order() {
|
||||
let e = event_line(
|
||||
¬ice(guild_event::PROMOTION, &["Tigole", "Furor", "Officer"]),
|
||||
false,
|
||||
)
|
||||
.expect("a promotion prints");
|
||||
assert_eq!(e.key, "ERR_GUILD_PROMOTE_SSS");
|
||||
assert_eq!(strings(&e), ["Tigole", "Furor", "Officer"]);
|
||||
}
|
||||
|
||||
/// **A short `strCount` is passed SHORT, never padded** (`0x5e745f`): 0 and >= 4 pass nothing
|
||||
/// at all, and 1–3 pass exactly what arrived. The visible consequence is the starved
|
||||
/// specifier, which `SStrPrintf` copies through — the reason this is a behaviour and not an
|
||||
/// implementation detail.
|
||||
#[test]
|
||||
fn the_emitter_tail_passes_one_two_or_three_strings_and_otherwise_none() {
|
||||
for (params, want) in [
|
||||
(&["a"][..], 1usize),
|
||||
(&["a", "b"][..], 2),
|
||||
(&["a", "b", "c"][..], 3),
|
||||
(&[][..], 0),
|
||||
(&["a", "b", "c", "d"][..], 0),
|
||||
] {
|
||||
let e = event_line(¬ice(guild_event::PROMOTION, params), false).expect("prints");
|
||||
assert_eq!(e.args.len(), want, "strCount {}", params.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign-on/sign-off lines are the friend-list ones, they take the name TWICE and ONCE, and
|
||||
/// they are the only two arms in the table with a display condition — the whole reason those
|
||||
/// two carry a guid.
|
||||
///
|
||||
/// **The polarity flipped in 1589** and this test says so on purpose: the flag used to mean
|
||||
/// "ignored" (suppress) and now means "announce", because `0x5ae810` turned out to be a
|
||||
/// friends-list test, not the ignore check the transcription named.
|
||||
#[test]
|
||||
fn sign_on_lines_are_the_friend_lines_and_obey_their_condition() {
|
||||
let notice = GuildEventNotice {
|
||||
event: guild_event::SIGNED_ON,
|
||||
params: vec!["Tigole".into()],
|
||||
guid: Some(9),
|
||||
};
|
||||
let on = event_line(¬ice(guild_event::SIGNED_ON, &["Tigole"]), true).expect("prints");
|
||||
assert_eq!(on.key, "ERR_FRIEND_ONLINE_SS");
|
||||
assert_eq!(
|
||||
event_line(¬ice, true).as_deref(),
|
||||
Some("|Hplayer:Tigole|h[Tigole]|h has come online."),
|
||||
"the name fills the link and the bracket both"
|
||||
);
|
||||
assert_eq!(
|
||||
event_line(¬ice, false),
|
||||
None,
|
||||
"a refused condition prints nothing at all"
|
||||
strings(&on),
|
||||
["Tigole", "Tigole"],
|
||||
"the link and the bracket"
|
||||
);
|
||||
let off = event_line(¬ice(guild_event::SIGNED_OFF, &["Tigole"]), true).expect("prints");
|
||||
assert_eq!(off.key, "ERR_FRIEND_OFFLINE_S");
|
||||
assert_eq!(strings(&off), ["Tigole"]);
|
||||
|
||||
for event in [guild_event::SIGNED_ON, guild_event::SIGNED_OFF] {
|
||||
assert!(
|
||||
event_line(¬ice(event, &["Tigole"]), false).is_none(),
|
||||
"a refused condition prints nothing at all"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The MOTD's line belongs to the FrameXML, and the two silent events stay silent.
|
||||
/// The three genuinely silent events — and, beside them, the two that were silent by MISTAKE.
|
||||
#[test]
|
||||
fn the_silent_events_print_nothing() {
|
||||
fn only_the_three_roster_events_are_silent() {
|
||||
for event in [
|
||||
guild_event::MOTD,
|
||||
guild_event::UPDATE_RANK_NAME,
|
||||
guild_event::UPDATE_ROSTER,
|
||||
guild_event::TABARD_CHANGE,
|
||||
0x77,
|
||||
] {
|
||||
let notice = GuildEventNotice {
|
||||
event,
|
||||
params: vec!["x".into(), "y".into()],
|
||||
guid: None,
|
||||
};
|
||||
assert_eq!(event_line(¬ice, false), None, "event {event:#04x}");
|
||||
assert_eq!(
|
||||
event_line(¬ice(event, &["x"]), false),
|
||||
None,
|
||||
"{event:#04x}"
|
||||
);
|
||||
}
|
||||
// `0x09` and every id past `0x0d` share the default arm, which pushes `0x69`. Both used to
|
||||
// return `None`; a message id the reference pushes is not a message we may drop.
|
||||
for event in [guild_event::TABARD_CHANGE, 0x0e, 0x77, 0xff] {
|
||||
let e = event_line(¬ice(event, &["x"]), false).expect("the default arm prints");
|
||||
assert_eq!(e.key, "ERR_GUILD_INTERNAL", "{event:#04x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Result `0x08` means two different things, and only the command tag beside it says which.
|
||||
#[test]
|
||||
fn the_two_meanings_of_result_eight_are_told_apart_by_the_command() {
|
||||
assert_eq!(
|
||||
command_line(&GuildCommandResult {
|
||||
command: guild_command::QUIT,
|
||||
name: String::new(),
|
||||
result: guild_command_error::LEADER_LEAVE,
|
||||
})
|
||||
.as_deref(),
|
||||
Some(ERR_GUILD_LEADER_LEAVE)
|
||||
);
|
||||
assert_eq!(
|
||||
command_line(&GuildCommandResult {
|
||||
command: guild_command::INVITE,
|
||||
name: String::new(),
|
||||
result: guild_command_error::PERMISSIONS,
|
||||
})
|
||||
.as_deref(),
|
||||
Some(ERR_GUILD_PERMISSIONS)
|
||||
);
|
||||
let quit = command_line(&GuildCommandResult {
|
||||
command: guild_command::QUIT,
|
||||
name: String::new(),
|
||||
result: guild_command_error::LEADER_LEAVE,
|
||||
})
|
||||
.expect("prints");
|
||||
assert_eq!(quit.key, "ERR_GUILD_LEADER_LEAVE");
|
||||
let other = command_line(&GuildCommandResult {
|
||||
command: guild_command::INVITE,
|
||||
name: String::new(),
|
||||
result: guild_command_error::PERMISSIONS,
|
||||
})
|
||||
.expect("prints");
|
||||
assert_eq!(other.key, "ERR_GUILD_PERMISSIONS");
|
||||
}
|
||||
|
||||
/// Result `0` is the success side, and there the command tag picks the line.
|
||||
#[test]
|
||||
fn result_zero_is_the_success_side() {
|
||||
assert_eq!(
|
||||
command_line(&GuildCommandResult {
|
||||
command: guild_command::INVITE,
|
||||
name: "Kaplan".into(),
|
||||
result: guild_command_error::PLAYER_NO_MORE_IN_GUILD,
|
||||
})
|
||||
.as_deref(),
|
||||
Some("You have invited Kaplan to join your guild.")
|
||||
);
|
||||
assert_eq!(
|
||||
command_line(&GuildCommandResult {
|
||||
command: guild_command::QUIT,
|
||||
name: "Legacy of Steel".into(),
|
||||
result: guild_command_error::PLAYER_NO_MORE_IN_GUILD,
|
||||
})
|
||||
.as_deref(),
|
||||
Some("You are no longer a member of Legacy of Steel.")
|
||||
);
|
||||
assert_eq!(
|
||||
let invited = command_line(&GuildCommandResult {
|
||||
command: guild_command::INVITE,
|
||||
name: "Kaplan".into(),
|
||||
result: guild_command_error::PLAYER_NO_MORE_IN_GUILD,
|
||||
})
|
||||
.expect("prints");
|
||||
assert_eq!(invited.key, "ERR_GUILD_INVITE_S");
|
||||
assert_eq!(strings(&invited), ["Kaplan"]);
|
||||
assert!(
|
||||
command_line(&GuildCommandResult {
|
||||
command: 0x99,
|
||||
name: String::new(),
|
||||
result: guild_command_error::PLAYER_NO_MORE_IN_GUILD,
|
||||
}),
|
||||
None,
|
||||
})
|
||||
.is_none(),
|
||||
"a command with no message says nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// `/ginfo`'s two lines, with the date swapped into the enUS month-day-year the wire is not in.
|
||||
/// **The `_S` arity control, 22 of 22** — the reference's `add esp,8` at every `_S` key and
|
||||
/// `add esp,4` at every other (guild-api-carve.md §5). It is checkable here because the key
|
||||
/// name carries the arity, and it is the one thing about this table that a wrong key cannot
|
||||
/// pass silently.
|
||||
#[test]
|
||||
fn the_ginfo_lines_swap_the_date_into_month_day_year() {
|
||||
fn every_underscore_s_key_takes_the_name_and_every_other_takes_nothing() {
|
||||
for result in 0u32..=0x15 {
|
||||
for command in [
|
||||
guild_command::CREATE,
|
||||
guild_command::INVITE,
|
||||
guild_command::QUIT,
|
||||
] {
|
||||
let Some(e) = command_line(&GuildCommandResult {
|
||||
command,
|
||||
name: "Kaplan".into(),
|
||||
result,
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let want = usize::from(e.key.ends_with("_S"));
|
||||
assert_eq!(e.args.len(), want, "{} (result {result}) ", e.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Every key this module names is a catalog row.** A key that is not one resolves to nothing
|
||||
/// or, worse, takes `Shown::keyed`'s fallback and turns a chat line red — which is exactly the
|
||||
/// class decision 2033 found sitting unnoticed for months. The surfaces are asserted with it,
|
||||
/// because two of these are NOT chat and the old text path sent all of them there.
|
||||
#[test]
|
||||
fn every_key_is_a_catalog_row_and_two_of_them_are_the_red_line() {
|
||||
let mut seen = Vec::new();
|
||||
for event in 0u8..=0x0f {
|
||||
for announce in [true, false] {
|
||||
seen.extend(event_line(¬ice(event, &["a", "b", "c"]), announce));
|
||||
}
|
||||
}
|
||||
for result in 0u32..=0x15 {
|
||||
for command in 0u32..=0x14 {
|
||||
seen.extend(command_line(&GuildCommandResult {
|
||||
command,
|
||||
name: "Kaplan".into(),
|
||||
result,
|
||||
}));
|
||||
}
|
||||
}
|
||||
seen.push(invite_line("Tigole", "Legacy of Steel"));
|
||||
seen.push(decline_line("Kaplan"));
|
||||
assert!(seen.len() > 20, "the sweep found the table");
|
||||
for e in &seen {
|
||||
assert!(by_key(e.key).is_some(), "{} is not a catalog row", e.key);
|
||||
}
|
||||
|
||||
// The two name refusals are `kind 2` — the red `UI_ERROR_MESSAGE`, not chat. Every text
|
||||
// this module used to compose went to chat, so these two were on the wrong surface.
|
||||
for key in ["ERR_GUILD_NAME_INVALID", "ERR_GUILD_NAME_EXISTS_S"] {
|
||||
assert_eq!(by_key(key).map(|r| r.kind), Some(MsgKind::Error), "{key}");
|
||||
}
|
||||
// …and the rest of the guild family is chat, which is where they already were.
|
||||
assert_eq!(
|
||||
info_lines(&GuildInfo {
|
||||
name: "Legacy of Steel".into(),
|
||||
created_day: 14,
|
||||
created_month: 3,
|
||||
created_year: 2005,
|
||||
member_count: 42,
|
||||
account_count: 30,
|
||||
}),
|
||||
vec![
|
||||
"Guild: Legacy of Steel".to_string(),
|
||||
"Guild created 3-14-2005, 42 players, 30 accounts".to_string(),
|
||||
]
|
||||
by_key("ERR_GUILD_PROMOTE_SSS").map(|r| r.kind),
|
||||
Some(MsgKind::Chat)
|
||||
);
|
||||
}
|
||||
|
||||
/// The invite notice names the inviter first and the guild second.
|
||||
#[test]
|
||||
fn the_invite_notice_names_both() {
|
||||
assert_eq!(
|
||||
invite_line("Tigole", "Legacy of Steel"),
|
||||
"Tigole invites you join Legacy of Steel."
|
||||
);
|
||||
assert_eq!(
|
||||
decline_line("Kaplan"),
|
||||
"Kaplan declines your guild invitation."
|
||||
);
|
||||
let e = invite_line("Tigole", "Legacy of Steel");
|
||||
assert_eq!(e.key, "ERR_INVITED_TO_GUILD_SS");
|
||||
assert_eq!(strings(&e), ["Tigole", "Legacy of Steel"]);
|
||||
assert_eq!(decline_line("Kaplan").key, "ERR_GUILD_DECLINE_S");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use benilla_formats::GuildEmblem;
|
||||
use benilla_protocol::messages::{
|
||||
guild_event, GuildCommandResult, GuildEventNotice, GuildQueryResponse, GuildRoster,
|
||||
guild_event, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
|
||||
GuildRosterMember, GUILD_RANKS_MAX_COUNT,
|
||||
};
|
||||
use benilla_protocol::ObjectFields;
|
||||
@@ -200,6 +200,13 @@ pub(crate) struct GuildState {
|
||||
/// Set whenever the pushed snapshot went stale — the feed rebuilds and pushes on this and
|
||||
/// skips the work otherwise (a 500-member roster is not worth re-resolving every frame).
|
||||
dirty: bool,
|
||||
/// `/ginfo` answers waiting for the VM. Their two lines are `GUILD_NAME_TEMPLATE` and
|
||||
/// `GUILD_INFO_TEMPLATE`, which are **not** catalog rows — `0x5e6fb0` resolves each token
|
||||
/// through the script VM and emits chat directly, passing no message record — so unlike every
|
||||
/// other line in this module they cannot ride [`crate::ui_action::UiErrorKeys`], whose whole
|
||||
/// contract is that the catalog names the surface. They wait here for [`feed`] instead, which
|
||||
/// is where the VM is (decision 2054).
|
||||
pending_info: Vec<GuildInfo>,
|
||||
}
|
||||
|
||||
impl GuildState {
|
||||
@@ -606,18 +613,20 @@ fn guild_emblem(
|
||||
}
|
||||
|
||||
/// The net drain's `SessionEvent::Guild*` arms, factored here so the wire laws live beside the
|
||||
/// state they drive ([`crate::ui_social::apply`]'s shape). The ones that owe chat lines push what
|
||||
/// [`lines`] composed, the way `crate::net::apply`'s group shims do.
|
||||
/// state they drive ([`crate::ui_social::apply`]'s shape). The ones that owe a line queue the
|
||||
/// **message id** [`lines`] named, the way `crate::net::apply`'s group shims do — the surface and
|
||||
/// the sound come off the catalog at the drain, not from here (decision 2054).
|
||||
pub(crate) mod apply {
|
||||
use super::*;
|
||||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_action::{UiError, UiErrorKeys};
|
||||
use crate::ui_social::SocialState;
|
||||
use benilla_protocol::messages::GuildInfo;
|
||||
|
||||
fn push_lines(chat_log: &mut ChatLog, lines: impl IntoIterator<Item = String>) {
|
||||
for line in lines {
|
||||
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, line));
|
||||
}
|
||||
/// Queue what [`lines`] named. The key IS the lookup and the catalog row behind it names the
|
||||
/// surface and the sound, so nothing here decides either — which is the difference decision
|
||||
/// 2054 made: this used to push composed English straight onto the system chat log, and two
|
||||
/// of these messages are not chat lines at all.
|
||||
fn push_lines(errors: &mut UiErrorKeys, lines: impl IntoIterator<Item = UiError>) {
|
||||
errors.0.extend(lines);
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE`.
|
||||
@@ -655,7 +664,7 @@ pub(crate) mod apply {
|
||||
/// this conjunct's entire job.
|
||||
pub(crate) fn event(
|
||||
guild: &mut GuildState,
|
||||
chat_log: &mut ChatLog,
|
||||
errors: &mut UiErrorKeys,
|
||||
social: &SocialState,
|
||||
notify: &GuildMemberNotify,
|
||||
self_guid: Option<u64>,
|
||||
@@ -663,7 +672,7 @@ pub(crate) mod apply {
|
||||
) {
|
||||
let announce = announce_signon(social, notify, self_guid, notice.guid);
|
||||
guild.apply_event(¬ice);
|
||||
push_lines(chat_log, lines::event_line(¬ice, announce));
|
||||
push_lines(errors, lines::event_line(¬ice, announce));
|
||||
}
|
||||
|
||||
/// The sign-on/sign-off pair's four-conjunct display condition, as one predicate — see
|
||||
@@ -692,33 +701,36 @@ pub(crate) mod apply {
|
||||
/// `SMSG_GUILD_COMMAND_RESULT`.
|
||||
pub(crate) fn command_result(
|
||||
guild: &mut GuildState,
|
||||
chat_log: &mut ChatLog,
|
||||
errors: &mut UiErrorKeys,
|
||||
result: GuildCommandResult,
|
||||
) {
|
||||
guild.apply_command_result(&result);
|
||||
push_lines(chat_log, lines::command_line(&result));
|
||||
push_lines(errors, lines::command_line(&result));
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_INVITE` — the popup's arm edge, plus the notice line the reference prints
|
||||
/// beside it.
|
||||
pub(crate) fn invite(
|
||||
guild: &mut GuildState,
|
||||
chat_log: &mut ChatLog,
|
||||
errors: &mut UiErrorKeys,
|
||||
inviter: String,
|
||||
guild_name: String,
|
||||
) {
|
||||
push_lines(chat_log, [lines::invite_line(&inviter, &guild_name)]);
|
||||
push_lines(errors, [lines::invite_line(&inviter, &guild_name)]);
|
||||
guild.apply_invite(inviter, guild_name);
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_DECLINE` — a line only; there is no state behind it.
|
||||
pub(crate) fn decline(chat_log: &mut ChatLog, name: &str) {
|
||||
push_lines(chat_log, [lines::decline_line(name)]);
|
||||
pub(crate) fn decline(errors: &mut UiErrorKeys, name: &str) {
|
||||
push_lines(errors, [lines::decline_line(name)]);
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_INFO` — the `/ginfo` answer, two lines and no state.
|
||||
pub(crate) fn info(chat_log: &mut ChatLog, info: GuildInfo) {
|
||||
push_lines(chat_log, lines::info_lines(&info));
|
||||
///
|
||||
/// Parked rather than composed: its templates are not catalog rows, so the lines are built
|
||||
/// where the VM is ([`GuildState::pending_info`]).
|
||||
pub(crate) fn info(guild: &mut GuildState, info: GuildInfo) {
|
||||
guild.pending_info.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -634,8 +634,18 @@ fn feed_mail(
|
||||
// by BuyPrice ascending. The templates are asked ahead of any mailbox (the catalog is five
|
||||
// rows) so the list is whole the frame the window opens: the stock `SendMailFrame_Reset`
|
||||
// selects row 1 on show, and an empty list then would leave every send silently unsent.
|
||||
//
|
||||
// **Only once there is a player.** `feed_mail` carries no run condition, so it runs from the
|
||||
// first frame of the process — the login screen, before the socket exists. The five asks went
|
||||
// out there, the io thread dropped them ("not connected"), and `Items::template` had already
|
||||
// latched all five in its ask-once `pending` set, which is cleared on DISCONNECT and never on
|
||||
// connect. The templates were therefore never re-asked, the list stayed empty for the whole
|
||||
// session, and the failure this very comment describes — every send silently unsent — is what
|
||||
// it caused. Gating on the self player is also what the list means: "what may I use", which is
|
||||
// not a question until there is a me.
|
||||
let usable = stationery
|
||||
.as_deref()
|
||||
.filter(|_| !self_q.is_empty())
|
||||
.map(|catalog| stationeries(catalog, &self_q, &mut items, icons.as_deref(), &commands))
|
||||
.unwrap_or_default();
|
||||
let memo = last_stationeries.get(&script);
|
||||
|
||||
@@ -931,9 +931,19 @@ fn sync_tiles(
|
||||
light_slot,
|
||||
&layer,
|
||||
) {
|
||||
info!(
|
||||
"ui_models: tile built for {} — {} parts, {} emitters, {} animated alphas",
|
||||
// **Named by its PANE, and at debug.** A tile is per-widget by construction
|
||||
// (`state.tiles` is keyed by `FrameHandle`, and 2019 requires it: two panes on
|
||||
// one file must not share their `MatAnimTable` rows, or two cooldowns at
|
||||
// different fractions would fight). So five cooldowns up at once legitimately
|
||||
// build five tiles — which, logged at info with only the PATH, arrived as five
|
||||
// byte-identical lines that read like a caching bug. The path alone also
|
||||
// recurs: `TILE_LINGER_FRAMES` is ~10 s, so any longer cooldown rebuilds and
|
||||
// re-logs at combat rate.
|
||||
debug!(
|
||||
"ui_models: tile built for {} on pane {} — {} parts, {} emitters, \
|
||||
{} animated alphas",
|
||||
req.path,
|
||||
script.frame_name(*handle).unwrap_or_default(),
|
||||
model.submeshes.len(),
|
||||
built.emitters.len(),
|
||||
built.alpha_parts.len()
|
||||
|
||||
@@ -317,11 +317,7 @@ impl GroupState {
|
||||
member: &str,
|
||||
result: u32,
|
||||
) -> Option<UiError> {
|
||||
let named = |key: &'static str| UiError {
|
||||
key,
|
||||
fill_s: Some(member.to_string()),
|
||||
fill_d: None,
|
||||
};
|
||||
let named = |key: &'static str| UiError::s(key, member);
|
||||
match result {
|
||||
party_result::OK => match operation {
|
||||
// The empty-name guard is the reference's, not defensiveness: an invite ack that
|
||||
@@ -679,11 +675,7 @@ mod tests {
|
||||
assert_eq!(g.pending_invite.as_deref(), Some("Bob"));
|
||||
assert_eq!(
|
||||
g.apply_command_result(party_operation::INVITE, "Carol", party_result::OK),
|
||||
Some(UiError {
|
||||
key: "ERR_INVITE_PLAYER_S",
|
||||
fill_s: Some("Carol".into()),
|
||||
fill_d: None
|
||||
})
|
||||
Some(UiError::s("ERR_INVITE_PLAYER_S", "Carol"))
|
||||
);
|
||||
assert_eq!(
|
||||
g.apply_command_result(
|
||||
@@ -691,19 +683,11 @@ mod tests {
|
||||
"Carol",
|
||||
party_result::ALREADY_IN_GROUP
|
||||
),
|
||||
Some(UiError {
|
||||
key: "ERR_ALREADY_IN_GROUP_S",
|
||||
fill_s: Some("Carol".into()),
|
||||
fill_d: None
|
||||
})
|
||||
Some(UiError::s("ERR_ALREADY_IN_GROUP_S", "Carol"))
|
||||
);
|
||||
assert_eq!(
|
||||
g.apply_command_result(party_operation::INVITE, "Xz", party_result::BAD_PLAYER_NAME),
|
||||
Some(UiError {
|
||||
key: "ERR_BAD_PLAYER_NAME_S",
|
||||
fill_s: Some("Xz".into()),
|
||||
fill_d: None
|
||||
})
|
||||
Some(UiError::s("ERR_BAD_PLAYER_NAME_S", "Xz"))
|
||||
);
|
||||
assert_eq!(
|
||||
g.apply_declined("Carol"),
|
||||
@@ -846,7 +830,7 @@ mod tests {
|
||||
// The `%s` arms are exactly the arms the binary passes `&name` to, which is an
|
||||
// independent cross-check: every one of them is an `_S` key.
|
||||
assert_eq!(
|
||||
msg.fill_s.is_some(),
|
||||
msg.arg_s().is_some(),
|
||||
takes_name,
|
||||
"result {result} name fill"
|
||||
);
|
||||
@@ -965,7 +949,7 @@ mod tests {
|
||||
// An `_S` key must actually carry the hole its fill expects.
|
||||
assert_eq!(
|
||||
text.contains("%s"),
|
||||
msg.fill_s.is_some(),
|
||||
msg.arg_s().is_some(),
|
||||
"{} vs its fill",
|
||||
msg.key
|
||||
);
|
||||
|
||||
@@ -1063,15 +1063,7 @@ mod tests {
|
||||
);
|
||||
// …and the named one, filled.
|
||||
assert_eq!(
|
||||
ui_error_text(
|
||||
&UiError {
|
||||
key: questgiver_failed_key(4),
|
||||
fill_s: Some("A Threat Within".into()),
|
||||
fill_d: None,
|
||||
},
|
||||
&g
|
||||
)
|
||||
.as_deref(),
|
||||
ui_error_text(&UiError::s(questgiver_failed_key(4), "A Threat Within"), &g).as_deref(),
|
||||
Some("A Threat Within failed: Inventory is full.")
|
||||
);
|
||||
// The log-full line is the RED one and carries no fill.
|
||||
|
||||
@@ -177,11 +177,7 @@ fn feed_quest_share(
|
||||
};
|
||||
match names.resolve(v.member, &commands).map(str::to_string) {
|
||||
Some(name) => {
|
||||
let err = UiError {
|
||||
key,
|
||||
fill_s: Some(name),
|
||||
fill_d: None,
|
||||
};
|
||||
let err = UiError::s(key, name);
|
||||
let get = |k: &str| script.lua().globals().get::<String>(k).ok();
|
||||
if let Some(text) = ui_error_text(&err, &get) {
|
||||
lines.push(Shown::keyed(err.key, text));
|
||||
@@ -292,14 +288,7 @@ mod tests {
|
||||
// the answer a decline produces.
|
||||
let line = |raw: u8| {
|
||||
let key = verdict_message(QuestShareMsg(raw)).unwrap();
|
||||
ui_error_text(
|
||||
&UiError {
|
||||
key,
|
||||
fill_s: Some("Mate".into()),
|
||||
fill_d: None,
|
||||
},
|
||||
&g,
|
||||
)
|
||||
ui_error_text(&UiError::s(key, "Mate"), &g)
|
||||
};
|
||||
assert_eq!(
|
||||
line(QuestShareMsg::SHARING_QUEST.0).as_deref(),
|
||||
|
||||
@@ -428,6 +428,22 @@ impl TextEngine {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// **A control character is not a missing glyph.** `\n` reaches here because the pre-warm
|
||||
// walks raw text (`ensure_str`), and no face shapes it — so it used to pay a full shape,
|
||||
// then warn as if a face were missing one. Caching the miss stopped the repeat work but
|
||||
// still left the warn, which is noise about a character that is SUPPOSED to have no glyph:
|
||||
// the markup parser consumes `\n` as a line break and it never reaches a draw. Answered
|
||||
// here, before the shape, so the warn stays meaningful for a real missing glyph.
|
||||
if ch.is_control() {
|
||||
self.chars.insert(
|
||||
(face, ppem, ch),
|
||||
CharCell {
|
||||
glyphs: Vec::new(),
|
||||
floor_sum: 0.0,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(f) = self.faces.get(face) else {
|
||||
return;
|
||||
};
|
||||
@@ -469,6 +485,20 @@ impl TextEngine {
|
||||
if self.complained.insert(ch) {
|
||||
warn!("ui_text: no glyph for {ch:?} in any registered face");
|
||||
}
|
||||
// **Cache the miss.** `complained` only silenced the WARNING; the shape itself was
|
||||
// re-run on every call, because the insert below was never reached. The pre-warm walks
|
||||
// `text.chars()` raw (`ensure_str`), so every `\n` in a drawn multi-line string paid a
|
||||
// full `Buffer::new` + `set_text` + `shape_until_scroll` per FontString per frame,
|
||||
// forever — `RAID_DESCRIPTION` alone carries three. An empty cell is exactly what the
|
||||
// consumers already do with the `None` they used to get: `char_cell` hands back zero
|
||||
// glyphs, so the pen steps nothing and draws nothing, and `floor_sum` sums nothing.
|
||||
self.chars.insert(
|
||||
(face, ppem, ch),
|
||||
CharCell {
|
||||
glyphs,
|
||||
floor_sum: 0.0,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.stats.chars_shaped += 1;
|
||||
@@ -974,6 +1004,31 @@ mod ppem_tests {
|
||||
assert_eq!(e.stats.cells_rasterized, raster, "the miss is paid once");
|
||||
}
|
||||
|
||||
/// **A character no face shapes is asked once too.** The sibling above pins that for a
|
||||
/// character that DOES shape; this one pins the miss, which was the leak: `complained`
|
||||
/// silenced the warning and nothing cached the result, so the shape re-ran on every ask. The
|
||||
/// pre-warm walks raw text (`ensure_str`), so a drawn multi-line FontString re-shaped every
|
||||
/// `\n` it held, every frame, for the life of the session.
|
||||
#[test]
|
||||
fn an_unshapeable_character_is_cached_as_a_miss() {
|
||||
let Some(mut e) = engine_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let face = e.face_for(None);
|
||||
let shaped = e.stats.chars_shaped;
|
||||
e.ensure_str(face, 14, 0, "\n");
|
||||
let c = e
|
||||
.char_cell(face, 14, '\n')
|
||||
.expect("the miss is cached, so the second ask short-circuits");
|
||||
// An empty cell is what every consumer already did with the `None` it used to get.
|
||||
assert!(c.glyphs.is_empty(), "a newline draws nothing");
|
||||
assert_eq!(c.floor_sum, 0.0, "…and steps nothing");
|
||||
assert_eq!(
|
||||
e.stats.chars_shaped, shaped,
|
||||
"a control character is answered before the shape, not by failing one"
|
||||
);
|
||||
}
|
||||
|
||||
/// Each face resolves to itself, and an unknown path falls back to Friz.
|
||||
#[test]
|
||||
fn a_font_path_resolves_to_its_own_face() {
|
||||
|
||||
@@ -701,11 +701,9 @@ fn answer_trade_request(
|
||||
// `GlobalStrings.lua` and routes it. Row `0xbb` carries `kind = 0`, chat type `0xa`, no
|
||||
// sound cue — a system chat line, not the red `UI_ERROR_MESSAGE` (`kind = 2`) — but no
|
||||
// line here says so, and that is the point: this arm no longer knows or decides.
|
||||
errors.0.push(crate::ui_action::UiError {
|
||||
key: "ERR_TRADE_BLOCKED_S",
|
||||
fill_s: Some(name),
|
||||
fill_d: None,
|
||||
});
|
||||
errors
|
||||
.0
|
||||
.push(crate::ui_action::UiError::s("ERR_TRADE_BLOCKED_S", name));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -903,11 +901,7 @@ fn feed_trade(
|
||||
continue;
|
||||
}
|
||||
match names.resolve(line.who, &commands).map(str::to_string) {
|
||||
Some(name) => errors.0.push(crate::ui_action::UiError {
|
||||
key: line.key,
|
||||
fill_s: Some(name),
|
||||
fill_d: None,
|
||||
}),
|
||||
Some(name) => errors.0.push(crate::ui_action::UiError::s(line.key, name)),
|
||||
// Not cached yet: `resolve` has asked, so park the debt again and retry next frame.
|
||||
None => trade.owe_named_line(line),
|
||||
}
|
||||
@@ -1471,11 +1465,10 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<crate::ui_action::UiErrorKeys>().0,
|
||||
vec![crate::ui_action::UiError {
|
||||
key: "ERR_INITIATE_TRADE_S",
|
||||
fill_s: Some("Grubbis".to_string()),
|
||||
fill_d: None,
|
||||
}],
|
||||
vec![crate::ui_action::UiError::s(
|
||||
"ERR_INITIATE_TRADE_S",
|
||||
"Grubbis".to_string()
|
||||
)],
|
||||
);
|
||||
assert_eq!(
|
||||
benilla_ui::messages::kind_of("ERR_INITIATE_TRADE_S"),
|
||||
@@ -1542,11 +1535,10 @@ mod tests {
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<crate::ui_action::UiErrorKeys>().0,
|
||||
vec![crate::ui_action::UiError {
|
||||
key: "ERR_PLAYER_BUSY_S",
|
||||
fill_s: Some("Grubbis".to_string()),
|
||||
fill_d: None,
|
||||
}],
|
||||
vec![crate::ui_action::UiError::s(
|
||||
"ERR_PLAYER_BUSY_S",
|
||||
"Grubbis".to_string()
|
||||
)],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1581,11 +1573,10 @@ mod tests {
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<crate::ui_action::UiErrorKeys>().0,
|
||||
vec![crate::ui_action::UiError {
|
||||
key: "ERR_INITIATE_TRADE_S",
|
||||
fill_s: Some("Skarrid".to_string()),
|
||||
fill_d: None,
|
||||
}],
|
||||
vec![crate::ui_action::UiError::s(
|
||||
"ERR_INITIATE_TRADE_S",
|
||||
"Skarrid".to_string()
|
||||
)],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1632,11 +1623,10 @@ mod tests {
|
||||
assert!(matches!(rx.try_recv(), Ok(ClientCommand::BusyTrade)));
|
||||
assert_eq!(
|
||||
app.world().resource::<crate::ui_action::UiErrorKeys>().0,
|
||||
vec![crate::ui_action::UiError {
|
||||
key: "ERR_TRADE_BLOCKED_S",
|
||||
fill_s: Some("Grubbis".to_string()),
|
||||
fill_d: None,
|
||||
}],
|
||||
vec![crate::ui_action::UiError::s(
|
||||
"ERR_TRADE_BLOCKED_S",
|
||||
"Grubbis".to_string()
|
||||
)],
|
||||
"the refusal raises the reference's own `0x496720(0xbb, name)`"
|
||||
);
|
||||
// …and the row it names is a CHAT row, which is the half a re-implementation gets wrong:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! **No user-facing sentence is written in Rust when the reference ships one.**
|
||||
//!
|
||||
//! The real client never composes display text: every sentence is a key into `GlobalStrings.lua`
|
||||
//! (in-game) or `GlueStrings.lua` (the login/character screens), resolved at runtime from the
|
||||
//! player's own install. Writing the English in Rust throws away three things at once —
|
||||
//! (in-game) or `GlueStrings.lua` (the login/character screens) — each with a `Localize()` patch
|
||||
//! file laid over it, which is where a good many of the sentences the player actually reads come
|
||||
//! from (decision 2052) — resolved at runtime from the player's own install. Writing the English in
|
||||
//! Rust throws away three things at once —
|
||||
//! localization, and, for anything that goes through the message catalog, the *surface* the
|
||||
//! message is shown on and the *voice line* it speaks with (decisions 1770, 1815, 2035).
|
||||
//!
|
||||
@@ -29,7 +31,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The drift still standing, per file: **252 literals over 32 files**, each a sentence the
|
||||
/// The drift still standing, per file: **208 literals over 31 files**, each a sentence the
|
||||
/// reference ships and we re-typed.
|
||||
///
|
||||
/// **These numbers may only decrease.** They are not a budget to spend; every one is a string that
|
||||
@@ -45,7 +47,6 @@ use std::path::{Path, PathBuf};
|
||||
/// is exactly how the first cut of this list carried a row the gates then failed on.
|
||||
const ALLOWED: &[(&str, usize)] = &[
|
||||
("benilla-app/src/ui_chat/frames.rs", 48),
|
||||
("benilla-app/src/ui_guild/lines.rs", 34),
|
||||
("benilla-app/src/ui_tooltip/mod.rs", 28),
|
||||
("benilla-app/src/ui_party/mod.rs", 12),
|
||||
("benilla-app/src/ui_social/mod.rs", 18),
|
||||
@@ -198,21 +199,33 @@ fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
|
||||
};
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let mut shipped = HashMap::new();
|
||||
// The base tables AND the locale patches laid over them (decision 2052): where `Localize()`
|
||||
// redefines a key, its wording is the one the player actually reads, so a set that stopped at
|
||||
// the base files would be grading against text this install never shows.
|
||||
for file in [
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\Localization.lua",
|
||||
"Interface\\GlueXML\\GlueStrings.lua",
|
||||
"Interface\\GlueXML\\GlueLocalization.lua",
|
||||
] {
|
||||
let src = chain
|
||||
.read_file(file)
|
||||
.unwrap_or_else(|e| panic!("{file}: {e}"));
|
||||
let mut taken = 0usize;
|
||||
for (k, v) in lua_table(&String::from_utf8_lossy(&src)) {
|
||||
let n = normalize(&v);
|
||||
// A one-word or punctuation-only value ("Locked", "%s") is too weak a signal: it
|
||||
// collides with ordinary program text. Sentences are what this is after.
|
||||
if n.split(' ').count() >= 2 && n.chars().any(|c| c.is_ascii_lowercase()) {
|
||||
shipped.entry(n).or_insert(k);
|
||||
taken += 1;
|
||||
}
|
||||
}
|
||||
// A file that reads fine but parses to nothing would make this whole test pass vacuously
|
||||
// — and the two `Localize()` files wrap their assignments in a function, a shape the base
|
||||
// tables never have. Every source has to contribute or the walk is grading against less
|
||||
// than it claims.
|
||||
assert!(taken > 0, "{file} contributed no sentences");
|
||||
}
|
||||
|
||||
let mut sources = Vec::new();
|
||||
|
||||
@@ -380,7 +380,17 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// nothing in a tile ever sampled until the tile's buffer was on a mirror list. Registered once
|
||||
/// at the tile renderer's startup, the same shape and the same reason as the palette mirror
|
||||
/// beside it.
|
||||
const CEILING: usize = 177;
|
||||
/// And 177 → 178: `model_fade::UnitRenderAlpha`, a PUBLISH — one streamed unit's live render
|
||||
/// alpha, composed from the unit ROOT's own presentation state. `ModelAlphas` beside it is the
|
||||
/// read side for a consumer that can wait for `PostUpdate`'s publish; this is the read side for
|
||||
/// one that cannot — the blob shadow runs in `Update`, and on the frame a unit's presentation
|
||||
/// begins the published component does not exist yet. Published as a door rather than as its
|
||||
/// three inputs (`UnitAppearFade`, `DespawnFade`, `model_render_alpha`) precisely because the
|
||||
/// lane that reconstructed the answer from parts got it wrong the same way every time: it read
|
||||
/// "no part is fading" as opaque, which is false of a *pending* unit, and put a full-strength
|
||||
/// shadow on the ground under an invisible creature for the length of a load. A caller asking
|
||||
/// one question cannot make that mistake; a caller handed the inputs can.
|
||||
const CEILING: usize = 178;
|
||||
|
||||
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
|
||||
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
|
||||
|
||||
@@ -533,8 +533,15 @@ pub struct GuildEventNotice {
|
||||
/// A [`guild_event`] id. The reference client's `switch` covers `0x00`–`0x0D` and treats
|
||||
/// anything above as its default arm, so an unknown id is a display question, not a parse one.
|
||||
pub event: u8,
|
||||
/// The event's string arguments, in order — at most **three** (the reference handler has
|
||||
/// exactly three stack buffers and its `strCount` is capped at 3).
|
||||
/// The event's string arguments, in order, exactly as many as `strCount` said.
|
||||
///
|
||||
/// **Not capped at three, and the reference is not either** — this doc used to claim it was.
|
||||
/// `0x5e7180`'s read loop has no bound: a `strCount >= 4` writes past its three `0x100` stack
|
||||
/// buffers into the live locals (wow-re `system/ui/scratch/guild-api-carve.md` §5). What *is*
|
||||
/// bounded is the **display**: the shared emitter tail `0x5e745f` passes 1, 2 or 3 strings to
|
||||
/// `DisplayError` and passes **none** for `strCount == 0` or `>= 4`. Reading into a `Vec` has
|
||||
/// no overflow to reproduce, so that rule lives on the display side
|
||||
/// (`ui_guild::lines::event_line`, decision 2054) and this field carries what arrived.
|
||||
pub params: Vec<String>,
|
||||
/// The guildmate the event is about — present for [`guild_event::SIGNED_ON`] and
|
||||
/// [`guild_event::SIGNED_OFF`] only. See [`read_guild_event`] for why that is narrower than
|
||||
|
||||
@@ -273,35 +273,53 @@ pub(crate) fn detect_shade_reclaims(
|
||||
}
|
||||
}
|
||||
|
||||
/// TEMP-VERIFY: how many parts carry the doodad-def marker, and how many of those the shade walk
|
||||
/// would otherwise have reached.
|
||||
pub fn b373_marker_count(
|
||||
/// `WOW_SHADE_CENSUS=<secs>` (any unparseable value = 5): a periodic one-line count of the two
|
||||
/// populations this pass has to keep apart — every part carrying the doodad-def marker, and the
|
||||
/// tagged parts *under* a shade root with the marked subset called out. B373 (2031/2041/2047) is
|
||||
/// what made the pair worth counting: a transport's cabin prop sits under the boat's shade root
|
||||
/// and must NOT take the boat's light, so "under a root" and "marked" diverging is the whole
|
||||
/// diagnosis, and the two numbers moving together again is how a regression shows up. Zero-cost
|
||||
/// when off: one env read, once.
|
||||
fn shade_census_every() -> Option<f32> {
|
||||
static EVERY: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
|
||||
*EVERY.get_or_init(|| {
|
||||
std::env::var("WOW_SHADE_CENSUS")
|
||||
.ok()
|
||||
.map(|v| v.parse().unwrap_or(5.0))
|
||||
})
|
||||
}
|
||||
|
||||
/// The `shade-census` printer (`WOW_SHADE_CENSUS=<secs>`) — see [`shade_census_every`].
|
||||
fn census_shade_marks(
|
||||
marked: Query<(), With<DoodadDefLit>>,
|
||||
roots: Query<Entity, With<GroundShade>>,
|
||||
children: Query<&Children>,
|
||||
is_marked: Query<(), With<DoodadDefLit>>,
|
||||
tagged: Query<(), With<MeshTag>>,
|
||||
time: Res<Time>,
|
||||
mut next: Local<f32>,
|
||||
) {
|
||||
if time.elapsed_secs() < *next {
|
||||
let Some(every) = shade_census_every() else {
|
||||
return;
|
||||
};
|
||||
let now = time.elapsed_secs();
|
||||
if now < *next {
|
||||
return;
|
||||
}
|
||||
*next = time.elapsed_secs() + 5.0;
|
||||
*next = now + every;
|
||||
let mut under_roots = 0usize;
|
||||
let mut marked_under_roots = 0usize;
|
||||
for r in &roots {
|
||||
for e in children.iter_descendants(r) {
|
||||
if tagged.get(e).is_ok() {
|
||||
under_roots += 1;
|
||||
if is_marked.get(e).is_ok() {
|
||||
if marked.get(e).is_ok() {
|
||||
marked_under_roots += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"B373-VERIFY DoodadDefLit total {} | tagged parts under a shade root {under_roots}, of which marked {marked_under_roots}",
|
||||
"shade-census: DoodadDefLit total {} | tagged parts under a shade root {under_roots}, of which marked {marked_under_roots}",
|
||||
marked.iter().count(),
|
||||
);
|
||||
}
|
||||
@@ -318,7 +336,7 @@ impl Plugin for EntityShadePlugin {
|
||||
(
|
||||
detect_shade_reclaims,
|
||||
update_ground_shade,
|
||||
b373_marker_count,
|
||||
census_shade_marks,
|
||||
)
|
||||
.chain()
|
||||
.after(classify_entity_interior),
|
||||
|
||||
@@ -305,6 +305,146 @@ pub fn model_render_alpha(
|
||||
(appear * despawn * self_fade * declared).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// **One streamed unit's live render alpha, asked of the unit** — the same product
|
||||
/// [`publish_model_alpha`] publishes as [`ModelAlpha`], answered on demand from the root's own
|
||||
/// presentation state.
|
||||
///
|
||||
/// [`ModelAlphas`] is the read side for a consumer that can wait for the publish: it looks up a
|
||||
/// number written in `PostUpdate` and composed down the [`ParentModel`] chain. This is the read
|
||||
/// side for a consumer that **cannot** — one that runs in `Update`, or on the very frame a unit's
|
||||
/// presentation begins, where the published component does not exist yet (`publish_model_alpha`
|
||||
/// inserts it through `Commands`). Both answer the same question from the same law
|
||||
/// ([`model_render_alpha`]); they differ only in where the inputs come from.
|
||||
///
|
||||
/// It exists because a consumer that reconstructs the answer for itself gets it wrong in the same
|
||||
/// way every time. The blob shadow gathered the live [`RenderFade`]s off a unit's *part* entities
|
||||
/// and read "no part is fading" as opaque — which is true of a settled unit and false of a
|
||||
/// **pending** one, whose parts are deliberately invisible and carry no `RenderFade` at all. The
|
||||
/// unit root is where the answer is unambiguous, because [`UnitAppearFade`] distinguishes the two
|
||||
/// states that a part-side walk cannot tell apart.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
#[allow(clippy::type_complexity)] // one query, the four facets of a unit's presentation
|
||||
pub struct UnitRenderAlpha<'w, 's> {
|
||||
time: Res<'w, Time>,
|
||||
viewer: Res<'w, crate::view::Viewer>,
|
||||
units: Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
Option<&'static UnitAppearFade>,
|
||||
Option<&'static DespawnFade>,
|
||||
Option<&'static ModelFade>,
|
||||
Has<crate::world_unit::ViewerUnit>,
|
||||
),
|
||||
>,
|
||||
}
|
||||
|
||||
impl UnitRenderAlpha<'_, '_> {
|
||||
/// `unit`'s render alpha this frame. A unit with nothing in flight — and an entity this query
|
||||
/// cannot read at all — is `1.0`, so a settled world costs one lookup and no arithmetic.
|
||||
///
|
||||
/// The viewer's own body carries the zoom feather here exactly as the publisher applies it,
|
||||
/// so a caller never folds it in itself.
|
||||
pub fn get(&self, unit: Entity) -> f32 {
|
||||
let Ok((appear, despawn, declared, is_self)) = self.units.get(unit) else {
|
||||
return 1.0;
|
||||
};
|
||||
model_render_alpha(
|
||||
self.time.elapsed_secs(),
|
||||
appear.copied(),
|
||||
despawn.and_then(DespawnFade::armed),
|
||||
if is_self { self.viewer.self_fade } else { 1.0 },
|
||||
declared.map_or(1.0, |d| d.0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The door's own tests — the states a consumer that reconstructs this number gets wrong.
|
||||
#[cfg(test)]
|
||||
mod unit_render_alpha_tests {
|
||||
use super::*;
|
||||
use bevy::ecs::system::SystemState;
|
||||
|
||||
/// A world at `now`, holding one unit root built by `build`.
|
||||
fn unit_at(now: f32, build: impl FnOnce(&mut bevy::ecs::world::EntityWorldMut)) -> f32 {
|
||||
let mut world = World::new();
|
||||
let mut time = Time::<()>::default();
|
||||
time.advance_by(std::time::Duration::from_secs_f32(now));
|
||||
world.insert_resource(time);
|
||||
world.insert_resource(crate::view::Viewer::default());
|
||||
let mut unit = world.spawn_empty();
|
||||
build(&mut unit);
|
||||
let unit = unit.id();
|
||||
let mut state = SystemState::<UnitRenderAlpha>::new(&mut world);
|
||||
let alpha = state.get(&world).get(unit);
|
||||
alpha
|
||||
}
|
||||
|
||||
/// **The blob shadow's bug, as the door now answers it.** A streamed unit waiting on the
|
||||
/// world to be shown carries `UnitAppearFade::Pending` and no live `RenderFade` anywhere in
|
||||
/// its tree; a consumer that asks "is any part of this unit fading" hears no and draws itself
|
||||
/// opaque over an invisible creature. Asking the ROOT distinguishes the two.
|
||||
#[test]
|
||||
fn a_pending_unit_is_zero_and_a_settled_one_is_opaque() {
|
||||
assert_eq!(
|
||||
unit_at(10.0, |u| {
|
||||
u.insert(UnitAppearFade::Pending { since: 9.0 });
|
||||
}),
|
||||
0.0,
|
||||
"pending: not shown yet, so nothing that rides this number may show either"
|
||||
);
|
||||
assert_eq!(
|
||||
unit_at(11.0, |u| {
|
||||
u.insert(UnitAppearFade::Live { started: 10.0 });
|
||||
}),
|
||||
fade_alpha(0.0, 1.0, 0.5),
|
||||
"live: the same cubic the body's parts run"
|
||||
);
|
||||
assert_eq!(
|
||||
unit_at(10.0, |_| {}),
|
||||
1.0,
|
||||
"nothing in flight: opaque, and no arithmetic paid for it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The unarmed `DespawnFade` sentinel is the component's business, not each caller's — the
|
||||
/// negative `started` a stream-out stamps before it arms would otherwise read as a fade that
|
||||
/// began long ago and finished, i.e. a unit that vanishes the moment it is marked.
|
||||
#[test]
|
||||
fn an_unarmed_despawn_stamp_is_not_a_fade() {
|
||||
assert_eq!(DespawnFade::default().armed(), None);
|
||||
assert_eq!(DespawnFade { started: 4.0 }.armed(), Some(4.0));
|
||||
assert_eq!(
|
||||
unit_at(10.0, |u| {
|
||||
u.insert(DespawnFade::default());
|
||||
}),
|
||||
1.0,
|
||||
"marked but not armed: still fully there"
|
||||
);
|
||||
assert_eq!(
|
||||
unit_at(11.0, |u| {
|
||||
u.insert(DespawnFade { started: 10.0 });
|
||||
}),
|
||||
fade_alpha(1.0, 0.0, 0.5),
|
||||
"armed: the ramp down"
|
||||
);
|
||||
}
|
||||
|
||||
/// An entity the query cannot read at all — a shadow whose owner was despawned this frame,
|
||||
/// a booth part with no presentation — is opaque, never an accidental 0 that blinks the
|
||||
/// thing out on its last frame.
|
||||
#[test]
|
||||
fn an_unreadable_owner_is_opaque() {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(Time::<()>::default());
|
||||
world.insert_resource(crate::view::Viewer::default());
|
||||
let gone = world.spawn_empty().id();
|
||||
world.despawn(gone);
|
||||
let mut state = SystemState::<UnitRenderAlpha>::new(&mut world);
|
||||
assert_eq!(state.get(&world).get(gone), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish [`ModelAlpha`] on every streamed object each frame. Runs in `PostUpdate`, after every
|
||||
/// Update-side fade writer and the camera controller that computes the self feather, and before the
|
||||
/// effect sims that consume it ([`crate::particles`], [`crate::ribbons`]).
|
||||
@@ -353,7 +493,7 @@ pub(crate) fn publish_model_alpha(
|
||||
let alpha = model_render_alpha(
|
||||
now,
|
||||
appear.copied(),
|
||||
despawn.map(|d| d.started).filter(|s| *s >= 0.0),
|
||||
despawn.and_then(DespawnFade::armed),
|
||||
if is_self { viewer.self_fade } else { 1.0 },
|
||||
// The game's declared fade — the aura ramp ticks it in Update (`apply_aura_alpha`)
|
||||
// and this runs PostUpdate, so it is fresh. `self_fade` above is the bare zoom
|
||||
@@ -786,6 +926,17 @@ impl Default for DespawnFade {
|
||||
}
|
||||
}
|
||||
|
||||
impl DespawnFade {
|
||||
/// The instant this fade-out was armed, or `None` while the stamp is still the unarmed
|
||||
/// sentinel — the shape [`model_render_alpha`] takes its `despawn_started` in.
|
||||
///
|
||||
/// The sentinel is a negative `started`, and every caller that read the field had to know
|
||||
/// that; asking here instead keeps the encoding inside the component that chose it.
|
||||
pub fn armed(&self) -> Option<f32> {
|
||||
(self.started >= 0.0).then_some(self.started)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the despawn fade-out. On first sight, arm a `{from: 1, to: 0}` [`RenderFade`] on **every
|
||||
/// fadeable descendant** (reusing the appear machinery — same cubic curve, material swap, classifier
|
||||
/// yield) and stamp the start; once [`APPEAR_FADE_SECS`] elapses, despawn the entity (children cascade).
|
||||
|
||||
@@ -1139,8 +1139,17 @@ pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<ModelMaterials>()
|
||||
.add_systems(Update, (scope_model_materials, evict_model_materials));
|
||||
// Deferred realization (`lazy`): a built material becomes an asset the frame something
|
||||
// visible binds it. `Last`, after every writer and the `PostUpdate` twin spawns.
|
||||
app.add_systems(Last, lazy::realize_bound);
|
||||
// visible binds it. The top of `PostUpdate` — `Assets::insert` only QUEUES its
|
||||
// `AssetEvent::Added`, so the sweep has to land before the store publishes
|
||||
// (`AssetEventSystems`) or the bound entity loses a frame of drawing, and before
|
||||
// `VisibilityPropagate` clears the view-visibility bits it reads. Both bounds and why they
|
||||
// are the only two available: `lazy`'s module doc.
|
||||
app.add_systems(
|
||||
PostUpdate,
|
||||
lazy::realize_bound
|
||||
.before(bevy::asset::AssetEventSystems)
|
||||
.before(bevy::camera::visibility::VisibilitySystems::VisibilityPropagate),
|
||||
);
|
||||
// Parking (`park`): a long-hidden streamed part puts its `Mesh3d` down. `Update`, after the
|
||||
// authority, on last frame's propagated verdict — the order the module doc explains.
|
||||
if park::enabled() {
|
||||
|
||||
@@ -13,15 +13,43 @@
|
||||
//! depth-prime twin — and the variants a batch never switches to sat in the store forever.
|
||||
//!
|
||||
//! The mechanism: the builder reserves a handle (`Assets::reserve_handle`) and parks the built
|
||||
//! value here, keyed by the reserved id. [`realize_bound`], in `Last`, walks the bound handles
|
||||
//! and inserts the asset for every one whose entity is view-visible this frame — before
|
||||
//! extraction, so the first drawn frame is the frame it was bound. A handle every holder has
|
||||
//! value here, keyed by the reserved id. [`realize_bound`] walks the bound handles and inserts
|
||||
//! the asset for every one whose entity is view-visible this frame. A handle every holder has
|
||||
//! dropped arrives as the store's own `AssetEvent::Unused` (it fires for a reserved id too) and
|
||||
//! the parked value goes with it. Nothing about the handles changes: a part still stores and
|
||||
//! swaps `Handle<WowModelMaterial>`s, and every reader that clones a material *before* binding
|
||||
//! it — the portrait booth's relight twins, a spell kit's per-instance copy, the pipeline
|
||||
//! warmer — calls [`realize`] first, which is a no-op for an asset that already exists.
|
||||
//!
|
||||
//! **The deadline is the store's event PUBLISH, not extraction.** `Assets::insert` does not
|
||||
//! announce anything: it pushes an `AssetEvent::Added` onto the store's private queue, and
|
||||
//! `Assets::<A>::asset_events` — scheduled by bevy_asset in **`PostUpdate`** — is what writes
|
||||
//! that queue into the message stream `extract_render_asset` reads. A sweep placed after it
|
||||
//! (this module's original `Last`) therefore realizes an asset the render world is not told
|
||||
//! about until the NEXT frame's publish, and `queue_material_meshes` skips every entity whose
|
||||
//! material is missing from `RenderAssets` — one frame of the object not drawn. That is
|
||||
//! invisible for a first bind (nothing was on screen to lose) and plainly visible for a
|
||||
//! **swap onto an entity that is already drawing**: the appear fade's blend→cutout latch, where
|
||||
//! a freshly logged-in NPC blinked out for a frame at the instant it finished fading in, and
|
||||
//! the doodad distance fade's latch and the far-side water twin behind it. Pinned by
|
||||
//! [`tests::the_sweep_publishes_its_added_in_the_frame_it_realizes`].
|
||||
//!
|
||||
//! **So the sweep is the top of `PostUpdate`, and the visibility it reads is last frame's** —
|
||||
//! not a preference, the only placement bevy's own graph leaves. `AssetEventSystems` is ordered
|
||||
//! *before* `CalculateBounds`, which is before `CheckVisibility` (bevy_camera's
|
||||
//! `VisibilityPlugin`), so "after this frame's verdict" and "before the publish" is a cycle; and
|
||||
//! `reset_view_visibility` (in `VisibilityPropagate`, also before `CheckVisibility`) clears every
|
||||
//! current bit, so a sweep between the two would read `false` for the whole world. Before both,
|
||||
//! `ViewVisibility::get` is still the previous frame's answer — which is exactly the right
|
||||
//! question here: the entity this exists to keep drawing is the one that WAS drawing and had its
|
||||
//! material swapped under it in `Update`.
|
||||
//!
|
||||
//! A binding whose entity was not visible last frame — a fresh spawn, an unparked part, the
|
||||
//! depth-prime twin `zfill::sync_zfill_twins` spawns in `PostUpdate` — realizes on the next
|
||||
//! sweep and draws on its second frame. That is what the `Last` placement did too (a
|
||||
//! just-spawned entity's material was realized on its spawn frame and published on the next),
|
||||
//! so nothing pays more than before, and everything that swaps pays a frame less.
|
||||
//!
|
||||
//! **The table is a process global, deliberately.** It is a side of `Assets<WowModelMaterial>`
|
||||
//! — "reserved, value parked" — that the asset store has no slot for, and the builder that
|
||||
//! needs it ([`super::model_material`]) is a free function with a two-dozen-argument signature
|
||||
@@ -223,9 +251,11 @@ pub fn pending_len() -> usize {
|
||||
with_pending(|p| p.len())
|
||||
}
|
||||
|
||||
/// `Last`: realize the material of every bound, view-visible entity, and drop the parked value
|
||||
/// of every handle the store reports unused. After `PostUpdate` so a twin spawned there (the
|
||||
/// depth-prime lane) is bound and visible in the same walk; before extraction by construction.
|
||||
/// The top of `PostUpdate`: realize the material of every bound entity that was view-visible on
|
||||
/// the last frame's verdict, and drop the parked value of every handle the store reports unused.
|
||||
/// Before `AssetEventSystems` (so the `Added` this insert queues is published in time for this
|
||||
/// frame's extraction) and before `VisibilityPropagate` (whose `reset_view_visibility` clears the
|
||||
/// bits this reads) — see the module doc's deadline paragraph for why those are the only bounds.
|
||||
pub(super) fn realize_bound(
|
||||
bound: Query<(&MeshMaterial3d<WowModelMaterial>, Option<&ViewVisibility>)>,
|
||||
mut materials: ResMut<Assets<WowModelMaterial>>,
|
||||
@@ -305,6 +335,102 @@ mod tests {
|
||||
assert!(parked.value_mut(&mut store, stray.id()).is_none());
|
||||
}
|
||||
|
||||
/// **The deadline is the publish, not the insert** — the law the `Last` placement broke.
|
||||
/// `Assets::insert` only queues its `AssetEvent::Added`; `Assets::<A>::asset_events`
|
||||
/// (bevy_asset, `PostUpdate`) is what writes it into the message stream the render world
|
||||
/// extracts from. A sweep that realizes AFTER that system announces nothing this frame, so
|
||||
/// `extract_render_asset` never extracts the material and `queue_material_meshes` skips every
|
||||
/// entity bound to it — one frame not drawn, seen as a blink the instant an appear fade
|
||||
/// latched from its blend twin onto its steady material.
|
||||
///
|
||||
/// The reader stands in for extraction: `ExtractSchedule` runs immediately after `Last` and
|
||||
/// reads the same message buffer, so "visible to a `Last` reader" is exactly "visible to
|
||||
/// extraction".
|
||||
#[test]
|
||||
fn the_sweep_publishes_its_added_in_the_frame_it_realizes() {
|
||||
use bevy::asset::{AssetEvent, AssetEventSystems, AssetPlugin};
|
||||
use bevy::camera::visibility::VisibilitySystems;
|
||||
|
||||
#[derive(Resource)]
|
||||
struct Table(Parked<Stub>);
|
||||
#[derive(Resource)]
|
||||
struct Reserved(Handle<Stub>);
|
||||
/// Frames on which a reader standing where extraction stands saw the `Added`.
|
||||
#[derive(Resource, Default)]
|
||||
struct Seen(Vec<u32>);
|
||||
#[derive(Resource, Default)]
|
||||
struct Frame(u32);
|
||||
|
||||
fn realize_reserved(
|
||||
mut table: ResMut<Table>,
|
||||
mut store: ResMut<Assets<Stub>>,
|
||||
reserved: Res<Reserved>,
|
||||
) {
|
||||
table.0.realize(&mut store, reserved.0.id());
|
||||
}
|
||||
|
||||
fn extraction_stand_in(
|
||||
mut events: MessageReader<AssetEvent<Stub>>,
|
||||
mut seen: ResMut<Seen>,
|
||||
frame: Res<Frame>,
|
||||
) {
|
||||
if events.read().any(|e| matches!(e, AssetEvent::Added { .. })) {
|
||||
seen.0.push(frame.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(mut frame: ResMut<Frame>) {
|
||||
frame.0 += 1;
|
||||
}
|
||||
|
||||
/// An app whose realize sweep sits where `place` puts it; returns the frames on which
|
||||
/// extraction could see the `Added`.
|
||||
fn frames_added_was_visible(place: impl FnOnce(&mut App)) -> Vec<u32> {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
|
||||
.init_asset::<Stub>()
|
||||
.init_resource::<Seen>()
|
||||
.init_resource::<Frame>()
|
||||
.add_systems(First, tick);
|
||||
let mut table = Parked::default();
|
||||
let handle = {
|
||||
let store = app.world().resource::<Assets<Stub>>();
|
||||
table.defer(store, Stub(1))
|
||||
};
|
||||
app.insert_resource(Table(table))
|
||||
.insert_resource(Reserved(handle));
|
||||
place(&mut app);
|
||||
// Ordered last in `Last`, i.e. the final read before `ExtractSchedule`.
|
||||
app.add_systems(Last, extraction_stand_in);
|
||||
app.update();
|
||||
app.update();
|
||||
app.world().resource::<Seen>().0.clone()
|
||||
}
|
||||
|
||||
// The shipped placement: realized in `Last`, published by the NEXT frame's PostUpdate.
|
||||
assert_eq!(
|
||||
frames_added_was_visible(|app| {
|
||||
app.add_systems(Last, realize_reserved.before(extraction_stand_in));
|
||||
}),
|
||||
vec![2],
|
||||
"a `Last` realize is announced a frame late — the blink"
|
||||
);
|
||||
|
||||
// Where it lives now: the top of `PostUpdate`, ahead of the publish.
|
||||
assert_eq!(
|
||||
frames_added_was_visible(|app| {
|
||||
app.add_systems(
|
||||
PostUpdate,
|
||||
realize_reserved
|
||||
.before(AssetEventSystems)
|
||||
.before(VisibilitySystems::VisibilityPropagate),
|
||||
);
|
||||
}),
|
||||
vec![1],
|
||||
"the sweep's own frame must carry the `Added`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_and_visible_realizes_in_the_walk_hidden_does_not() {
|
||||
let mut store = Assets::<Stub>::default();
|
||||
|
||||
@@ -18,22 +18,57 @@ use super::{
|
||||
WORD_SHADE_LIT, WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X, WORD_WRAP_Y,
|
||||
};
|
||||
|
||||
/// The declined-batch census (`declined`), printed once a streaming burst SETTLES rather than on
|
||||
/// every count change.
|
||||
///
|
||||
/// Two things were wrong with printing on change. It fires from a `PostUpdate` system that runs
|
||||
/// every frame, so a zone streaming in emitted a near-identical INFO line per frame — a Goldshire
|
||||
/// login produced ten, climbing 8 → 1630, which reads like a fault rather than a residency count.
|
||||
/// And the line named only refusals, so it could not be told apart from a dead pass; `accepted` is
|
||||
/// now beside them (at Goldshire the refusals sit against a five-digit accepted population, which
|
||||
/// is the fact the line was missing).
|
||||
///
|
||||
/// The grain is the honest one for a running total: hold until the counts have not moved for
|
||||
/// [`CENSUS_SETTLE_FRAMES`], then print the total once — with [`CENSUS_MAX_HOLD_FRAMES`] as the
|
||||
/// ceiling, so a stream that never goes quiet (a long flight path) still reports instead of
|
||||
/// trading one silence for another.
|
||||
fn census_declines(gx: &mut StaticGx, frame: u32) {
|
||||
if gx.declined == gx.declined_logged {
|
||||
return;
|
||||
}
|
||||
if gx.declined != gx.declined_seen {
|
||||
gx.declined_seen = gx.declined;
|
||||
gx.declined_changed = frame;
|
||||
}
|
||||
let settled = frame.wrapping_sub(gx.declined_changed) >= CENSUS_SETTLE_FRAMES;
|
||||
let overdue = frame.wrapping_sub(gx.declined_printed) >= CENSUS_MAX_HOLD_FRAMES;
|
||||
if !settled && !overdue {
|
||||
return;
|
||||
}
|
||||
gx.declined_printed = frame;
|
||||
let d = gx.declined;
|
||||
info!(
|
||||
"static-gx: {} batch(es) accepted; declined — env-map {}, depth-flag {}, shade-family {}, \
|
||||
prop-fader {} (batches), prop-no-instance {} (props)",
|
||||
gx.accepted, d[0], d[1], d[2], d[3], d[4]
|
||||
);
|
||||
gx.declined_logged = d;
|
||||
}
|
||||
|
||||
/// How long the decline counts must sit still before the census prints — ~1 s at 60 fps, which is
|
||||
/// shorter than a streaming burst and longer than the gaps inside one.
|
||||
const CENSUS_SETTLE_FRAMES: u32 = 60;
|
||||
|
||||
/// The ceiling on that hold — ~10 s at 60 fps. Counts that never settle (a flight path streaming
|
||||
/// continuously) report on this instead, so the throttle can never turn the census silent.
|
||||
const CENSUS_MAX_HOLD_FRAMES: u32 = 600;
|
||||
|
||||
/// Bake dirty-and-quiet cells into retained draw data; publish into [`render::GxWorld`].
|
||||
pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Assets<Mesh>>) {
|
||||
let _t = super::gx_perf_guard(0);
|
||||
gx.frame = gx.frame.wrapping_add(1);
|
||||
let frame = gx.frame;
|
||||
for i in 0..5 {
|
||||
if gx.declined[i] != gx.declined_logged[i] {
|
||||
let d = gx.declined;
|
||||
info!(
|
||||
"static-gx: declined so far — env-map {}, depth-flag {}, shade-family {}, \
|
||||
prop-fader {} (batches), prop-no-instance {} (props)",
|
||||
d[0], d[1], d[2], d[3], d[4]
|
||||
);
|
||||
gx.declined_logged = d;
|
||||
}
|
||||
}
|
||||
census_declines(&mut gx, frame);
|
||||
// The reveal gate's "publish what you have" (see [`StaticGx::flush_now`]): consumed here, so
|
||||
// one request bakes one flush's worth of dirty regions and the windows resume next frame.
|
||||
let now = std::mem::take(&mut gx.flush_now);
|
||||
@@ -352,6 +387,59 @@ mod tests {
|
||||
use benilla_formats::{ModelBlend, RenderSubmesh, WmoBatchClass};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// **The census reports once per burst, and cannot be starved into silence.** It used to
|
||||
/// print on any count change from a system that runs every frame, so a Goldshire login
|
||||
/// emitted ten near-identical INFO lines climbing 8 → 1630 — a residency count that read as
|
||||
/// a fault. Three things are pinned here: a moving count stays quiet, a settled one prints
|
||||
/// exactly once, and a count that never settles still reports on the ceiling.
|
||||
#[test]
|
||||
fn the_decline_census_prints_once_a_burst_settles() {
|
||||
let mut gx = StaticGx::default();
|
||||
let mut frame = 0u32;
|
||||
let tick = |gx: &mut StaticGx, frame: &mut u32| {
|
||||
*frame += 1;
|
||||
census_declines(gx, *frame);
|
||||
};
|
||||
// A burst: the count moves every frame, and nothing is reported while it does.
|
||||
for _ in 0..10 {
|
||||
gx.declined[3] += 1;
|
||||
tick(&mut gx, &mut frame);
|
||||
}
|
||||
assert_eq!(gx.declined_logged, [0; 5], "a moving count stays quiet");
|
||||
// It settles: after the window, the total lands once and stays landed.
|
||||
for _ in 0..CENSUS_SETTLE_FRAMES {
|
||||
tick(&mut gx, &mut frame);
|
||||
}
|
||||
assert_eq!(gx.declined_logged[3], 10, "the settled total reports");
|
||||
let logged = gx.declined_logged;
|
||||
for _ in 0..CENSUS_SETTLE_FRAMES * 2 {
|
||||
tick(&mut gx, &mut frame);
|
||||
}
|
||||
assert_eq!(gx.declined_logged, logged, "…once, not every frame after");
|
||||
// A stream that never goes quiet reports on the ceiling instead of never.
|
||||
for _ in 0..CENSUS_MAX_HOLD_FRAMES + 1 {
|
||||
gx.declined[3] += 1;
|
||||
tick(&mut gx, &mut frame);
|
||||
}
|
||||
assert!(
|
||||
gx.declined_logged[3] > 10,
|
||||
"a count that never settles still reports"
|
||||
);
|
||||
}
|
||||
|
||||
/// The census counts THIS world's population: a map change resets it, or the line reports the
|
||||
/// sum of two worlds and reads as a leak (`static_merge::reset`'s own rationale).
|
||||
#[test]
|
||||
fn a_map_change_resets_the_census() {
|
||||
let mut gx = StaticGx::default();
|
||||
let g = tri([0.0; 3]);
|
||||
assert!(gx.divert(batch(&g, Vec3::ZERO, None, ModelBlend::Opaque)));
|
||||
gx.tally_prop_declined(false);
|
||||
assert_eq!((gx.accepted, gx.declined[3]), (1, 1));
|
||||
gx.clear();
|
||||
assert_eq!((gx.accepted, gx.declined[3]), (0, 0), "a fresh world");
|
||||
}
|
||||
|
||||
/// The bake cadence (B3, decision 1432): first bakes wait only the short window, re-bakes
|
||||
/// of a published region wait for the long one, and the age cap consolidates a
|
||||
/// never-quiet region regardless.
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
//! `faders=`/`fade_events=`.
|
||||
//!
|
||||
//! Exclusions the collector refuses (each falls through to the ordinary merge/entity path, and
|
||||
//! the flush logs a one-line census so a declined population is never silent): env-mapped
|
||||
//! the flush logs a one-line census — refusals beside the ACCEPTED count, once a streaming burst
|
||||
//! settles — so a declined population is never silent, and never mistakable for a dead pass in
|
||||
//! the other direction either): env-mapped
|
||||
//! batches (`texture_unit_lookup > 2` — view-generated UVs), the depth-flag oddities
|
||||
//! (`no_depth_write`/`no_depth_test` — pipeline-state per batch), and the `ShadeSel::Matte`/
|
||||
//! `Rig` families (exterior WMO MODD props ride the prop site, not this lane).
|
||||
@@ -443,10 +445,24 @@ pub struct StaticGx {
|
||||
pub(crate) world: render::GxWorld,
|
||||
frame: u32,
|
||||
/// Declined-batch census (env-map / depth-flag / shade-family / prop-fader /
|
||||
/// prop-without-instance refusals), logged once per count change so a silently-thinner
|
||||
/// population can't masquerade as covered (1429's no-silent-caps note).
|
||||
/// prop-without-instance refusals), logged once a streaming burst settles so a
|
||||
/// silently-thinner population can't masquerade as covered (1429's no-silent-caps note).
|
||||
declined: [u32; 5],
|
||||
declined_logged: [u32; 5],
|
||||
/// The census's last-seen counts, the frame they last MOVED, and the frame it last PRINTED —
|
||||
/// the settle timer that turns a login's per-frame line storm into one line per burst. Before
|
||||
/// it, the census fired on ANY count change from a `PostUpdate` system that runs every frame,
|
||||
/// so streaming in a zone emitted a near-identical INFO line per frame (ten of them in one
|
||||
/// Goldshire login). The two frame stamps are distinct on purpose: the settle window measures
|
||||
/// from the last change, the ceiling from the last line, or a count that moves every frame
|
||||
/// resets its own deadline forever and never reports.
|
||||
declined_seen: [u32; 5],
|
||||
declined_changed: u32,
|
||||
declined_printed: u32,
|
||||
/// Batches the divert ACCEPTED, against which the refusals above are read. Without it the
|
||||
/// census line cannot distinguish "1,630 declined out of ~25,000" from "the pass is dead" —
|
||||
/// which is exactly the reading it forced when a log was triaged on 2026-09-06.
|
||||
accepted: u32,
|
||||
/// Exiled entities whose seed died out from under them (owner release, map clear) —
|
||||
/// drained and despawned by the scan, which owns the exile lifecycle end to end.
|
||||
pending_despawn: Vec<Entity>,
|
||||
@@ -799,6 +815,7 @@ impl StaticGx {
|
||||
}
|
||||
entry.dirty = true;
|
||||
entry.last_change = self.frame;
|
||||
self.accepted += 1;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -891,6 +908,15 @@ impl StaticGx {
|
||||
self.world.props.clear();
|
||||
self.world.visible.clear();
|
||||
self.world.visible_wmos.clear();
|
||||
// The census counts THIS world's population. `static_merge::reset` exists for exactly
|
||||
// this reason — a cumulative counter carried across a map change reports the sum of two
|
||||
// worlds and reads as a leak.
|
||||
self.declined = [0; 5];
|
||||
self.declined_logged = [0; 5];
|
||||
self.declined_seen = [0; 5];
|
||||
self.declined_changed = 0;
|
||||
self.declined_printed = 0;
|
||||
self.accepted = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user