the trainer re-derives its states as the reference does

- the trainer window re-derives every service's state when your money, level, spells or talents change, instead of asking the server for a second list after a purchase (2333, 2336)
- the backpack button reads the stock bag scan: with Bagnon holding the bags it stays unlit, as the reference leaves it (2332)
- three unused dev switches retire, and fifteen others are named as instruments (2335)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 12:14:17 +07:00
co-authored by Claude Opus 5.5
parent 6d5328994b
commit 887a4db586
18 changed files with 1086 additions and 257 deletions
+15 -1
View File
@@ -960,10 +960,24 @@ pub(crate) mod schedule_tests {
/// as a one-shot outside the schedule. The three targeting systems the same record moved
/// from `UiActionPlugin` to `SpellPlugin` kept their orders and their sets.
///
/// **2,888 (decision 2333)** — the trainer feed became the state re-evaluator's host and
/// reads the pet bar, the player's descriptor and its field edges. Read off the dump on the
/// tree that lands: undeclared, the feed met six systems; `.after(UnitFeed)` (the shape the
/// demo unit feed already takes) declares five of them, and the one it keeps —
/// `entities::evict_display_caches` — is the pair every feed that reads `ItemDisplays` has
/// (42 on this tree). So the feed's own count went 6 → 1, and the total went 2,887 → 2,888:
/// diffing the two dumps, the edge **re-partitioned** the census — five pairs among unrelated
/// systems (`cinematic::*` against `ui_logout::drain_logout`, `arm_loot_fx` against the
/// party/social drains, …) left the actionable list and six others (`resolve_equipment`
/// against `drain_guild`, `emit_minimap` against `drain_quest`, …) entered it, none of them
/// touching anything this record touched. That is the instrument moving under an edge, not
/// a new undeclared order; 2333 hands it to this ratchet's owner (2287) as a finding. The
/// number is the dump's, not a sum.
///
/// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with
/// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is
/// about a resource that commutes by construction, the claim belongs in [`Classes`].
const UPDATE_ACTIONABLE_CEILING: usize = 2_886;
const UPDATE_ACTIONABLE_CEILING: usize = 2_888;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
+2 -2
View File
@@ -3,8 +3,8 @@
//! Opens the vanilla patch chain from wherever the install is (`$WOW_DATA`, the project folder on a
//! dev build, else beside the binary — decision 1175) and streams the world around the
//! player through Bevy's `AssetServer` (the `benilla-assets` `mpq://` pipeline): ADT terrain tiles within
//! `$WOW_TILE_RADIUS` — their splat-blended ground (tiling `$WOW_TEX_TILES`), doodads/WMOs, water, and
//! ground clutter — plus the avian colliders the character controller walks on. Lit by a time-of-day WoW
//! the live Terrain Distance window (decision 1513) — their splat-blended ground, doodads/WMOs, water,
//! and ground clutter — plus the avian colliders the character controller walks on. Lit by a time-of-day WoW
//! lighting model (`Light.dbc` sampled against the server clock) with a sky dome, sun/moon discs, and
//! distance fog; a faithful `EffectGlow` bloom on top.
//!
+5 -108
View File
@@ -61,8 +61,9 @@
//! non-rectangular-region case even after real scissor lands.
//! - **Text.** `FontString`s render through `cosmic-text` per 0068 §2; nothing here rasterizes glyphs.
//! - **Extraction from a widget arena.** Nothing yet *produces* [`UiQuad`]s from frames/anchors/strata —
//! that's the widget-arena milestone this pass is built ahead of. The `dev`-gated demo feeder below
//! fills [`UiQuads`] directly to prove the pass in isolation.
//! that's the widget-arena milestone this pass is built ahead of. (A `WOW_UI_DEMO` feeder once
//! filled [`UiQuads`] with synthetic strata to prove the pass in isolation; the widget engine has
//! been the producer since, and the feeder retired — 2335.)
use bevy::asset::RenderAssetUsages;
use bevy::camera::visibility::RenderLayers;
@@ -79,8 +80,6 @@ use bevy::shader::ShaderRef;
use bevy::sprite_render::{Material2d, Material2dKey, Material2dPlugin};
use bevy::window::PrimaryWindow;
use benilla_assets::{AssetSet, WorldAssets};
/// Per-corner UVs for a quad: one explicit `(u,v)` sample per **screen** corner, in the
/// [`Run::push_quad`] winding — `[top-left, top-right, bottom-right, bottom-left]`. Deliberately four
/// independent pairs, **not** a `(min,max)` rect, for two reasons that a 2-corner rect can't serve:
@@ -287,9 +286,8 @@ impl Default for UiQuad {
/// The pass's one input resource, two lanes with distinct change protocols:
///
/// - [`Self::quads`] — the BASE lane, owned by a wholesale producer (the widget arena's extract;
/// the dev demo feeder): replaced in full, and the producer sets [`Self::dirty`] only when the
/// new content differs.
/// - [`Self::quads`] — the BASE lane, owned by a wholesale producer (the widget arena's extract):
/// replaced in full, and the producer sets [`Self::dirty`] only when the new content differs.
/// - [`Self::overlays`] — the APPEND lane ([`UiQuadAppend`]: the minimap fill, V-plates, combat
/// text): cleared at the top of the append window ([`clear_ui_overlays`]) and re-emitted every
/// frame; [`rebuild_ui_mesh`] itself diffs it against last frame's. Appenders never touch
@@ -623,27 +621,9 @@ impl Plugin for PlayerUiPlugin {
.add_systems(Update, clear_ui_overlays.before(UiQuadAppend))
.add_systems(Update, rebuild_ui_mesh.after(UiQuadAppend))
.add_systems(Last, count_material_events);
// Dev-only demo feeder (mirrors the repo's env-var dev-instrument gating — e.g. `$WOW_CAPTURE`,
// `$WOW_TILE_RADIUS` — since the compile-time `dev` cargo feature decision 0026 sets as the
// eventual seam isn't built yet; see `demo_enabled`'s doc comment). Runs once at Startup, after
// the asset chain opens so it can load a real BLP through `WorldAssets::sprite_texture`.
if demo_enabled() {
app.add_systems(Startup, seed_demo_quads.after(AssetSet::Open));
}
}
}
/// `$WOW_UI_DEMO=1` fills [`UiQuads`] once at startup with synthetic content proving the sort, the
/// texture-batching/ordering story, and the CPU clip. Same env-var-gated-instrument idiom as
/// `capture.rs`'s `$WOW_CAPTURE` and `assets::open_world_assets`'s `$WOW_TILE_RADIUS`/`$WOW_TEX_TILES`
/// — decision 0026 phase 1 (a compile-time `dev` cargo feature) is recorded as the target seam but not
/// yet built (0026 §4: "phased, not built now"), so today's instrument-gating convention across the
/// codebase is a runtime env-var check, not `#[cfg(feature = "dev")]`.
fn demo_enabled() -> bool {
std::env::var("WOW_UI_DEMO").as_deref() == Ok("1")
}
/// A full-window overlay camera that composites the player-UI quad pass above the 3D world and below
/// the egui dev overlays. Mirrors `debug_panel::spawn_egui_camera`'s pattern (own render layer, higher
/// order, alpha-blend `Write` output, no clear) — see the module doc for the order arbitration.
@@ -1611,89 +1591,6 @@ fn rebuild_ui_mesh(
}
}
/// Deliberately-overlapping synthetic content proving the sort: 5 z strata × 40 quads each, offset both
/// within a stratum (neighbours overlap 35px) and across strata (each stratum offset 20px from the
/// last, so a later, higher-`z_key` stratum's field visibly paints over the previous one's — the actual
/// thing this pass exists to get right). Plus one real-BLP-textured quad and one CPU-clipped quad,
/// both given the highest z_keys — see the module doc: this ordering is what makes the shared "white"
/// texture identity get split into two separate runs (the icon quad sits between them in z_key).
const STRATA: usize = 5;
const PER_STRATUM: usize = 40;
const QUAD_SIZE: f32 = 80.0;
const COLS: usize = 10;
fn strata_color(s: usize) -> [f32; 4] {
// Red → orange → yellow → green → blue, alpha < 1 so the overlaps are visibly additive-ish
// (provable by eye: a later stratum's quad should read as a *blend* over the earlier one where
// clipped by nothing else, and fully opaque-looking where nothing underlies it).
const COLORS: [[f32; 3]; STRATA] = [
[0.85, 0.15, 0.15],
[0.90, 0.55, 0.10],
[0.85, 0.80, 0.10],
[0.15, 0.75, 0.25],
[0.15, 0.45, 0.90],
];
let c = COLORS[s % STRATA];
[c[0], c[1], c[2], 0.85]
}
fn seed_demo_quads(
mut quads: ResMut<UiQuads>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
let mut out = Vec::with_capacity(STRATA * PER_STRATUM + 2);
for s in 0..STRATA {
let stratum_offset = s as f32 * 20.0;
for i in 0..PER_STRATUM {
let (col, row) = (i % COLS, i / COLS);
let x = 40.0 + stratum_offset + col as f32 * 45.0;
let y = 40.0 + stratum_offset + row as f32 * 45.0;
out.push(UiQuad {
rect: Rect::new(x, y, x + QUAD_SIZE, y + QUAD_SIZE),
z_key: (s as u64) * 1000 + i as u64,
color: strata_color(s),
..default()
});
}
}
// Real BLP through the same UI-art path the loading screen uses (`sprite_texture`: sRGB, clamp,
// no mip chain — one texture mapped to one quad, not tiling world art).
if let Some(mut assets) = world_assets {
if let Some(icon) =
assets.sprite_texture("Interface\\Icons\\INV_Misc_QuestionMark.blp", &mut images)
{
out.push(UiQuad {
rect: Rect::new(560.0, 40.0, 560.0 + 64.0, 40.0 + 64.0),
z_key: (STRATA as u64) * 1000 + 500,
texture: Some(icon),
..default()
});
}
}
// CPU-clip demo: a 160×160 quad clipped to its left half — proves the intersect + UV-reprojection
// path (not just a quad that already happened to fit).
let clip_rect = Rect::new(700.0, 40.0, 700.0 + 160.0, 40.0 + 160.0);
out.push(UiQuad {
rect: clip_rect,
z_key: (STRATA as u64) * 1000 + 600,
color: [0.2, 0.8, 1.0, 1.0],
clip: Some(Rect::new(
clip_rect.min.x,
clip_rect.min.y,
clip_rect.min.x + 80.0,
clip_rect.max.y,
)),
..default()
});
quads.quads = out;
quads.dirty = true;
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1538,12 +1538,12 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
/// divergence went with it, unrecorded — and this suite, skipping at every land from 08-30
/// (2329), never said so.
///
/// What this pins now is the client as it is, which is the reference: the bare
/// `ToggleBackpack()` path (the `B` binding's body) ends lit, because nothing runs after OnShow
/// there; a real click ends **unlit** on open as well as on close, because the stock tail is the
/// last word. Whether to reinstate Bug 7's divergence as an adapter shim is the director's call
/// (1751 §2 reserves shims for engine differences, and this is a look); if they say lit, the
/// second assertion below flips back and the shim carries the why.
/// What this pins is the client as it is, which is the reference: the bare `ToggleBackpack()`
/// path (the `B` binding's body) ends lit, because nothing runs after OnShow there; a real click
/// ends **unlit** on open as well as on close, because the stock tail is the last word. Decision
/// 2332 retired Bug 7's divergence on record (1751 §2 reserves shims for engine differences, and
/// this is a look); if the director ever wants it lit, the second assertion below flips back and
/// a shim in `ContainerFrameAdapters.xml` carries the why.
#[test]
fn the_backpack_button_lit_state_with_bagnon_holding_the_bags() {
let root = corpus_or_skip!();
@@ -1085,12 +1085,11 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(rows(&mut s), 3, "the folded group keeps its header only");
// He trains. The app re-asks for the list (`trainer_buy_succeeded`) and marks its own answer as
// the repaint it is; the bought service comes back gray.
// He trains. The spell lands (`SMSG_LEARNED_SPELL`) and the state re-evaluator (2333, the
// reference's `0x4d7d40`) repaints the bought row gray IN PLACE — no second list, so nothing
// that a list packet resets.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
@@ -1214,13 +1213,12 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_
"the Train button bought the selected row"
);
// The app re-asks for the list and marks its answer the repaint it is (B256); the bought
// service comes back gray and, with "already known" off, leaves the list. Cleave slides up into
// row 2 under where the selection used to be.
// The spell lands and the re-evaluator (2333) repaints the bought row gray in place; with
// "already known" off it leaves the list, and Cleave slides up into row 2 under where the
// selection used to be. (Until 2333 this was a second list packet marked as a refresh —
// B256.)
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, warrior!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+156 -32
View File
@@ -13,10 +13,12 @@
//! `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.
//! successful buy answers `SMSG_TRAINER_BUY_SUCCEEDED`, which the reference does not even handle:
//! the spell itself lands via `SMSG_LEARNED_SPELL`, and that — like a level, a skill point, a coin
//! or a pet spell — runs the **state re-evaluator** ([`reeval`], the reference's `0x4d7d40`,
//! decision 2333) over the open list and fires `TRAINER_UPDATE`; 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
@@ -36,7 +38,13 @@ use benilla_ui::script::{
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use benilla_protocol::field::{
FIELD_PLAYER_FIELD_COINAGE, FIELD_PLAYER_SKILL_INFO_1_1, FIELD_UNIT_LEVEL,
};
use benilla_protocol::messages::PLAYER_SKILL_SLOTS;
use bevy::ecs::system::SystemParam;
use crate::net::{ClientCommand, FieldChanged, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
@@ -66,26 +74,27 @@ pub(crate) struct TrainerOpen {
pub(crate) trainer_type: u32,
/// The trainer's greeting line (`SMSG_TRAINER_LIST`'s trailing string).
pub(crate) greeting: String,
/// A `SMSG_TRAINER_LIST` that **begins a window session** has landed and the feed has not yet
/// handed it to the engine. Drives the engine's filter/collapse/**selection** reset
/// ([`UiScript::reset_trainer_list_state`]) — the reference's builder rewrites all three on every
/// list packet (decision 1128; 2231 for the selection, `0x4d7b42`). It is not the same edge as a
/// snapshot change: those re-push the same list.
/// A `SMSG_TRAINER_LIST` has landed and the feed has not yet handed it to the engine. Drives
/// the engine's filter/collapse/**selection** reset ([`UiScript::reset_trainer_list_state`]) —
/// the reference's builder rewrites all three on every list packet (decision 1128; 2231 for
/// the selection, `0x4d7b42`). It is not the same edge as a snapshot change: those re-push
/// the same list.
///
/// "Begins a session", not "a packet arrived", because the two are the same thing in the
/// reference and are **not** in benilla (B253/B256's arc): the reference gets a list packet only
/// when a trainer opens — it repaints a purchase from a client-side state re-derivation
/// (`0x4d7d40`, decision 1128 §4.2) — while we ask for a fresh list after every buy. Letting our
/// own refresh carry the reference's per-packet reset made the state filter (and the collapse
/// set) evaporate on learning a spell, which is not something the reference can do. See
/// [`Self::refresh_pending`].
/// Every list packet begins a window session, in benilla as in the reference: the reference
/// gets one only when a trainer opens, and since 2333 so does benilla — a purchase, a level
/// or a skill point repaints the open window through the state re-evaluator ([`reeval`]),
/// never through a second list. (B253/B256's arc, when benilla still re-requested the list
/// after every buy, needed a "this packet is our own refresh" mark here so the player's
/// filter survived a purchase; the re-request and the mark went together.)
pub(crate) fresh_list: bool,
/// The post-buy re-list **we asked for** ([`crate::net::apply`]'s `trainer_buy_succeeded`) is in
/// flight: the answering packet repaints the open window rather than opening one, so it must not
/// reset the filter/collapse masks — nor the selection, which is what leaves the learned spell
/// selected-but-hidden and so takes the detail pane down with it, the reference's own
/// post-purchase behaviour (2231). Cleared by the packet it belongs to, and by any close.
pub(crate) refresh_pending: bool,
/// One of the reference's twelve re-evaluation triggers fired while the window is open — a
/// spell learned/removed/superseded, a spell modifier (the two "talent" callers, 2336), the
/// pet's spellbook, or (read by the feed itself off the descriptor edges) money, level or a
/// skill slot. The feed runs [`reeval::re_derive_all`]
/// over the list and fires `TRAINER_UPDATE` whether or not a state moved, as `0x4d7d40`'s
/// tail does (`0x4d83f4`) — the stock window re-reads `GetMoney()` for the Train button on
/// exactly that event.
pub(crate) re_derive: bool,
}
impl TrainerOpen {
@@ -97,14 +106,21 @@ impl TrainerOpen {
services: Vec<TrainerSpell>,
greeting: String,
) {
// A repaint of the trainer already on screen, asked for by our own post-buy re-request, is
// not a new window session — so it carries no mask reset (the field's own doc says why).
let refresh = self.trainer == Some(trainer) && std::mem::take(&mut self.refresh_pending);
self.trainer = Some(trainer);
self.trainer_type = trainer_type;
self.services = services;
self.greeting = greeting;
self.fresh_list = !refresh;
// Every list packet is a window session (the field's doc says why), and a fresh list is
// the server's own states: nothing is pending against it.
self.fresh_list = true;
self.re_derive = false;
}
/// One of the twelve triggers fired: re-derive on the next feed, if a window is open.
pub(crate) fn trigger_re_derive(&mut self) {
if self.trainer.is_some() {
self.re_derive = true;
}
}
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
@@ -114,8 +130,7 @@ impl TrainerOpen {
self.trainer_type = 0;
self.greeting.clear();
self.fresh_list = false;
// An in-flight refresh dies with the window: the next list is a real open, and must reset.
self.refresh_pending = false;
self.re_derive = false;
}
/// Disconnect: drop the open window (mirrors the gossip/merchant session clears).
@@ -131,6 +146,7 @@ impl TrainerOpen {
pub(crate) struct TrainerErrors(pub Vec<u32>);
mod net;
mod reeval;
pub(crate) struct UiTrainerPlugin;
@@ -146,7 +162,11 @@ impl Plugin for UiTrainerPlugin {
// 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.in_set(UiFeed),
// After the unit feeds: the re-evaluator reads the pet bar (2333), which the
// pet's old-target clear (a `UnitFeed` member) also writes — the same shape
// `ui_script`'s demo feed takes. Either order reads the same pet spellbook;
// the declaration is what the schedule ratchet asks for (2287).
feed_trainer.in_set(UiFeed).after(crate::ui_unit::UnitFeed),
drain_trainer.after(UiInput),
),
);
@@ -344,17 +364,33 @@ fn snapshot(
})
}
/// **The re-evaluator's inputs and three of its triggers** (2333), bundled: the player's own
/// descriptor (level, the skill slots) and its per-field edges (money, level, a skill slot —
/// decision 2297), the pet bar, and the object index + stores that resolve the pet's descriptor.
#[derive(SystemParam)]
struct ReEvalInputs<'w, 's> {
self_player: Query<'w, 's, (Entity, &'static ObjectStore), With<SelfPlayer>>,
stores: Query<'w, 's, &'static ObjectStore>,
index: Res<'w, GuidIndex>,
pet_bar: Res<'w, crate::ui_pet::PetBar>,
edges: MessageReader<'w, 's, FieldChanged>,
}
/// 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)] // one Bevy system's full input set
fn feed_trainer(
script: Option<NonSendMut<UiScript>>,
// ResMut only to consume the fresh-packet latch below — the feed never authors trainer content.
// ResMut to consume the fresh-packet latch and to write the re-derived states (2333) — the
// states are the one piece of trainer content the client authors, exactly as the reference's
// `0x4d7d40` overwrites its own records.
mut open: ResMut<TrainerOpen>,
actions: Res<PlayerActions>,
spells: Option<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
mut re_eval: ReEvalInputs,
// 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>>,
@@ -397,6 +433,94 @@ fn feed_trainer(
script.reset_trainer_list_state(open.trainer_type);
open.fresh_list = false;
}
// The descriptor-side triggers of the re-evaluator (2333): money, level, any skill slot, on
// the player's own object. Read every frame so the reader never backs up; they only matter
// while a window is open.
let self_player = re_eval.self_player.iter().next();
for edge in re_eval.edges.read() {
let Some((me, _)) = self_player else {
continue;
};
if edge.entity != me {
continue;
}
// The skill watchers (2336): the reference registers the VALUE word and the MAX and
// PERMBONUS words of every slot (`0x5de180`, `0x5de450`), not the id/step dword and not
// the temp bonus — so a slot's second dword always triggers and its third only when the
// high half (permBonus) moved.
let skill_edge = edge
.index
.checked_sub(FIELD_PLAYER_SKILL_INFO_1_1)
.filter(|&r| r < 3 * u16::from(PLAYER_SKILL_SLOTS))
.is_some_and(|r| match r % 3 {
1 => true,
2 => (edge.old ^ edge.new) & 0xFFFF_0000 != 0,
_ => false,
});
let player_field = edge.kind == benilla_protocol::messages::ObjectType::Player
&& (edge.index == FIELD_PLAYER_FIELD_COINAGE || skill_edge);
if player_field || edge.unit_field(FIELD_UNIT_LEVEL) {
open.trigger_re_derive();
}
}
// The re-evaluation itself — `0x4d7d40` over every row, then `TRAINER_UPDATE` regardless of
// whether a state moved (its tail fires `0x136` unconditionally).
let forced = open.re_derive && open.trainer.is_some();
if forced {
open.re_derive = false;
let player = reeval::PlayerView {
known: actions.spells.clone(),
level: self_player
.and_then(|(_, store)| store.0.unit_level())
.unwrap_or(0),
skills: self_player
.map(|(_, store)| {
(0..PLAYER_SKILL_SLOTS)
.filter_map(|i| store.0.player_skill(i))
.filter(|s| s.skill_id != 0)
.map(|s| reeval::SkillSlot {
skill_id: u32::from(s.skill_id),
step: u32::from(s.step),
// The permanent bonus rides only a non-zero value (`0x4d8087`'s
// guard, the skill-pair reader's too).
value_plus_perm: if s.value == 0 {
0
} else {
i32::from(s.value) + i32::from(s.perm_bonus)
},
})
.collect()
})
.unwrap_or_default(),
pet: (re_eval.pet_bar.spells.pet_guid != 0)
.then(|| re_eval.index.0.get(&re_eval.pet_bar.spells.pet_guid))
.flatten()
.and_then(|&e| re_eval.stores.get(e).ok())
.map(|store| reeval::PetView {
level: store.0.unit_level().unwrap_or(0),
known: re_eval
.pet_bar
.spells
.spells
.iter()
.filter(|e| e.is_spell())
.map(|e| e.action())
.collect(),
}),
};
let TrainerOpen {
services,
trainer_type,
..
} = &mut *open;
reeval::re_derive_all(
services,
*trainer_type,
&spells.catalog,
&skill_lines.catalog,
&player,
);
}
let fresh = snapshot(
&open,
&spells.catalog,
@@ -425,7 +549,7 @@ fn feed_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 {
if fresh == *last && !name_changed && !switched && !forced {
return;
}
script.set_trainer(fresh.clone());
+58 -30
View File
@@ -7,7 +7,7 @@ use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::{TrainerErrors, TrainerOpen};
use crate::net::{ClientCommand, NetCommands, NetHandlerApp};
use crate::net::NetHandlerApp;
/// Register the trainer handlers — called from [`super::UiTrainerPlugin`]. One per kind, plus
/// the session-end listener.
@@ -16,9 +16,54 @@ pub(super) fn register(app: &mut App) {
app.net_handler(K::TrainerList, on_list)
.net_handler(K::TrainerBuySucceeded, on_buy_succeeded)
.net_handler(K::TrainerBuyFailed, on_buy_failed)
// Five of the reference's twelve re-evaluation triggers arrive as packets (2333, 2336):
// a spell learned, removed or superseded, a spell modifier, and the pet's spellbook. Second handlers on the
// kind — the feed does the re-derivation later in the frame, so it reads the book as it
// is after every handler on the packet has run, whatever the registration order.
.net_handler(K::SpellLearned, on_spell_learned)
.net_handler(K::SpellRemoved, on_spell_removed)
.net_handler(K::SpellSuperceded, on_spell_superceded)
.net_handler(K::PetSpells, on_pet_spells)
.net_handler(K::SpellModifier, on_spell_modifier)
.net_handler(K::Disconnected, on_session_end);
}
/// A spell modifier arrived (`SMSG_SET_FLAT/PCT_SPELL_MODIFIER` — the reference's two "talent"
/// callers, `0x6e999f`/`0x6e99bb`, 2336): the same re-derivation.
fn on_spell_modifier(In(ev): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
if matches!(ev, SessionEvent::SpellModifier { .. }) {
trainer_open.trigger_re_derive();
}
}
/// A spell entered the book while a trainer is open (`0x5e9fb7`): re-derive on the next feed.
fn on_spell_learned(In(ev): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
if matches!(ev, SessionEvent::SpellLearned { .. }) {
trainer_open.trigger_re_derive();
}
}
/// A spell left the book (`0x5ea25f`): the same.
fn on_spell_removed(In(ev): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
if matches!(ev, SessionEvent::SpellRemoved { .. }) {
trainer_open.trigger_re_derive();
}
}
/// A rank replaced another: the book moved, so the "known at a higher rank" answers may have.
fn on_spell_superceded(In(ev): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
if matches!(ev, SessionEvent::SpellSuperceded { .. }) {
trainer_open.trigger_re_derive();
}
}
/// The pet's spellbook arrived or changed (`0x4bdaf5`): a pet-spell service may have flipped.
fn on_pet_spells(In(ev): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
if matches!(ev, SessionEvent::PetSpells(_)) {
trainer_open.trigger_re_derive();
}
}
fn on_list(In(ev): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
if let SessionEvent::TrainerList {
trainer,
@@ -31,13 +76,9 @@ fn on_list(In(ev): In<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
}
}
fn on_buy_succeeded(
In(ev): In<SessionEvent>,
mut trainer_open: ResMut<TrainerOpen>,
commands: Res<NetCommands>,
) {
fn on_buy_succeeded(In(ev): In<SessionEvent>) {
if let SessionEvent::TrainerBuySucceeded { trainer, spell_id } = ev {
trainer_buy_succeeded(trainer, spell_id, &mut trainer_open, &commands);
trainer_buy_succeeded(trainer, spell_id);
}
}
@@ -69,29 +110,16 @@ fn trainer_list(
trainer_open.open(trainer, trainer_type, services, greeting);
}
/// A trainer taught a service — confirmation only; the spell already landed via
/// `SMSG_LEARNED_SPELL`. Re-request the list to repaint the bought row green→gray (and unlock
/// any next rank); the server never auto-resends on a buy (VERIFIED vmangos
/// `HandleTrainerBuySpellOpcode`). Guard on the window still being open for this trainer so a
/// late reply for a closed/switched window doesn't re-open it.
///
/// This re-request is **benilla's**, not the reference's — the reference repaints a purchase by
/// re-deriving every service's state client-side (`0x4d7d40`, decision 1128 §4.2) and receives no
/// packet at all. So the answering list is marked as a *refresh* ([`TrainerOpen::refresh_pending`]):
/// it repaints the open window, and must not carry the reference's per-packet filter/collapse reset,
/// which is what dropped the player's "Available only" choice the moment they learned a spell
/// (B256).
fn trainer_buy_succeeded(
trainer: u64,
spell_id: u32,
trainer_open: &mut TrainerOpen,
net_commands: &NetCommands,
) {
debug!("net: trainer {trainer:#x} taught spell {spell_id} — re-listing");
if trainer_open.trainer == Some(trainer) {
trainer_open.refresh_pending = true;
let _ = net_commands.0.send(ClientCommand::TrainerList { trainer });
}
/// A trainer taught a service — confirmation only, and the reference **registers no handler for
/// this opcode** (wow-re `re/net/opcode-handlers.tsv`: 0x1B3 is absent from the 387 it handles).
/// The spell itself lands via `SMSG_LEARNED_SPELL`, and that packet is one of the twelve triggers
/// of the state re-evaluator ([`super::reeval`], decision 2333), which repaints the bought row
/// green→gray and unlocks the next rank from the player's own book. Until 2333 benilla answered
/// this packet by re-requesting the whole list — a round trip the reference never makes
/// (VERIFIED vmangos `HandleTrainerBuySpellOpcode`: the server never resends on its own either),
/// and the reason the player's filter kept dying on a purchase (B256). Logged, nothing more.
fn trainer_buy_succeeded(trainer: u64, spell_id: u32) {
debug!("net: trainer {trainer:#x} taught spell {spell_id}");
}
/// A trainer refused a purchase — the trainer window's error line.
+218
View File
@@ -0,0 +1,218 @@
//! **The trainer's state re-evaluator** — the reference's `0x4d7d40`, transcribed (decision 2333).
//!
//! The service `state` byte a trainer window shows is a wire value only on the **first** frame.
//! The real client keeps twelve triggers on the open window — money, level, a skill change, a
//! spell learned or removed, a talent, the pet's spellbook, and the two async spell-data callbacks
//! — and each of them runs `0x4d7d40`, which **discards** every service's wire state and
//! re-derives it from the player's own spellbook, skills, level and pet, then recounts the groups
//! and fires `TRAINER_UPDATE` (wow-re `system/ui/scratch/trainer-service-suppression.md` §5,
//! byte-verified; the twelve callers are listed there). It is *not* called by the builder or the
//! packet handler, so a freshly opened window shows the server's bytes verbatim until the first
//! trigger.
//!
//! Until 2333 benilla treated the wire state as durable and, on a purchase, asked the server for a
//! fresh list — a round trip the reference never makes (it registers no handler for
//! `SMSG_TRAINER_BUY_SUCCEEDED` at all) — so a level gained or a skill point earned with the window
//! open left the rows stale. This module is the re-derivation; [`super::feed_trainer`] runs it on
//! the same triggers and fires the same event.
//!
//! **What it deliberately reproduces:** the skill leg's not-found exit jumps *past* the state
//! write (`0x4d8082` → `0x4d8123`), so a service whose required skill line the player does not
//! hold at all comes out **green** — the opposite of the server's red. That is the 1.12 client's
//! own behaviour (§5.1), pinned by `a_skill_line_the_player_lacks_entirely_reads_green`, and the
//! reason a fresh Blacksmithing recipe list can flip from red to green under a level-up without
//! the player touching anything.
//!
//! **The whole law, byte-verified three ways** (wow-re `system/ui/scratch/trainer-reeval-law.md`,
//! a §5 round of five workers plus the orchestrator's own read; decision 2336 folds it in):
//! the admission gate, the two `trainerType == 1` writes of state 3, the pet-spell legs as early
//! exits, the required-ability leg's pet variant, and the twelve triggers named to their
//! handlers. What is not built is the one thing no vmangos server reaches: a type-1 (mount)
//! trainer's layout, where a row re-derived to state 2 also has its group key severed
//! (`0x4d82c1`) — noted, not transcribed, until that layout exists (§7.5).
use std::collections::BTreeSet;
use benilla_formats::{LearnEffect, SkillLineCatalog, SpellCatalog};
use benilla_protocol::messages::trainer_spell_state::{GRAY, GREEN, RED};
use benilla_protocol::messages::TrainerSpell;
/// The `trainerType` the reference gates its state-3 writes on (`ds:0xb73a08 == 1`).
const TRAINER_TYPE_STATE_THREE: u32 = 1;
/// The state the type-1 required-ability leg writes instead of RED (`0x4d83a4`): a row no filter
/// can ever show (`SetTrainerServiceTypeFilter` sets bits 0/1/2 only — §5.2).
const HIDDEN: u8 = 3;
/// One `PLAYER_SKILL_INFO` slot as the re-evaluator reads it: the line id, the step word
/// (`+0x84a`) and `value + permBonus` (`+0x84c` + `+0x852`) — the bonus added only when the
/// value is non-zero, the same guard the skill-pair reader `0x5ea460` has (verified, 2336).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct SkillSlot {
pub skill_id: u32,
pub step: u32,
pub value_plus_perm: i32,
}
/// The player's pet, when the bar names one whose object has streamed: its level and its own
/// spellbook (the `IsSpellKnown` pet path scans the pet's book, not the player's).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct PetView {
pub level: u32,
pub known: BTreeSet<u32>,
}
/// Everything `0x4d7d40` reads off the player, gathered once per re-evaluation.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct PlayerView {
/// The player's known-spell set (`[0xb710fc]`'s bitmap).
pub known: BTreeSet<u32>,
/// `UNIT_FIELD_LEVEL`.
pub level: u32,
/// The occupied `PLAYER_SKILL_INFO` slots.
pub skills: Vec<SkillSlot>,
/// The pet, or `None` — "no pet" is itself a verdict for a pet-spell service.
pub pet: Option<PetView>,
}
impl PlayerView {
fn skill(&self, skill_id: u32) -> Option<&SkillSlot> {
self.skills.iter().find(|s| s.skill_id == skill_id)
}
}
/// `IsSpellKnown(unit, id) || KnownHigherRank(unit, id)` — `0x60c740` OR `0x60c8d0`
/// (wow-re `trainer-requirement.md`), against whichever unit's book `known` is.
fn known_or_higher(id: u32, known: &BTreeSet<u32>, skill_lines: &SkillLineCatalog) -> bool {
known.contains(&id) || skill_lines.higher_rank_known(id, known)
}
/// Re-derive one service's state byte from the player, as `0x4d7d40` does per row. The wire
/// state is an input only through the admission gate: a row whose wire spell has no `Spell.dbc`
/// record is skipped whole and keeps it (`0x4d7dcd` → next row); every other row restarts at 0
/// (`0x4d7dec`).
pub(super) fn re_derive(
wire: &TrainerSpell,
trainer_type: u32,
spells: &SpellCatalog,
skill_lines: &SkillLineCatalog,
player: &PlayerView,
) -> u8 {
// 0 · the admission gate.
if spells.get(wire.spell).is_none() {
return wire.state;
}
// 1 · the wire state is discarded; the state restarts at 0.
let mut state = GREEN;
// 2 · per learn effect of the WIRE spell, in slot order; every write overwrites the last.
let mut learn_effects = 0u32;
let mut known_effects = 0u32;
// The row's LEARN_SPELL target (`[ebp-0x28]`) and the pet its LEARN_PET_SPELL resolved
// (`[ebp-0x18]`) — both read again by the required-ability leg.
let mut taught: Option<u32> = None;
let mut pet_resolved: Option<&PetView> = None;
for effect in spells.learn_effects(wire.spell) {
match *effect {
LearnEffect::Spell(t) => {
learn_effects += 1;
taught = Some(t);
if trainer_type == TRAINER_TYPE_STATE_THREE
&& skill_lines.higher_rank_known(t, &player.known)
{
// `0x4d7e3e`: at a type-1 trainer a rank the player has already passed is
// hidden outright — counted as a learn effect, never as a known one, so
// step 3 cannot turn it into 2.
state = HIDDEN;
} else if known_or_higher(t, &player.known, skill_lines) {
known_effects += 1;
}
}
LearnEffect::SkillStep { skill, step } => {
// The player's step word for that line against the effect's value (`0x4d7f17`);
// no slot for the line ⇒ no write (`0x4d7ecf`).
if player.skill(skill).is_some_and(|s| s.step >= step) {
state = GRAY;
}
}
LearnEffect::PetSpell(t) => {
learn_effects += 1;
// No pet ⇒ unavailable (`0x4d803c`), and the loop ends there.
let Some(pet) = &player.pet else {
state = RED;
break;
};
pet_resolved = Some(pet);
if known_or_higher(t, &pet.known, skill_lines) {
// Known by the PET (`IsSpellKnown`'s pet path scans the pet's book).
known_effects += 1;
} else if wire.req_level > 1 && pet.level < u32::from(wire.req_level) {
// A pet below the row's level ⇒ unavailable (`0x4d7fac`), loop ends.
state = RED;
break;
}
}
}
}
// 3 · every learn effect known ⇒ known (`0x4d7fc6`), over whatever the loop left.
if learn_effects > 0 && known_effects == learn_effects {
state = GRAY;
}
// 4 · only while still 0: the skill leg. A slot for the line, below the requirement ⇒
// unavailable (`0x4d811f`); NO slot ⇒ no write at all (`0x4d8082` jumps past it) — the
// reference's own green-for-a-skill-you-lack (§5.1).
if state == GREEN && wire.req_skill != 0 {
if let Some(slot) = player.skill(wire.req_skill) {
if slot.value_plus_perm < wire.req_skill_value as i32 {
state = RED;
}
}
}
// 5 · still 0: the required abilities, against the PET's book when the row resolved one
// (`0x4d81c4`) and the player's otherwise; the first missing one decides. On the pet the
// verdict is always unavailable (`0x4d8204`). On the player at a type-1 trainer it is the
// hidden state (`0x4d83a4`) when the missing ability is exactly the rank before what the
// row teaches — `SkillLineAbility.forward(missing) == taught` — and unavailable
// (`0x4d83ad`) for any other missing ability.
if state == GREEN {
let (book, on_pet) = match pet_resolved {
Some(pet) => (&pet.known, true),
None => (&player.known, false),
};
let missing = wire
.req_spells
.iter()
.copied()
.filter(|&id| id != 0)
.find(|&id| !known_or_higher(id, book, skill_lines));
if let Some(missing) = missing {
let previous_rank_of_taught =
taught.is_some_and(|t| skill_lines.rank_successor(missing) == Some(t));
state =
if !on_pet && trainer_type == TRAINER_TYPE_STATE_THREE && previous_rank_of_taught {
HIDDEN
} else {
RED
};
}
}
// Still 0: a level requirement above 1 the player has not reached (`0x4d8239`); no
// trainer-type variant.
if state == GREEN && wire.req_level > 1 && player.level < u32::from(wire.req_level) {
state = RED;
}
state
}
/// Re-derive every listed service in place — the whole of `0x4d7d40`'s per-row pass. The group
/// recount the reference does next (`0x4d8260`–`0x4d83c9`) is the engine's, synthesized from the
/// pushed states (`benilla_ui::script::trainer`), so nothing is counted here.
pub(super) fn re_derive_all(
services: &mut [TrainerSpell],
trainer_type: u32,
spells: &SpellCatalog,
skill_lines: &SkillLineCatalog,
player: &PlayerView,
) {
for wire in services.iter_mut() {
wire.state = re_derive(wire, trainer_type, spells, skill_lines, player);
}
}
+404 -35
View File
@@ -695,18 +695,15 @@ fn tooltip_falls_back_to_the_wire_spell() {
);
}
/// **B256 — the state filter died on every purchase.** The reset the reference's list builder does
/// per packet (`0x4d75d9`, decision 1128) is only ever observable there at a window *opening*: the
/// reference never receives a list packet with the window already up, because it repaints a purchase
/// from a client-side state re-derivation (`0x4d7d40`). benilla has no such re-derivation and asks
/// the server for a fresh list after every buy — so our synthetic packet was dragging a reset along
/// that the reference cannot produce, and the player's "Available only" came back as
/// available+unavailable the moment they learned a spell (their collapsed groups went with it).
///
/// So [`TrainerOpen::fresh_list`] means *this packet begins a window session*, and the post-buy
/// re-request marks its own answer as the repaint it is. The four cases below are the whole law.
/// **Every list packet begins a window session.** The reset the reference's list builder does
/// per packet (`0x4d75d9`, decision 1128) is only ever observable at a window *opening*, because
/// the reference never receives a list packet with the window already up: a purchase, a level, a
/// skill point repaint the open window through the state re-evaluator (`0x4d7d40`,
/// [`super::reeval`], decision 2333), never through a second list. Since 2333 benilla is the
/// same — the post-buy re-request that B256's "refresh" mark existed to tell apart is gone — so
/// the law is one line: a list packet opens, and opening resets.
#[test]
fn only_a_packet_that_opens_a_window_resets_the_filter() {
fn every_list_packet_begins_a_window_session() {
const TRAINER: u64 = 0xabc;
let list = || vec![wire(1, trainer_spell_state::GREEN, 10, 1, 0)];
let mut open = TrainerOpen::default();
@@ -715,37 +712,409 @@ fn only_a_packet_that_opens_a_window_resets_the_filter() {
assert!(open.fresh_list, "a first list opens the window: reset");
open.fresh_list = false; // the feed consumes it
// The post-buy re-request (`ui_trainer::net::trainer_buy_succeeded`) marks its own answer.
open.refresh_pending = true;
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(
!open.fresh_list,
"our own re-list repaints the open window — the player's filter and collapse stand"
);
assert!(
!open.refresh_pending,
"and the mark is spent, so the NEXT packet is a real open again"
);
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(
open.fresh_list,
"an unmarked packet is always a session start"
"the same trainer's list again is a session start again"
);
// A refresh that never came back must not eat a later open's reset: the close spends it.
open.refresh_pending = true;
// A pending re-derivation never survives a list: the packet carries the server's own states.
open.fresh_list = false;
open.trigger_re_derive();
assert!(open.re_derive);
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(
!open.re_derive,
"a fresh list is the server's states — nothing pending against it"
);
// And a trigger with no window open is nothing at all (`0x4d7d46`'s early-out).
open.clear();
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(open.fresh_list, "a close drops any in-flight refresh mark");
open.trigger_re_derive();
assert!(!open.re_derive);
}
// Nor may a mark meant for one trainer be spent by another's list.
open.refresh_pending = true;
open.open(TRAINER + 1, 0, list(), "Greetings".into());
assert!(
open.fresh_list,
"a different trainer is a new window whatever is in flight"
);
mod re_derive {
//! The state re-evaluator's clauses ([`super::super::reeval`]), one test per byte-verified
//! leg of `0x4d7d40` (wow-re `trainer-service-suppression.md` §5), on synthetic catalogs.
use super::super::reeval::{re_derive, PetView, PlayerView, SkillSlot};
use super::*;
use benilla_formats::{LearnEffect, SlaInfo, SpellDisplay};
use std::collections::HashMap;
const BLACKSMITHING: u32 = 164;
/// Wrapper 2020 teaches 2018 and steps Blacksmithing to 1 — the real openers' shape.
const OPENER: u32 = 2020;
const TAUGHT: u32 = 2018;
/// A class ability wrapper: rank 1 teaches 100, rank 2 teaches 101 (100 → 101 by
/// `forward_spellid`), no skill step.
const RANK1_WRAPPER: u32 = 500;
const RANK1: u32 = 100;
const RANK2: u32 = 101;
/// A pet wrapper: teaches the pet 300.
const PET_WRAPPER: u32 = 700;
const PET_SPELL: u32 = 300;
/// A rank-2 wrapper: teaches RANK2 (whose previous rank is RANK1).
const RANK2_WRAPPER: u32 = 501;
fn catalog() -> SpellCatalog {
// Every wrapper has a record — the admission gate (`0x4d7dcd`) skips a row without one.
let displays = [OPENER, RANK1_WRAPPER, RANK2_WRAPPER, PET_WRAPPER]
.into_iter()
.map(|id| (id, SpellDisplay::default()))
.collect();
SpellCatalog::from_displays_and_effects(
displays,
HashMap::from([
(
OPENER,
vec![
LearnEffect::Spell(TAUGHT),
LearnEffect::SkillStep {
skill: BLACKSMITHING,
step: 1,
},
],
),
(RANK1_WRAPPER, vec![LearnEffect::Spell(RANK1)]),
(RANK2_WRAPPER, vec![LearnEffect::Spell(RANK2)]),
(PET_WRAPPER, vec![LearnEffect::PetSpell(PET_SPELL)]),
]),
)
}
fn skill_lines() -> SkillLineCatalog {
let row = |skill_id, forward| SlaInfo {
skill_id,
req_skill_value: 0,
forward_spell_id: forward,
trivial_low: 0,
trivial_high: 0,
};
SkillLineCatalog::from_abilities([(RANK1, row(1, RANK2)), (RANK2, row(1, 0))])
}
fn service(spell: u32) -> TrainerSpell {
// The WIRE state is RED on purpose: every case below must ignore it (`0x4d7dec`).
let mut w = wire(spell, trainer_spell_state::RED, 10, 1, 0);
w.req_skill_value = 0;
w
}
fn player() -> PlayerView {
PlayerView {
known: BTreeSet::new(),
level: 10,
skills: Vec::new(),
pet: None,
}
}
fn state(wire: &TrainerSpell, trainer_type: u32, player: &PlayerView) -> u8 {
re_derive(wire, trainer_type, &catalog(), &skill_lines(), player)
}
#[test]
fn the_wire_state_is_discarded_and_a_met_service_reads_green() {
assert_eq!(
state(&service(RANK1_WRAPPER), 0, &player()),
trainer_spell_state::GREEN
);
}
#[test]
fn every_learn_effect_known_reads_gray() {
let mut p = player();
p.known.insert(RANK1);
assert_eq!(
state(&service(RANK1_WRAPPER), 0, &p),
trainer_spell_state::GRAY
);
}
#[test]
fn a_higher_known_rank_counts_as_known() {
// Rank 2 in the book, rank 1 offered: `KnownHigherRank` (0x60c8d0) says known.
let mut p = player();
p.known.insert(RANK2);
assert_eq!(
state(&service(RANK1_WRAPPER), 0, &p),
trainer_spell_state::GRAY
);
}
#[test]
fn a_skill_step_the_player_already_holds_reads_gray() {
let mut p = player();
p.skills.push(SkillSlot {
skill_id: BLACKSMITHING,
step: 1,
value_plus_perm: 1,
});
assert_eq!(state(&service(OPENER), 0, &p), trainer_spell_state::GRAY);
}
#[test]
fn a_skill_line_below_the_requirement_reads_red() {
// A recipe-shaped service: a plain learn wrapper with a skill requirement. (An OPENER
// would read gray here — the player already holds the step it teaches.)
let mut w = service(RANK1_WRAPPER);
w.req_skill = BLACKSMITHING;
w.req_skill_value = 75;
let mut p = player();
p.skills.push(SkillSlot {
skill_id: BLACKSMITHING,
step: 1,
value_plus_perm: 40,
});
assert_eq!(state(&w, 0, &p), trainer_spell_state::RED);
// Met (value + permanent bonus reaches it): the leg leaves the state alone.
p.skills[0].value_plus_perm = 75;
assert_eq!(state(&w, 0, &p), trainer_spell_state::GREEN);
}
/// The reference's own wart (§5.1): the skill scan's not-found exit jumps past the compare,
/// so a line the player lacks entirely writes nothing — green, where the server sends red.
#[test]
fn a_skill_line_the_player_lacks_entirely_reads_green() {
let mut w = service(RANK1_WRAPPER);
w.req_skill = BLACKSMITHING;
w.req_skill_value = 75;
assert_eq!(state(&w, 0, &player()), trainer_spell_state::GREEN);
}
#[test]
fn an_unknown_required_ability_reads_red_or_hidden_at_type_one() {
// The opener (its taught spell unknown, no skill slot held) with a prerequisite ability.
let mut w = service(OPENER);
w.req_spells = [RANK1, 0, 0];
assert_eq!(state(&w, 0, &player()), trainer_spell_state::RED);
assert_eq!(state(&w, 2, &player()), trainer_spell_state::RED);
// Trainer type 1 too: RANK1 is not the rank before TAUGHT, so this is a plain miss, not
// the hidden state (`0x4d83a4`'s extra conjunct — `a_missing_previous_rank_…` pins it).
assert_eq!(state(&w, 1, &player()), trainer_spell_state::RED);
// Known — or known at a higher rank — satisfies it.
let mut p = player();
p.known.insert(RANK2);
assert_eq!(state(&w, 0, &p), trainer_spell_state::GREEN);
let mut p = player();
p.known.insert(RANK1);
assert_eq!(state(&w, 1, &p), trainer_spell_state::GREEN);
}
#[test]
fn a_level_requirement_above_one_gates_on_the_players_level() {
let mut w = service(RANK1_WRAPPER);
w.req_level = 20;
assert_eq!(state(&w, 0, &player()), trainer_spell_state::RED);
let mut p = player();
p.level = 20;
assert_eq!(state(&w, 0, &p), trainer_spell_state::GREEN);
// `req_level <= 1` is not a gate at all (`row[+0x14] > 1`).
w.req_level = 1;
let mut p = player();
p.level = 0;
assert_eq!(state(&w, 0, &p), trainer_spell_state::GREEN);
}
#[test]
fn a_pet_spell_needs_a_pet_of_the_rows_level_and_reads_off_the_pets_book() {
let mut w = service(PET_WRAPPER);
w.req_level = 12;
// The row's level binds the player too (`0x4d8239`); keep the player clear of it so the
// pet legs are what decides.
let player = || PlayerView {
level: 20,
..player()
};
assert_eq!(state(&w, 3, &player()), trainer_spell_state::RED, "no pet");
let mut p = player();
p.pet = Some(PetView {
level: 11,
known: BTreeSet::new(),
});
assert_eq!(
state(&w, 3, &p),
trainer_spell_state::RED,
"pet below the row's level"
);
p.pet = Some(PetView {
level: 12,
known: BTreeSet::new(),
});
assert_eq!(state(&w, 3, &p), trainer_spell_state::GREEN);
// Known by the PET, not the player: gray.
p.pet = Some(PetView {
level: 12,
known: BTreeSet::from([PET_SPELL]),
});
assert_eq!(state(&w, 3, &p), trainer_spell_state::GRAY);
p.known.insert(PET_SPELL);
p.pet = Some(PetView {
level: 12,
known: BTreeSet::new(),
});
assert_eq!(
state(&w, 3, &p),
trainer_spell_state::GREEN,
"the player's own book is not the pet's"
);
}
#[test]
fn the_legs_run_in_the_references_order() {
// A known service with an unmet skill line and an unmet level is GRAY: legs 4 and 5 run
// only while the state is still 0.
let mut w = service(OPENER);
w.req_skill = BLACKSMITHING;
w.req_skill_value = 300;
w.req_level = 60;
let mut p = player();
p.known.insert(TAUGHT);
p.skills.push(SkillSlot {
skill_id: BLACKSMITHING,
step: 1,
value_plus_perm: 1,
});
assert_eq!(state(&w, 2, &p), trainer_spell_state::GRAY);
}
/// The admission gate (`0x4d7dcd`): a wire spell with no `Spell.dbc` record is skipped
/// whole — its wire state stands, whatever the player has.
#[test]
fn a_row_without_a_spell_record_keeps_its_wire_state() {
let w = wire(999_999, trainer_spell_state::RED, 10, 1, 0);
assert_eq!(state(&w, 0, &player()), trainer_spell_state::RED);
let w = wire(999_999, trainer_spell_state::GRAY, 10, 1, 0);
assert_eq!(state(&w, 0, &player()), trainer_spell_state::GRAY);
}
/// `0x4d7e3e`: at a type-1 trainer, a rank the player has already passed is hidden — and it
/// is counted as a learn effect but never as a known one, so it cannot come back as gray.
#[test]
fn a_higher_rank_known_at_a_type_one_trainer_hides_the_row() {
let mut p = player();
p.known.insert(RANK2);
assert_eq!(state(&service(RANK1_WRAPPER), 1, &p), 3);
assert_eq!(
state(&service(RANK1_WRAPPER), 0, &p),
trainer_spell_state::GRAY
);
// Knowing exactly the taught rank is not "higher": gray at every type.
let mut p = player();
p.known.insert(RANK1);
assert_eq!(
state(&service(RANK1_WRAPPER), 1, &p),
trainer_spell_state::GRAY
);
}
/// `0x4d83a4`: the type-1 hidden state on the required-ability leg needs the missing ability
/// to be exactly the rank before what the row teaches; any other missing ability is plain
/// unavailable, and so is every miss at every other type.
#[test]
fn a_missing_previous_rank_at_a_type_one_trainer_hides_the_row() {
let mut w = service(RANK2_WRAPPER);
w.req_spells = [RANK1, 0, 0];
assert_eq!(
state(&w, 1, &player()),
3,
"the missing ability is the previous rank"
);
assert_eq!(state(&w, 0, &player()), trainer_spell_state::RED);
let mut w = service(RANK2_WRAPPER);
w.req_spells = [RANK1 + 500, 0, 0];
assert_eq!(
state(&w, 1, &player()),
trainer_spell_state::RED,
"an unrelated ability"
);
}
/// The pet-spell legs are early exits: nothing after a missing or under-level pet is
/// visited (`0x4d803c`/`0x4d7fac` leave the loop), and a pet that KNOWS the spell is never
/// level-tested at all.
#[test]
fn the_pet_legs_leave_the_loop_and_a_knowing_pet_skips_the_level_test() {
let spells = SpellCatalog::from_displays_and_effects(
HashMap::from([(PET_WRAPPER, SpellDisplay::default())]),
HashMap::from([(
PET_WRAPPER,
vec![
LearnEffect::PetSpell(PET_SPELL),
LearnEffect::SkillStep {
skill: BLACKSMITHING,
step: 1,
},
],
)]),
);
let mut w = service(PET_WRAPPER);
w.req_level = 12;
let mut p = player();
p.level = 20;
p.skills.push(SkillSlot {
skill_id: BLACKSMITHING,
step: 1,
value_plus_perm: 1,
});
// No pet: the step effect after it is never reached, so the row stays unavailable.
assert_eq!(
re_derive(&w, 3, &spells, &skill_lines(), &p),
trainer_spell_state::RED
);
// A pet that knows the spell, below the row's level: known, not level-tested.
p.pet = Some(PetView {
level: 5,
known: BTreeSet::from([PET_SPELL]),
});
assert_eq!(
re_derive(&w, 3, &spells, &skill_lines(), &p),
trainer_spell_state::GRAY
);
}
/// The required-ability leg runs on the PET's book when the row resolved a pet, and its
/// failure is unavailable at every trainer type — the type-1 hidden state is the player
/// variant's alone.
#[test]
fn a_pet_rows_required_abilities_are_the_pets_and_never_hide() {
let mut w = service(PET_WRAPPER);
w.req_spells = [RANK1, 0, 0];
let mut p = player();
p.level = 20;
p.known.insert(RANK1); // the PLAYER knows it — irrelevant for a pet row
p.pet = Some(PetView {
level: 20,
known: BTreeSet::new(),
});
assert_eq!(state(&w, 3, &p), trainer_spell_state::RED);
assert_eq!(
state(&w, 1, &p),
trainer_spell_state::RED,
"no hidden state on the pet path"
);
p.pet = Some(PetView {
level: 20,
known: BTreeSet::from([RANK2]),
});
assert_eq!(
state(&w, 3, &p),
trainer_spell_state::GREEN,
"the pet knows a higher rank"
);
}
#[test]
fn re_derive_all_rewrites_every_row_in_place() {
let mut list = vec![service(RANK1_WRAPPER), service(OPENER)];
let mut p = player();
p.known.insert(RANK1);
super::super::reeval::re_derive_all(&mut list, 0, &catalog(), &skill_lines(), &p);
assert_eq!(list[0].state, trainer_spell_state::GRAY);
assert_eq!(list[1].state, trainer_spell_state::GREEN);
}
}
/// The three group-header keys against the player's REAL `GlobalStrings.lua` — the guard the
@@ -792,7 +792,7 @@ impl WorldAssets {
}
/// World/render configuration read once at startup from the environment, shared by the subsystems
/// that need it: terrain reads `tex_tiles` (splat tiling) + `unload_budget` (the release lane).
/// that need it: terrain reads `unload_budget` (the release lane).
/// Inserted by [`AssetPlugin`] alongside [`WorldAssets`]; its *presence* is also the "there is a
/// client install" gate the world-side setups key on.
///
@@ -801,8 +801,6 @@ impl WorldAssets {
/// Distance setting), as the reference derives it (decision 1513).
#[derive(Resource, Clone, Copy)]
pub struct RenderConfig {
/// Ground-texture repeats per chunk (`$WOW_TEX_TILES`, default 8).
pub tex_tiles: f32,
/// Stale tiles released per frame on a within-map window shift (`$WOW_TILE_UNLOAD`,
/// default 1; `0` = unbudgeted — the whole trailing row in one frame, the pre-B181
/// behaviour, kept as the controlled A/B leg on one build).
+9 -9
View File
@@ -197,14 +197,14 @@ pub use spells::{
cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times,
load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii,
load_spell_ranges, min_max_range, substitute, CcExemption, FormRefusal, LearnAnnouncement,
OpenLock, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes,
SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE,
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_PROSPECTING, SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING,
SPELL_EFFECT_TRADE_SKILL,
LearnEffect, OpenLock, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog,
SpellDispelTypes, SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius,
SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD,
ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR,
ON_NEXT_SWING_RANGE, 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_PROSPECTING, SPELL_EFFECT_SKILL_STEP,
SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
};
mod skill_lines;
pub use skill_lines::{
@@ -266,7 +266,7 @@ pub use terrain::{
adt_to_tile_mesh, area_id_at, find_tile_near, ground_effect_at, impassable_at, load_tile_mesh,
load_tiles_around, mcsh_shadowed_at, terrain_height_at, triangle_z_at, ChunkMesh, Doodad,
MapTiles, TileMesh, WmoInstance, ALPHA_MAP_SIZE, CHUNK_SIZE, SHADOW_MAP_SIZE, STORMWIND_XY,
TILE_SIZE,
TERRAIN_LAYER_TILES, TILE_SIZE,
};
mod wdl;
/// World (x, y) ↔ ADT tile `(col, row)` — the same mapping the streamer uses to pick tiles
@@ -351,6 +351,46 @@ impl SkillLineCatalog {
head
}
/// **Is a higher rank of `spell_id` known?** — the reference's `KnownHigherRank`
/// (`0x60c8d0`, wow-re `trainer-requirement.md`): walk the `forward_spellid` chain forward
/// from `spell_id` (exclusive) and answer on the first known rank. The trainer's requirement
/// colouring and its state re-evaluator both OR this with plain known-ness (2333).
pub fn higher_rank_known(
&self,
spell_id: u32,
known: &std::collections::BTreeSet<u32>,
) -> bool {
let mut cur = spell_id;
for _ in 0..MAX_RANK_CHAIN {
let Some(next) = self.rank_successor(cur) else {
return false;
};
if known.contains(&next) {
return true;
}
cur = next;
}
false
}
/// A catalog holding only ability rows — for tests that need a rank chain or a skill
/// requirement without the DBCs ([`Self::from_spell_lines`]'s fuller sibling).
pub fn from_abilities(abilities: impl IntoIterator<Item = (u32, SlaInfo)>) -> Self {
let abilities: HashMap<u32, SlaInfo> = abilities.into_iter().collect();
let rank_prev = abilities
.iter()
.filter(|(_, a)| a.forward_spell_id != 0)
.map(|(id, a)| (a.forward_spell_id, *id))
.collect();
Self {
abilities,
lines: HashMap::new(),
categories: HashMap::new(),
race_class: HashMap::new(),
rank_prev,
}
}
/// The **highest rank of `spell_id`'s ability that `known` contains** — the rank an action-bar
/// slot pointing at `spell_id` must actually hold. `None` when no rank of the chain is known
/// (an empty book, or an ability the character never learned): the caller leaves the slot
@@ -385,3 +385,34 @@ fn real_rank_chains_resolve_the_highest_known_rank() {
assert_eq!(cat.rank_successor(6603), None);
assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603));
}
/// `KnownHigherRank` (`0x60c8d0`): walk the `forward_spellid` chain forward from the spell and
/// answer on the first known rank — the spell itself is not a "higher" rank of itself.
#[test]
fn a_higher_rank_is_known_only_forward_along_the_chain() {
use std::collections::BTreeSet;
let row = |forward| SlaInfo {
skill_id: 1,
req_skill_value: 0,
forward_spell_id: forward,
trivial_low: 0,
trivial_high: 0,
};
// 10 → 11 → 12.
let cat = SkillLineCatalog::from_abilities([(10, row(11)), (11, row(12)), (12, row(0))]);
let known = |ids: &[u32]| ids.iter().copied().collect::<BTreeSet<u32>>();
assert!(cat.higher_rank_known(10, &known(&[12])));
assert!(cat.higher_rank_known(10, &known(&[11])));
assert!(
!cat.higher_rank_known(10, &known(&[10])),
"the spell itself is not a higher rank"
);
assert!(
!cat.higher_rank_known(12, &known(&[10, 11])),
"nothing is forward of the top rank"
);
assert!(
!cat.higher_rank_known(99, &known(&[10, 11, 12])),
"an unchained spell has no chain"
);
}
+120 -1
View File
@@ -298,6 +298,24 @@ const COL_EFFECT_TRIGGER_1: usize = 109;
/// `SpellEffects` value `36` — `SPELL_EFFECT_LEARN_SPELL`: a trainer's learn wrapper carries it, and
/// its `EffectTriggerSpell` is the ability the player ends up with (decision 0247's taught spell).
pub const SPELL_EFFECT_LEARN_SPELL: u32 = 36;
/// One of a spell's three effect slots, as the trainer's state re-evaluator reads them
/// (`0x4d7d40`, wow-re `trainer-service-suppression.md` §5; decision 2333): the three effect
/// types that decide whether a service is already "known". Any other effect type is not a learn
/// effect and is skipped. Slot order is kept — the reference walks `+0xf4/+0xf8/+0xfc` in order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LearnEffect {
/// `SPELL_EFFECT_LEARN_SPELL` — teaches `EffectTriggerSpell`.
Spell(u32),
/// `SPELL_EFFECT_SKILL_STEP` — raises skill line `skill` (`EffectMiscValue`) to `step`. The
/// step is the effect's value, `EffectBasePoints + EffectDieSides`: on the real build-5875
/// file every profession opener carries `die = 1` and `base = step − 1` (Apprentice `0+1`,
/// Journeyman `1+1`, Expert `2+1`, Artisan `3+1` — pinned by
/// `the_openers_skill_steps_read_off_the_real_file`).
SkillStep { skill: u32, step: u32 },
/// `SPELL_EFFECT_LEARN_PET_SPELL` — teaches the player's pet `EffectTriggerSpell`.
PetSpell(u32),
}
/// `SpellEffects` value `57` — `SPELL_EFFECT_LEARN_PET_SPELL`, the learn wrapper's pet twin. The
/// trainer's **icon** law accepts either in its three-slot wrapper scan (byte-verified: the paired
/// `cmp ecx,0x24` / `cmp ecx,0x39` at `0x4d8ff5`/`0x4d8ffa`, wow-re
@@ -609,6 +627,9 @@ pub const SPELL_EFFECT_PROSPECTING: u32 = 127;
pub struct SpellCatalog {
spells: HashMap<u32, SpellDisplay>,
learned_spell: HashMap<u32, u32>,
/// Spell id → its learn effects in slot order ([`SpellCatalog::learn_effects`]); only spells
/// that carry at least one have an entry.
learn_effects: HashMap<u32, Vec<LearnEffect>>,
/// Spell id → the `Languages.dbc` id its `Effect_1` declares
/// ([`SpellCatalog::declared_language`]).
declared_language: HashMap<u32, u32>,
@@ -620,14 +641,39 @@ impl SpellCatalog {
/// path is [`load_spell_catalog`]. Carries no learn-spell map (synthetic ids teach nothing) and
/// no dispel table, so [`Self::dispel_name`] answers `None` for everything.
pub fn from_displays(spells: HashMap<u32, SpellDisplay>) -> Self {
Self::from_displays_and_effects(spells, HashMap::new())
}
/// [`Self::from_displays`] with an explicit learn-effect table — for the trainer tests, whose
/// synthetic wrappers need to teach something.
pub fn from_displays_and_effects(
spells: HashMap<u32, SpellDisplay>,
learn_effects: HashMap<u32, Vec<LearnEffect>>,
) -> Self {
let learned_spell = learn_effects
.iter()
.filter_map(|(id, effects)| {
effects.iter().find_map(|e| match e {
LearnEffect::Spell(taught) => Some((*id, *taught)),
_ => None,
})
})
.collect();
Self {
spells,
learned_spell: HashMap::new(),
learned_spell,
learn_effects,
declared_language: HashMap::new(),
dispel_types: SpellDispelTypes::default(),
}
}
/// A spell's learn effects in slot order — empty for a plain ability. What the trainer's
/// state re-evaluator walks (decision 2333).
pub fn learn_effects(&self, id: u32) -> &[LearnEffect] {
self.learn_effects.get(&id).map_or(&[], Vec::as_slice)
}
pub fn get(&self, id: u32) -> Option<&SpellDisplay> {
self.spells.get(&id)
}
@@ -770,6 +816,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
let spells_set = parse(&spell_bytes, spell_schema(), "Spell.dbc")?;
let mut spells: HashMap<u32, SpellDisplay> = HashMap::new();
let mut learned_spell: HashMap<u32, u32> = HashMap::new();
let mut learn_effects: HashMap<u32, Vec<LearnEffect>> = HashMap::new();
let mut declared_language: HashMap<u32, u32> = HashMap::new();
for r in spells_set.records() {
let Some(id) = u32_at(r, 0) else { continue };
@@ -784,6 +831,30 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
}
}
}
// The learn effects in slot order, for the trainer's state re-evaluator (2333). A
// SKILL_STEP's step is the effect's value: base points + die sides (see `LearnEffect`).
let effects: Vec<LearnEffect> = (0..3)
.filter_map(|i| {
let trigger = || u32_at(r, COL_EFFECT_TRIGGER_1 + i).filter(|&t| t != 0);
match u32_at(r, COL_EFFECT_1 + i)? {
SPELL_EFFECT_LEARN_SPELL => trigger().map(LearnEffect::Spell),
SPELL_EFFECT_LEARN_PET_SPELL => trigger().map(LearnEffect::PetSpell),
SPELL_EFFECT_SKILL_STEP => {
let skill = i32_at(r, COL_EFFECT_MISC_1 + i).filter(|&m| m > 0)? as u32;
let value = i32_at(r, COL_EFFECT_BASE_POINTS_1 + i).unwrap_or(0)
+ i32_at(r, COL_EFFECT_DIE_SIDES_1 + i).unwrap_or(0);
Some(LearnEffect::SkillStep {
skill,
step: value.max(0) as u32,
})
}
_ => None,
}
})
.collect();
if !effects.is_empty() {
learn_effects.insert(id, effects);
}
// The language declaration (wow-re `chat-language-scramble.md` §8). **Effect slot 0 only**
// — the reference dispatches on `Effect_1` alone (`[SpellRec+0xf4]`) and reads
// `EffectMiscValue_1` (`+0x1a8`); it does not scan the other two slots the way the learn
@@ -949,6 +1020,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
Ok(SpellCatalog {
spells,
learned_spell,
learn_effects,
declared_language,
dispel_types,
})
@@ -957,3 +1029,50 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;
#[cfg(test)]
mod learn_effect_tests {
use super::*;
/// The profession openers on the real build-5875 file: the WIRE spell (the wrapper a trainer
/// lists) carries both the learn effect and the skill step, and the step is base points + die
/// sides. What `0x4d7d40` compares the player's step word against (2333).
#[test]
fn the_openers_skill_steps_read_off_the_real_file() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_catalog(&mut chain).expect("Spell.dbc");
let blacksmithing = 164;
for (wrapper, taught, step) in [
(2020, 2018, 1),
(2021, 3100, 2),
(3539, 3538, 3),
(9786, 9785, 4),
] {
assert_eq!(
cat.learn_effects(wrapper),
&[
LearnEffect::Spell(taught),
LearnEffect::SkillStep {
skill: blacksmithing,
step
}
],
"wrapper {wrapper}"
);
}
// Mining's opener: base −1 + die 1 on the LEARN slot is not a step; the SKILL_STEP slot is.
assert_eq!(
cat.learn_effects(2581),
&[
LearnEffect::Spell(2575),
LearnEffect::SkillStep {
skill: 186,
step: 1
}
]
);
// A plain ability teaches nothing.
assert!(cat.learn_effects(2383).is_empty(), "Find Herbs");
}
}
+8 -2
View File
@@ -40,6 +40,12 @@ pub(crate) const UNIT_SIZE: f32 = CHUNK_SIZE / 8.0;
/// World (x, y) of Stormwind, from `TaxiNodes.dbc` — a handy anchor for "Elwynn".
pub const STORMWIND_XY: (f32, f32) = (-8840.56, 489.7);
/// Ground-texture repeats per chunk — how many times a layer texture tiles across one MCNK. The
/// binary's own value is 8 (each of the chunk's 8×8 cells carries one repeat), and it is not a
/// setting: a `$WOW_TEX_TILES` knob once let a capture try other values and was retired (2335)
/// because nothing in the reference tunes it.
pub const TERRAIN_LAYER_TILES: f32 = 8.0;
/// One MCNK chunk as an indexed triangle mesh in **raw WoW coords**, plus texturing data.
///
/// Vertex arrays (`positions`, `normals`, `uvs`) hold **145 entries** in MCVT's stride-17 order:
@@ -56,8 +62,8 @@ pub struct ChunkMesh {
/// removes the lighting seams at MCNK boundaries. Empty if the chunk had no MCNR data.
pub normals: Vec<[f32; 3]>,
/// Per-vertex texture coordinates (0..1 across the chunk; outer at `(c/8, r/8)`, inner at
/// `((c+½)/8, (r+½)/8)`). Renderers scale `× tex_tiles` (=8 per the binary) for layer
/// sampling and use the raw 0..1 for alpha/shadow.
/// `((c+½)/8, (r+½)/8)`). Renderers scale `× `[`TERRAIN_LAYER_TILES`] for layer sampling and
/// use the raw 0..1 for alpha/shadow.
pub uvs: Vec<[f32; 2]>,
/// Triangle-list indices into `positions` (4-tri center-fan/cell, holed blocks omitted).
pub indices: Vec<u32>,
+1 -9
View File
@@ -82,21 +82,13 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
// How much terrain is resident is NOT a knob here: the streamer derives its window from the
// live `farclip` (`view::ViewDistance`, the player's Terrain Distance setting) the way the
// reference does — `terrain_stream::window`, decision 1513. `$WOW_TILE_RADIUS` is retired.
// Ground-texture repeats per chunk; tunable live in the panel afterward.
let tex_tiles = std::env::var("WOW_TEX_TILES")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(8.0);
// See the field doc: the tile-unload budget (B181). Default 1 — even the fastest focus
// (boosted free-fly, ~1 stale row/s) produces stale tiles far slower than 60/s drains them.
let unload_budget = std::env::var("WOW_TILE_UNLOAD")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
commands.insert_resource(RenderConfig {
tex_tiles,
unload_budget,
});
commands.insert_resource(RenderConfig { unload_budget });
match open_chain(&data) {
Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf)),
+4 -8
View File
@@ -163,14 +163,13 @@ pub(crate) const DETAIL_DOODAD_FADE_FAR: f32 = 70.0;
/// — player-settable through **either** registered CVar over this one field, [`ClutterConfig::frill_density`]
/// being the conversion: `WorldDetail` (the panel's stop, 0/1/2 → ×1/×2/×3) or `frillDensity` (the
/// reference's own cells-per-chunk, 1..256), both arms in `benilla-app`'s `cvars`;
/// `scale` resizes each doodad model; `alpha_ref` is the alpha-test cutout threshold
/// `alpha_ref` is the alpha-test cutout threshold
/// ([`DETAIL_DOODAD_ALPHA_REF`]); `fade_far` is the clutter draw-distance horizon (yd,
/// [`DETAIL_DOODAD_FADE_FAR`]; fade starts at 0.75×). Initial values from `$WOW_CLUTTER_DENSITY` /
/// `$WOW_CLUTTER_SCALE` / `$WOW_CLUTTER_ALPHA` / `$WOW_CLUTTER_FADE`; `density 0` disables clutter.
/// `$WOW_CLUTTER_ALPHA` / `$WOW_CLUTTER_FADE`; `density 0` disables clutter.
#[derive(Resource, Clone, Copy)]
pub struct ClutterConfig {
pub density: f32,
pub scale: f32,
pub alpha_ref: f32,
pub fade_far: f32,
}
@@ -216,7 +215,6 @@ impl Default for ClutterConfig {
// overdraw on the ground — the thing a bandwidth-bound part has least of. Medium is the
// nearest stop no sparser than a fresh install, and the row is one drag from High.
density: env("WOW_CLUTTER_DENSITY").unwrap_or(2.0).max(0.0),
scale: env("WOW_CLUTTER_SCALE").unwrap_or(1.0).max(0.01),
alpha_ref: env("WOW_CLUTTER_ALPHA")
.unwrap_or(DETAIL_DOODAD_ALPHA_REF)
.clamp(0.0, 1.0),
@@ -402,7 +400,6 @@ pub(crate) fn scatter_tile_clutter(
fn build_chunk_clutter(
chunk_entity: Entity,
models: &[(String, Vec<ShadedPlacement>)],
scale: f32,
alpha_ref: f32,
fade_far: f32,
geometry: &mut ClutterGeometry,
@@ -437,8 +434,8 @@ fn build_chunk_clutter(
let base = positions.len() as u32;
let origin = wow_to_bevy(d.position);
let rot = Quat::from_rotation_y(d.yaw);
// Per-instance scale (client's random [0.9,1.1]) × the debug-panel size multiplier.
let inst_scale = scale * d.scale;
// Per-instance scale (the client's random [0.9,1.1]).
let inst_scale = d.scale;
// MCSH tint per placement — same value on every vertex of THIS instance, so all
// tris of one grass clump share the lit/shadowed colour. The shader multiplies
// `texture × vertex_color` in gamma space (the faithful MODULATE 1×).
@@ -626,7 +623,6 @@ pub(crate) fn stream_chunk_clutter(
let built = build_chunk_clutter(
ent,
&cc.models,
cfg.scale,
cfg.alpha_ref,
cfg.fade_far,
&mut geometry,
+1 -2
View File
@@ -750,7 +750,6 @@ fn stream_terrain(
// wall moves this with it.
let center = focus.resolve(camera.single().ok().map(|c| c.translation));
let window = StreamWindow::at(view.farclip, center[0], center[1]);
let tiling = cfg.as_ref().map(|c| c.tex_tiles).unwrap_or(8.0);
let (cx, cy) = window.focus_tile();
let same_window = state.focus == (cx, cy) && state.reach == Some((window.inner, window.outer));
// Never while a load is in flight: `paced` is false from a snap until the body settles, and
@@ -960,7 +959,7 @@ fn stream_terrain(
layer_array: adt.layer_array.clone(),
alpha_array: adt.alpha_array.clone(),
shadow_array: adt.shadow_array.clone(),
params: Vec4::new(tiling, 0.0, 0.0, 0.0),
params: Vec4::new(benilla_formats::TERRAIN_LAYER_TILES, 0.0, 0.0, 0.0),
light_buf: shared_light.0.clone(),
},
});