Files
benilla-octo/crates/benilla-app/src/ui_mirror.rs
T
SamandClaude Fable 5.1 aa207b4050 settings live in one registry, and the net drain starts to peel
- the CVars are one engine-side table that outlives every interface reload: a change fires its observer, the eight latched rows wait for RestartGx, and /console gains set, cvar_reset, cvar_default, cvarlist and help (2303)
- every push into the interface runs in one feed phase after the net drain and before the tick, held by a structural test on the built schedule (2304)
- subsystems own their packet handlers, dispatched in packet order; the auction house is the first family out of the 273-arm drain (2305)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:47:09 +07:00

241 lines
12 KiB
Rust

//! The mirror-timer feed: breath / fatigue / feign-death off the wire → FrameXML events
//! (decision 0874).
//!
//! The net bridge queues [`MirrorTimerEdge`]s and the drain fires the reference client's
//! FrameScript events into the script VM — `MIRROR_TIMER_START` / `_PAUSE` / `_STOP`, the exact
//! contract stock `Interface\FrameXML\MirrorTimer.xml` (`MirrorTimer1/2/3`) registers for.
//! The bars themselves are the reference's: the frame stores the value and integrates
//! `value + scale * elapsed` every OnUpdate, so a packet every few seconds is enough to paint a
//! smooth countdown.
//!
//! **The client computes nothing here.** Breath and fatigue are server state — vmangos's
//! `Player::UpdateMirrorTimers` runs them off its own liquid checks (`IsUnderwater`,
//! `IsInHighSea`) and ships a value + a signed rate. That is why there is no local "am I
//! underwater" predicate in this module and should not be one: a second, disagreeing authority
//! is exactly how a bar ends up drifting from the drowning damage that follows it.
//!
//! The `ui_cast::CastBarFeed` pattern throughout — one queue, one drain, no per-frame work.
use benilla_ui::script::{ScriptValue, UiScript};
use bevy::prelude::*;
use benilla_protocol::messages::{MirrorTimerKind, MirrorTimerStart};
use crate::ui_action::Spells;
use crate::ui_unit::UnitFeed;
/// One mirror-timer edge off the wire, queued by the net bridge for the bars.
#[derive(Debug, Clone, Copy)]
pub(crate) enum MirrorTimerEdge {
/// `SMSG_START_MIRROR_TIMER` — start, or wholly re-state, one timer. The server re-sends this
/// on every change (direction, remaining, frozen), so it arrives repeatedly for one bar.
Start(MirrorTimerStart),
/// `SMSG_PAUSE_MIRROR_TIMER` — freeze/unfreeze. vmangos never sends it (it substitutes a full
/// `Start`), but a server that does must not be ignored.
Pause { kind: u32, paused: bool },
/// `SMSG_STOP_MIRROR_TIMER` — that timer is over; its bar hides.
Stop { kind: u32 },
}
/// The net bridge's mirror-timer queue (the [`crate::ui_cast::CastBarFeed`] pattern).
#[derive(Resource, Default)]
pub(crate) struct MirrorTimerFeed(pub(crate) Vec<MirrorTimerEdge>);
/// The FrameScript name the reference passes as **arg1** for each timer type — the key its
/// `MirrorTimerColors` table is indexed by, and the stem of the caption lookup below.
///
/// The client holds these as a 3-entry table indexed by the wire's `timerType`
/// (`WoW.exe`: `"EXHAUSTION"` @`0x460520`, `"BREATH"` @`0x46052c`, `"FEIGNDEATH"` @`0x460534`,
/// contiguous and in the server's `MirrorTimer::Type` order). Note the type-0 name is
/// `EXHAUSTION`, not the server's own word for it (`FATIGUE`) — the two ends disagree on the
/// name of the same timer, and it is the *client's* word that the Lua is keyed by.
fn script_name(kind: MirrorTimerKind) -> &'static str {
match kind {
MirrorTimerKind::Fatigue => "EXHAUSTION",
MirrorTimerKind::Breath => "BREATH",
MirrorTimerKind::FeignDeath => "FEIGNDEATH",
}
}
/// The static caption for a timer with **no owning spell** — the `GlobalStrings.lua` value the
/// reference's `<NAME>_LABEL` lookup resolves to.
///
/// The 1.12 `GlobalStrings.lua` defines exactly two of the three: `BREATH_LABEL = "Breath"` and
/// `EXHAUSTION_LABEL = "Fatigue"` (each commented "Used as the label for the … status bar"), and
/// **no** `FEIGNDEATH_LABEL`. The reference's `GetGlobalString 0x703bf0` never returns NULL — it
/// pre-seeds a static empty string (`0x882748`) — so the missing one is the **empty caption**, not
/// a nil the Lua would trip over.
///
/// Inlined here rather than looked up through the VM for the same reason `CastingBar.xml` inlines
/// `FAILED`/`INTERRUPTED`: benilla loads no `GlobalStrings.lua` yet.
fn global_string_label(kind: MirrorTimerKind) -> &'static str {
match kind {
MirrorTimerKind::Fatigue => "Fatigue",
MirrorTimerKind::Breath => "Breath",
MirrorTimerKind::FeignDeath => "",
}
}
/// The bar's caption — **arg6** of `MIRROR_TIMER_START`, and it is **not** a fixed word.
///
/// §5-VERIFIED (wow-re `object-layer/scratch/mirror-timer.md`, the 2026-08-02 cross-check of
/// handler `0x5e7990` and its label helper `0x5e7b10`): the client tries the **owning spell's
/// localized name first** — `Spell.dbc` `SpellRec + 0x1e0 + 4*locale`, indexed by the START
/// packet's `spellId` — and only falls back to the `"<NAME>_LABEL"` global string when there is
/// no spell (`spellId == 0`).
///
/// That correction matters in play, and 0874 had it wrong: a water-breathing effect owns the
/// breath timer for its duration (vmangos `UpdateMirrorTimers` starts the timer from
/// `GetMirrorTimerBuff(type)` and passes `buff->GetId()`), so while it is up the bar is captioned
/// with **the spell's own name**, not "Breath". The bar's colour still keys off the timer name,
/// so only the word changes.
///
/// `spell_name` is the already-resolved catalog lookup (the `ui_cast` idiom: the script VM has no
/// spell-catalog binding, so the drain resolves it — one lookup face, decision 0107). `None`
/// covers both "no owning spell" and "spell not in the catalog"; the reference's fallback chain
/// ends at the global string either way.
fn caption(kind: MirrorTimerKind, spell_name: Option<&str>) -> String {
spell_name
.filter(|n| !n.is_empty())
.unwrap_or_else(|| global_string_label(kind))
.to_string()
}
/// Drain the queue into the script VM, one FrameScript event per edge.
///
/// A `kind` the client has no bar for is **dropped**. The reference does fire the event for one —
/// its type→name switch (`0x5e7ae0`) answers `"UNKNOWN"` for anything outside 0..2 — but
/// `MirrorTimerColors["UNKNOWN"]` is nil, so `MirrorTimer_Show` errors at the colour read
/// *before* `dialog:Show()` and no bar ever appears. Dropping it is the same observable without
/// the Lua error, and vanilla never sends one anyway (the server's `NUM_CLIENT_TIMERS` gate keeps
/// its fourth timer, `ENVIRONMENTAL`, off the wire entirely).
fn feed_mirror_timers(
script: Option<NonSendMut<UiScript>>,
mut feed: ResMut<MirrorTimerFeed>,
spells: Option<Res<Spells>>,
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
) {
let Some(mut script) = script else {
// No VM (a capture/headless run): drop the edges rather than let them pile up unbounded.
feed.0.clear();
return;
};
// The owning spell's name, resolved here because the script VM has no spell-catalog binding
// (the `ui_cast` idiom, decision 0107). `0` = no spell, which is the common case.
let spell_name = |id: u32| -> Option<String> {
(id != 0)
.then(|| spells.as_ref()?.catalog.get(id).map(|d| d.name.clone()))
.flatten()
};
for edge in feed.0.drain(..) {
let raw = match edge {
MirrorTimerEdge::Start(start) => start.kind,
MirrorTimerEdge::Pause { kind, .. } | MirrorTimerEdge::Stop { kind } => kind,
};
let Some(kind) = MirrorTimerKind::from_wire(raw) else {
continue;
};
// The handler's two tutorial arms (`0x5e7ab0` type 0, `0x5e7acd` type 1; 1976).
if matches!(edge, MirrorTimerEdge::Start(_)) {
match kind {
MirrorTimerKind::Fatigue => {
if let Some(t) = tutorials.as_mut() {
t.write(crate::tutorial::TutorialEvent::trigger(
crate::tutorial::id::FATIGUE,
));
}
}
MirrorTimerKind::Breath => {
if let Some(t) = tutorials.as_mut() {
t.write(crate::tutorial::TutorialEvent::trigger(
crate::tutorial::id::BREATH,
));
}
}
_ => {}
}
}
let name = ScriptValue::Str(script_name(kind).into());
let (event, args): (&str, Vec<ScriptValue>) = match edge {
MirrorTimerEdge::Start(start) => (
"MIRROR_TIMER_START",
vec![
name,
ScriptValue::Int(i64::from(start.remaining_ms)),
ScriptValue::Int(i64::from(start.duration_ms)),
ScriptValue::Int(i64::from(start.scale)),
ScriptValue::Int(i64::from(start.paused)),
ScriptValue::Str(caption(kind, spell_name(start.spell_id).as_deref())),
],
),
// **This edge reaches a reference body that raises**, and it is left that way on
// purpose (1751 window 7). `MirrorTimerFrame_OnEvent` reads `arg1` as the timer name
// and then as a number (`arg1 > 0`), comparing a string to a number — 1.12's own bug.
// vmangos never sends the packet: it substitutes a full START and says so in
// `Player::SendMirrorTimers`, which is presumably how the bug survived. If a server
// ever does send one, the raise is the correct loud failure (1203) and the repair
// belongs in an adapter over the reference's body, not in a second vocabulary here.
MirrorTimerEdge::Pause { paused, .. } => (
"MIRROR_TIMER_PAUSE",
vec![name, ScriptValue::Int(i64::from(paused))],
),
MirrorTimerEdge::Stop { .. } => ("MIRROR_TIMER_STOP", vec![name]),
};
// One line per edge. The bars live inside the script VM, so from outside it a mirror
// timer is otherwise unobservable — and "did the server start a breath timer *here*?" is
// the first question of any drowning / fatigue / liquid-hazard probe.
debug!("net: mirror timer {event} {edge:?}");
script.fire_event(event, args);
}
}
/// The mirror-timer UI seam: the queue + its drain, ordered like the cast bar's — before the VM
/// ticks, so an edge and its first OnUpdate land on the same frame.
pub(crate) struct UiMirrorPlugin;
impl Plugin for UiMirrorPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MirrorTimerFeed>()
.add_systems(Update, feed_mirror_timers.in_set(UnitFeed));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The client's arg1 names, in the server's `MirrorTimer::Type` order — and the fact that
/// type 0 is `EXHAUSTION` on the client but `FATIGUE` on the server. Getting this wrong is
/// silent: `MirrorTimerColors[timer]` would be nil and the bar's colour read would error.
#[test]
fn arg1_is_the_clients_name_not_the_servers() {
assert_eq!(script_name(MirrorTimerKind::Fatigue), "EXHAUSTION");
assert_eq!(script_name(MirrorTimerKind::Breath), "BREATH");
assert_eq!(script_name(MirrorTimerKind::FeignDeath), "FEIGNDEATH");
}
/// With no owning spell the caption is the 1.12 `GlobalStrings.lua` value the `<NAME>_LABEL`
/// lookup resolves to — and the feign-death one is empty because that global does not exist
/// (the reference's `GetGlobalString` hands back a static empty string, never nil).
#[test]
fn a_spell_less_timer_captions_from_the_global_string() {
assert_eq!(caption(MirrorTimerKind::Fatigue, None), "Fatigue");
assert_eq!(caption(MirrorTimerKind::Breath, None), "Breath");
assert_eq!(caption(MirrorTimerKind::FeignDeath, None), "");
}
/// The correction 0874 got wrong (§5, wow-re `mirror-timer.md`): the client tries the OWNING
/// SPELL's localized name first and only falls back to the global string. A water-breathing
/// effect owns the breath timer while it is up, so the bar reads with the spell's name.
#[test]
fn an_owning_spell_captions_the_bar_with_its_own_name() {
assert_eq!(
caption(MirrorTimerKind::Breath, Some("Water Breathing")),
"Water Breathing"
);
// An id the catalog can't resolve falls back exactly as a spell-less timer does.
assert_eq!(caption(MirrorTimerKind::Breath, None), "Breath");
assert_eq!(caption(MirrorTimerKind::Breath, Some("")), "Breath");
}
}