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