diff --git a/crates/benilla-app/src/capture/fxview.rs b/crates/benilla-app/src/capture/fxview.rs index 8614b91c..e3e2e1e9 100644 --- a/crates/benilla-app/src/capture/fxview.rs +++ b/crates/benilla-app/src/capture/fxview.rs @@ -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 diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 3a84ac3d..ed6b04d3 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -227,6 +227,9 @@ fn glue_screen() -> Option { /// 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 = v.split(',').filter_map(|p| p.trim().parse().ok()).collect(); + (c.len() == 3).then(|| [c[0], c[1], c[2]]) + }); let display: Option = 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::(); 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") diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index bc631217..889a05c8 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -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, /// 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, diff --git a/crates/benilla-app/src/capture/waterfx.rs b/crates/benilla-app/src/capture/waterfx.rs index 8dc3f869..e0d1518f 100644 --- a/crates/benilla-app/src/capture/waterfx.rs +++ b/crates/benilla-app/src/capture/waterfx.rs @@ -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()); diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index 302d553b..a54dfe45 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -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. diff --git a/crates/benilla-formats/examples/water_here.rs b/crates/benilla-formats/examples/water_here.rs index 46bf444c..8ef2c692 100644 --- a/crates/benilla-formats/examples/water_here.rs +++ b/crates/benilla-formats/examples/water_here.rs @@ -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 diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index 1486aac0..fc908284 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -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( /// — 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.** /// diff --git a/crates/benilla-world/src/sky_order.rs b/crates/benilla-world/src/sky_order.rs index 9c19723b..5f4c2d36 100644 --- a/crates/benilla-world/src/sky_order.rs +++ b/crates/benilla-world/src/sky_order.rs @@ -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 diff --git a/crates/benilla-world/src/water_fx/mod.rs b/crates/benilla-world/src/water_fx/mod.rs index 68160201..4c2ec249 100644 --- a/crates/benilla-world/src/water_fx/mod.rs +++ b/crates/benilla-world/src/water_fx/mod.rs @@ -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, diff --git a/crates/benilla-world/src/world_map.rs b/crates/benilla-world/src/world_map.rs index 16da6f54..208e8ce4 100644 --- a/crates/benilla-world/src/world_map.rs +++ b/crates/benilla-world/src/world_map.rs @@ -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> // 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:#}"),