the foam stops at the waterline, and skill bonuses show the right halves

- the foam still washed past the waterline onto the sand after the shore fix: the slope-scaled depth bias ported from the reference's `glPolygonOffset(-4.0, …)` pulls toward the eye as the square of view distance, and what it spends is the wet-cell skirt — whole cells are the unit of liquid geometry, so a shoreline carries liquid-lattice triangles over dry sand (23 cells, up to 7.1 yd inland at the reported beach) held back by nothing but the terrain having drawn first; the tie it would settle does not exist here (our foam patch IS the liquid surface's own triangles — same cells, same winding, identity transform — so the depths agree exactly), and −4.0 is a hardcoded immediate every consumer of that state gets, not the foam lane's number; the slope term is disarmed with an `== 0.0` assert, reproduced three ways at the director's pin (slope 0 stops dead at the waterline, 4.0 washes onto the sand, 64.0 does it wide)
- weapon and defense skill bonuses showed the halves the wrong way round: the reader every skill-shaped binding goes through (`0x5ea460`) writes `value + permBonus` and `tempBonus`; ours returned `(value, temp + perm)` — right total, wrong split, which is why no fixture caught it — so a 300 weapon skill with a +5 talent and a +10 aura read 300 (+15) where the reference prints 305 (+10), on the sheet's Attack row and in the Defense tooltip's `(base +x)`; `UnitRangedAttack` is also pinned as a direct call gated on the PLAYER typemask alone, so a pet gets `(0, 0)`
- instruments: `water_here` also reports WET cells the ground climbs out of (the skirt a depth bias spends), the foam wake can be laid along a bank (`WOW_WFX_HEAD`) since no shoreline in the game is axis-aligned, `fxview` can stand its subject at any world point (`WOW_FX_AT`) so a blob shadow can be photographed on any terrain, and `WOW_MAP` refuses a name instead of silently falling through to Eastern Kingdoms — `WOW_MAP=Kalimdor` had produced a subject floating in empty ocean that read as a renderer bug

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:03:14 +07:00
co-authored by Claude Fable 5.1
parent c34ea85c1b
commit 9d2ffd71d8
10 changed files with 409 additions and 144 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ pub(crate) fn drive_fx_view(
return;
};
let root = *state.root.get_or_insert_with(|| {
let mut pos = benilla_assets::coords::wow_to_bevy(FXVIEW_POS);
let mut pos = benilla_assets::coords::wow_to_bevy(req.at.unwrap_or(FXVIEW_POS));
// `WOW_FX_DISPLAY`: the UNIT lane. Spawn the live component set a streamed creature gets
// (`net::apply`'s, the same one the `vplates` fixture's wolf uses) and let the ordinary
// unit pipeline build the model — so what this shoots is a creature, not a model, and
+36 -17
View File
@@ -227,6 +227,9 @@ fn glue_screen() -> Option<GlueScreen> {
/// surface decals, `crate::ground_fx`; default 0 = the mid-air point), `WOW_FX_HOLD` (=1 keeps
/// the fixture alive past one sequence pass — previewing a persistent HOLD kit's steady state;
/// default 0 = the game's discrete-instance reap at one pass, then the pool drains),
/// `WOW_FX_AT` (`x,y,z` — plant the fixture at a real world point instead of the Northshire
/// hillside, with `WOW_MAP` picking the map and `WOW_FX_MINUTE` the clock; the ground-decal lanes
/// need real terrain under the subject),
/// `WOW_FX_UP` (yards to raise the fixture above its resolved seat — for models authored below
/// their anchor, whose opening frames the terrain otherwise swallows; default 0).
#[derive(Resource)]
@@ -264,6 +267,14 @@ pub(crate) struct FxViewRequest {
pub(crate) ground: bool,
/// See `WOW_FX_HOLD` above.
pub(crate) hold: bool,
/// `WOW_FX_AT=x,y,z` — plant the fixture at this raw WoW point instead of [`FXVIEW_POS`]
/// (with `WOW_MAP` picking the map, like `vista` and `waterfx`). The ground-decal lanes —
/// blob shadow, footprints, the selection ring — only exist *on* terrain, and which terrain
/// decides whether they exist at all (footprints gate on the surface's `TerrainType.Flags & 1`,
/// i.e. snow and sand) and how hard the coplanar tie is (a steep slope seen at a grazing
/// angle is the whole difficulty). A fixture nailed to one Northshire hillside can photograph
/// none of that.
pub(crate) at: Option<[f32; 3]>,
/// `WOW_FX_UP` — raise the fixture this many yards above its resolved seat (default 0).
/// The escape hatch for models authored BELOW their anchor (Arcane Intellect's star cluster
/// starts 1.6 yd under its attach point): at the default seat the terrain swallows the
@@ -586,6 +597,10 @@ impl Plugin for CapturePlugin {
None
} else {
Some(if name == "fxview" {
let at = std::env::var("WOW_FX_AT").ok().and_then(|v| {
let c: Vec<f32> = v.split(',').filter_map(|p| p.trim().parse().ok()).collect();
(c.len() == 3).then(|| [c[0], c[1], c[2]])
});
let display: Option<u32> = std::env::var("WOW_FX_DISPLAY")
.ok()
.and_then(|v| v.trim().parse().ok());
@@ -627,21 +642,28 @@ impl Plugin for CapturePlugin {
ground: knob("WOW_FX_GROUND", 0.0) > 0.5,
hold: knob("WOW_FX_HOLD", 0.0) > 0.5,
up: knob("WOW_FX_UP", 0.0),
// `WOW_FX_AT=x,y,z` plants the fixture anywhere in the real world, with
// `WOW_MAP` picking the map — the same pair `vista` and `waterfx` take. The
// default hillside can photograph an effect but not a ground decal: which
// surface is under the subject decides whether footprints exist at all, and
// how steeply it falls away decides how hard the coplanar tie is.
at,
})
.init_resource::<FxViewState>();
Scenario {
name: "fxview",
map: scenarios::MAP_AZEROTH, // the fixture spawns over the Northshire slope
eye: GROUND_EYE, // overridden per frame by the orbit in `pin_scene`
look: FXVIEW_POS,
minute: 720,
map: None,
eye: GROUND_EYE, // overridden per frame by the orbit in `pin_scene`
look: at.unwrap_or(FXVIEW_POS),
minute: knob("WOW_FX_MINUTE", 720.0) as u32,
ui: None,
}
} else if name == "waterfx" {
// The water-foam viewer (see `capture::waterfx`): a wading unit over a synthetic
// wet lattice — or, with WOW_WFX_AT=x,y,z, in the real streamed liquid at that pin
// — framed by a fixed orbit around the rig centre. Knobs: WOW_WFX_MODE
// (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_AGE (s), WOW_WFX_DEPTH (yd),
// (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_HEAD (deg), WOW_WFX_AGE (s),
// WOW_WFX_DEPTH (yd),
// camera WOW_WFX_AZ/EL/DIST. Not a golden scenario.
let knob = |k: &str, d: f32| {
std::env::var(k)
@@ -676,6 +698,7 @@ impl Plugin for CapturePlugin {
app.insert_resource(waterfx::WaterFxView {
mode,
speed: knob("WOW_WFX_SPEED", 4.0),
heading: knob("WOW_WFX_HEAD", 0.0).to_radians(),
age: knob("WOW_WFX_AGE", 1.5),
center,
depth: knob("WOW_WFX_DEPTH", 0.5),
@@ -686,10 +709,7 @@ impl Plugin for CapturePlugin {
name: "waterfx",
// The synthetic lattice sits over the Northshire slope; a live rig
// (`WOW_WFX_AT`) goes wherever its pin is, so its map is a knob like `vista`'s.
map: std::env::var("WOW_MAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(scenarios::MAP_AZEROTH),
map: None,
eye,
look: center,
minute: 720,
@@ -733,10 +753,7 @@ impl Plugin for CapturePlugin {
name: "vista",
// The arbitrary-viewpoint instrument goes anywhere, so its map is a knob: a
// horizon report from Kalimdor is `WOW_MAP=1` (a `Map.dbc` id).
map: std::env::var("WOW_MAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(scenarios::MAP_AZEROTH),
map: None,
eye,
look: [
eye[0] + d * pitch.cos() * face.cos(),
@@ -772,10 +789,12 @@ impl Plugin for CapturePlugin {
// terrain/WDL streamers and per-map lighting all key off it. Raw WoW coords repeat on every
// continent, so a scenario that could not say which map it meant would stream the wrong
// world's tiles and photograph a void (Felwood's tile `33_24` exists in Azeroth, empty).
// Written back for `vista` too, which is where the value came from — a harmless no-op that
// keeps one path for "which map is this run on" (decision 0743). A glue screen has no map.
if let Some(s) = &scenario {
std::env::set_var("WOW_MAP", s.map.to_string());
// The arbitrary-viewpoint instruments (`vista`, `waterfx`, `fxview`) carry `map: None`
// instead: their map IS the knob, so writing it back would be a round trip through a second
// parser — and a second parser is how `WOW_MAP=Kalimdor` came to photograph Azeroth in
// silence. `world_map` is the one reader (decision 0743). A glue screen has no map.
if let Some(m) = scenario.as_ref().and_then(|s| s.map) {
std::env::set_var("WOW_MAP", m.to_string());
}
let shot_name = scenario.map(|s| s.name).or(glue.map(|g| g.name)).unwrap();
let out = std::env::var("WOW_CAPTURE_OUT")
+73 -69
View File
@@ -11,7 +11,11 @@ pub(super) struct Scenario {
/// Felwood spot's tile (`33_24`) exists in Azeroth too, empty — so a scenario that could not
/// name its map silently photographed the wrong world. The harness seeds
/// [`benilla_world::world_map::CurrentMap`] from this before streaming starts (decision 0743).
pub(super) map: u32,
/// Which `Map.dbc` map this scenario photographs. `None` = **the `$WOW_MAP` knob decides** —
/// the arbitrary-viewpoint instruments (`vista`, `waterfx`, `fxview`) go anywhere, so they have
/// no map of their own and must not write one back over the knob. A named golden scenario
/// always says, because raw WoW coords repeat on every continent.
pub(super) map: Option<u32>,
/// Camera eye, raw WoW coords `(x, y, z)`.
pub(super) eye: [f32; 3],
/// Camera look-at target, raw WoW coords.
@@ -304,7 +308,7 @@ pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[
pub(super) const SCENARIOS: &[Scenario] = &[
Scenario {
name: "water-noon",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: WATER_EYE,
look: WATER_LOOK,
minute: 720,
@@ -312,7 +316,7 @@ pub(super) const SCENARIOS: &[Scenario] = &[
},
Scenario {
name: "water-night",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: WATER_EYE,
look: WATER_LOOK,
minute: 0,
@@ -320,7 +324,7 @@ pub(super) const SCENARIOS: &[Scenario] = &[
},
Scenario {
name: "felwood-noon",
map: MAP_KALIMDOR,
map: Some(MAP_KALIMDOR),
eye: FELWOOD_EYE,
look: FELWOOD_LOOK,
minute: 720,
@@ -328,7 +332,7 @@ pub(super) const SCENARIOS: &[Scenario] = &[
},
Scenario {
name: "felwood-night",
map: MAP_KALIMDOR,
map: Some(MAP_KALIMDOR),
eye: FELWOOD_EYE,
look: FELWOOD_LOOK,
minute: 0,
@@ -444,7 +448,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// shape 0688 wired and nothing has ever photographed it.
Scenario {
name: "tram-undersea",
map: MAP_DEEPRUN_TRAM,
map: Some(MAP_DEEPRUN_TRAM),
eye: TRAM_EYE,
look: TRAM_LOOK,
minute: 720,
@@ -458,7 +462,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// that a half-clouded zone hides. Kept as the standing reproducer for the cloud palette.
Scenario {
name: "tainted-scar-noon",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: SCAR_EYE,
look: SCAR_LOOK,
minute: 720,
@@ -473,7 +477,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// of one build) — same coplanar-batch draw-order defect as `chest-indoor-*` below (0815 Open).
Scenario {
name: "overlook-noon",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: OVERLOOK_EYE,
look: OVERLOOK_LOOK,
minute: 720,
@@ -481,7 +485,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "overlook-night",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: OVERLOOK_EYE,
look: OVERLOOK_LOOK,
minute: 0,
@@ -491,7 +495,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `inn-noon` — the room is lit by hearth and candles, so the clock barely reaches it).
Scenario {
name: "inn-noon",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: INN_EYE,
look: INN_LOOK,
minute: 720,
@@ -502,7 +506,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// slides off the rails and the shot degrades into an ordinary fence.
Scenario {
name: "fence-shadowline-day",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: FENCE_EYE,
look: FENCE_LOOK,
minute: 624,
@@ -516,7 +520,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// about the light curves, and a baseline is the right place to hold the answer (0749 addendum).
Scenario {
name: "fence-shadowline-night",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: FENCE_EYE,
look: FENCE_LOOK,
minute: 0,
@@ -524,7 +528,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "creature-sun-front",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9496.46, 59.54, 58.28],
look: [-9500.00, 56.00, 57.28],
minute: 720,
@@ -535,7 +539,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "creature-sun-rear",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9503.54, 52.46, 58.28],
look: [-9500.00, 56.00, 57.28],
minute: 720,
@@ -546,7 +550,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "creature-shade-front",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9496.46, 47.54, 57.75],
look: [-9500.00, 44.00, 56.75],
minute: 720,
@@ -557,7 +561,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "creature-shade-rear",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9503.54, 40.46, 57.75],
look: [-9500.00, 44.00, 56.75],
minute: 720,
@@ -568,7 +572,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "creature-indoor-front",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9466.57, 34.73, 59.70],
look: [-9469.40, 31.90, 58.70],
minute: 720,
@@ -579,7 +583,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "creature-indoor-rear",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9472.23, 29.07, 59.70],
look: [-9469.40, 31.90, 58.70],
minute: 720,
@@ -590,7 +594,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "chest-sun-front",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9496.82, 59.18, 57.98],
look: [-9500.00, 56.00, 56.93],
minute: 720,
@@ -601,7 +605,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "chest-sun-rear",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9503.18, 52.82, 57.98],
look: [-9500.00, 56.00, 56.93],
minute: 720,
@@ -612,7 +616,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "chest-shade-front",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9496.82, 47.18, 57.45],
look: [-9500.00, 44.00, 56.40],
minute: 720,
@@ -623,7 +627,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "chest-shade-rear",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9503.18, 40.82, 57.45],
look: [-9500.00, 44.00, 56.40],
minute: 720,
@@ -641,7 +645,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// they are deterministic.
Scenario {
name: "chest-indoor-front",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9466.85, 34.45, 59.40],
look: [-9469.40, 31.90, 58.35],
minute: 720,
@@ -652,7 +656,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "chest-indoor-rear",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9471.95, 29.35, 59.40],
look: [-9469.40, 31.90, 58.35],
minute: 720,
@@ -663,7 +667,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "house-north",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: HOUSE_EYE,
look: [-9389.1, 71.2, 58.0],
minute: 720,
@@ -671,7 +675,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "house-south",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: HOUSE_EYE,
look: [-9489.1, 71.2, 58.0],
minute: 720,
@@ -679,7 +683,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "house-west",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: HOUSE_EYE,
look: [-9439.1, 121.2, 58.0],
minute: 720,
@@ -687,7 +691,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "house-east",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: HOUSE_EYE,
look: [-9439.1, 21.2, 58.0],
minute: 720,
@@ -700,7 +704,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// every diff unseen.
Scenario {
name: "house-north-midnight",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: HOUSE_EYE,
look: [-9389.1, 71.2, 58.0],
minute: 0,
@@ -716,7 +720,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// client does the same — the audit's "faithful-cull residue"), which vanishes the room.
Scenario {
name: "inn-interior",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-9463.3, 4.4, 58.8],
look: [-9462.1, -5.6, 58.5],
minute: 720,
@@ -727,7 +731,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// stays as the warm-light/fog fixture.
Scenario {
name: "northshire-dusk",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 1170, // 19:30 — warm dusk light + fog
@@ -735,7 +739,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "northshire-sky-noon",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: SKY_EYE,
look: SKY_LOOK,
minute: 720, // day sky-dome gradient + fog horizon
@@ -743,7 +747,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "northshire-sky-dusk",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: SKY_EYE,
look: SKY_LOOK,
minute: 1170, // dusk dome warp + low sun + stars emerging
@@ -757,7 +761,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// was cut along a giant faceted circle).
Scenario {
name: "northshire-sun-flare",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: SKY_EYE,
look: [-8797.0, 23.0, 264.0], // eye + 300·(elev 30°, az 45°) — the sun's spot at 17:30
minute: 1050,
@@ -772,7 +776,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// unit tests (`flare_ray_*`) and the sun fixtures.
Scenario {
name: "northshire-moonrise",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: SKY_EYE,
look: [-8775.0, 45.0, 190.0], // eye + 300·(elev 15°, az 45°) — the moon's spot at 22:44
minute: 1364,
@@ -784,7 +788,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// the moonrise fixture above proves its absence early, this one its presence at depth of night.
Scenario {
name: "northshire-moon-halo",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: SKY_EYE,
look: [-8858.0, -38.0, 358.0], // eye + 300·(elev 55°, az 45°) — the moon's spot at 00:00
minute: 0,
@@ -796,7 +800,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// load is resident here; Northshire scenes never exercise that scale.
Scenario {
name: "stormwind",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 96.0],
look: [-8809.1, 672.3, 94.0],
minute: 720,
@@ -808,7 +812,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// director look pass, so "looks like shit" gets caught in-loop, not on the director's screen.
Scenario {
name: "ui-merchant",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -816,7 +820,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "ui-gossip",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -824,7 +828,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "ui-bank",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -832,7 +836,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "ui-quest",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -840,7 +844,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "ui-questgreeting",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -848,7 +852,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "ui-questlog",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -856,7 +860,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "ui-loot",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -864,7 +868,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
Scenario {
name: "ui-bag",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -874,7 +878,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-tooltip`.
Scenario {
name: "ui-tooltip",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -884,7 +888,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// seeded hostile wolf. Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-tooltip-world`.
Scenario {
name: "ui-tooltip-world",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -894,7 +898,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-char`.
Scenario {
name: "ui-char",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -905,7 +909,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-unitframes`.
Scenario {
name: "ui-unitframes",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -917,7 +921,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-combopoints`.
Scenario {
name: "ui-combopoints",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -927,7 +931,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-partyinvite`.
Scenario {
name: "ui-partyinvite",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -940,7 +944,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-actionbar`.
Scenario {
name: "ui-actionbar",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -952,7 +956,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE=vplates` (main.rs sizes this window 1024×768 — the 1:1 gx window).
Scenario {
name: "vplates",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: [-8956.5, -137.5, 85.6],
look: [-8949.95, -132.49, 84.8],
minute: 720,
@@ -963,7 +967,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-worldmap`.
Scenario {
name: "ui-worldmap",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -972,7 +976,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// The spellbook over a seeded mage book. Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-spellbook`.
Scenario {
name: "ui-spellbook",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -982,7 +986,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-macro`.
Scenario {
name: "ui-macro",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -992,7 +996,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-macro-popup`.
Scenario {
name: "ui-macro-popup",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1002,7 +1006,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-chatedit`.
Scenario {
name: "ui-chatedit",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1013,7 +1017,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-chat-tabhover`.
Scenario {
name: "ui-chat-tabhover",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1023,7 +1027,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-social`.
Scenario {
name: "ui-social",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1033,7 +1037,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options`.
Scenario {
name: "ui-options",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1043,7 +1047,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-audio`.
Scenario {
name: "ui-options-audio",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1053,7 +1057,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-graphics`.
Scenario {
name: "ui-options-graphics",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1063,7 +1067,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-chat`.
Scenario {
name: "ui-options-chat",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1073,7 +1077,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-color-picker`.
Scenario {
name: "ui-color-picker",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1083,7 +1087,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-dropdown`.
Scenario {
name: "ui-options-dropdown",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1093,7 +1097,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-keybindings`.
Scenario {
name: "ui-keybindings",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1103,7 +1107,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-search`.
Scenario {
name: "ui-options-search",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
@@ -1114,7 +1118,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// out in the water. Run with `WOW_CAPTURE=name-water`.
Scenario {
name: "name-water",
map: MAP_AZEROTH,
map: Some(MAP_AZEROTH),
eye: WATER_EYE,
look: WATER_LOOK,
minute: 720,
+19 -7
View File
@@ -5,6 +5,7 @@
//! shipped systems; the rig only supplies a unit, water, and motion.
//!
//! `WOW_CAPTURE=waterfx` + knobs: `WOW_WFX_MODE` (`ring`|`wake`|`turn`), `WOW_WFX_SPEED` (yd/s),
//! `WOW_WFX_HEAD` (wake heading, WoW degrees, 0 = +X — point it along a real bank),
//! `WOW_WFX_AGE` (s of motion before the shot), `WOW_WFX_DEPTH` (yd below the surface; > ~0.8
//! also exercises the step-in one-shot), camera `WOW_WFX_AZ`/`EL`/`DIST`. Not a golden scenario —
//! output depends on the knobs.
@@ -41,8 +42,12 @@ pub(crate) enum WfxMode {
#[derive(Resource)]
pub(crate) struct WaterFxView {
pub(crate) mode: WfxMode,
/// Translation speed for [`WfxMode::Wake`] (yd/s, WoW +X).
/// Translation speed for [`WfxMode::Wake`] (yd/s, along [`Self::heading`]).
pub(crate) speed: f32,
/// Which way a [`WfxMode::Wake`] walks, as a WoW yaw in radians (0 = +X). No shoreline in the
/// game is axis-aligned, so a wake that can only run along +X can never be laid **along** a
/// bank — the one arrangement where a trail's whole length ties against the waterline at once.
pub(crate) heading: f32,
/// Seconds the unit moves/stands before the shot (foam accumulates).
pub(crate) age: f32,
/// Rig centre in raw WoW coords `(x, y, surface_z)` — where the unit ends up at shot time.
@@ -148,10 +153,14 @@ pub(crate) fn spawn(
/// subsystem never gives it a fallback cube over the foam). A wake starts far enough back that its
/// trail ENDS at the rig centre after `age` seconds.
fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, surf: f32) {
let start_x = if view.mode == WfxMode::Wake {
cx - view.speed * view.age
let (start_x, start_y) = if view.mode == WfxMode::Wake {
let back = view.speed * view.age;
(
cx - back * view.heading.cos(),
cy - back * view.heading.sin(),
)
} else {
cx
(cx, cy)
};
commands.spawn((
WaterFxDummy,
@@ -173,7 +182,7 @@ fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, su
bound: None,
},
crate::entities::VisualAttached,
Transform::from_translation(wow_to_bevy([start_x, cy, surf - view.depth])),
Transform::from_translation(wow_to_bevy([start_x, start_y, surf - view.depth])),
));
}
@@ -197,8 +206,11 @@ pub(crate) fn drive(
for mut t in &mut units {
match view.mode {
WfxMode::Wake => {
// WoW +X = Bevy −Z (bevy = (−wow.y, wow.z, −wow.x)).
t.translation.z -= view.speed * time.delta_secs();
// bevy = (−wow.y, wow.z, −wow.x), so a WoW heading (cos h, sin h) walks the dummy
// along Bevy (−sin h, 0, −cos h).
let step = view.speed * time.delta_secs();
t.translation.x -= step * view.heading.sin();
t.translation.z -= step * view.heading.cos();
}
WfxMode::Turn => {
t.rotate_y(2.0 * time.delta_secs());
+80 -7
View File
@@ -409,16 +409,38 @@ fn weapon_skill_id(
weapon_subclass_skill(t.subclass).unwrap_or(SKILL_UNARMED)
}
/// Read a skill line's `(value, temp+perm bonus)` pair from the `PLAYER_SKILL_INFO` triplets;
/// `(0, 0)` when the line isn't known (a ranged pair with nothing equipped).
fn skill_pair(store: &ObjectStore, skill_id: u32) -> (u32, i32) {
/// Read a skill line's `(value + PERM bonus, TEMP bonus)` pair from the `PLAYER_SKILL_INFO`
/// triplets; `(0, 0)` when the line isn't known (a ranged pair with nothing equipped).
///
/// **The split is not `(value, temp + perm)`, which is what this returned until decision 1812.**
/// The reader every skill-shaped binding goes through is `0x5ea460`, and it writes
/// `*out1 = value + permBonus` / `*out2 = tempBonus` — the permanent half folds into the BASE and
/// only the temporary half is the modifier (VERIFIED, wow-re
/// `ui/scratch/unitrangedattack-skill-pair.md` §3; the field reads are `+0x84C` `movzx` value,
/// `+0x850` `movsx` temp — the block's only sign-extended read — and `+0x852` `movzx` perm).
///
/// The old shape had the right TOTAL and the wrong halves, which is why nothing caught it: the
/// paper doll's Attack row prints `base` and colours `base + mod`, so a 300-value line with a +5
/// talent and a +10 aura read "300" with a green +15 where the reference reads "305" with a green
/// +10. wow-re's own `object-layer/scratch/w2b2.md` had the two inverted as well, and that is
/// where ours came from; the same round corrected it there.
///
/// **The perm add is skipped when the value is 0** (`0x5ea4b4 jle`), so an unknown line stays a
/// flat 0 rather than reading back a bare talent bonus.
///
/// Signed, because the sum is: `fild dword` on both pushes, and a perm malus deeper than the skill
/// is representable on the wire even if no live server sends one.
fn skill_pair(store: &ObjectStore, skill_id: u32) -> (i32, i32) {
for i in 0..PLAYER_SKILL_SLOTS {
if let Some(s) = store.0.player_skill(i) {
if u32::from(s.skill_id) == skill_id {
return (
u32::from(s.value),
i32::from(s.temp_bonus) + i32::from(s.perm_bonus),
);
let value = i32::from(s.value);
let base = if value == 0 {
0
} else {
value + i32::from(s.perm_bonus)
};
return (base, i32::from(s.temp_bonus));
}
}
}
@@ -1223,6 +1245,57 @@ mod tests {
use benilla_protocol::messages::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
/// **The skill pair splits `(value + PERM, TEMP)`, not `(value, temp + perm)`** — the reader
/// every skill-shaped binding goes through (`0x5ea460`, VERIFIED: wow-re
/// `ui/scratch/unitrangedattack-skill-pair.md` §3).
///
/// It reported the right TOTAL and the wrong halves until decision 1812, which is exactly why
/// nothing caught it: `UnitAttackBothHands`'s caller prints the base and colours `base + mod`,
/// so both shapes agree on the number a player adds up and disagree on the two they read. The
/// fixture below is the one the record quotes — 300 / +5 talent / +10 aura — where the
/// reference shows **305** with a green **+10** and ours showed 300 with a green +15.
///
/// The zero-value leg is the other half of the law (`0x5ea4b4 jle`): an unknown line must stay
/// a flat 0 rather than reading back a bare talent bonus.
#[test]
fn a_skill_pair_folds_the_permanent_bonus_into_the_base() {
/// `PLAYER_SKILL_INFO_1_1` — id|step, then value|max, then temp|perm, per slot.
const F_SKILL: u16 = 718;
let pair = |lo: u16, hi: u16| u32::from(lo) | (u32::from(hi) << 16);
// Slot 0: Defense (95) at 300, +10 temporary, +5 permanent.
// Slot 1: Swords (43) at 0 — known id, no value, a +7 permanent that must NOT surface.
let store = ObjectStore(ObjectFields::from_pairs(&[
(F_SKILL, pair(95, 0)),
(F_SKILL + 1, pair(300, 300)),
(F_SKILL + 2, pair(10, 5)),
(F_SKILL + 3, pair(43, 0)),
(F_SKILL + 4, pair(0, 300)),
(F_SKILL + 5, pair(0, 7)),
]));
assert_eq!(
skill_pair(&store, 95),
(305, 10),
"the permanent bonus belongs to the base and the temporary one is the modifier"
);
assert_eq!(
skill_pair(&store, 43),
(0, 0),
"a line at value 0 skips the perm add — a bare talent bonus is not a skill"
);
assert_eq!(
skill_pair(&store, 162),
(0, 0),
"a line the player does not have at all"
);
// A malus deeper than the skill is representable, which is why the pair is signed.
let cursed = ObjectStore(ObjectFields::from_pairs(&[
(F_SKILL, pair(95, 0)),
(F_SKILL + 1, pair(5, 300)),
(F_SKILL + 2, pair(0, u16::MAX - 9)), // perm = -10
]));
assert_eq!(skill_pair(&cursed, 95), (-5, 0));
}
/// `PLAYER_FIELD_BANK_BAG_SLOT_1` — bank bag slot 0's guid pair (protocol test `bank.rs`).
const F_BANK_BAG_1: u16 = 612;
/// `OBJECT_FIELD_ENTRY` on the item object.
@@ -19,6 +19,10 @@
//! the only thing the renderer draws, so wherever the lattice runs out before the ground climbs
//! through the plane, the water ends on a 4.167-yd straight edge in open water: a shape no
//! shoreline has;
//! - its **mirror** — wet cells the ground climbs out of, which is the skirt of liquid-lattice
//! geometry that lies over dry sand. Nothing renders there only because the terrain drew first
//! and won the depth test, so this is the exact budget a depth bias spends: a decal pulled `b`
//! yards toward the eye paints `b / slope` yards of that skirt;
//! - the **shoreline slope** at the pin, and what a coplanarity *lift* costs there. A decal held `l`
//! yards above the water plane keeps painting for `l / slope` yards past the waterline, on dry
//! ground — the arithmetic behind B348's foam-on-the-sand, and the reason that settle is a depth
@@ -256,6 +260,64 @@ fn main() -> anyhow::Result<()> {
);
}
// ── The mirror: WET cells the ground climbs out of ───────────────────────────────────────
// The scan above finds water that is missing; this one finds water geometry that is *over dry
// land*. Whole wet cells are the unit the renderer draws, so wherever the ground climbs through
// the plane inside a cell, the liquid mesh — and every decal built on the same lattice — carries
// a skirt of geometry up the beach. Nothing renders there only because the terrain drew first
// and won the depth test, which makes this number the exact **budget a depth bias spends**: a
// decal pulled `b` yards toward the eye paints `b / slope` yards of that skirt onto the sand.
let mut skirts: Vec<(f32, f32, (i32, i32))> = Vec::new(); // (height above plane, run inland, cell)
for (k, idxs) in by_key.iter() {
let c = &cells[idxs[0]];
if (c.x - x).hypot(c.y - y) > radius {
continue;
}
let mut highest = 0.0_f32;
for si in 0..=16 {
for sj in 0..=16 {
let px = c.x - CELL / 2.0 + CELL * si as f32 / 16.0;
let py = c.y - CELL / 2.0 + CELL * sj as f32 / 16.0;
if let Some(g) = terrain_height_at(&chunks, [px, py, 0.0]) {
highest = highest.max(g - c.surface);
}
}
}
if highest > 0.02 {
let h = 1.0_f32;
let sample = |dx: f32, dy: f32| terrain_height_at(&chunks, [c.x + dx, c.y + dy, 0.0]);
let slope = match (
sample(h, 0.0),
sample(-h, 0.0),
sample(0.0, h),
sample(0.0, -h),
) {
(Some(px), Some(mx), Some(py), Some(my)) => {
(((px - mx) / (2.0 * h)).powi(2) + ((py - my) / (2.0 * h)).powi(2)).sqrt()
}
_ => 0.0,
};
let run = if slope > 0.0 {
highest / slope
} else {
f32::INFINITY
};
skirts.push((highest, run, *k));
}
}
skirts.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("finite"));
println!(
"\n WET cells with dry ground inside them (the skirt the depth test has to eat): {}",
skirts.len()
);
for (hi, run, k) in skirts.iter().take(6) {
println!(
" cell {k:?} at ({:.1}, {:.1}) — ground up to {hi:.3} yd above the plane, {run:.2} yd inland",
(k.0 as f32 + 0.5) * CELL,
(k.1 as f32 + 0.5) * CELL
);
}
// ── The waterline's slope, and what a coplanarity lift costs horizontally ────────────────
// A decal on the water plane, lifted `l` yards to win the coplanar tie, keeps painting for
// `l / slope` yards past the waterline — up the beach, on dry ground. Measure the slope where
+68 -11
View File
@@ -150,24 +150,35 @@ pub struct UnitCombatStats {
/// Ranged damage range (`UNIT_FIELD_MINRANGEDDAMAGE`/`MAXRANGEDDAMAGE`).
pub ranged_min_damage: f32,
pub ranged_max_damage: f32,
/// The equipped main-hand weapon's skill line as `(value, temp+perm bonus)` — the app resolves
/// WHICH skill via [`weapon_subclass_skill`] (unarmed = [`SKILL_UNARMED`]) and reads the pair
/// from `PLAYER_SKILL_INFO`; `UnitAttackBothHands` serves it verbatim.
pub main_weapon_skill: (u32, i32),
/// The equipped main-hand weapon's skill line as **`(value + PERM bonus, TEMP bonus)`** — the
/// app resolves WHICH skill via [`weapon_subclass_skill`] (unarmed = [`SKILL_UNARMED`]) and
/// reads the pair from `PLAYER_SKILL_INFO`; `UnitAttackBothHands` serves it verbatim.
///
/// **The permanent half belongs to the BASE, not to the modifier** (`0x5ea460`, VERIFIED —
/// wow-re `ui/scratch/unitrangedattack-skill-pair.md` §3; corrected here by decision 1812).
/// Every one of these four pairs comes out of that one reader, so all four carry the split.
pub main_weapon_skill: (i32, i32),
/// The OFF hand's, read the same way. `UnitAttackBothHands` pushes both hands (§4 of wow-re's
/// `pet-paperdoll-stat-api.md`: `0x518810` calls `[vtbl+0xb0]` twice, hand 0 then hand 1), and
/// an empty or non-weapon off hand is Unarmed exactly as the main hand is.
pub offhand_weapon_skill: (u32, i32),
/// The ranged weapon's skill pair (`UnitRangedAttack`).
pub ranged_weapon_skill: (u32, i32),
/// The [`SKILL_DEFENSE`] line's `(value, temp+perm bonus)` pair, read out of `PLAYER_SKILL_INFO`
/// like the two weapon pairs above; `UnitDefense` serves it verbatim.
pub offhand_weapon_skill: (i32, i32),
/// The ranged weapon's skill pair (`UnitRangedAttack`), same split.
///
/// **Player-only, and by a different gate than its two neighbours.** `UnitRangedAttack`
/// `0x518b90` is a DIRECT call gated on the PLAYER typemask alone — no vtable slot and **no
/// `CGUnit_C` fallback body** (wow-re `unitrangedattack-skill-pair.md` §4). So where
/// `UnitDefense` and `UnitAttackBothHands` pass a pet through SELF-OR-MINE and answer
/// `level * 5`, this one answers `(0, 0)` for a pet and for every other player too. Mirroring
/// [`cgunit_skill`] here would be wrong.
pub ranged_weapon_skill: (i32, i32),
/// The [`SKILL_DEFENSE`] line's pair, in the same `(value + perm, temp)` split as the three
/// above and out of the same reader; `UnitDefense` serves it verbatim.
///
/// **The player's only.** A creature has no skill block, and the client does not read one for
/// it: `UnitDefense` forks on the resolved unit's vtable and gives a non-player
/// `UNIT_FIELD_LEVEL * 5` instead ([`cgunit_skill`]), so the pet feed never fills this and the
/// binding never reads it for a pet.
pub defense_skill: (u32, i32),
pub defense_skill: (i32, i32),
/// Whether a wand is equipped (`HasWandEquipped` — the ref swaps the ranged-attack action for
/// wand Shoot on it).
pub has_wand: bool,
@@ -496,7 +507,7 @@ fn with_unit_stats<T>(
/// — so the pair can never sum below zero (`0x519298` for `UnitDefense`, `0x5188a7` per hand for
/// `UnitAttackBothHands`; wow-re `ui/scratch/pet-paperdoll-stat-api.md` §4). A debuff deeper than
/// the skill itself reads as "reduced to 0", never as a negative total.
fn skill_clamped((base, modifier): (u32, i32)) -> (i64, i64) {
fn skill_clamped((base, modifier): (i32, i32)) -> (i64, i64) {
let base = i64::from(base);
let modifier = i64::from(modifier);
(base, if modifier + base < 0 { -base } else { modifier })
@@ -1503,6 +1514,52 @@ mod tests {
);
}
/// **`UnitRangedAttack` does NOT take the pet fork its two neighbours take** (decision 1812).
///
/// `UnitDefense` `0x519200` and `UnitAttackBothHands` `0x518810` gate on SELF-OR-MINE and then
/// dispatch through the resolved unit's vtable, so a pet passes and lands on `CGUnit_C`'s
/// `level * 5`. `UnitRangedAttack` `0x518b90` is a **direct call** gated on the PLAYER typemask
/// alone, with no vtable slot and **no `CGUnit_C` fallback body at all** (VERIFIED, wow-re
/// `ui/scratch/unitrangedattack-skill-pair.md` §4 — `0x612b40`/`0x5edae0`/`0x5ea460` appear as
/// a dword zero times image-wide, against a positive control of one hit each for the four
/// addresses that ARE in a vtable).
///
/// So a pet's ranged attack is `(0, 0)`, not `level * 5`. The whole reason this is a test and
/// not a comment is that the three verbs look interchangeable from the FrameXML side, and
/// mirroring the defense handling here would be a plausible, silent, wrong number.
#[test]
fn ranged_attack_gives_a_pet_zeros_where_defense_gives_it_level_times_five() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
s.set_pet_combat_stats(Some(pet_stats()));
s.set_unit(
"pet",
Some(super::super::UnitState {
exists: true,
level: 60,
..Default::default()
}),
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("pet")"#)
.unwrap(),
(300, 0),
"the vtable fork: level * 5"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitRangedAttack("pet")"#)
.unwrap(),
(0, 0),
"no fork, no fallback body — a pet fails the PLAYER typemask and gets zeros"
);
// …and so does another PLAYER, which is the same gate seen from the other side.
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitRangedAttack("target")"#)
.unwrap(),
(0, 0)
);
}
/// **`UnitAttackBothHands` pushes FOUR values, one pair per hand — and it pushed two until
/// decision 1810.**
///
+38 -19
View File
@@ -264,30 +264,46 @@ impl Rung {
/// `0x68fd0f` arms EGxRs id `0` for the foam draw, and arming that id non-zero is what issues
/// the client's sole `glPolygonOffset` — `factor` a hardcoded `0xc0800000` = **−4.0**
/// (`0x59bf0a`, in capability arm `0x41`, whose only image-wide call site is inside the id-`0`
/// applicator), `units` = `footstepBias(0.125) × [0x810390]` ⇒ **−8.0001**. So the reference
/// settles this decal with a **slope-scaled** term plus a near-zero constant, which is the
/// right instrument and not an accident: a near-horizontal decal seen at a grazing angle has a
/// depth gradient a constant cannot follow, and the shoreline is exactly that view.
/// applicator), `units` = `footstepBias(0.125) × [0x810390]` ⇒ **−8.0001**. The `-4.0` is not
/// this lane's number: it is the immediate every consumer of EGxRs id `0` gets, and the foam
/// reaches it by borrowing the **footprint's own cvar** — which is idiom reuse ("this is a
/// ground decal, arm the ground-decal offset"), not a tie this decal has to win. Only the
/// `units` term is the foam's own.
///
/// **The signs invert here.** GL depth runs 0 = near, so its negative offset pulls toward the
/// eye; ours is reversed-Z (`CompareFunction::GreaterEqual`, near = 1), where toward the eye is
/// *larger* depth — so both terms are positive. [`DECAL_RASTER`](Rung::DECAL_RASTER) being
/// *larger* depth — so a ported term is positive. [`DECAL_RASTER`](Rung::DECAL_RASTER) being
/// positive for the same job corroborates the convention independently.
///
/// **Two things here are ours, not transcribed.** `factor` multiplies the primitive's *window*
/// depth slope, and reversed-Z gives a plane a different gradient than GL's mapping does, so
/// 4.0 is the reference's number in a frame where it means something slightly different.
/// And the constant is in ULPs of a **float** depth buffer (per-primitive, the quantisation
/// 1806 is about) rather than GL's fixed `2⁻²⁴` — same order, not the same unit. The client's
/// *D3D* backend arms no slope term at all and compensates with a flat bias ~256× the GL
/// constant; which of the two encodings is canonical turns on the `gxApi` device-identity
/// question, which is open on wow-re's `gx` board. The GL shape is ported because it is the
/// one that is *correct* in any depth format, and because a flat constant is described at the
/// bytes as standing in for the missing slope term.
/// **The constant is in ULPs of a float depth buffer** (per-primitive — the quantisation 1806
/// is about) rather than GL's fixed `2⁻²⁴`: same order, not the same unit. Its world-space pull
/// is `z · C · 2⁻²³`, i.e. ~1 µm per yard of view distance at `C = 8` — invisible by
/// construction, which is the point. It is a guard against a driver rounding one pipeline's
/// coplanar arithmetic differently from another's, not a tie-breaker anything rests on.
pub const FOAM_RASTER: i32 = 8;
/// The slope-scale half of [`FOAM_RASTER`](Rung::FOAM_RASTER) — see its doc for the bytes,
/// the sign inversion, and what is ported vs transcribed.
pub const FOAM_RASTER_SLOPE: f32 = 4.0;
/// **The slope half is deliberately NOT armed — the tie it settles does not exist here** (B348
/// second round, 1811; supersedes 1809 §3's transcription).
///
/// The reference arms `factor = -4.0` and it is the right instrument *there*. It is the wrong
/// one here because our foam patch is not a decal *over* the liquid surface — it **is** the
/// liquid surface's own triangles: [`water_fx::build_patch`] emits the wet cells straight out
/// of [`WaterChunkInfo`]'s grid, in the liquid mesh's own winding, through the same
/// `clip_from_world` (`DECAL_WORLD_CLIP`, 0781) against a mesh whose `Transform` is
/// `IDENTITY`. Same vertices, same matrix, same arithmetic — so the depths agree exactly and
/// `GreaterEqual` passes the tie on its own. That holds for the fullbright kinds too, which
/// are the only liquids that write depth at all (magma/slime are `AlphaMode::Opaque`; water is
/// `Blend`, depth-write off, so it never competes at all).
///
/// What a slope term *does* buy, measured: a near-horizontal decal at a grazing angle has a
/// window-depth gradient of `n / (h · f)` per pixel (eye height `h` above the plane, focal
/// length `f` in pixels), so `factor · m` is a world-space pull of `factor · z² / (h · f)` —
/// **growing as the square of view distance, and the near plane cancels out**. Whole wet cells
/// are the unit of liquid geometry, so a shoreline carries a skirt of liquid-lattice triangles
/// over dry sand (up to 7 yd of it at the reported beach — `benilla-formats --example
/// water_here`), held back by nothing but the terrain having drawn first. Any pull toward the
/// eye spends that budget: at `4.0` the wake visibly washed onto the beach, which is what the
/// director reported twice.
pub const FOAM_RASTER_SLOPE: f32 = 0.0;
/// **World text** — above the celestial glare, so a flare never washes a nameplate, and above
/// every sky rung. The reference draws its world text late in the frame (decision 0519).
/// Small on purpose: 6× the far plane is all the ordering needs, and this same field doubles
@@ -330,7 +346,10 @@ const _: () = {
// unread — see FOAM_RASTER). Both halves pull toward the eye under reversed-Z, so both are
// positive; a negative here would push the decal INTO its receiver.
assert!(Rung::FOAM_RASTER > 0 && Rung::FOAM_RASTER < Rung::DECAL_RASTER);
assert!(Rung::FOAM_RASTER_SLOPE > 0.0);
// The slope half stays disarmed on this lane (1811): its pull grows as z² and it spends the
// wet-lattice skirt onto the beach, while the tie it would settle is already won by the patch
// being the liquid mesh's own triangles. A nonzero value here is a decision, not a tune.
assert!(Rung::FOAM_RASTER_SLOPE == 0.0);
// ─── The pre-water decal band (B347, 1785/1789) ─────────────────────────────────────────
// Internally ordered as the reference's frame emits them — ring then shadow, both from
+14 -9
View File
@@ -45,9 +45,13 @@
//! depth-buffer bias and the foam vertices sit at the queried MCLQ height exactly, so there is no
//! geometric lift in the binary to be faithful to (the "−2048 GL units" this file cited for a
//! year is refuted; the real term is `footstepBias(0.125) × [0x810390]` = D3D `DEPTHBIAS −1/8192`).
//! Ours is the decal family's rasterizer bias plus `DECAL_WORLD_CLIP` — absolute verts through the
//! same `clip_from_world` the world meshes run (0781), so the tie is settled in depth ULPs with
//! zero horizontal cost.
//! Ours needs no settle at all, and that is worth stating rather than assuming (1811): the patch
//! is not a decal *over* the surface, it **is** the liquid mesh's own triangles — the same wet
//! cells, the same winding, through the same `clip_from_world` (`DECAL_WORLD_CLIP`, 0781) as a
//! mesh whose `Transform` is `IDENTITY` — so the depths agree exactly and `GreaterEqual` passes
//! the tie unaided. [`Rung::FOAM_RASTER`] stays as a few ULPs of insurance against a driver
//! rounding two pipelines differently; the **slope half is disarmed**, because its pull grows as
//! z² and the only thing it can reach is the skirt of wet-cell geometry lying over dry sand.
//!
//! The formulas + lifecycle math live in [`params`]; this half is the ECS: the emitter over the
//! avatar + streamed units, the record pool, and one additive effect-stream draw per
@@ -598,12 +602,13 @@ fn push_water_foam(
lighting: crate::particles::buffer::EffectLighting::None,
anchor: centroid / n as f32,
bias: FOAM_BIAS,
// **The reference's own settle, both halves** (`Rung::FOAM_RASTER`, whose doc
// carries the bytes): a near-zero constant plus a slope-scaled term, which is what
// a near-horizontal decal at a grazing angle needs — the shoreline view. A nonzero
// constant also selects `DECAL_WORLD_CLIP`, so these absolute verts skip the
// cam-relative rebase and tie against the ground through the world meshes' own
// matrix (0781).
// A few ULPs of constant and **no slope term** (`Rung::FOAM_RASTER*`, whose docs
// carry the bytes and the reasoning, 1811): the patch is the liquid mesh's own
// triangles, so `GreaterEqual` already passes the coplanar tie and the only thing
// a pull toward the eye can reach is the wet-lattice skirt over dry sand. The
// nonzero constant also selects `DECAL_WORLD_CLIP`, so these absolute verts skip
// the cam-relative rebase and go through the world meshes' own matrix (0781) —
// which is what makes the two depths agree in the first place.
raster_bias: crate::sky_order::Rung::FOAM_RASTER,
raster_slope: crate::sky_order::Rung::FOAM_RASTER_SLOPE,
cam_relative: false,
+18 -4
View File
@@ -17,6 +17,23 @@ use benilla_assets::{AssetSet, WorldAssets};
/// arrives and tells us the character's real map. A worldport then bumps [`CurrentMap`].
pub(crate) const DEFAULT_MAP_ID: u32 = 0;
/// `$WOW_MAP` — the map a **server-less** run starts on, as a `Map.dbc` id (`0` = Azeroth,
/// `1` = Kalimdor). Unset gives [`DEFAULT_MAP_ID`].
///
/// **Set-but-unparseable is a hard error, deliberately.** This used to fall back to Azeroth, so
/// `WOW_MAP=Kalimdor` silently photographed the wrong continent — the capture came back as empty
/// ocean with the subject floating in it, which reads as a renderer bug and costs a session the
/// time to chase one. An instrument that answers the wrong question quietly is worse than one that
/// refuses.
fn map_id_from_env() -> u32 {
match std::env::var("WOW_MAP") {
Err(_) => DEFAULT_MAP_ID,
Ok(v) => v.trim().parse().unwrap_or_else(|_| {
panic!("WOW_MAP={v:?} is not a Map.dbc id — it takes a NUMBER (0 = Azeroth, 1 = Kalimdor), not a name")
}),
}
}
/// The map the player is currently on. Written by `player::control` when it drains the world stream's
/// worldport; watched by the terrain + WDL streamers (reload ADTs/WDL for the new map), the loading
/// screen (cover the swap), and time-of-day lighting (per-map outdoor light).
@@ -93,10 +110,7 @@ fn load_world_map(mut commands: Commands, world_assets: Option<Res<WorldAssets>>
// A named capture scenario carries its own map (`capture::scenarios::Scenario::map`)
// and writes it here before this runs, so the golden sweep's Kalimdor shots need no env
// at all — see `capture::CapturePlugin::build` (decision 0743).
let map = std::env::var("WOW_MAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAP_ID);
let map = map_id_from_env();
commands.insert_resource(CurrentMap(map));
}
Err(e) => error!("failed to load Map.dbc, cross-map teleport disabled: {e:#}"),