diff --git a/crates/benilla/src/capture/fixtures.rs b/crates/benilla/src/capture/fixtures.rs index d4b86a10..8b96211c 100644 --- a/crates/benilla/src/capture/fixtures.rs +++ b/crates/benilla/src/capture/fixtures.rs @@ -17,6 +17,18 @@ const WOLF_FACTION: u32 = 32; /// onto the water *beyond* it. const NAME_WATER_POS: [f32; 3] = [-9512.97, -331.29, 61.4]; +/// The lighting matrix's chest (decision 0744): `GameObjectDisplayInfo` 259, +/// `World\SkillActivated\Containers\TreasureChest01.mdx`. GameObject guids carry the `0xF110` high +/// word, and the descriptor is left at its defaults — an unstated `GAMEOBJECT_STATE` holds the +/// closed rest pose, which is the frame we want. +const CHEST_DISPLAY: u32 = 259; +const CHEST_GUID: u64 = (0xF110u64 << 48) | 0x744; + +/// Which way a lighting-matrix subject faces (Bevy yaw). The matrix's `front` camera sits on the +/// lighting sun's bearing (azimuth 45°), so the subject is turned to meet it: `front` reads the +/// face, `rear` the tail — the same body, its lit and unlit sides. +const SUBJECT_YAW: f32 = 2.36; + /// Seed the fixture window's state resources once, right after the scene goes resident — the real /// feeds then push it into the VM during the settle window exactly as live wire data would. Item /// icons resolve through the offline `ItemDisplayCatalog` (display ids chosen from entries that @@ -1025,6 +1037,53 @@ pub(super) fn seed_ui_fixture( // draws one — the ShouldShowName exclusivity) even though enemy plates boot ON. vplates.enemies = false; } + // The lighting matrix (decision 0744). One spawn, through the same component set a streamed + // entity gets, at a position whose light lane was read out of the data (see the matrix note + // in `scenarios`). Deliberately ANONYMOUS: no name is registered and plates are forced off, + // so no glyph rides over the body — the diff of these cells must be about light and nothing + // else. + UiFixture::Subject { kind, at } => { + use benilla_protocol::messages::ObjectFields; + let transform = Transform { + translation: wow_to_bevy(at), + rotation: Quat::from_rotation_y(SUBJECT_YAW), + ..default() + }; + match kind { + SubjectKind::Creature => { + commands.spawn(( + crate::net::Guid(WOLF_GUID), + crate::net::NetEntity { + kind: benilla_protocol::EntityKind::Unit, + display_id: Some(WOLF_DISPLAY), + scale: 1.0, + }, + crate::net::ObjectStore(ObjectFields::from_pairs(&[ + (22, 100), // UNIT_FIELD_HEALTH + (28, 100), // UNIT_FIELD_MAXHEALTH + (34, 2), // UNIT_FIELD_LEVEL + (35, WOLF_FACTION), // UNIT_FIELD_FACTIONTEMPLATE + ])), + transform, + Visibility::default(), + )); + } + SubjectKind::Chest => { + commands.spawn(( + crate::net::Guid(CHEST_GUID), + crate::net::NetEntity { + kind: benilla_protocol::EntityKind::GameObject, + display_id: Some(CHEST_DISPLAY), + scale: 1.0, + }, + crate::net::ObjectStore(ObjectFields::default()), + transform, + Visibility::default(), + )); + } + } + vplates.enemies = false; + } } } diff --git a/crates/benilla/src/capture/mod.rs b/crates/benilla/src/capture/mod.rs index 8e2dc194..f9608c40 100644 --- a/crates/benilla/src/capture/mod.rs +++ b/crates/benilla/src/capture/mod.rs @@ -86,7 +86,7 @@ pub(crate) use probes::{ EntityCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeExitPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, }; -use scenarios::{Scenario, UiFixture, GROUND_EYE, SCENARIOS}; +use scenarios::{Scenario, SubjectKind, UiFixture, GROUND_EYE, SCENARIOS}; /// Is the app running a capture (`$WOW_CAPTURE` set)? Read by `main` to disable net + add the harness. pub(crate) fn scenario_active() -> bool { diff --git a/crates/benilla/src/capture/scenarios.rs b/crates/benilla/src/capture/scenarios.rs index b02f872a..4ca268cf 100644 --- a/crates/benilla/src/capture/scenarios.rs +++ b/crates/benilla/src/capture/scenarios.rs @@ -96,6 +96,27 @@ pub(super) enum UiFixture { /// sweep covered this — decision 0519 wrote the law and shipped it with the sort bias /// pointing the wrong way, invisible to every gate. The name must read at full strength. NameWater, + /// One cell of the **lighting matrix** (decision 0744): a creature or a GameObject spawned + /// through the live path at `at`, so each lane an object's light can take is photographed from + /// two sides. See [`SubjectKind`] and the matrix note above [`SUBJECT_SUN`]. + Subject { + kind: SubjectKind, + /// Where the subject stands, raw WoW coords — its FEET, not its body centre. + at: [f32; 3], + }, +} + +/// What the lighting matrix puts in frame. Both spawn with the same live component set a streamed +/// entity gets (`NetEntity` + descriptor), differing only in `EntityKind` — so the matrix exercises +/// the real unit and GameObject paths rather than a bespoke preview. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum SubjectKind { + /// The Timber Wolf (entry 69, display 604) — the same subject `vplates`/`name-water` use, so a + /// defect seen here can be A/B'd against them directly. + Creature, + /// `World\SkillActivated\Containers\TreasureChest01.mdx` (`GameObjectDisplayInfo` 259) — the + /// canonical world chest, at its closed rest pose. + Chest, } /// The ON-DEMAND Northshire framings, anchored at the Human start (around `SPAWN_XY` @@ -217,6 +238,116 @@ pub(super) const SCENARIOS: &[Scenario] = &[ minute: 0, ui: None, }, + Scenario { + name: "creature-sun-front", + map: MAP_AZEROTH, + eye: [-9496.46, 59.54, 58.28], + look: [-9500.00, 56.00, 57.28], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Creature, + at: SUBJECT_SUN, + }), + }, + Scenario { + name: "creature-sun-rear", + map: MAP_AZEROTH, + eye: [-9503.54, 52.46, 58.28], + look: [-9500.00, 56.00, 57.28], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Creature, + at: SUBJECT_SUN, + }), + }, + Scenario { + name: "creature-shade-front", + map: MAP_AZEROTH, + eye: [-9496.46, 47.54, 57.75], + look: [-9500.00, 44.00, 56.75], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Creature, + at: SUBJECT_SHADE, + }), + }, + Scenario { + name: "creature-shade-rear", + map: MAP_AZEROTH, + eye: [-9503.54, 40.46, 57.75], + look: [-9500.00, 44.00, 56.75], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Creature, + at: SUBJECT_SHADE, + }), + }, + Scenario { + name: "creature-indoor-front", + map: MAP_AZEROTH, + eye: [-9466.57, 34.73, 59.70], + look: [-9469.40, 31.90, 58.70], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Creature, + at: SUBJECT_INDOOR, + }), + }, + Scenario { + name: "creature-indoor-rear", + map: MAP_AZEROTH, + eye: [-9472.23, 29.07, 59.70], + look: [-9469.40, 31.90, 58.70], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Creature, + at: SUBJECT_INDOOR, + }), + }, + Scenario { + name: "chest-sun-front", + map: MAP_AZEROTH, + eye: [-9496.82, 59.18, 57.98], + look: [-9500.00, 56.00, 56.93], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Chest, + at: SUBJECT_SUN, + }), + }, + Scenario { + name: "chest-sun-rear", + map: MAP_AZEROTH, + eye: [-9503.18, 52.82, 57.98], + look: [-9500.00, 56.00, 56.93], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Chest, + at: SUBJECT_SUN, + }), + }, + Scenario { + name: "chest-shade-front", + map: MAP_AZEROTH, + eye: [-9496.82, 47.18, 57.45], + look: [-9500.00, 44.00, 56.40], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Chest, + at: SUBJECT_SHADE, + }), + }, + Scenario { + name: "chest-shade-rear", + map: MAP_AZEROTH, + eye: [-9503.18, 40.82, 57.45], + look: [-9500.00, 44.00, 56.40], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Chest, + at: SUBJECT_SHADE, + }), + }, ]; /// Director shot 1 — the Northshire overlook, north-east of the Abbey looking at it: terrain, @@ -261,10 +392,73 @@ pub(super) const INN_LOOK: [f32; 3] = [-9458.8, -7.5, 48.2]; pub(super) const FELWOOD_EYE: [f32; 3] = [4060.9, -944.3, 256.8]; pub(super) const FELWOOD_LOOK: [f32; 3] = [4014.0, -954.4, 242.9]; +// --------------------------------------------------------------------------------------------- +// The LIGHTING MATRIX (decision 0744) — one subject, three lanes, two sides. +// +// The golden spots are landscape: they photograph terrain, buildings and water, and (captures being +// server-less) contain no creature or GameObject at all. So the *object* light path — the one every +// creature, player, pet and chest in the game is lit by — had no regression coverage whatsoever, +// while the bug ledger's unit reports (blacked-out textures, mis-lit NPCs across city WMOs) sit +// squarely on it. +// +// The three positions are the three lanes an object's light can take, and they were CHOSEN FROM THE +// DATA, not by eye — `WOW_LIGHT_AT`/`WOW_LIGHT_GRID` (`wmo_portal::audit::light_probe`) reports the +// lane and the terrain MCSH bit at any world point: +// +// SUN (-9500, 56) terrain z 56.48 MCSH false lane exterior-on-terrain — sun term at full +// SHADE (-9500, 44) terrain z 55.95 MCSH true lane exterior-on-terrain — sun term dimmed +// INDOOR (-9469.4, 31.9) terrain z none zone-text indoor 5, lane BAKE g05 — no sun at all +// +// SUN and SHADE are twelve yards apart on the same open ground, so between that pair the ONLY +// changed input is the baked shadow bit — exactly the discriminator `entity_shade` ramps on +// (2.5 lit → 0.5 shadowed). INDOOR stands in the Goldshire inn's common room, where the interior +// classifier owns the tag instead and the sun never reaches. +// +// Two sides per lane, straddling the light: the game's LIGHTING sun is near-fixed at azimuth 45° +// (`sun::follow`), so `front` puts the camera on that bearing (sun behind us — the subject's lit +// face) and `rear` puts it opposite (the shadowed face). Indoors there is no sun, so the pair simply +// samples the bake from two sides. Cameras sit at a fixed offset off the subject's feet, eye a +// little above and aimed at the body, so every cell frames the subject identically and a diff is +// about light, never framing. + +/// Lighting-matrix subject positions (feet, raw WoW coords) — see the note above. +pub(super) const SUBJECT_SUN: [f32; 3] = [-9500.0, 56.0, 56.48]; +pub(super) const SUBJECT_SHADE: [f32; 3] = [-9500.0, 44.0, 55.95]; +pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9]; + /// Every remaining named viewpoint — the UI look-pass fixtures, the sun/moon/sky regression /// fixtures, the house-compass and street scenes. Capturable by name (`WOW_CAPTURE=`) for /// debugging and look passes, but NOT part of the blessed baseline sweep. pub(super) const ON_DEMAND: &[Scenario] = &[ + // The chest INDOORS is out of the blessed sweep and in here instead: measured over two runs of + // one build it is not reproducible (front MAE 1.551 / 9.2 % of pixels, rear 0.529 / 7.6 %), and + // the diff is the whole body shifting brightness in perfect registration — LIGHT, not pose. The + // GameObject's interior light lane does not converge by the shutter, while the CREATURE at the + // same spot is bit-identical. That is the ledger's "mis-lit objects inside city WMOs" class, and + // it is open (decision 0746). Capture them by name to work on it; they rejoin the sweep the day + // they are deterministic. + Scenario { + name: "chest-indoor-front", + map: MAP_AZEROTH, + eye: [-9466.85, 34.45, 59.40], + look: [-9469.40, 31.90, 58.35], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Chest, + at: SUBJECT_INDOOR, + }), + }, + Scenario { + name: "chest-indoor-rear", + map: MAP_AZEROTH, + eye: [-9471.95, 29.35, 59.40], + look: [-9469.40, 31.90, 58.35], + minute: 720, + ui: Some(UiFixture::Subject { + kind: SubjectKind::Chest, + at: SUBJECT_INDOOR, + }), + }, Scenario { name: "house-north", map: MAP_AZEROTH, diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index 99434f67..fe3a1539 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -235,6 +235,22 @@ fn main() -> AppExit { // It is not the only source of per-run drift, though it was long documented as such: the // other is the frame clock itself, frozen in `capture` (decision 0723). std::env::set_var("WOW_CLUTTER_DENSITY", "0"); + // Third source of per-run drift, and the one the lighting matrix (decision 0746) hit: the + // anim-LOD park/wake gate (`creature_anim::lod::gate_rig_animation`). Whether a rig is + // parked, and which pose it wakes into, depends on when its model finished loading relative + // to the frustum/room evaluation — asset-load timing, which the frozen clock does not + // control. MEASURED: the seeded wolf lands in one of exactly two poses, the pair always + // MAE 4.123 apart, and three runs with the gate off are bit-identical. + // + // Off for captures, unless explicitly overridden. A still frame is meant to contain only + // rigs that are IN view, and a rig in view is one the gate should never park — so what a + // capture loses is the gate's own correctness, not the shot's subject. That loss is named, + // not free: a rig wrongly parked in frame stays invisible to the sweep, and the deeper + // question the measurement raises — why a woken rig does not converge on the absolute-clock + // pose the gate promises — is open in 0746 and worth its own hunt. + if std::env::var("WOW_NO_ANIM_LOD").is_err() { + std::env::set_var("WOW_NO_ANIM_LOD", "1"); + } } // The `mpq://` asset source must be registered BEFORE `AssetPlugin` (inside `DefaultPlugins`) diff --git a/crates/benilla/src/portrait/light.rs b/crates/benilla/src/portrait/light.rs index 30c8850e..040c341c 100644 --- a/crates/benilla/src/portrait/light.rs +++ b/crates/benilla/src/portrait/light.rs @@ -47,6 +47,9 @@ impl BoothRig { let Some(buffer) = self.buffer.clone() else { // No booth buffer at all (headless tests) — there is nothing to wait FOR, so this // stays a plain fallback rather than an unready signal. + if super::booth_log() { + eprintln!("[booth] variant NO-BUFFER -> world lane (unlit in a booth)"); + } return world.clone(); }; match material_variant(&mut self.variants, &buffer, world, materials, false) { diff --git a/crates/benilla/src/portrait/mod.rs b/crates/benilla/src/portrait/mod.rs index 30c1fe34..db36f103 100644 --- a/crates/benilla/src/portrait/mod.rs +++ b/crates/benilla/src/portrait/mod.rs @@ -299,6 +299,24 @@ fn booth_anchors( creatures?.display_anchors(display_id) } +/// `WOW_BOOTH_LOG=1` — the resolved framing for a bake: which anchors it had and where the camera +/// ended up. An opaque near-black pane is what a camera parked *inside* the model renders, so +/// "black" and "zoomed to max" may be one symptom; this is the line that tells them apart. +fn log_frame(token: &str, a: &PortraitAnchors, cam: &Transform) { + if booth_log() { + eprintln!( + "[booth] {token} frame eye=({:.3},{:.3},{:.3}) authored_cam={} pivot={:.3} head_y={:.3} gr={:.3}", + cam.translation.x, + cam.translation.y, + cam.translation.z, + a.camera.is_some(), + a.pivot_height, + a.head.map_or(f32::NAN, |h| h.y), + a.ground_radius, + ); + } +} + /// Arm `booth` after a content edge: render the settle window, plus every frame until each twin /// material's texture is resident. `twins` = the material handles the bake just installed. fn wake_booth<'a>( @@ -799,6 +817,7 @@ fn sync_portraits( ); // Frame through the display's authored portrait camera (heuristic anchors for the // camera-less few), resolved above before anything was torn down. + log_frame(token, &anchors, &frame(&anchors).0); aim(&mut cams, token, &frame(&anchors)); log_bake(token, "bake", parts.len(), riders.len(), billboards.len()); wake_booth( @@ -1042,6 +1061,7 @@ fn sync_body_booth( ); // Body framing from the display's bounds — the full standing figure, feet-to-crown. // Resolved before the teardown above; see the portrait site for why it cannot be faked. + log_frame(slot, &anchors, &body_frame(&anchors).0); aim(cams, slot, &body_frame(&anchors)); log_bake(slot, "bake", parts.len(), riders.len(), billboards.len()); wake_booth(