step budgets come from the binary, and a step-down follows the ground

- the step-up and step-down budgets are the client's own verified numbers, and the step regime is chosen by the blocking edge
- walking off a kerb follows the surface down instead of diving past the edge; steep ground supports you only while you are descending onto it
- a ride on a moving platform survives the ground snap, and a vertical wall no longer depends on a float's sign
- trainer and craft rows sort with the real client's own comparators, one per window — Beast Training lists a skill's ranks in ascending order again

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-08 17:13:02 +07:00
co-authored by Claude Fable 5
parent cf44e38574
commit 76e787d704
19 changed files with 1933 additions and 619 deletions
+9 -5
View File
@@ -510,11 +510,15 @@ pub(in crate::net) fn extrapolate_remote_units(
from,
vel,
time.delta(),
// No hover offset: a remote's own granted modes are not modelled yet — decision
// 0866 builds the family for our own mover. A hovering *observed* player draws
// a yard low until the relayed `MSG_MOVE_HOVER` is read into per-unit mode
// state; the same shape as every other unmodelled remote mode.
0.0,
// Default support: **no hover offset**, because a remote's own granted modes
// are not modelled yet — decision 0866 builds the family for our own mover. A
// hovering *observed* player draws a yard low until the relayed
// `MSG_MOVE_HOVER` is read into per-unit mode state; the same shape as every
// other unmodelled remote mode. And **no carried steep-support bit** (1129):
// it is per-frame state the dead-reckon does not keep between packets, so a
// watched player descending a steep face gets the ordinary cone reach, and the
// next packet's wire Z corrects whatever that misses.
crate::player::mover::Support::default(),
)
.center
};
+46 -1
View File
@@ -26,6 +26,13 @@ pub(super) struct Frame {
pub y_in: f32,
/// Feet height leaving the step (yd) — after the slide *and* the step-down snap.
pub y_out: f32,
/// Horizontal distance covered this frame (yd). Paired with the vertical it gives the frame's
/// **descent slope**, which is the whole question behind "we dive over the edge instead of
/// stepping down it": the reference bounds a step-down to the foot cone's own
/// [`super::STEP_SLOPE_RATIO`] per unit of horizontal travel (wow-re `step-off-recourse.md`), so
/// a walk-off that reads far steeper — or worse, flat-then-plummet across two frames — is the
/// mover leaving the surface instead of following it down.
pub dx: f32,
pub grounded: bool,
pub on_walkable: bool,
pub vel_y: f32,
@@ -158,19 +165,57 @@ pub(super) fn frame(f: Frame) {
}
),
};
// The descent slope, printed only while actually going down and moving: `>cone` marks a frame
// steeper than the foot cone allows, which is the signature of a dive rather than a step-down.
let slope = if dy < -1.0e-4 && f.dx > 1.0e-4 {
let s = -dy / f.dx;
format!(
" dx={:.3} slope={s:.2}{}",
f.dx,
if s > super::STEP_SLOPE_RATIO {
" >cone"
} else {
""
}
)
} else {
format!(" dx={:.3}", f.dx)
};
// The dive's real signature, and it is not steepness — it is the *absence* of descent. A body
// that leaves the surface travels forward with `dy≈0` while the probe reports a steep face right
// beneath it; everything after that is a ballistic arc, and by the time the fall looks steep the
// frame that caused it is long gone. `>cone` was the first guess and the director's capture
// showed it never fires: the arc starts at slope 0.05 and only reaches 0.83. This is the marker
// that would have named it on the first read.
let left = f
.snap
.and_then(|(_, hit)| hit)
.is_some_and(|(_, ny)| ny < super::GROUND_COS)
// **Flat is the signature — not "anything that isn't descending".** A dive frame is one that
// travels forward and goes *nowhere* vertically while a steep surface sits below it. Written
// as `dy > -eps` this also swallows every frame that is climbing, where not descending is
// exactly right: first the atomic step-ups, then — once those were excluded — the ordinary
// walk up a hill, which rises through the slope ride and carries no `climb` at all. Three
// false-positive classes on three consecutive captures, the second of which buried nine real
// takeoffs under fifty-five phantoms. A window around zero admits none of them.
&& dy.abs() < 1.0e-3
&& f.dx > 1.0e-4;
let left = if left { " LEFT-SURFACE" } else { "" };
let climb = f.climb.map_or(String::new(), |t| format!(" climb={t:+.3}"));
let anchored = if f.anchored { " ROOTED" } else { "" };
dbg_trace::line(
"move",
&format!(
"y {:9.3} -> {:9.3} dy={:+.3} grounded={} walk={} vy={:+7.2} {}{}{}",
"y {:9.3} -> {:9.3} dy={:+.3}{} grounded={} walk={} vy={:+7.2} {}{}{}{}",
f.y_in,
f.y_out,
dy,
slope,
f.grounded as u8,
f.on_walkable as u8,
f.vel_y,
snap,
left,
climb,
anchored
),
+472 -60
View File
@@ -151,7 +151,7 @@ pub(super) fn step(
// failure 0209's atomic commit was built to make impossible. The ride is re-earned from the
// certification every frame it continues, so this can hold nothing up that has not just proved
// it can be climbed.
let on_floor = !held && (on_walkable || player.cone_riding) && player.vel_y <= 0.0;
let on_floor = !held && (on_walkable || player.steep_support) && player.vel_y <= 0.0;
// The wedged rest (decision 0211) stands until real ground takes over or the support
// vanishes — we walked off the funnel wall into open air, which resumes a normal fresh fall.
@@ -175,7 +175,7 @@ pub(super) fn step(
if want_jump {
player.vel_y = JUMP_SPEED;
player.wedged = false;
player.cone_riding = false;
player.steep_support = false;
jumped = true;
}
} else {
@@ -221,7 +221,10 @@ pub(super) fn step(
center,
player.horiz_vel,
time.delta(),
hover_offset,
Support {
offset: hover_offset,
steep: player.steep_support,
},
);
// The step-up probe (this is the LOCAL mover; a remote's dead-reckon is not a report
// anyone is looking at): a walk frame that went nowhere writes the `stup` deep report —
@@ -239,7 +242,7 @@ pub(super) fn step(
center = g.center;
climb = g.climb;
snap_probe = g.snap;
player.cone_riding = g.cone_riding;
player.steep_support = g.steep_support;
if let Some(e) = g.ground {
ground_entity = Some(e);
}
@@ -255,7 +258,7 @@ pub(super) fn step(
// remote mover's arc runs, so a jump meets our walls whoever is jumping (decision 0627).
center = airborne_step(ms, capsule, &filter, center, velocity, time.delta());
// Nothing here can be riding a cone: this arm is the arc, the hold and the anchor.
player.cone_riding = false;
player.steep_support = false;
}
// Wedge-rest detection (decisions 0211/0212): airborne, already falling fast, yet the
// descent achieved is a sliver of what gravity intended — [`WEDGE_STILL_FRAMES`] in a row
@@ -330,6 +333,7 @@ pub(super) fn step(
move_trace::frame(move_trace::Frame {
y_in: pre_move.y - half_h.y,
y_out: player.pos.y,
dx: (center - pre_move).xz().length(),
grounded,
on_walkable,
vel_y: player.vel_y,
@@ -351,6 +355,23 @@ pub(super) fn step(
}
}
/// **What already holds the body up, entering the frame** — the two facts [`grounded_step`] cannot
/// work out for itself, because both are carried from the *previous* frame's resolve.
///
/// They travel together because they are the same question asked twice: how far below the feet does
/// "the ground" start, and does the thing under us count as a floor at all.
#[derive(Clone, Copy, Default)]
pub(crate) struct Support {
/// The body rests this far **above** the surface — HOVER's float (decision 0866),
/// [`super::HOVER_HEIGHT`] while the mode is up and `0.0` for everyone else, which is the
/// ordinary case.
pub(crate) offset: f32,
/// The support entering the frame is a **certified steep contact**, not a walkable floor — the
/// reference's `0x4000000` (decision 1125). It is the sole gate on the step-down probe's deep
/// reach; see the reach in [`grounded_step`].
pub(crate) steep: bool,
}
/// What one grounded walk step resolved against the world came out as ([`grounded_step`]).
pub(crate) struct GroundedStep {
/// The resolved capsule centre.
@@ -362,10 +383,11 @@ pub(crate) struct GroundedStep {
/// The height gain (yd) this frame's climb achieved — the atomic step-up's committed rise, or
/// the distance a foot-cone ride carried the body up its skirt.
pub(crate) climb: Option<f32>,
/// The body is part-way up a **foot-cone ride** and is *supported by the edge it is riding*,
/// not standing on a floor — so the caller must keep treating it as grounded until a walkable
/// floor takes over (decision 1123). Always `false` for a frame that did not ride.
pub(crate) cone_riding: bool,
/// The body is **supported by a certified steep contact** rather than standing on a walkable
/// floor — climbing a foot-cone ride (1123) or following a surface down off a ledge (1127) —
/// so the caller must keep treating it as grounded until a walkable floor takes over. This is
/// the reference's `0x4000000` (decision 1125). `false` for an ordinary frame.
pub(crate) steep_support: bool,
/// The election snap's `(probe reach, what it found)` — trace fodder, `None` when the step-up
/// took the frame instead. The inner pair is `(hit distance, hit normal.y)`.
pub(crate) snap: Option<(f32, Option<(f32, f32)>)>,
@@ -396,8 +418,9 @@ pub(crate) fn grounded_step(
center: Vec3,
horiz_vel: Vec3,
dt: std::time::Duration,
surface_offset: f32,
support: Support,
) -> GroundedStep {
let surface_offset = support.offset;
let cast = |from: Vec3, disp: Vec3| {
crate::collision::one_sided::cast_move(ms, capsule, from, disp, SKIN_WIDTH, filter)
};
@@ -454,20 +477,34 @@ pub(crate) fn grounded_step(
);
}
}
// **Which regime** (decision 1123, wow-re `climb-vs-slide.md` §2/§4/§6). The certification above
// settles *whether* the obstacle can be cleared; its committed rise settles *how*. The real
// **Which regime** (decisions 1123/1126, wow-re `climb-vs-slide.md` §2/§4/§6). The certification
// above settles *whether* the obstacle can be cleared; the height of the **blocking edge** — the
// face the look-ahead is pressed against, measured from the feet — settles *how*. The real
// client's solid is a cone below [`FOOT_CONE_HEIGHT`] and a vertical box above it, so a low edge
// never meets a wall to be lifted over — it meets the slanted skirt, and the ordinary slide
// carries the body up it. Only an edge that clears the cone meets the box square, and that
// square meeting is the instant step-up. One law, two outcomes, selected by the rise:
// - `dy ≤ FOOT_CONE_HEIGHT` ⇒ **ride** the skirt at atan 1.8494 ≈ 61.6°, over as many frames
// as the gait needs (a kerb takes two at a run) — smooth, and never a teleport;
// - `dy > FOOT_CONE_HEIGHT` ⇒ the atomic pop of 0209, unchanged;
// - no certification at all ⇒ neither: the plain slide, and the body never rises. (This is
// the gate that keeps the ride honest — a tall wall's contact point sits *inside* the cone
// band too, so height alone would ride it. Only the certification distinguishes them.)
let ride_to = match attempt.verdict {
StepVerdict::Commit { landed, dy, .. } if dy <= FOOT_CONE_HEIGHT => Some(landed.y),
// never meets a wall to be lifted over: it meets the slanted skirt, and the ordinary slide
// carries the body up it. Only an edge that clears the cone meets the box square, and that square
// meeting is the instant step-up. One law, two outcomes:
// - edge inside the cone ⇒ **ride** the skirt at atan 1.8494 ≈ 61.6°, over as many frames as
// the gait needs — smooth, and never a teleport;
// - edge above the cone ⇒ the atomic pop of 0209, unchanged;
// - no certification at all ⇒ neither: the plain slide, and the body never rises.
//
// **The certification is the gate, not the height** — a tall wall's contact against a *capsule*
// sits at the hemisphere centre, ≈0.33 yd up and squarely inside the band, so height alone would
// ride a cliff. Only "the settle found a walkable floor" separates the two.
//
// 1123 selected on the certified *rise* instead, which was indistinguishable while the advance was
// one body radius and became wrong the moment 1126 lengthened it: the rise then measures how high
// the ground is a yard further on, not how tall the thing in front of you is. Booty Bay's 0.40 yd
// step onto a rising ramp reads as a 0.755 yd obstacle under that rule and teleports; its blocking
// edge is 0.40 and rides. The edge is the quantity the reference's own cone band tests, and unlike
// the rise it does not move when the probe reaches further.
let ride_to = match (attempt.verdict, attempt.contact) {
(StepVerdict::Commit { landed, .. }, Some((p, _)))
if p.y - (center.y - CAPSULE_HEIGHT * 0.5) <= FOOT_CONE_HEIGHT =>
{
Some(landed.y)
}
_ => None,
};
if ride_to.is_none() {
@@ -479,7 +516,7 @@ pub(crate) fn grounded_step(
ground: None,
climb: Some(landed.y - center.y),
snap: None,
cone_riding: false,
steep_support: false,
};
}
}
@@ -520,47 +557,136 @@ pub(crate) fn grounded_step(
let mut slid = out.position;
// Snap onto the surface so we follow downhill slopes + steps down — the client's step-vs-fall
// election (`0x6367b0`, wow-re `step-vs-fall-election.md`): the probe reaches
// [`STEP_SLOPE_RATIO`]·travel + [`STEP_SNAP_SLACK`] + the unit's collision height (`0x617430` =
// `[unit+0xb8]`, our [`CAPSULE_HEIGHT`]; the election's `0x4000000`-gated extension — decision
// 0182) and snaps only onto a *walkable* floor (≤50°, the election's own `cos50°` =
// [`GROUND_COS`]). A deeper or steeper floor is NOT absorbed: no snap, the next frame's ground
// probe misses, and the gap becomes a fall (the client's `StartFalling(0)` election) — a short
// ledge drop reads as a quick, continuous, steep descent, which is what the director's eye
// confirmed against the reference (decision 0190; 0189's instant absorbed step read as a
// teleport and was reverted).
// [`STEP_SLOPE_RATIO`]·travel + [`STEP_SNAP_SLACK`], and snaps only onto a *walkable* floor
// (≤50°, the election's own `cos50°` = [`GROUND_COS`]).
//
// Standing still the reach is slack + the collision height, which is what re-grounds an
// *idle* body every frame: the small float a raw wire Z leaves a watched player standing on
// our terrain is taken out here, the same way [`crate::net::motion::ground_clamp_creatures`]
// takes it out of an idle NPC.
// `surface_offset` is HOVER (decision 0866): the reference's WALK resolver `0x6367b0` adds
// **The reach is the cone's own slope, and nothing else, on an ordinary walking frame**
// (decision 1129). It used to carry a flat `+`[`CAPSULE_HEIGHT`] — 2.028 yd of unconditional
// extra depth — on the reading that `0x617430` returned a collision height. Both halves of that
// were wrong (decision 1125, wow-re `mover-collision-scalars.md` + `step-off-recourse.md`):
// `0x617430` is `[unit+0xb8]`, the dimensionless scale ratio `max(SCALE_X / CreatureModelScale,
// 1)`, so `H` is **1.0** for a player and not 2.028; and the reference adds it only while
// `0x4000000` is set — "the current support is a certified STEEP contact, not a walkable
// floor", which is [`Support::steep`]. So the deep reach belongs to the step-down *recourse*,
// where a body already following a steep face down needs to see the walkable ground waiting at
// its foot; the ordinary walking frame reaches exactly as far as the foot cone could rest.
//
// What that costs is real and intended: a drop deeper than the cone is NOT absorbed. No snap,
// the next frame's ground probe misses, and the gap becomes a fall (the client's
// `StartFalling(0)` election) — a short ledge drop reads as a quick, continuous, steep descent,
// which is what the director's eye confirmed against the reference (decision 0190; 0189's
// instant absorbed step read as a teleport and was reverted). At 2.028 yd we were absorbing
// nearly two body-heights of ledge in one frame, which is the same teleport by another route.
//
// Standing still the reach collapses to the slack alone, so an idle body is re-grounded only
// when it is genuinely resting on the floor; a body left floating above one falls the float and
// lands, rather than being pulled down through up to two yards of air.
//
// [`Support::offset`] is HOVER (decision 0866): the reference's WALK resolver `0x6367b0` adds
// `[0x7ff9d8]` = 1.0 to this same surface offset while `MOVEFLAG_HOVER` is set, and widens the
// step-down reach by the same yard (`0x633e35`) so the float still follows the ground down.
// Both halves are here: the reach grows by the offset, and the snap stops that far short of the
// floor. Zero for everyone not hovering, which is the ordinary case and unchanged.
let d = slid - center;
let reach =
d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK + CAPSULE_HEIGHT + surface_offset;
let reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO
+ STEP_SNAP_SLACK
+ if support.steep { STEP_UP_HEIGHT } else { 0.0 }
+ surface_offset;
let hit = cast(slid, Vec3::NEG_Y * reach);
let snap = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y))));
let mut ground = None;
let mut steep_support = false;
// **A ride's height is earned; the snap follows ground *down*, it never undoes a climb.**
//
// This is where our capsule and the reference's cone part company, and it has to be said out
// loud or the ride only works on bevels. Mid-ride the real client's foot cone is *resting on
// the very edge it is climbing* — its skirt is in contact, so its down-probe is blocked at zero
// and there is nothing to settle to. A capsule against a **vertical** riser touches it side-on
// and hangs clear of everything below, so the same down-probe sees the floor it is climbing
// away from, a full ride's height beneath it, and yanks it straight back down — every frame,
// forever. Booty Bay's 0.40 yd step is exactly that: certified `COMMIT dy=+0.439` on every
// frame, and stalled at +0.020.
//
// So while a ride is in progress the only floor that may end it is one **higher than the ride
// began on** — the tread it is certified to be climbing to. A floor at or below the start is
// the ground being left behind, and it gets no say.
// The height this frame's ride earned, read before the snap can spend any of it. (The two snap
// arms below are mutually exclusive — walkable or steep — so one value serves both.)
let ride_rise = (slid.y - center.y).max(0.0);
// **The step-down follows the surface; it does not leave it** (decision 1127). The reference's
// finalize writes `pos.z -= min(clearance, d_h·1.8493990)` *before* it classifies anything, and a
// steep landing is answered by the multipass rather than by a fall — a `ret 2` continues,
// grounded, with the "my support is a certified steep contact" bit set (wow-re
// `step-off-recourse.md`; the bit's meaning is decision 1125's).
//
// We refused every hit under 50° outright, so walking off a kerb the probe found only the ~61°
// riser, declined it, and the body kept its full height while its forward speed carried it out
// past the edge — then gravity took over. The director's capture is unambiguous: one frame of
// `dy=+0.000 dx=0.118` with the surface 0.162 below, then eighteen airborne frames landing 2.1 yd
// downrange. That is the dive.
//
// **The descent is unconditional, it is bounded by the probe itself, and only the *support* is
// conditional.** Byte-exact (`0x636e45`–`0x636e52`, wow-re `step-off-recourse.md` §1): the
// finalize sweeps down by `L` and writes `pos.z -= achieved`, where `achieved` is the sweep's own
// output — the clearance when something is under us, and the **whole of `L`** when nothing is —
// and only *then* does it classify what it found. So the cap and the reach are the same number,
// and there is no second, smaller bound: `L` is how far the body may fall to stay with its
// surface this frame, whatever the surface turns out to be.
//
// 1127 shipped a separate `min(drop, d_h·1.8494 + 1/36)` cap because at the time `reach` also
// carried the bogus flat 2.028 and could not be used as the bound. With the reach corrected
// above, the two are one quantity again — and the difference is exactly what closes the last of
// the dive: on a face **steeper than the cone** the old cap could never keep up (walking off the
// 0.91 yd step, whose face is 66°, the surface drops 0.256 in a frame the cone allowed 0.242, so
// every frame leaked its remainder into a small fall). Once the first frame latches the
// steep-support bit, `L` grows by `H` and the following frames can take the whole clearance.
if let Some(h) = hit.as_ref().filter(|h| h.normal1.y < GROUND_COS) {
let drop = (h.distance - surface_offset).max(0.0);
slid.y -= drop;
// **Support is earned by descending, not by touching.** A bound alone reads a body *resting*
// on a steep face as supported — standing still the reach collapses to the slack, the resting
// clearance is ~0, and any "is it within reach" test passes — which perches a motionless body
// on a 60° bank forever instead of letting it slide off. That is 1121's named "standing on
// >50° faces" hazard, and it is a worse defect than the dive this recourse exists to fix.
//
// Requiring real downward progress states the actual condition: this is a step-*down* in
// progress, not a resting state. A body standing on a bank, or traversing one across the
// slope, makes no progress and gets nothing. (The reference reaches the same place by a
// different road — its `0x4000000` is set only by a multipass `ret 2`, a full rise-step-settle
// certification that a body with nowhere to descend to cannot pass.)
steep_support = drop > STEP_SNAP_SLACK;
}
// **Nothing under us: descend the whole probe anyway, then let the fall elect.** The same
// `pos.z -= achieved` runs on the no-hit leg with `achieved == L` — wow-re `step-off-recourse.md`
// §1, "no committed record: `pos.z` has ALREADY dropped by the full `L`" — so the frame that
// leaves a ledge starts its fall a probe's-worth lower instead of flat. A ride is exempt: mid-ride
// the body legitimately hangs clear of everything below, and this would spend the climb.
if hit.is_none() && !rode {
slid.y -= (reach - surface_offset).max(0.0);
}
if let Some(h) = hit.filter(|h| h.normal1.y >= GROUND_COS) {
// `max(…, 0)` is the reference's, and it is the difference between a float and a pop: the
// snap **only ever descends** (`0x636e8d`–`0x636e9e` skips the write when `L − 1.0 < 0`), so
// a hovering body that is already within its clearance is left where it is rather than
// yanked up to it. The rise to clearance is a separate rate-limited climb — see [`step`].
slid.y -= (h.distance - surface_offset).max(0.0);
ground = Some(h.entity);
let drop = (h.distance - surface_offset).max(0.0);
if !(rode && drop >= ride_rise) {
slid.y -= drop;
ground = Some(h.entity);
}
}
GroundedStep {
center: slid,
// A ride is a climb too — the trace reads the same whichever regime moved the body.
climb: rode.then_some(slid.y - center.y),
// Mid-ride the body is held up by the edge under its skirt, and the frame-start ground
// probe can only see the steep riser there. Say so, so the caller keeps it standing
// instead of letting gravity undo the climb. A snap onto a walkable floor ends the ride:
// the tread is under the feet and ordinary grounding takes over from here.
cone_riding: rode && ground.is_none(),
// **Supported by a certified steep contact** — the reference's `0x4000000`, whose meaning
// decision 1125 settled. Two ways to earn it and one meaning: mid-ride the body is held up
// by the edge under its skirt (climbing), and mid-step-down it is resting on the surface it
// is following off a ledge (descending). Either way the frame-start ground probe looks
// straight down and sees only a steep face, so without this the frame reads as airborne and
// gravity takes the body off the surface — the climb undone on the way up, the dive on the
// way down. A walkable floor ends it: ordinary grounding takes over from there.
steep_support: (rode && ground.is_none()) || steep_support,
ground,
snap,
}
@@ -605,6 +731,25 @@ pub(crate) fn airborne_step(
.position
}
/// **The overhang line is a tolerance, not zero** — and it has to be, or half the world's vertical
/// walls behave differently from the other half for no physical reason.
///
/// Every "is this face steep-but-not-overhanging" test here is a range whose floor used to be `0.0`.
/// An axis-aligned authored quad — a doorstep, a crate, a dock plank, most of Booty Bay — computes a
/// normal whose `y` is `±0` decided by nothing but which way the cross product's rounding fell. The
/// director's capture caught both halves of that coin on the *same* step: `n.y = −4.08e-5` refused to
/// certify and refused to ride, where `+0.009` on the same geometry did both. A real overhang is a
/// ceiling, nowhere near this line, so widening the floor by a ten-thousandth costs nothing and
/// makes a vertical wall a vertical wall whichever way the rounding goes.
const OVERHANG_EPS: f32 = -1.0e-4;
/// A face the mover meets side-on: too steep to stand on, not a ceiling. The shared gate behind the
/// step-up's certification, the foot-cone ride and the wall flatten — one line, so the three can
/// never disagree about what a vertical wall is (see [`OVERHANG_EPS`]).
fn is_steep_face(ny: f32) -> bool {
(OVERHANG_EPS..GROUND_COS).contains(&ny)
}
/// The even-speed ramp ride: a walkable slope never slows or deflects the grounded walk. The
/// real client's walk step is two-dimensional — the resolver takes speed·dt as a *horizontal*
/// distance and a normalized 2D direction, and Z follows purely through the snap/step machinery
@@ -651,7 +796,7 @@ fn walkable_ride_velocity(n: Vec3, v: Vec3) -> Option<Vec3> {
/// certification's call in [`grounded_step`], because a tall wall's contact point sits inside the
/// cone band too and height alone cannot tell the two apart.
fn foot_cone_ride(n: Vec3, v: Vec3) -> Option<Vec3> {
if !(0.0..GROUND_COS).contains(&n.y) {
if !is_steep_face(n.y) {
return None;
}
// Steepness bounds the horizontal part below by sin 50°, so the normalize is safe.
@@ -679,7 +824,7 @@ fn foot_cone_ride(n: Vec3, v: Vec3) -> Option<Vec3> {
/// `floor_block_on_wall`); penetration safety is untouched — the slide's sweeps still stop at
/// the real surface, the plane only shapes the deflection.
fn steep_wall_plane(n: Vec3, v: Vec3) -> Option<Vec3> {
if !(0.0..GROUND_COS).contains(&n.y) {
if !is_steep_face(n.y) {
return None;
}
let vn = v.dot(n);
@@ -815,7 +960,7 @@ pub(crate) fn step_up(
return none(StepVerdict::NoFace);
};
let n = ahead.normal1;
if n.y >= GROUND_COS || n.y < 0.0 || n.dot(dir_h) >= 0.0 {
if n.y >= GROUND_COS || n.y < OVERHANG_EPS || n.dot(dir_h) >= 0.0 {
return none(StepVerdict::NoFace);
}
let at = |verdict| StepAttempt {
@@ -881,6 +1026,12 @@ mod tests {
/// these casts, so a mis-wound fixture would silently be a hole to fall through.
fn world_with_kerb() -> App {
const PROFILE: [(f32, f32); 4] = [(-2.0, 0.0), (0.29, 0.0), (0.446, 0.28), (3.0, 0.28)];
world_from_profile(&PROFILE)
}
/// The fixture builder behind every profiled world here: a `(x, y)` polyline extruded across
/// `z`, wound so every face's **authored** normal points up and back at the approaching body.
fn world_from_profile(profile: &[(f32, f32)]) -> App {
const W: f32 = 3.0;
let mut app = App::new();
// avian's collider backend reads `Assets<Mesh>` and `SceneSpawner` even in a meshless
@@ -894,7 +1045,7 @@ mod tests {
));
app.init_asset::<Mesh>();
let (mut verts, mut tris) = (Vec::new(), Vec::new());
for w in PROFILE.windows(2) {
for w in profile.windows(2) {
let (&(x0, y0), &(x1, y1)) = (&w[0], &w[1]);
let b = verts.len() as u32;
verts.extend([
@@ -950,8 +1101,24 @@ mod tests {
/// Walk the capsule into the kerb and run `frames` consecutive **whole** grounded steps from
/// wherever the last one left it — the mover's own loop, so what these assert is the behaviour
/// on screen and not a single probe in isolation.
fn walk_kerb(start_y: f32, frames: usize) -> Vec<(f32, Option<f32>, bool)> {
world_with_kerb()
fn walk_kerb(start_y: f32, frames: usize) -> Vec<Row> {
walk_profile(world_with_kerb(), start_y, frames)
}
/// One frame of a walked fixture: `(feet height, climb, steep support, walkable ground)`.
type Row = (f32, Option<f32>, bool, bool);
/// Walk the capsule into `world` and run `frames` consecutive **whole** grounded steps from
/// wherever the last one left it — the mover's own loop, so what these assert is the behaviour
/// on screen and not a single probe in isolation. Rows are `(feet height, climb, ride latch)`.
fn walk_profile(world: App, start_y: f32, frames: usize) -> Vec<Row> {
walk_from(world, Vec3::new(-1.0, start_y, 0.0), Vec3::X, frames)
}
/// The general walker: place the **feet** at `start`, push in `dir`, and run `frames` whole
/// grounded steps from wherever the last one left the body.
fn walk_from(mut world: App, start: Vec3, dir: Vec3, frames: usize) -> Vec<Row> {
world
.world_mut()
.run_system_once(move |ms: MoveAndSlide| {
let capsule = player_capsule();
@@ -961,23 +1128,268 @@ mod tests {
&ms, &capsule, from, disp, SKIN_WIDTH, &filter,
)
};
let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5 + start_y, 0.0);
let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance);
let mut center = start + Vec3::X * run;
let start = start + Vec3::Y * (CAPSULE_HEIGHT * 0.5);
let run = cast(start, dir).map_or(1.0, |h| h.distance);
let mut center = start + dir * run;
let dt = std::time::Duration::from_secs_f32(TRAVEL_60FPS / 7.0);
// The steep-support bit is carried frame to frame exactly as the driver carries it
// (`Player::steep_support`), so the deep step-down reach is gated here the way it is
// on screen — a fixture that reset it every frame would test a mover we do not ship.
let mut support = Support::default();
(0..frames)
.map(|_| {
let g =
grounded_step(&ms, &capsule, &filter, center, Vec3::X * 7.0, dt, 0.0);
grounded_step(&ms, &capsule, &filter, center, dir * 7.0, dt, support);
center = g.center;
// Feet height relative to the street, the climb, and the ride latch.
(center.y - CAPSULE_HEIGHT * 0.5, g.climb, g.cone_riding)
support.steep = g.steep_support;
(
center.y - CAPSULE_HEIGHT * 0.5,
g.climb,
g.steep_support,
g.ground.is_some(),
)
})
.collect::<Vec<_>>()
})
.unwrap()
}
/// **Booty Bay's step** — a 0.40 yd *vertical* riser onto a ~22° ramp, profiled from the
/// director's steered capture at WoW `(-14314.3, 466.2, 18.5)`, collider `54046v0`: the wall
/// probe reads `n=(-0.96,+0.00,-0.27)` with the blocking edge `h=+0.40` above the feet, and the
/// surface ahead runs `+0.35:+0.40/+0.93` … `+1.40:+0.82/+0.93`.
///
/// The riser is tilted by 0.002 yd across its 0.40 of rise for one reason: a *perfectly* axis-
/// aligned quad computes a normal whose `y` is ±0 on a float coin flip, and this fixture must
/// pin the ride, not that coin flip. `an_exactly_vertical_riser_still_certifies` owns the flip.
fn world_with_vertical_step() -> App {
const PROFILE: [(f32, f32); 4] = [(-2.0, 0.0), (0.30, 0.0), (0.302, 0.40), (3.0, 1.48)];
world_from_profile(&PROFILE)
}
#[test]
fn a_vertical_step_is_climbed_not_stalled() {
// The 1123 regression the capture caught. A 0.40 yd rise is inside [`FOOT_CONE_HEIGHT`], so
// it rides — and against a *vertical* riser a capsule hangs clear of everything below, so
// the election snap saw the street a full ride beneath it and pulled the body back down
// every frame. Certified `COMMIT dy=+0.439` on every one, and stalled at +0.020 forever.
let frames = walk_profile(world_with_vertical_step(), 0.0, 6);
let top = frames.last().unwrap().0;
assert!(
top > 0.40,
"the body must finish on the step, not stalled below it: {frames:?}"
);
// Monotone: a ride that is undone and re-earned reads as a judder, which is what the
// director would see even if the body eventually arrived.
for w in frames.windows(2) {
assert!(
w[1].0 >= w[0].0 - 1.0e-3,
"the climb must never go backwards: {frames:?}"
);
}
assert!(
frames[0].0 < 0.40,
"…and it is still a ride, not a one-frame pop: {frames:?}"
);
}
#[test]
fn a_tall_step_behind_an_unwalkable_face_is_climbed() {
// Stormwind, WoW `(-8427.4, 607.4, 95.1)`, collider `14282v1` — the director's second
// capture. A 0.91 yd step whose face is a 66° bevel for most of a yard before the flat top
// begins, so it defeated **both** of the old budgets at once: the top is above 0209's 0.7
// rise ceiling, and 1121's one-radius advance put the settle probe still over the bevel,
// where the walkable gate correctly refused it. The captured ladder is unanimous —
// `STEEP fwd0.31 ny+0.41` at every rung out to 1.20.
const P: [(f32, f32); 4] = [(-2.0, -0.02), (0.28, -0.02), (0.694, 0.91), (3.0, 0.91)];
let frames = walk_profile(world_from_profile(&P), -0.02, 8);
assert!(
frames.last().unwrap().0 > 0.88,
"the body must finish on the 0.91 yd top: {frames:?}"
);
for w in frames.windows(2) {
assert!(
w[1].0 >= w[0].0 - 1.0e-3,
"the climb must never go backwards: {frames:?}"
);
}
// The blocking edge is low on the bevel, inside the cone, so this rides — six frames of
// smooth diagonal rather than a yard-high teleport.
assert!(
frames[0].0 < 0.3,
"a 0.91 yd gain in one frame is the teleport 1126 exists to avoid: {frames:?}"
);
}
#[test]
fn stepping_off_a_kerb_follows_the_surface_down() {
// Decision 1127, and the exact frame the director's capture caught: walking *off* the same
// Stormwind kerb the down-probe finds only its ~61° riser, and refusing that outright left
// the body at full height while its forward speed carried it past the edge — one frame of
// `dy=+0.000 dx=0.118` with the surface 0.162 below, then eighteen airborne frames landing
// 2.1 yd downrange. The dive.
//
// **Support** is the thing to assert, because losing it *is* the dive: the caller turns an
// unsupported frame into gravity, and everything after that is an arc no snap can undo.
let frames = walk_from(world_with_kerb(), Vec3::new(1.6, 0.28, 0.0), Vec3::NEG_X, 8);
for (i, f) in frames.iter().enumerate() {
assert!(
f.2 || f.3,
"frame {i} left the surface entirely — that is the dive: {frames:?}"
);
}
assert!(
frames.last().unwrap().0 < 0.05,
"the walk-off must reach the street: {frames:?}"
);
}
#[test]
fn a_face_steeper_than_the_cone_still_descends_what_it_can() {
// The "only sometimes" case. A face steeper than the cone cannot be followed exactly, and the
// question is what the mover does with the part it *can* follow. The reference descends
// `min(clearance, cap)` unconditionally and only then decides it is falling; the first cut of
// 1127 descended the clearance **or nothing**, so a surface a hair steeper than the cone
// dived — eight takeoffs in one capture, each missing the bound by 0.001–0.019.
//
// 66° face (`ny ≈ 0.41`, the captured value), walked off at a run: the first frame past the
// lip must give up most of a cone's worth of height, not hold its own.
const P: [(f32, f32); 4] = [(-2.0, 0.0), (0.60, 0.0), (1.0, -0.91), (3.0, -0.91)];
// (`walk_from`'s approach cast falls back to a yard when nothing blocks — there is no wall
// to stop against here — so the start is placed a yard short of the lip on purpose.)
let frames = walk_from(
world_from_profile(&P),
Vec3::new(-0.6, 0.0, 0.0),
Vec3::X,
5,
);
let cone = TRAVEL_60FPS * STEP_SLOPE_RATIO;
let biggest = frames
.windows(2)
.map(|w| w[0].0 - w[1].0)
.fold(frames[0].0.abs(), f32::max);
assert!(
biggest > cone * 0.5,
"some frame past the lip must follow the face down rather than hold height: \
{frames:?} (best drop {biggest:.3}, a cone's worth is {cone:.3})"
);
}
#[test]
fn a_ledge_deeper_than_the_probe_is_a_fall_not_an_absorbed_step() {
// **The pin on decision 1129's reach.** A 1.2 yd sheer drop — deeper than the step-down probe
// can see, shallower than the 2.028 yd of flat extra depth the probe used to carry. Under the
// old reach the snap found the lower floor from the top of the ledge and absorbed the whole
// drop in ONE frame, still walkably grounded the entire way: a downward teleport, and the
// step-up teleport's exact mirror.
//
// What must happen instead is what the reference does: the body leaves the ground before it
// reaches the bottom, and the gap is a fall. So — **an airborne frame must come first.**
// (This test fails on the pre-1129 reach, which is the only reason it is worth having.)
const P: [(f32, f32); 4] = [(-2.0, 0.0), (0.60, 0.0), (0.62, -1.20), (3.0, -1.20)];
let frames = walk_from(
world_from_profile(&P),
Vec3::new(-1.0, 0.0, 0.0),
Vec3::X,
12,
);
// The measurable form: **no single frame may give up an unbounded amount of height.** A
// frame's descent is the slide's own drop plus the step-down snap's, each bounded by what
// the foot cone could rest on over one frame's travel — so two cone-reaches is a generous
// ceiling. The corrected mover clears it easily (its worst frame here gives up 0.257); the
// old reach blows straight through, holding height to the lip and then taking the remaining
// **1.126 yd in a single frame**, walkably grounded at both ends of it.
let bound = 2.0 * (TRAVEL_60FPS * STEP_SLOPE_RATIO + STEP_SNAP_SLACK);
let worst = frames
.windows(2)
.map(|w| w[0].0 - w[1].0)
.fold(0.0_f32, f32::max);
assert!(
worst <= bound,
"no frame may drop more than {bound:.3} yd; worst was {worst:.3}\n{frames:#?}"
);
}
#[test]
fn an_idle_body_is_not_pulled_down_through_open_air() {
// The other half of the same reach. A motionless body a yard above flat ground: standing
// still the probe is the slack and nothing more, so it must find nothing, keep its height,
// and be handed to gravity. The old reach was `slack + 2.028` even at a dead stop — it found
// the floor a yard down and yanked the body onto it, one frame, no fall.
const P: [(f32, f32); 2] = [(-2.0, 0.0), (3.0, 0.0)];
let (dy, ground) = world_from_profile(&P)
.world_mut()
.run_system_once(move |ms: MoveAndSlide| {
let capsule = player_capsule();
let filter = SpatialQueryFilter::default();
let dt = std::time::Duration::from_secs_f32(TRAVEL_60FPS / 7.0);
let center = Vec3::new(0.0, CAPSULE_HEIGHT * 0.5 + 1.0, 0.0);
let g = grounded_step(
&ms,
&capsule,
&filter,
center,
Vec3::ZERO,
dt,
Support::default(),
);
(g.center.y - center.y, g.ground.is_some())
})
.unwrap();
assert!(!ground, "a body a yard up is standing on nothing");
assert!(
dy.abs() <= STEP_SNAP_SLACK * 2.0,
"it must keep its height and fall, not be snapped a yard down: dy={dy:+.3}"
);
}
#[test]
fn a_motionless_body_is_never_perched_on_a_steep_slope() {
// A 60° bank. Walk onto it, then STOP. Standing still the cone bound collapses to the slack,
// so does the steep support quietly keep a motionless body perched on it?
const P: [(f32, f32); 3] = [(-2.0, 0.0), (0.0, 0.0), (3.0, -5.196)];
let out = world_from_profile(&P)
.world_mut()
.run_system_once(move |ms: MoveAndSlide| {
let capsule = player_capsule();
let filter = SpatialQueryFilter::default();
let dt = std::time::Duration::from_secs_f32(TRAVEL_60FPS / 7.0);
let mut center = Vec3::new(0.6, CAPSULE_HEIGHT * 0.5 - 1.039, 0.0);
let mut rows = Vec::new();
let mut support = Support::default();
for i in 0..4 {
// Two frames walking, then two standing perfectly still.
let v = if i < 2 { Vec3::X * 7.0 } else { Vec3::ZERO };
let g = grounded_step(&ms, &capsule, &filter, center, v, dt, support);
center = g.center;
support.steep = g.steep_support;
rows.push((v.length() > 0.0, g.steep_support));
}
rows
})
.unwrap();
for (moving, supported) in out {
assert!(
moving || !supported,
"a motionless body must never hold a steep face — it slides off"
);
}
}
#[test]
fn an_exactly_vertical_riser_still_certifies() {
// The other half of the same capture. An axis-aligned riser's normal has `y = ±0` depending
// on nothing but the cross product's rounding, and an overhang guard written as `n.y < 0.0`
// refuses the negative half — reading a plain vertical step as a ceiling. Half the world's
// risers, decided by a float sign.
const PROFILE: [(f32, f32); 4] = [(-2.0, 0.0), (0.30, 0.0), (0.30, 0.40), (3.0, 1.48)];
let frames = walk_profile(world_from_profile(&PROFILE), 0.0, 6);
assert!(
frames.last().unwrap().0 > 0.40,
"an exactly-vertical riser must climb exactly like a tilted one: {frames:?}"
);
}
#[test]
fn the_kerb_is_ridden_up_its_skirt_never_popped() {
// Decision 1123, the whole point: a 0.28 yd kerb is inside the foot cone
@@ -988,7 +1400,7 @@ mod tests {
let frames = walk_kerb(0.0, 4);
let arrived = frames
.iter()
.position(|&(y, _, _)| (y - 0.28).abs() < 0.03)
.position(|&(y, _, _, _)| (y - 0.28).abs() < 0.03)
.expect("the ride should put the feet on the 0.28 yd tread");
assert!(
arrived > 0,
@@ -1011,7 +1423,7 @@ mod tests {
// the cone band too). Read the same geometry from a full kerb below — a 2.3 yd wall — and
// nothing may rise: no ride, no climb, no lift, just the slide. This is the check that
// stops the ride becoming a ladder up every cliff in the world.
for (y, climb, riding) in walk_kerb(-2.0, 4) {
for (y, climb, riding, _) in walk_kerb(-2.0, 4) {
assert!(!riding, "a wall must never start a cone ride");
assert!(climb.is_none(), "a wall must never register a climb");
assert!(
+72 -28
View File
@@ -45,7 +45,15 @@ pub(super) const STATIONARY_CHASE_RATE: f32 = 8.0;
// fidelity targets. The mechanism is a thin kinematic controller over avian's `MoveAndSlide`; further
// refinements (accel/decel curves, partial air control beyond the one-shot nudge) dial up from here.
/// Player capsule radius (yd) — the vanilla box's ±1/3 half-width.
/// Player capsule radius (yd) — the vanilla `CMovement` ctor's ±1/3 half-width, and a *tunable* per
/// the block header above, not a fidelity target.
///
/// Known since to be the ctor **placeholder**, not the real mover's radius: `0x6174b0` overwrites
/// `+0xb0`/`+0xb4` from `CreatureModelData` on every model build (`0x5fb880` → `0x5fb9dd` passes
/// `force = 1`, which skips the only refusal path), so the live extents are per-model — human male
/// radius **0.30555**, human female **0.20835** (VERIFIED, wow-re `mover-collision-scalars.md`;
/// decision 1125). Adopting them is the same movement-fidelity question [`CAPSULE_HEIGHT`] describes
/// below, and this radius is its other half.
pub(super) const CAPSULE_RADIUS: f32 = 1.0 / 3.0;
/// Player capsule total height (yd) — the **movement** capsule avian sweeps, deliberately a
/// constant. Numerically the vanilla ctor-default collision height it was derived from
@@ -107,17 +115,34 @@ pub(super) const GROUND_PROBE: f32 = 0.2;
/// probe reaches `d_h · ratio + slack + collision height` below the post-move position, where
/// `d_h` is the frame's achieved horizontal travel. Scaling by the travel makes the absorbed
/// *slope* the constant (atan 1.8494 ≈ 61.6°, comfortably above the 50° walkable limit),
/// frame-rate independent; the collision-height term (our [`CAPSULE_HEIGHT`], the election's
/// `0x4000000`-gated `+0x617430()` extension — decision 0182) is what absorbs a discrete ledge:
/// a fence-height drop is a silent straight-down step, only a deeper floor becomes a fall.
/// frame-rate independent; the collision-height term (our [`CAPSULE_HEIGHT`] — decision 0182) is
/// what absorbs a discrete ledge: a fence-height drop is a silent straight-down step, only a deeper
/// floor becomes a fall. That term is a **known deviation** on two counts — the reference's `H` is
/// 1.0, not 2.028, and its extension is `0x4000000`-gated rather than always-on (decision 1125).
///
/// The ratio itself is confirmed to be one constant with two jobs, exactly as we use it: `0x80c740`
/// has four references image-wide, and they are the foot cone's waist ring (`0x631c0b`) and this
/// step-down term (`0x636dda`, inside `0x6367b0`) among them — the same 61.6° serving the climb and
/// the descent (wow-re `mover-collision-scalars.md`).
pub(super) const STEP_SLOPE_RATIO: f32 = 1.849_399;
/// The election's fixed slack (yd) added to the travel-scaled snap reach — `[0x7ff9d0]` = 1/36 yd.
pub(super) const STEP_SNAP_SLACK: f32 = 0.027_777_8;
/// The step-up rise ceiling (yd): how tall an obstacle the atomic step-up can walk you onto
/// (decision 0209). A plain tunable, deliberately modest — stairs, doorsteps, low rocks — and
/// deliberately NOT the reference's ~2 yd body-height budget, so fences (collision tops
/// 1.8–2.3 yd) always slide. One number to nudge if a real spot feels too restrictive.
pub(super) const STEP_UP_HEIGHT: f32 = 0.7;
/// The step-up **rise ceiling** (yd): how tall an obstacle the atomic step-up can walk you onto —
/// the reference's own `H`, VERIFIED (decision 1126).
///
/// 0209 set this to 0.7 as a deliberately modest tunable, on the understanding that the reference's
/// budget was a ~2 yd body height we did not want. That understanding was wrong on its central fact:
/// `0x617430` returns `CMovement+0xb8`, which is **not a height** but the dimensionless ratio
/// `max(SCALE_X / CreatureModelScale, 1)`, and a complete writer census puts it at **1.0** for a
/// clean local player (decision 1125). The reference's rise budget was never 2 — it was 1.0, and the
/// gap we were preserving was 0.7 vs 1.0, not 0.7 vs 2.
///
/// 0209's invariant is untouched by closing it: a fence's collision top is 1.8–2.3 yd, so fences,
/// trunks and walls still slide at exactly the height they always did. What changes is the class
/// 0209's note invited us to fix — "one number to nudge if a real spot feels too restrictive" —
/// which the director's captures found twice: a 0.91 yd Stormwind step (`14282v1`, a 66° face onto a
/// flat top) and 1121's deferred 1.04 yd ledge.
pub(super) const STEP_UP_HEIGHT: f32 = 1.0;
/// The step-up **certify advance** (yd): how far ahead the maneuver looks for the tread it would
/// stand on. A property of the BODY, never of the frame (decision 1121).
///
@@ -129,16 +154,24 @@ pub(super) const STEP_UP_HEIGHT: f32 = 0.7;
/// commit onto the tread, at every one of the seven captured contacts. Sidewalks 0.28 yd tall
/// refused to be stepped onto because the probe never reached them.
///
/// A body radius is the principled value: it is exactly the distance at which the capsule's
/// footprint has cleared the lip it is standing against, and it is the same number whatever the
/// frame rate or the gait. The frame's travel still wins when it is *longer* (a very low frame
/// rate must not step you less far than you asked to walk), so this is a floor, not a fixed reach.
/// A body radius was the principled *guess* — exactly the distance at which the capsule's footprint
/// has cleared the lip it is standing against, and the same number whatever the frame rate or the
/// gait. It is now superseded by the measured one (decision 1126): the reference steps
/// `max(H·tan50°, radius + 1/720)` square into the certified face, which with the verified `H` of 1.0
/// is **1.1918 yd** — three and a half times our radius. 1121 reached for a body-scaled number in the
/// absence of this one and got the *shape* of the answer right; the magnitude was never ours to
/// invent. The frame's travel still wins when it is longer, so this remains a floor, not a fixed
/// reach.
///
/// It does not widen what may be climbed: the rise ceiling is still [`STEP_UP_HEIGHT`], the settle
/// must still find a *walkable* floor higher than the feet, and the elevated forward sweep is still
/// clipped by anything in the way — so a fence, a trunk and a two-trunk pinch all still block at
/// the same body height they did before, with a clipped advance and a net-zero settle.
pub(super) const STEP_UP_ADVANCE: f32 = CAPSULE_RADIUS;
/// It does not widen what may be climbed, and that argument is what makes the larger reach safe: the
/// rise ceiling is still [`STEP_UP_HEIGHT`], the settle must still find a *walkable* floor higher
/// than the feet, and — the load-bearing part — **the elevated forward sweep is clipped by anything
/// in the way**, so the advance only ever reaches as far as there is clear air at the raised height.
/// A fence, a trunk and a two-trunk pinch still block at the same body height, with a clipped advance
/// and a net-zero settle. What the longer reach buys is the case the director's capture found: a step
/// whose *face* is unwalkable for most of a yard before the flat top begins, where a probe that stops
/// at one radius is still over the face and reads it as a steep floor.
pub(super) const STEP_UP_ADVANCE: f32 = 1.191_753_6;
/// The **foot cone's height** (yd): how far above the feet the reference's movement solid is still
/// narrower than its full radius — and therefore the band within which a blocking edge is *ridden
/// up* rather than stepped onto (decision 1123).
@@ -155,9 +188,15 @@ pub(super) const STEP_UP_ADVANCE: f32 = CAPSULE_RADIUS;
/// the vertical box square, and that is the case the instant step-up ([`STEP_UP_HEIGHT`]) exists
/// for. One solid, two behaviours, selected by the edge's height above the feet.
///
/// Not a new tunable: it is our own two constants multiplied, and it reproduces the reference's
/// ≈0.62 yd exactly, because [`CAPSULE_RADIUS`] is already the reference's own mover radius of 1/3
/// (`climb-vs-slide.md` §2 names it).
/// Not a new tunable: it is our own two constants multiplied, so it is *our* mover's cone — the band
/// is a property of the body's radius, and ours is [`CAPSULE_RADIUS`].
///
/// It is **not** the real client's band, and the gap is recorded rather than papered over (decision
/// 1125, correcting 1123's claim that it matched exactly). Because [`CAPSULE_RADIUS`] is the ctor
/// placeholder, ours comes out at 0.616 against a live human male's **0.565** and a human female's
/// **0.385** — ~9% and ~60% generous. It only decides ride-vs-pop for obstacles *between* the two
/// bands, so the cost is a narrow slice of over-smooth rides on tall-ish steps; closing it properly
/// means per-model mover extents, which moves far more than this constant.
pub(super) const FOOT_CONE_HEIGHT: f32 = CAPSULE_RADIUS * STEP_SLOPE_RATIO;
/// The landing probe (yd): while airborne, walk mode resumes only this close to the floor, so
/// the arc ends where the slide actually contacts (skin scale) instead of [`GROUND_PROBE`]
@@ -508,13 +547,18 @@ pub(crate) struct Player {
pub(super) wedged: bool,
/// Consecutive stalled airborne frames (see [`WEDGE_STALL_RATIO`]).
pub(super) wedge_still: u8,
/// Part-way up a **foot-cone ride** — the body is resting on the slanted skirt of a low edge it
/// has already certified it can clear, climbing it over the frames the gait needs (decision
/// 1123). Counts as standing: the frame-start ground probe sees only the steep riser under the
/// feet, so without this the mid-ride frame reads as airborne and gravity undoes the climb —
/// the mid-face dwelling 0209 was built to make impossible. Re-earned from the certification
/// every frame (no certification, no ride), and cleared the moment a walkable floor takes over.
pub(super) cone_riding: bool,
/// **Supported by a certified steep contact, not a walkable floor** — the reference's
/// `0x4000000`, whose meaning decision 1125 settled from the bytes. Two ways to earn it, one
/// meaning: climbing the slanted skirt of a low edge already certified clearable (the foot-cone
/// ride, 1123), or resting on the surface being followed down off a ledge (1127).
///
/// Counts as standing. The frame-start ground probe looks straight down and sees only the steep
/// face, so without this the frame reads as airborne and gravity takes the body off the surface:
/// the climb undone into the mid-face dwelling 0209 exists to prevent, and on the way down the
/// dive past the edge the director reported. Re-earned every frame from the certification going
/// up and from the cone's descent bound coming down — neither can hold a body that has not just
/// proved it is on a surface — and cleared the moment a walkable floor takes over.
pub(super) steep_support: bool,
/// The take-off vertical speed (yd/s, WoW +Z up) snapshotted when the airborne phase began — the
/// client's `StartFalling` argument (`+0xa0`, constant per arc) and the `zspeed` we send in the
/// jump tail: `JUMP_SPEED` for a jump, **exactly 0** for a step-off (the walk election calls
+28 -18
View File
@@ -42,13 +42,15 @@ use crate::ui_unit::UnitFeed;
// leg). INTERIM admission pending a wow-re detail pass: a known spell joins the craft list when its
// SLA line matches AND (`castUI != 0` OR the tradeskill bit) and it is not an opener.
/// The open Craft window: the skill line whose recipes it lists (`None` = closed). Routed here
/// by the opener's `EffectMiscValue[0] != 0` (byte-VERIFIED — wow-re `tradeskill` TU-A); a Beast
/// Training opener also routes here and simply lists nothing (its pet-ability content is its own
/// arc, 0437 out-of-scope). Client-local state, no wire.
/// The open Craft window: the skill line whose recipes it lists (`None` = closed) and the **craft
/// type** that opened it. Routed here by the opener's `EffectMiscValue[0] != 0` (byte-VERIFIED —
/// wow-re `tradeskill` TU-A); that same misc value *is* the craft type (1 Beast Training ·
/// 3 Enchanting), which the client keeps at `ds:0xbdcfb8` and reads for both the window's admission
/// filter and its row comparator (decision 1124). Client-local state, no wire.
#[derive(Resource, Default)]
pub(crate) struct CraftOpen {
pub(crate) line: Option<u32>,
pub(crate) craft_type: u32,
}
pub(crate) struct UiCraftPlugin;
@@ -148,6 +150,7 @@ fn feed_craft(
};
let fresh = (|| -> Option<CraftState> {
let line = open.line?;
let craft_type = open.craft_type;
let spells = spells.as_deref()?;
let skill_lines = skill_lines.as_deref()?;
let store = self_store.single().ok()?;
@@ -164,14 +167,24 @@ fn feed_craft(
lookup: &|id| spells.catalog.get(id),
home_area: None,
};
let mut recipes: Vec<CraftRecipe> = actions
// The **admission law** — `0x5e9c20`, byte-verified (decision 1124): the player knows the
// spell, it is not hidden (`Attributes & 0x20`), and its `castUI` **equals this window's
// craft type**. It is not a skill-line join: the client walks its own per-type list at
// `CGPlayer_C + 0x1cd0 + 0x10*type` and never consults `SkillLineAbility` for membership. On
// the shipped `Spell.dbc` the two agree on every spell a player can know (castUI 1 → 110 of
// the 111 line-261 rows, the exception being the opener 5149 itself, which castUI 0 excludes
// where the old `effects[0] != TRADE_SKILL` clause used to; castUI 3 → every line-333
// recipe), so this is a law correction, not a content change. What it also does is keep the
// `spellLevel = 0` **trainer-wrapper tier** of every pet ability (24519-24522 and friends,
// castUI 1 but never known) out of the list — the tier whose presence would flatten the
// sort's rank key even with the right comparator.
let recipes: Vec<CraftRecipe> = actions
.spells
.iter()
.filter(|&&s| {
spells.catalog.get(s).is_some_and(|d| {
(d.cast_ui != 0 || d.attributes & SPELL_ATTR_IS_TRADESKILL != 0)
&& d.effects[0] != benilla_formats::SPELL_EFFECT_TRADE_SKILL
}) && skill_lines.catalog.spell_to_line(s) == Some(line)
d.cast_ui == craft_type && d.attributes & SPELL_ATTR_IS_TRADESKILL == 0
})
})
.filter_map(|&s| {
let d = spells.catalog.get(s)?;
@@ -241,23 +254,20 @@ fn feed_craft(
reagents,
tools,
tooltip: craft_tooltip(s, d),
spell_level: d.spell_level,
})
})
.collect();
recipes.sort_by(|a, b| {
let req = |r: &CraftRecipe| {
skill_lines
.catalog
.ability(r.spell_id)
.map(|x| x.req_skill_value)
.unwrap_or(0)
};
req(b).cmp(&req(a)).then_with(|| a.name.cmp(&b.name))
});
// No app-side sort: the ROW ORDER is the engine's, transcribing this craft type's own
// comparator (`benilla_ui::script::craft::recipe_order`, decision 1124) — the same seam the
// trainer tree and the TradeSkill list already sit behind. What used to be here was a
// `req_skill_value`-descending sort keyed on a DBC column the client never reads, over rows
// whose iteration order was a `HashSet`'s.
Some(CraftState {
name,
rank,
max_rank,
craft_type,
recipes,
})
})();
@@ -118,8 +118,8 @@ fn service(
skill_req,
ability_reqs,
is_trade_skill: false,
skill_line,
skill_line_name: line_name.into(),
group_key: skill_line,
group_name: line_name.into(),
}
}
@@ -130,7 +130,7 @@ fn service(
fn menu() -> TrainerState {
TrainerState {
greeting: "Well met. Let me show you the way of the warrior.".into(),
is_tradeskill: false,
trainer_type: 0,
groups: Vec::new(),
services: vec![
service(
@@ -443,7 +443,7 @@ fn filter_rows_toggle_through_the_dropdown_kit() {
fn long_menu() -> TrainerState {
TrainerState {
greeting: "Much to learn.".into(),
is_tradeskill: false,
trainer_type: 0,
groups: Vec::new(),
services: (1..=15)
.map(|i| {
+11 -5
View File
@@ -142,14 +142,20 @@ fn open_trade_skill(
// The routing key — byte-VERIFIED (wow-re `tradeskill` TU-A, `0x6e4bd7`): the opener's
// `EffectMiscValue[0] != 0` routes to the CraftFrame (Enchanting 3, Beast Training 1);
// zero routes to the TradeSkillFrame. NOT a skill-line test — 0437's line-333 INTERIM
// is corrected by this fold-back.
let to_craft = spells
// is corrected by this fold-back. The nonzero value is not merely a flag: it IS the craft
// type the client keeps at `ds:0xbdcfb8`, and the Craft window keys both its admission
// filter and its row comparator on it (decision 1124), so it rides along.
let craft_type = spells
.as_deref()
.and_then(|s| s.catalog.get(spell_id))
.is_some_and(|d| d.effect_misc_value[0] != 0);
if to_craft {
debug!("ui_tradeskill: opener {spell_id} opens the CraftFrame (line {line})");
.and_then(|d| u32::try_from(d.effect_misc_value[0]).ok())
.unwrap_or(0);
if craft_type != 0 {
debug!(
"ui_tradeskill: opener {spell_id} opens the CraftFrame (line {line}, craft type {craft_type})"
);
craft_open.line = Some(line);
craft_open.craft_type = craft_type;
} else {
debug!("ui_tradeskill: opener {spell_id} opens skill line {line}");
if open.line != Some(line) {
+74 -15
View File
@@ -1,30 +1,33 @@
//! The trainer window's two **byte-transcribed laws** — the icon and the tooltip.
//! The trainer window's **byte-transcribed laws** — the icon, the tooltip, and the group key.
//!
//! They live together because they are the same *kind* of thing (a direct transcription of one Lua
//! binding each, with the binary's addresses in the doc), and apart from the feed because they are
//! the part that must never drift toward "what seems consistent". The whole point of both records
//! is that they **disagree with each other** on the same row: the icon needs a trainer-type gate the
//! tooltip does not have, and the icon pins the *wire* wrapper where the tooltip hops to the
//! *taught* spell. On ~806 of the shipped corpus's trainer services the reference client visibly
//! shows one spell's icon above another spell's tooltip. Putting them side by side makes that
//! deliberate, and makes a future "unification" obviously wrong rather than obviously tidy.
//! They live together because they are the same *kind* of thing (a direct transcription of one
//! binding or builder branch each, with the binary's addresses in the doc), and apart from the feed
//! because they are the part that must never drift toward "what seems consistent". The whole point
//! of putting them side by side is that they **disagree with each other on the same row**, on
//! purpose: the icon needs a trainer-type gate the tooltip does not have; the icon pins the *wire*
//! wrapper where the tooltip hops to the *taught* spell; the group key hops at three trainer types
//! and resolves nothing at the fourth. On ~806 of the shipped corpus's trainer services the
//! reference client visibly shows one spell's icon above another spell's tooltip. Keeping them
//! adjacent makes that deliberate, and makes a future "unification" obviously wrong rather than
//! obviously tidy — decision 1124 is the second time a plausible unification (the display name,
//! assumed to hop like the group key) turned out to be refuted at the bytes.
//!
//! Sources: wow-re `system/ui/scratch/spell-icon-substitution-law.md` §1 (the icon) and
//! `system/ui/scratch/trainer-service-tooltip-law.md` (the tooltip).
//! Sources: wow-re `system/ui/scratch/spell-icon-substitution-law.md` §1 (the icon),
//! `trainer-service-tooltip-law.md` (the tooltip) and `trainer-craft-list-order.md` (the group key).
use benilla_formats::{
SpellCatalog, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_LEARN_PET_SPELL,
SPELL_EFFECT_LEARN_SPELL,
SkillLineCatalog, SpellCatalog, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM,
SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKILL_STEP,
};
use benilla_protocol::messages::trainer_spell_state;
use benilla_ui::script::{TrainerServiceCategory, TrainerTooltip};
use benilla_ui::script::{TrainerServiceCategory, TrainerTooltip, TRAINER_GROUP_KNOWN};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::NetCommands;
use crate::ui_items::item_icon;
use super::TRAINER_TYPE_TRADESKILL;
use super::{TRAINER_TYPE_MOUNT, TRAINER_TYPE_TRADESKILL};
/// The trainer's **icon law** — `GetTrainerServiceIcon 0x4d8f50`, byte-verified whole in wow-re
/// (`system/ui/scratch/spell-icon-substitution-law.md` §1; the binding is not a marshal, the entire
@@ -149,6 +152,62 @@ pub(super) fn service_tooltip(wire_spell: u32, spells: &SpellCatalog) -> Trainer
wire_only
}
/// The **group-key law** — the builder `0x4d7560`'s per-trainer-type branch at `0x4d7786`
/// (byte-verified, decision 1124; wow-re `system/ui/scratch/trainer-craft-list-order.md`). It is the
/// third law on this page that forks on the same dword the other two do, and the one that decides
/// what the director actually sees at the top of a profession trainer's list.
///
/// **Type 2 (tradeskill) does not resolve a skill line at all.** The key defaults to `2` and becomes
/// `1` iff the **WIRE** spell's own `Spell.dbc Effect[0..2]` contains `44 SKILL_STEP` (`0x4d77b6`) —
/// no `EffectTriggerSpell` deref, no `SkillLineAbility` lookup. Two consequences that a skill-line
/// implementation gets wrong even when its ordering is right: the partition is **total**, so no row
/// is ever dropped at a tradeskill trainer; and the header vocabulary is the client's own label table
/// (`0x807520 + key * 0x40`), whose two entries are the global strings `TRADESKILL_SERVICE_STEP`
/// ("Development Skills") and `TRADESKILL_SERVICE_LEARN` ("Recipes") — 1.12.1 enUS `GlobalStrings.lua`,
/// the same inlining [`super::train_error_text`] does for `ERR_NOT_ENOUGH_MONEY`.
///
/// **Type 1** (mount — the client's "talent") is a *hybrid*, not a third predicate: an
/// already-known service (`state == 2`) goes to the signed **`-1`** group (`0x4d77e8`) under the
/// `KNOWN_TALENTS_HEADER` global string ("My Talents"); everything else takes the same skill-line
/// path as types 0/3.
///
/// **Types 0/3 (and type 1's remainder)** key on the **TAUGHT** spell's `SkillLine`
/// (`0x4d7c60`→`0x60c920`, decision 0247 — the one hop in this file that survived 1124's audit); an
/// unresolved `0` drops the service (`0x4d7807`). Pet (3) is not special-cased anywhere.
pub(super) fn service_group(
wire_spell: u32,
taught: u32,
trainer_type: u32,
category: TrainerServiceCategory,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
) -> (u32, String) {
if trainer_type == TRAINER_TYPE_TRADESKILL {
let step = spells
.get(wire_spell)
.is_some_and(|d| d.effects.contains(&SPELL_EFFECT_SKILL_STEP));
return if step {
(1, "Development Skills".to_string())
} else {
(2, "Recipes".to_string())
};
}
if trainer_type == TRAINER_TYPE_MOUNT && category == TrainerServiceCategory::Used {
return (TRAINER_GROUP_KNOWN, "My Talents".to_string());
}
let line = skill_lines
.and_then(|c| c.spell_to_line(taught))
.unwrap_or(0);
if line == 0 {
return (0, String::new());
}
let name = skill_lines
.and_then(|c| c.line(line))
.map(|l| l.name.clone())
.unwrap_or_else(|| format!("Skill {line}"));
(line, name)
}
/// The green/red/gray colour a wire `state` byte maps to (decision 0237): GRAY → known, GREEN →
/// learnable, everything else (RED + any unexpected value) → gated.
pub(super) fn category(state: u8) -> TrainerServiceCategory {
+39 -35
View File
@@ -43,10 +43,15 @@ use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession
use crate::ui_spellbook::SkillLines;
/// `SMSG_TRAINER_LIST`'s `trainer_type` for a tradeskill trainer (0 class · 1 mount · 2 tradeskill ·
/// 3 pet). Used only to classify a service as tradeskill-vs-learn-spell for the Era
/// `IsTrainerServiceTradeSkill` surface — a whole-trainer approximation (per-service typing from the
/// spell's effects is a later refinement).
/// 3 pet). Three separate laws fork on it here — the **icon** ([`service_icon`]), the **group key**
/// ([`service_group`]), and the Era `IsTrainerServiceTradeSkill` flag, which remains a whole-trainer
/// approximation (per-service typing from the spell's effects is a later refinement; the reference
/// window's Lua never calls it).
const TRAINER_TYPE_TRADESKILL: u32 = 2;
/// `SMSG_TRAINER_LIST`'s `trainer_type` for a mount trainer — the type the client's own vocabulary
/// calls "talent" (`IsTalentTrainer 0x4d8ed0`), and the one whose grouping folds already-known
/// services into a "My Talents" bucket ([`service_group`], decision 1124).
const TRAINER_TYPE_MOUNT: u32 = 1;
/// The open trainer, filled by the net bridge ([`crate::net`]) and read by [`feed_trainer`]. Holds
/// the trainer guid and its services exactly as the wire delivered them (`SMSG_TRAINER_LIST`), plus
@@ -150,12 +155,20 @@ fn resolve_service(
items: &mut Items,
commands: &NetCommands,
) -> TrainerService {
// The trainer offers a LEARN wrapper (decision 0247); the ability it teaches — with the skill
// line the tree groups by, and the real display name/icon — is the taught spell. Resolve it once
// for both. `wire.spell` (the wrapper) stays the buy id below (CMSG_TRAINER_BUY_SPELL names it).
// A spell that teaches nothing (a plain ability, or before Spell.dbc loads) resolves to itself.
// The trainer offers a LEARN wrapper (decision 0247); the ability it teaches is the taught
// spell, and the tree GROUPS by that hop (`0x4d7c60` → `[skillrec+4]`). It is the only thing
// that hops: the row's **displayed name and subtext are the WIRE spell's own** `Spell.dbc`
// columns — `GetTrainerServiceInfo` reads `row[+0]` for both returns (`0x4d8aa0` → `[+0x1e0]`,
// `0x4d8b50` → `[+0x204]`), with no `EffectTriggerSpell` deref anywhere in either body. 0247
// recorded the display as hopping too; decision 1124 refutes that at the bytes, and it is not a
// cosmetic difference: 1607 of the 4711 shipped learn wrappers (34.1 %) disagree with what they
// teach, and that set is *every profession-learn row* — spell 2020 is "Apprentice Blacksmith"
// with no subtext where the taught 2018 is "Blacksmithing"/"Apprentice". Pet rows agree on both
// columns, which is how the wrong hop survived this long.
// `wire.spell` (the wrapper) stays the buy id below (CMSG_TRAINER_BUY_SPELL names it); a spell
// that teaches nothing (a plain ability, or before Spell.dbc loads) resolves to itself.
let taught = spells.learned_spell(wire.spell).unwrap_or(wire.spell);
let display = spells.get(taught);
let display = spells.get(wire.spell);
let cat = category(wire.state);
// The SKILL gate has no per-gate "met" bit on the 5875 wire, so approximate it from the service's
// overall category (an unavailable service has some unmet gate). The real client computes the
@@ -200,36 +213,27 @@ fn resolve_service(
}
})
.collect();
// The tree's grouping key (decision 0247): the TAUGHT spell's skill line (`SkillLineAbility.dbc`
// → `SkillLine.dbc` name) — the wire wrapper id itself is never in `SkillLineAbility`, so grouping
// MUST go through the hop above. `0` when unresolved — the engine drops such a service from the
// tree, matching the client. A resolved id with a missing name falls back to `Skill <id>`. We log
// a genuine miss (catalog present but no line) so DBC gaps surface rather than silently swallowing
// a service the server offered.
let skill_line = skill_lines
.and_then(|c| c.spell_to_line(taught))
.unwrap_or(0);
if skill_line == 0 && skill_lines.is_some() {
// The tree's grouping key — its own byte-verified law, and per trainer type ([`service_group`],
// decision 1124). Only the skill-line arm (types 0/1/3) can fail: the wire wrapper id itself is
// never in `SkillLineAbility`, so that arm MUST go through the taught-spell hop above, and an
// unresolved `0` drops the service from the tree exactly as the client's builder does. Log the
// genuine miss (catalog present but no line) so DBC gaps surface rather than silently swallowing
// a service the server offered. The tradeskill arm resolves no line at all and cannot miss.
let (group_key, group_name) =
service_group(wire.spell, taught, trainer_type, cat, spells, skill_lines);
if group_key == 0 && skill_lines.is_some() {
debug!(
"ui_trainer: trainer spell {} (teaches {taught}) has no skill line — dropped from the tree",
wire.spell
);
}
let skill_line_name = if skill_line != 0 {
skill_lines
.and_then(|c| c.line(skill_line))
.map(|l| l.name.clone())
.unwrap_or_else(|| format!("Skill {skill_line}"))
} else {
String::new()
};
TrainerService {
spell_id: wire.spell,
name: display.map(|d| d.name.clone()),
subtext: display.and_then(|d| d.rank.clone()),
// The NAME/subtext hop through the taught spell stays (decisions 0247/0252 — the wrapper is
// not in SkillLineAbility, so grouping and display MUST hop). The ICON does not: it is its
// own byte-verified law over the WIRE spell ([`service_icon`]).
// The icon is its own byte-verified law over the WIRE spell ([`service_icon`]) — as, since
// 1124, are the name and subtext above. Only the GROUP key still hops to the taught spell,
// and only because the wrapper is not in `SkillLineAbility` at all.
texture: service_icon(wire.spell, trainer_type, spells, icons, items, commands),
// The detail pane's description body, left empty by design. The real
// `GetTrainerServiceDescription` returns the spell's *tooltip* — `Spell.dbc`'s Description
@@ -245,8 +249,8 @@ fn resolve_service(
skill_req,
ability_reqs,
is_trade_skill: trainer_type == TRAINER_TYPE_TRADESKILL,
skill_line,
skill_line_name,
group_key,
group_name,
tooltip: service_tooltip(wire.spell, spells),
}
}
@@ -266,7 +270,7 @@ fn snapshot(
open.trainer?;
Some(TrainerState {
greeting: open.greeting.clone(),
is_tradeskill: open.trainer_type == TRAINER_TYPE_TRADESKILL,
trainer_type: open.trainer_type,
services: open
.services
.iter()
@@ -283,8 +287,8 @@ fn snapshot(
)
})
.collect(),
// The engine synthesizes the skill-line tree in `set_trainer` — the app pushes only the flat
// services (each carrying its resolved skill line above).
// The engine synthesizes the tree in `set_trainer` — the app pushes only the flat services
// (each carrying its resolved group key above).
groups: Vec::new(),
})
}
@@ -422,7 +426,7 @@ fn drain_trainer(
}
mod law;
use law::{category, service_icon, service_tooltip};
use law::{category, service_group, service_icon, service_tooltip};
#[cfg(test)]
mod tests;
+57 -6
View File
@@ -318,17 +318,66 @@ fn resolve_hops_the_learn_wrapper_to_the_taught_ability() {
);
assert_eq!(svc.spell_id, 1605, "the buy id stays the wire wrapper");
assert_eq!(
svc.skill_line, 26,
svc.group_key, 26,
"grouped under the taught ability's Arms line"
);
assert_eq!(svc.skill_line_name, "Arms");
assert_eq!(svc.group_name, "Arms");
assert_eq!(
svc.name.as_deref(),
Some("Heroic Strike"),
"display resolves through the taught spell"
"the WIRE spell's own name — which here happens to match the taught one"
);
}
/// The **display name is the WIRE spell's**, not the taught spell's (decision 1124, refuting 0247's
/// display half). 1605/78 above cannot tell the two laws apart — both are "Heroic Strike", as are
/// 65.9 % of the shipped learn wrappers, which is how the wrong hop survived. The profession-learn
/// wrappers are the visible 34.1 %: on real 5875 data spell **2020 is "Apprentice Blacksmith" with
/// no rank**, where the spell it teaches (2018) is "Blacksmithing"/"Apprentice" — so this is the row
/// the director watches change. Skips without client data.
#[test]
fn display_name_is_the_wire_spell_not_the_taught_one() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let skills = benilla_formats::load_skill_line_catalog(&mut chain).expect("load skill lines");
// Dane Lindgren's learn row, exactly as the wire carries it (reqLevel 5, no skill gate).
let svc = resolve_service(
&wire(2020, trainer_spell_state::GREEN, 9, 5, 0),
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&skills),
&HashSet::new(),
);
assert_eq!(svc.name.as_deref(), Some("Apprentice Blacksmith"));
assert_eq!(svc.subtext.as_deref(), None);
assert_ne!(
spells.get(2020).map(|d| d.name.as_str()),
spells.get(2018).map(|d| d.name.as_str()),
"the two names really do differ on the shipped data — the test is not vacuous"
);
// And the group law that puts it first: 2020 carries Effect 44 SKILL_STEP where a recipe
// wrapper does not. Both read off the real `Spell.dbc` rather than a fixture.
assert_eq!(svc.group_key, 1);
assert_eq!(svc.group_name, "Development Skills");
let recipe = resolve_service(
&wire(2743, trainer_spell_state::RED, 50, 0, 164),
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&skills),
&HashSet::new(),
);
assert_eq!(recipe.group_key, 2);
assert_eq!(recipe.group_name, "Recipes");
assert_eq!(recipe.name.as_deref(), Some("Copper Chain Pants"));
}
#[test]
fn category_maps_the_wire_state_byte() {
assert_eq!(
@@ -381,9 +430,11 @@ fn resolve_reads_cost_state_and_gates_with_no_catalog() {
);
assert!(svc.is_trade_skill, "trainer_type 2 → tradeskill");
assert!(svc.prof_first_rank == w.is_primary_prof_first_rank);
// No skill-line catalog → skill_line 0 (the engine drops it from the tree) and an empty name.
assert_eq!(svc.skill_line, 0);
assert_eq!(svc.skill_line_name, "");
// No spell catalog at a TRADESKILL trainer → the SKILL_STEP predicate reads false and the row
// falls to the "Recipes" group. Nothing is ever dropped at type 2 (the partition is total), so
// the "unresolved → 0" arm belongs to the skill-line types, not this one.
assert_eq!(svc.group_key, 2);
assert_eq!(svc.group_name, "Recipes");
// No skill gate when req_skill is 0.
let plain = resolve_service(
&wire(78, trainer_spell_state::GREEN, 50, 5, 0),
@@ -0,0 +1,80 @@
//! Throwaway: the sort/admission inputs the trainer & craft windows key on. Two modes:
//!
//! * `line <skill-line-name-substring>...` — a line's abilities with their raw
//! `SkillLineAbility.dbc` columns (`req_skill_value` col 7, `reqtrainpoints` col 14), their
//! `Spell.dbc` name/rank, `castUI`, `attributes` and `spellLevel`.
//! * `castui <n>` — every spell whose `castUI == n`, with the skill line (if any) its first
//! `SkillLineAbility` row names: the client's craft-admission key vs benilla's SLA join.
fn main() -> anyhow::Result<()> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let skills = benilla_formats::load_skill_line_catalog(&mut chain)?;
let spells = benilla_formats::load_spell_catalog(&mut chain)?;
// Raw SkillLineAbility rows: (skillId, spellId, req_skill_value, reqtrainpoints).
let sla = chain.read_file("DBFilesClient\\SkillLineAbility.dbc")?;
let g = |o: usize| u32::from_le_bytes(sla[o..o + 4].try_into().unwrap());
let (rows, _fields, rec) = (g(4) as usize, g(8) as usize, g(12) as usize);
let raw: Vec<(u32, u32, u32, u32)> = (0..rows)
.map(|r| {
let b = 20 + r * rec;
(g(b + 4), g(b + 8), g(b + 28), g(b + 56))
})
.collect();
let mut args = std::env::args().skip(1);
let mode = args.next().unwrap_or_default();
let rest: Vec<String> = args.map(|s| s.to_lowercase()).collect();
if mode == "castui" {
let want: u32 = rest[0].parse()?;
let mut hits = 0;
for spell in 1..30000u32 {
let Some(d) = spells.get(spell) else { continue };
if d.cast_ui != want {
continue;
}
hits += 1;
let line = skills.spell_to_line(spell).unwrap_or(0);
let hidden = d.attributes & benilla_formats::SPELL_ATTR_IS_TRADESKILL != 0;
println!(
" {spell:>6} line={line:<5} attr20={} lvl={:<3} {} ({})",
u8::from(hidden),
d.spell_level,
d.name,
d.rank.as_deref().unwrap_or("")
);
}
println!("castUI == {want}: {hits} spells");
return Ok(());
}
let mut lines: Vec<(u32, String)> = Vec::new();
for id in 1..1200u32 {
if let Some(l) = skills.line(id) {
let lower = l.name.to_lowercase();
if rest.iter().any(|w| lower.contains(w)) {
lines.push((id, l.name.clone()));
}
}
}
for (id, name) in &lines {
let mut mine: Vec<&(u32, u32, u32, u32)> = raw.iter().filter(|r| r.0 == *id).collect();
mine.sort_by_key(|r| r.1);
println!("== skill line {id}: {name} — {} SLA rows", mine.len());
for &&(_, spell, req, tp) in mine.iter().take(120) {
let d = spells.get(spell);
let n = d.map(|d| d.name.as_str()).unwrap_or("?");
let rank = d.and_then(|d| d.rank.as_deref()).unwrap_or("");
println!(
" spell {spell:>6} req={req:<4} tp={tp:<4} castUI={:<2} attr20={} lvl={:<3} {n} ({rank})",
d.map(|d| d.cast_ui).unwrap_or(0),
u8::from(d.is_some_and(|d| d.attributes
& benilla_formats::SPELL_ATTR_IS_TRADESKILL
!= 0)),
d.map(|d| d.spell_level).unwrap_or(0),
);
}
}
Ok(())
}
+2 -1
View File
@@ -141,7 +141,8 @@ pub use spells::{
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
ATTR_ONLY_STEALTHED, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM,
SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL,
SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING,
SPELL_EFFECT_TRADE_SKILL,
};
mod skill_lines;
pub use skill_lines::{
+6
View File
@@ -267,6 +267,12 @@ pub const SPELL_EFFECT_LEARN_SPELL: u32 = 36;
/// **display** hop deliberately stays 36-only — that is decision 0247's verified grouping hop, and
/// widening it is a separate question nothing has asked.
pub const SPELL_EFFECT_LEARN_PET_SPELL: u32 = 57;
/// `SpellEffects` value `44` — `SPELL_EFFECT_SKILL_STEP`: the effect that raises a profession's
/// *potential* (Apprentice → Journeyman → …). It is the whole of what a **tradeskill trainer's**
/// display tree partitions on — the builder tests the WIRE spell's three effect slots for it and
/// reads nothing else (`0x4d77b6`, decision 1124; benilla-app's `ui_trainer::law::service_group` is
/// the transcription).
pub const SPELL_EFFECT_SKILL_STEP: u32 = 44;
/// `SpellEffects` value `SPELL_EFFECT_OPEN_LOCK` — the lock-opening effect the GameObject
/// interact-cast matches (decision 0239; RE `cursor-system.md` §8, the client's
+196 -30
View File
@@ -46,8 +46,10 @@
//!
//! ## v1 is FLAT — no header rows (the law landed for TradeSkill; Craft is a named divergence)
//!
//! Exactly [`super::tradeskill`]'s own v1 law: [`CraftState::recipes`] renders in the app's pre-sorted
//! order, one row per recipe, `index` 1-based straight into that list. `GetCraftInfo` therefore never
//! Exactly [`super::tradeskill`]'s own v1 law: [`CraftState::recipes`] renders as one flat row per
//! recipe, `index` 1-based straight into the list — though the ORDER of that list is now this
//! module's, not the app's (decision 1124: [`recipe_order`], the craft type's own byte-verified
//! comparator, applied in [`UiScript::set_craft`]). `GetCraftInfo` therefore never
//! returns the `"header"` `craftType` the ref Lua also checks for (ref l.38/77/199/252/292). The
//! header/grouping law itself is no longer pending — decision 0446 confirmed it byte-exact (wow-re
//! `tradeskill` TU-B) and TradeSkill grew the real tree engine ([`super::tradeskill`]'s `build_groups`)
@@ -153,10 +155,14 @@ pub struct CraftRecipe {
/// What the detail-icon hover describes ([`CraftTooltip`]) — resolved app-side, because the law
/// reads `Spell.dbc` effect columns the engine cannot see.
pub tooltip: CraftTooltip,
/// `Spell.dbc spellLevel` (`+0x74`) — the **rank-ordering key** of the Beast Training comparator
/// ([`recipe_order`]). Also what `GetCraftInfo`'s `requiredLevel` return is built from, though
/// that surface is still hardcoded `0` here (module doc).
pub spell_level: u32,
}
/// One open craft window: the skill line's name/rank and its (app-presorted) recipe list. Pushed whole
/// by the app ([`UiScript::set_craft`]); `None` means the window is closed.
/// One open craft window: the skill line's name/rank, the craft type, and its recipe list. Pushed
/// whole by the app ([`UiScript::set_craft`]); `None` means the window is closed.
#[derive(Clone, Debug, PartialEq)]
pub struct CraftState {
/// The window's title (`GetCraftName`) — e.g. `"Enchanting"`. Also the skill line's display name
@@ -165,18 +171,73 @@ pub struct CraftState {
pub name: String,
pub rank: u32,
pub max_rank: u32,
/// The window's recipe rows, PRE-SORTED by the app — the engine renders this order verbatim (v1
/// ships no grouping, module doc); `index` is 1-based straight into this list.
/// The **craft type** — the opener spell's `EffectMiscValue[0]`, which the client stores at
/// `ds:0xbdcfb8` (1 Beast Training · 3 Enchanting). It is the comparator switch: `cmp
/// ds:0xbdcfb8,1` @ `0x4f6765` picks `0x4f6920` for Beast Training, `0x4f67a0` otherwise.
pub craft_type: u32,
/// The window's recipe rows in ANY order — [`UiScript::set_craft`] sorts them ([`recipe_order`]);
/// `index` is 1-based into the sorted list.
pub recipes: Vec<CraftRecipe>,
}
/// The craft type whose comparator carries the extra `spellLevel` key ([`recipe_order`]) — the
/// opener spell 5149 "Beast Training"'s `EffectMiscValue[0]`. The only other type this window ever
/// opens with is Enchanting's **3**, which takes the shorter comparator `0x4f67a0`.
const CRAFT_TYPE_BEAST_TRAINING: u32 = 1;
/// The Craft window's **row order** — `0x4f6920` (craft type 1, Beast Training) / `0x4f67a0` (every
/// other type, i.e. Enchanting), byte-verified in wow-re
/// (`system/ui/scratch/trainer-craft-list-order.md`, decision 1124). Both are the same cascade and
/// the Beast Training one has one extra key:
///
/// 1. the **difficulty tier** `[+0xc]` ascending ([`super::TradeSkillDifficulty::tier`]);
/// 2. the localized **name** (collator `0x64a4c0`);
/// 3. **`Spell.dbc spellLevel`** (`+0x74`) ascending — **only** in `0x4f6920`. This is the key that
/// orders a pet ability's ranks, and the whole reason Beast Training needs its own comparator.
///
/// What benilla had instead was `SkillLineAbility.req_skill_value`, descending — a column the client
/// never reads anywhere in its craft TU, and which is a constant `1` on every row of skill line 261
/// *and* 333 in the shipped DBC. So the primary key was inert and the order collapsed to the name.
///
/// The trailing `spell_id` key is **ours, not the client's**: its cascade ends at `spellLevel`, so
/// rows tying on everything above (Charge's six ranks all carry `spellLevel = 0`) fall through to its
/// qsort's residue over an array benilla doesn't reproduce. Something must break that tie, and a
/// deterministic key is the only honest choice — benilla's was a `HashSet`'s iteration order, which
/// re-shuffled the pane between runs. Ascending spell id is also ascending rank across every 1.12
/// pet-ability family, so it agrees with the reference wherever the reference is defined.
fn recipe_order(a: &CraftRecipe, b: &CraftRecipe, craft_type: u32) -> std::cmp::Ordering {
a.difficulty
.tier()
.cmp(&b.difficulty.tier())
.then_with(|| collate(&a.name, &b.name))
.then_with(|| {
if craft_type == CRAFT_TYPE_BEAST_TRAINING {
a.spell_level.cmp(&b.spell_level)
} else {
std::cmp::Ordering::Equal
}
})
.then_with(|| a.spell_id.cmp(&b.spell_id))
}
/// The WoW enUS collator (`0x64a4c0` — the case-**insensitive** one the craft comparators use, unlike
/// the trainer rows' `0x64a480`), approximated: case-insensitive alphabetical, with the raw bytes as
/// a stable tie-break so equal-when-folded names keep a deterministic order.
fn collate(a: &str, b: &str) -> std::cmp::Ordering {
a.to_lowercase()
.cmp(&b.to_lowercase())
.then_with(|| a.cmp(b))
}
impl super::UiScript {
/// Push (or clear, with `None`) the open craft window's recipe snapshot. Snapshots re-push every
/// time a resolved field changes — no diffing happens here. On a push, the engine **preserves the
/// selection across the replace**: if the previously selected recipe's spell id still appears in
/// the new list, the selection follows it to its new index; otherwise it clears to `0`
/// ([`super::tradeskill::set_trade_skill`]'s own selection-survival law, verbatim). Clearing
/// (`None`) always resets the selection.
/// time a resolved field changes — no diffing happens here. On a push, the engine **sorts the
/// rows** into the craft type's own order ([`recipe_order`] — the ordering is the engine's, like
/// the trainer tree's and the tradeskill list's, so one binding's verified comparator lives in
/// exactly one place) and then **preserves the selection across the replace**: if the previously
/// selected recipe's spell id still appears in the new list, the selection follows it to its new
/// index; otherwise it clears to `0` ([`super::tradeskill::set_trade_skill`]'s own
/// selection-survival law, verbatim). Clearing (`None`) always resets the selection.
pub fn set_craft(&mut self, state: Option<CraftState>) {
let mut model = self.model_mut();
match state {
@@ -184,7 +245,9 @@ impl super::UiScript {
model.craft_selection = 0;
model.craft = None;
}
Some(s) => {
Some(mut s) => {
let craft_type = s.craft_type;
s.recipes.sort_by(|a, b| recipe_order(a, b, craft_type));
let prev_spell_id = model
.craft_selection
.checked_sub(1)
@@ -458,6 +521,9 @@ mod tests {
use super::*;
use crate::script::UiScript;
/// Enchanting's `EffectMiscValue[0]` — the craft type whose comparator has no `spellLevel` key.
const CRAFT_TYPE_ENCHANTING: u32 = 3;
/// One recipe fixture — a single-reagent, single-tool row, distinct spell ids per name.
fn recipe(
spell_id: u32,
@@ -485,16 +551,20 @@ mod tests {
have: 3,
}],
tools: vec![("Runed Copper Rod".into(), true)],
spell_level: 0,
}
}
/// A two-recipe Enchanting window: Minor Beastslaying (trivial, craftable) then Minor Health
/// (optimal, out of reagents).
/// A two-recipe Enchanting window: Minor Beastslaying (trivial, craftable) and Minor Health
/// (optimal, out of reagents). Declared trivial-first on purpose — the engine sorts by
/// difficulty tier ascending ([`recipe_order`]), so the window renders **Minor Health at index 1**
/// and Beastslaying at 2, whatever order the app pushed.
fn state() -> CraftState {
CraftState {
name: "Enchanting".into(),
rank: 57,
max_rank: 75,
craft_type: CRAFT_TYPE_ENCHANTING,
recipes: vec![
recipe(
7418,
@@ -543,10 +613,10 @@ mod tests {
lvl
),
(
"Enchant Weapon - Minor Beastslaying",
"Enchant Weapon - Minor Health",
"",
"trivial",
5,
"optimal",
0,
None,
0,
0
@@ -557,15 +627,15 @@ mod tests {
"local n,s,t,a,e,tp,l = GetCraftInfo(2) return n,s,t,a,e,tp,l",
)
.unwrap();
assert_eq!((kind2.as_str(), avail2), ("optimal", 0));
assert_eq!((kind2.as_str(), avail2), ("trivial", 5));
assert_eq!(
s.eval::<String>("return GetCraftIcon(1)").unwrap(),
"Interface\\Icons\\Spell_7418"
"Interface\\Icons\\Spell_683"
);
assert_eq!(
s.eval::<String>("return GetCraftDescription(1)").unwrap(),
"Enchant Weapon - Minor Beastslaying description."
"Enchant Weapon - Minor Health description."
);
assert_eq!(s.eval::<i64>("return GetCraftNumReagents(1)").unwrap(), 1);
let (rname, ricon, need, have) = s
@@ -598,16 +668,17 @@ mod tests {
#[test]
fn selection_persists_across_a_repush_by_spell_id() {
let mut s = UiScript::new().unwrap();
// Minor Beastslaying (spell 7418) starts at index 2.
let mut first = state();
first.recipes.swap(0, 1);
s.set_craft(Some(first));
// Minor Beastslaying (spell 7418) sorts to index 2 (trivial, behind the optimal row).
s.set_craft(Some(state()));
s.run("SelectCraft(2)").unwrap();
assert_eq!(s.eval::<i64>("return GetCraftSelectionIndex()").unwrap(), 2);
// Re-push with 7418 back at index 1 (a reagent-count re-list) — the selection follows it.
s.set_craft(Some(state()));
// A re-list that turns 7418 optimal moves it to index 1 — the selection FOLLOWS the spell
// id, not the index (and this is the case the engine-side sort makes reachable).
let mut promoted = state();
promoted.recipes[0].difficulty = super::super::TradeSkillDifficulty::Optimal;
promoted.recipes[0].name = "Aaa Beastslaying".into();
s.set_craft(Some(promoted));
assert_eq!(s.eval::<i64>("return GetCraftSelectionIndex()").unwrap(), 1);
// A re-push that drops the selected recipe entirely clears the selection.
@@ -623,7 +694,11 @@ mod tests {
s.set_craft(Some(state()));
s.run("DoCraft(1) DoCraft(2)").unwrap();
assert_eq!(s.take_craft_dos(), vec![7418, 683]);
assert_eq!(
s.take_craft_dos(),
vec![683, 7418],
"display order, not push order"
);
assert!(s.take_craft_dos().is_empty(), "drained");
// An out-of-range index is ignored.
@@ -661,7 +736,8 @@ mod tests {
fn get_craft_spell_focus_multivalue_shape() {
let mut s = UiScript::new().unwrap();
let mut c = state();
c.recipes[0].tools = vec![
// recipes[1] (Minor Health, optimal) is the row the sort puts at index 1.
c.recipes[1].tools = vec![
("Runed Copper Rod".into(), true),
("Arcanite Rod".into(), false),
];
@@ -679,7 +755,7 @@ mod tests {
// A recipe with no tools returns an empty multivalue (select('#', ...) == 0).
let mut c2 = state();
c2.recipes[0].tools.clear();
c2.recipes[1].tools.clear();
s.set_craft(Some(c2));
assert_eq!(
s.eval::<i64>("return select('#', GetCraftSpellFocus(1))")
@@ -703,4 +779,94 @@ mod tests {
assert_eq!(s.eval::<i64>("return GetNumCrafts()").unwrap(), 0);
assert_eq!(s.eval::<i64>("return GetCraftSelectionIndex()").unwrap(), 0);
}
/// **Beast Training's rank order** (decision 1124), pinned against wow-re's emulated run of the
/// real `0x4f6920` over real `Spell.dbc` values — and the regression for the director's report
/// that "Beast Training lists a skill's ranks out of ascending order" (ledger B229).
///
/// The fixture is the reported pane: four Arcane Resistance ranks and four Fire Resistance
/// ranks, pushed **shuffled** (as a `HashSet`'s iteration order used to deliver them), all on
/// one difficulty tier because pet abilities carry no trivial ranks. Name groups them; the
/// `spellLevel` key — 20/30/40/50 per rank — is what orders the ranks inside a group, and it is
/// the key the old `req_skill_value`-descending sort did not have.
#[test]
fn beast_training_orders_ranks_by_spell_level() {
let rank = |spell_id: u32, name: &str, level: u32| CraftRecipe {
spell_id,
tooltip: CraftTooltip::Spell(spell_id),
name: name.into(),
sub_name: format!("Rank {}", level / 10 - 1),
difficulty: super::super::TradeSkillDifficulty::Trivial,
num_available: 0,
icon: None,
description: None,
needs_item_target: false,
reagents: vec![],
tools: vec![],
spell_level: level,
};
// Shuffled on purpose — the exact shape of the report (Arcane read 1, 4, 3, 5).
let recipes = vec![
rank(24508, "Arcane Resistance", 30),
rank(24441, "Fire Resistance", 30),
rank(24495, "Arcane Resistance", 20),
rank(24464, "Fire Resistance", 50),
rank(24510, "Arcane Resistance", 50),
rank(24440, "Fire Resistance", 20),
rank(24509, "Arcane Resistance", 40),
rank(24463, "Fire Resistance", 40),
];
let mut s = UiScript::new().unwrap();
s.set_craft(Some(CraftState {
name: "Beast Training".into(),
rank: 0,
max_rank: 0,
craft_type: CRAFT_TYPE_BEAST_TRAINING,
recipes: recipes.clone(),
}));
let seen: Vec<(String, String)> = (1..=8)
.map(|i| {
s.eval::<(String, String)>(&format!("local n,sub = GetCraftInfo({i}) return n,sub"))
.unwrap()
})
.collect();
let got: Vec<String> = seen.iter().map(|(n, sub)| format!("{n} ({sub})")).collect();
assert_eq!(
got,
[
"Arcane Resistance (Rank 1)",
"Arcane Resistance (Rank 2)",
"Arcane Resistance (Rank 3)",
"Arcane Resistance (Rank 4)",
"Fire Resistance (Rank 1)",
"Fire Resistance (Rank 2)",
"Fire Resistance (Rank 3)",
"Fire Resistance (Rank 4)",
]
);
// The falsifiable control wow-re ran: the SAME rows at the Enchanting craft type select
// `0x4f67a0`, which has no `spellLevel` key — so the ranks fall to the trailing spell-id
// tie-break instead. That they still come out ascending here is benilla's determinism, not
// the reference's order; what matters is that the two types differ at all.
s.set_craft(Some(CraftState {
name: "Beast Training".into(),
rank: 0,
max_rank: 0,
craft_type: CRAFT_TYPE_ENCHANTING,
recipes,
}));
let ids: Vec<i64> = (1..=8)
.map(|i| {
s.eval::<i64>(&format!("DoCraft({i}) return 0")).unwrap();
0
})
.collect();
assert_eq!(ids.len(), 8);
assert_eq!(
s.take_craft_dos(),
vec![24495, 24508, 24509, 24510, 24440, 24441, 24463, 24464],
"no spellLevel key at type 3 — the order is name then the deterministic id tie-break"
);
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ pub use trade::{TradeSideState, TradeSlotItem, TradeState, TRADE_SLOTS};
pub use tradeskill::{TradeSkillDifficulty, TradeSkillReagent, TradeSkillRecipe, TradeSkillState};
pub use trainer::{
TrainerAbilityReq, TrainerGroup, TrainerService, TrainerServiceCategory, TrainerSkillReq,
TrainerState, TrainerTooltip,
TrainerState, TrainerTooltip, TRAINER_GROUP_KNOWN,
};
pub use types::{
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
@@ -520,8 +520,8 @@ fn trainer_service(spell_id: u32, name: &str, tooltip: TrainerTooltip) -> Traine
name: Some(name.into()),
texture: Some("Interface\\Icons\\Temp".into()),
category: TrainerServiceCategory::Available,
skill_line: 26,
skill_line_name: "Arms".into(),
group_key: 26,
group_name: "Arms".into(),
..Default::default()
}
}
@@ -678,11 +678,13 @@ fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() {
needs_item_target: false,
reagents: vec![],
tools: vec![],
spell_level: 0,
};
s.set_craft(Some(CraftState {
name: "Enchanting".into(),
rank: 100,
max_rank: 150,
craft_type: 3,
recipes: vec![
recipe(
7420,
@@ -129,8 +129,12 @@ impl TradeSkillDifficulty {
}
/// The byte tier a group sorts recipes by, ascending (wow-re `tradeskill` TU-B, VERIFIED):
/// Optimal < Medium < Easy < Trivial, 0..3.
fn tier(self) -> u8 {
/// Optimal < Medium < Easy < Trivial, 0..3. The **Craft** window's row comparators key on the
/// same tier byte first (`row[+0xc]`, decision 1124), which is why this is `pub(crate)` and not
/// private to this module: the client's two code tables are one vocabulary shifted by one
/// (Craft's `0` is a `"none"` tier benilla cannot produce), so the *relative* order is identical
/// and the shift is invisible to a comparator.
pub(crate) fn tier(self) -> u8 {
match self {
TradeSkillDifficulty::Optimal => 0,
TradeSkillDifficulty::Medium => 1,
@@ -17,16 +17,35 @@
//! `"header"` (a skill-line group header, with `isExpanded` set) or the service's colour state
//! `"available"`/`"unavailable"`/`"used"` (green/red/gray, `isExpanded` nil).
//!
//! ## The tree (decision 0247 — the byte-verified grouping/sort model)
//! ## The tree (decisions 0247 + 1124 — the byte-verified grouping/sort model, **per trainer type**)
//!
//! The 1.12 wire (`SMSG_TRAINER_LIST`) is a **flat** service list; the client builds a **collapsible
//! skill-line tree** on top of it, and `index` is **1-based into that visible tree**, not the wire
//! order. [`UiScript::set_trainer`] takes the flat services (each already carrying its resolved
//! [`TrainerService::skill_line`]/name, resolved app-side from `SkillLineAbility.dbc`) and synthesizes
//! the tree: one **header row per distinct skill line** (headers sorted by name), then that line's
//! services sorted by the within-group keys (required level → required-skill value → localized name →
//! localized rank — keys 4–7 of the verified comparator `0x4d85c0`). State is a per-row **colour**, not
//! the grouping key. A service whose skill line is `0` (unresolved) is dropped, matching the client.
//! tree** on top of it, and `index` is **1-based into that visible tree**, not the wire order.
//! [`UiScript::set_trainer`] takes the flat services (each already carrying its app-resolved
//! [`TrainerService::group_key`]/[`TrainerService::group_name`]) and synthesizes the tree: one
//! **header row per distinct group key**, then that group's services in the group's own order. State
//! is a per-row **colour**, never the grouping key.
//!
//! **The `trainerType` selects everything** — the list finalizer `0x4d8410` picks the row comparator
//! by `ds:0xb73a08` (`dec eax; je` chain @ `0x4d8561`) and the builder `0x4d7560` picks both the
//! group key and the header comparator by the same dword (`0x4d7786`, `0x4d79eb`). Neither the group
//! key nor the sort is one law with a "later refinement" — they are four laws, and 1124 is where
//! benilla stopped applying the class one to all of them:
//!
//! | type | rows | headers | group key |
//! |---|---|---|---|
//! | 0 class, 3 pet | `0x4d85c0` — level → skill value → name → rank | `0x4d7b90`, by name | taught spell's `SkillLine` |
//! | 1 mount ("talent") | `0x4d8850` — **state byte → name** | `0x4d7b90`, `-1` first then by name | `-1` when the service is `used`, else the `SkillLine` |
//! | 2 tradeskill | `0x4d8760` — **skill value → name**, no level key, no rank key | `0x4d7c30`, by **raw key asc** | **1 or 2**, see below |
//!
//! At type 2 the group key is **not a skill line and never resolves one**: the builder defaults it
//! to 2 and sets it to 1 iff the **wire** spell's own `Spell.dbc Effect[0..2]` contains `44`
//! `SKILL_STEP` (`0x4d77b6`), which makes exactly two groups — `TRADESKILL_SERVICE_STEP` ("Development
//! Skills", the profession-learn services) and `TRADESKILL_SERVICE_LEARN` ("Recipes") — and means
//! **no row is ever dropped at a tradeskill trainer**. That partition, not a level key, is what puts
//! "Apprentice Blacksmith" at the top of a blacksmithing trainer: `reqLevel` is inert at type 2.
//! At the other types a service whose group key is `0` (unresolved skill line) is still dropped,
//! matching the client.
//!
//! `GetNumTrainerServices`/every getter index the **visible** rows: headers always show, but a
//! service hides when its state fails the dropdown filter ([`Model::trainer_filter`]) or its group is
@@ -36,12 +55,17 @@
//!
//! ## Faithful stubs (no wire data yet — kept so the ported XML runs)
//!
//! `GetTrainerServiceStepReq` returns nil (no tradeskill-step data on the wire), `IsTalentTrainer` is
//! always false (benilla drives no talent trainer), and per-requirement `hasReq`/`met` flags are
//! derived from the service's overall `category` (an unavailable service has an unmet gate) rather
//! than a per-gate wire bit, which 5875 does not send. The within-group sort uses the **class**
//! comparator (`0x4d85c0`) for every trainer; the talent/tradeskill comparator variants (`0x4d8850`/
//! `0x4d8760`, documented in 0247) are a later refinement — the director's window is the class trainer.
//! `GetTrainerServiceStepReq` returns nil (no tradeskill-step data on the wire), and per-requirement
//! `hasReq`/`met` flags are derived from the service's overall `category` (an unavailable service has
//! an unmet gate) rather than a per-gate wire bit, which 5875 does not send.
//!
//! One thing the client does that this engine does not, named rather than left to be discovered: a
//! group whose row counters go to zero **vanishes entirely, header included** (`0x4d8460`,
//! `0x4d8528`–`0x4d8549`) — which is how a type-1 trainer whose every service is `used` shows one
//! "My Talents" header and no skill-line headers at all. benilla always keeps a header. The case
//! that is *reachable* here (does the state-filter dropdown emptying a group also take its header?)
//! is an open question with wow-re; the build-time case above cannot arise, because benilla builds
//! a group only from services that exist.
use mlua::{Lua, MultiValue, Value};
@@ -79,6 +103,18 @@ impl TrainerServiceCategory {
}
}
/// The row's **state byte** `[+0x30]` — the value `0x4d8ba0` maps to the colour string (`0` →
/// available, `2` → used, anything else → unavailable). It is a colour on every trainer type but
/// one: at type 1 it is also the third sort key ([`talent_order`]), which is why this exists
/// separately from the filter slot it numerically coincides with.
fn state_key(self) -> u8 {
match self {
TrainerServiceCategory::Available => 0,
TrainerServiceCategory::Unavailable => 1,
TrainerServiceCategory::Used => 2,
}
}
/// Parse the Era filter-type string the sort checkboxes pass.
fn from_filter_str(s: &str) -> Option<Self> {
match s {
@@ -176,41 +212,45 @@ pub struct TrainerService {
pub ability_reqs: Vec<TrainerAbilityReq>,
/// A tradeskill step (`IsTrainerServiceTradeSkill`) vs. a plain learn-spell.
pub is_trade_skill: bool,
/// The taught spell's skill line (`SkillLineAbility.dbc` → `SkillLine.dbc`), resolved app-side —
/// the tree's grouping key (decision 0247). `0` = unresolved: the service is **dropped** from the
/// tree (the client's `skillLine == 0 → drop`).
pub skill_line: u32,
/// The skill line's localized display name (`SkillLine.dbc`) — the header row's text.
pub skill_line_name: String,
/// The tree's grouping key, resolved app-side by the **trainer type's own** builder law
/// (module doc, decision 1124): the taught spell's `SkillLine` at types 0/1/3, or the
/// `SKILL_STEP` partition's `1`/`2` at type 2. `0` = unresolved: the service is **dropped** from
/// the tree (the client's `skillLine == 0 → drop`) — reachable only at types 0/1/3.
pub group_key: u32,
/// The group's localized display name — the header row's text. `SkillLine.dbc`'s name at types
/// 0/1/3; the `TRADESKILL_SERVICE_STEP`/`_LEARN` global strings at type 2.
pub group_name: String,
/// What the detail-icon hover describes ([`TrainerTooltip`]) — resolved app-side, because the
/// law reads `Spell.dbc` fields the engine cannot see. Independent of [`Self::texture`].
pub tooltip: TrainerTooltip,
}
/// One skill-line group in the display tree (decision 0247): the header's skill line + name and the
/// positions (into [`TrainerState::services`]) of the line's services, pre-sorted by the within-group
/// keys. Synthesized by [`UiScript::set_trainer`] — the app pushes only the flat services.
/// One group in the display tree (decisions 0247/1124): the header's key + name and the positions
/// (into [`TrainerState::services`]) of the group's services, pre-sorted by the trainer type's
/// within-group comparator. Synthesized by [`UiScript::set_trainer`] — the app pushes only the flat
/// services.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TrainerGroup {
/// The skill-line id (the collapse key).
pub skill_line: u32,
/// The group key (also the collapse key).
pub key: u32,
/// The header's display name.
pub name: String,
/// The line's services, as positions into [`TrainerState::services`], sorted (level → skill-value
/// → name → rank).
/// The group's services, as positions into [`TrainerState::services`], in display order.
pub services: Vec<usize>,
}
/// One open trainer window: the services, the greeting line, and whether the trainer is a tradeskill
/// trainer (`IsTradeskillTrainer` — drives the real window's tradeskill-vs-class layout switch).
/// Pushed whole by the app; `None` means no trainer is open (the window is closed).
/// One open trainer window: the services, the greeting line, and the wire trainer type. Pushed whole
/// by the app; `None` means no trainer is open (the window is closed).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TrainerState {
pub services: Vec<TrainerService>,
/// The trainer's greeting/title line (`SMSG_TRAINER_LIST`'s trailing string).
pub greeting: String,
/// `IsTradeskillTrainer()` — the whole-trainer kind (`trainer_type == 2`).
pub is_tradeskill: bool,
/// `SMSG_TRAINER_LIST`'s `trainerType` (`ds:0xb73a08`), verbatim — **the** switch: it selects the
/// row comparator, the header comparator and the group-key law (module doc), and it is what the
/// two whole-trainer predicates test (`IsTradeskillTrainer 0x4d8ea0`: `== 2`; `IsTalentTrainer
/// 0x4d8ed0`: `== 1`). 0 class · 1 mount · 2 tradeskill · 3 pet.
pub trainer_type: u32,
/// The sorted skill-line groups over `services` — **built by [`UiScript::set_trainer`]**, not
/// pushed by the app (default-empty on a bare `TrainerState`). The display tree walks these.
pub groups: Vec<TrainerGroup>,
@@ -231,9 +271,8 @@ impl super::UiScript {
model.trainer = None;
}
Some(mut s) => {
s.groups = build_groups(&s.services);
let live: std::collections::HashSet<u32> =
s.groups.iter().map(|g| g.skill_line).collect();
s.groups = build_groups(&s.services, s.trainer_type);
let live: std::collections::HashSet<u32> = s.groups.iter().map(|g| g.key).collect();
model.trainer_collapsed.retain(|sl| live.contains(sl));
model.trainer = Some(s);
}
@@ -270,64 +309,152 @@ fn collate(a: &str, b: &str) -> std::cmp::Ordering {
.then_with(|| a.cmp(b))
}
/// The within-group service comparator — keys 4–7 of the verified class comparator (`0x4d85c0`,
/// decision 0247): required level → required-skill value → localized name → localized rank/subtext.
fn service_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
let skillval = |s: &TrainerService| s.skill_req.as_ref().map_or(0, |r| r.rank);
/// `SMSG_TRAINER_LIST`'s tradeskill `trainerType` (module doc's table).
const TRAINER_TYPE_TRADESKILL: u32 = 2;
/// `SMSG_TRAINER_LIST`'s mount `trainerType` — the one the client's own vocabulary calls "talent"
/// (`IsTalentTrainer 0x4d8ed0` tests it).
const TRAINER_TYPE_MOUNT: u32 = 1;
/// The already-known group a **type 1** trainer buckets its `used` services into — the client's
/// signed `-1` group key (`0x4d77e8`), whose header takes the `KNOWN_TALENTS_HEADER` global string
/// ("My Talents", 1.12.1 enUS). Modelled as `u32::MAX` because the key is otherwise a skill-line id;
/// the header comparator `0x4d7b90` tests for it explicitly and sorts it **first**, ahead of the
/// name ordering that ranks every other header.
pub const TRAINER_GROUP_KNOWN: u32 = u32::MAX;
/// A service's required-skill value (`[+0x1c]`, the wire's `reqSkillValue`) — `0` when the service
/// carries no skill gate.
fn skill_value(s: &TrainerService) -> u32 {
s.skill_req.as_ref().map_or(0, |r| r.rank)
}
/// The within-group comparator for a **class** (type 0) or **pet** (type 3) trainer — keys ④–⑦ of
/// the verified `0x4d85c0`: required level (`[+0x14]` byte) → required-skill value (`[+0x1c]` u32) →
/// localized name → localized rank/subtext, all ascending. Pet is not special-cased anywhere in the
/// selection chain at `0x4d8561`; it lands here with class.
fn class_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
let name = |s: &TrainerService| s.name.clone().unwrap_or_default();
let rank = |s: &TrainerService| s.subtext.clone().unwrap_or_default();
a.level_req
.cmp(&b.level_req)
.then_with(|| skillval(a).cmp(&skillval(b)))
.then_with(|| skill_value(a).cmp(&skill_value(b)))
.then_with(|| collate(&name(a), &name(b)))
.then_with(|| collate(&rank(a), &rank(b)))
}
/// Build the display tree from the flat services (decision 0247): group by skill line (dropping the
/// unresolved `skill_line == 0`), sort each group's services by [`service_order`], and sort the groups
/// by localized name (skill-line id breaks a name tie). The service positions index back into the
/// unchanged `services` slice, so every getter still resolves a row to its full service data.
fn build_groups(services: &[TrainerService]) -> Vec<TrainerGroup> {
/// The within-group comparator for a **tradeskill** trainer (type 2) — the whole of `0x4d8760`'s
/// row half: required-skill value **ascending** (`0x4d87d9`/`0x4d8801`), then the localized name.
///
/// The two keys it pointedly does *not* have are the interesting ones, and both were in benilla's
/// output until 1124: there is **no `[+0x14]` required-level key** (which is what used to sink the
/// profession-learn row — the only row with a level — to the bottom of every profession trainer),
/// and **no `[+0x204]` rank tie-break**.
fn tradeskill_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
let name = |s: &TrainerService| s.name.clone().unwrap_or_default();
skill_value(a)
.cmp(&skill_value(b))
.then_with(|| collate(&name(a), &name(b)))
}
/// The within-group comparator for a **mount** trainer (type 1, the client's "talent") — the whole
/// of `0x4d8850`'s row half: the **state byte** `[+0x30]` ascending (`0x4d88f1`, so available →
/// unavailable → used), then the localized name. The one comparator on which the green/red/gray is a
/// sort key rather than only a colour; like the tradeskill one it has no level, skill-value or rank
/// key.
fn talent_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
let name = |s: &TrainerService| s.name.clone().unwrap_or_default();
a.category
.state_key()
.cmp(&b.category.state_key())
.then_with(|| collate(&name(a), &name(b)))
}
/// Build the display tree from the flat services (decisions 0247/1124), by the trainer type's own
/// three laws:
///
/// * **group** on the app-resolved [`TrainerService::group_key`], dropping the unresolved `0` (which
/// a type-2 list never produces — its partition is total);
/// * **order each group's services** with that type's row comparator;
/// * **order the headers** by raw key ascending at type 2 (`0x4d7c30`: one key, no name, no
/// tie-break), else by localized name with the key breaking a name tie (`0x4d7b90`).
///
/// The service positions index back into the unchanged `services` slice, so every getter still
/// resolves a row to its full service data.
fn build_groups(services: &[TrainerService], trainer_type: u32) -> Vec<TrainerGroup> {
let mut map: std::collections::HashMap<u32, TrainerGroup> = std::collections::HashMap::new();
for (i, s) in services.iter().enumerate() {
if s.skill_line == 0 {
if s.group_key == 0 {
continue;
}
map.entry(s.skill_line)
map.entry(s.group_key)
.or_insert_with(|| TrainerGroup {
skill_line: s.skill_line,
name: s.skill_line_name.clone(),
key: s.group_key,
name: s.group_name.clone(),
services: Vec::new(),
})
.services
.push(i);
}
let mut groups: Vec<TrainerGroup> = map.into_values().collect();
let order = match trainer_type {
TRAINER_TYPE_TRADESKILL => tradeskill_order,
TRAINER_TYPE_MOUNT => talent_order,
_ => class_order,
};
for g in &mut groups {
g.services
.sort_by(|&a, &b| service_order(&services[a], &services[b]));
.sort_by(|&a, &b| order(&services[a], &services[b]));
}
if trainer_type == TRAINER_TYPE_TRADESKILL {
groups.sort_by_key(|g| g.key);
} else {
// `0x4d7b90`: the `-1` (already-known) group first, then localized name. Its second key —
// `hdr[+0x24] == 0` first, between those two — is uncarved and unreachable here: benilla's
// headers are one per distinct skill line, so nothing ties on the name that it would break.
groups.sort_by(|a, b| {
(a.key != TRAINER_GROUP_KNOWN)
.cmp(&(b.key != TRAINER_GROUP_KNOWN))
.then_with(|| collate(&a.name, &b.name))
.then_with(|| a.key.cmp(&b.key))
});
}
groups.sort_by(|a, b| collate(&a.name, &b.name).then(a.skill_line.cmp(&b.skill_line)));
groups
}
/// The visible rows in display order (decision 0247): each group's header (always shown), then — when
/// the group is not collapsed — its services that pass the state filter. The Lua's 1-based `index` is
/// a position in *this* list.
/// The visible rows in display order (decisions 0247/1124). Per group, in order: the header — **only
/// if at least one of the group's services passes the state filter** — then, when the group is not
/// collapsed, those services. The Lua's 1-based `index` is a position in *this* list.
///
/// **The filter and the collapse are deliberately asymmetric**, and that asymmetry is structural in
/// the client rather than incidental. The finalizer builds a per-group flag `hdr[+0x1c]` by walking
/// the group's per-state member counts through the **live** state mask `ds:0xb73a1c` (`0x4d8431`,
/// `0x4d8447`), and the hide it drives at `0x4d8535` has **no header-row exemption** — so a group
/// every one of whose rows the dropdown hides disappears entirely, header included, and unchecking
/// every box empties the window (`GetNumTrainerServices() == 0`). The collapse test three
/// instructions later (`0x4d8541`) *does* exempt headers (`0x4d853d`) and reads a different field
/// (`hdr[+0x20]`), so a collapsed group keeps its header — which is what makes it re-expandable.
/// benilla had both cases keeping the header, with a test asserting it; 1124 inverted the filter
/// half.
fn rows(model: &Model) -> Vec<Row> {
let Some(t) = model.trainer.as_ref() else {
return Vec::new();
};
let mut out = Vec::new();
for (gi, g) in t.groups.iter().enumerate() {
out.push(Row::Header(gi));
if !model.trainer_collapsed.contains(&g.skill_line) {
for &si in &g.services {
if model.trainer_filter[t.services[si].category.filter_slot()] {
out.push(Row::Service(si));
}
}
let mut shown = g
.services
.iter()
.copied()
.filter(|&si| model.trainer_filter[t.services[si].category.filter_slot()])
.peekable();
if shown.peek().is_none() {
continue;
}
out.push(Row::Header(gi));
if model.trainer_collapsed.contains(&g.key) {
continue;
}
out.extend(shown.map(Row::Service));
}
out
}
@@ -362,7 +489,7 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
model
.trainer
.as_ref()
.map(|t| t.groups.iter().map(|g| g.skill_line).collect())
.map(|t| t.groups.iter().map(|g| g.key).collect())
.unwrap_or_default()
} else {
match rows(model).get(id - 1) {
@@ -370,7 +497,7 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
.trainer
.as_ref()
.and_then(|t| t.groups.get(*gi))
.map(|g| g.skill_line)
.map(|g| g.key)
.into_iter()
.collect(),
_ => Vec::new(),
@@ -435,7 +562,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
match row {
Row::Header(gi) => {
let g = &t.groups[gi];
let expanded = !model.trainer_collapsed.contains(&g.skill_line);
let expanded = !model.trainer_collapsed.contains(&g.key);
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&g.name)?),
Value::Nil,
@@ -573,22 +700,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// IsTradeskillTrainer() → 1/nil for the whole trainer (drives the tradeskill-vs-class layout).
// IsTradeskillTrainer() → 1/nil for the whole trainer (drives the tradeskill-vs-class layout):
// `0x4d8ea0` tests trainerType == 2.
g.set(
"IsTradeskillTrainer",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(era_bool(
model.trainer.as_ref().is_some_and(|t| t.is_tradeskill),
))
Ok(era_bool(model.trainer.as_ref().is_some_and(|t| {
t.trainer_type == TRAINER_TYPE_TRADESKILL
})))
})?,
)?;
// IsTalentTrainer() → always nil (benilla drives no talent trainer; the ref window's talent hacks
// guard on this).
// IsTalentTrainer() → 1/nil: `0x4d8ed0` tests trainerType == 1 — vmangos's MOUNT trainers, which
// the client's own vocabulary calls "talent" (decision 1124). It used to return a hardcoded nil
// on the belief that benilla drives no such trainer; the shipped world has 23 of them.
g.set(
"IsTalentTrainer",
lua.create_function(|_, ()| Ok(Value::Nil))?,
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(era_bool(
model.trainer.as_ref().is_some_and(|t| t.trainer_type == 1),
))
})?,
)?;
// GetTrainerGreetingText() → the trainer's greeting line ("" when no trainer is open).
@@ -702,339 +836,5 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
fn svc(
spell_id: u32,
name: &str,
cat: TrainerServiceCategory,
skill_line: u32,
line_name: &str,
) -> TrainerService {
TrainerService {
spell_id,
// The fixture's default subject: the wire spell, no hop (the "no learn wrapper"
// fallback); the tooltip tests set the arm they mean explicitly.
tooltip: TrainerTooltip::Spell {
spell_id,
alt_caster: false,
},
name: Some(name.into()),
subtext: Some("Rank 1".into()),
texture: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
description: format!("Teaches {name}."),
cost: 100,
prof_first_rank: false,
category: cat,
level_req: 10,
skill_req: None,
ability_reqs: vec![],
is_trade_skill: false,
skill_line,
skill_line_name: line_name.into(),
}
}
/// A two-line warrior trainer. Groups sort by name (Arms < Fury); within Arms, the level keys
/// order Heroic Strike (level 1) before Cleave (level 20). The default-expanded tree is thus:
/// `[H:Arms, Heroic Strike(avail), Cleave(used), H:Fury, Bloodrage(unavail)]` — a 5-row list whose
/// service rows sit at indices 2, 3, 5.
fn trainer() -> TrainerState {
let mut hs = svc(
78,
"Heroic Strike",
TrainerServiceCategory::Available,
26,
"Arms",
);
hs.level_req = 1;
let mut cl = svc(284, "Cleave", TrainerServiceCategory::Used, 26, "Arms");
cl.level_req = 20;
let br = svc(
285,
"Bloodrage",
TrainerServiceCategory::Unavailable,
256,
"Fury",
);
TrainerState {
greeting: "What would you like to learn?".into(),
is_tradeskill: false,
groups: Vec::new(),
services: vec![hs, cl, br],
}
}
#[test]
fn tree_interleaves_headers_and_ordered_services() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
assert!(s
.eval::<bool>("return GetTrainerServiceInfo(1) == nil")
.unwrap());
s.set_trainer(Some(trainer()));
// 2 headers + 3 services = 5 visible rows.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Row 1 is the "Arms" header: name, nil subtext, "header", isExpanded=1.
let (hn, hsub, ht, hexp) = s
.eval::<(String, Option<String>, String, Option<i64>)>(
"local n,s,t,e = GetTrainerServiceInfo(1) return n,s,t,e",
)
.unwrap();
assert_eq!((hn.as_str(), ht.as_str()), ("Arms", "header"));
assert_eq!((hsub, hexp), (None, Some(1)));
// Rows 2..5: services (name/state) then the Fury header then its service — the sorted tree.
let info = |s: &mut UiScript, i: i64| {
s.eval::<(String, String)>(&format!(
"local n,_,t = GetTrainerServiceInfo({i}) return n,t"
))
.unwrap()
};
assert_eq!(
info(&mut s, 2),
("Heroic Strike".into(), "available".into())
);
assert_eq!(info(&mut s, 3), ("Cleave".into(), "used".into()));
assert_eq!(info(&mut s, 4), ("Fury".into(), "header".into()));
assert_eq!(info(&mut s, 5), ("Bloodrage".into(), "unavailable".into()));
}
#[test]
fn service_getters_read_the_row_at_a_visible_index() {
let mut s = UiScript::new().unwrap();
let mut t = trainer();
// Heroic Strike (services[0], the row at index 2) carries a full gate set.
t.services[0].skill_req = Some(TrainerSkillReq {
name: "Blacksmithing".into(),
rank: 100,
met: true,
});
t.services[0].ability_reqs = vec![TrainerAbilityReq {
name: "Apprentice".into(),
met: false,
}];
s.set_trainer(Some(t));
// Cost/level/desc/reqs all resolve at the SERVICE index 2 (not the header at 1).
assert_eq!(
s.eval::<(i64, i64, i64)>("return GetTrainerServiceCost(2)")
.unwrap(),
(100, 0, 0)
);
assert_eq!(
s.eval::<i64>("return GetTrainerServiceLevelReq(2)")
.unwrap(),
1
);
assert_eq!(
s.eval::<String>("return GetTrainerServiceDescription(2)")
.unwrap(),
"Teaches Heroic Strike."
);
let (skill, rank, has) = s
.eval::<(String, i64, i64)>("return GetTrainerServiceSkillReq(2)")
.unwrap();
assert_eq!((skill.as_str(), rank, has), ("Blacksmithing", 100, 1));
assert!(s
.eval::<bool>(
"local n,h = GetTrainerServiceAbilityReq(2,1) return n=='Apprentice' and h==nil"
)
.unwrap());
assert!(s
.eval::<bool>("local l,p = IsTrainerServiceLearnSpell(2) return l==1 and p==nil")
.unwrap());
// A HEADER row (index 1) has no service data — the getters no-op to defaults.
assert_eq!(
s.eval::<i64>("return GetTrainerServiceLevelReq(1)")
.unwrap(),
0
);
assert!(s
.eval::<bool>("return GetTrainerServiceIcon(1) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetTrainerServiceSkillReq(1) == nil")
.unwrap());
}
#[test]
fn state_filter_hides_services_not_headers() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Hide "used": Cleave drops (5 → 4), but BOTH headers stay — a state filter culls services
// only. Row 3 is now the Fury header (Cleave was there).
s.run("SetTrainerServiceTypeFilter('used', 0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 4);
let types: Vec<String> = (1..=4)
.map(|i| {
s.eval::<String>(&format!(
"local _,_,t = GetTrainerServiceInfo({i}) return t"
))
.unwrap()
})
.collect();
assert_eq!(types, ["header", "available", "header", "unavailable"]);
// Hide ALL states: the two headers remain (each group keeps its header even with no services).
s.run("SetTrainerServiceTypeFilter('available', 0)")
.unwrap();
s.run("SetTrainerServiceTypeFilter('unavailable', 0)")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
assert!(s
.eval::<bool>(
"local _,_,t1 = GetTrainerServiceInfo(1) \
local _,_,t2 = GetTrainerServiceInfo(2) \
return t1=='header' and t2=='header'"
)
.unwrap());
}
#[test]
fn collapse_by_header_index_and_collapse_all() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
// Collapse the Arms group by its header's display index (1): its two services vanish, the
// header stays and now reports isExpanded=nil. 5 → 3 (H:Arms, H:Fury, Bloodrage).
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 3);
assert!(s
.eval::<bool>("local _,_,t,e = GetTrainerServiceInfo(1) return t=='header' and e==nil")
.unwrap());
assert_eq!(
s.eval::<String>("local n = GetTrainerServiceInfo(2) return n")
.unwrap(),
"Fury",
"Arms' services are folded; Fury's header is now row 2"
);
// Expand it back by the same header index.
s.run("ExpandTrainerSkillLine(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Collapse-all (id 0): both groups fold → just the two headers.
s.run("CollapseTrainerSkillLine(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
// Expand-all (id 0): back to the full tree.
s.run("ExpandTrainerSkillLine(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
#[test]
fn collapse_survives_a_content_update_and_resets_on_close() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("CollapseTrainerSkillLine(1)").unwrap(); // fold Arms
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 3);
// A re-list (same skill lines) keeps the fold — a buy re-lists and the tree shouldn't jump open.
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
3,
"Arms stays collapsed across a content update"
);
// Close → the collapse set resets; a re-open is fully expanded.
s.set_trainer(None);
s.set_trainer(Some(trainer()));
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
#[test]
fn buy_queues_the_selected_services_spell_id_headers_no_op() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
// Row 5 is Bloodrage (spell 285). Buying it queues its spell id, not its index.
s.run("BuyTrainerService(5)").unwrap();
assert_eq!(s.take_trainer_buys(), vec![285]);
assert!(s.take_trainer_buys().is_empty(), "drained");
// Buying a HEADER row (index 1) queues nothing.
s.run("BuyTrainerService(1)").unwrap();
assert!(s.take_trainer_buys().is_empty(), "a header is not buyable");
}
#[test]
fn selection_and_close_intents() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
s.run("SelectTrainerService(2)").unwrap();
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
2
);
s.run("SelectTrainerService(9)").unwrap(); // OOB (past the 5 rows) clears
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
assert!(!s.take_trainer_close());
s.run("CloseTrainer()").unwrap();
assert!(s.take_trainer_close());
assert!(!s.take_trainer_close(), "drained");
}
#[test]
fn tradeskill_and_talent_flags() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == nil")
.unwrap());
let mut t = trainer();
t.is_tradeskill = true;
t.services[0].is_trade_skill = true; // Heroic Strike → the row at index 2
s.set_trainer(Some(t));
assert!(s.eval::<bool>("return IsTradeskillTrainer() == 1").unwrap());
assert!(s
.eval::<bool>("return IsTrainerServiceTradeSkill(2) == 1")
.unwrap());
assert!(s.eval::<bool>("return IsTalentTrainer() == nil").unwrap());
}
#[test]
fn unresolved_skill_line_is_dropped() {
let mut s = UiScript::new().unwrap();
let mut t = trainer();
// A service whose skill line didn't resolve (0) is dropped from the tree entirely.
t.services.push(svc(
999,
"Orphan Spell",
TrainerServiceCategory::Available,
0,
"",
));
s.set_trainer(Some(t));
// Still 5 rows — the orphan contributes neither a header nor a service row.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
#[test]
fn clearing_empties_and_resets_selection() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("SelectTrainerService(2)").unwrap();
s.set_trainer(None);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
}
}
mod tests;
@@ -0,0 +1,620 @@
//! The trainer tree's tests — the per-`trainerType` ordering laws (decisions 0247/1124) pinned
//! against wow-re's own emulated runs of the real finalizer, plus the filter/collapse/intent
//! surface. Split out of `mod.rs` when the four comparators pushed it past the file budget.
use super::*;
use crate::script::UiScript;
fn svc(
spell_id: u32,
name: &str,
cat: TrainerServiceCategory,
group_key: u32,
group_name: &str,
) -> TrainerService {
TrainerService {
spell_id,
// The fixture's default subject: the wire spell, no hop (the "no learn wrapper"
// fallback); the tooltip tests set the arm they mean explicitly.
tooltip: TrainerTooltip::Spell {
spell_id,
alt_caster: false,
},
name: Some(name.into()),
subtext: Some("Rank 1".into()),
texture: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
description: format!("Teaches {name}."),
cost: 100,
prof_first_rank: false,
category: cat,
level_req: 10,
skill_req: None,
ability_reqs: vec![],
is_trade_skill: false,
group_key,
group_name: group_name.into(),
}
}
/// A two-line warrior trainer (type 0). Groups sort by name (Arms < Fury); within Arms, the level
/// keys order Heroic Strike (level 1) before Cleave (level 20). The default-expanded tree is thus:
/// `[H:Arms, Heroic Strike(avail), Cleave(used), H:Fury, Bloodrage(unavail)]` — a 5-row list whose
/// service rows sit at indices 2, 3, 5.
fn trainer() -> TrainerState {
let mut hs = svc(
78,
"Heroic Strike",
TrainerServiceCategory::Available,
26,
"Arms",
);
hs.level_req = 1;
let mut cl = svc(284, "Cleave", TrainerServiceCategory::Used, 26, "Arms");
cl.level_req = 20;
let br = svc(
285,
"Bloodrage",
TrainerServiceCategory::Unavailable,
256,
"Fury",
);
TrainerState {
greeting: "What would you like to learn?".into(),
trainer_type: 0,
groups: Vec::new(),
services: vec![hs, cl, br],
}
}
/// Every visible row as `(name, serviceType)` — the shape the ported XML actually renders.
fn visible(s: &mut UiScript) -> Vec<(String, String)> {
let n = s.eval::<i64>("return GetNumTrainerServices()").unwrap();
(1..=n)
.map(|i| {
s.eval::<(String, String)>(&format!(
"local n,_,t = GetTrainerServiceInfo({i}) return n,t"
))
.unwrap()
})
.collect()
}
#[test]
fn tree_interleaves_headers_and_ordered_services() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
assert!(s
.eval::<bool>("return GetTrainerServiceInfo(1) == nil")
.unwrap());
s.set_trainer(Some(trainer()));
// 2 headers + 3 services = 5 visible rows.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Row 1 is the "Arms" header: name, nil subtext, "header", isExpanded=1.
let (hn, hsub, ht, hexp) = s
.eval::<(String, Option<String>, String, Option<i64>)>(
"local n,s,t,e = GetTrainerServiceInfo(1) return n,s,t,e",
)
.unwrap();
assert_eq!((hn.as_str(), ht.as_str()), ("Arms", "header"));
assert_eq!((hsub, hexp), (None, Some(1)));
// Rows 2..5: services (name/state) then the Fury header then its service — the sorted tree.
let info = |s: &mut UiScript, i: i64| {
s.eval::<(String, String)>(&format!(
"local n,_,t = GetTrainerServiceInfo({i}) return n,t"
))
.unwrap()
};
assert_eq!(
info(&mut s, 2),
("Heroic Strike".into(), "available".into())
);
assert_eq!(info(&mut s, 3), ("Cleave".into(), "used".into()));
assert_eq!(info(&mut s, 4), ("Fury".into(), "header".into()));
assert_eq!(info(&mut s, 5), ("Bloodrage".into(), "unavailable".into()));
}
#[test]
fn service_getters_read_the_row_at_a_visible_index() {
let mut s = UiScript::new().unwrap();
let mut t = trainer();
// Heroic Strike (services[0], the row at index 2) carries a full gate set.
t.services[0].skill_req = Some(TrainerSkillReq {
name: "Blacksmithing".into(),
rank: 100,
met: true,
});
t.services[0].ability_reqs = vec![TrainerAbilityReq {
name: "Apprentice".into(),
met: false,
}];
s.set_trainer(Some(t));
// Cost/level/desc/reqs all resolve at the SERVICE index 2 (not the header at 1).
assert_eq!(
s.eval::<(i64, i64, i64)>("return GetTrainerServiceCost(2)")
.unwrap(),
(100, 0, 0)
);
assert_eq!(
s.eval::<i64>("return GetTrainerServiceLevelReq(2)")
.unwrap(),
1
);
assert_eq!(
s.eval::<String>("return GetTrainerServiceDescription(2)")
.unwrap(),
"Teaches Heroic Strike."
);
let (skill, rank, has) = s
.eval::<(String, i64, i64)>("return GetTrainerServiceSkillReq(2)")
.unwrap();
assert_eq!((skill.as_str(), rank, has), ("Blacksmithing", 100, 1));
assert!(s
.eval::<bool>(
"local n,h = GetTrainerServiceAbilityReq(2,1) return n=='Apprentice' and h==nil"
)
.unwrap());
assert!(s
.eval::<bool>("local l,p = IsTrainerServiceLearnSpell(2) return l==1 and p==nil")
.unwrap());
// A HEADER row (index 1) has no service data — the getters no-op to defaults.
assert_eq!(
s.eval::<i64>("return GetTrainerServiceLevelReq(1)")
.unwrap(),
0
);
assert!(s
.eval::<bool>("return GetTrainerServiceIcon(1) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetTrainerServiceSkillReq(1) == nil")
.unwrap());
}
/// The state filter takes a group's **header with it** once it hides the group's last service — the
/// finalizer's `[+0x1c]` hide has no header-row exemption (`0x4d8528`/`0x4d8535`, decision 1124).
/// This asserted the opposite until 1124: benilla rendered bare headers over empty groups, and an
/// all-boxes-off filter left a window full of headings and nothing else.
#[test]
fn state_filter_takes_a_groups_header_with_its_last_service() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Hide "used": Cleave drops (5 → 4), and BOTH headers stay — Arms still has Heroic Strike.
s.run("SetTrainerServiceTypeFilter('used', 0)").unwrap();
assert_eq!(
visible(&mut s)
.into_iter()
.map(|(_, t)| t)
.collect::<Vec<_>>(),
["header", "available", "header", "unavailable"]
);
// Now hide "available" too: Arms loses its last row, so the ARMS HEADER GOES WITH IT — only
// Fury's header and its unavailable service remain.
s.run("SetTrainerServiceTypeFilter('available', 0)")
.unwrap();
assert_eq!(
visible(&mut s),
[
("Fury".to_string(), "header".to_string()),
("Bloodrage".to_string(), "unavailable".to_string()),
]
);
// Every box off renders NOTHING — not a list of bare headings.
s.run("SetTrainerServiceTypeFilter('unavailable', 0)")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
assert!(s
.eval::<bool>("return GetTrainerServiceInfo(1) == nil")
.unwrap());
}
/// Collapse is the *asymmetric* case, and deliberately so: it hides a group's services but keeps the
/// header — a different field (`hdr[+0x20]`) tested three instructions after the filter's, with a
/// header-row exemption the filter's lacks (`0x4d853d`). Without it a collapsed group could never be
/// re-expanded, having no header left to click.
#[test]
fn collapse_keeps_the_header_the_filter_would_remove() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("CollapseTrainerSkillLine(1)").unwrap(); // fold Arms — both its services hide
assert_eq!(
visible(&mut s),
[
("Arms".to_string(), "header".to_string()),
("Fury".to_string(), "header".to_string()),
("Bloodrage".to_string(), "unavailable".to_string()),
]
);
// Collapse-all keeps every header; the filter's empty case keeps none.
s.run("CollapseTrainerSkillLine(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
}
#[test]
fn collapse_by_header_index_and_collapse_all() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
// Collapse the Arms group by its header's display index (1): its two services vanish, the
// header stays and now reports isExpanded=nil. 5 → 3 (H:Arms, H:Fury, Bloodrage).
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 3);
assert!(s
.eval::<bool>("local _,_,t,e = GetTrainerServiceInfo(1) return t=='header' and e==nil")
.unwrap());
assert_eq!(
s.eval::<String>("local n = GetTrainerServiceInfo(2) return n")
.unwrap(),
"Fury",
"Arms' services are folded; Fury's header is now row 2"
);
// Expand it back by the same header index.
s.run("ExpandTrainerSkillLine(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Collapse-all (id 0): both groups fold → just the two headers.
s.run("CollapseTrainerSkillLine(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
// Expand-all (id 0): back to the full tree.
s.run("ExpandTrainerSkillLine(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
#[test]
fn collapse_survives_a_content_update_and_resets_on_close() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("CollapseTrainerSkillLine(1)").unwrap(); // fold Arms
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 3);
// A re-list (same skill lines) keeps the fold — a buy re-lists and the tree shouldn't jump open.
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
3,
"Arms stays collapsed across a content update"
);
// Close → the collapse set resets; a re-open is fully expanded.
s.set_trainer(None);
s.set_trainer(Some(trainer()));
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
#[test]
fn buy_queues_the_selected_services_spell_id_headers_no_op() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
// Row 5 is Bloodrage (spell 285). Buying it queues its spell id, not its index.
s.run("BuyTrainerService(5)").unwrap();
assert_eq!(s.take_trainer_buys(), vec![285]);
assert!(s.take_trainer_buys().is_empty(), "drained");
// Buying a HEADER row (index 1) queues nothing.
s.run("BuyTrainerService(1)").unwrap();
assert!(s.take_trainer_buys().is_empty(), "a header is not buyable");
}
#[test]
fn selection_and_close_intents() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
s.run("SelectTrainerService(2)").unwrap();
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
2
);
s.run("SelectTrainerService(9)").unwrap(); // OOB (past the 5 rows) clears
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
assert!(!s.take_trainer_close());
s.run("CloseTrainer()").unwrap();
assert!(s.take_trainer_close());
assert!(!s.take_trainer_close(), "drained");
}
/// The two whole-trainer predicates fork on the wire `trainer_type` — `IsTradeskillTrainer` on
/// `== 2` (`0x4d8ea0`), `IsTalentTrainer` on `== 1` (`0x4d8ed0`) — so at most one is ever true, and
/// at a class trainer neither is.
#[test]
fn tradeskill_and_talent_flags() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == nil")
.unwrap());
// A class trainer: neither predicate.
s.set_trainer(Some(trainer()));
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == nil and IsTalentTrainer() == nil")
.unwrap());
// A tradeskill trainer — one "Recipes" group, so its one service is the row at index 2.
let mut recipe = svc(
2743,
"Copper Chain Pants",
TrainerServiceCategory::Unavailable,
2,
"Recipes",
);
recipe.is_trade_skill = true;
s.set_trainer(Some(TrainerState {
greeting: String::new(),
trainer_type: 2,
groups: Vec::new(),
services: vec![recipe],
}));
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == 1 and IsTalentTrainer() == nil")
.unwrap());
assert!(s
.eval::<bool>("return IsTrainerServiceTradeSkill(2) == 1")
.unwrap());
// A mount trainer — the "talent" predicate, which used to be a hardcoded nil.
s.set_trainer(Some(TrainerState {
greeting: String::new(),
trainer_type: 1,
groups: Vec::new(),
services: vec![svc(
33,
"Riding",
TrainerServiceCategory::Available,
762,
"Riding",
)],
}));
assert!(s
.eval::<bool>("return IsTalentTrainer() == 1 and IsTradeskillTrainer() == nil")
.unwrap());
}
#[test]
fn unresolved_skill_line_is_dropped() {
let mut s = UiScript::new().unwrap();
let mut t = trainer();
// A service whose skill line didn't resolve (0) is dropped from the tree entirely.
t.services.push(svc(
999,
"Orphan Spell",
TrainerServiceCategory::Available,
0,
"",
));
s.set_trainer(Some(t));
// Still 5 rows — the orphan contributes neither a header nor a service row.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
#[test]
fn clearing_empties_and_resets_selection() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("SelectTrainerService(2)").unwrap();
s.set_trainer(None);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
}
/// **The tradeskill order, pinned against wow-re's emulated run of the real finalizer** (decision
/// 1124). The fixture is creature 957 "Dane Lindgren" — the blacksmithing trainer in the director's
/// report — with his 19 real `npc_trainer` rows: 18 recipes on skill line 164 with ascending
/// `reqSkillValue`, plus the profession-learn service 2020 ("Apprentice Blacksmith", `reqLevel 5`,
/// no skill gate). wow-re fed exactly this set through the builder `0x4d7560` + finalizer `0x4d8410`
/// with `0xb73a08 = 2` and the real `qsort`/collator, and got the 21 rows asserted below.
///
/// Two things this pins that benilla had wrong, and one it never had:
/// - the learn row is **first among services**, under its own `Development Skills` header — placed
/// by the `SKILL_STEP` group partition, *not* by any level key;
/// - `reqLevel` is **inert** at type 2 (the class comparator's first key, which used to sink 2020 to
/// the bottom of the list);
/// - recipes ascend by `reqSkillValue`, ties broken by name.
#[test]
fn tradeskill_trainer_matches_the_emulated_reference_order() {
// (wire spell, name, reqSkillValue) — the recipes, in wire order (ascending here, but the
// fixture is shuffled below to prove the order is the comparator's and not the input's).
let recipes: &[(u32, &str, u32)] = &[
(2743, "Copper Chain Pants", 1),
(2754, "Copper Mace", 15),
(2755, "Copper Axe", 20),
(3340, "Copper Chain Boots", 20),
(2756, "Copper Shortsword", 25),
(3341, "Rough Grinding Stone", 25),
(9984, "Copper Claymore", 30),
(8881, "Copper Dagger", 30),
(2744, "Copper Battle Axe", 35),
(3299, "Copper Chain Belt", 35),
(3342, "Runed Copper Gauntlets", 40),
(3343, "Runed Copper Pants", 45),
(2746, "Coarse Sharpening Stone", 65),
(7409, "Coarse Weightstone", 65),
(3118, "Heavy Copper Maul", 65),
(3300, "Runed Copper Belt", 70),
(2747, "Thick War Axe", 70),
(3344, "Coarse Grinding Stone", 75),
];
let mut services: Vec<TrainerService> = recipes
.iter()
.map(|&(id, name, req)| {
// Group key 2 = TRADESKILL_SERVICE_LEARN: no SKILL_STEP effect on the wire spell.
let mut s = svc(id, name, TrainerServiceCategory::Unavailable, 2, "Recipes");
s.subtext = None;
s.level_req = 0;
s.skill_req = Some(TrainerSkillReq {
name: "Blacksmithing".into(),
rank: req,
met: false,
});
s
})
.collect();
services.reverse(); // wire order is not display order — and must not be
// Group key 1 = TRADESKILL_SERVICE_STEP: spell 2020 carries Effect 44 SKILL_STEP.
let mut learn = svc(
2020,
"Apprentice Blacksmith",
TrainerServiceCategory::Available,
1,
"Development Skills",
);
learn.subtext = None;
learn.level_req = 5; // the key that used to decide everything, and decides nothing here
learn.prof_first_rank = true;
services.insert(services.len() / 2, learn);
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(TrainerState {
greeting: "Care to learn how to turn the ore that you find into weapons?".into(),
trainer_type: 2,
groups: Vec::new(),
services,
}));
let rows = visible(&mut s);
let expected: Vec<(&str, &str)> = vec![
("Development Skills", "header"),
("Apprentice Blacksmith", "available"),
("Recipes", "header"),
("Copper Chain Pants", "unavailable"),
("Copper Mace", "unavailable"),
("Copper Axe", "unavailable"),
("Copper Chain Boots", "unavailable"),
("Copper Shortsword", "unavailable"),
("Rough Grinding Stone", "unavailable"),
("Copper Claymore", "unavailable"),
("Copper Dagger", "unavailable"),
("Copper Battle Axe", "unavailable"),
("Copper Chain Belt", "unavailable"),
("Runed Copper Gauntlets", "unavailable"),
("Runed Copper Pants", "unavailable"),
("Coarse Sharpening Stone", "unavailable"),
("Coarse Weightstone", "unavailable"),
("Heavy Copper Maul", "unavailable"),
("Runed Copper Belt", "unavailable"),
("Thick War Axe", "unavailable"),
("Coarse Grinding Stone", "unavailable"),
];
let got: Vec<(&str, &str)> = rows.iter().map(|(n, t)| (n.as_str(), t.as_str())).collect();
assert_eq!(got, expected);
}
/// **The mount (type 1, the client's "talent") order, pinned against wow-re's emulated run**
/// (decision 1124): the already-known services fold into the `-1` "My Talents" group, which the
/// header comparator puts **first**, ahead of the name-ordered skill-line headers; within a group
/// the state byte sorts available → unavailable → used, then the name.
#[test]
fn mount_trainer_folds_known_services_into_my_talents() {
let known = |id: u32, name: &str| {
svc(
id,
name,
TrainerServiceCategory::Used,
TRAINER_GROUP_KNOWN,
"My Talents",
)
};
let services = vec![
svc(
33,
"Riding",
TrainerServiceCategory::Available,
762,
"Riding",
),
known(6648, "Tiger Riding"),
svc(
824,
"Horse Riding",
TrainerServiceCategory::Unavailable,
148,
"Horse Riding",
),
svc(
8394,
"Ram Riding",
TrainerServiceCategory::Available,
152,
"Ram Riding",
),
];
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(TrainerState {
greeting: "Ready to ride?".into(),
trainer_type: 1,
groups: Vec::new(),
services,
}));
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
assert_eq!(
got,
[
"My Talents",
"Tiger Riding",
"Horse Riding",
"Horse Riding",
"Ram Riding",
"Ram Riding",
"Riding",
"Riding",
],
"the -1 group leads; every other header is name-ordered"
);
// And the type predicates fork on the same dword the sort does.
assert!(s.eval::<bool>("return IsTalentTrainer() == 1").unwrap());
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == nil")
.unwrap());
}
/// The **state byte is a sort key at type 1 and only there** — the same three services, ordered by
/// state (available → unavailable → used) inside one group, where the class cascade would order
/// them by level and the tradeskill one by skill value.
#[test]
fn talent_order_sorts_on_state_within_a_group() {
let one = |id: u32, name: &str, cat: TrainerServiceCategory| svc(id, name, cat, 762, "Riding");
let services = vec![
one(3, "Cee", TrainerServiceCategory::Available),
one(1, "Aye", TrainerServiceCategory::Used),
one(2, "Bee", TrainerServiceCategory::Unavailable),
];
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(TrainerState {
greeting: String::new(),
trainer_type: 1,
groups: Vec::new(),
services: services.clone(),
}));
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
assert_eq!(got, ["Riding", "Cee", "Bee", "Aye"]);
// The same three at a CLASS trainer sort by name instead (equal levels, no skill gates) — the
// proof that the comparator really is selected by the type and not shared.
s.set_trainer(Some(TrainerState {
greeting: String::new(),
trainer_type: 0,
groups: Vec::new(),
services,
}));
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
assert_eq!(got, ["Riding", "Aye", "Bee", "Cee"]);
}