the Ironforge lava returns, and the sound crackle is cornered

- the Great Forge's lava stays drawn from every angle, and the chasm catwalk reads as a connected grate again
- music decoding and shader-compile bursts stop crowding the audio's lane, and the output buffer follows the device's real deadline
- the audio path grows its instruments: a mix tap to WAV, a starvation meter, a hardware overload listener
- trainer detail icons carry the reference's own tooltip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:03:52 +07:00
co-authored by Claude Fable 5
parent 50aedb8b05
commit ba9a333aeb
37 changed files with 2835 additions and 1118 deletions
Generated
+2
View File
@@ -610,8 +610,10 @@ dependencies = [
"objc2-foundation 0.2.2",
"rand 0.8.6",
"raw-window-handle",
"rtrb",
"serde",
"smithay-clipboard",
"symphonia",
"toml",
]
+10
View File
@@ -76,6 +76,16 @@ fontdb = "0.23"
# cpal types, so this must stay the exact version kira resolves — one cpal in the graph.
cpal = "0.17"
kira = { version = "0.12", default-features = false, features = ["cpal", "mp3", "wav", "pcm", "adpcm"] }
# kira's own decoder, named directly for one trait: the mixer feeds streams through its own
# `MediaSource` so kira's per-stream decode thread runs OUR code and can promote its QoS (decision
# 1109 — a bare-spawned decode thread starves under the world-entry burst and zero-fills the mix).
# No codecs of its own (kira's features enable those); must stay the version kira resolves — one
# symphonia in the graph, or the trait impl won't line up.
symphonia = { version = "0.5", default-features = false }
# The mix tap's audio-thread → writer-thread ring (decision 1112): the tap effect must never
# block or allocate on the render path. kira's own SPSC ring crate; kept on the version kira
# resolves so the graph carries one rtrb.
rtrb = "0.3"
mint = "0.5"
# The gossip greeting is a weighted random DRAW per menu-open, not a fixed pick (wow-re
# `gossip-npctext-law.md` — the reference re-rolls in its gossip handler), so the app needs a uniform
@@ -45,6 +45,7 @@
#import bevy_pbr::{
pbr_fragment::pbr_input_from_standard_material,
pbr_functions::alpha_discard,
pbr_bindings,
forward_io::{VertexOutput, FragmentOutput},
mesh_view_bindings::view,
mesh_functions,
@@ -545,14 +546,26 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
// horizon). Disabled (no-op) for normal models where `clutter_fade.w == 0`. This is a draw-distance
// concern, not lighting, so it survives the Phase-0 strip.
var base_color = pbr_input.material.base_color;
// Interior WMO batches carry LIGHTING data in the MOCV ALPHA — TRANS (tint.w == 2) the
// lit↔bake lerp factor, INT (tint.w == 1) the ×4 self-illumination mask. Bevy pre-folds
// ATTRIBUTE_COLOR (rgba) into base_color, so un-fold the alpha before any cutout test —
// coverage stays the texel alpha, exactly the reference's interior pixel shader
// (its output alpha is tex.a; MOCV.a never reaches coverage).
// WMO batches carry LIGHTING data in the MOCV ALPHA — TRANS (tint.w == 2) the lit↔bake lerp
// factor, INT (tint.w == 1) the ×4 self-illumination mask. Bevy pre-folds ATTRIBUTE_COLOR
// (rgba) into base_color, but coverage must stay the texel alpha, exactly the reference's
// WMO pixel path (its output alpha is tex.a; MOCV.a never reaches coverage —
// wow-re models.md). Re-sampling the texture here (same UV, same implicit derivatives — the
// texture cache makes it free) replaces the old divide-the-fold-back-out reconstruction,
// which was numerically annihilated where MOCV.a ≈ 0: `tex.a × ε ÷ max(ε, 1/255)` collapses
// to 0 at ε = 0 and to 4-bit rubble near it — B65's Great Forge chasm deck, whose
// self-illumination mask is authored 0, alpha-eroded into "missing floor" exactly there.
#ifdef VERTEX_COLORS
if (m.model_flags.x > 0.5 && m.tint.w > 0.5) {
base_color.a = base_color.a / max(in.color.a, 1.0 / 255.0);
if (m.model_flags.x > 0.5) {
#ifdef VERTEX_UVS_A
base_color.a = textureSample(
pbr_bindings::base_color_texture,
pbr_bindings::base_color_sampler,
vo.uv,
).a;
#else
base_color.a = 1.0;
#endif
}
#endif
// Ground-clutter distance fade: multiply the cutout alpha by the camera-distance ramp BEFORE the
+4 -1
View File
@@ -444,7 +444,10 @@
-- ── The detail icon (ref CraftIcon OnEnter, l.564-568 — the Ctrl/Shift dressing-room branches
-- dropped, deviation 3): SetCraftSpell, NOT SetCraftItem — Craft has no product-item concept
-- (craft.rs's own module doc). ───────────────────────────────────────────────────────────────
-- (craft.rs's own module doc). The binding behind it is a SELECTOR into the shared spell/item
-- builders (`SetCraftSpell 0x533e90`); a CREATE_ITEM recipe therefore renders the product's
-- full ITEM tooltip here even though the row's ICON is the spell's (Law D) — the two are
-- different laws on the same row, byte-verified. ─────────────────────────────────────────────
function BenillaCraftIcon_OnEnter(button)
if GetCraftSelectionIndex() ~= 0 then
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
+17 -1
View File
@@ -729,13 +729,29 @@
</Frame>
<!-- The detail spell icon (ref ClassTrainerSkillIcon): a 37×37 button over a 64×64 UI-EmptySlot
ring, at the detail area's top-left. Icon via SetNormalTexture; Hide()n when unselected. -->
ring, at the detail area's top-left. Icon via SetNormalTexture; Hide()n when unselected.
The hover is the ref's own, verbatim (Blizzard_TrainerUI.xml:452-459): owner is the icon
button, anchor ANCHOR_RIGHT, subject `ClassTrainerFrame.selectedService` — the SELECTED
row, which is why the tooltip describes the selection no matter which row the mouse
passed over on its way here. GetTrainerSelectionIndex() is our engine-held equivalent of
that field; it reads 0 with nothing selected, and SetTrainerService no-ops on 0. This is
the ONLY SetTrainerService call site in the shipped client — the list rows carry no
tooltip of their own (their template's OnEnter only recolours the subtext). -->
<Button name="BenillaTrainerSkillIcon" hidden="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="26" y="-291"/></Offset></Anchor></Anchors>
<Layers><Layer level="BACKGROUND">
<Texture file="Interface\Buttons\UI-EmptySlot"><Size><AbsDimension x="64" y="64"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="-13" y="13"/></Offset></Anchor></Anchors></Texture>
</Layer></Layers>
<Scripts>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetTrainerService(GetTrainerSelectionIndex())
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Button>
<!-- The detail cost coins (ref ClassTrainerDetailMoneyFrame), left-to-right off the "Cost:"
@@ -562,6 +562,7 @@ mod tests {
world_from_local: bevy::math::Affine3A::IDENTITY,
name_set: 0,
visible: vec![false, false],
liquid_visited: vec![false, false],
flooded: vec![None, None],
})
.id();
+3 -1
View File
@@ -114,7 +114,9 @@ impl Default for ModelDebug {
Self {
kind_visible: [true; 4],
blend_visible: [true; 5],
portal_cull: true,
// `WOW_NOPORTALCULL=1` presets the panel's A/B switch off, so a headless capture can
// shoot the same viewpoint with and without the cull (the B65 diff loop).
portal_cull: std::env::var("WOW_NOPORTALCULL").is_err(),
}
}
}
@@ -242,14 +242,21 @@ pub(super) fn apply_model_visibility(
},
);
// The group-only audience: portal cull and nothing else. Serial — a building has a handful of
// The group-only audience — a building's MLIQ surfaces. Serial: a building has a handful of
// liquid surfaces, not the ~100k submeshes above — and change-gated like every write here.
//
// The gate is the **ever-visited latch**, not this frame's PVS: the client's render-record
// persistence (wow-re `wmo-record-persistence.md` @`00a766f6`) draws a visited MLIQ group's
// liquid every frame with no portal re-check for the rest of the world session — the Great
// Forge's walkway-level pool stays put when its group drops out of the flood (B65). An index
// past the latch fails OPEN (portal-less props never latch and must always draw).
for (gv, mut vis, xf, aabb, exterior) in &mut group_only {
let portal_ok = !m.portal_cull
|| instances
.get(gv.instance)
.ok()
.is_none_or(|inst| gv.drawn_by(inst));
|| instances.get(gv.instance).ok().is_none_or(|inst| {
gv.groups
.iter()
.any(|&g| inst.liquid_visited.get(g as usize).copied().unwrap_or(true))
});
// …and the same exterior-window term as the submesh walk: another building's canal is
// exterior content, the canal of the building you are standing in is not.
let exterior_ok = !exterior || Some(gv.instance) == own_instance || gate.admits(xf, aabb);
@@ -549,6 +549,10 @@ fn spawn_model_rig(
Transform::from_xyz(0.0, 0.0, -0.5).with_scale(Vec3::splat(0.01)),
tag,
WarmRig,
// Hidden at spawn: a hidden rig is never extracted, so it queues no pipeline. The pass
// reveals rigs a slice at a time (`super::reveal_slice`) — that is what paces the
// compile burst instead of paying all 1480 in one blocking frame (decision 1116).
Visibility::Hidden,
ChildOf(cam),
));
if let Some(aabb) = aabb {
@@ -575,6 +579,8 @@ fn spawn_lane_rig<M: Material>(
MeshMaterial3d(mat),
Transform::from_xyz(0.0, 0.0, -0.5).with_scale(Vec3::splat(0.01)),
WarmRig,
// Hidden at spawn — revealed a slice at a time; see `spawn_model_rig`.
Visibility::Hidden,
ChildOf(cam),
));
if let Some(aabb) = aabb {
+137 -12
View File
@@ -13,6 +13,14 @@
//!
//! The pieces:
//!
//! **The burst is paced** (decision 1116). Compiling behind the cover fixed *where* the stall
//! lands, not its shape: all ~1480 variants became drawable in one frame, so one frame blocked
//! 1.0–2.3 s — the cover frozen solid, and CoreAudio missing a device cycle inside it (the
//! crackle of 1114/1115). Rigs now spawn hidden and are revealed [`WARM_REVEAL_PER_FRAME`] at a
//! time, each hidden again the frame after (its pipeline is compiled by then), so every frame's
//! synchronous batch is bounded and the machine gets gaps. Same pipelines, same order of
//! magnitude of work — verified byte-identical inventories via `WOW_PIPE_TRACE`.
//!
//! - [`WarmPass`] + `spawn_menagerie` — the warm pass: one tiny rig per reachable pipeline
//! variant — the model lane with its shard-rung and far-side twins, and the sky/water lanes
//! (celestial, stars, clouds, gradient dome, WMO skybox, liquid; decision 0945 widened 0837's
@@ -79,7 +87,7 @@ pub(crate) fn plugin(app: &mut App) {
});
app.insert_resource(PipeWatch(shared.clone()));
app.init_resource::<WarmPass>();
app.add_systems(Last, publish_cover);
app.add_systems(Last, (publish_cover, publish_compile_burst));
// Before the Present stage so the loading screen reads this frame's gate, not last frame's.
app.add_systems(
Update,
@@ -100,6 +108,14 @@ pub(crate) fn plugin(app: &mut App) {
render_app.add_systems(Render, watch_pipelines.in_set(RenderSystems::Cleanup));
}
/// Main world → render thread: the render thread is about to spend this window blocked inside
/// Metal pipeline compilation with nothing but a still cover on screen, so it drops out of the
/// frame-critical QoS band for the duration (decision 1117; the band itself is `thread_qos`).
fn publish_compile_burst(warm: Res<WarmPass>) {
let bursting = warm.spawned_at.is_some() && !warm.done;
crate::thread_qos::COMPILE_BURST.store(bursting, Ordering::Relaxed);
}
/// Main world → render world: is the frame covered right now?
fn publish_cover(
watch: Res<PipeWatch>,
@@ -246,8 +262,11 @@ struct WarmBoothCam;
/// condition, so the cover holds while menagerie pipelines are still compiling.
#[derive(Resource, Default)]
pub(crate) struct WarmPass {
/// `Time::elapsed_secs` when the menagerie spawned under the current cover; `None` = idle
/// (no cover, or the pass already finished for this cover).
/// `Time<Real>::elapsed_secs` when the menagerie spawned under the current cover; `None` =
/// idle (no cover, or the pass already finished for this cover). **Real, not virtual**: the
/// pass's whole subject is a burst that stalls frames, and `Time<Virtual>` clamps its delta
/// at 250 ms — so on the virtual clock this pass measured its own 1.3–2.3 s burst as
/// "0.27 s" and its 10 s backstop was 10 *virtual* seconds (decision 1116).
spawned_at: Option<f32>,
/// This cover's warm work is done (drained, timed out, or not applicable).
done: bool,
@@ -256,6 +275,12 @@ pub(crate) struct WarmPass {
effect_tex: Option<Handle<Image>>,
/// Consecutive covered+in-world frames seen while idle (see [`WARM_COVER_PRESENT_FRAMES`]).
covered_frames: u32,
/// Pacing state (1116): rigs warmed so far, when the last slice went out, how many frames
/// the reveal spanned, and the slice currently on screen (hidden again next frame).
revealed: usize,
last_reveal: f32,
reveal_frames: u32,
showing: Vec<Entity>,
}
impl WarmPass {
@@ -265,18 +290,72 @@ impl WarmPass {
}
}
/// The menagerie must have been extracted + drawn + its pipelines queued before `pending == 0`
/// means anything — under a second even on the entry frame's load.
/// The last revealed slice must have been extracted + drawn + its pipelines queued before
/// `pending == 0` means anything (the counters cross worlds ±1 frame) — anchored to the last
/// reveal, not to the spawn, now that the reveal is paced.
const WARM_SETTLE_SECS: f32 = 0.25;
/// Rigs revealed per frame — the pacing slice (decision 1116).
///
/// On macOS Bevy compiles every pipeline with `block_on` **inline on the render thread**, and
/// `PipelineCache::process_queue` drains the whole backlog in one frame with no budget
/// (bevy_render 0.18.1 `pipeline_cache.rs:869` and `:697`; `synchronous_pipeline_compilation`
/// is `cfg`'d out of existence here). So the burst's shape is set entirely by how many variants
/// we make *drawable* per frame. Revealing all ~1480 at once bought one unbroken 1.3 s (warm
/// shader cache) to 2.3 s (cold) frame — the loading screen frozen solid, and CoreAudio's IO
/// cycle missing its hardware deadline inside it (1114/1115). A slice this size keeps each
/// frame's compile batch inside one 43 ms device cycle, so the audio HAL always gets its turn
/// and the cover animates instead of freezing.
///
/// Override with `$WOW_WARM_SLICE` for A/B work; **0 means unpaced** (the pre-1116 behaviour,
/// kept as the baseline arm — the honest way to re-measure the burst this const exists to
/// break up).
const WARM_REVEAL_PER_FRAME: usize = 24;
/// The pacing slice actually in force, `$WOW_WARM_SLICE` applied once.
fn reveal_slice() -> usize {
static SLICE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
*SLICE.get_or_init(|| {
let n = std::env::var("WOW_WARM_SLICE")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.map_or(
WARM_REVEAL_PER_FRAME,
|n| if n == 0 { usize::MAX } else { n },
);
if n != WARM_REVEAL_PER_FRAME {
info!("pipeline warm: slice overridden to {n} rigs/frame (WOW_WARM_SLICE)");
}
n
})
}
/// Marker: this rig has had its one visible frame, so its pipeline is compiled and it is hidden
/// again. Without it a slice stays drawable for the rest of the pass and every later frame
/// redraws the whole warmed set, so the pass's per-frame cost climbs as it runs. (Measured: it
/// does *not* move the pass's total wall time — the per-frame floor is elsewhere — it keeps the
/// per-frame cost flat, which is what pacing is trying to buy.)
#[derive(Component)]
struct Warmed;
/// The pacing query: every rig but the twin booth CAMERA, which is a view, not a variant —
/// hiding it would take the booth-layer rigs' camera away mid-pass.
type WarmRigVis<'w, 's> = Query<
'w,
's,
(Entity, &'static mut Visibility, Has<Warmed>),
(With<WarmRig>, Without<WarmBoothCam>),
>;
/// 0737's rule: never hold a cover unbounded. A timeout fires the tripwire-adjacent warn and
/// releases; the remaining compiles land live (the pre-0837 world, once, with a named cause).
const WARM_TIMEOUT_SECS: f32 = 10.0;
/// Covered frames the pass waits before spawning the menagerie, so the compile burst lands
/// behind a cover that is ON THE GLASS. On world entry the raise frame still renders the glue
/// (the state flips a frame later), so the FIRST covered+in-world frame is also the first frame
/// the cover can draw — spawning then puts the whole synchronous burst in that same frame's
/// render, and what stays on screen for the burst is the previous present: the frozen character
/// screen (the director's report). Two extra frames ≈ 33 ms of cover; the burst is seconds cold.
/// the cover can draw — and what stays on screen while the pass runs is otherwise the previous
/// present: the frozen character screen (the director's report). Two extra frames ≈ 33 ms of
/// cover; the burst is seconds cold. Since 1116 the burst is paced rather than paid in one
/// frame, so this guards the pass's first slices rather than one monolithic stall.
const WARM_COVER_PRESENT_FRAMES: u32 = 3;
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
@@ -286,9 +365,10 @@ fn run_warm_pass(
watch: Res<PipeWatch>,
loading: Res<LoadingScreen>,
state: Res<State<ClientState>>,
time: Res<Time>,
time: Res<Time<Real>>,
camera: Query<Entity, With<crate::player::WorldCamera>>,
rigs: Query<(Entity, Option<&ChildOf>), With<WarmRig>>,
mut rig_vis: WarmRigVis,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut lanes: WarmLanes,
@@ -305,6 +385,7 @@ fn run_warm_pass(
warm.spawned_at = None;
warm.effect_tex = None;
warm.covered_frames = 0;
warm.showing.clear();
despawn_rigs(&mut commands, &rigs);
return;
}
@@ -330,6 +411,9 @@ fn run_warm_pass(
return;
}
warm.spawned_at = Some(now);
warm.last_reveal = now;
warm.revealed = 0;
warm.reveal_frames = 0;
// The twin booth (0958): the custom-projection view key space real bakes use — the real
// booths warm the placeholder-Perspective class, this camera the NONSTANDARD one. It is
// a WarmRig, so every despawn path below cleans it up with the rigs.
@@ -350,7 +434,7 @@ fn run_warm_pass(
&mut cache,
&light.0,
);
info!("pipeline warm: menagerie up ({count} variants)");
info!("pipeline warm: menagerie up ({count} variants, {WARM_REVEAL_PER_FRAME}/frame)");
return;
};
if warm.done {
@@ -365,16 +449,57 @@ fn run_warm_pass(
Vec3::new(0.001, 0.0, -0.5),
Color::WHITE,
);
// The pacing slice (1116). Two moves per frame, in this order:
//
// 1. Hide the slice revealed LAST frame. Its rigs were extracted and drawn by that frame's
// render, which is where the (synchronous) compile happened — they have nothing left to
// contribute, and leaving them visible makes every later frame redraw the whole warmed
// set. Hiding is safe precisely because extraction for the frame that revealed them has
// already run: this system is in `Update`, an extract behind.
// 2. Reveal the next slice, bounding this frame's compile batch.
//
// A frame that reveals nothing means the menagerie is fully warmed, and only then can
// `pending == 0` mean "drained".
for e in std::mem::take(&mut warm.showing) {
if let Ok((_, mut vis, _)) = rig_vis.get_mut(e) {
*vis = Visibility::Hidden;
}
}
let slice = reveal_slice();
for (e, mut vis, warmed) in &mut rig_vis {
if warmed {
continue;
}
if warm.showing.len() >= slice {
break;
}
*vis = Visibility::Visible;
commands.entity(e).insert(Warmed);
warm.showing.push(e);
}
let revealed_now = warm.showing.len();
if revealed_now > 0 {
warm.revealed += revealed_now;
warm.last_reveal = now;
warm.reveal_frames += 1;
}
let all_revealed = revealed_now == 0;
let last_reveal = warm.last_reveal;
let pending = watch
.0
.created
.load(Ordering::Relaxed)
.saturating_sub(watch.0.settled.load(Ordering::Relaxed));
if now - spawned >= WARM_SETTLE_SECS && pending == 0 {
if all_revealed && pending == 0 && now - last_reveal >= WARM_SETTLE_SECS {
warm.done = true;
warm.effect_tex = None;
despawn_rigs(&mut commands, &rigs);
info!("pipeline warm: drained in {:.2}s", now - spawned);
info!(
"pipeline warm: {} rigs drained in {:.2}s over {} paced frames",
warm.revealed,
now - spawned,
warm.reveal_frames,
);
} else if now - spawned >= WARM_TIMEOUT_SECS {
warm.done = true;
warm.effect_tex = None;
+27
View File
@@ -1399,6 +1399,33 @@ fn control(
}
}
// `WOW_CAM_DUMP`: the per-frame INPUT signal beside `seat_camera`'s realized-pose `[cam]`
// line — wall clock, frame dt, this frame's accumulated mouse delta, the active look mode,
// and the yaw/pos the frame produced. A turn-feel question ("keyboard turn smooth, mouse
// turn jittery") needs the input cadence and the output cadence on the same timeline: a
// bursty `dx` under a steady `dt` convicts event delivery; a steady `dx` with an uneven
// realized pose convicts everything downstream.
if std::env::var_os("WOW_CAM_DUMP").is_some() {
eprintln!(
"[turn] t={:.6} dt={:.6} dx={:.3} dy={:.3} look={} face={:.6} model={:.6} \
pos [{:.4},{:.4},{:.4}]",
time.elapsed_secs_f64(),
dt,
mouse_motion.delta.x,
mouse_motion.delta.y,
match rig.look {
Some(LookButton::Right) => "R",
Some(LookButton::Left) => "L",
None => "-",
},
player.face_yaw,
player.model_yaw,
player.pos.x,
player.pos.y,
player.pos.z,
);
}
// The camera-collision sweep is rooted at the *head* (capsule top hemisphere centre), not the
// framing pivot — see `seat_camera`'s doc for why. Computed here (not in `camera`) because it
// depends on the avatar's own capsule constants, which are a movement concern.
+45 -7
View File
@@ -27,9 +27,23 @@ const GLUE_MUSIC_FADE_OUT_MS: u64 = 2000;
#[derive(Message)]
pub(crate) struct GlueSound(pub(crate) &'static str);
/// The held glue-music stream (non-`Sync` handle — non-Send state, like [`super::zone`]'s).
#[derive(Default)]
struct GlueMusic(Option<mixer::StreamingSoundHandle<kira::sound::FromFileError>>);
/// The held glue-music stream (non-`Sync` handle — non-Send state, like [`super::zone`]'s), plus
/// its starvation watch (decision 1109). The handle is held through the stop-fade, not dropped at
/// it: the fade's 2 s ride through the world-entry load burst is exactly the crackle-prone window,
/// and [`watch_glue_music`] can only see a stream it still holds.
struct GlueMusic {
handle: Option<mixer::StreamingSoundHandle<kira::sound::FromFileError>>,
watch: mixer::StreamWatch,
}
impl Default for GlueMusic {
fn default() -> Self {
Self {
handle: None,
watch: mixer::StreamWatch::new("glue music"),
}
}
}
/// Drain glue clicks into the kit player (2D SFX, by name — the client's name-registry path).
fn play_glue_sounds(
@@ -68,7 +82,7 @@ fn start_glue_music(
assets: Option<Res<WorldAssets>>,
config: Res<SoundConfig>,
) {
if music.0.is_some() {
if music.handle.is_some() {
return; // still playing (select ⇄ create hops re-enter CharSelect)
}
let (Some(assets), Some(mixer_ref)) = (assets, out.mixer.as_mut()) else {
@@ -89,19 +103,40 @@ fn start_glue_music(
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
Ok(h) => {
info!("glue music: {GLUE_MUSIC}");
music.0 = Some(h);
music.handle = Some(h);
}
Err(e) => debug!("glue music: {e:#}"),
}
}
/// Entering the world: fade the theme out on the backend and drop the handle.
/// Entering the world: fade the theme out on the backend. The handle is deliberately kept — the
/// [`GlueMusic`] docs say why — and reaped by [`watch_glue_music`] once the fade lands on
/// `Stopped`.
fn stop_glue_music(mut music: NonSendMut<GlueMusic>) {
if let Some(mut h) = music.0.take() {
if let Some(h) = music.handle.as_mut() {
h.stop(mixer::fade(GLUE_MUSIC_FADE_OUT_MS));
}
}
/// Per-frame stream health + handle reaping: the starvation watch (decision 1109) over the held
/// theme, and the drop once it reaches `Stopped` — after the entry fade lands, or at the theme's
/// natural end. On the glue screens that end re-arms [`start_glue_music`], which brings the theme
/// back from the top — the login screen no longer falls silent for good after one play-through
/// (the reference's theme keeps going; it rides the same music machinery as zone tracks,
/// `PlayGlueMusic` → `0x45aeb0`, which re-selects when a track ends).
fn watch_glue_music(mut music: NonSendMut<GlueMusic>, time: Res<Time>) {
let music = &mut *music;
let Some(h) = music.handle.as_ref() else {
return;
};
if h.state() == kira::sound::PlaybackState::Stopped {
music.handle = None;
music.watch.reset();
return;
}
music.watch.feed(h, f64::from(time.delta_secs()));
}
pub(super) fn plugin(app: &mut App) {
app.add_message::<GlueSound>()
.insert_non_send_resource(GlueMusic::default())
@@ -114,6 +149,9 @@ pub(super) fn plugin(app: &mut App) {
// same `wow_main_theme` — decision 0539) and keeps across the glue screens.
start_glue_music
.run_if(in_state(ClientState::Login).or(in_state(ClientState::CharSelect))),
// No run condition: the stop-fade rides into `InWorld`, and that leg is exactly
// the window the watch exists for (1109).
watch_glue_music.after(start_glue_music),
),
);
}
@@ -0,0 +1,174 @@
//! The HAL overload meter — the crackle detector that lives *downstream* of our mix
//! (decision 1114).
//!
//! The 1112 corner: the director's crackle is in none of our audio — the tap capture is clean
//! by scan and by their own ear, the callback met every deadline, the decoder never starved.
//! What remains is the one hop no in-process meter watched: CoreAudio's **IO cycle** — the HAL
//! collecting every client's buffer and handing the result to the hardware DMA on a hard
//! deadline. When that cycle runs late (a late thread wakeup, a memory-bandwidth storm from a
//! GPU upload burst — a first login's world entry is exactly that), the device plays stale or
//! zero samples and the speaker crackles, while the producing app measures itself healthy: the
//! mix was computed fast and correct, just consumed late. kira's load meter cannot see it
//! (it times `process()`, not the cycle), and macOS does not persist it to the unified log.
//!
//! CoreAudio *does* announce it to clients: the `kAudioDeviceProcessorOverload` property fires
//! a listener on every overloaded cycle. This module registers that listener on the default
//! output device and a Bevy system reports new overloads once per window — so the next live
//! crackle either stamps a `HAL overload` WARN at the moment the ear hears it (confirming the
//! downstream mechanism, and pointing at the device-buffer dial, 1026's residue), or stays
//! silent and rules this hop out too.
//!
//! macOS only by construction; every other platform compiles to a no-op. The listener sticks
//! to the device that was default at registration — a mid-session output swap loses the meter
//! (accepted: kira's cpal backend deliberately never re-queries devices either, 1026).
use bevy::prelude::*;
#[cfg(target_os = "macos")]
mod sys {
use std::sync::atomic::{AtomicU64, Ordering};
/// Overloaded-cycle count since launch, bumped by the listener (CoreAudio calls it on an
/// internal thread; the poller reads it on the main thread).
pub(super) static OVERLOADS: AtomicU64 = AtomicU64::new(0);
// The tiny slice of the CoreAudio C API this needs — declared here like `thread_qos` does
// for pthread QoS, so no version-pinned -sys crate enters the graph for four constants.
type OSStatus = i32;
type AudioObjectID = u32;
#[repr(C)]
struct AudioObjectPropertyAddress {
selector: u32,
scope: u32,
element: u32,
}
/// `kAudioObjectSystemObject`
const SYSTEM_OBJECT: AudioObjectID = 1;
/// `kAudioHardwarePropertyDefaultOutputDevice` — 'dOut'
const DEFAULT_OUTPUT: u32 = u32::from_be_bytes(*b"dOut");
/// `kAudioDeviceProcessorOverload` — 'over'
const PROCESSOR_OVERLOAD: u32 = u32::from_be_bytes(*b"over");
/// `kAudioObjectPropertyScopeGlobal` — 'glob'
const SCOPE_GLOBAL: u32 = u32::from_be_bytes(*b"glob");
/// `kAudioObjectPropertyElementMain`
const ELEMENT_MAIN: u32 = 0;
#[link(name = "CoreAudio", kind = "framework")]
unsafe extern "C" {
fn AudioObjectGetPropertyData(
object: AudioObjectID,
address: *const AudioObjectPropertyAddress,
qualifier_size: u32,
qualifier: *const core::ffi::c_void,
size: *mut u32,
data: *mut core::ffi::c_void,
) -> OSStatus;
fn AudioObjectAddPropertyListener(
object: AudioObjectID,
address: *const AudioObjectPropertyAddress,
listener: extern "C" fn(
AudioObjectID,
u32,
*const AudioObjectPropertyAddress,
*mut core::ffi::c_void,
) -> OSStatus,
client_data: *mut core::ffi::c_void,
) -> OSStatus;
}
extern "C" fn on_overload(
_object: AudioObjectID,
_n: u32,
_addresses: *const AudioObjectPropertyAddress,
_client: *mut core::ffi::c_void,
) -> OSStatus {
OVERLOADS.fetch_add(1, Ordering::Relaxed);
0
}
/// Register the overload listener on the current default output device. `Err` carries the
/// failing call for the log; a machine without a device returns quietly.
pub(super) fn register() -> Result<(), &'static str> {
let default_addr = AudioObjectPropertyAddress {
selector: DEFAULT_OUTPUT,
scope: SCOPE_GLOBAL,
element: ELEMENT_MAIN,
};
let mut device: AudioObjectID = 0;
let mut size = std::mem::size_of::<AudioObjectID>() as u32;
let status = unsafe {
AudioObjectGetPropertyData(
SYSTEM_OBJECT,
&default_addr,
0,
core::ptr::null(),
&mut size,
(&mut device as *mut AudioObjectID).cast(),
)
};
if status != 0 || device == 0 {
return Err("default output device lookup failed");
}
let overload_addr = AudioObjectPropertyAddress {
selector: PROCESSOR_OVERLOAD,
scope: SCOPE_GLOBAL,
element: ELEMENT_MAIN,
};
let status = unsafe {
AudioObjectAddPropertyListener(
device,
&overload_addr,
on_overload,
core::ptr::null_mut(),
)
};
if status != 0 {
return Err("overload listener registration failed");
}
Ok(())
}
pub(super) fn count() -> u64 {
OVERLOADS.load(Ordering::Relaxed)
}
}
/// Register the listener at startup (macOS with a live mixer only — silent runs have no device
/// stream to overload).
pub(super) fn setup(out: NonSend<super::SoundOutput>) {
if out.mixer.is_none() {
return;
}
#[cfg(target_os = "macos")]
match sys::register() {
Ok(()) => info!("audio: HAL overload listener armed on the default output device"),
Err(what) => warn!("audio: HAL overload listener unavailable — {what}"),
}
}
/// Report new overloads, rate-limited to one line per second — each one is a device cycle the
/// OS glitched *after* our (clean) mix, so a line landing at the moment a crackle is heard is
/// the downstream confirmation 1114 exists to catch.
pub(super) fn poll(time: Res<Time>, mut last: Local<(u64, f32)>) {
#[cfg(not(target_os = "macos"))]
{
let _ = (time, last);
}
#[cfg(target_os = "macos")]
{
let now = time.elapsed_secs();
let count = sys::count();
let (reported, last_line_at) = *last;
if count > reported && now - last_line_at >= 1.0 {
warn!(
"audio: {} HAL processor overload(s) — the OS missed the device cycle's hardware \
deadline DOWNSTREAM of our mix; the speaker glitched even though the mix is \
clean. This is the 1112 crackle signature.",
count - reported,
);
*last = (count, now);
}
}
}
+200
View File
@@ -0,0 +1,200 @@
//! `$WOW_MIX_TAP=<path.wav>` — record the final mix to disk (decision 1112).
//!
//! The crackle hunts (1026, 1109) kept hitting the same wall: every scheduling meter reads
//! healthy while the director's ear still catches a crackle — because the meters watch the
//! *pipeline* (deadlines, decoder liveness, stream position) and a crackle can live purely in
//! the *waveform* (a clipped sum, a stepped parameter, a discontinuity none of our counters
//! model). This closes the gap at the root: a [`kira::effect::Effect`] on the **main track**
//! copies every processed frame into a lock-free ring, and a writer thread drains it to a
//! standard WAV — so a reported crackle becomes data to scan, timestamp, and classify instead
//! of a sound to argue about.
//!
//! Two properties matter:
//! - **Pre-clamp.** Main-track effects run before the renderer's hard clamp to ±1.0
//! (`backend/renderer.rs`), so output clipping shows up as samples beyond full scale in the
//! capture — measurable, not just audible.
//! - **Crash-safe file.** The writer patches the RIFF/data sizes on every flush, so the WAV is
//! valid up to the last second even if the app exits hard; there is no finalize step to miss.
//!
//! The audio-thread half never allocates or blocks (a fixed ring push per sample; overflow is
//! counted, not waited on). The ring holds [`RING_SECONDS`] of stereo audio — the writer wakes
//! every [`FLUSH_EVERY`] and would have to stall ~50× past its cadence before a sample drops.
use bevy::prelude::*;
use kira::effect::{Effect, EffectBuilder};
use kira::info::Info;
use kira::Frame;
use std::io::{Seek, SeekFrom, Write};
/// Ring depth in seconds of stereo audio. Deep enough that only a wedged writer drops samples.
const RING_SECONDS: usize = 8;
/// Writer wake cadence. Also the upper bound on audio lost at a hard kill.
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_millis(250);
/// Install the tap on `main_track_builder` if `$WOW_MIX_TAP` names a path. Returns the builder
/// unchanged otherwise. `sample_rate` is the device rate the mixer negotiated — the WAV header
/// must match what the renderer actually produces, so an unprobeable device (`None`) skips the
/// tap rather than record on a guessed time axis.
pub(super) fn install(
builder: kira::track::MainTrackBuilder,
sample_rate: Option<u32>,
) -> kira::track::MainTrackBuilder {
let Some(path) = std::env::var_os("WOW_MIX_TAP") else {
return builder;
};
let Some(sample_rate) = sample_rate else {
warn!("mix tap: device sample rate unknown — not recording");
return builder;
};
let path = std::path::PathBuf::from(path);
let file = match std::fs::File::create(&path) {
Ok(f) => f,
Err(e) => {
warn!("mix tap: cannot create {}: {e}", path.display());
return builder;
}
};
let (producer, consumer) = rtrb::RingBuffer::new(sample_rate as usize * 2 * RING_SECONDS);
std::thread::Builder::new()
.name("mix-tap".into())
.spawn(move || writer(file, consumer, sample_rate))
.expect("spawn mix-tap writer");
info!("mix tap: recording the main mix to {}", path.display());
let mut builder = builder;
builder.add_effect(TapBuilder { producer });
builder
}
struct TapBuilder {
producer: rtrb::Producer<f32>,
}
impl EffectBuilder for TapBuilder {
type Handle = ();
fn build(self) -> (Box<dyn Effect>, Self::Handle) {
(
Box::new(Tap {
producer: self.producer,
dropped: 0,
}),
(),
)
}
}
/// The audio-thread half: copy each frame into the ring. Push-and-count on overflow — the one
/// thing this must never do is block or allocate on the render path.
struct Tap {
producer: rtrb::Producer<f32>,
dropped: u64,
}
impl Effect for Tap {
fn process(&mut self, input: &mut [Frame], _dt: f64, _info: &Info) {
for f in input.iter() {
if self.producer.push(f.left).is_err() || self.producer.push(f.right).is_err() {
self.dropped += 1;
}
}
}
}
/// The writer half: drain the ring to the WAV, patching the header sizes each flush so the file
/// on disk is always valid. Ends (and logs the tally) when the producer side is dropped — i.e.
/// when the mixer itself is torn down at app exit.
fn writer(mut file: std::fs::File, mut consumer: rtrb::Consumer<f32>, sample_rate: u32) {
if let Err(e) = file.write_all(&wav_header(sample_rate, 0)) {
warn!("mix tap: header write failed: {e}");
return;
}
let mut samples_written: u64 = 0;
let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
loop {
let done = consumer.is_abandoned();
buf.clear();
while let Ok(s) = consumer.pop() {
buf.extend_from_slice(&s.to_le_bytes());
}
if !buf.is_empty() {
if let Err(e) = file.write_all(&buf) {
warn!("mix tap: write failed: {e}");
return;
}
samples_written += (buf.len() / 4) as u64;
// Patch the sizes so the file is valid as-is (crash-safe: no finalize step).
let header = wav_header(sample_rate, samples_written);
if file.seek(SeekFrom::Start(0)).is_ok() {
let _ = file.write_all(&header);
let _ = file.seek(SeekFrom::End(0));
}
}
if done {
info!(
"mix tap: closed — {:.1}s of mix recorded",
samples_written as f64 / 2.0 / f64::from(sample_rate)
);
return;
}
std::thread::sleep(FLUSH_EVERY);
}
}
/// A 44-byte WAV header for stereo IEEE-float-32 at `sample_rate`, sized for `samples` samples
/// (total across both channels). Rewritten in place on every flush.
fn wav_header(sample_rate: u32, samples: u64) -> [u8; 44] {
let data_bytes = (samples * 4) as u32;
let byte_rate = sample_rate * 2 * 4;
let mut h = [0u8; 44];
h[0..4].copy_from_slice(b"RIFF");
h[4..8].copy_from_slice(&(36 + data_bytes).to_le_bytes());
h[8..12].copy_from_slice(b"WAVE");
h[12..16].copy_from_slice(b"fmt ");
h[16..20].copy_from_slice(&16u32.to_le_bytes());
h[20..22].copy_from_slice(&3u16.to_le_bytes()); // IEEE float
h[22..24].copy_from_slice(&2u16.to_le_bytes()); // stereo
h[24..28].copy_from_slice(&sample_rate.to_le_bytes());
h[28..32].copy_from_slice(&byte_rate.to_le_bytes());
h[32..34].copy_from_slice(&8u16.to_le_bytes()); // block align: 2 ch × 4 B
h[34..36].copy_from_slice(&32u16.to_le_bytes()); // bits per sample
h[36..40].copy_from_slice(b"data");
h[40..44].copy_from_slice(&data_bytes.to_le_bytes());
h
}
#[cfg(test)]
mod tests {
use super::*;
/// The header is a valid stereo float-32 WAV that a strict parser accepts, and the patched
/// sizes match the sample count — the crash-safety of the tap rests on every flush leaving
/// a well-formed file.
#[test]
fn wav_header_is_well_formed_and_patchable() {
let h = wav_header(48_000, 96_000); // 1 s of stereo
assert_eq!(&h[0..4], b"RIFF");
assert_eq!(
u32::from_le_bytes(h[4..8].try_into().unwrap()),
36 + 384_000
);
assert_eq!(&h[8..12], b"WAVE");
assert_eq!(u16::from_le_bytes(h[20..22].try_into().unwrap()), 3);
assert_eq!(u16::from_le_bytes(h[22..24].try_into().unwrap()), 2);
assert_eq!(u32::from_le_bytes(h[24..28].try_into().unwrap()), 48_000);
assert_eq!(u32::from_le_bytes(h[28..32].try_into().unwrap()), 384_000);
assert_eq!(u32::from_le_bytes(h[40..44].try_into().unwrap()), 384_000);
// kira's own symphonia decoder accepts the produced file — the exact consumer a
// captured tap goes back through when we analyze it.
let mut file = Vec::new();
file.extend_from_slice(&wav_header(48_000, 4));
for s in [0.5f32, -0.5, 0.25, -0.25] {
file.extend_from_slice(&s.to_le_bytes());
}
let sound =
kira::sound::static_sound::StaticSoundData::from_cursor(std::io::Cursor::new(file))
.expect("tap WAV decodes");
assert_eq!(sound.frames.len(), 2);
assert!((sound.frames[0].left - 0.5).abs() < 1e-6);
assert!((sound.frames[1].right - -0.25).abs() < 1e-6);
}
}
+252 -16
View File
@@ -132,8 +132,15 @@ impl Mixer {
/// Open the default audio device. Fails cleanly when there is none (headless/CI) — the caller
/// runs silent with `None` (mirrors the client's `-nosound` gate).
pub(crate) fn new() -> Result<Self> {
let (backend_settings, sample_rate) = backend_settings();
let settings = AudioManagerSettings::<DefaultBackend> {
backend_settings: backend_settings(),
backend_settings,
// The mix tap (decision 1112, `$WOW_MIX_TAP`) rides the main track as an effect —
// a no-op builder unless the env var names a capture path.
main_track_builder: super::mix_tap::install(
kira::track::MainTrackBuilder::new(),
sample_rate,
),
..Default::default()
};
let mut manager = AudioManager::<DefaultBackend>::new(settings)
@@ -301,30 +308,37 @@ impl Mixer {
/// conferencing tool) can drag it down under us and we would never know. Naming it puts a floor
/// under the callback's deadline instead of inheriting someone else's.
///
/// 1024 frames is ~21 ms at 48 kHz. Latency that size is inaudible for WoW — nothing here is
/// rhythm-critical, the shortest UI click is an order of magnitude longer — and it buys ~4× the
/// deadline of a 256-frame buffer for the mix to finish in.
const TARGET_BUFFER_FRAMES: u32 = 1024;
/// 2048 frames is ~43 ms at 48 kHz. Latency that size is inaudible for WoW — nothing here is
/// rhythm-critical, the shortest UI click is an order of magnitude longer. The size is set by
/// the *HAL's* deadline, not ours: a confirmed crackle (decision 1115) was the OS missing the
/// device IO-cycle deadline under system pressure while our mix ran clean, and the buffer size
/// is the cycle length — fewer, longer cycles mean twice the slack per cycle for the HAL. Our
/// own mix cost was never the constraint at 1024 (~21 ms, decision 1026) and is even less so
/// here.
const TARGET_BUFFER_FRAMES: u32 = 2048;
/// Build the cpal backend settings: kira's default device, our explicit buffer size.
/// Build the cpal backend settings: kira's default device, our explicit buffer size. Also
/// reports the negotiated sample rate (the mix tap's WAV header must match the renderer);
/// `None` when the device/config could not be probed — those paths run on kira defaults.
///
/// `device` stays `None` on purpose — that keeps kira's own default-device selection *and* its
/// disconnect/restart handling (`custom_device = false`). We override only the config. Every
/// failure path falls back to kira's defaults, so a machine we can't probe still opens the device
/// exactly as before; [`Mixer::new`]'s caller already tolerates no-device.
fn backend_settings() -> CpalBackendSettings {
fn backend_settings() -> (CpalBackendSettings, Option<u32>) {
let fallback = CpalBackendSettings::default();
let Some(device) = cpal::default_host().default_output_device() else {
return fallback;
return (fallback, None);
};
let Ok(supported) = device.default_output_config() else {
return fallback;
return (fallback, None);
};
// Clamp into what the device will actually accept; `Unknown` means cpal can't tell us the
// range, and asking for a size outside it fails the stream build — so leave those alone.
let cpal::SupportedBufferSize::Range { min, max } = supported.buffer_size() else {
info!("audio: device reports no buffer-size range — leaving it to the driver");
return fallback;
let rate = supported.config().sample_rate;
return (fallback, Some(rate));
};
let frames = TARGET_BUFFER_FRAMES.clamp(*min, *max);
let mut config = supported.config();
@@ -335,10 +349,14 @@ fn backend_settings() -> CpalBackendSettings {
config.channels,
f64::from(frames) / f64::from(config.sample_rate) * 1000.0,
);
CpalBackendSettings {
config: Some(config),
..fallback
}
let rate = config.sample_rate;
(
CpalBackendSettings {
config: Some(config),
..fallback
},
Some(rate),
)
}
/// The mix-health counters — what a crackle actually *is*, in numbers (decision 1026).
@@ -436,9 +454,162 @@ pub(crate) fn loop_from_bytes(bytes: Vec<u8>) -> Result<StaticSoundData> {
Ok(sfx_from_bytes(bytes)?.loop_region(..))
}
/// Wrap compressed audio bytes for decode-streaming (music/ambience).
/// Wrap compressed audio bytes for decode-streaming (music/ambience). The bytes go in behind
/// [`PromotingSource`] — the decode-thread QoS fix (decision 1109) — never a bare cursor.
pub(crate) fn stream_from_bytes(bytes: Vec<u8>) -> Result<StreamingSoundData<FromFileError>> {
StreamingSoundData::from_cursor(std::io::Cursor::new(bytes)).context("opening stream")
StreamingSoundData::from_media_source(PromotingSource(std::io::Cursor::new(bytes)))
.context("opening stream")
}
/// Compressed-audio source whose reads promote the calling thread — the stream-decode QoS fix
/// (decision 1109).
///
/// kira decodes each streaming sound on a thread of its own (`decode_scheduler.rs`, a bare
/// `std::thread::spawn`) feeding a 16 384-frame ring buffer (~0.37 s at 44.1 kHz); when that
/// buffer runs dry mid-play the sound zero-fills whole callback blocks (`streaming/sound.rs`,
/// the `slots() < 2` branch) — hard amplitude steps, i.e. a crackle, and one that registers on
/// **neither** [`MixHealth`] meter: the mix met its deadline and no stream error fired. A bare
/// spawn lands at *default* QoS — macOS does not inherit the spawner's class
/// (`thread_qos::tests` pins this) — which is below everything the world-entry burst runs: the
/// compute pool at user-interactive, eight IO workers at user-initiated. First login is the
/// guaranteed collision: the glue theme is mid-fade (a live stream) exactly while every core
/// saturates, so its decoder starves and the loading screen crackles.
///
/// The only place our code runs on that thread is the decoder's reads from the byte source, so
/// the source itself promotes: first touch per thread raises it to user-interactive. The class
/// is justified — the mix's input has the hardest deadline in the app, and the thread sleeps
/// ~99% of its life (MP3 decodes at ~100× real time), so the promotion costs nothing. The latch
/// is thread-local; the open/probe calls kira makes on the *main* thread re-assert that
/// thread's existing class, harmlessly.
struct PromotingSource(std::io::Cursor<Vec<u8>>);
/// Once-per-thread promotion latch for [`PromotingSource`].
fn promote_decode_thread() {
std::thread_local! {
static PROMOTED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
PROMOTED.with(|p| {
if !p.get() {
crate::thread_qos::promote_current_thread(crate::thread_qos::QosClass::UserInteractive);
debug!(
"audio: stream decode thread {:?} promoted",
std::thread::current().id()
);
p.set(true);
}
});
}
impl std::io::Read for PromotingSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
promote_decode_thread();
self.0.read(buf)
}
}
impl std::io::Seek for PromotingSource {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
promote_decode_thread();
self.0.seek(pos)
}
}
impl symphonia::core::io::MediaSource for PromotingSource {
fn is_seekable(&self) -> bool {
true
}
fn byte_len(&self) -> Option<u64> {
Some(self.0.get_ref().len() as u64)
}
}
/// How much audible time may go missing per watch window before [`StreamWatch`] reports —
/// over two zero-filled ~43 ms callback blocks: past the ±1-block quantization of the shared
/// position and the sample-clock/wall-clock drift, and far under any real starvation burst
/// (hundreds of ms).
const STREAM_STARVED_MIN_SECS: f64 = 0.1;
/// The accounting window [`StreamWatch`] compares over.
const STREAM_WATCH_WINDOW_SECS: f64 = 1.0;
/// Position-freeze watch over a live stream — the starvation meter [`MixHealth`] lacks (decision
/// 1109; `poll_mix_health`'s docs name this exact blind spot).
///
/// A starved stream is *audible* — kira zero-fills the callback block — but the mix made its
/// deadline, so nothing else measures it. What does move is the stream's shared position: it
/// freezes while the state stays audible. Fed once per frame from the held handle; over each
/// ~[`STREAM_WATCH_WINDOW_SECS`] window it compares wall time elapsed against position advanced
/// and WARNs when more than [`STREAM_STARVED_MIN_SECS`] went missing. Playback rate is always
/// 1.0 here (WoW pitches no music), so the two clocks agree to drift well under the threshold.
///
/// **Known blind spot (1112, deliberate):** the watch samples on the main thread, so a stream
/// that starves *during a main-thread stall* and reaches `Stopped` before the next frame is
/// never accounted — the window is discarded by the reset. Reconstructing it from the stop
/// deadline would trade false positives for coverage; the mix tap (`$WOW_MIX_TAP`) owns that
/// class instead, because the tap records on the audio thread and doesn't care what the main
/// thread was doing.
pub(crate) struct StreamWatch {
label: &'static str,
last_pos: Option<f64>,
expected: f64,
advanced: f64,
}
impl StreamWatch {
pub(crate) fn new(label: &'static str) -> Self {
Self {
label,
last_pos: None,
expected: 0.0,
advanced: 0.0,
}
}
/// Per-frame feed from the held handle. Call every frame the handle exists.
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 let Some(lost) = self.observe(audible, handle.position(), dt) {
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",
self.label,
lost * 1000.0,
);
}
}
/// Drop the baseline — call when the watched handle is dropped/replaced, so the next
/// stream's position (starting at 0, i.e. *behind* the old one's) can't read as a freeze.
pub(crate) fn reset(&mut self) {
self.last_pos = None;
self.expected = 0.0;
self.advanced = 0.0;
}
/// The accounting core, pure so the tests below can drive it without a device. Returns
/// `Some(lost_secs)` when a window closes starved.
fn observe(&mut self, audible: bool, pos: f64, dt: f64) -> Option<f64> {
// Non-audible states (stopped, paused) drop the baseline: position legitimately holds.
if !audible {
self.reset();
return None;
}
let Some(last) = self.last_pos.replace(pos) else {
return None; // first audible frame — baseline only
};
self.expected += dt;
// `max(0.0)`: a track replaced on the slot mid-window jumps backwards once (the new
// stream starts at 0); count it as no advance for that one frame, not as negative.
self.advanced += (pos - last).max(0.0);
if self.expected < STREAM_WATCH_WINDOW_SECS {
return None;
}
let lost = self.expected - self.advanced;
self.expected = 0.0;
self.advanced = 0.0;
(lost > STREAM_STARVED_MIN_SECS).then_some(lost)
}
}
#[cfg(test)]
@@ -532,6 +703,71 @@ mod tests {
);
}
/// The starvation accounting (decision 1109): a stream that advances in lockstep with wall
/// time stays quiet; one whose position freezes mid-window (kira's zero-fill) reports the
/// missing time; a slot swap's one-frame backward jump never reads as a freeze; and going
/// non-audible drops the baseline so a later stream starts clean.
#[test]
fn stream_watch_accounts_freezes_not_swaps() {
let dt = 1.0 / 60.0;
// Healthy: position tracks wall time exactly — a full window closes clean.
let mut w = StreamWatch::new("test");
let mut pos = 0.0;
for _ in 0..90 {
assert_eq!(w.observe(true, pos, dt), None, "healthy stream reported");
pos += dt;
}
// Starved: 18 frames (~0.3 s) frozen inside the window → that window reports ~0.3 s.
let mut w = StreamWatch::new("test");
let mut pos = 0.0;
let mut reports = Vec::new();
for i in 0..90 {
if !(30..48).contains(&i) {
pos += dt;
}
if let Some(lost) = w.observe(true, pos, dt) {
reports.push(lost);
}
}
assert_eq!(reports.len(), 1, "one starved window: {reports:?}");
assert!(
(reports[0] - 0.3).abs() < 0.05,
"lost ≈ 0.3 s, got {reports:?}"
);
// Slot swap: position jumps backwards once (new track starts at 0) — no report.
let mut w = StreamWatch::new("test");
let mut pos = 40.0;
for i in 0..90 {
if i == 30 {
pos = 0.0;
}
assert_eq!(
w.observe(true, pos, dt),
None,
"slot swap reported as freeze"
);
pos += dt;
}
// Non-audible resets the baseline: a stopped-then-restarted stream (position far behind
// the old one's) opens a fresh window instead of inheriting a phantom freeze.
let mut w = StreamWatch::new("test");
let mut pos = 70.0;
for _ in 0..30 {
assert_eq!(w.observe(true, pos, dt), None);
pos += dt;
}
assert_eq!(w.observe(false, pos, dt), None);
let mut pos = 0.0;
for _ in 0..90 {
assert_eq!(w.observe(true, pos, dt), None, "restart read as freeze");
pos += dt;
}
}
/// The fade contract every transition rests on: `stop(tween)` fades the channel to silence
/// over the tween's duration on the audio thread, and **dropping the handle immediately after
/// does not cut it short** — the command is already published to the renderer's triple-buffer
+16 -2
View File
@@ -21,11 +21,13 @@ pub(crate) mod footsteps;
mod gameobject;
mod glue;
mod greeting;
mod hal_overload;
pub(crate) mod interior;
mod kit;
mod liquid_loop;
mod math;
mod missile;
mod mix_tap;
mod mixer;
mod money;
mod mount;
@@ -197,6 +199,7 @@ impl Plugin for SoundPlugin {
})
.init_resource::<SoundConfig>()
.init_resource::<AudioListener>()
.add_systems(Startup, hal_overload::setup)
.add_systems(
Update,
(
@@ -206,6 +209,7 @@ impl Plugin for SoundPlugin {
toggle_mute,
apply_master_volume.after(toggle_mute),
poll_mix_health,
hal_overload::poll,
),
);
kit::plugin(app);
@@ -308,10 +312,15 @@ const MIX_HEALTH_REPORT: std::time::Duration = std::time::Duration::from_secs(5)
/// A miss is not a maybe: `load >= 1.0` means the mix did not finish before the driver needed it,
/// and the driver played whatever was in the buffer. If this line is quiet during a crackle, the
/// crackle is *not* an underrun and the next suspect is upstream (a stepped parameter, a starved
/// stream decoder) — which is exactly the disambiguation we could not make before.
/// stream decoder) — which is exactly the disambiguation we could not make before. The starved
/// stream decoder has its own meter now — [`mixer::StreamWatch`], fed by the music-stream
/// holders (decision 1109; it registers on *neither* counter here) — and when every meter is
/// quiet while the ear still hears something, `$WOW_MIX_TAP` records the waveform itself
/// (decision 1112: a crackle can live purely in the mix's *content*).
fn poll_mix_health(
mut out: NonSendMut<SoundOutput>,
time: Res<Time>,
mut exit: MessageReader<bevy::app::AppExit>,
mut since_report: Local<std::time::Duration>,
mut last_overruns: Local<u64>,
) {
@@ -320,7 +329,12 @@ fn poll_mix_health(
};
let health = mixer.poll_health();
*since_report += time.delta();
if *since_report < MIX_HEALTH_REPORT {
// App exit forces the report out NOW: a session that quits shortly after the interesting
// moment (log in, hear the crackle, close the window) used to silently lose every miss
// since the last 5 s boundary — the 1112 hunt found the director's entire post-reveal
// window unmetered exactly this way.
let exiting = exit.read().next().is_some();
if *since_report < MIX_HEALTH_REPORT && !exiting {
return;
}
*since_report = std::time::Duration::ZERO;
+13
View File
@@ -150,6 +150,9 @@ pub(super) struct ZoneAudio {
zone_music: u32,
/// The playing music stream (a zone track, an intro, or a server-pushed event track).
music: Option<StreamingSoundHandle<kira::sound::FromFileError>>,
/// Starvation watch over the music slot (decision 1109) — a streamed track whose decode
/// thread is outrun zero-fills the mix, which no other meter sees.
music_watch: mixer::StreamWatch,
/// The SoundEntries kit on the music slot (0 = never started one) — the "what is playing" a
/// repeat `SMSG_PLAY_MUSIC` is compared against ([`slot_holds`]).
music_kit: u32,
@@ -186,6 +189,7 @@ impl Default for ZoneAudio {
Self {
zone_music: 0,
music: None,
music_watch: mixer::StreamWatch::new("zone music"),
music_kit: 0,
music_kit_vol: 1.0,
next_track_at: None,
@@ -432,6 +436,12 @@ fn zone_audio(
mixer::glide(),
);
}
// The starvation watch (1109) over the playing track. A track fading out on a dropped handle
// 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()));
}
// 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.
let ambience_gain = fade_in_gain(&mut zone.ambience_fade_in, now);
@@ -680,6 +690,9 @@ fn leave_world(mut zone: NonSendMut<ZoneAudio>) {
if let Some(mut h) = zone.music.take() {
h.stop(mixer::fade(WORLD_TEARDOWN_FADE_MS));
}
// The next login's first track starts at position 0, far behind this one's baseline — a
// stale watch would read that as a freeze (1109).
zone.music_watch.reset();
if let Some(mut h) = zone.ambience.take() {
h.stop(mixer::fade(WORLD_TEARDOWN_FADE_MS));
}
@@ -304,6 +304,7 @@ pub(super) fn spawn_loaded_placements(
world_from_local: p.transform.compute_affine(),
name_set: p.name_set,
visible: vec![true; m.group_nav.len()],
liquid_visited: vec![false; m.group_nav.len()],
// The MOGP `groupLiquid` override, resolved once at spawn: 13
// groups in the whole archive declare "this room is wholly
// submerged" in place of carrying a liquid grid, and 5 of them are
+46 -6
View File
@@ -23,8 +23,23 @@ pub enum QosClass {
UserInteractive = 0x21,
/// Work the frame is waiting on soon but not this frame (asset IO, async compute, net IO).
UserInitiated = 0x19,
/// Darwin's unspecified/default class — where a *thread that will block for seconds* belongs.
/// Apple's duration table calls user-interactive "virtually instantaneous" and user-initiated
/// "a few seconds or less"; a multi-second shader-compile burst is neither. Metal's compiler
/// runs the compile at the **calling thread's** QoS, and Apple's own prescription for
/// pipeline prewarming is exactly this class (WWDC25 "Explore Metal 4 games":
/// "Set QoS class to default for pipeline prewarming and streaming", with sample code using
/// `QOS_CLASS_DEFAULT`). Decision 1117.
Default = 0x15,
}
/// Set while the *covered* pipeline-warm burst runs (decision 1117): the render thread spends
/// that window blocked inside Metal pipeline compilation, and there is no frame to protect —
/// the loading cover is a still image. Published by `pipe_warm`, applied by
/// [`promote_render_thread`] on the render thread itself, which is the only thread that can set
/// its own QoS.
pub static COMPILE_BURST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Promote the calling thread to `class`. Safe to call repeatedly; logs once on failure.
pub fn promote_current_thread(class: QosClass) {
#[cfg(target_os = "macos")]
@@ -74,17 +89,24 @@ fn current_thread_qos() -> Option<u32> {
}
fn promote_render_thread(_world: &mut World) {
// The class this thread should be in *right now*: its normal frame-critical band, or the
// default band while it is the shader compiler's caller under a cover (1117).
let want = if COMPILE_BURST.load(std::sync::atomic::Ordering::Relaxed) {
QosClass::Default
} else {
QosClass::UserInteractive
};
std::thread_local! {
static PROMOTED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static APPLIED: std::cell::Cell<Option<QosClass>> = const { std::cell::Cell::new(None) };
}
PROMOTED.with(|p| {
if !p.get() {
promote_current_thread(QosClass::UserInteractive);
APPLIED.with(|p| {
if p.get() != Some(want) {
promote_current_thread(want);
debug!(
"thread QoS: promoted render-schedule thread {:?}",
"thread QoS: render-schedule thread {:?} → {want:?}",
std::thread::current().name().unwrap_or("<unnamed>")
);
p.set(true);
p.set(Some(want));
}
});
}
@@ -109,4 +131,22 @@ mod tests {
assert_eq!(observed.1, Some(class as u32), "promotion did not apply");
}
}
/// A spawned thread does NOT inherit its spawner's QoS class — it lands at default even when
/// the spawner is user-interactive. This is the load-bearing fact of decision 1109: kira
/// spawns its per-stream decode threads from the (promoted) main thread, and without
/// inheritance those threads sit *below* the promoted worker pools and starve under the
/// world-entry burst — hence `sound::mixer::PromotingSource` promoting from inside.
#[test]
fn qos_is_not_inherited_by_spawned_threads() {
let (parent, child) = std::thread::spawn(|| {
promote_current_thread(QosClass::UserInteractive);
let child = std::thread::spawn(current_thread_qos).join().unwrap();
(current_thread_qos(), child)
})
.join()
.unwrap();
assert_eq!(parent, Some(QosClass::UserInteractive as u32));
assert_eq!(child, Some(0x15), "QoS inherited — 1109's premise changed");
}
}
+70 -11
View File
@@ -19,9 +19,12 @@
use bevy::prelude::*;
use benilla_formats::{SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY};
use benilla_formats::{
SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM,
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_SPELL,
};
use benilla_protocol::messages::PLAYER_SKILL_SLOTS;
use benilla_ui::script::{CraftReagent, CraftRecipe, CraftState, UiScript};
use benilla_ui::script::{CraftReagent, CraftRecipe, CraftState, CraftTooltip, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
@@ -33,13 +36,11 @@ use crate::ui_spellbook::SkillLines;
use crate::ui_tradeskill::SpellFocus;
use crate::ui_unit::UnitFeed;
/// `SPELL_ATTR_IS_TRADESKILL` — the item-recipe marker (0227's add-gate bit). Craft recipes
/// (enchants, the rod crafts) do NOT carry it — they carry **`castUI != 0`** instead (pinned on
/// the live 5875 data this cycle: 7418/7421 castUI=3, attributes bare 0x10000; exactly the
/// spellbook add-gate's third exclusion leg). INTERIM admission pending a wow-re detail pass:
/// a known spell joins the craft list when its SLA line matches AND (`castUI != 0` OR the
/// tradeskill bit) and it is not an opener.
const ATTR_IS_TRADESKILL: u32 = 0x20;
// NOTE on the admission filter below: Craft recipes (enchants, the rod crafts) do NOT carry
// `SPELL_ATTR_IS_TRADESKILL` — they carry **`castUI != 0`** instead (pinned on the live 5875 data:
// 7418/7421 castUI=3, attributes bare 0x10000; exactly the spellbook add-gate's third exclusion
// leg). INTERIM admission pending a wow-re detail pass: a known spell joins the craft list when its
// SLA line matches AND (`castUI != 0` OR the tradeskill bit) and it is not an opener.
/// The open Craft window: the skill line whose recipes it lists (`None` = closed). Routed here
/// by the opener's `EffectMiscValue[0] != 0` (byte-VERIFIED — wow-re `tradeskill` TU-A); a Beast
@@ -97,6 +98,36 @@ fn craft_icon(d: &benilla_formats::SpellDisplay) -> Option<String> {
d.icon.clone()
}
/// The Craft window's **tooltip law** — `SetCraftSpell 0x533e90`, byte-verified in wow-re
/// (`ui/scratch/trainer-service-tooltip-law.md` §4.1). Like the trainer's, the binding is a
/// selector into the two shared builders and emits no line of its own; unlike the trainer's, it
/// reads the **recipe's own** effect columns:
///
/// ```text
/// for i in 0..3:
/// if Effect[i] == 36 LEARN_SPELL: return Spell(EffectTriggerSpell[i]) # NOT validated
/// if Effect[i] == 24 CREATE_ITEM: return Item(EffectItemType[i])
/// return Spell(recipe spell id) # every enchant
/// ```
///
/// Three differences from [`crate::ui_trainer::service_tooltip`] that a shared implementation would
/// erase: this tests the recipe's own `Effect[i]` where the trainer tests the *taught* spell's
/// `Attributes & 0x20`; it never tests `57 LEARN_PET_SPELL` and so never sets `altCaster`; and it
/// hops `EffectTriggerSpell[i]` **without** checking that it resolves, where the trainer's scan
/// falls through to the next slot on an unresolvable trigger. Same shape, different law — the
/// icon side's lesson (decision 1107) applies to the content side too.
fn craft_tooltip(spell_id: u32, d: &benilla_formats::SpellDisplay) -> CraftTooltip {
for i in 0..3 {
if d.effects[i] == SPELL_EFFECT_LEARN_SPELL {
return CraftTooltip::Spell(d.effect_trigger_spell[i]);
}
if d.effects[i] == SPELL_EFFECT_CREATE_ITEM {
return CraftTooltip::Item(d.effect_item_type[i]);
}
}
CraftTooltip::Spell(spell_id)
}
/// Build the craft snapshot — `None` when the window is closed or the catalogs haven't loaded.
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set (the feed precedent)
fn feed_craft(
@@ -138,7 +169,7 @@ fn feed_craft(
.iter()
.filter(|&&s| {
spells.catalog.get(s).is_some_and(|d| {
(d.cast_ui != 0 || d.attributes & ATTR_IS_TRADESKILL != 0)
(d.cast_ui != 0 || d.attributes & SPELL_ATTR_IS_TRADESKILL != 0)
&& d.effects[0] != benilla_formats::SPELL_EFFECT_TRADE_SKILL
}) && skill_lines.catalog.spell_to_line(s) == Some(line)
})
@@ -209,6 +240,7 @@ fn feed_craft(
needs_item_target,
reagents,
tools,
tooltip: craft_tooltip(s, d),
})
})
.collect();
@@ -287,7 +319,7 @@ fn drain_craft(
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::{SpellDisplay, SPELL_EFFECT_CREATE_ITEM};
use benilla_formats::SpellDisplay;
/// Law D never looks at an item. The regression this pins is the *sibling* one: a rod-making
/// `CREATE_ITEM` recipe — the exact case our old code substituted the product for — still
@@ -315,4 +347,31 @@ mod tests {
};
assert_eq!(craft_icon(&d), None);
}
/// Law D's content twin. Three arms, and the point of each is that it differs from the
/// TRAINER's law on the same shape: this reads the recipe's OWN effect columns.
#[test]
fn craft_tooltip_reads_the_recipes_own_effects() {
// An enchant — no matching slot — describes the recipe spell itself.
let enchant = SpellDisplay {
effects: [SPELL_EFFECT_ENCHANT_ITEM, 0, 0],
..Default::default()
};
assert_eq!(craft_tooltip(7420, &enchant), CraftTooltip::Spell(7420));
// A rod craft — CREATE_ITEM in slot 0 — describes the ITEM, off the SAME slot.
let rod = SpellDisplay {
effects: [SPELL_EFFECT_CREATE_ITEM, 0, 0],
effect_item_type: [6218, 0, 0],
..Default::default()
};
assert_eq!(craft_tooltip(7421, &rod), CraftTooltip::Item(6218));
// A LEARN_SPELL slot hops — and, unlike the trainer's law, does NOT check that the trigger
// resolves. There is no catalog argument here at all, which is the difference made
// structural: a trigger id this client has never heard of is still what gets described.
let mut teacher = SpellDisplay::default();
teacher.effects[1] = SPELL_EFFECT_LEARN_SPELL;
teacher.effect_trigger_spell[1] = 999_999;
assert_eq!(craft_tooltip(5149, &teacher), CraftTooltip::Spell(999_999));
}
}
@@ -103,6 +103,10 @@ fn service(
) -> TrainerService {
TrainerService {
spell_id,
tooltip: benilla_ui::script::TrainerTooltip::Spell {
spell_id,
alt_caster: false,
},
name: Some(name.into()),
subtext: None,
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
+2 -6
View File
@@ -26,7 +26,7 @@ use std::time::Instant;
use bevy::prelude::*;
use benilla_formats::{SpellFocusCatalog, SPELL_EFFECT_CREATE_ITEM};
use benilla_formats::{SpellFocusCatalog, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM};
use benilla_protocol::messages::PLAYER_SKILL_SLOTS;
use benilla_ui::script::{
TradeSkillDifficulty, TradeSkillReagent, TradeSkillRecipe, TradeSkillState, UiScript,
@@ -380,7 +380,7 @@ fn feed_trade_skill(
spells
.catalog
.get(s)
.is_some_and(|d| d.attributes & ATTR_IS_TRADESKILL != 0)
.is_some_and(|d| d.attributes & SPELL_ATTR_IS_TRADESKILL != 0)
&& skill_lines.catalog.spell_to_line(s) == Some(line)
})
.filter_map(|&s| {
@@ -441,10 +441,6 @@ fn feed_trade_skill(
*last = fresh;
}
/// `SPELL_ATTR_IS_TRADESKILL` — the recipe marker (0227's add-gate bit; the book divert the
/// spellbook applies is the same bit that ADMITS a spell here).
const ATTR_IS_TRADESKILL: u32 = 0x20;
/// Drain the Lua intents and run the repeat machine: `DoTradeSkill` latches the count and casts
/// through the ONE cast-send path (0216 §8); our own GO for the latched spell re-casts until dry;
/// a fail or `CloseTradeSkill` stops everything.
-973
View File
@@ -1,973 +0,0 @@
//! The app-side **trainer feed** (decision 0237 phase 3) — the inward half of the trainer seam
//! around [`benilla_ui::script`]'s `trainer` module, the twin of [`crate::ui_merchant`]'s merchant
//! feed.
//!
//! The net bridge fills [`TrainerOpen`] from the wire (`SMSG_TRAINER_LIST` → the trainer's services +
//! greeting, reached through the gossip trainer option). Each frame [`feed_trainer`] resolves each
//! wire [`TrainerSpell`] to a Lua-facing [`TrainerService`] — name/subtext from the spell catalog
//! (`Spell.dbc`, loaded whole at startup), the **icon** from its own byte-verified law
//! ([`service_icon`], which at a *tradeskill* trainer fronts the taught recipe's created item and so
//! does need the ask-once item-template query the item rows use), the skill-requirement name from
//! the skill-line catalog, the green/red/gray state straight off the wire
//! `state` byte — pushes the snapshot ([`UiScript::set_trainer`]), and fires `TRAINER_SHOW` on open /
//! `TRAINER_UPDATE` on a content change / `TRAINER_CLOSED` on clear. [`drain_trainer`] pulls the Lua
//! intents back out: the Train button's `BuyTrainerService` → `CMSG_TRAINER_BUY_SPELL` for the open
//! trainer, and `CloseTrainer` → a local clear (vanilla's client-side close sends no packet). A
//! successful buy answers `SMSG_TRAINER_BUY_SUCCEEDED` — the spell itself lands via
//! `SMSG_LEARNED_SPELL` (already in the book), and the net apply re-requests the list to repaint the
//! bought row green→gray; a refusal (`SMSG_TRAINER_BUY_FAILED`) stages into [`TrainerErrors`] for the
//! feed to surface on the window's red error line.
//!
//! The standardized NPC-session range guard ([`crate::ui_session`]) client-side-closes the window
//! when the player walks out of the trainer's service range (or the trainer despawns) — the same
//! `CloseTrainer` clear.
use std::collections::HashSet;
use benilla_formats::{
SkillLineCatalog, SpellCatalog, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL,
};
use benilla_protocol::messages::{trainer_spell_state, TrainerSpell};
use bevy::prelude::*;
use benilla_ui::script::{
ScriptValue, TrainerAbilityReq, TrainerService, TrainerServiceCategory, TrainerSkillReq,
TrainerState, UiScript,
};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_items::item_icon;
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
use crate::ui_spellbook::SkillLines;
/// `SMSG_TRAINER_LIST`'s `trainer_type` for a tradeskill trainer (0 class · 1 mount · 2 tradeskill ·
/// 3 pet). Used only to classify a service as tradeskill-vs-learn-spell for the Era
/// `IsTrainerServiceTradeSkill` surface — a whole-trainer approximation (per-service typing from the
/// spell's effects is a later refinement).
const TRAINER_TYPE_TRADESKILL: u32 = 2;
/// The open trainer, filled by the net bridge ([`crate::net`]) and read by [`feed_trainer`]. Holds
/// the trainer guid and its services exactly as the wire delivered them (`SMSG_TRAINER_LIST`), plus
/// the window-framing type and the greeting title. Cleared on a client-side close and on disconnect.
#[derive(Resource, Default)]
pub(crate) struct TrainerOpen {
/// The trainer whose window is open; `None` = no trainer open.
pub(crate) trainer: Option<u64>,
/// The wire services (order = 1-based display order).
pub(crate) services: Vec<TrainerSpell>,
/// The window-framing kind (0 class · 1 mount · 2 tradeskill · 3 pet).
pub(crate) trainer_type: u32,
/// The trainer's greeting line (`SMSG_TRAINER_LIST`'s trailing string).
pub(crate) greeting: String,
}
impl TrainerOpen {
/// Open (or replace) the window with a trainer's freshly-listed services.
pub(crate) fn open(
&mut self,
trainer: u64,
trainer_type: u32,
services: Vec<TrainerSpell>,
greeting: String,
) {
self.trainer = Some(trainer);
self.trainer_type = trainer_type;
self.services = services;
self.greeting = greeting;
}
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
pub(crate) fn clear(&mut self) {
self.trainer = None;
self.services.clear();
self.trainer_type = 0;
self.greeting.clear();
}
/// Disconnect: drop the open window (mirrors the gossip/merchant session clears).
pub(crate) fn clear_session(&mut self) {
self.clear();
}
}
/// Trainer purchase refusals (`SMSG_TRAINER_BUY_FAILED`), staged by the net apply for the feed to
/// surface on the window's red error line — the merchant [`crate::ui_merchant::MerchantErrors`]
/// twin. Each entry is a [`benilla_protocol::messages::train_fail`] code.
#[derive(Resource, Default)]
pub(crate) struct TrainerErrors(pub Vec<u32>);
pub(crate) struct UiTrainerPlugin;
impl Plugin for UiTrainerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TrainerOpen>()
.init_resource::<TrainerErrors>()
.add_systems(
Update,
(
// Range-close before the feed so the clear turns into TRAINER_CLOSED the same frame;
// push before the input pass so an open/close is on screen the same frame; drain
// after it (mirrors ui_merchant/ui_gossip).
close_npc_session_out_of_range::<TrainerOpen>.before(feed_trainer),
feed_trainer.before(UiInput),
drain_trainer.after(UiInput),
),
);
}
}
/// The red error-line text for a trainer refusal (`SMSG_TRAINER_BUY_FAILED`'s
/// [`benilla_protocol::messages::train_fail`] code). The money case is the exact enUS `GlobalString`
/// (`ERR_NOT_ENOUGH_MONEY`); the skill/unavailable cases are best-effort English — the real client
/// builds these in C++, so their exact wording is a wow-re confirm item (rarely reached: the Train
/// button is disabled unless the service is available and affordable).
fn train_error_text(code: u32) -> String {
use benilla_protocol::messages::train_fail;
match code {
train_fail::NOT_ENOUGH_MONEY => "You don't have enough money.",
train_fail::NOT_ENOUGH_SKILL => "You do not have the required skill.",
_ => "That trainer service is not available.",
}
.to_string()
}
/// The trainer's **icon law** — `GetTrainerServiceIcon 0x4d8f50`, byte-verified whole in wow-re
/// (`system/ui/scratch/spell-icon-substitution-law.md` §1; the binding is not a marshal, the entire
/// resolution is inlined there). Three gates, then a fallback:
///
/// 1. **The trainer type is 2** (tradeskill/profession — `[0xb73a08]`, the trainer-list packet's
/// type dword stored verbatim). A class/mount/pet trainer never substitutes.
/// 2. **The WIRE spell has a learn-wrapper effect** in any of its three slots —
/// `SPELL_EFFECT_LEARN_SPELL` or `SPELL_EFFECT_LEARN_PET_SPELL` (`0x4d8ff5`/`0x4d8ffa`). The
/// **first** matching slot wins; its `EffectTriggerSpell` is the taught spell.
/// 3. **That taught spell creates an item** (`EffectItemType[0] != 0`, `0x4d906a`) → the created
/// item's `ItemDisplayInfo` icon.
///
/// Otherwise: the **WIRE (wrapper) spell's own `SpellIconID`** (`0x4d9008`). This is the fact
/// benilla had backwards — `esi` is pinned to the wire record at `0x4d8fd7` and is never reassigned
/// on any path reaching the fallback, so **the client never paints the taught spell's own icon at a
/// trainer**. We used to, which is what put a blue `Spell_Shadow_SealOfKings` crown on Copper
/// Shortsword: spell 2756 (the wrapper the server sends) hops to 2739 (the recipe), whose own
/// `SpellIconID` is that crown — while the law wants item 2847's sword.
///
/// A gate-3 hit whose item template is not cached yet returns `None` (the client pushes Lua `nil`
/// and repaints on the cache callback, `0x4d9140` → `TRAINER_UPDATE`). Our equivalent is free: the
/// template answer changes the snapshot, so [`feed_trainer`]'s diff re-fires `TRAINER_UPDATE` on its
/// own.
fn service_icon(
wire_spell: u32,
trainer_type: u32,
spells: &SpellCatalog,
icons: Option<&ItemDisplays>,
items: &mut Items,
commands: &NetCommands,
) -> Option<String> {
let wire = spells.get(wire_spell)?;
if trainer_type == TRAINER_TYPE_TRADESKILL {
// Gate 2: the first learn-wrapper slot wins — a miss (or a zero trigger) falls straight
// through to the wire icon rather than scanning on, exactly as the loop's `je` does.
let taught = wire
.effects
.iter()
.position(|&e| e == SPELL_EFFECT_LEARN_SPELL || e == SPELL_EFFECT_LEARN_PET_SPELL)
.map(|i| wire.effect_trigger_spell[i])
.filter(|&t| t != 0);
// Gate 3: the taught spell's product item, slot 0 only.
if let Some(product) = taught
.and_then(|t| spells.get(t))
.map(|d| d.effect_item_type[0])
.filter(|&e| e != 0)
{
return items
.template(product, 0, commands)
.map(|t| t.display_info_id)
.and_then(|d| item_icon(icons, d));
}
}
wire.icon.clone()
}
/// The green/red/gray colour a wire `state` byte maps to (decision 0237): GRAY → known, GREEN →
/// learnable, everything else (RED + any unexpected value) → gated.
fn category(state: u8) -> TrainerServiceCategory {
match state {
trainer_spell_state::GRAY => TrainerServiceCategory::Used,
trainer_spell_state::GREEN => TrainerServiceCategory::Available,
_ => TrainerServiceCategory::Unavailable,
}
}
/// Resolve one wire [`TrainerSpell`] into the Lua-facing [`TrainerService`]: name/subtext/icon from
/// the spell catalog (`None` only before `Spell.dbc` has loaded — the row shows a placeholder), the
/// skill-req name from the skill-line catalog (falling back to `Skill <id>` if it hasn't loaded),
/// the ability-req names + rank from the same spell catalog, the state/cost/gates straight off the
/// wire. `known` is the player's known-spell set ([`PlayerActions::spells`]) — each prerequisite
/// ability is coloured by whether the player already knows that specific spell (see below).
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
fn resolve_service(
wire: &TrainerSpell,
trainer_type: u32,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
icons: Option<&ItemDisplays>,
items: &mut Items,
commands: &NetCommands,
) -> TrainerService {
// The trainer offers a LEARN wrapper (decision 0247); the ability it teaches — with the skill
// line the tree groups by, and the real display name/icon — is the taught spell. Resolve it once
// for both. `wire.spell` (the wrapper) stays the buy id below (CMSG_TRAINER_BUY_SPELL names it).
// A spell that teaches nothing (a plain ability, or before Spell.dbc loads) resolves to itself.
let taught = spells.learned_spell(wire.spell).unwrap_or(wire.spell);
let display = spells.get(taught);
let cat = category(wire.state);
// The SKILL gate has no per-gate "met" bit on the 5875 wire, so approximate it from the service's
// overall category (an unavailable service has some unmet gate). The real client computes the
// skill gate locally too — player skill value ≥ required, like the level gate the XML already
// checks with `UnitLevel` — so a faithful per-gate skill check waits only on threading the
// player's skill values here; the ability gate below already does its own per-gate check, so a
// skill-gated service is the one remaining coarse case (decision 0253).
let skill_met = cat != TrainerServiceCategory::Unavailable;
let skill_req = (wire.req_skill != 0).then(|| TrainerSkillReq {
name: skill_lines
.and_then(|c| c.line(wire.req_skill))
.map(|l| l.name.clone())
.unwrap_or_else(|| format!("Skill {}", wire.req_skill)),
rank: wire.req_skill_value,
met: skill_met,
});
// Each prerequisite ability is coloured by whether the player already KNOWS that specific spell —
// the byte-verified real-client mechanism (wow-re `system/ui/scratch/trainer-requirement.md`):
// `GetTrainerServiceAbilityReq`'s hasReq is `IsSpellKnown(reqSpellId)`, evaluated per-requirement
// and INDEPENDENT of the service's overall category — so a spell gated only by LEVEL still shows
// its already-learned prev-rank prerequisite WHITE, not red. The req id is a real ability id (not
// a learn wrapper — verified there too), so there's no hop: look it up directly. The name carries
// its rank exactly as the client does — `"Name (Rank)"` when the spell has a rank subtext, else
// the bare name (the client's `"%s (%s)"`). The client also ORs `KnownHigherRank`; benilla has no
// rank chain, and sequential trainer ranks never reach that clause, so the direct known-check
// covers every real case.
let ability_reqs = wire
.req_spells
.iter()
.filter(|&&s| s != 0)
.map(|&s| {
let name = match spells.get(s) {
Some(d) => match d.rank.as_deref() {
Some(rank) if !rank.is_empty() => format!("{} ({})", d.name, rank),
_ => d.name.clone(),
},
None => format!("Spell {s}"),
};
TrainerAbilityReq {
name,
met: known.contains(&s),
}
})
.collect();
// The tree's grouping key (decision 0247): the TAUGHT spell's skill line (`SkillLineAbility.dbc`
// → `SkillLine.dbc` name) — the wire wrapper id itself is never in `SkillLineAbility`, so grouping
// MUST go through the hop above. `0` when unresolved — the engine drops such a service from the
// tree, matching the client. A resolved id with a missing name falls back to `Skill <id>`. We log
// a genuine miss (catalog present but no line) so DBC gaps surface rather than silently swallowing
// a service the server offered.
let skill_line = skill_lines
.and_then(|c| c.spell_to_line(taught))
.unwrap_or(0);
if skill_line == 0 && skill_lines.is_some() {
debug!(
"ui_trainer: trainer spell {} (teaches {taught}) has no skill line — dropped from the tree",
wire.spell
);
}
let skill_line_name = if skill_line != 0 {
skill_lines
.and_then(|c| c.line(skill_line))
.map(|l| l.name.clone())
.unwrap_or_else(|| format!("Skill {skill_line}"))
} else {
String::new()
};
TrainerService {
spell_id: wire.spell,
name: display.map(|d| d.name.clone()),
subtext: display.and_then(|d| d.rank.clone()),
// The NAME/subtext hop through the taught spell stays (decisions 0247/0252 — the wrapper is
// not in SkillLineAbility, so grouping and display MUST hop). The ICON does not: it is its
// own byte-verified law over the WIRE spell ([`service_icon`]).
texture: service_icon(wire.spell, trainer_type, spells, icons, items, commands),
// The detail pane's description body, left empty by design. The real
// `GetTrainerServiceDescription` returns the spell's *tooltip* — `Spell.dbc`'s Description
// with its `$s1`/`$o1`/`$d`/`$a1` tokens substituted from the spell's effect base points,
// duration, and radius. Feeding the raw column would render the unsubstituted tokens
// (broken-looking, not faithful), so descriptions wait on a spell-tooltip token engine —
// its own arc, shared with the spellbook's hover tooltips — not a lone `SpellCatalog` column.
description: String::new(),
cost: wire.cost,
prof_first_rank: wire.is_primary_prof_first_rank,
category: cat,
level_req: u32::from(wire.req_level),
skill_req,
ability_reqs,
is_trade_skill: trainer_type == TRAINER_TYPE_TRADESKILL,
skill_line,
skill_line_name,
}
}
/// Build the Lua-facing snapshot from [`TrainerOpen`] + the spell/skill catalogs — `None` when no
/// trainer is open.
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
fn snapshot(
open: &TrainerOpen,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
icons: Option<&ItemDisplays>,
items: &mut Items,
commands: &NetCommands,
) -> Option<TrainerState> {
open.trainer?;
Some(TrainerState {
greeting: open.greeting.clone(),
is_tradeskill: open.trainer_type == TRAINER_TYPE_TRADESKILL,
services: open
.services
.iter()
.map(|w| {
resolve_service(
w,
open.trainer_type,
spells,
skill_lines,
known,
icons,
items,
commands,
)
})
.collect(),
// The engine synthesizes the skill-line tree in `set_trainer` — the app pushes only the flat
// services (each carrying its resolved skill line above).
groups: Vec::new(),
})
}
/// Push the current trainer into the VM and fire the show/update/close events on a transition (or a
/// content change). Diffed against a `Local` memory, exactly like the gossip/merchant feeds. A
/// different trainer while the window is already open is a real close+open (the client's `ShowUIPanel`
/// early-returns when visible, so the open sound only re-plays after a hide — decision 0096).
#[allow(clippy::too_many_arguments)]
fn feed_trainer(
script: Option<NonSendMut<UiScript>>,
open: Res<TrainerOpen>,
actions: Res<PlayerActions>,
spells: Option<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
// A tradeskill trainer's rows front the CREATED ITEM's icon, so the feed needs the ask-once
// template cache + `ItemDisplayInfo.dbc` — the tradeskill window's own pair ([`service_icon`]).
icons: Option<Res<ItemDisplays>>,
mut items: ResMut<Items>,
mut errors: ResMut<TrainerErrors>,
commands: Res<NetCommands>,
mut names: ResMut<NameCache>,
mut last: Local<Option<TrainerState>>,
mut last_trainer: Local<Option<u64>>,
mut last_name: Local<Option<String>>,
) {
let Some(mut script) = script else {
return;
};
// Refusals surface as the client's red error line (the merchant/equip/cast path's exact shape).
for code in errors.0.drain(..) {
script.fire_event(
"UI_ERROR_MESSAGE",
vec![ScriptValue::Str(train_error_text(code))],
);
}
// Nothing to resolve a name/icon from yet — try again once Spell.dbc lands.
let Some(spells) = spells.as_deref() else {
return;
};
// The tree groups by skill line (decision 0247), so the skill-line catalog is required, not
// optional: without it every service resolves to skill_line 0 and drops. A trainer only opens
// well after world-entry, by when both DBCs have loaded, so this gate never actually delays a
// real window — it just refuses to render an all-dropped empty tree.
let Some(skill_lines) = skill_lines.as_deref() else {
return;
};
let fresh = snapshot(
&open,
&spells.catalog,
Some(&skill_lines.catalog),
&actions.spells,
icons.as_deref(),
&mut items,
&commands,
);
// The trainer's name resolves through the NameCache (a creature-name query, ask-once — the
// real client's `UnitName("npc")`). `None`/empty while in flight; the title shows the static
// "Trainer" until it lands, then re-fires TRAINER_UPDATE with the name as arg1 (the diff below
// tracks the name too, so a name-only change still repaints the title). It rides an event arg
// rather than a `TrainerState` field so no benilla-ui engine change is needed — the merchant
// vendor-name pattern.
let trainer_name = open
.trainer
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
let name_changed = *last_name != trainer_name;
let switched = npc_switched(*last_trainer, open.trainer);
if fresh == *last && !name_changed && !switched {
return;
}
script.set_trainer(fresh.clone());
let name_arg = || vec![ScriptValue::Str(trainer_name.clone().unwrap_or_default())];
if switched {
// Close the old trainer, open the new: the frame hides then shows. The TRAINER_CLOSED routes
// through the window's OnHide → CloseTrainer, which queues a close intent — consume it here so
// the drain does NOT clear the trainer we just re-opened to (the merchant switch pattern).
script.fire_event("TRAINER_CLOSED", vec![]);
script.fire_event("TRAINER_SHOW", name_arg());
let _ = script.take_trainer_close();
} else {
match (&*last, &fresh) {
(None, Some(_)) => script.fire_event("TRAINER_SHOW", name_arg()),
(Some(_), Some(_)) => script.fire_event("TRAINER_UPDATE", name_arg()),
(Some(_), None) => script.fire_event("TRAINER_CLOSED", vec![]),
(None, None) => {}
}
}
*last = fresh;
*last_trainer = open.trainer;
*last_name = trainer_name;
}
/// The trainer window is an NPC session: the standardized range guard ([`crate::ui_session`])
/// client-side-closes it — the exact `CloseTrainer` clear — when the player walks out of the
/// trainer's service range or the trainer despawns.
impl NpcSession for TrainerOpen {
fn npc(&self) -> Option<u64> {
self.trainer
}
fn close(&mut self) {
self.clear();
}
}
/// Drain the Lua intents: the Train button's buys (`BuyTrainerService` queues the chosen row's spell
/// id) → `CMSG_TRAINER_BUY_SPELL` for the open trainer; a close → a local clear (no packet, vanilla).
/// The server answers a buy with `SMSG_TRAINER_BUY_SUCCEEDED` (→ the spell lands via
/// `SMSG_LEARNED_SPELL`, and the apply re-requests the list to repaint the row gray) or
/// `SMSG_TRAINER_BUY_FAILED` (→ the window's error line via [`TrainerErrors`]).
fn drain_trainer(
script: Option<NonSendMut<UiScript>>,
mut open: ResMut<TrainerOpen>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
for spell_id in script.take_trainer_buys() {
let Some(trainer) = open.trainer else {
debug!(
"ui_trainer: BuyTrainerService(spell {spell_id}) with no open trainer — ignored"
);
continue;
};
debug!("ui_trainer: buy service (trainer {trainer:#x}, spell {spell_id})");
let _ = commands
.0
.send(ClientCommand::TrainerBuySpell { trainer, spell_id });
}
if script.take_trainer_close() {
debug!("ui_trainer: client-side close (no packet)");
open.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::{ItemDisplay, ItemDisplayCatalog, SpellDisplay};
use std::collections::HashMap;
/// An empty spell catalog (`Spell.dbc` not resolved) — the resolver's name/icon lookups miss.
fn empty_catalog() -> SpellCatalog {
SpellCatalog::from_displays(HashMap::new())
}
/// The feed-side trio [`service_icon`] needs — the shared seam, since the tradeskill and craft
/// icon laws terminate in the same ask-once cache. Gate 3's "template in flight" arm is the
/// default; the tests that want a landed template seed one.
use crate::items::TestDeps as Deps;
/// [`resolve_service`] with the icon trio defaulted — the shape the gate/state tests want.
fn resolve_service(
wire: &TrainerSpell,
trainer_type: u32,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
) -> TrainerService {
let mut deps = Deps::new();
super::resolve_service(
wire,
trainer_type,
spells,
skill_lines,
known,
None,
&mut deps.items,
&deps.commands,
)
}
fn wire(spell: u32, state: u8, cost: u32, req_level: u8, req_skill: u32) -> TrainerSpell {
TrainerSpell {
spell,
state,
cost,
can_learn_primary_prof: false,
is_primary_prof_first_rank: false,
req_level,
req_skill,
req_skill_value: if req_skill != 0 { 100 } else { 0 },
req_spells: [0, 0, 0],
}
}
/// [`snapshot`] with the icon trio defaulted.
fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option<TrainerState> {
let mut deps = Deps::new();
snapshot(
open,
spells,
None,
&HashSet::new(),
None,
&mut deps.items,
&deps.commands,
)
}
/// The icon law's fixture (wow-re `spell-icon-substitution-law.md` §1's shape, synthetic so
/// every gate is reachable): wrapper 100 teaches 200 via a slot-0 `LEARN_SPELL`; 200 creates
/// item 777; item 777's display 5 carries the "real" art. Each spell has a DISTINCT icon so a
/// wrong arm is named by the assertion, not merely unequal.
fn icon_catalog() -> SpellCatalog {
let wrapper = SpellDisplay {
name: "Copper Shortsword".into(),
icon: Some("WRAPPER".into()),
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let taught = SpellDisplay {
name: "Copper Shortsword".into(),
icon: Some("TAUGHT".into()),
effect_item_type: [777, 0, 0],
..Default::default()
};
SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, taught)]))
}
/// The product item's template + `ItemDisplayInfo` row, landed.
fn landed_item(deps: &mut Deps) -> ItemDisplays {
let mut t = crate::items::test_template("Copper Shortsword");
t.display_info_id = 5;
deps.items.insert_template(777, Some(t));
ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::from([(
5,
ItemDisplay {
icon: Some("ITEM".into()),
..Default::default()
},
)])))
}
fn icon_of(
spells: &SpellCatalog,
trainer_type: u32,
wire_spell: u32,
land: bool,
) -> Option<String> {
let mut deps = Deps::new();
let icons = land.then(|| landed_item(&mut deps));
service_icon(
wire_spell,
trainer_type,
spells,
icons.as_ref(),
&mut deps.items,
&deps.commands,
)
}
/// The trainer icon law, arm by arm. The director's report — a blue `Spell_Shadow_SealOfKings`
/// crown where the sword belongs — was BOTH halves of this wrong: we substituted nothing, and we
/// fell back to the taught spell's icon, which the client never paints at a trainer on any path.
#[test]
fn trainer_icon_substitutes_the_product_only_at_a_tradeskill_trainer() {
let spells = icon_catalog();
// Gate 1+2+3 all pass, template landed → the created ITEM's icon.
assert_eq!(
icon_of(&spells, TRAINER_TYPE_TRADESKILL, 100, true).as_deref(),
Some("ITEM")
);
// Gate 1 fails (a class trainer): the WIRE spell's own icon — never the taught spell's.
// This is the corrected fallback; the old code returned "TAUGHT" here.
assert_eq!(
icon_of(&spells, 0, 100, true).as_deref(),
Some("WRAPPER"),
"a class trainer serves the wrapper's own icon, not the taught spell's"
);
// Gate 2 fails (no learn-wrapper effect in any slot) → the wire spell's own icon. Spell 200
// creates an item, so only the missing wrapper effect can be keeping the substitution off.
assert_eq!(
icon_of(&spells, TRAINER_TYPE_TRADESKILL, 200, true).as_deref(),
Some("TAUGHT"),
"200 IS the wire spell here, so its own icon is the right answer"
);
// A spell with no record at all → nil (every failure funnels there, 0x4d911c).
assert_eq!(icon_of(&spells, TRAINER_TYPE_TRADESKILL, 999, true), None);
}
/// Gate 3: a wrapper whose taught spell creates NO item falls back to the wire icon — the
/// class/pet-trainer population, which is why the fallback arm has to be right.
#[test]
fn trainer_icon_falls_back_when_the_taught_spell_makes_no_item() {
let wrapper = SpellDisplay {
icon: Some("WRAPPER".into()),
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let taught = SpellDisplay {
icon: Some("TAUGHT".into()),
..Default::default() // effect_item_type all zero
};
let spells = SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, taught)]));
assert_eq!(
icon_of(&spells, TRAINER_TYPE_TRADESKILL, 100, true).as_deref(),
Some("WRAPPER")
);
}
/// The wrapper scan covers all three effect slots and accepts `LEARN_PET_SPELL` (57) as well as
/// `LEARN_SPELL` (36) — the paired `cmp ecx,0x24` / `cmp ecx,0x39` at `0x4d8ff5`/`0x4d8ffa`.
#[test]
fn trainer_icon_scans_every_effect_slot_for_either_learn_effect() {
let wrapper = SpellDisplay {
icon: Some("WRAPPER".into()),
effects: [0, SPELL_EFFECT_LEARN_PET_SPELL, 0],
effect_trigger_spell: [0, 200, 0],
..Default::default()
};
let taught = SpellDisplay {
effect_item_type: [777, 0, 0],
..Default::default()
};
let spells = SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, taught)]));
assert_eq!(
icon_of(&spells, TRAINER_TYPE_TRADESKILL, 100, true).as_deref(),
Some("ITEM"),
"a pet-learn effect in slot 1 substitutes just the same"
);
}
/// The in-flight case: gate 3 passes but the item template is not cached, so the icon reads
/// `None` **and the template gets asked for exactly once**. The real client pushes Lua nil here
/// and repaints from the cache callback (`0x4d9140` → `TRAINER_UPDATE`); our equivalent is the
/// feed's own snapshot diff, which re-fires once the answer changes the texture.
#[test]
fn trainer_icon_is_nil_until_the_product_template_lands_and_asks_once() {
let spells = icon_catalog();
let mut deps = Deps::new();
let icons =
ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::new()));
let first = service_icon(
100,
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&icons),
&mut deps.items,
&deps.commands,
);
assert_eq!(first, None, "no template yet → nil, not the wrapper's icon");
assert_eq!(
deps.queried_entries(),
vec![777],
"and the created item's template was asked for"
);
// A second frame before the answer lands must not re-ask (the ask-once cache).
let _ = service_icon(
100,
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&icons),
&mut deps.items,
&deps.commands,
);
assert!(
deps.queried_entries().is_empty(),
"asked once, not per frame"
);
}
/// The director's exact case on the real shipped `Spell.dbc` — the check wow-re's §7 pins:
/// spell 2756 is the wrapper a Blacksmithing trainer sends, 2739 the recipe it teaches, 2847 the
/// sword. At a tradeskill trainer the law must reach for item 2847; at a class trainer it must
/// serve 2756's own icon. Skips without client data.
#[test]
fn trainer_icon_on_real_data_reaches_for_the_crafted_item() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
// The two textures a wrong law produces, straight off the shipped DBC.
let wrapper_icon = spells.get(2756).unwrap().icon.clone();
let recipe_icon = spells.get(2739).unwrap().icon.clone();
assert_eq!(
recipe_icon.as_deref(),
Some("Interface\\Icons\\Spell_Shadow_SealOfKings"),
"the blue crown the director saw IS the recipe spell's own icon"
);
// A tradeskill trainer: gate 3 fires for item 2847 and the icon waits on the template —
// crucially it is NOT the crown.
let mut deps = Deps::new();
let icons =
ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::new()));
let icon = service_icon(
2756,
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&icons),
&mut deps.items,
&deps.commands,
);
assert_eq!(
icon, None,
"waiting on the item template, not painting the crown"
);
assert_eq!(
deps.queried_entries(),
vec![2847],
"the law reached for the crafted sword's template"
);
// A class trainer with the same wire spell: the wrapper's own icon, not the recipe's.
let mut deps = Deps::new();
let class_icon = service_icon(2756, 0, &spells, None, &mut deps.items, &deps.commands);
assert_eq!(class_icon, wrapper_icon);
assert_ne!(
class_icon, recipe_icon,
"the taught spell's icon is never painted at a trainer"
);
}
/// The learn-spell hop end-to-end on real 5875 data (decision 0247): a warrior trainer sends the
/// LEARN wrapper 1605 ("learn Heroic Strike"), which is not in SkillLineAbility — resolve_service
/// must hop through the taught spell (78) to group it under Arms (26) and show its name, while the
/// BUY id stays the wrapper (1605) the server expects. This is the exact failure that emptied the
/// tree. Skips without client data.
#[test]
fn resolve_hops_the_learn_wrapper_to_the_taught_ability() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let skills =
benilla_formats::load_skill_line_catalog(&mut chain).expect("load skill lines");
let svc = resolve_service(
&wire(1605, trainer_spell_state::GREEN, 10, 1, 0),
0,
&spells,
Some(&skills),
&HashSet::new(),
);
assert_eq!(svc.spell_id, 1605, "the buy id stays the wire wrapper");
assert_eq!(
svc.skill_line, 26,
"grouped under the taught ability's Arms line"
);
assert_eq!(svc.skill_line_name, "Arms");
assert_eq!(
svc.name.as_deref(),
Some("Heroic Strike"),
"display resolves through the taught spell"
);
}
#[test]
fn category_maps_the_wire_state_byte() {
assert_eq!(
category(trainer_spell_state::GREEN),
TrainerServiceCategory::Available
);
assert_eq!(
category(trainer_spell_state::RED),
TrainerServiceCategory::Unavailable
);
assert_eq!(
category(trainer_spell_state::GRAY),
TrainerServiceCategory::Used
);
// An unexpected value is treated as gated (safe default), never "learnable".
assert_eq!(category(99), TrainerServiceCategory::Unavailable);
}
#[test]
fn resolve_reads_cost_state_and_gates_with_no_catalog() {
// Empty catalog (Spell.dbc not resolved): name/subtext/icon nil, the wire fields still land,
// and the skill-req name falls back rather than dropping the gate.
let spells = empty_catalog();
let mut w = wire(2018, trainer_spell_state::RED, 1000, 20, 164);
w.req_spells = [78, 0, 0];
// Empty known set: the player knows nothing → the ability gate reads unmet on its own terms.
let svc = resolve_service(&w, TRAINER_TYPE_TRADESKILL, &spells, None, &HashSet::new());
assert_eq!(svc.spell_id, 2018);
assert!(svc.name.is_none(), "no catalog → name in flight");
assert_eq!(svc.cost, 1000);
assert_eq!(svc.category, TrainerServiceCategory::Unavailable);
assert_eq!(svc.level_req, 20);
// Unavailable service → the SKILL gate reads unmet (coarse, from the category — no per-gate
// wire bit); the ABILITY gate reads unmet because the empty known set doesn't contain spell 78
// (per-gate, not from the category). No catalog → the ability name falls back to "Spell 78".
assert_eq!(
svc.skill_req,
Some(TrainerSkillReq {
name: "Skill 164".to_string(),
rank: 100,
met: false,
})
);
assert_eq!(
svc.ability_reqs,
vec![TrainerAbilityReq {
name: "Spell 78".to_string(),
met: false,
}]
);
assert!(svc.is_trade_skill, "trainer_type 2 → tradeskill");
assert!(svc.prof_first_rank == w.is_primary_prof_first_rank);
// No skill-line catalog → skill_line 0 (the engine drops it from the tree) and an empty name.
assert_eq!(svc.skill_line, 0);
assert_eq!(svc.skill_line_name, "");
// No skill gate when req_skill is 0.
let plain = resolve_service(
&wire(78, trainer_spell_state::GREEN, 50, 5, 0),
0,
&spells,
None,
&HashSet::new(),
);
assert_eq!(plain.skill_req, None);
assert!(plain.ability_reqs.is_empty());
assert!(!plain.is_trade_skill);
}
/// A prerequisite ability's met/unmet is per-gate — whether the player KNOWS that spell — and is
/// decoupled from the service's overall category (wow-re `system/ui/scratch/trainer-requirement.md`).
/// The director's exact case: an UNAVAILABLE spell (gated by level) whose already-learned prev-rank
/// prerequisite must still read met (white), not red. Deterministic (no client data needed).
#[test]
fn ability_req_met_tracks_known_spells_not_the_service_category() {
let spells = empty_catalog();
let mut w = wire(845, trainer_spell_state::RED, 100, 20, 0); // unavailable (gated by level)
w.req_spells = [78, 0, 0]; // requires Heroic Strike (78)
// Player doesn't know 78 → the prerequisite is unmet (red).
let unknown = resolve_service(&w, 0, &spells, None, &HashSet::new());
assert_eq!(unknown.category, TrainerServiceCategory::Unavailable);
assert!(!unknown.ability_reqs[0].met, "prereq unknown → unmet");
// Player knows 78 → the prerequisite is met (white) EVEN THOUGH the service stays unavailable.
let known: HashSet<u32> = [78].into_iter().collect();
let learned = resolve_service(&w, 0, &spells, None, &known);
assert_eq!(learned.category, TrainerServiceCategory::Unavailable);
assert!(
learned.ability_reqs[0].met,
"prereq known → met, decoupled from the unavailable service"
);
}
/// The prerequisite name carries its rank the way the client does — `"Name (Rank)"` — resolved on
/// real 5875 data: Heroic Strike (78) is Rank 1, so a service requiring it shows "Heroic Strike
/// (Rank 1)", met iff the player knows 78. Skips without client data.
#[test]
fn ability_req_shows_the_required_rank_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let mut w = wire(846, trainer_spell_state::RED, 100, 20, 0);
w.req_spells = [78, 0, 0]; // Heroic Strike Rank 1
let known: HashSet<u32> = [78].into_iter().collect();
let svc = resolve_service(&w, 0, &spells, None, &known);
assert_eq!(
svc.ability_reqs,
vec![TrainerAbilityReq {
name: "Heroic Strike (Rank 1)".to_string(),
met: true,
}],
"the prereq shows its rank and reads met because the player knows it"
);
// Not known → same name, but unmet (red).
let svc = resolve_service(&w, 0, &spells, None, &HashSet::new());
assert!(!svc.ability_reqs[0].met);
assert_eq!(svc.ability_reqs[0].name, "Heroic Strike (Rank 1)");
}
#[test]
fn snapshot_is_none_when_closed_and_lists_services_when_open() {
let spells = empty_catalog();
let mut open = TrainerOpen::default();
assert!(snap(&open, &spells).is_none());
open.open(
0x42,
0,
vec![
wire(78, trainer_spell_state::GREEN, 100, 10, 0),
wire(79, trainer_spell_state::GRAY, 200, 0, 0),
],
"Learn from me.".into(),
);
let state = snap(&open, &spells).expect("open → Some");
assert_eq!(state.greeting, "Learn from me.");
assert_eq!(state.services.len(), 2);
assert_eq!(
state.services[0].category,
TrainerServiceCategory::Available
);
assert_eq!(state.services[1].category, TrainerServiceCategory::Used);
open.clear();
assert!(snap(&open, &spells).is_none());
assert_eq!(open.trainer, None);
}
}
+160
View File
@@ -0,0 +1,160 @@
//! The trainer window's two **byte-transcribed laws** — the icon and the tooltip.
//!
//! They live together because they are the same *kind* of thing (a direct transcription of one Lua
//! binding each, with the binary's addresses in the doc), and apart from the feed because they are
//! the part that must never drift toward "what seems consistent". The whole point of both records
//! is that they **disagree with each other** on the same row: the icon needs a trainer-type gate the
//! tooltip does not have, and the icon pins the *wire* wrapper where the tooltip hops to the
//! *taught* spell. On ~806 of the shipped corpus's trainer services the reference client visibly
//! shows one spell's icon above another spell's tooltip. Putting them side by side makes that
//! deliberate, and makes a future "unification" obviously wrong rather than obviously tidy.
//!
//! Sources: wow-re `system/ui/scratch/spell-icon-substitution-law.md` §1 (the icon) and
//! `system/ui/scratch/trainer-service-tooltip-law.md` (the tooltip).
use benilla_formats::{
SpellCatalog, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_LEARN_PET_SPELL,
SPELL_EFFECT_LEARN_SPELL,
};
use benilla_protocol::messages::trainer_spell_state;
use benilla_ui::script::{TrainerServiceCategory, TrainerTooltip};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::NetCommands;
use crate::ui_items::item_icon;
use super::TRAINER_TYPE_TRADESKILL;
/// The trainer's **icon law** — `GetTrainerServiceIcon 0x4d8f50`, byte-verified whole in wow-re
/// (`system/ui/scratch/spell-icon-substitution-law.md` §1; the binding is not a marshal, the entire
/// resolution is inlined there). Three gates, then a fallback:
///
/// 1. **The trainer type is 2** (tradeskill/profession — `[0xb73a08]`, the trainer-list packet's
/// type dword stored verbatim). A class/mount/pet trainer never substitutes.
/// 2. **The WIRE spell has a learn-wrapper effect** in any of its three slots —
/// `SPELL_EFFECT_LEARN_SPELL` or `SPELL_EFFECT_LEARN_PET_SPELL` (`0x4d8ff5`/`0x4d8ffa`). The
/// **first** matching slot wins; its `EffectTriggerSpell` is the taught spell.
/// 3. **That taught spell creates an item** (`EffectItemType[0] != 0`, `0x4d906a`) → the created
/// item's `ItemDisplayInfo` icon.
///
/// Otherwise: the **WIRE (wrapper) spell's own `SpellIconID`** (`0x4d9008`). This is the fact
/// benilla had backwards — `esi` is pinned to the wire record at `0x4d8fd7` and is never reassigned
/// on any path reaching the fallback, so **the client never paints the taught spell's own icon at a
/// trainer**. We used to, which is what put a blue `Spell_Shadow_SealOfKings` crown on Copper
/// Shortsword: spell 2756 (the wrapper the server sends) hops to 2739 (the recipe), whose own
/// `SpellIconID` is that crown — while the law wants item 2847's sword.
///
/// A gate-3 hit whose item template is not cached yet returns `None` (the client pushes Lua `nil`
/// and repaints on the cache callback, `0x4d9140` → `TRAINER_UPDATE`). Our equivalent is free: the
/// template answer changes the snapshot, so [`feed_trainer`]'s diff re-fires `TRAINER_UPDATE` on its
/// own.
pub(super) fn service_icon(
wire_spell: u32,
trainer_type: u32,
spells: &SpellCatalog,
icons: Option<&ItemDisplays>,
items: &mut Items,
commands: &NetCommands,
) -> Option<String> {
let wire = spells.get(wire_spell)?;
if trainer_type == TRAINER_TYPE_TRADESKILL {
// Gate 2: the first learn-wrapper slot wins — a miss (or a zero trigger) falls straight
// through to the wire icon rather than scanning on, exactly as the loop's `je` does.
let taught = wire
.effects
.iter()
.position(|&e| e == SPELL_EFFECT_LEARN_SPELL || e == SPELL_EFFECT_LEARN_PET_SPELL)
.map(|i| wire.effect_trigger_spell[i])
.filter(|&t| t != 0);
// Gate 3: the taught spell's product item, slot 0 only.
if let Some(product) = taught
.and_then(|t| spells.get(t))
.map(|d| d.effect_item_type[0])
.filter(|&e| e != 0)
{
return items
.template(product, 0, commands)
.map(|t| t.display_info_id)
.and_then(|d| item_icon(icons, d));
}
}
wire.icon.clone()
}
/// The trainer's **tooltip law** — `SetTrainerService 0x5338b0`, byte-verified whole in wow-re
/// (`system/ui/scratch/trainer-service-tooltip-law.md`). It is a *selector*, not a renderer: the
/// binding emits no tooltip line of its own (verified negative — none of the four AddLine helpers
/// appears in its extent) and hands one of the two shared builders a subject. That is why this
/// returns a [`TrainerTooltip`] rather than any text.
///
/// ```text
/// for i in 0..3:
/// if WIRE.Effect[i] in {36 LEARN_SPELL, 57 LEARN_PET_SPELL}:
/// t = WIRE.EffectTriggerSpell[i]
/// if t resolves to a Spell.dbc row: # else: NEXT SLOT, not abandon
/// if TAUGHT.Attributes & 0x20: # the ITEM route
/// return Item(TAUGHT.EffectItemType[ TAUGHT.Effect[0]==24 ? i : 0 ])
/// return Spell(t, altCaster = WIRE.Effect[i] == 57)
/// return Spell(WIRE.id, altCaster = false) # the only path describing the wrapper
/// ```
///
/// Three things a re-implementation gets wrong by default, all of them ours to get right here:
///
/// - **The scan is stricter than the icon's.** A slot whose `EffectTriggerSpell[i]` is out of range
/// or resolves to a NULL row advances to the *next* slot (`jl`/`jg` → `0x5339f0`); the icon
/// binding's first match wins outright. So the two can pick different slots on the same row.
/// - **`Effect[0] == 24` is NOT the item-vs-spell decision** — `Attributes & 0x20` alone is. The
/// effect test only picks *which slot* the item id comes from, because the spell builder
/// `0x52e610` re-applies the same Attributes bit itself at `0x52e6d2` and redirects to the item
/// builder with `EffectItemType[0]`. A client that treats `Effect[0]==24` as the gate renders a
/// spell tooltip where the reference renders an item one. (Divergence population on the shipped
/// `Spell.dbc`: 1161 spells carry the bit, 1159 with `Effect[0]==24`; the two that don't — 2479
/// and 7920 — have `EffectItemType` all-zero, so both routes end at an empty tooltip.)
/// - **It hops where the icon pins.** [`service_icon`] describes the WIRE wrapper on its fallback;
/// this describes the TAUGHT spell on every path that resolves one, and has no trainer-type gate
/// at all. On ~806 shipped services that means one spell's icon over another spell's tooltip —
/// the reference client's own behaviour, not a bug to reconcile.
pub(super) fn service_tooltip(wire_spell: u32, spells: &SpellCatalog) -> TrainerTooltip {
let wire_only = TrainerTooltip::Spell {
spell_id: wire_spell,
alt_caster: false,
};
let Some(wire) = spells.get(wire_spell) else {
return wire_only;
};
for i in 0..3 {
let effect = wire.effects[i];
if effect != SPELL_EFFECT_LEARN_SPELL && effect != SPELL_EFFECT_LEARN_PET_SPELL {
continue;
}
let trigger = wire.effect_trigger_spell[i];
// Unresolvable trigger → the NEXT slot, not the fallback (the binding's `jl`/`jg`).
let Some(taught) = spells.get(trigger) else {
continue;
};
if taught.attributes & SPELL_ATTR_IS_TRADESKILL != 0 {
let slot = if taught.effects[0] == SPELL_EFFECT_CREATE_ITEM {
i
} else {
0
};
return TrainerTooltip::Item(taught.effect_item_type[slot]);
}
return TrainerTooltip::Spell {
spell_id: trigger,
alt_caster: effect == SPELL_EFFECT_LEARN_PET_SPELL,
};
}
wire_only
}
/// The green/red/gray colour a wire `state` byte maps to (decision 0237): GRAY → known, GREEN →
/// learnable, everything else (RED + any unexpected value) → gated.
pub(super) fn category(state: u8) -> TrainerServiceCategory {
match state {
trainer_spell_state::GRAY => TrainerServiceCategory::Used,
trainer_spell_state::GREEN => TrainerServiceCategory::Available,
_ => TrainerServiceCategory::Unavailable,
}
}
+428
View File
@@ -0,0 +1,428 @@
//! The app-side **trainer feed** (decision 0237 phase 3) — the inward half of the trainer seam
//! around [`benilla_ui::script`]'s `trainer` module, the twin of [`crate::ui_merchant`]'s merchant
//! feed.
//!
//! The net bridge fills [`TrainerOpen`] from the wire (`SMSG_TRAINER_LIST` → the trainer's services +
//! greeting, reached through the gossip trainer option). Each frame [`feed_trainer`] resolves each
//! wire [`TrainerSpell`] to a Lua-facing [`TrainerService`] — name/subtext from the spell catalog
//! (`Spell.dbc`, loaded whole at startup), the **icon** from its own byte-verified law
//! ([`service_icon`], which at a *tradeskill* trainer fronts the taught recipe's created item and so
//! does need the ask-once item-template query the item rows use), the skill-requirement name from
//! the skill-line catalog, the green/red/gray state straight off the wire
//! `state` byte — pushes the snapshot ([`UiScript::set_trainer`]), and fires `TRAINER_SHOW` on open /
//! `TRAINER_UPDATE` on a content change / `TRAINER_CLOSED` on clear. [`drain_trainer`] pulls the Lua
//! intents back out: the Train button's `BuyTrainerService` → `CMSG_TRAINER_BUY_SPELL` for the open
//! trainer, and `CloseTrainer` → a local clear (vanilla's client-side close sends no packet). A
//! successful buy answers `SMSG_TRAINER_BUY_SUCCEEDED` — the spell itself lands via
//! `SMSG_LEARNED_SPELL` (already in the book), and the net apply re-requests the list to repaint the
//! bought row green→gray; a refusal (`SMSG_TRAINER_BUY_FAILED`) stages into [`TrainerErrors`] for the
//! feed to surface on the window's red error line.
//!
//! The standardized NPC-session range guard ([`crate::ui_session`]) client-side-closes the window
//! when the player walks out of the trainer's service range (or the trainer despawns) — the same
//! `CloseTrainer` clear.
use std::collections::HashSet;
use benilla_formats::{SkillLineCatalog, SpellCatalog};
use benilla_protocol::messages::TrainerSpell;
use bevy::prelude::*;
use benilla_ui::script::{
ScriptValue, TrainerAbilityReq, TrainerService, TrainerServiceCategory, TrainerSkillReq,
TrainerState, UiScript,
};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
use crate::ui_spellbook::SkillLines;
/// `SMSG_TRAINER_LIST`'s `trainer_type` for a tradeskill trainer (0 class · 1 mount · 2 tradeskill ·
/// 3 pet). Used only to classify a service as tradeskill-vs-learn-spell for the Era
/// `IsTrainerServiceTradeSkill` surface — a whole-trainer approximation (per-service typing from the
/// spell's effects is a later refinement).
const TRAINER_TYPE_TRADESKILL: u32 = 2;
/// The open trainer, filled by the net bridge ([`crate::net`]) and read by [`feed_trainer`]. Holds
/// the trainer guid and its services exactly as the wire delivered them (`SMSG_TRAINER_LIST`), plus
/// the window-framing type and the greeting title. Cleared on a client-side close and on disconnect.
#[derive(Resource, Default)]
pub(crate) struct TrainerOpen {
/// The trainer whose window is open; `None` = no trainer open.
pub(crate) trainer: Option<u64>,
/// The wire services (order = 1-based display order).
pub(crate) services: Vec<TrainerSpell>,
/// The window-framing kind (0 class · 1 mount · 2 tradeskill · 3 pet).
pub(crate) trainer_type: u32,
/// The trainer's greeting line (`SMSG_TRAINER_LIST`'s trailing string).
pub(crate) greeting: String,
}
impl TrainerOpen {
/// Open (or replace) the window with a trainer's freshly-listed services.
pub(crate) fn open(
&mut self,
trainer: u64,
trainer_type: u32,
services: Vec<TrainerSpell>,
greeting: String,
) {
self.trainer = Some(trainer);
self.trainer_type = trainer_type;
self.services = services;
self.greeting = greeting;
}
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
pub(crate) fn clear(&mut self) {
self.trainer = None;
self.services.clear();
self.trainer_type = 0;
self.greeting.clear();
}
/// Disconnect: drop the open window (mirrors the gossip/merchant session clears).
pub(crate) fn clear_session(&mut self) {
self.clear();
}
}
/// Trainer purchase refusals (`SMSG_TRAINER_BUY_FAILED`), staged by the net apply for the feed to
/// surface on the window's red error line — the merchant [`crate::ui_merchant::MerchantErrors`]
/// twin. Each entry is a [`benilla_protocol::messages::train_fail`] code.
#[derive(Resource, Default)]
pub(crate) struct TrainerErrors(pub Vec<u32>);
pub(crate) struct UiTrainerPlugin;
impl Plugin for UiTrainerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TrainerOpen>()
.init_resource::<TrainerErrors>()
.add_systems(
Update,
(
// Range-close before the feed so the clear turns into TRAINER_CLOSED the same frame;
// push before the input pass so an open/close is on screen the same frame; drain
// after it (mirrors ui_merchant/ui_gossip).
close_npc_session_out_of_range::<TrainerOpen>.before(feed_trainer),
feed_trainer.before(UiInput),
drain_trainer.after(UiInput),
),
);
}
}
/// The red error-line text for a trainer refusal (`SMSG_TRAINER_BUY_FAILED`'s
/// [`benilla_protocol::messages::train_fail`] code). The money case is the exact enUS `GlobalString`
/// (`ERR_NOT_ENOUGH_MONEY`); the skill/unavailable cases are best-effort English — the real client
/// builds these in C++, so their exact wording is a wow-re confirm item (rarely reached: the Train
/// button is disabled unless the service is available and affordable).
fn train_error_text(code: u32) -> String {
use benilla_protocol::messages::train_fail;
match code {
train_fail::NOT_ENOUGH_MONEY => "You don't have enough money.",
train_fail::NOT_ENOUGH_SKILL => "You do not have the required skill.",
_ => "That trainer service is not available.",
}
.to_string()
}
/// Resolve one wire [`TrainerSpell`] into the Lua-facing [`TrainerService`]: name/subtext/icon from
/// the spell catalog (`None` only before `Spell.dbc` has loaded — the row shows a placeholder), the
/// skill-req name from the skill-line catalog (falling back to `Skill <id>` if it hasn't loaded),
/// the ability-req names + rank from the same spell catalog, the state/cost/gates straight off the
/// wire. `known` is the player's known-spell set ([`PlayerActions::spells`]) — each prerequisite
/// ability is coloured by whether the player already knows that specific spell (see below).
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
fn resolve_service(
wire: &TrainerSpell,
trainer_type: u32,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
icons: Option<&ItemDisplays>,
items: &mut Items,
commands: &NetCommands,
) -> TrainerService {
// The trainer offers a LEARN wrapper (decision 0247); the ability it teaches — with the skill
// line the tree groups by, and the real display name/icon — is the taught spell. Resolve it once
// for both. `wire.spell` (the wrapper) stays the buy id below (CMSG_TRAINER_BUY_SPELL names it).
// A spell that teaches nothing (a plain ability, or before Spell.dbc loads) resolves to itself.
let taught = spells.learned_spell(wire.spell).unwrap_or(wire.spell);
let display = spells.get(taught);
let cat = category(wire.state);
// The SKILL gate has no per-gate "met" bit on the 5875 wire, so approximate it from the service's
// overall category (an unavailable service has some unmet gate). The real client computes the
// skill gate locally too — player skill value ≥ required, like the level gate the XML already
// checks with `UnitLevel` — so a faithful per-gate skill check waits only on threading the
// player's skill values here; the ability gate below already does its own per-gate check, so a
// skill-gated service is the one remaining coarse case (decision 0253).
let skill_met = cat != TrainerServiceCategory::Unavailable;
let skill_req = (wire.req_skill != 0).then(|| TrainerSkillReq {
name: skill_lines
.and_then(|c| c.line(wire.req_skill))
.map(|l| l.name.clone())
.unwrap_or_else(|| format!("Skill {}", wire.req_skill)),
rank: wire.req_skill_value,
met: skill_met,
});
// Each prerequisite ability is coloured by whether the player already KNOWS that specific spell —
// the byte-verified real-client mechanism (wow-re `system/ui/scratch/trainer-requirement.md`):
// `GetTrainerServiceAbilityReq`'s hasReq is `IsSpellKnown(reqSpellId)`, evaluated per-requirement
// and INDEPENDENT of the service's overall category — so a spell gated only by LEVEL still shows
// its already-learned prev-rank prerequisite WHITE, not red. The req id is a real ability id (not
// a learn wrapper — verified there too), so there's no hop: look it up directly. The name carries
// its rank exactly as the client does — `"Name (Rank)"` when the spell has a rank subtext, else
// the bare name (the client's `"%s (%s)"`). The client also ORs `KnownHigherRank`; benilla has no
// rank chain, and sequential trainer ranks never reach that clause, so the direct known-check
// covers every real case.
let ability_reqs = wire
.req_spells
.iter()
.filter(|&&s| s != 0)
.map(|&s| {
let name = match spells.get(s) {
Some(d) => match d.rank.as_deref() {
Some(rank) if !rank.is_empty() => format!("{} ({})", d.name, rank),
_ => d.name.clone(),
},
None => format!("Spell {s}"),
};
TrainerAbilityReq {
name,
met: known.contains(&s),
}
})
.collect();
// The tree's grouping key (decision 0247): the TAUGHT spell's skill line (`SkillLineAbility.dbc`
// → `SkillLine.dbc` name) — the wire wrapper id itself is never in `SkillLineAbility`, so grouping
// MUST go through the hop above. `0` when unresolved — the engine drops such a service from the
// tree, matching the client. A resolved id with a missing name falls back to `Skill <id>`. We log
// a genuine miss (catalog present but no line) so DBC gaps surface rather than silently swallowing
// a service the server offered.
let skill_line = skill_lines
.and_then(|c| c.spell_to_line(taught))
.unwrap_or(0);
if skill_line == 0 && skill_lines.is_some() {
debug!(
"ui_trainer: trainer spell {} (teaches {taught}) has no skill line — dropped from the tree",
wire.spell
);
}
let skill_line_name = if skill_line != 0 {
skill_lines
.and_then(|c| c.line(skill_line))
.map(|l| l.name.clone())
.unwrap_or_else(|| format!("Skill {skill_line}"))
} else {
String::new()
};
TrainerService {
spell_id: wire.spell,
name: display.map(|d| d.name.clone()),
subtext: display.and_then(|d| d.rank.clone()),
// The NAME/subtext hop through the taught spell stays (decisions 0247/0252 — the wrapper is
// not in SkillLineAbility, so grouping and display MUST hop). The ICON does not: it is its
// own byte-verified law over the WIRE spell ([`service_icon`]).
texture: service_icon(wire.spell, trainer_type, spells, icons, items, commands),
// The detail pane's description body, left empty by design. The real
// `GetTrainerServiceDescription` returns the spell's *tooltip* — `Spell.dbc`'s Description
// with its `$s1`/`$o1`/`$d`/`$a1` tokens substituted from the spell's effect base points,
// duration, and radius. Feeding the raw column would render the unsubstituted tokens
// (broken-looking, not faithful), so descriptions wait on a spell-tooltip token engine —
// its own arc, shared with the spellbook's hover tooltips — not a lone `SpellCatalog` column.
description: String::new(),
cost: wire.cost,
prof_first_rank: wire.is_primary_prof_first_rank,
category: cat,
level_req: u32::from(wire.req_level),
skill_req,
ability_reqs,
is_trade_skill: trainer_type == TRAINER_TYPE_TRADESKILL,
skill_line,
skill_line_name,
tooltip: service_tooltip(wire.spell, spells),
}
}
/// Build the Lua-facing snapshot from [`TrainerOpen`] + the spell/skill catalogs — `None` when no
/// trainer is open.
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
fn snapshot(
open: &TrainerOpen,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
icons: Option<&ItemDisplays>,
items: &mut Items,
commands: &NetCommands,
) -> Option<TrainerState> {
open.trainer?;
Some(TrainerState {
greeting: open.greeting.clone(),
is_tradeskill: open.trainer_type == TRAINER_TYPE_TRADESKILL,
services: open
.services
.iter()
.map(|w| {
resolve_service(
w,
open.trainer_type,
spells,
skill_lines,
known,
icons,
items,
commands,
)
})
.collect(),
// The engine synthesizes the skill-line tree in `set_trainer` — the app pushes only the flat
// services (each carrying its resolved skill line above).
groups: Vec::new(),
})
}
/// Push the current trainer into the VM and fire the show/update/close events on a transition (or a
/// content change). Diffed against a `Local` memory, exactly like the gossip/merchant feeds. A
/// different trainer while the window is already open is a real close+open (the client's `ShowUIPanel`
/// early-returns when visible, so the open sound only re-plays after a hide — decision 0096).
#[allow(clippy::too_many_arguments)]
fn feed_trainer(
script: Option<NonSendMut<UiScript>>,
open: Res<TrainerOpen>,
actions: Res<PlayerActions>,
spells: Option<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
// A tradeskill trainer's rows front the CREATED ITEM's icon, so the feed needs the ask-once
// template cache + `ItemDisplayInfo.dbc` — the tradeskill window's own pair ([`service_icon`]).
icons: Option<Res<ItemDisplays>>,
mut items: ResMut<Items>,
mut errors: ResMut<TrainerErrors>,
commands: Res<NetCommands>,
mut names: ResMut<NameCache>,
mut last: Local<Option<TrainerState>>,
mut last_trainer: Local<Option<u64>>,
mut last_name: Local<Option<String>>,
) {
let Some(mut script) = script else {
return;
};
// Refusals surface as the client's red error line (the merchant/equip/cast path's exact shape).
for code in errors.0.drain(..) {
script.fire_event(
"UI_ERROR_MESSAGE",
vec![ScriptValue::Str(train_error_text(code))],
);
}
// Nothing to resolve a name/icon from yet — try again once Spell.dbc lands.
let Some(spells) = spells.as_deref() else {
return;
};
// The tree groups by skill line (decision 0247), so the skill-line catalog is required, not
// optional: without it every service resolves to skill_line 0 and drops. A trainer only opens
// well after world-entry, by when both DBCs have loaded, so this gate never actually delays a
// real window — it just refuses to render an all-dropped empty tree.
let Some(skill_lines) = skill_lines.as_deref() else {
return;
};
let fresh = snapshot(
&open,
&spells.catalog,
Some(&skill_lines.catalog),
&actions.spells,
icons.as_deref(),
&mut items,
&commands,
);
// The trainer's name resolves through the NameCache (a creature-name query, ask-once — the
// real client's `UnitName("npc")`). `None`/empty while in flight; the title shows the static
// "Trainer" until it lands, then re-fires TRAINER_UPDATE with the name as arg1 (the diff below
// tracks the name too, so a name-only change still repaints the title). It rides an event arg
// rather than a `TrainerState` field so no benilla-ui engine change is needed — the merchant
// vendor-name pattern.
let trainer_name = open
.trainer
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
let name_changed = *last_name != trainer_name;
let switched = npc_switched(*last_trainer, open.trainer);
if fresh == *last && !name_changed && !switched {
return;
}
script.set_trainer(fresh.clone());
let name_arg = || vec![ScriptValue::Str(trainer_name.clone().unwrap_or_default())];
if switched {
// Close the old trainer, open the new: the frame hides then shows. The TRAINER_CLOSED routes
// through the window's OnHide → CloseTrainer, which queues a close intent — consume it here so
// the drain does NOT clear the trainer we just re-opened to (the merchant switch pattern).
script.fire_event("TRAINER_CLOSED", vec![]);
script.fire_event("TRAINER_SHOW", name_arg());
let _ = script.take_trainer_close();
} else {
match (&*last, &fresh) {
(None, Some(_)) => script.fire_event("TRAINER_SHOW", name_arg()),
(Some(_), Some(_)) => script.fire_event("TRAINER_UPDATE", name_arg()),
(Some(_), None) => script.fire_event("TRAINER_CLOSED", vec![]),
(None, None) => {}
}
}
*last = fresh;
*last_trainer = open.trainer;
*last_name = trainer_name;
}
/// The trainer window is an NPC session: the standardized range guard ([`crate::ui_session`])
/// client-side-closes it — the exact `CloseTrainer` clear — when the player walks out of the
/// trainer's service range or the trainer despawns.
impl NpcSession for TrainerOpen {
fn npc(&self) -> Option<u64> {
self.trainer
}
fn close(&mut self) {
self.clear();
}
}
/// Drain the Lua intents: the Train button's buys (`BuyTrainerService` queues the chosen row's spell
/// id) → `CMSG_TRAINER_BUY_SPELL` for the open trainer; a close → a local clear (no packet, vanilla).
/// The server answers a buy with `SMSG_TRAINER_BUY_SUCCEEDED` (→ the spell lands via
/// `SMSG_LEARNED_SPELL`, and the apply re-requests the list to repaint the row gray) or
/// `SMSG_TRAINER_BUY_FAILED` (→ the window's error line via [`TrainerErrors`]).
fn drain_trainer(
script: Option<NonSendMut<UiScript>>,
mut open: ResMut<TrainerOpen>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
for spell_id in script.take_trainer_buys() {
let Some(trainer) = open.trainer else {
debug!(
"ui_trainer: BuyTrainerService(spell {spell_id}) with no open trainer — ignored"
);
continue;
};
debug!("ui_trainer: buy service (trainer {trainer:#x}, spell {spell_id})");
let _ = commands
.0
.send(ClientCommand::TrainerBuySpell { trainer, spell_id });
}
if script.take_trainer_close() {
debug!("ui_trainer: client-side close (no packet)");
open.clear();
}
}
mod law;
use law::{category, service_icon, service_tooltip};
#[cfg(test)]
mod tests;
+643
View File
@@ -0,0 +1,643 @@
//! The trainer feed's tests — the two byte-transcribed laws ([`super::law`]), the wire→service
//! resolution, and the snapshot.
use super::*;
use benilla_formats::{
ItemDisplay, ItemDisplayCatalog, SpellDisplay, SPELL_ATTR_IS_TRADESKILL,
SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL,
};
use benilla_protocol::messages::trainer_spell_state;
use benilla_ui::script::TrainerTooltip;
use std::collections::HashMap;
/// An empty spell catalog (`Spell.dbc` not resolved) — the resolver's name/icon lookups miss.
fn empty_catalog() -> SpellCatalog {
SpellCatalog::from_displays(HashMap::new())
}
/// The feed-side trio [`service_icon`] needs — the shared seam, since the tradeskill and craft
/// icon laws terminate in the same ask-once cache. Gate 3's "template in flight" arm is the
/// default; the tests that want a landed template seed one.
use crate::items::TestDeps as Deps;
/// [`resolve_service`] with the icon trio defaulted — the shape the gate/state tests want.
fn resolve_service(
wire: &TrainerSpell,
trainer_type: u32,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
) -> TrainerService {
let mut deps = Deps::new();
super::resolve_service(
wire,
trainer_type,
spells,
skill_lines,
known,
None,
&mut deps.items,
&deps.commands,
)
}
fn wire(spell: u32, state: u8, cost: u32, req_level: u8, req_skill: u32) -> TrainerSpell {
TrainerSpell {
spell,
state,
cost,
can_learn_primary_prof: false,
is_primary_prof_first_rank: false,
req_level,
req_skill,
req_skill_value: if req_skill != 0 { 100 } else { 0 },
req_spells: [0, 0, 0],
}
}
/// [`snapshot`] with the icon trio defaulted.
fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option<TrainerState> {
let mut deps = Deps::new();
snapshot(
open,
spells,
None,
&HashSet::new(),
None,
&mut deps.items,
&deps.commands,
)
}
/// The icon law's fixture (wow-re `spell-icon-substitution-law.md` §1's shape, synthetic so
/// every gate is reachable): wrapper 100 teaches 200 via a slot-0 `LEARN_SPELL`; 200 creates
/// item 777; item 777's display 5 carries the "real" art. Each spell has a DISTINCT icon so a
/// wrong arm is named by the assertion, not merely unequal.
fn icon_catalog() -> SpellCatalog {
let wrapper = SpellDisplay {
name: "Copper Shortsword".into(),
icon: Some("WRAPPER".into()),
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let taught = SpellDisplay {
name: "Copper Shortsword".into(),
icon: Some("TAUGHT".into()),
effect_item_type: [777, 0, 0],
..Default::default()
};
SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, taught)]))
}
/// The product item's template + `ItemDisplayInfo` row, landed.
fn landed_item(deps: &mut Deps) -> ItemDisplays {
let mut t = crate::items::test_template("Copper Shortsword");
t.display_info_id = 5;
deps.items.insert_template(777, Some(t));
ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::from([(
5,
ItemDisplay {
icon: Some("ITEM".into()),
..Default::default()
},
)])))
}
fn icon_of(
spells: &SpellCatalog,
trainer_type: u32,
wire_spell: u32,
land: bool,
) -> Option<String> {
let mut deps = Deps::new();
let icons = land.then(|| landed_item(&mut deps));
service_icon(
wire_spell,
trainer_type,
spells,
icons.as_ref(),
&mut deps.items,
&deps.commands,
)
}
/// The trainer icon law, arm by arm. The director's report — a blue `Spell_Shadow_SealOfKings`
/// crown where the sword belongs — was BOTH halves of this wrong: we substituted nothing, and we
/// fell back to the taught spell's icon, which the client never paints at a trainer on any path.
#[test]
fn trainer_icon_substitutes_the_product_only_at_a_tradeskill_trainer() {
let spells = icon_catalog();
// Gate 1+2+3 all pass, template landed → the created ITEM's icon.
assert_eq!(
icon_of(&spells, TRAINER_TYPE_TRADESKILL, 100, true).as_deref(),
Some("ITEM")
);
// Gate 1 fails (a class trainer): the WIRE spell's own icon — never the taught spell's.
// This is the corrected fallback; the old code returned "TAUGHT" here.
assert_eq!(
icon_of(&spells, 0, 100, true).as_deref(),
Some("WRAPPER"),
"a class trainer serves the wrapper's own icon, not the taught spell's"
);
// Gate 2 fails (no learn-wrapper effect in any slot) → the wire spell's own icon. Spell 200
// creates an item, so only the missing wrapper effect can be keeping the substitution off.
assert_eq!(
icon_of(&spells, TRAINER_TYPE_TRADESKILL, 200, true).as_deref(),
Some("TAUGHT"),
"200 IS the wire spell here, so its own icon is the right answer"
);
// A spell with no record at all → nil (every failure funnels there, 0x4d911c).
assert_eq!(icon_of(&spells, TRAINER_TYPE_TRADESKILL, 999, true), None);
}
/// Gate 3: a wrapper whose taught spell creates NO item falls back to the wire icon — the
/// class/pet-trainer population, which is why the fallback arm has to be right.
#[test]
fn trainer_icon_falls_back_when_the_taught_spell_makes_no_item() {
let wrapper = SpellDisplay {
icon: Some("WRAPPER".into()),
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let taught = SpellDisplay {
icon: Some("TAUGHT".into()),
..Default::default() // effect_item_type all zero
};
let spells = SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, taught)]));
assert_eq!(
icon_of(&spells, TRAINER_TYPE_TRADESKILL, 100, true).as_deref(),
Some("WRAPPER")
);
}
/// The wrapper scan covers all three effect slots and accepts `LEARN_PET_SPELL` (57) as well as
/// `LEARN_SPELL` (36) — the paired `cmp ecx,0x24` / `cmp ecx,0x39` at `0x4d8ff5`/`0x4d8ffa`.
#[test]
fn trainer_icon_scans_every_effect_slot_for_either_learn_effect() {
let wrapper = SpellDisplay {
icon: Some("WRAPPER".into()),
effects: [0, SPELL_EFFECT_LEARN_PET_SPELL, 0],
effect_trigger_spell: [0, 200, 0],
..Default::default()
};
let taught = SpellDisplay {
effect_item_type: [777, 0, 0],
..Default::default()
};
let spells = SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, taught)]));
assert_eq!(
icon_of(&spells, TRAINER_TYPE_TRADESKILL, 100, true).as_deref(),
Some("ITEM"),
"a pet-learn effect in slot 1 substitutes just the same"
);
}
/// The in-flight case: gate 3 passes but the item template is not cached, so the icon reads
/// `None` **and the template gets asked for exactly once**. The real client pushes Lua nil here
/// and repaints from the cache callback (`0x4d9140` → `TRAINER_UPDATE`); our equivalent is the
/// feed's own snapshot diff, which re-fires once the answer changes the texture.
#[test]
fn trainer_icon_is_nil_until_the_product_template_lands_and_asks_once() {
let spells = icon_catalog();
let mut deps = Deps::new();
let icons = ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::new()));
let first = service_icon(
100,
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&icons),
&mut deps.items,
&deps.commands,
);
assert_eq!(first, None, "no template yet → nil, not the wrapper's icon");
assert_eq!(
deps.queried_entries(),
vec![777],
"and the created item's template was asked for"
);
// A second frame before the answer lands must not re-ask (the ask-once cache).
let _ = service_icon(
100,
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&icons),
&mut deps.items,
&deps.commands,
);
assert!(
deps.queried_entries().is_empty(),
"asked once, not per frame"
);
}
/// The director's exact case on the real shipped `Spell.dbc` — the check wow-re's §7 pins:
/// spell 2756 is the wrapper a Blacksmithing trainer sends, 2739 the recipe it teaches, 2847 the
/// sword. At a tradeskill trainer the law must reach for item 2847; at a class trainer it must
/// serve 2756's own icon. Skips without client data.
#[test]
fn trainer_icon_on_real_data_reaches_for_the_crafted_item() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
// The two textures a wrong law produces, straight off the shipped DBC.
let wrapper_icon = spells.get(2756).unwrap().icon.clone();
let recipe_icon = spells.get(2739).unwrap().icon.clone();
assert_eq!(
recipe_icon.as_deref(),
Some("Interface\\Icons\\Spell_Shadow_SealOfKings"),
"the blue crown the director saw IS the recipe spell's own icon"
);
// A tradeskill trainer: gate 3 fires for item 2847 and the icon waits on the template —
// crucially it is NOT the crown.
let mut deps = Deps::new();
let icons = ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::new()));
let icon = service_icon(
2756,
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&icons),
&mut deps.items,
&deps.commands,
);
assert_eq!(
icon, None,
"waiting on the item template, not painting the crown"
);
assert_eq!(
deps.queried_entries(),
vec![2847],
"the law reached for the crafted sword's template"
);
// A class trainer with the same wire spell: the wrapper's own icon, not the recipe's.
let mut deps = Deps::new();
let class_icon = service_icon(2756, 0, &spells, None, &mut deps.items, &deps.commands);
assert_eq!(class_icon, wrapper_icon);
assert_ne!(
class_icon, recipe_icon,
"the taught spell's icon is never painted at a trainer"
);
}
/// The learn-spell hop end-to-end on real 5875 data (decision 0247): a warrior trainer sends the
/// LEARN wrapper 1605 ("learn Heroic Strike"), which is not in SkillLineAbility — resolve_service
/// must hop through the taught spell (78) to group it under Arms (26) and show its name, while the
/// BUY id stays the wrapper (1605) the server expects. This is the exact failure that emptied the
/// tree. Skips without client data.
#[test]
fn resolve_hops_the_learn_wrapper_to_the_taught_ability() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let skills = benilla_formats::load_skill_line_catalog(&mut chain).expect("load skill lines");
let svc = resolve_service(
&wire(1605, trainer_spell_state::GREEN, 10, 1, 0),
0,
&spells,
Some(&skills),
&HashSet::new(),
);
assert_eq!(svc.spell_id, 1605, "the buy id stays the wire wrapper");
assert_eq!(
svc.skill_line, 26,
"grouped under the taught ability's Arms line"
);
assert_eq!(svc.skill_line_name, "Arms");
assert_eq!(
svc.name.as_deref(),
Some("Heroic Strike"),
"display resolves through the taught spell"
);
}
#[test]
fn category_maps_the_wire_state_byte() {
assert_eq!(
category(trainer_spell_state::GREEN),
TrainerServiceCategory::Available
);
assert_eq!(
category(trainer_spell_state::RED),
TrainerServiceCategory::Unavailable
);
assert_eq!(
category(trainer_spell_state::GRAY),
TrainerServiceCategory::Used
);
// An unexpected value is treated as gated (safe default), never "learnable".
assert_eq!(category(99), TrainerServiceCategory::Unavailable);
}
#[test]
fn resolve_reads_cost_state_and_gates_with_no_catalog() {
// Empty catalog (Spell.dbc not resolved): name/subtext/icon nil, the wire fields still land,
// and the skill-req name falls back rather than dropping the gate.
let spells = empty_catalog();
let mut w = wire(2018, trainer_spell_state::RED, 1000, 20, 164);
w.req_spells = [78, 0, 0];
// Empty known set: the player knows nothing → the ability gate reads unmet on its own terms.
let svc = resolve_service(&w, TRAINER_TYPE_TRADESKILL, &spells, None, &HashSet::new());
assert_eq!(svc.spell_id, 2018);
assert!(svc.name.is_none(), "no catalog → name in flight");
assert_eq!(svc.cost, 1000);
assert_eq!(svc.category, TrainerServiceCategory::Unavailable);
assert_eq!(svc.level_req, 20);
// Unavailable service → the SKILL gate reads unmet (coarse, from the category — no per-gate
// wire bit); the ABILITY gate reads unmet because the empty known set doesn't contain spell 78
// (per-gate, not from the category). No catalog → the ability name falls back to "Spell 78".
assert_eq!(
svc.skill_req,
Some(TrainerSkillReq {
name: "Skill 164".to_string(),
rank: 100,
met: false,
})
);
assert_eq!(
svc.ability_reqs,
vec![TrainerAbilityReq {
name: "Spell 78".to_string(),
met: false,
}]
);
assert!(svc.is_trade_skill, "trainer_type 2 → tradeskill");
assert!(svc.prof_first_rank == w.is_primary_prof_first_rank);
// No skill-line catalog → skill_line 0 (the engine drops it from the tree) and an empty name.
assert_eq!(svc.skill_line, 0);
assert_eq!(svc.skill_line_name, "");
// No skill gate when req_skill is 0.
let plain = resolve_service(
&wire(78, trainer_spell_state::GREEN, 50, 5, 0),
0,
&spells,
None,
&HashSet::new(),
);
assert_eq!(plain.skill_req, None);
assert!(plain.ability_reqs.is_empty());
assert!(!plain.is_trade_skill);
}
/// A prerequisite ability's met/unmet is per-gate — whether the player KNOWS that spell — and is
/// decoupled from the service's overall category (wow-re `system/ui/scratch/trainer-requirement.md`).
/// The director's exact case: an UNAVAILABLE spell (gated by level) whose already-learned prev-rank
/// prerequisite must still read met (white), not red. Deterministic (no client data needed).
#[test]
fn ability_req_met_tracks_known_spells_not_the_service_category() {
let spells = empty_catalog();
let mut w = wire(845, trainer_spell_state::RED, 100, 20, 0); // unavailable (gated by level)
w.req_spells = [78, 0, 0]; // requires Heroic Strike (78)
// Player doesn't know 78 → the prerequisite is unmet (red).
let unknown = resolve_service(&w, 0, &spells, None, &HashSet::new());
assert_eq!(unknown.category, TrainerServiceCategory::Unavailable);
assert!(!unknown.ability_reqs[0].met, "prereq unknown → unmet");
// Player knows 78 → the prerequisite is met (white) EVEN THOUGH the service stays unavailable.
let known: HashSet<u32> = [78].into_iter().collect();
let learned = resolve_service(&w, 0, &spells, None, &known);
assert_eq!(learned.category, TrainerServiceCategory::Unavailable);
assert!(
learned.ability_reqs[0].met,
"prereq known → met, decoupled from the unavailable service"
);
}
/// The prerequisite name carries its rank the way the client does — `"Name (Rank)"` — resolved on
/// real 5875 data: Heroic Strike (78) is Rank 1, so a service requiring it shows "Heroic Strike
/// (Rank 1)", met iff the player knows 78. Skips without client data.
#[test]
fn ability_req_shows_the_required_rank_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let mut w = wire(846, trainer_spell_state::RED, 100, 20, 0);
w.req_spells = [78, 0, 0]; // Heroic Strike Rank 1
let known: HashSet<u32> = [78].into_iter().collect();
let svc = resolve_service(&w, 0, &spells, None, &known);
assert_eq!(
svc.ability_reqs,
vec![TrainerAbilityReq {
name: "Heroic Strike (Rank 1)".to_string(),
met: true,
}],
"the prereq shows its rank and reads met because the player knows it"
);
// Not known → same name, but unmet (red).
let svc = resolve_service(&w, 0, &spells, None, &HashSet::new());
assert!(!svc.ability_reqs[0].met);
assert_eq!(svc.ability_reqs[0].name, "Heroic Strike (Rank 1)");
}
#[test]
fn snapshot_is_none_when_closed_and_lists_services_when_open() {
let spells = empty_catalog();
let mut open = TrainerOpen::default();
assert!(snap(&open, &spells).is_none());
open.open(
0x42,
0,
vec![
wire(78, trainer_spell_state::GREEN, 100, 10, 0),
wire(79, trainer_spell_state::GRAY, 200, 0, 0),
],
"Learn from me.".into(),
);
let state = snap(&open, &spells).expect("open → Some");
assert_eq!(state.greeting, "Learn from me.");
assert_eq!(state.services.len(), 2);
assert_eq!(
state.services[0].category,
TrainerServiceCategory::Available
);
assert_eq!(state.services[1].category, TrainerServiceCategory::Used);
open.clear();
assert!(snap(&open, &spells).is_none());
assert_eq!(open.trainer, None);
}
/// A wrapper spell whose slot `slot` carries `effect` and triggers `trigger`.
fn wrapper(effect: u32, slot: usize, trigger: u32) -> SpellDisplay {
let mut d = SpellDisplay {
name: "Learn Something".into(),
icon: Some("WRAPPER".into()),
..Default::default()
};
d.effects[slot] = effect;
d.effect_trigger_spell[slot] = trigger;
d
}
/// The plain taught ability — no tradeskill bit, so the SPELL route.
fn taught_ability() -> SpellDisplay {
SpellDisplay {
name: "Heroic Strike".into(),
..Default::default()
}
}
/// A taught RECIPE — the tradeskill bit set, `Effect[0] == CREATE_ITEM`, product in every slot
/// so a wrong-slot read is named by the assertion rather than reading zero.
fn taught_recipe() -> SpellDisplay {
SpellDisplay {
name: "Copper Shortsword".into(),
attributes: SPELL_ATTR_IS_TRADESKILL,
effects: [SPELL_EFFECT_CREATE_ITEM, 0, 0],
effect_item_type: [2847, 3333, 4444],
..Default::default()
}
}
/// The ITEM arm: at a trainer, a taught spell carrying the tradeskill bit is described by its
/// CREATED ITEM's tooltip — the director's own case, 2756 → 2739 → item 2847.
#[test]
fn tooltip_takes_the_item_arm_when_the_taught_spell_is_a_recipe() {
let spells = SpellCatalog::from_displays(HashMap::from([
(2756, wrapper(SPELL_EFFECT_LEARN_SPELL, 0, 2739)),
(2739, taught_recipe()),
]));
assert_eq!(service_tooltip(2756, &spells), TrainerTooltip::Item(2847));
}
/// The SPELL arm hops: a class trainer's service describes the TAUGHT ability, not the wrapper.
/// This is the half that disagrees with the icon, which paints the WIRE spell's art — the two
/// are different records on the same row, by design.
#[test]
fn tooltip_hops_to_the_taught_spell_where_the_icon_pins_the_wire() {
let spells = SpellCatalog::from_displays(HashMap::from([
(100, wrapper(SPELL_EFFECT_LEARN_SPELL, 0, 200)),
(200, taught_ability()),
]));
assert_eq!(
service_tooltip(100, &spells),
TrainerTooltip::Spell {
spell_id: 200,
alt_caster: false,
},
);
// The icon law, same row, same catalog: the WIRE spell's own art. Pinned together so the
// disagreement is visible in one place and can't be "fixed" into agreement.
let mut deps = Deps::new();
assert_eq!(
super::service_icon(100, 0, &spells, None, &mut deps.items, &deps.commands),
Some("WRAPPER".into()),
);
}
/// `altCaster` is set by exactly one thing: the matched slot being `LEARN_PET_SPELL`. It gates
/// both the totems and the reagents blocks in the builder.
#[test]
fn tooltip_sets_alt_caster_only_for_a_pet_learn_wrapper() {
let spells = SpellCatalog::from_displays(HashMap::from([
(100, wrapper(SPELL_EFFECT_LEARN_PET_SPELL, 0, 200)),
(200, taught_ability()),
]));
assert_eq!(
service_tooltip(100, &spells),
TrainerTooltip::Spell {
spell_id: 200,
alt_caster: true,
},
);
}
/// The scan is STRICTER than the icon's: a learn slot whose trigger doesn't resolve advances to
/// the NEXT slot rather than abandoning the substitution. Slot 0 triggers a spell that isn't in
/// the catalog; slot 1 triggers a real one, and slot 1 is what must win.
#[test]
fn tooltip_scan_advances_past_an_unresolvable_trigger() {
let mut w = wrapper(SPELL_EFFECT_LEARN_SPELL, 0, 999_999);
w.effects[1] = SPELL_EFFECT_LEARN_SPELL;
w.effect_trigger_spell[1] = 200;
let spells = SpellCatalog::from_displays(HashMap::from([(100, w), (200, taught_ability())]));
assert_eq!(
service_tooltip(100, &spells),
TrainerTooltip::Spell {
spell_id: 200,
alt_caster: false,
},
);
}
/// The item SLOT quirk (`0x52e610`'s own redirect at `0x52e6d2`): `Attributes & 0x20` alone
/// decides item-vs-spell. `Effect[0] == 24` only picks WHICH slot the item id comes from — the
/// matched wrapper slot `i` when it holds, slot 0 when it doesn't. Here the wrapper's learn
/// effect is in slot 1, so a recipe reads `EffectItemType[1]` while a non-`CREATE_ITEM`
/// tradeskill spell reads `EffectItemType[0]`.
#[test]
fn tooltip_item_slot_follows_the_effect_gate_not_the_attribute_gate() {
// The learn effect in slot 1, slot 0 empty. Built per-use: SpellDisplay is not Clone.
let slot1_wrapper = || {
let mut w = wrapper(SPELL_EFFECT_LEARN_SPELL, 1, 200);
w.effects[0] = 0;
w
};
let spells = SpellCatalog::from_displays(HashMap::from([
(100, slot1_wrapper()),
(200, taught_recipe()),
]));
assert_eq!(
service_tooltip(100, &spells),
TrainerTooltip::Item(3333),
"Effect[0]==CREATE_ITEM -> EffectItemType[matched slot]"
);
// Same wrapper, a taught spell that carries the bit WITHOUT Effect[0]==24: still the item
// arm (the bit alone decides), but off slot 0.
let mut odd = taught_recipe();
odd.effects[0] = 6;
let spells = SpellCatalog::from_displays(HashMap::from([(100, slot1_wrapper()), (200, odd)]));
assert_eq!(
service_tooltip(100, &spells),
TrainerTooltip::Item(2847),
"the bit alone still routes to the item builder, from slot 0"
);
}
/// No learn wrapper at all (a direct ability, or `Spell.dbc` not loaded): the WIRE spell — the
/// only path that describes the wrapper.
#[test]
fn tooltip_falls_back_to_the_wire_spell() {
let spells = SpellCatalog::from_displays(HashMap::from([(100, taught_ability())]));
assert_eq!(
service_tooltip(100, &spells),
TrainerTooltip::Spell {
spell_id: 100,
alt_caster: false,
},
);
// Catalog miss (before Spell.dbc lands) — same answer, no panic.
assert_eq!(
service_tooltip(100, &empty_catalog()),
TrainerTooltip::Spell {
spell_id: 100,
alt_caster: false,
},
);
}
+13 -1
View File
@@ -261,7 +261,19 @@ fn wmo_pin_probe() {
// the real placement transform, so portal projection sees what the runtime sees.
let eye_bevy = placed.world_from_local.transform_point3(wow_to_bevy(eye));
let look_bevy = placed.world_from_local.transform_point3(wow_to_bevy(look));
let clip = Mat4::perspective_rh(0.9, 16.0 / 9.0, 0.1, 1000.0)
// The app's real projection by default (`CAM_FOVY`; near/far don't matter — the flood clips
// against the 4 side planes only). `WOW_PIN_FOVY`/`WOW_PIN_ASPECT` override both to replay a
// dump header's recorded values exactly (a window is rarely 16:9).
let scalar = |var: &str, default: f32| {
std::env::var(var).map_or(default, |s| {
s.trim()
.parse()
.unwrap_or_else(|_| panic!("{var} wants a number; got {s:?}"))
})
};
let fovy = scalar("WOW_PIN_FOVY", crate::player::CAM_FOVY);
let aspect = scalar("WOW_PIN_ASPECT", 16.0 / 9.0);
let clip = Mat4::perspective_rh(fovy, aspect, 0.1, 1000.0)
* Mat4::look_at_rh(eye_bevy, look_bevy, Vec3::Y);
let model = &subject.model;
+42 -2
View File
@@ -167,6 +167,18 @@ pub struct WmoPortalInstance {
/// PVS. Seeded all-`true` (everything shows until the first compute, and forever for portal-less
/// props).
pub visible: Vec<bool>,
/// Per-group **ever-visited** latch — the client's render-record persistence (wow-re
/// `wmo-record-persistence.md`, landed wow-re main @`00a766f6`): the visit callback `0x685d70`
/// creates a per-(instance,group) record the first time the flood visits a group, the record
/// pools are cleared only at world init/teardown, and a recorded MLIQ group's **liquid surface
/// draws every frame with no portal/frustum re-check** (`0xc7cb04` list, walk `0x684cd0 →
/// 0x6b62e0`). So liquid is gated on *ever seen*, not on this frame's PVS — the Great Forge's
/// walkway-level pool must not vanish when its group drops out of the flood. Latched by
/// [`compute_wmo_pvs`] from `visible`; starts all-`false` (a never-visited group's liquid is
/// genuinely absent in the reference until first visit). Lifetime is the placement's residency,
/// not the world session — a restreamed placement re-latches from the first flood, a small
/// divergence from the client's global pools noted in the B65 decision record.
pub liquid_visited: Vec<bool>,
/// Per-group **whole-group submersion** (indexed by absolute group index), from the group's
/// MOGP `groupLiquid` override: `Some(kind)` means standing anywhere inside this group is
/// standing under liquid — at every Z, with no surface grid involved at all.
@@ -275,8 +287,23 @@ fn compute_wmo_pvs(
// The terrain leg of the seed's down-ray, sampled once for the camera's column: the client casts the
// same segment at the ground and drops the WMO hit when the ground is nearer (`FUN_006821f0`).
let terrain = terrain_height_under(&streamer, &adt_tiles, eye_world);
let dump = std::mem::take(&mut probe.dump_requested);
let mut dump_text = dump.then(|| format!("eye world: {eye_world:?}\n"));
// `WOW_CULLDUMP=1` re-requests the dump every frame (last writer wins): a headless capture has
// no panel button, and the first frames race asset residency, so a one-shot request from
// startup would photograph an empty world.
static ENV_DUMP: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let dump = std::mem::take(&mut probe.dump_requested)
|| *ENV_DUMP.get_or_init(|| std::env::var_os("WOW_CULLDUMP").is_some());
// The camera pose IN FULL: replaying a dump in the pin probe needs forward + fov + aspect, not
// just the eye — B65's first dump recorded only the eye and the replay had to reconstruct the
// look from the player's position.
let mut dump_text = dump.then(|| {
let fwd = cam_t.forward();
let (fovy, aspect) = match proj {
Projection::Perspective(p) => (p.fov, p.aspect_ratio),
_ => (f32::NAN, f32::NAN),
};
format!("eye world: {eye_world:?}\nforward: {fwd:?} fovy {fovy:.4} aspect {aspect:.4}\n")
});
// The camera's MFOG fog target + interior claim — resolved from the same down-ray seed the
// flood runs anyway (see [`fog`]); the first placement whose seed claims a TRUE INTERIOR
@@ -370,6 +397,18 @@ fn compute_wmo_pvs(
}
}
}
// The render-record persistence latch (`liquid_visited` — the client's per-(instance,group)
// visit records): a group entering the PVS this frame stays "visited" for the rest of this
// placement's residency, and the visibility authority draws its MLIQ surface off THIS latch,
// never off the per-frame `visible`.
if inst.liquid_visited.len() != groups {
inst.liquid_visited = vec![false; groups];
}
for g in 0..groups {
if inst.visible.get(g).copied().unwrap_or(false) {
inst.liquid_visited[g] = true;
}
}
}
if camera_fog.0 != fog_target {
camera_fog.0 = fog_target;
@@ -730,6 +769,7 @@ mod tests {
world_from_local: Affine3A::IDENTITY,
name_set: 0,
visible: vec![false, true, false],
liquid_visited: vec![false; 3],
flooded: vec![None; 3],
};
let key = |groups: &[u16]| WmoGroupVis {
+3 -3
View File
@@ -139,9 +139,9 @@ pub use spells::{
ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes,
SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
ATTR_ONLY_STEALTHED, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM,
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL,
SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
ATTR_ONLY_STEALTHED, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM,
SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL,
SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
};
mod skill_lines;
pub use skill_lines::{
+1 -1
View File
@@ -430,7 +430,7 @@ impl SpellDisplay {
/// are hidden. Passive-but-shown spells exist (a passive is NOT itself a book gate); only the
/// three bits above hide a spell.
pub fn in_spellbook(&self) -> bool {
self.attributes & (ATTR_DO_NOT_DISPLAY | ATTR_IS_TRADESKILL) == 0 && self.cast_ui == 0
self.attributes & (ATTR_DO_NOT_DISPLAY | SPELL_ATTR_IS_TRADESKILL) == 0 && self.cast_ui == 0
}
/// Whether this spell appears in the **pet's** book — a *different* add-gate from
+7 -1
View File
@@ -371,7 +371,13 @@ const ATTR_DO_NOT_DISPLAY: u32 = 0x80;
/// `Attributes` bit `0x20` — `SPELL_ATTR_IS_TRADESKILL` (cmangos `SpellDefines.h`): profession /
/// recipe spells the client diverts to a name-only path, never a book slot (decision 0227). See
/// [`SpellDisplay::in_spellbook`].
const ATTR_IS_TRADESKILL: u32 = 0x20;
///
/// Public because four separate surfaces test this one bit and each had grown its own copy of the
/// literal: the spellbook's add-gate here, the TradeSkill and Craft admission filters, and — since
/// the trainer TOOLTIP law landed — the gate that decides whether a trainer service renders an ITEM
/// tooltip instead of a spell one (`SetTrainerService 0x5338b0` at `0x533a1b`, and again inside the
/// spell builder's own redirect at `0x52e6d2`).
pub const SPELL_ATTR_IS_TRADESKILL: u32 = 0x20;
/// `AttributesEx` bit `0x10000000` — vmangos `SPELL_ATTR_EX_NO_AURA_ICON` ("Client doesn't display
/// these spells in aura bar"): the other display-filter bit. All three warrior stances carry it
/// (extracted 5875 `Spell.dbc`: 2457 `AttributesEx 0x90000000`, 71/2458 `0x10000000`).
+33 -4
View File
@@ -60,10 +60,13 @@
//!
//! ## The tooltip channels
//!
//! `GameTooltip:SetCraftItem(craftIndex, reagentIndex)` / `GameTooltip:SetCraftSpell(craftIndex)` are
//! registered in [`super::tooltip_item`], beside `SetTradeSkillItem` — see that module's doc for the
//! v1 SetCraftSpell call (a plain two-line render off this module's own `name`/`description`, not a
//! second ask-once round trip through [`super::tooltip_spell`]).
//! `GameTooltip:SetCraftItem(craftIndex, reagentIndex)` is registered in [`super::tooltip_item`],
//! beside `SetTradeSkillItem`. `GameTooltip:SetCraftSpell(craftIndex)` lives in
//! [`super::tooltip_spell`] beside `SetTrainerService`, its structural twin: both are SELECTORS
//! into the shared spell/item builders rather than renderers of their own. The v1 two-line
//! "name white, description gold" render is gone — `SetCraftSpell 0x533e90` funnels into the same
//! `0x52e610`/`0x52b650` pair every other content binding does, so two lines were eight-plus short
//! (wow-re `ui/scratch/trainer-service-tooltip-law.md` §4.1).
use mlua::{Lua, MultiValue, Value, Variadic};
@@ -87,6 +90,26 @@ pub struct CraftReagent {
pub have: u32,
}
/// What `GameTooltip:SetCraftSpell` describes for one row — the pre-resolved output of the
/// app-side law (`ui_craft::craft_tooltip`, transcribing `SetCraftSpell 0x533e90`).
/// [`super::TrainerTooltip`]'s twin in shape and its opposite in law: this one tests the recipe's
/// **own** `Effect[i]` (24 `CREATE_ITEM` → the item, 36 `LEARN_SPELL` → the taught spell), never
/// `57`, never sets `altCaster`, and does **not** validate that the hop resolves before taking it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CraftTooltip {
/// The ITEM builder, on `EffectItemType[i]` of the matched `CREATE_ITEM` slot.
Item(u32),
/// The SPELL builder, on `EffectTriggerSpell[i]` of the matched `LEARN_SPELL` slot — or on the
/// recipe spell itself when no slot matched, which is every enchant.
Spell(u32),
}
impl Default for CraftTooltip {
fn default() -> Self {
CraftTooltip::Spell(0)
}
}
/// One recipe row (`GetCraftInfo` + its detail-pane getters): a known spell carrying attr
/// `SPELL_ATTR_IS_TRADESKILL`, resolved by the app from `Spell.dbc`/`SkillLineAbility.dbc`/bag counts —
/// [`super::tradeskill::TradeSkillRecipe`]'s twin, minus the product-item concept, plus `sub_name` +
@@ -127,6 +150,9 @@ pub struct CraftRecipe {
/// exposed to Lua as `GetCraftSpellFocus`'s alternating multivalue
/// ([`super::tradeskill::TradeSkillRecipe::tools`]'s twin).
pub tools: Vec<(String, bool)>,
/// What the detail-icon hover describes ([`CraftTooltip`]) — resolved app-side, because the law
/// reads `Spell.dbc` effect columns the engine cannot see.
pub tooltip: CraftTooltip,
}
/// One open craft window: the skill line's name/rank and its (app-presorted) recipe list. Pushed whole
@@ -441,6 +467,9 @@ mod tests {
) -> CraftRecipe {
CraftRecipe {
spell_id,
// The fixture's default subject: the recipe itself (the enchant case — no matching
// effect slot), so a test that cares about the item arm sets it explicitly.
tooltip: CraftTooltip::Spell(spell_id),
name: name.into(),
sub_name: String::new(),
difficulty,
+2 -2
View File
@@ -113,7 +113,7 @@ pub use char_stats::{
SKILL_DEFENSE, SKILL_UNARMED,
};
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
pub use craft::{CraftReagent, CraftRecipe, CraftState};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
EnchantConfirm, WorldPick, EQUIPMENT_BAG,
@@ -151,7 +151,7 @@ pub use trade::{TradeSideState, TradeSlotItem, TradeState, TRADE_SLOTS};
pub use tradeskill::{TradeSkillDifficulty, TradeSkillReagent, TradeSkillRecipe, TradeSkillState};
pub use trainer::{
TrainerAbilityReq, TrainerGroup, TrainerService, TrainerServiceCategory, TrainerSkillReq,
TrainerState,
TrainerState, TrainerTooltip,
};
pub use types::{
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
@@ -498,3 +498,234 @@ fn action_hover_delegates_by_kind() {
assert_eq!(s.take_spell_tooltip_asks(), vec![5143], "the miss asked");
assert!(s.take_errors().is_empty());
}
/// The tooltip's left-column texts in order.
fn left_lines(s: &mut UiScript) -> Vec<String> {
let n: i64 = s.eval("return TT:NumLines()").unwrap();
(1..=n)
.map(|i| {
s.eval::<String>(&format!(
"return getglobal('TTTextLeft{i}'):GetText() or ''"
))
.unwrap()
})
.collect()
}
/// One trainer service fixture with an explicit tooltip subject.
fn trainer_service(spell_id: u32, name: &str, tooltip: TrainerTooltip) -> TrainerService {
TrainerService {
spell_id,
tooltip,
name: Some(name.into()),
texture: Some("Interface\\Icons\\Temp".into()),
category: TrainerServiceCategory::Available,
skill_line: 26,
skill_line_name: "Arms".into(),
..Default::default()
}
}
/// `SetTrainerService` is a SELECTOR: it renders no line of its own and routes to whichever shared
/// builder the app-side law picked. All three arms are pinned here — the item arm, the spell arm
/// (with `altCaster` suppressing the reagents block), and a header row, which is a no-op.
///
/// The index is a VISIBLE row index, so row 1 is the "Arms" header and the services start at 2 —
/// the same interleave `SetTradeSkillItem` has, and the same way to get it wrong.
#[test]
fn set_trainer_service_selects_the_builder_and_never_renders_its_own_line() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(
2847,
ItemTemplateView {
name: "Copper Shortsword".into(),
quality: 1,
class: 2,
subclass: 7,
..Default::default()
},
);
s.set_spell_tooltip(
200,
SpellTooltipView {
name: "Heroic Strike".into(),
cost: Some("15 Rage".into()),
reagents: Some("Reagents: Linen Cloth".into()),
description: "A strong attack.".into(),
..Default::default()
},
);
s.set_trainer(Some(TrainerState {
services: vec![
trainer_service(2756, "Copper Shortsword", TrainerTooltip::Item(2847)),
trainer_service(
100,
"Heroic Strike",
TrainerTooltip::Spell {
spell_id: 200,
alt_caster: false,
},
),
trainer_service(
101,
"Train Growl",
TrainerTooltip::Spell {
spell_id: 200,
alt_caster: true,
},
),
],
..Default::default()
}));
s.run(
r#"
local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
CreateFrame("GameTooltip", "TT")
-- Row 1 is the "Arms" header: a no-op, not a shifted hit onto a neighbouring service.
TT:SetOwner(a, "ANCHOR_RIGHT")
TT:SetTrainerService(1)
assert(TT:NumLines() == 0, "a header row renders nothing")
-- Row 2: the recipe -> the ITEM builder, full item tooltip (not the spell's name).
TT:SetOwner(a, "ANCHOR_RIGHT")
TT:SetTrainerService(2)
assert(TTTextLeft1:GetText() == "Copper Shortsword", "the item arm renders the item")
assert(TT:NumLines() > 1, "the ITEM builder, not a one-line stub")
"#,
)
.unwrap();
// Row 3: the class-trainer service -> the SPELL builder on the TAUGHT spell, reagents shown.
s.run(
r#"
TT:SetOwner(AB1, "ANCHOR_RIGHT")
TT:SetTrainerService(3)
"#,
)
.unwrap();
let texts = left_lines(&mut s);
assert_eq!(
texts[0], "Heroic Strike",
"the spell arm hops to the taught"
);
assert!(
texts.iter().any(|t| t == "Reagents: Linen Cloth"),
"altCaster clear -> the reagents block renders: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "A strong attack."),
"the gold description is part of the shared builder's law: {texts:?}"
);
// Row 4: the same view, altCaster set -> the reagents block is gone, everything else stays.
s.run(
r#"
TT:SetOwner(AB1, "ANCHOR_RIGHT")
TT:SetTrainerService(4)
"#,
)
.unwrap();
let texts = left_lines(&mut s);
assert!(
!texts.iter().any(|t| t == "Reagents: Linen Cloth"),
"altCaster gates the reagents block: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "A strong attack."),
"and gates ONLY that block: {texts:?}"
);
assert!(s.take_errors().is_empty());
}
/// `SetCraftSpell` is `SetTrainerService`'s structural twin: a selector, not the two-line
/// name/description stub it used to be. Both arms are pinned, and the assertion that matters most
/// is the negative one — a rod recipe's hover shows the ROD's item tooltip while the same row's
/// ICON is the spell's (Law D, decision 1107). Icon and tooltip disagreeing on one row is the
/// verified shape, not a bug to reconcile.
#[test]
fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(
6218,
ItemTemplateView {
name: "Runed Copper Rod".into(),
quality: 1,
..Default::default()
},
);
s.set_spell_tooltip(
7420,
SpellTooltipView {
name: "Enchant Bracer - Minor Health".into(),
cast_time: Some("Instant cast".into()),
description: "Permanently enchant bracers.".into(),
..Default::default()
},
);
let recipe = |spell_id: u32, name: &str, tooltip: CraftTooltip| CraftRecipe {
spell_id,
tooltip,
name: name.into(),
sub_name: String::new(),
difficulty: TradeSkillDifficulty::Optimal,
num_available: 1,
icon: Some("Interface\\Icons\\Spell_Holy_Heal".into()),
description: Some("the OLD two-line stub's second line".into()),
needs_item_target: false,
reagents: vec![],
tools: vec![],
};
s.set_craft(Some(CraftState {
name: "Enchanting".into(),
rank: 100,
max_rank: 150,
recipes: vec![
recipe(
7420,
"Enchant Bracer - Minor Health",
CraftTooltip::Spell(7420),
),
recipe(7421, "Runed Copper Rod", CraftTooltip::Item(6218)),
],
}));
s.run(
r#"
local a = CreateFrame("Button", "AB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
CreateFrame("GameTooltip", "TT")
TT:SetOwner(a, "ANCHOR_RIGHT")
TT:SetCraftSpell(1)
"#,
)
.unwrap();
let texts = left_lines(&mut s);
assert_eq!(texts[0], "Enchant Bracer - Minor Health");
assert!(
texts.iter().any(|t| t == "Instant cast"),
"the SHARED spell builder's law, not a two-line stub: {texts:?}"
);
assert!(
!texts
.iter()
.any(|t| t == "the OLD two-line stub's second line"),
"the row's own `description` field is no longer the tooltip's source: {texts:?}"
);
s.run(
r#"
TT:SetOwner(AB2, "ANCHOR_RIGHT")
TT:SetCraftSpell(2)
"#,
)
.unwrap();
let texts = left_lines(&mut s);
assert_eq!(
texts[0], "Runed Copper Rod",
"a CREATE_ITEM recipe hovers the ITEM, though its icon is the spell's: {texts:?}"
);
assert!(s.take_errors().is_empty());
}
@@ -42,7 +42,7 @@ use super::{ItemTemplateView, Model};
mod names;
mod render;
use names::{quality_color, GOLD, GRAY, WHITE};
use names::{quality_color, GRAY, WHITE};
use render::render_view;
/// Look up the store; a miss records the ask (the app sends `CMSG_ITEM_QUERY` and pushes back —
@@ -641,44 +641,5 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?,
)?;
// GameTooltip:SetCraftSpell(craftIndex) — the craft window's detail-icon hover (decision 0437
// phase 3, ref `CraftIcon`'s OnEnter, `Blizzard_CraftUI.xml:566`): a plain two-line render
// (name white, description gold) straight off THIS recipe's own pre-resolved fields, v1's
// deliberate choice over a second ask-once round trip through super::tooltip_spell's
// SpellTooltipView store (craft.rs's own module doc, "The tooltip channels") — CraftRecipe
// already carries the app-resolved description, so a second store keyed the same spell id
// under a different channel would just double the app's push traffic for the same string. An
// out-of-range craft index is a no-op.
m.set(
"SetCraftSpell",
lua.create_function(|lua, (this, craft_index): (Table, usize)| {
let found = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(c) = &model.craft else {
return Ok(());
};
craft_index
.checked_sub(1)
.and_then(|i| c.recipes.get(i))
.map(|r| (r.name.clone(), r.description.clone()))
};
let Some((name, description)) = found else {
return Ok(());
};
let h = frame_handle_of(lua, &this)?;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
append_line(lua, &this, (name, WHITE), None, false)?;
if let Some(desc) = description {
append_line(lua, &this, (desc, GOLD), None, true)?;
}
show_or_hide_empty(lua, h);
Ok(())
})?,
)?;
Ok(())
}
+159 -14
View File
@@ -25,7 +25,7 @@ use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::tooltip::{append_line, clear_content, fire_cleared};
use super::Model;
use super::{CraftTooltip, Model, TrainerTooltip};
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
/// The rank column's gray — byte-verified `0xff808080` (0276).
@@ -136,15 +136,38 @@ const RED: [f32; 4] = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
/// Render one spell view — the verified law (module doc). `aura` renders the aura variant:
/// white description, plus the caller-supplied duration-remaining line (`SetPlayerBuff` only).
/// `talent` interleaves the talent lines ([`TalentLines`] doc).
/// The builder's parameter vector, named as the byte law names it (`0x52e610`'s param3..param8 —
/// wow-re `ui/scratch/tooltip-content-law.md` §3). These were three positional `bool`s at a
/// 7-argument call site, which is exactly the shape that gets silently transposed; `Default` is the
/// plain spell hover every caller but two wants.
#[derive(Clone, Copy, Default)]
pub(super) struct SpellRenderOpts {
/// Render through the AURA builder (`0x52f880`) rather than the spell builder: gold name, white
/// description, the dispel-class right column.
pub(super) aura: bool,
/// `param6` showRank — the gray "Rank N" right column. `SetSpell` passes 0 (the spellbook hover
/// never shows it), `SetAction` passes 1.
pub(super) show_rank: bool,
/// `param5` altCaster — **one** gate suppressing **both** the totems and the reagents lines
/// (byte-verified at the two branch sites `0x52ed43` and `0x52f393`). Set only by
/// `SetTrainerService`, and only when the matched learn-wrapper slot was `LEARN_PET_SPELL`: a
/// pet-training service shows neither block.
pub(super) alt_caster: bool,
}
fn render_spell(
lua: &Lua,
this: &Table,
v: &SpellTooltipView,
aura: bool,
show_rank: bool,
opts: SpellRenderOpts,
remaining: Option<String>,
talent: Option<&TalentLines>,
) -> mlua::Result<()> {
let SpellRenderOpts {
aura,
show_rank,
alt_caster,
} = opts;
// The name colour splits by BUILDER, byte-verified: the spell builder `0x52e610` writes its
// name line through `0x530270` (white), the aura builder `0x52f880` through `0x530380` — the
// GOLD wrapper (wow-re tooltip-content-law §3 line 1 vs §3-BUFF). SetTrackingSpell's gold,
@@ -206,7 +229,9 @@ fn render_spell(
append_line(lua, this, (req.clone(), color), None, false)?;
}
// Reagents (law §3.8): white + wrapped, the missing entries inline-red inside the text.
if let Some(reagents) = &v.reagents {
// Suppressed wholesale by altCaster — the same gate that hides the totems block, which we
// have no feed for yet, so this is the only half of it that is observable here.
if let Some(reagents) = v.reagents.as_ref().filter(|_| !alt_caster) {
append_line(lua, this, (reagents.clone(), WHITE), None, true)?;
}
// Chance to dodge/parry/block/crit (law line 10, §3-CHANCE) — white, NOT wrapped, and it
@@ -266,7 +291,14 @@ pub(super) fn set_spell_with_talent(
super::talent::ask_next_rank(lua, talent.next_spell);
}
match spell_view_of(lua, spell_id) {
Some(v) => render_spell(lua, this, &v, false, false, None, Some(&talent))?,
Some(v) => render_spell(
lua,
this,
&v,
SpellRenderOpts::default(),
None,
Some(&talent),
)?,
None => {
// The view hasn't landed: show the talent head alone (the ask is recorded; the
// hover's re-enter repaints complete) — the spell channel's own fallback shape.
@@ -284,8 +316,7 @@ fn set_spell_by_id(
this: &Table,
spell_id: u32,
fallback_name: Option<String>,
aura: bool,
show_rank: bool,
opts: SpellRenderOpts,
remaining: Option<String>,
) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
@@ -295,7 +326,7 @@ fn set_spell_by_id(
}
fire_cleared(lua, h);
match spell_view_of(lua, spell_id) {
Some(v) => render_spell(lua, this, &v, aura, show_rank, remaining, None)?,
Some(v) => render_spell(lua, this, &v, opts, remaining, None)?,
None => {
if let Some(name) = fallback_name {
append_line(lua, this, (name, WHITE), None, false)?;
@@ -337,7 +368,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
})?,
)?;
// GameTooltip:SetShapeshift(index) — the stance-bar hover (the form's own spell tooltip).
@@ -351,7 +382,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
})?,
)?;
// GameTooltip:SetPetAction(index) — the pet-bar hover (decision 0982). Only ever reached for a
@@ -377,7 +408,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
set_spell_by_id(lua, &this, spell_id, name, SpellRenderOpts::default(), None)
})?,
)?;
// GameTooltip:SetPlayerBuff(index [, filter]) — the buff-bar hover: the aura variant (white
@@ -420,7 +451,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, true, false, remaining)
set_spell_by_id(
lua,
&this,
spell_id,
name,
SpellRenderOpts {
aura: true,
..Default::default()
},
remaining,
)
})?,
)?;
// GameTooltip:SetUnitBuff(unit, index) / SetUnitDebuff(unit, index) — the target frame's aura
@@ -445,7 +486,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// entry with spell id 0: content clears and the empty plate hides — never a
// stale previous tooltip left showing. Id 0 records no ask.
let (spell_id, name) = hit.unwrap_or((0, None));
set_spell_by_id(lua, &this, spell_id, name, true, false, None)
set_spell_by_id(
lua,
&this,
spell_id,
name,
SpellRenderOpts {
aura: true,
..Default::default()
},
None,
)
})?,
)?;
}
@@ -509,7 +560,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
let Some(a) = action else { return Ok(()) };
match a.kind {
0x00 => set_spell_by_id(lua, &this, a.action, None, false, true, None),
0x00 => set_spell_by_id(
lua,
&this,
a.action,
None,
SpellRenderOpts {
show_rank: true,
..Default::default()
},
None,
),
0x80 => {
// Route through the shared item renderer (the id-keyed entry).
let f: mlua::Function = this.get("SetItemById")?;
@@ -519,5 +580,89 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
})?,
)?;
// GameTooltip:SetTrainerService(index) — the trainer detail-icon hover (ref
// `Blizzard_TrainerUI.xml:452`, whose OnEnter is SetOwner(this,"ANCHOR_RIGHT") +
// SetTrainerService(ClassTrainerFrame.selectedService) + Show(); the LIST ROWS carry no tooltip
// at all). Byte-verified whole in wow-re `ui/scratch/trainer-service-tooltip-law.md`.
//
// The binding is a **selector, not a renderer**: `0x5338b0` emits no line of its own (verified
// negative — none of the four AddLine helpers appears in its extent) and hands one of the two
// shared builders a subject. The subject is decided app-side, because the law reads `Spell.dbc`
// fields the engine cannot see, and arrives pre-resolved as `TrainerService::tooltip`. All this
// does is pick the renderer — which is exactly what the reference binding does.
//
// `index` is a **VISIBLE** row index (headers interleave), resolved through the same mapping
// every other trainer getter uses; a header row is a no-op.
m.set(
"SetTrainerService",
lua.create_function(|lua, (this, index): (Table, usize)| {
let subject = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match super::trainer::service(&model, index) {
Some(s) => s.tooltip.clone(),
None => return Ok(()),
}
};
match subject {
// Route through the shared item renderer, the way the reference routes into
// `0x52b650`. No fallback name: an item id of 0 or a template still in flight
// renders an EMPTY tooltip, which is the builder's own early-out.
TrainerTooltip::Item(item_id) => {
let f: mlua::Function = this.get("SetItemById")?;
f.call::<()>((this.clone(), item_id))
}
TrainerTooltip::Spell {
spell_id,
alt_caster,
} => set_spell_by_id(
lua,
&this,
spell_id,
None,
SpellRenderOpts {
alt_caster,
..Default::default()
},
None,
),
}
})?,
)?;
// GameTooltip:SetCraftSpell(craftIndex) — the Craft window's detail-icon hover (ref
// `CraftIcon`'s OnEnter, `Blizzard_CraftUI.xml:566`). `SetTrainerService`'s structural twin and
// its law's opposite (wow-re `ui/scratch/trainer-service-tooltip-law.md` §4.1): a selector into
// the same two shared builders, deciding on the RECIPE's own effect columns rather than a
// taught spell's attributes. The subject arrives pre-resolved as `CraftRecipe::tooltip`.
//
// This replaced a v1 two-line "name white, description gold" render. That was wrong in both
// halves: `0x533e90` funnels into `0x52e610`/`0x52b650` like every other content binding, so
// two lines were eight-plus short, and on a `LEARN_SPELL` or `CREATE_ITEM` recipe it was
// describing the wrong subject entirely. `craft_index` is a raw recipe position — the Craft
// window is FLAT, with no headers to interleave (unlike the tradeskill list).
m.set(
"SetCraftSpell",
lua.create_function(|lua, (this, craft_index): (Table, usize)| {
let subject = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(c) = &model.craft else {
return Ok(());
};
match craft_index.checked_sub(1).and_then(|i| c.recipes.get(i)) {
Some(r) => r.tooltip.clone(),
None => return Ok(()),
}
};
match subject {
CraftTooltip::Item(item_id) => {
let f: mlua::Function = this.get("SetItemById")?;
f.call::<()>((this.clone(), item_id))
}
CraftTooltip::Spell(spell_id) => {
set_spell_by_id(lua, &this, spell_id, None, SpellRenderOpts::default(), None)
}
}
})?,
)?;
Ok(())
}
+50 -2
View File
@@ -107,6 +107,43 @@ pub struct TrainerAbilityReq {
pub met: bool,
}
/// What `GameTooltip:SetTrainerService` describes for one row — the pre-resolved output of the
/// **app-side** tooltip law (`ui_trainer::service_tooltip`, transcribing `SetTrainerService
/// 0x5338b0`). The engine holds no DBC, so the app decides the subject and the engine only picks a
/// renderer; the binding in the real client does exactly that too — it emits no line of its own and
/// is a three-way selector into the shared spell builder `0x52e610` or item builder `0x52b650`.
///
/// This is deliberately **not** derivable from [`TrainerService::texture`]: the tooltip and the icon
/// disagree, by design and in both directions (wow-re `ui/scratch/trainer-service-tooltip-law.md`
/// §6 — the icon needs a trainer-type gate the tooltip does not have, and the icon pins the *wire*
/// wrapper where the tooltip hops to the *taught* spell). On ~806 of the shipped corpus's trainer
/// services the reference client visibly shows one spell's icon above another spell's tooltip.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TrainerTooltip {
/// The ITEM builder, on this item id — the taught spell carries `SPELL_ATTR_IS_TRADESKILL`.
/// `0` (or a template still in flight) renders an EMPTY tooltip, which is the client's own
/// behaviour: the builder early-outs on a cache miss and the query is enqueued.
Item(u32),
/// The SPELL builder, on the taught spell — or on the wire spell itself when no learn-wrapper
/// slot resolved, the only path that describes the wrapper.
Spell {
spell_id: u32,
/// `param5` altCaster — ONE gate suppressing BOTH the totems and the reagents lines
/// (byte-verified at `0x52ed43` and `0x52f393`). Set exactly when the matched wrapper slot
/// was `SPELL_EFFECT_LEARN_PET_SPELL`.
alt_caster: bool,
},
}
impl Default for TrainerTooltip {
fn default() -> Self {
TrainerTooltip::Spell {
spell_id: 0,
alt_caster: false,
}
}
}
/// One trainer service row, resolved by the app from the wire `TrainerSpell` (decision 0237). Plain
/// data — position in [`TrainerState::services`] is the *unsorted* wire order; the 1-based index the
/// Lua uses is a position in the visible display tree ([`rows`], built from [`TrainerState::groups`]).
@@ -145,6 +182,9 @@ pub struct TrainerService {
pub skill_line: u32,
/// The skill line's localized display name (`SkillLine.dbc`) — the header row's text.
pub skill_line_name: String,
/// What the detail-icon hover describes ([`TrainerTooltip`]) — resolved app-side, because the
/// law reads `Spell.dbc` fields the engine cannot see. Independent of [`Self::texture`].
pub tooltip: TrainerTooltip,
}
/// One skill-line group in the display tree (decision 0247): the header's skill line + name and the
@@ -293,8 +333,10 @@ fn rows(model: &Model) -> Vec<Row> {
}
/// The service at a 1-based visible index, or `None` when that row is a **header** (or OOB / no
/// trainer) — so the getters/buy that read a service safely no-op on a header row.
fn service(model: &Model, index: usize) -> Option<&TrainerService> {
/// trainer) — so the getters/buy that read a service safely no-op on a header row. `pub(super)`:
/// `GameTooltip:SetTrainerService` lives in the tooltip channel and must resolve its index through
/// the same VISIBLE mapping, never a raw `services[]` position.
pub(super) fn service(model: &Model, index: usize) -> Option<&TrainerService> {
let n = index.checked_sub(1)?;
match rows(model).get(n)? {
Row::Service(si) => model.trainer.as_ref()?.services.get(*si),
@@ -675,6 +717,12 @@ mod tests {
) -> TrainerService {
TrainerService {
spell_id,
// The fixture's default subject: the wire spell, no hop (the "no learn wrapper"
// fallback); the tooltip tests set the arm they mean explicitly.
tooltip: TrainerTooltip::Spell {
spell_id,
alt_caster: false,
},
name: Some(name.into()),
subtext: Some("Rank 1".into()),
texture: Some(format!("Interface\\Icons\\Spell_{spell_id}")),