diff --git a/crates/benilla-app/assets/ui/ColorPickerFrame.xml b/crates/benilla-app/assets/ui/ColorPickerFrame.xml index 94f321b9..0fc5d307 100644 --- a/crates/benilla-app/assets/ui/ColorPickerFrame.xml +++ b/crates/benilla-app/assets/ui/ColorPickerFrame.xml @@ -66,9 +66,13 @@ **width**; the value marker scales by the **wheel's** height while anchoring to the strip). OTHER DEVIATIONS, each forced: - · **`` (ref l.177-184) is not transcribed** — this engine gives plain frames no - raw key events (SCRIPT_KINDS has no `OnKeyDown`), the same wall StackSplit.xml hit - (its own header note). Its ESCAPE arm is `HideUIPanel(this) + cancelFunc(previousValues)`, i.e. + · **`` (ref l.177-184) is not transcribed.** The engine reason is GONE — decision + 1319 built the frame key-down walk and `OnKeyDown` is a live script kind (StackSplit.xml + declares one), so this could now be transcribed as written. It is not, because transcribing + it changes more than ESC: the walk's gate is EXISTENCE, so the frame would begin swallowing + EVERY key while the picker is open, and the UiPanels ESC rung below would have to come out + in the same move. That is a deliberate deferral now, not a wall. Its ESCAPE arm is + `HideUIPanel(this) + cancelFunc(previousValues)`, i.e. exactly what the Cancel button does, so the arm is carried in UiPanels.xml's ESC chain as `ColorPickerCancelButton:Click()` — the reference's own idiom for this (its game-menu chain cancel-clicks `OptionsFrameCancel` the same way). ESC therefore CANCELS, it does not diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index 199c8e3e..8ee23c17 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -761,9 +761,10 @@ end -- The colour picker next (ColorPickerFrame.xml). The reference hooks ESC on the frame -- itself (`` l.177-184, whose ESCAPE arm is `HideUIPanel(this)` + - -- `cancelFunc(previousValues)`); this engine gives plain frames no raw key events, so - -- it routes through this shared chain like the stack-split spinner and the world map - -- do. **Cancel-CLICK, not hide** — the ref's own arm restores the previous colour, and + -- `cancelFunc(previousValues)`); that frame does not transcribe its OnKeyDown (see its + -- own header — the engine can deliver one since 1319; the deferral is now about the + -- existence gate, not the engine), so it routes through this shared chain like the + -- world map does. **Cancel-CLICK, not hide** — the ref's own arm restores the previous colour, and -- clicking the real button is how the options rung below already spells this (the ref's -- `OptionsFrameCancel:Click()`), so there is one cancel path, not two. local picker = getglobal("ColorPickerFrame") @@ -805,7 +806,8 @@ return end -- The fullscreen world map next (the ref's OnKeyDown ESCAPE arm — WorldMapFrame.xml - -- l.628; ours routes through this chain since frames get no raw key events). + -- l.628; ours routes through this chain rather than transcribing that frame's own + -- OnKeyDown, which the engine could deliver since 1319). local worldmap = getglobal("WorldMapFrame") if worldmap and worldmap:IsVisible() then -- HideUIPanel, not a bare Hide: the map holds the full-screen slot (decision 0203's diff --git a/crates/benilla-app/src/capture/probes/visual_census.rs b/crates/benilla-app/src/capture/probes/visual_census.rs index d5461785..fee14f3c 100644 --- a/crates/benilla-app/src/capture/probes/visual_census.rs +++ b/crates/benilla-app/src/capture/probes/visual_census.rs @@ -111,6 +111,11 @@ type VisualQuery = ( Option<&'static HeldAttached>, // …and its mount, the third chained-model root the pick offers (decision 0441). Option<&'static crate::entities::mount::MountChild>, + // The descriptor, read for exactly one thing here: which half of the corpse fork a + // `TYPEID_CORPSE` row is on (decision 1706). A corpse has no name-cache entry, so without this + // every corpse row reads `Corpse … ?` and cannot say whether `meshes=0` means "the bone-pile + // model is missing" or "the dressed body is missing" — opposite findings. + Option<&'static crate::net::ObjectStore>, ); /// One line per streamed entity within [`UnitVisuals::radius`] of the body — cubes first, then @@ -150,7 +155,7 @@ fn fire_unit_visuals( let mut rows: Vec<(bool, i64, String)> = Vec::new(); let (mut cube_n, mut blank_n, mut pending_n) = (0u32, 0u32, 0u32); let mut cube_displays: Vec = Vec::new(); - for (entity, guid, net, t, children, attached, held, mount) in &entities { + for (entity, guid, net, t, children, attached, held, mount, store) in &entities { if matches!(net.kind, EntityKind::DynamicObject | EntityKind::Other) || t.translation.distance_squared(body.translation) > radius2 { @@ -214,7 +219,12 @@ fn fire_unit_visuals( u8::from(cube), held.join(","), if attached { "attached" } else { "PENDING" }, - names.peek(guid.0).unwrap_or("?"), + // The name, or — for a corpse, which never has one — which fork it took. + match (net.kind, store) { + (EntityKind::Corpse, Some(s)) if s.0.corpse_is_bones() => "", + (EntityKind::Corpse, Some(_)) => "", + _ => names.peek(guid.0).unwrap_or("?"), + }, ), )); } diff --git a/crates/benilla-app/src/cinematic.rs b/crates/benilla-app/src/cinematic.rs index 41d81618..4ed48842 100644 --- a/crates/benilla-app/src/cinematic.rs +++ b/crates/benilla-app/src/cinematic.rs @@ -200,18 +200,41 @@ fn spawn_letterbox(mut commands: Commands) { } } +/// The height of **one** letterbox bar, in the same units as the screen it is measured against. +/// +/// The reference's law in one expression — see [`drive_letterbox`] for where each term comes from. +/// Pure and unit-agnostic, so it is testable against the reference's own numbers without a window. +fn letterbox_bar(width: f32, screen: f32) -> f32 { + let two_to_one = (screen - (width / 2.0).min(screen)) / 2.0; + two_to_one.min(screen / 6.0).max(0.0) +} + /// Raise the letterbox and darken the HUD while a shot is on screen; put both back after. /// -/// The bar height is the reference's own law, the one `CinematicFrame` carries in Lua: only a -/// screen wider than 4:3 gets bars, and there the picture is cropped to **2:1** — `width/2`, -/// capped at the screen height, with the remainder split evenly. Computed here per frame so a -/// window resized mid-cinematic stays letterboxed correctly. +/// The bar height is the reference's own law, and it is **two** halves of one formula. The Lua in +/// `CinematicFrame.lua` crops the picture to **2:1** — `width/2`, capped at the screen height, +/// remainder split evenly — but only `if width/height > 4/3`; below that it recomputes nothing and +/// the bars stand at the size `CinematicFrame.xml` declares them, `1024 x 128`. That is not a +/// separate rule: the frame is authored on the client's native `1024 x 768` sheet (`WorldMapFrame` +/// authors its fullscreen backdrop at exactly that, and `SetupFullscreenScale` grows it), so `128` +/// is `height/6` — which is the 2:1 formula evaluated at exactly 4:3. The `if` is a guard around a +/// recompute, not a gate on whether there are bars. +/// +/// So the whole law is one line: **`min((height - min(width/2, height))/2, height/6)`**. Wider than +/// 4:3 the first term wins and the picture is 2:1; at 4:3 they are equal; narrower, the cap holds +/// the bars at a sixth apiece exactly as the reference's un-recomputed textures do. Reading it as +/// "only a wide screen gets bars" left benilla un-letterboxed at 4:3 and below, which is the +/// reference's *native* aspect. +/// +/// Computed here per frame rather than once in `OnLoad`: a window resized mid-cinematic stays +/// letterboxed, where the reference (whose resolution changed only across a restart) would not. fn drive_letterbox( cine: Res, mut hidden: ResMut, mut bars: Query<(&mut Node, &mut Visibility), With>, windows: Query<&Window>, mut ours: Local, + mut logged: Local, ) { let playing = cine.is_playing(); // Only ever un-hide a UI *we* hid: a player who pressed ALT-Z before the cinematic keeps @@ -231,15 +254,15 @@ fn drive_letterbox( .flatten() .map_or(0.0, |w| { let (width, screen) = (w.width(), w.height().max(1.0)); - if width / screen <= 4.0 / 3.0 { - return 0.0; - } - ((screen - (width / 2.0).min(screen)) / 2.0).max(0.0) + letterbox_bar(width, screen) }); - if *ours && height > 0.0 { - // Once per cinematic, not per frame: the measured crop, so the letterbox is a number in - // the log rather than something only an eye can confirm. - debug!("cinematic: letterbox bar {height:.1} px"); + // On the edges and on a resize, never every frame: the measured crop, so the letterbox is a + // number in the log rather than something only an eye can confirm. + if height != *logged { + *logged = height; + if height > 0.0 { + debug!("cinematic: letterbox bar {height:.1} px"); + } } for (mut node, mut vis) in &mut bars { let want = if height > 0.0 { @@ -506,3 +529,61 @@ fn ack(net: Option<&NetCommands>) { let _ = net.0.send(ClientCommand::CompleteCinematic); } } + +#[cfg(test)] +mod tests { + use super::letterbox_bar; + + /// The reference computes the bars on its native `1024 x 768` sheet, so that sheet is where its + /// own numbers are quotable — and both of its cases have to come out of the one expression. + #[test] + fn the_letterbox_matches_the_reference_on_its_own_sheet() { + // Exactly 4:3 — the branch `CinematicFrame.lua` does NOT take, so the bars stand at the + // size `CinematicFrame.xml` declares: 128 of 768. The formula has to agree there, because + // that agreement is the proof the XML default *is* the formula at the boundary. + assert_eq!(letterbox_bar(1024.0, 768.0), 128.0); + + // Wider: the Lua recomputes, `desiredHeight = width/2`, remainder split evenly. 16:10 on + // the same sheet is 1229 x 768 -> picture 614.5, bars 76.75 apiece. + assert!((letterbox_bar(1228.8, 768.0) - 76.8).abs() < 0.05); + } + + /// The wide case is a **2:1 picture**, whatever the screen: that is the whole point of + /// `desiredHeight = width/2`, and it is the number a player can measure off a screenshot. + #[test] + fn a_widescreen_picture_is_cropped_to_two_to_one() { + for (w, h) in [(1920.0, 1080.0), (2560.0, 1440.0), (1600.0, 900.0)] { + let bar = letterbox_bar(w, h); + let picture = h - 2.0 * bar; + assert!( + (w / picture - 2.0).abs() < 1e-3, + "{w}x{h}: bar {bar} leaves {picture}, aspect {}", + w / picture + ); + } + } + + /// The regression this function exists for: benilla read the Lua's `if` as "only a wide screen + /// gets bars" and drew none at or below 4:3 — the reference's *native* aspect, and the one a + /// windowed player lands on most easily. + #[test] + fn four_three_and_narrower_are_still_letterboxed() { + // 4:3 at a real resolution. + assert!((letterbox_bar(1024.0, 768.0) / 768.0 - 1.0 / 6.0).abs() < 1e-6); + assert!((letterbox_bar(1600.0, 1200.0) / 1200.0 - 1.0 / 6.0).abs() < 1e-6); + // 5:4 and square: the reference leaves its textures alone, so the cap holds at a sixth + // rather than following `width/2` down to a taller bar. + assert!((letterbox_bar(1280.0, 1024.0) / 1024.0 - 1.0 / 6.0).abs() < 1e-6); + assert!((letterbox_bar(768.0, 768.0) / 768.0 - 1.0 / 6.0).abs() < 1e-6); + } + + /// A window can be any shape; none of them may produce a negative or a screen-swallowing bar. + #[test] + fn no_window_shape_produces_a_nonsense_bar() { + for (w, h) in [(1.0, 1.0), (4000.0, 100.0), (100.0, 4000.0), (0.0, 720.0)] { + let bar = letterbox_bar(w, h); + assert!(bar >= 0.0, "{w}x{h} -> {bar}"); + assert!(2.0 * bar <= h, "{w}x{h} -> {bar} swallows the screen"); + } + } +} diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 4eb3a423..7120f82c 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -52,7 +52,7 @@ use carried_light::spawn_carried_lights; /// helm/shoulder resolution, all resolved from the unit descriptor + ItemDisplayInfo and spawned as /// children of the body's attach-point joints. mod equipment; -use equipment::{attach_held_items, resolve_equipment}; +use equipment::{attach_held_items, resolve_corpse_equipment, resolve_equipment}; /// Item / enchant glow effects (decision 0805): the `Spells\Enchantments\*.mdx` models an item's /// `ItemVisuals` id hangs on the item's OWN attachment points — the permanent weapon glows and @@ -70,6 +70,11 @@ use mount::reseat_mounts; /// Live descriptor appearance (decision 0695): a `Values` delta moving the display id swaps the /// model in place (druid forms, GM morphs — ledger B69/F04) and one moving `SCALE_X` eases the /// render scale (the reference's 2 s cosine smoothstep); both restamp the collision height. +/// The corpse OBJECT (decision 1706): a `TYPEID_CORPSE` create rendered as the dead body — the +/// character-model chain dressed from the corpse's own `CORPSE_FIELD_*` snapshot, or the +/// `DeathSkeleton` prop once the server has converted it to bones. +pub(crate) mod corpse; + mod live_display; pub(crate) use live_display::DisplaySwapped; use live_display::{refresh_live_display, tick_scale_ease}; @@ -509,6 +514,9 @@ fn evict_display_caches( gos: Option>, items: Option>, glows: Option>, + // The bone-pile bodies (decision 1706) — 16 at most, but they hold M2 handles like any other + // display cache and must die with the map for the same reason (the teleport asset leak). + mut bones: ResMut, ) { if changes.is_empty() { return; @@ -537,6 +545,7 @@ fn evict_display_caches( if let Some(mut g) = glows { g.models.clear(); } + bones.0.clear(); } /// Expire the streamed-entity dedups by **distance** (decision 0793) — the within-map half of @@ -667,6 +676,9 @@ impl Plugin for EntitiesPlugin { .before(benilla_world::schedule::WorldStage::Input), ) .init_resource::() + // The 16 bone-pile body models (decision 1706), keyed (race, sex) rather than by a + // display id — a skeleton has no CreatureDisplayInfo row. + .init_resource::() .init_resource::() .init_resource::() // The per-caster pending-projectile queues (the client's `unit+0xac` lists). @@ -698,7 +710,10 @@ impl Plugin for EntitiesPlugin { ( // Equipment resolution first (decisions 0072/0074): it *creates* item // DisplayModel entries, which update_display_models then builds the same frame. - resolve_equipment, + // The corpse's dress rides beside it (decision 1706, one nested unordered + // element — the outer tuple is at `chain()`'s 20-element ceiling): a disjoint + // entity set, the same item-model cache, the same must-be-before-the-build. + (resolve_equipment, resolve_corpse_equipment), // Spell-effect resolution likewise creates its path-keyed entries (0099 P3), // as does the missile launcher (P4) — both feed the same SpellFx model cache. resolve_spell_fx, @@ -756,10 +771,13 @@ impl Plugin for EntitiesPlugin { // scan; both only read a player Bevy already advanced in `PreUpdate`, and both // want to run after the attach passes so a fresh instance is covered at once. (spell_fx::fire_fx_anim_events, spell_fx::advance_fx_anim), - // A gear change re-dresses the standing visual in place — a re-composited atlas - // on the same parts, the equipment geosets re-selected, every attachment left - // alone (decision 0835, the reference's own shape). - attach::redress_player_looks, + // One nested (unordered) element — the outer tuple is at `chain()`'s + // 20-element ceiling. Two independent post-attach touch-ups on a standing + // visual: a gear change re-dresses it in place — a re-composited atlas on the + // same parts, the equipment geosets re-selected, every attachment left alone + // (decision 0835, the reference's own shape) — and a freshly attached corpse + // arms its settled Dead/Drowned pose (decision 1706). + (attach::redress_player_looks, corpse::pose_corpses), // A mount transition does the same, and for the same reason (B199): the // field diff **re-seats** the standing rig — onto the mount's attachment-0 // joint, or back onto its own frame — where it used to tear the whole rider @@ -1055,7 +1073,9 @@ fn setup_entities( /// per-submesh material, with creature skin slots filled from the display's variations). #[allow(clippy::too_many_arguments)] fn update_display_models( - entities: Query<&NetEntity>, + // `ObjectStore` rides along for the corpses: which cache holds a corpse's model is a + // descriptor question (`CORPSE_FLAG_BONES`), not a display-id one — decision 1706. + entities: Query<(&NetEntity, Option<&crate::net::ObjectStore>)>, mut creatures: Option>, mut gameobjects: Option>, mut held: Option>, @@ -1065,6 +1085,8 @@ fn update_display_models( mut forms: ResMut, asset_server: Res, mut mats: benilla_world::model_render::M2BatchMaterials, + // The bone-pile display cache + the ChrRaces fileStrings its paths are built from (1706). + mut bones: ResMut, // The glue-preview want (decisions 0423 + 0465): the glue screens' look's body displayId, so // its model builds with no wire entity (the screens run pre-world, where no NetEntity carries it). glue_preview: Option>, @@ -1082,7 +1104,30 @@ fn update_display_models( // The (kind, display) pairs live in the world this frame — cheap to collect. let mut actives: Vec<(EntityKind, u32)> = entities .iter() - .filter_map(|e| e.display_id.map(|d| (e.kind, d))) + // A **bone pile** is skipped here (decision 1706): it carries a perfectly valid display id + // — the server's corpse→bones conversion copies the field verbatim — and the client + // deliberately ignores it, so requesting the body it names would load and build a + // character model nothing will ever render. Its own model is collected below. + .filter(|(e, store)| { + !matches!( + corpse::corpse_model(e, *store), + Some(corpse::CorpseModel::Bones(..)) + ) + }) + .filter_map(|(e, _)| e.display_id.map(|d| (e.kind, d))) + .collect(); + // The bone piles want the OTHER cache (decision 1706): their model is keyed (race, sex), so + // they are collected separately and never enter the display-id want list above — a bone pile's + // display id is a live field the client deliberately ignores, and building the body it names + // would load a model nothing renders. Their FLESH siblings need nothing here: a corpse's + // display id IS a `CreatureDisplayInfo` id, so the `Corpse` arm below resolves it exactly like + // a unit's. + let bones_wanted: Vec<(u8, u8)> = entities + .iter() + .filter_map(|(e, store)| match corpse::corpse_model(e, store) { + Some(corpse::CorpseModel::Bones(race, sex)) => Some((race, sex)), + _ => None, + }) .collect(); // The glue-preview body (decisions 0423 + 0465): a want beside the NetEntity scan, so the // character on the glue stage has a model even though nothing streamed it in. @@ -1116,7 +1161,10 @@ fn update_display_models( match kind { // Players resolve their body model through the very same creature chain (decision 0041): // displayId → CreatureDisplayInfo → CreatureModelData → a `Character\…\…\….mdx` body. - EntityKind::Unit | EntityKind::Player => { + // …and so does a **corpse** that is not a bone pile (decision 1706): its + // `CORPSE_FIELD_DISPLAY_ID` is the dead player's own body display, which is the + // reference's own lookup at `0x5d6759`. + EntityKind::Unit | EntityKind::Player | EntityKind::Corpse => { let Some(cr) = creatures.as_deref_mut() else { continue; }; @@ -1164,6 +1212,29 @@ fn update_display_models( } } + // The bone piles (decision 1706): request each wanted `(race, sex)` skeleton once, then build + // its parts as the M2 lands — the same two-step as every other display, on its own tiny cache. + // Character-creation data is the source of the race fileString the path is built from, so a + // session that could not load it simply shows no skeletons rather than guessing at names. + if let Some(cc) = char_create.as_deref() { + for key in bones_wanted { + corpse::ensure_bones_display(&mut bones, &cc.0, key, &asset_server); + if let Some(dm) = bones.0.get_mut(&key) { + if dm.parts.is_none() { + build_parts( + dm, + m2s, + wmos, + &mut forms, + &asset_server, + &mut mats, + false, // gameobject: a prop body — unit lighting, no hull collider + ); + } + } + } + } + // Held-item displays (decision 0072): entries are created by `resolve_equipment`; build each // one's parts once its M2 loads. Items are static meshes — no collider, unit lighting. if let Some(held) = held.as_deref_mut() { diff --git a/crates/benilla-app/src/entities/attach/char_skin.rs b/crates/benilla-app/src/entities/attach/char_skin.rs index d87c7d70..f9def06a 100644 --- a/crates/benilla-app/src/entities/attach/char_skin.rs +++ b/crates/benilla-app/src/entities/attach/char_skin.rs @@ -80,6 +80,24 @@ pub(super) fn resolve_char_look( }, }); } + // A **corpse** that is not a bone pile (decision 1706): its look is its own + // `CORPSE_FIELD_BYTES_1/_2` snapshot, taken at death — not the owner's live `PLAYER_BYTES`, + // which the corpse cannot see and which may belong to a player who has since logged out. This + // is the reference's own source: `0x5d6260` reads `[[corpse+0x110]+0x69..+0x6f]` into the + // corpse's own `CCharacterComponent`. A bone pile builds no component and reads `None` here, + // which is exactly `0x5d6291`'s early skip. + if net.kind == EntityKind::Corpse && d.is_character_body { + let look = super::super::corpse::corpse_char_look(stores.get(entity).ok())?; + return Some(CharLook { + race: look.race, + sex: look.sex, + skin: look.skin, + hair_style: look.hair_style, + hair_color: look.hair_color, + facial_hair: look.facial_hair, + body: BodySkin::Composite { face: look.face }, + }); + } if net.kind == EntityKind::Player && d.is_character_body { // A player wearing a character body. Race/sex come from `UNIT_FIELD_BYTES_0`; the // `PLAYER_BYTES` / `PLAYER_BYTES_2` customization is *optional on the wire* — vmangos omits an @@ -139,7 +157,11 @@ pub(super) fn resolve_worn_equip( dm: Option<&DisplayModel>, ) -> WornEquip { match net.kind { - EntityKind::Player => equipment + // A **corpse** rides the player arm (decision 1706): its `Equipment` is resolved from the + // 19 `CORPSE_FIELD_ITEM` slots, which are already ItemDisplayInfo ids — the same values a + // player's items resolve to, so the geoset + region-composite law downstream is literally + // the same law, on a different source. + EntityKind::Player | EntityKind::Corpse => equipment .map(|e| WornEquip { bodyslots: e.bodyslots, cloak: e.cloak, diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index 1d91d68d..b02d942a 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -208,6 +208,17 @@ fn arm_sequence_clock( // A loader-idle GameObject needs no driver and no transitions — the looping player IS the // whole animation, and nothing will ever arm over it. EntityKind::GameObject => {} + // A **corpse** gets the clock and the transitions but **no** [`AnimDriver`] (decision + // 1706): its pose is one settled arm ([`super::corpse::pose_corpses`]), and the driver is + // the *unit* gait selector — movement flags, sheath ceremony, combat, fidget — none of + // which an object with no movement block can answer. Enrolling it there would put a + // corpse in Stand. + EntityKind::Corpse => { + commands + .entity(entity) + .insert(AnimationTransitions::new()) + .remove::(); + } _ => { commands .entity(entity) @@ -268,6 +279,8 @@ pub(super) fn attach_entity_visuals( assets: Res, creatures: Option>, gameobjects: Option>, + // The bone-pile body cache (decision 1706) — the corpse lane's second model source. + bones: Res, // Character geoset selection (decision 0041, Milestone B): the customization tables + the entity's // decoded appearance, to pick which body geosets a player shows. Absent ⇒ no filtering (every geoset). characters: Option>, @@ -305,14 +318,38 @@ pub(super) fn attach_entity_visuals( if net.kind == EntityKind::Player && !equipment.is_some_and(|e| e.settled) { continue; } + // A **corpse** waits on the same component for a different reason (decision 1706): its gear + // is final on arrival, so this is not a round-trip wait but a *descriptor* wait — + // `resolve_corpse_equipment` runs one step earlier in this same chain and inserts the + // component from the store. It matters because the corpse lane has no re-dress: its gear + // is a death-time snapshot that cannot change, so a body composited before the descriptor + // landed would stay naked for as long as it lies there. + if net.kind == EntityKind::Corpse && equipment.is_none() { + continue; + } // The entity's built display model (if it has one): None ⇒ no display / not a modeled kind. - let dm = net.display_id.and_then(|disp| match net.kind { - EntityKind::Unit | EntityKind::Player => { - creatures.as_deref().and_then(|c| c.models.get(&disp)) - } - EntityKind::GameObject => gameobjects.as_deref().and_then(|g| g.models.get(&disp)), - _ => None, - }); + // + // A **corpse** is the one kind whose model cache is not chosen by its display id (decision + // 1706): `CORPSE_FLAG_BONES` picks between the ordinary creature chain and the + // `DeathSkeleton` props, exactly as the reference's `0x5d6700` does. The fork + // lives in one function that both this pass and the display build read, so the two can + // never look in different caches for the same body. + let dm = match net.kind { + EntityKind::Corpse => match super::corpse::corpse_model(net, stores.get(entity).ok()) { + Some(super::corpse::CorpseModel::Bones(race, sex)) => bones.0.get(&(race, sex)), + Some(super::corpse::CorpseModel::Flesh(disp)) => { + creatures.as_deref().and_then(|c| c.models.get(&disp)) + } + None => None, + }, + _ => net.display_id.and_then(|disp| match net.kind { + EntityKind::Unit | EntityKind::Player => { + creatures.as_deref().and_then(|c| c.models.get(&disp)) + } + EntityKind::GameObject => gameobjects.as_deref().and_then(|g| g.models.get(&disp)), + _ => None, + }), + }; // Worn equipment driving the geoset selection (and, for a player, the region composite): a // player from its resolved `Equipment`, a character-model NPC from its display's // CreatureDisplayInfoExtra columns. Zeroed (a beast / GameObject / no data) = the naked body. @@ -512,6 +549,17 @@ pub(super) fn attach_entity_visuals( skins, ) } + // A **corpse** (decision 1706). A fresh body is a character model and its held + // Death pose is a bone pose, so it has to skin or it lies in bind pose — standing + // up. The `animations` guard is what keeps a **bone pile** off this path: the 16 + // shipped skeletons are fully static two-bone props with no sequence at all, so + // skinning one could only reproduce the mesh it already renders, at the cost of a + // scarce palette slot (the same trade decision 0941 made for the poseless GOs). + (EntityKind::Corpse, Some(d)) + if !d.skeleton.joints.is_empty() && d.animations.is_some() => + { + setup_skinned_instance(&mut commands, &mut palettes, entity, entity, d, skins) + } // A GameObject whose model authors a real skeleton + animation draws through the // skinned twin like a creature. Two flavours share the rig: a door/button/chest // (`go_animates`) runs the open/close state machine off GAMEOBJECT_STATE (decision @@ -561,7 +609,7 @@ pub(super) fn attach_entity_visuals( // the flame ring's spreading spawn window) reading file slot 0 at t = 0 for ever. if matches!( net.kind, - EntityKind::Unit | EntityKind::Player | EntityKind::GameObject + EntityKind::Unit | EntityKind::Player | EntityKind::GameObject | EntityKind::Corpse ) { if let Some(anims) = dm.and_then(|d| d.animations.as_ref()) { arm_sequence_clock(&mut commands, entity, anims, net.kind, go_state_machine); @@ -843,6 +891,12 @@ pub(super) fn attach_entity_visuals( // (appear ramp, stream-out ramp, the self-avatar feather) multiplies // its clouds — decision 0827. alpha: Some(entity), + // …and the entity IS the light node: one node per object, created + // from its TYPEID and filled every frame whether or not a mesh batch + // consumes it. A LIT emitter is a consumer of that node exactly as a + // lit batch is, and on the Onyxia lava traps it is the ONLY one + // (`crate::entities` → `benilla_world::interior::EmitterLitBy`). + light_node: Some(entity), }, // The emitters' rate/enabled read this instance's PLAYING sequence — a // unit's or GameObject's `AnimationPlayer` on the root. A quest object's @@ -966,6 +1020,20 @@ pub(super) fn attach_entity_visuals( } // A DynamicObject is deliberately invisible as an *object* — its look is the // spell's area effect (the dest-anchored visual lane), never a fallback cube. + // + // A **corpse** takes no cube either (decision 1706), and for the GameObject arm's + // reason rather than the DynamicObject's: it reached here only because its display + // named no model — a `CORPSE_FIELD_DISPLAY_ID` absent from `CreatureDisplayInfo`, + // or a bone pile whose race has no shipped skeleton. Both are the reference's own + // null-row legs, which draw nothing (`0x5d6700` returns 0 and the model never + // loads); a body-sized red box on the ground would read as a bug in itself. + EntityKind::Corpse => { + debug!( + "corpse (display {:?}) has no usable model — not rendering", + net.display_id + ); + None + } EntityKind::DynamicObject | EntityKind::Other => None, }; if let Some((material, mesh, lift)) = fallback { diff --git a/crates/benilla-app/src/entities/corpse.rs b/crates/benilla-app/src/entities/corpse.rs new file mode 100644 index 00000000..19bd5269 --- /dev/null +++ b/crates/benilla-app/src/entities/corpse.rs @@ -0,0 +1,283 @@ +//! **The corpse OBJECT rendered as the dead body** (decision 1706) — the deferral decision 0308 §7 +//! opened ("the corpse *object* rendered as the dead body — CGCorpse law") and this closes. +//! +//! A `TYPEID_CORPSE` (7) object is what you run back to: the body a released player leaves behind, +//! and the **bone pile** the server converts it into once it is reclaimed, looted, or times out. +//! Until 1706 it streamed in, spawned an entity, latched its guid for the reclaim send — and drew +//! nothing at all, because [`EntityKind`] had no variant for it and every model lane matched on +//! `Unit | Player | GameObject`. +//! +//! ## The reference's law (`CGCorpse_C` dress `0x5d6260`, model getter `0x5d6700`) +//! +//! It forks once, on `CORPSE_FIELD_FLAGS` bit 0 (`CORPSE_FLAG_BONES`), and the two halves share +//! nothing: +//! +//! - **A fresh body** (`0x5d6297`) allocates a `CCharacterComponent` at `[corpse+0x29c]` and fills +//! it from the corpse's OWN snapshot — the seven `CORPSE_FIELD_BYTES_1/_2` bytes (`+0x69..+0x6f`) +//! and the 19 `CORPSE_FIELD_ITEM` slots (`+0x1c + slot*4`, low 24 bits = the ItemDisplayInfo id) +//! — through the very same compositor entry `0x478cb0` a living player is dressed by. Its model +//! is `CORPSE_FIELD_DISPLAY_ID` down the ordinary CreatureDisplayInfo → CreatureModelData chain +//! (`0x5d6759`), which is why the whole character pipeline (decisions 0041/0044/0045/0074) simply +//! applies: a corpse *is* a player body wearing a wire-supplied look. +//! - **A bone pile** (`0x5d6291 jne`) builds **no component at all** — no appearance, no gear — and +//! takes its model from race/sex instead: `0x5d670c` formats +//! `World\Generic\PassiveDoodads\DeathSkeletons\%s%sDeathSkeleton.mdx` from `ChrRaces[race]+0x3c` +//! (the client fileString: `"Human"`, `"Scourge"`, …) and `["Male","Female","NOSEX"][sex]`. Those +//! 16 shipped models are fully static, two-boned, one hardcoded texture each. +//! +//! Three slots are carved out of the dress loop, and none of them is a "skip if empty": +//! `0x5d6465`/`0x5d6470` skip slot 0 (head) and slot 0xe (back) when this corpse's own +//! `CORPSE_FLAG_HIDE_HELM 0x08` / `HIDE_CLOAK 0x10` are set — its own bits on its own field, and the +//! opposite instruction polarity to the player lane (wow-re `helm-cloak-hide.md` §2b) — and +//! `0x5d644e` skips slot 0x11 (ranged) unconditionally. +//! +//! ## What a corpse deliberately does NOT wear +//! +//! **No weapons.** Slots 0xf/0x10 detour at `0x5d645a`/`0x5d645f` to `0x5d649b`, which pushes the +//! raw `CORPSE_FIELD_ITEM` word as a **guid** into the object-manager lookup `0x468460` with +//! typemask 2 (`TYPEMASK_ITEM`) and skips the slot on the null return. That word is +//! `DisplayInfoID | (InventoryType << 24)` — never a guid — so the lookup cannot succeed and the +//! branch is dead. We reproduce the *outcome* (a corpse wears armour, not weapons), not the +//! mechanism: aping a dead lookup would be aping a quirk, which §3 of the contract is against. +//! +//! ## The pose +//! +//! `0x5d63de`/`0x5d6402` arm bone 0 through the shared M2 arm `0x7121a0` with **AnimationData id 6 +//! (`Dead`)**, or **132 (`Drowned`)** when `0x5d6540` says so. That predicate is a **liquid** query: +//! `0x670630` over the corpse's scene node (the same call and the same `surfaceZ − subject.z` shape +//! wow-re records for the breath classifier `0x607710` and the pivot-height glide), compared against +//! the f32 at `[0x80abfc]` = **0.66666669** — i.e. a corpse submerged by more than ⅔ yd lies drowned +//! rather than merely dead. Both ids resolve through the model's own fallback table before playing: +//! `HumanMale.m2` authors neither 6 nor 132's head directly, and `AnimationData.dbc` walks +//! `Dead → Death(1)` and `Drowned → Drown(131)`. +//! +//! The clip is armed **once, seeked to its end**. A corpse object never collapses in front of you — +//! it is created after the release, already lying down — so this is the settled-pose arm the unit +//! driver already uses for a body that streamed in dead, not a replay. + +use std::collections::HashMap; + +use benilla_assets::{m2_url, AnimClip, ModelAnimations}; +use benilla_protocol::{CorpseLook, EntityKind}; +use bevy::prelude::*; + +use super::display::{empty_shell, DisplayModel, ModelHandle}; +use crate::creature_anim::AnimData; +use crate::net::{NetEntity, ObjectStore}; + +/// `AnimationData.dbc` **6 `Dead`** — the settled corpse pose (`0x5d63fe push 0x6`). +const DEAD: u16 = 6; +/// `AnimationData.dbc` **132 `Drowned`** — the submerged corpse pose (`0x5d63f7 push 0x84`). +const DROWNED: u16 = 132; +/// `[0x80abfc]`, read out of the shipped PE's `.rdata`: how far a corpse must be under a liquid +/// surface before it lies drowned rather than dead. Yards. +const DROWNED_DEPTH: f32 = 0.666_666_7; + +/// The **bone-pile** body models, keyed `(race, sex)` — 16 at most, one per playable ChrRaces row × +/// sex, and every one a static two-bone prop. Kept out of [`super::Creatures`] on purpose: that map +/// is keyed by `CreatureDisplayInfo` id, a real DBC keyspace, and a skeleton has no id in it. +#[derive(Resource, Default)] +pub(crate) struct BonesModels(pub(crate) HashMap<(u8, u8), DisplayModel>); + +/// Where one corpse's body model comes from — the `0x5d6700` fork, resolved once and read by both +/// the display-build pass and the attach pass so the two can never disagree about which cache holds +/// this corpse's model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::entities) enum CorpseModel { + /// `CORPSE_FLAG_BONES` — `DeathSkeleton`, from [`BonesModels`]. + Bones(u8, u8), + /// A fresh body — a `CreatureDisplayInfo` id, from [`super::Creatures`] like any other body. + Flesh(u32), +} + +/// Classify a corpse entity's model source. `None` when it is not a corpse, or while its descriptor +/// has not landed (the one-frame window between the create's entity spawn and the pending-fields +/// flush), or for a bone pile whose race/sex the client data cannot name. +pub(in crate::entities) fn corpse_model( + net: &NetEntity, + store: Option<&ObjectStore>, +) -> Option { + if net.kind != EntityKind::Corpse { + return None; + } + let s = &store?.0; + if s.corpse_is_bones() { + let look = s.corpse_look()?; + return Some(CorpseModel::Bones(look.race, look.sex.min(1))); + } + // The wire's display id, which for a corpse is the dead player's own body display. A corpse + // whose create carried none has no body to build — the debug cube would be the wrong answer + // here (nothing named a model), so it draws nothing, like the reference's null-row leg. + net.display_id.map(CorpseModel::Flesh) +} + +/// The corpse's appearance snapshot — [`ObjectFields::corpse_look`](benilla_protocol::ObjectFields::corpse_look) +/// for a corpse that is not a bone pile. `None` for anything else, which is exactly the reference's +/// gate: a bone pile builds no character component, so it has no look to read. +pub(in crate::entities) fn corpse_char_look(store: Option<&ObjectStore>) -> Option { + let s = &store?.0; + (!s.corpse_is_bones()).then(|| s.corpse_look())? +} + +/// `World\Generic\PassiveDoodads\DeathSkeletons\DeathSkeleton.m2` — `0x5d673c`'s format +/// string `0x85fb30` with `ChrRaces[race]` column 15 and the sex table `0x856450`. +/// +/// Sex is clamped to the two shipped halves: the reference's third string is `"NOSEX"`, for which no +/// skeleton file exists, and no player corpse can carry it. +fn bones_model_path(race_file: &str, sex: u8) -> String { + let sex = if sex == 0 { "Male" } else { "Female" }; + format!("World\\Generic\\PassiveDoodads\\DeathSkeletons\\{race_file}{sex}DeathSkeleton.mdx") +} + +/// Ensure a `(race, sex)` bone-pile display exists in the cache, requesting its model on first ask. +/// A race with no `ChrRaces` fileString (nothing playable — no skeleton ships) caches an empty +/// display, so the miss is asked once rather than every frame. +pub(in crate::entities) fn ensure_bones_display( + bones: &mut BonesModels, + races: &benilla_formats::CharCreateCatalog, + key: (u8, u8), + asset_server: &AssetServer, +) { + if bones.0.contains_key(&key) { + return; + } + let dm = match races.race_file(key.0) { + Some(file) => DisplayModel { + handle: ModelHandle::M2(asset_server.load(m2_url(&bones_model_path(file, key.1)))), + ..empty_shell() + }, + None => super::display::empty_display(), + }; + bones.0.insert(key, dm); +} + +/// This corpse's held pose has been armed — the arm is once-only, like the reference's, whose +/// per-frame `0x5d6850` re-arms the *same* id and so is a hold, not a replay. +#[derive(Component)] +pub(super) struct CorpsePosed; + +/// Arm each freshly-attached corpse's settled pose: `Dead`, or `Drowned` when it lies more than +/// [`DROWNED_DEPTH`] under a liquid surface, seeked to the clip's end. +/// +/// Runs off the attach's own output (an `AnimationPlayer` + the model's [`ModelAnimations`]) rather +/// than off a driver: a corpse has no state machine to run, and giving it an +/// [`AnimDriver`](crate::creature_anim::AnimDriver) would enrol it in the unit gait selector, which +/// has no meaning for an object with no movement, no sheath and no combat. +#[allow(clippy::type_complexity)] // one query's tuple + its Without filter +pub(super) fn pose_corpses( + mut commands: Commands, + mut corpses: Query< + ( + Entity, + &NetEntity, + &GlobalTransform, + &ModelAnimations, + &mut AnimationPlayer, + &mut bevy::animation::transition::AnimationTransitions, + ), + Without, + >, + anim_data: Option>, + world: benilla_world::world_point::WorldPoint, +) { + for (entity, net, tf, anims, mut player, mut transitions) in &mut corpses { + if net.kind != EntityKind::Corpse { + continue; + } + let wow = benilla_assets::coords::bevy_to_wow(tf.translation()); + // The reference's `0x5d6540`: ANY liquid, not water alone — it is the generic `0x670630` + // query, the same one the breath classifier runs, so lava and slime count too. + let submerged = world + .liquid_at(benilla_world::world_point::Subject::Unit(entity), wow) + .is_some_and(|hit| hit.surface_z - wow[2] > DROWNED_DEPTH); + let want = if submerged { DROWNED } else { DEAD }; + // The model's own fallback resolution (decision 0082) — a character body authors neither + // `Dead` nor `Drowned` directly and walks to `Death`/`Drown`. + let catalog = anim_data.as_deref().map(|a| &a.0); + let resolved = catalog.map_or(want, |cat| anims.resolve(want, cat).id); + let Some(clip) = anims.find(resolved) else { + // No clip to hold: mark it posed anyway so this doesn't re-run every frame for a body + // whose model authors nothing (a bone pile — static, and correct as it stands). + commands.entity(entity).insert(CorpsePosed); + continue; + }; + arm_settled(&mut player, &mut transitions, clip); + debug!( + "corpse pose: {entity} arms {} ({want} -> {resolved}) held at {:.3}s", + if submerged { "Drowned" } else { "Dead" }, + clip.duration + ); + commands.entity(entity).insert(CorpsePosed); + } +} + +/// Play `clip` and hold it at its end pose — the settled corpse, never the collapse. +fn arm_settled( + player: &mut AnimationPlayer, + transitions: &mut bevy::animation::transition::AnimationTransitions, + clip: &AnimClip, +) { + let active = transitions.play(player, clip.node, std::time::Duration::ZERO); + if clip.looping { + active.repeat(); + } else { + active.seek_to(clip.duration); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The 16 shipped skeleton files, spelled exactly as `0x5d673c`'s format string builds them. + #[test] + fn bones_paths_match_the_shipped_files() { + assert_eq!( + bones_model_path("Human", 0), + "World\\Generic\\PassiveDoodads\\DeathSkeletons\\HumanMaleDeathSkeleton.mdx" + ); + assert_eq!( + bones_model_path("Scourge", 1), + "World\\Generic\\PassiveDoodads\\DeathSkeletons\\ScourgeFemaleDeathSkeleton.mdx" + ); + // `.mdx` is the authored extension the client formats; the loader swaps it for the shipped + // `.m2` like every other model path. + assert_eq!( + m2_url(&bones_model_path("NightElf", 0)), + "mpq://world/generic/passivedoodads/deathskeletons/nightelfmaledeathskeleton.m2" + ); + } + + /// The model fork is the BONES bit, and nothing else: a bone pile carries a perfectly good + /// display id (the server's conversion copies it verbatim) and must still resolve to a skeleton. + #[test] + fn bones_flag_beats_the_display_id() { + use benilla_protocol::ObjectFields; + let net = NetEntity { + kind: EntityKind::Corpse, + display_id: Some(49), + scale: 1.0, + }; + // race 1 (Human), sex 0, in CORPSE_FIELD_BYTES_1 bytes 1/2. + let bytes_1 = 1u32 << 8; + let flesh = ObjectStore( + ObjectFields::from_pairs(&[(32, bytes_1), (33, 0)]) + .into_created(benilla_protocol::messages::ObjectType::Corpse), + ); + assert_eq!( + corpse_model(&net, Some(&flesh)), + Some(CorpseModel::Flesh(49)) + ); + let bones = ObjectStore( + ObjectFields::from_pairs(&[(32, bytes_1), (33, 0), (35, 0x01)]) + .into_created(benilla_protocol::messages::ObjectType::Corpse), + ); + assert_eq!( + corpse_model(&net, Some(&bones)), + Some(CorpseModel::Bones(1, 0)) + ); + // …and a bone pile has no look to dress from, where a fresh body does. + assert!(corpse_char_look(Some(&bones)).is_none()); + assert!(corpse_char_look(Some(&flesh)).is_some()); + } +} diff --git a/crates/benilla-app/src/entities/equipment/mod.rs b/crates/benilla-app/src/entities/equipment/mod.rs index 99a504dd..1981f906 100644 --- a/crates/benilla-app/src/entities/equipment/mod.rs +++ b/crates/benilla-app/src/entities/equipment/mod.rs @@ -37,8 +37,8 @@ use benilla_assets::m2_url; mod resolve; pub(in crate::entities) use resolve::placement; -pub(super) use resolve::resolve_equipment; pub(crate) use resolve::DressKey; +pub(super) use resolve::{resolve_corpse_equipment, resolve_equipment}; mod spawn; pub(super) use spawn::attach_held_items; diff --git a/crates/benilla-app/src/entities/equipment/resolve.rs b/crates/benilla-app/src/entities/equipment/resolve.rs index 2757d61d..320d4ac6 100644 --- a/crates/benilla-app/src/entities/equipment/resolve.rs +++ b/crates/benilla-app/src/entities/equipment/resolve.rs @@ -622,17 +622,222 @@ pub(in crate::entities) fn resolve_equipment( } } +/// **The corpse's dress** (decision 1706) — [`resolve_equipment`]'s sibling for a `TYPEID_CORPSE` +/// body, kept apart from it rather than threaded through it because almost nothing it does applies. +/// +/// A corpse's gear is a *snapshot*, and it is already resolved: the 19 `CORPSE_FIELD_ITEM` slots +/// carry `DisplayInfoID | (InventoryType << 24)` (vmangos `Player.cpp:4821`), so there is no item +/// entry, no template round trip, and therefore no `settled` handshake to wait on — the answer is +/// complete the moment the descriptor lands. There is likewise no sheath state, no draw/stow +/// ceremony, no nock lane, no quiver, no enchant scan and no glow: the reference's corpse dress +/// (`0x5d6260`) is one flat loop over the 19 slots into `0x478cb0`, and nothing else. +/// +/// **Three slots never dress**, and the two shapes are different: +/// - slot 0 (head) when this corpse's own `CORPSE_FLAG_HIDE_HELM 0x08` is set, and slot 14 (back) +/// when `HIDE_CLOAK 0x10` is (`0x5d6465`/`0x5d6470` — its own bits on its own field, snapshotted +/// from `PLAYER_FLAGS` at death; wow-re `helm-cloak-hide.md` §2b). Suppression is a display id of +/// **zero**, the same shape the player lane uses (decision 1472), so the whole downstream chain — +/// the helm's attach model, its RF-0083 hide-masks, the cloak's geoset and cape texture — follows +/// for free. +/// - slots 15/16/17 (mainhand, offhand, ranged) — **always**. Ranged is skipped outright +/// (`0x5d644e`); the two weapon slots take a branch that looks up the packed item word as an +/// *object guid* (`0x5d649b` → `0x468460`, typemask 2) and can therefore never resolve. A corpse +/// wears armour, not weapons. See this module's sibling `entities::corpse` for why we reproduce +/// the outcome and not the dead lookup. +/// +/// A **bone pile** wears nothing at all (`0x5d6291`'s early skip — it builds no character component +/// in the first place). It still gets an empty [`Equipment`], because that component is also the +/// attach gate: a corpse whose descriptor has not landed must wait a frame rather than composite a +/// naked body it would never re-dress (the corpse lane has no re-dress — its gear cannot change). +#[allow(clippy::type_complexity)] // one query's tuple + its change filter +pub(in crate::entities) fn resolve_corpse_equipment( + mut commands: Commands, + corpses: Query< + (Entity, &NetEntity, Ref, Option<&Equipment>), + Or<(Changed, Without)>, + >, + held: Option>, + asset_server: Res, +) { + let Some(mut held) = held else { + return; + }; + for (entity, net, store, current) in &corpses { + if net.kind != EntityKind::Corpse { + continue; + } + let s = &store.0; + let bones = s.corpse_is_bones(); + // The armour composite + the two preference-gated slots. `settled` is unconditionally + // true: every id here is final on arrival. + let mut eq = Equipment { + settled: true, + ..default() + }; + if !bones { + for (slot, idx) in COMPOSITE_SLOTS { + eq.bodyslots[idx] = s.corpse_item(slot).map_or(0, |(display, _)| display); + } + if !s.corpse_hides_cloak() { + eq.cloak = s.corpse_item(14).map_or(0, |(display, _)| display); + } + if !s.corpse_hides_helm() { + eq.helm = s.corpse_item(0).map_or(0, |(display, _)| display); + } + } + if current != Some(&eq) { + commands.entity(entity).insert(eq); + } + // The two attach sub-models a corpse can wear: the helm (equipment slot 0, per-race/sex + // file) and the shoulder pair (slot 2, one display row → a left/right model pair). Both are + // ordinary `0x478cb0` slots in the reference's loop; they are attachments on our side + // because that is how a character body carries them. + let mut slots: [Option; ATTACH_SLOTS] = [None; ATTACH_SLOTS]; + if let (false, Some(look)) = (bones, s.corpse_look()) { + let (race, sex) = (look.race, look.sex.min(1)); + if eq.helm != 0 { + let kind = ItemModelKind::Helm { race, sex }; + ensure_item_model(&mut held, eq.helm, kind, &asset_server); + slots[3] = Some(HeldSlot { + display: eq.helm, + kind, + attach: attach_id::HELM, + visual: NO_GLOW, + }); + } + // Shoulders carry no hide preference — the reference gates only slots 0 and 0xe. + if let Some((shoulder, _)) = s.corpse_item(2) { + for (kind, attach, idx) in [ + (ItemModelKind::ShoulderLeft, attach_id::SHOULDER_LEFT, 4), + (ItemModelKind::ShoulderRight, attach_id::SHOULDER_RIGHT, 5), + ] { + ensure_item_model(&mut held, shoulder, kind, &asset_server); + slots[idx] = Some(HeldSlot { + display: shoulder, + kind, + attach, + visual: NO_GLOW, + }); + } + } + } + commands.entity(entity).insert(HeldItems { slots }); + } +} + #[cfg(test)] mod tests { use bevy::prelude::*; + use super::super::HELD_SLOTS; use super::super::{Equipment, HeldItems, ItemDisplays}; - use super::{ammo_attach, attach_id, placement, resolve_equipment}; + use super::{ammo_attach, attach_id, placement, resolve_corpse_equipment, resolve_equipment}; use crate::items::Items; use crate::net::{ClientCommand, NetCommands, NetEntity, ObjectStore}; use benilla_protocol::messages::{ItemInfo, ObjectFields}; use benilla_protocol::EntityKind; + /// A corpse's dress is the descriptor, packed (decision 1706): armour off the + /// `CORPSE_FIELD_ITEM` slots as ItemDisplayInfo ids with no template round trip, the head and + /// back slots suppressed by the corpse's OWN `CORPSE_FLAG_HIDE_HELM`/`HIDE_CLOAK` bits, and + /// **never** a weapon or a ranged slot. + #[test] + fn corpse_dresses_from_its_own_snapshot() { + use benilla_protocol::messages::ObjectType; + + /// `CORPSE_FIELD_ITEM + slot` = field 13 + slot, packing + /// `DisplayInfoID | (InventoryType << 24)`. + fn item(slot: u16, display: u32, inv: u32) -> (u16, u32) { + (13 + slot, display | (inv << 24)) + } + // Head 900, shoulders 901, chest 902, back 903, mainhand 904, ranged 905 — and race 1 / + // sex 0 in CORPSE_FIELD_BYTES_1 bytes 1/2. + let dressed = |flags: u32| { + let mut pairs = vec![ + item(0, 900, 1), + item(2, 901, 3), + item(4, 902, 5), + item(14, 903, 16), + item(15, 904, 13), + item(17, 905, 15), + (32, 1 << 8), + (33, 0), + ]; + if flags != 0 { + pairs.push((35, flags)); + } + ObjectStore(ObjectFields::from_pairs(&pairs).into_created(ObjectType::Corpse)) + }; + let run = |store: ObjectStore| { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default())); + app.insert_resource(ItemDisplays::icons_for_tests( + benilla_formats::ItemDisplayCatalog::from_displays( + std::collections::HashMap::new(), + ), + )); + let corpse = app + .world_mut() + .spawn(( + NetEntity { + kind: EntityKind::Corpse, + display_id: Some(49), + scale: 1.0, + }, + store, + )) + .id(); + app.add_systems(Update, resolve_corpse_equipment); + app.update(); + let w = app.world(); + ( + *w.get::(corpse).expect("corpse dressed"), + w.get::(corpse).expect("attach slots").clone(), + ) + }; + + let (eq, held) = run(dressed(0)); + // Chest is composite index 1 (the COMPOSITE_SLOTS table's equipment slot 4). + assert_eq!(eq.bodyslots[1], 902, "chest off CORPSE_FIELD_ITEM[4]"); + assert_eq!(eq.helm, 900); + assert_eq!(eq.cloak, 903); + assert!( + eq.settled, + "a corpse's gear is final on arrival — never pending" + ); + assert!(held.slots[3].is_some(), "the helm attaches"); + assert!( + held.slots[4].is_some() && held.slots[5].is_some(), + "the shoulder pair attaches off equipment slot 2" + ); + // The three slots the reference never dresses: mainhand, offhand, ranged. + assert!( + held.slots[..HELD_SLOTS].iter().all(Option::is_none), + "a corpse wears armour, never weapons" + ); + + // HIDE_HELM 0x08 / HIDE_CLOAK 0x10 — this corpse's own bits, suppressing by a ZERO id so + // the geoset masks and the attach model fall away together. + let (eq, held) = run(dressed(0x08)); + assert_eq!(eq.helm, 0); + assert_eq!(eq.cloak, 903, "the cloak bit is a different bit"); + assert!(held.slots[3].is_none(), "no helm model either"); + let (eq, _) = run(dressed(0x10)); + assert_eq!(eq.cloak, 0); + assert_eq!(eq.helm, 900); + + // BONES 0x01 — no character component at all, so nothing is worn however full the slots. + let (eq, held) = run(dressed(0x01)); + assert_eq!( + eq, + Equipment { + settled: true, + ..default() + } + ); + assert!(held.slots.iter().all(Option::is_none)); + } + /// The ranged slot's whole placement law: in hand only while ranged-drawn (state 2) — bow /// (INVTYPE_RANGED 15) to the left hand, gun/crossbow/wand/thrown (RANGEDRIGHT 26 / THROWN 25) /// to the right — and invisible in every other sheath state, regardless of the item's own diff --git a/crates/benilla-app/src/entities/equipment/spawn.rs b/crates/benilla-app/src/entities/equipment/spawn.rs index edab76c4..d09393e9 100644 --- a/crates/benilla-app/src/entities/equipment/spawn.rs +++ b/crates/benilla-app/src/entities/equipment/spawn.rs @@ -726,6 +726,11 @@ fn spawn_slot( // (`0x714000`) — so the sparkle on a pauldron fades in with the body wearing it // and vanishes with the avatar in first person (0827/0833). alpha: Some(root), + // The light node is the WEARER's, never the item's: the reference has one node + // per object and aliases the wearer's collector into each attached model + // (`[item+0x3b8] = [wearer+0x3b8]`, `0x718960`) — the same rule this item's mesh + // batches already classify under (`BodyBakeCenter`). + light_node: Some(entity), }, // A held item spawns no rig; its emitters run the item model's own slot-0 // loop on the spawn clock (the torch burns always — the doodad law). diff --git a/crates/benilla-app/src/entities/live_display.rs b/crates/benilla-app/src/entities/live_display.rs index 58b6a9b0..f86c37f4 100644 --- a/crates/benilla-app/src/entities/live_display.rs +++ b/crates/benilla-app/src/entities/live_display.rs @@ -70,12 +70,28 @@ pub(super) struct ScaleEase { /// (a real morph never zeroes it; the create block's absent-is-zero fold means `0` also reads /// "never sent", so neither tears a visual down to a cube). fn live_display_id(kind: EntityKind, store: &ObjectStore) -> Option { - let raw = match kind { - EntityKind::Unit | EntityKind::Player => store.0.unit_displayid(), - EntityKind::GameObject => store.0.gameobject_displayid(), + match kind { + EntityKind::Unit | EntityKind::Player => store + .0 + .unit_displayid() + .filter(|&d| d > 0) + .map(|d| d as u32), + EntityKind::GameObject => store + .0 + .gameobject_displayid() + .filter(|&d| d > 0) + .map(|d| d as u32), + // A corpse's body display (decision 1706). It is not expected to move — the field is a + // death-time snapshot — but it is the same field the create interpreted, so the same + // differ answers for it. **Not** covered here: the flesh→bones flip, which the reference + // reacts to with a full model reload (its `CORPSE_FIELD_FLAGS` mirror handler + // `0x5d5fa0`→`0x5d6d60`). vmangos cannot produce it — the conversion destroys the corpse + // object and creates a *separate* bones object under a new guid (`Map.cpp:3625`, + // `RemoveCorpses`) — so the reload path is deliberately unbuilt rather than speculatively + // modelled; see decision 1706's named omissions. + EntityKind::Corpse => store.0.corpse_display_id(), _ => None, - }?; - (raw > 0).then_some(raw as u32) + } } /// Diff each attached entity's live descriptor appearance against what its visual was built with, @@ -154,7 +170,7 @@ pub(super) fn refresh_live_display( // a scale the create deliberately floored to 1.0. let scaled_kind = matches!( net.kind, - EntityKind::Unit | EntityKind::Player | EntityKind::GameObject + EntityKind::Unit | EntityKind::Player | EntityKind::GameObject | EntityKind::Corpse ); if let Some(live) = store.0.object_scale_x().filter(|s| *s > 0.0 && scaled_kind) { if live != net.scale { diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs index 40c66442..a24aca9a 100644 --- a/crates/benilla-app/src/entities/spell_fx/mod.rs +++ b/crates/benilla-app/src/entities/spell_fx/mod.rs @@ -426,6 +426,10 @@ pub(crate) fn attach_effect_visuals( // This instance IS the model these particles belong to; its chain (set above) // carries the host's fade down to them — decision 0833. alpha: Some(root), + // A spell effect has no light node wired: the whole `Spells\` corpus SETS the + // emitter unlit bit, so nothing here consumes one today. A lit one would fall + // back to the scene's light, which is what it takes now. + light_node: None, }, // The emitters' rate/enabled windows ride the played sequence: a `CEffect` ADVANCES // (`Stand` → `Hold` → `Decay`), so it reads the live one off its own player like a diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index b9f8b265..ea4e298d 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -358,6 +358,11 @@ pub(super) fn spawn_wmo_gameobject_props( on_owner_loss: particles::OwnerLoss::Free, // A placed prop carries no fade component of its own; its emitters // take the doodad distance fade in the sim instead (0827). + // A WMO prop is lit by the CMapDoodadDef provider (`0x6a8050`), not the + // WENTITY one this edge feeds — its interior words come from its own MODD + // colour and are folded at spawn (`PropLight::Interior`). Wiring that twin + // into the emitter lane is a named residual, not this lane's node. + light_node: None, alpha: None, }, // A placed prop: the doodad law — which is NOT one arm for life. It re-rolls diff --git a/crates/benilla-app/src/net/apply/death.rs b/crates/benilla-app/src/net/apply/death.rs index 9dd97681..93ca1c1e 100644 --- a/crates/benilla-app/src/net/apply/death.rs +++ b/crates/benilla-app/src/net/apply/death.rs @@ -15,8 +15,9 @@ use crate::ui_action::UiErrorTexts; use super::super::SelfGuid; /// OUR corpse streaming into range (a `TYPEID_CORPSE` create whose owner is us): remember its guid -/// for the reclaim send (decision 0308 §5). Corpses classify as [`EntityKind::Other`], and the -/// owner field is corpse-only, so the filter is exact. Rides the `ObjectCreate` arm. +/// for the reclaim send (decision 0308 §5). The kind is exact ([`EntityKind::Corpse`] since 1706 — +/// it was `Other` while nothing rendered a corpse), and the owner field is corpse-only, so nothing +/// else can match. Rides the `ObjectCreate` arm. pub(super) fn note_corpse( guid: u64, kind: EntityKind, @@ -24,7 +25,7 @@ pub(super) fn note_corpse( self_guid: &SelfGuid, death_net: &mut DeathNet, ) { - if kind == EntityKind::Other && fields.corpse_owner() == self_guid.0 { + if kind == EntityKind::Corpse && fields.corpse_owner() == self_guid.0 { death_net.corpse_guid = Some(guid); } } diff --git a/crates/benilla-app/src/pipe_warm/mod.rs b/crates/benilla-app/src/pipe_warm/mod.rs index 5d531c1e..a2c91548 100644 --- a/crates/benilla-app/src/pipe_warm/mod.rs +++ b/crates/benilla-app/src/pipe_warm/mod.rs @@ -636,7 +636,10 @@ fn warm_effect_lane( // module exists to prevent (0937's holes, 0958's blind lanes). The lit arm is // rare content (400 of 7792 emitters) which is exactly why it would otherwise // never be warm when it finally shows up. - for lit in [false, true] { + for lighting in [ + benilla_world::particles::buffer::EffectLighting::None, + benilla_world::particles::buffer::EffectLighting::Scene, + ] { let start = quads.begin(); for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { quads.verts.push(EffectVertex { @@ -652,7 +655,7 @@ fn warm_effect_lane( texture: tex.id(), blend, fog: EffectFog::Off, - lit, + lighting, anchor: Vec3::ZERO, bias: 0.0, raster_bias, diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index a7f4a2e4..2bb13c5e 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -245,6 +245,10 @@ pub(super) fn spawn_booth_effects( // A booth bake has no appear/despawn ramp and no self-avatar feather — its // riders are always opaque (0827). alpha: None, + // A booth's light is its own buffer (`EffectLightOverride` below), never a + // world light node — the world's would fog and shade the pane by the world's + // time of day. + light_node: None, }, benilla_world::particles::EmitClock::Pinned, // an item's effects loop forever ) else { @@ -325,6 +329,9 @@ pub(super) fn spawn_booth_own_emitters( benilla_world::particles::EmitterFrames { owner: Some(owner), anchor: Some(root), + // A booth's light is its own buffer (`EffectLightOverride` below), never a world + // light node — the world's would fog and shade the pane by the world's time of day. + light_node: None, // The bake root is torn down and rebuilt as a whole. on_owner_loss: benilla_world::particles::OwnerLoss::Free, // A booth bake has no appear/despawn ramp and no self-avatar feather (0827). diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index 1e980b4b..9ce36424 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -172,7 +172,11 @@ pub(super) fn update_hover( ), With, >, - units: Query<&Guid, Without>, + // The pick's guid resolution — **and its kind**, which the skinless fallback below needs to + // apply the same eligibility rule the faithful path states inline (decision 1706: a corpse + // renders, and renders as a character body with pickable parts, but it is not a unit and a + // click must never send `CMSG_SET_SELECTION` for it). + units: Query<(&Guid, &NetEntity), Without>, ) { hovered.target = None; hovered.guid = None; @@ -194,7 +198,7 @@ pub(super) fn update_hover( // (visually topmost) plate wins an overlap. if let Some(&(_, entity)) = plate_rects.0.iter().rev().find(|(r, _)| r.contains(cursor)) { hovered.target = Some(entity); - hovered.guid = units.get(entity).ok().map(|g| g.0); + hovered.guid = units.get(entity).ok().map(|(g, _)| g.0); hovered.distance = 0.0; // a plate is topmost UI — it beats any world GameObject at a tie *last_pick = Some(entity); return; @@ -328,8 +332,20 @@ pub(super) fn update_hover( continue; } let parent = child_of.parent(); - if faithful.contains(&parent) || units.get(parent).is_err() { - continue; // posed-mesh-tested above, or not a targetable unit (e.g. the self player) + if faithful.contains(&parent) { + continue; // posed-mesh-tested above + } + // Not a targetable unit (the self player; or — since 1706 — a **corpse**, which is a + // streamed body with a guid, a character model and pickable parts, and yet is not a unit: + // the reference's own interact slot for `CGCorpse_C` is `0x5d6bf0` (release/loot), never + // `SetTarget`. The faithful path states this rule inline at its head; without it here the + // corpse would be pass-2-eligible through the back door and a left click on one would send + // a selection for a guid no unit owns. + let Ok((_, parent_net)) = units.get(parent) else { + continue; + }; + if !matches!(parent_net.kind, EntityKind::Unit | EntityKind::Player) { + continue; } if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) { if best.is_none_or(|(bt, _)| t < bt) { @@ -370,7 +386,7 @@ pub(super) fn update_hover( } if let Some((t, entity)) = best { hovered.target = Some(entity); - hovered.guid = units.get(entity).ok().map(|g| g.0); + hovered.guid = units.get(entity).ok().map(|(g, _)| g.0); hovered.distance = t; } *last_pick = best.map(|(_, e)| e); diff --git a/crates/benilla-app/src/ui_script/cinematic_tests.rs b/crates/benilla-app/src/ui_script/cinematic_tests.rs new file mode 100644 index 00000000..ad31d017 --- /dev/null +++ b/crates/benilla-app/src/ui_script/cinematic_tests.rs @@ -0,0 +1,129 @@ +//! **The cinematic frame's key-down consumption** — what makes a fly-by something you *watch*. +//! +//! `CinematicFrame` is fullscreen, keyboard-enabled, and carries an `OnKeyDown` that answers +//! ESCAPE. In the reference that is enough to swallow **every** key while it is up, because the +//! key-down walk's gate is EXISTENCE, not handling (wow-re `ui/scratch/frame-key-script-delivery.md` +//! §3, VERIFIED): a shown keyboard frame with the slot set consumes the key whatever its script +//! does with it, and a 1.12 handler has no way to signal "not handled" (§3.1). +//! +//! The reference's own Lua is the proof, and it is why these tests exist: that same `OnKeyDown` has +//! to call `RunBinding("SCREENSHOT")` **by hand** to get one key back. It would not need to if +//! unhandled keys fell through to their bindings. +//! +//! benilla had the walk (decision 1319) but fed it only ten key names, so ESCAPE was consumed and +//! `W` was not — and the player could walk around underneath their own intro cinematic. + +use benilla_ui::script::UiScript; + +fn load_xml(s: &UiScript, file: &str) { + let text = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("assets/ui") + .join(file), + ) + .unwrap(); + let doc = benilla_ui::framexml::parse(&text).unwrap(); + let report = benilla_ui::loader::load(s, &doc, &|_| None); + assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); +} + +/// The frame tree a cinematic actually runs against: `UIParent` (whose `ShowUIPanel` the frame's +/// `CINEMATIC_START` arm calls) and the cinematic frame itself. +fn ui_with_the_cinematic_frame() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "Fonts.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "MoneyFrame.xml"); // StaticPopup's money row, or UiPanels errors at load + load_xml(&s, "UiPanels.xml"); + load_xml(&s, "CinematicFrame.xml"); + s.resolve(); + s +} + +/// Show the cinematic frame the way the engine does — the `CINEMATIC_START` edge `feed_ui` fires. +fn start_cinematic(s: &mut UiScript) { + s.set_in_cinematic(true); + s.fire_event("CINEMATIC_START", vec![]); + s.resolve(); +} + +/// With a fly-by on screen, the movement keys are the cinematic frame's, not the world's. +/// +/// `frame_key_input` returning `true` is exactly the consumption that suppresses the key's binding +/// and the gameplay readers ([`super::UiKeyboardCapture`]) — so this *is* the assertion that you +/// cannot walk during a cinematic. +#[test] +fn a_playing_cinematic_swallows_the_movement_keys() { + let mut s = ui_with_the_cinematic_frame(); + + // Before it starts, the frame is hidden and declines: the world keeps its keys. + for key in ["W", "A", "S", "D", "SPACE", "1"] { + assert!( + !s.frame_key_input(key), + "{key} must reach the world while no cinematic is playing" + ); + } + + start_cinematic(&mut s); + assert_eq!( + s.eval::("return CinematicFrame:IsVisible() and 1 or 0") + .unwrap(), + 1, + "the frame itself is up — `InCinematic()` would only echo the flag this test just set" + ); + for key in ["W", "A", "S", "D", "SPACE", "1", "F1", "P"] { + assert!( + s.frame_key_input(key), + "{key} must be swallowed by the cinematic frame" + ); + } +} + +/// The two keys the reference deliberately keeps working, and the third it does not. +/// +/// ESCAPE ends the shot; the screenshot binding is re-run by hand from inside the handler +/// (`RunBinding("SCREENSHOT")`) precisely *because* consumption is unconditional. Both are +/// consumed at the walk either way — "keeps working" means the frame's own script acts on them, +/// never that they fall through. +#[test] +fn escape_is_consumed_and_acted_on_rather_than_falling_through() { + let mut s = ui_with_the_cinematic_frame(); + start_cinematic(&mut s); + + assert!( + s.frame_key_input("ESCAPE"), + "ESCAPE is consumed like any key" + ); + assert!( + s.take_session_requests() + .iter() + .any(|r| matches!(r, benilla_ui::script::SessionRequest::StopCinematic)), + "and its handler asked the engine to stop the cinematic" + ); +} + +/// The host feed itself: every key the walk is supposed to carry must actually *have* a name, or +/// the delivery in [`super::input`] is a silent no-op for it. This is the half that was missing — +/// the walk was faithful, the feed was ten keys wide. +#[test] +fn the_host_has_a_reference_name_for_the_keys_it_now_delivers() { + use crate::bindings::chord::key_token; + use bevy::prelude::KeyCode; + + for (code, name) in [ + (KeyCode::KeyW, "W"), + (KeyCode::KeyA, "A"), + (KeyCode::KeyS, "S"), + (KeyCode::KeyD, "D"), + (KeyCode::Space, "SPACE"), + (KeyCode::Digit1, "1"), + (KeyCode::F1, "F1"), + ] { + assert_eq!( + key_token(code), + Some(name), + "{code:?} must reach a keyboard frame under the reference's own name" + ); + } +} diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index a1ecbeb9..672e24b4 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -277,6 +277,30 @@ pub(super) fn feed_ui_input( continue; } } + // EVERY OTHER KEY reaches a keyboard frame by name too. The reference's key-down walk + // carries the whole key table — its `arg1` comes from the *same* table the keybinding + // chord uses (wow-re `frame-key-script-delivery.md` §4.2), which is [`chord::key_token`] + // here — and the frame's gate is EXISTENCE, not handling: a shown keyboard frame with an + // `OnKeyDown` swallows the key whatever its script does with it (§3, §3.1). This host used + // to feed only the ten names above, so `CinematicFrame` — fullscreen, keyboard-enabled, an + // `OnKeyDown` that answers ESCAPE — consumed ESC and let W straight through, and the player + // walked around underneath their own intro cinematic. + // + // The reference's own Lua is the proof this is the law and not an over-reading: that + // handler has to call `RunBinding("SCREENSHOT")` **by hand** to get one key back. It would + // not need to if unhandled keys fell through to their bindings. + // + // Consumption sets the capture gate and nothing else: it suppresses the binding and the + // world/gameplay readers, and deliberately does NOT suppress `char_input` below — `OnChar` + // is a separate channel off a separate dispatcher (`0x765df0`), which is exactly how the + // stack-split spinner receives a digit whose key-down its own `OnKeyDown` already ate. + else if named.is_none() { + if let Some(token) = crate::bindings::chord::key_token(ev.key_code) { + if script.frame_key_input(token) { + capture.0 = true; + } + } + } if let Some(name) = named { // The three box-event keys. An unconsumed press is not acted on here: every GAME // action of a key — ESCAPE's close/cancel ladder (TOGGLEGAMEMENU), TAB's targeting, diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 87c1afa4..54861960 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -745,6 +745,9 @@ impl benilla_ui::script::TextMeasure for FixedWidthFont { } } +#[cfg(test)] +mod cinematic_tests; + #[cfg(test)] mod cast_tests; diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 35c83f09..62620ef2 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -35,6 +35,11 @@ pub enum EntityKind { /// area effect hangs on (Blizzard's storm, Flamestrike's burn). Carries no display id; its /// visual resolves through the spell chain (`DYNAMICOBJECT_SPELLID`), not a model field. DynamicObject, + /// A `TYPEID_CORPSE` (7) create — a dead player's remains: a fresh body while the owner is a + /// ghost, or the **bone pile** it converts to once released/looted. It renders as the dead + /// player (decision 1706): the character-model chain, dressed from the corpse's own + /// `CORPSE_FIELD_BYTES_*` + `CORPSE_FIELD_ITEM` snapshot. + Corpse, Other, } diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 48919d93..d2760a3c 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -1081,22 +1081,38 @@ fn entity_kind(t: ObjectType) -> EntityKind { ObjectType::Unit => EntityKind::Unit, ObjectType::GameObject => EntityKind::GameObject, ObjectType::DynamicObject => EntityKind::DynamicObject, + ObjectType::Corpse => EntityKind::Corpse, _ => EntityKind::Other, } } -/// The display id from a create packet — units, **players**, and GameObjects all carry one. A player's -/// body model resolves through the same CreatureDisplayInfo→CreatureModelData chain as a creature's: -/// the 1.12 client has no race/sex→path resolver, CGPlayer inherits CGUnit's displayId model-build -/// (decision 0041), so we route `Player` alongside `Unit`. Cast from the wire `i32`; `0`/absent → -/// `None`. +/// The display id from a create packet — units, **players**, GameObjects **and corpses** all carry +/// one. A player's body model resolves through the same CreatureDisplayInfo→CreatureModelData chain +/// as a creature's: the 1.12 client has no race/sex→path resolver, CGPlayer inherits CGUnit's +/// displayId model-build (decision 0041), so we route `Player` alongside `Unit`. A corpse's +/// `CORPSE_FIELD_DISPLAY_ID` is the dead player's own body display (vmangos writes +/// `GetNativeDisplayId()` straight in), so it resolves down that identical chain — the reference's +/// `0x5d6700` does exactly this lookup (decision 1706). +/// +/// **A bone pile's display id is deliberately still reported here.** The client ignores it (it +/// builds `DeathSkeleton` from the BYTES instead), but that is a *model-resolution* law, +/// not a wire fact — the field is present and it is what it is, and the fork lives where models are +/// resolved (`entities::corpse`). Reporting `None` here would also blind the live-display differ to +/// a flesh→bones flag flip, which the reference reacts to with a full model reload. +/// +/// Cast from the wire `i32`; `0`/absent → `None`. fn display_id(t: ObjectType, mask: &ObjectFields) -> Option { - let raw = match t { - ObjectType::Unit | ObjectType::Player => mask.unit_displayid(), - ObjectType::GameObject => mask.gameobject_displayid(), + match t { + ObjectType::Unit | ObjectType::Player => { + mask.unit_displayid().filter(|&d| d > 0).map(|d| d as u32) + } + ObjectType::GameObject => mask + .gameobject_displayid() + .filter(|&d| d > 0) + .map(|d| d as u32), + ObjectType::Corpse => mask.corpse_display_id(), _ => None, - }?; - (raw > 0).then_some(raw as u32) + } } /// The per-object scale (`OBJECT_FIELD_SCALE_X`) units/players/GameObjects carry — the *complete* @@ -1107,7 +1123,12 @@ fn display_id(t: ObjectType, mask: &ObjectFields) -> Option { /// sends a positive value on create). fn object_scale(t: ObjectType, mask: &ObjectFields) -> f32 { let raw = match t { - ObjectType::Unit | ObjectType::Player | ObjectType::GameObject => mask.object_scale_x(), + // A corpse joins them: `OBJECT_FIELD_SCALE_X` is an OBJECT-block field and vmangos sets it + // (`Corpse::Create` → `SetObjectScale(DEFAULT_OBJECT_SCALE)`), so the same one-field law + // sizes it (decision 1706). + ObjectType::Unit | ObjectType::Player | ObjectType::GameObject | ObjectType::Corpse => { + mask.object_scale_x() + } _ => None, }; raw.filter(|s| *s > 0.0).unwrap_or(1.0) diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index 5d500f40..45670ff5 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -18,9 +18,9 @@ pub use events::{ SessionEvent, }; pub use messages::{ - CharCreateReq, CharEnumItem, Character, CreateSpline, ItemInfo, JumpInfo, MonsterMoveFacing, - MoveMode, ObjectFields, OwnerFallback, ServerPacket, SpeedKind, TransportPose, - CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, + CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo, JumpInfo, + MonsterMoveFacing, MoveMode, ObjectFields, OwnerFallback, ServerPacket, SpeedKind, + TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, }; pub use world::{ diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index fa45af91..98ec6460 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -229,10 +229,10 @@ pub use trade::{ }; pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_state, TrainerSpell}; pub use update_object::{ - quest_slot_state, CreateSpline, MovementBlock, Object, ObjectFields, ObjectType, OwnerFallback, - PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, - FIELD_PLAYER_SKILL_INFO_1_1, PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, - PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, + quest_slot_state, CorpseLook, CreateSpline, MovementBlock, Object, ObjectFields, ObjectType, + OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, + AURA_FLAG_EFF_INDEX_MASK, FIELD_PLAYER_SKILL_INFO_1_1, PLAYER_EXPLORED_ZONES_SLOTS, + PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, }; pub use vendor::{ buy_item, buy_result, buyback_item, list_inventory, repair_item, sell_item, sell_result, diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 084327b4..0c4c3241 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -594,6 +594,24 @@ pub struct PlayerSkillSlot { /// `None`. Conflating the two made every `player_*` accessor answer a confident `0` for any /// created creature, which is how the pet sheet's damage multiplier came out `0` instead of the /// unstreamed default `1.0` and the tooltip read `inf - inf` / `nan` (director, 2026-08-07). +/// A corpse's body appearance — the seven `CORPSE_FIELD_BYTES_1`/`_2` bytes the reference's dress +/// `0x5d6260` loads into the corpse's own `CCharacterComponent` (`+0x69..+0x6f`), which are the same +/// seven selectors a living player's body is composited from. Read via +/// [`ObjectFields::corpse_look`]. +/// +/// This is the corpse's OWN snapshot, taken at death: it is not the owner's live `PLAYER_BYTES`, +/// and it survives the owner logging out or being nowhere near. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CorpseLook { + pub race: u8, + pub sex: u8, + pub skin: u8, + pub face: u8, + pub hair_style: u8, + pub hair_color: u8, + pub facial_hair: u8, +} + #[derive(Debug, Clone, Default)] pub struct ObjectFields { /// Presence bitmask, one bit per field index (`index = word * 32 + bit`) — the wire's block @@ -683,6 +701,101 @@ impl ObjectFields { self.get_guid(6).filter(|&g| g != 0) } + /// `CORPSE_FIELD_DISPLAY_ID` (field 12, `OBJECT_END + 0x6`): the dead player's body model, as a + /// **`CreatureDisplayInfo` id** — vmangos writes the owner's `GetNativeDisplayId()` straight in + /// (`Player::CreateCorpse`, `Player.cpp:4809`), so it resolves down the very same + /// displayId → CreatureDisplayInfo → CreatureModelData chain a player's living body does + /// (decision 0041). The reference reads exactly this field for the model: `0x5d6700`'s + /// not-bones leg is `[[corpse+0x110]+0x18]` → `[0xc0de90]` (CreatureDisplayInfo) → `[row+4]` + /// → `[0xc0de68]` (CreatureModelData) → `[row+8]` = the path. + /// + /// **Only meaningful while [`Self::corpse_is_bones`] is false.** A bone pile carries the field + /// (the server's corpse→bones conversion copies fields 3.. verbatim, vmangos `Map.cpp:3638`) + /// and the client ignores it: `0x5d6700` branches on the BONES bit *first* and builds the + /// skeleton path from race/sex instead. + pub fn corpse_display_id(&self) -> Option { + self.get_u32(12).filter(|&d| d != 0) + } + + /// One `CORPSE_FIELD_ITEM` slot (fields 13..31, `OBJECT_END + 0x7 + slot`, `slot` = the + /// **equipment slot** 0..18): the worn piece as `(ItemDisplayInfo id, InventoryType)` — the + /// server packs `DisplayInfoID | (InventoryType << 24)` (`Player::CreateCorpse`, + /// `Player.cpp:4821`), and the client unpacks the same way (`0x5d6423 and eax,0xffffff` + /// before the `[0xc0dc10]` ItemDisplayInfo index). `None` for an empty slot. + /// + /// Note this is a **display id, already resolved** — unlike `PLAYER_VISIBLE_ITEM`, which + /// carries item *entries* and needs a template round trip. A corpse dresses off the wire alone. + pub fn corpse_item(&self, slot: u8) -> Option<(u32, u8)> { + let raw = self.get_u32(13 + u16::from(slot))?; + let display = raw & 0x00ff_ffff; + (display != 0).then_some((display, (raw >> 24) as u8)) + } + + /// `CORPSE_FIELD_BYTES_1` (field 32, `OBJECT_END + 0x1A`) — byte 1 **race**, byte 2 **gender**, + /// byte 3 **skinColor** (byte 0 is unused; vmangos writes `(0) | (race<<8) | (gender<<16) | + /// (skin<<24)`, `Corpse.cpp:228`). The reference reads them one byte at a time off the same + /// word — `[[corpse+0x110]+0x69/+0x6a/+0x6b]` in the dress `0x5d6260`. + fn corpse_bytes_1(&self) -> Option { + self.get_u32(32) + } + /// `CORPSE_FIELD_BYTES_2` (field 33, `OBJECT_END + 0x1B`) — byte 0 **face**, byte 1 + /// **hairStyle**, byte 2 **hairColor**, byte 3 **facialHair** (`Corpse.cpp:229`; the reference's + /// `+0x6c..+0x6f`). + fn corpse_bytes_2(&self) -> Option { + self.get_u32(33) + } + /// The corpse's body appearance — `(race, sex, skin, face, hairStyle, hairColor, facialHair)`, + /// the seven bytes `0x5d6260` loads into the corpse's own `CCharacterComponent`. `None` when + /// neither BYTES word is known (a bare `Values` delta). + pub fn corpse_look(&self) -> Option { + let (b1, b2) = (self.corpse_bytes_1()?, self.corpse_bytes_2()?); + Some(CorpseLook { + race: (b1 >> 8) as u8, + sex: (b1 >> 16) as u8, + skin: (b1 >> 24) as u8, + face: b2 as u8, + hair_style: (b2 >> 8) as u8, + hair_color: (b2 >> 16) as u8, + facial_hair: (b2 >> 24) as u8, + }) + } + + /// `CORPSE_FIELD_FLAGS` (field 35, `OBJECT_END + 0x1D`) — the corpse's own flag word, read by + /// the reference as `[[corpse+0x110]+0x74]`. `0` when absent. + pub fn corpse_flags(&self) -> u32 { + self.get_u32(35).unwrap_or(0) + } + /// `CORPSE_FLAG_BONES` (`0x01`) — this object is a **bone pile**, not a fresh body. It is the + /// first thing `0x5d6260` tests (`0x5d6287 test byte [eax+0x74],0x1; jne`): a bone pile builds + /// **no** character component at all, wears nothing, and takes its model from race/sex rather + /// than from [`Self::corpse_display_id`] (`0x5d6700`). Named in vmangos `Corpse.h:45`; set on + /// the converted object at `Map.cpp:3644`. + pub fn corpse_is_bones(&self) -> bool { + self.corpse_flags() & 0x01 != 0 + } + /// `CORPSE_FLAG_HIDE_HELM` (`0x08`) — the owner's helm preference, snapshotted at death + /// (vmangos copies `PLAYER_FLAGS_HIDE_HELM` in, `Player.cpp:4801`). The corpse honours it by + /// **never installing equipment slot 0** (`0x5d6465 test byte [edx+0x74],0x8` → the skip), + /// which is its own bit on its own field — *not* `PLAYER_FLAGS`, and the opposite instruction + /// polarity to the player lane (wow-re `helm-cloak-hide.md` §2b). + pub fn corpse_hides_helm(&self) -> bool { + self.corpse_flags() & 0x08 != 0 + } + /// `CORPSE_FLAG_HIDE_CLOAK` (`0x10`) — the same, for equipment slot 14 (`0x5d6470`). + pub fn corpse_hides_cloak(&self) -> bool { + self.corpse_flags() & 0x10 != 0 + } + + /// `CORPSE_FIELD_DYNAMIC_FLAGS` (field 36, `OBJECT_END + 0x1E`) bit 0 — this bone pile carries + /// **lootable insignia** (`CORPSE_DYNFLAG_LOOTABLE`, vmangos `Map.cpp:3660`). The reference's + /// `0x5d6e20` is literally `return *(+0x110)+0x78 & 1`, and it gates both the corpse highlight + /// emitter (`0x5d6e30`/`0x5d6e80`, armed at build and re-armed by the field's own mirror + /// handler) and the `CMSG_LOOT` route off the corpse's interact slot (wow-re + /// `loot-anim-leg.md`). + pub fn corpse_lootable(&self) -> bool { + self.get_u32(36).unwrap_or(0) & 0x01 != 0 + } + /// `DYNAMICOBJECT_CASTER` (fields 6–7, `OBJECT_END + 0x0`, a 2-field guid — vmangos /// `UpdateFields_1_12_1.h:325`): the unit whose ground cast anchored this object. /// Index-twin of [`Self::corpse_owner`] — each TYPEID reuses `OBJECT_END`; the TypeId (or diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index a9443fc3..46769c9b 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -579,6 +579,74 @@ fn created_store_reads_absent_fields_as_zero_a_delta_as_none() { assert_eq!(create.corpse_owner(), None); } +/// The `TYPEID_CORPSE` descriptor's field indices and its two packed words (decision 1706). Every +/// number here is a *position*, and a position is exactly what a doc comment cannot check: an +/// off-by-one on `CORPSE_FIELD_ITEM`'s base would read the display id as a shoulder and dress a +/// body in a helm's row, silently. +#[test] +fn corpse_descriptor_indices_and_packing() { + // `OBJECT_END + 0x6 = 12` display · `+0x7 = 13` item[0] · `+0x1A/0x1B = 32/33` bytes · + // `+0x1D = 35` flags · `+0x1E = 36` dynamic flags (vmangos `UpdateFields_1_12_1.h:338-350`). + let corpse = ObjectFields::from_pairs(&[ + (12, 49), + // Slot 4 (chest): DisplayInfoID 902 | InventoryType 5 << 24 (`Player.cpp:4821`). + (13 + 4, 902 | (5 << 24)), + // BYTES_1: (0) | race<<8 | gender<<16 | skin<<24 (`Corpse.cpp:228`). + (32, (6 << 8) | (1 << 16) | (3 << 24)), + // BYTES_2: face | hairstyle<<8 | haircolor<<16 | facialhair<<24 (`Corpse.cpp:229`). + (33, 7 | (2 << 8) | (4 << 16) | (5 << 24)), + (35, 0x04 | 0x08), + (36, 0x01), + ]) + .into_created(ObjectType::Corpse); + + assert_eq!(corpse.corpse_display_id(), Some(49)); + assert_eq!( + corpse.corpse_item(4), + Some((902, 5)), + "display | invType<<24" + ); + assert_eq!( + corpse.corpse_item(3), + None, + "an empty slot is absent, not (0, 0)" + ); + + let look = corpse.corpse_look().expect("both BYTES words present"); + assert_eq!( + ( + look.race, + look.sex, + look.skin, + look.face, + look.hair_style, + look.hair_color, + look.facial_hair + ), + (6, 1, 3, 7, 2, 4, 5), + "the seven bytes the reference loads at [descr+0x69..+0x6f], in that order" + ); + + // The flags are three independent bits on one field — and BONES is the model fork, so a + // mis-read here swaps a dressed body for a skeleton. + assert!(!corpse.corpse_is_bones(), "0x01 clear"); + assert!(corpse.corpse_hides_helm(), "0x08 set"); + assert!(!corpse.corpse_hides_cloak(), "0x10 clear"); + assert!( + corpse.corpse_lootable(), + "DYNAMIC_FLAGS bit 0 — a different field" + ); + + // …and CORPSE_END = 38, so a created corpse answers 0 (not None) for every field below it. + let bare = ObjectFields::from_pairs(&[(12, 49)]).into_created(ObjectType::Corpse); + assert_eq!(bare.corpse_flags(), 0); + assert!( + bare.corpse_look().is_some(), + "absent BYTES read as the zero default" + ); + assert!(!bare.corpse_lootable()); +} + /// …and "absent = 0" stops at the end of the object's OWN descriptor (decision 1081). A creature /// has no PLAYER block to be absent from, so a PLAYER-block read off one is `None` — a question it /// cannot answer — not a confident `0`. diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs index cd2cc21c..8598ce70 100644 --- a/crates/benilla-protocol/src/messages/update_object/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -13,8 +13,8 @@ mod fields; mod movement; pub use fields::{ - power_display_scale, quest_slot_state, ObjectFields, OwnerFallback, PlayerSkillSlot, - QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, + power_display_scale, quest_slot_state, CorpseLook, ObjectFields, OwnerFallback, + PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, FIELD_PLAYER_SKILL_INFO_1_1, PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, }; diff --git a/crates/benilla-world/src/ground_fx.rs b/crates/benilla-world/src/ground_fx.rs index febdb0d4..e99fb152 100644 --- a/crates/benilla-world/src/ground_fx.rs +++ b/crates/benilla-world/src/ground_fx.rs @@ -256,7 +256,7 @@ pub(crate) fn update_ground_fx_decals( fog: decal.fog, // Spell ground-fx art is authored to burn at its own colour (the lane law in // `EffectBlend::from_model`); no lit ground quad observed in the corpus. - lit: false, + lighting: super::particles::buffer::EffectLighting::None, anchor: decal.center, bias: crate::sky_order::Rung::GROUND_FX, raster_bias: crate::sky_order::Rung::GROUND_FX as i32, diff --git a/crates/benilla-world/src/interior.rs b/crates/benilla-world/src/interior.rs index fd2ba69f..d39e51b5 100644 --- a/crates/benilla-world/src/interior.rs +++ b/crates/benilla-world/src/interior.rs @@ -52,7 +52,9 @@ use bevy::prelude::*; use crate::lighting::{PropProbeSlot, PropProbes}; use crate::model_fade::{PendingAppearFade, RenderFade}; -use crate::terrain_stream::{fold_interior_probe, PropLobeLight, TerrainStreamer}; +use crate::terrain_stream::{ + fold_interior_probe, interior_light_up, PropLobeLight, TerrainStreamer, +}; use crate::wmo_portal::{indoor_verdict_at, IndoorVerdict, WmoPortalInstance}; use benilla_assets::materials::WowModelMaterial; @@ -197,6 +199,42 @@ pub struct ClassifiedBy(pub Entity); #[relationship_target(relationship = ClassifiedBy)] pub struct LitParts(Vec); +/// The emitter → anchor edge of the same registry: a **LIT** particle emitter names the net-entity +/// root whose light node it draws under, exactly as a lit mesh batch names it through +/// [`ClassifiedBy`]. +/// +/// It exists because a light node has **two** consumer classes and only one of them is a mesh. The +/// reference creates the node from the object's TYPEID — `0x613e10` → `0x670db0` → `0x7134b0` for +/// every UNIT/PLAYER/GAMEOBJECT/DYNAMICOBJECT/CORPSE — never from what its batches happen to do, +/// and a particle draw commits that node's light through the same per-batch state producer a +/// submesh does (the reference has no particle material: it synthesizes an `M2Material` from the +/// emitter's file record every draw). wow-re `part-lit-normal-space.md` §6.2/§6.7. +/// +/// Onyxia's lava trap is the case that proves the split: all five of `ONYZIASLAIRLAVATRAP.M2`'s +/// mesh batches are UNLIT (`m2batch`: flags 0x13) while emitters #0/#1/#2 are lit, so the object +/// registered no [`LitParts`] at all and the classifier never visited it — 104 of them stood in a +/// WMO interior taking the day/night sun. +#[derive(Component)] +#[relationship(relationship_target = LitEmitters)] +pub struct EmitterLitBy(pub Entity); + +/// The anchor-side emitter list [`EmitterLitBy`] maintains — the second reason an anchor is +/// classified at all. Never mutated by hand; bevy removes it when the last emitter leaves, which +/// is what releases an emitter-only anchor's probe slot when its cloud streams out. +#[derive(Component)] +#[relationship_target(relationship = EmitterLitBy)] +pub struct LitEmitters(Vec); + +/// The constant RGB a LIT particle of this anchor's model is multiplied by — the anchor's own +/// committed light evaluated along the **world up axis**, which is the only normal a particle quad +/// ever carries (one constant per draw, wow-re `part-lit-normal-space.md` §2/§3; decision 1696). +/// +/// Present only while the anchor is on the [`AppliedLaw::Bake`] law. Its ABSENCE is the exterior +/// lane and means "take the scene's light", which the effect shader applies by itself — so an +/// outdoor emitter costs nothing here and reads exactly as it did before this component existed. +#[derive(Component, Clone, Copy, PartialEq, Debug)] +pub struct ParticleLight(pub [f32; 3]); + /// The anchor's classification record (0734) — the law its parts render under, plus the /// movement/residency gate that used to live per part. Inserted by the classifier on the first /// resolve; a settled anchor is one distance compare per frame, whatever its part count. @@ -401,14 +439,24 @@ pub fn classify_entity_interior( adt_tiles: Res>, lighting: Res, mut probes: ResMut, - mut anchors: Query<( - Entity, - &GlobalTransform, - &LitParts, - Option<&mut InteriorAnchor>, - Has, - Option<&BodyBakeCenter>, - )>, + // The walk is over LIGHT NODES: an anchor is visited when it has a lit mesh part **or** a lit + // particle emitter, because those are the node's two consumer classes ([`EmitterLitBy`]). The + // filter is what keeps it a light-node walk and not a walk over every entity in the world — + // a node with no consumer would still ray, and would still take a probe slot from a table + // that has a floor. + mut anchors: Query< + ( + Entity, + &GlobalTransform, + Option<&LitParts>, + Option<&mut InteriorAnchor>, + Has, + Option<&BodyBakeCenter>, + Option<&LitEmitters>, + Option<&mut ParticleLight>, + ), + Or<(With, With)>, + >, mut nodes: Query<&mut crate::entity_shade::GroundShade>, bake_states: Query<&BakeState>, seats: Query<&PropProbeSlot>, @@ -436,7 +484,17 @@ pub fn classify_entity_interior( // Anchors that wanted a resolve but had every part fade-excluded — the appear-fade lockout. let mut n_fade_blocked = 0usize; let mut resolve_us = 0.0f32; - for (anchor, anchor_t, lit_parts, mut state, containment, bake_center) in &mut anchors { + for ( + anchor, + anchor_t, + lit_parts, + mut state, + containment, + bake_center, + lit_emitters, + particle_light, + ) in &mut anchors + { n_anchors += 1; let pos = anchor_t.translation(); let had_state = state.is_some(); @@ -452,29 +510,54 @@ pub fn classify_entity_interior( if let AppliedLaw::Bake(slot) = state.law { if let (Ok(node), Ok(bake)) = (nodes.get(anchor), bake_states.get(anchor)) { if !node.ramps_settled() { - let coeffs = fold_interior_probe( + let words = ( node.ambient.to_array(), (bake.word * node.intensity()).to_array(), - bake.ref_point, - &bake.lobes, ); - probes.update_owned(slot, coeffs); + probes.update_owned( + slot, + fold_interior_probe(words.0, words.1, bake.ref_point, &bake.lobes), + ); + // The mesh's probe and the emitters' constant off the SAME committed + // words, through the two lanes' different curves (1709). + if let Some(mut light) = particle_light { + light.set_if_neq(ParticleLight(interior_light_up( + words.0, + words.1, + bake.ref_point, + &bake.lobes, + ))); + } } } } continue; } } - // Re-resolving: the kind comes from the anchor's first classifiable part. Since 0755 a - // fading part is classifiable, so this only bails on an anchor whose parts have all been - // despawned (a teardown mid-frame) — `fade_blocked` is kept as the tripwire that the - // lockout has not crept back in. - let Some(kind) = lit_parts - .iter() - .find_map(|part| parts.get(part).ok().map(|(lit, ..)| lit.kind.clone())) - else { - n_fade_blocked += 1; - continue; + // Re-resolving. The law is anchor-level and reduces to ONE input — the footprint fold's + // reference point, or `None` for the day/night matte — so this asks the anchor's first + // classifiable part for it. Since 0755 a fading part is classifiable, so the part arm only + // comes up empty on an anchor whose parts have all been despawned (a teardown mid-frame). + // + // An anchor with NO mesh part at all is the emitter-only node: nothing to ask, and every + // entity M2 bakes (see [`InteriorKind`]), so it takes the footprint law from its own bake + // centre. `fade_blocked` stays as the tripwire that the 0755 lockout has not crept back in. + let part_bake = lit_parts + .into_iter() + .flat_map(LitParts::iter) + .find_map(|part| { + parts.get(part).ok().map(|(lit, ..)| match &lit.kind { + InteriorKind::Bake { center, .. } => Some(*center), + InteriorKind::Matte => None, + }) + }); + let bake = match part_bake { + Some(center) => center, + None if lit_emitters.is_some() => Some(bake_center.map_or(Vec3::ZERO, |c| c.0)), + None => { + n_fade_blocked += 1; + continue; + } }; let seated = seats.get(anchor).ok().map(|s| s.0); n_resolved += 1; @@ -493,11 +576,11 @@ pub fn classify_entity_interior( anchor_t, attach, attach_at, - &kind, + bake, seated, ); resolve_us += _r.elapsed().as_secs_f32() * 1e6; - let kind_bake = matches!(kind, InteriorKind::Bake { .. }); + let kind_bake = bake.is_some(); let changed = match state.as_deref_mut() { Some(state) => { let changed = state.law != law; @@ -529,8 +612,14 @@ pub fn classify_entity_interior( // ATTACH and the point it probed are printed too (0776): a line that says only "exterior" // can't be read without knowing which lane produced it, and the two lanes now probe // different points. - if (law != AppliedLaw::Exterior || had_state) - && std::env::var_os("WOW_INTERIOR_LOG").is_some() + // Scoped to interior verdicts (plus interior→exterior flips) so the world's exterior + // masses stay silent — but `WOW_INTERIOR_LOG=all` prints the first-resolve exteriors too, + // which is the ONLY reading that answers "this thing is standing in a room and isn't + // lighting like it": a node that resolves exterior and stays there is otherwise invisible + // to this instrument, and looks identical to a node the walk never visited. + let log = std::env::var("WOW_INTERIOR_LOG").ok(); + if log.is_some() + && (law != AppliedLaw::Exterior || had_state || log.as_deref() == Some("all")) { // The PART COUNT is load-bearing, not decoration: a lane readout says which law the // model took, never which of its batches actually joined. A billboard card that @@ -538,11 +627,17 @@ pub fn classify_entity_interior( // Stratholme sign baked correctly *and* its chains lit from the sky, and the anchor // log said only "INTERIOR bake" for weeks (decision 0778). Compare it against the // model's batch count (`benilla-extract m2batch `). + // + // The EMITTER count is the same reading for the node's other consumer class: a model + // whose batches are all unlit registers zero parts and is here entirely on its lit + // emitters, and "0 parts / 0 emitters" is impossible — so the pair says which + // consumer put this node in front of the classifier, which is otherwise invisible. eprintln!( - "[interior] t {:.2} anchor {anchor:?} ({} parts) at ({:.1}, {:.1}, {:.1}) {} probe \ - ({:.1}, {:.1}, {:.1}) -> {}", + "[interior] t {:.2} anchor {anchor:?} ({} parts, {} emitters) at \ + ({:.1}, {:.1}, {:.1}) {} probe ({:.1}, {:.1}, {:.1}) -> {}", time.elapsed_secs(), - lit_parts.len(), + lit_parts.map_or(0, |p| p.len()), + lit_emitters.map_or(0, |e| e.len()), pos.x, pos.y, pos.z, @@ -560,7 +655,7 @@ pub fn classify_entity_interior( } ); } - for part in lit_parts.iter() { + for part in lit_parts.into_iter().flat_map(LitParts::iter) { if let Ok((mut lit, mut material, mut tag, fm, ramping, pending)) = parts.get_mut(part) { let fade = fm.filter(|_| ramping || pending); @@ -741,7 +836,7 @@ fn resolve_anchor_law( anchor_t: &GlobalTransform, attach: crate::wmo_portal::LightAttach, attach_at: Vec3, - kind: &InteriorKind, + bake_center: Option, seated: Option, ) -> AppliedLaw { let verdict = indoor_verdict_at( @@ -761,12 +856,12 @@ fn resolve_anchor_law( node.on_wmo = on_wmo; } } - let law = match kind { - InteriorKind::Matte => match verdict { + let law = match bake_center { + None => match verdict { IndoorVerdict::DayNight | IndoorVerdict::Baked { .. } => AppliedLaw::Matte, IndoorVerdict::Outdoors | IndoorVerdict::OutdoorsOnWmo => AppliedLaw::Exterior, }, - InteriorKind::Bake { center, .. } => { + Some(center) => { match verdict { IndoorVerdict::Outdoors | IndoorVerdict::OutdoorsOnWmo => AppliedLaw::Exterior, IndoorVerdict::DayNight => AppliedLaw::Matte, @@ -780,7 +875,7 @@ fn resolve_anchor_law( // lobes from the model's bbox-centre reference point. Refolded per frame // while the entity moves or the chases run (the reference re-runs its attach // per env update — for a settled entity every input is time-independent). - let ref_point = anchor_t.transform_point(*center); + let ref_point = anchor_t.transform_point(center); let word = Vec3::from_array(floor168(mocv)); let (ambient, intensity) = match nodes.get_mut(anchor) { Ok(mut node) => { @@ -799,12 +894,9 @@ fn resolve_anchor_law( // committed words, directly. Err(_) => (Vec3::from_array(cap96(mocv)), 1.0), }; - let coeffs = fold_interior_probe( - ambient.to_array(), - (word * intensity).to_array(), - ref_point, - &lobes, - ); + let words = (ambient.to_array(), (word * intensity).to_array()); + let coeffs = fold_interior_probe(words.0, words.1, ref_point, &lobes); + let emitter_light = interior_light_up(words.0, words.1, ref_point, &lobes); let slot = match seated { // Staying in Bake: the anchor keeps its owned slot, rewritten in place — // no component churn, no extraction churn. @@ -819,11 +911,17 @@ fn resolve_anchor_law( // `try_insert`: the anchor may have a same-frame despawn already // queued (the net teardown on a stream drop) — despawn discards // this pure cache, and the insert must not panic at apply time. - commands.entity(anchor).try_insert(BakeState { - word, - lobes, - ref_point, - }); + commands.entity(anchor).try_insert(( + BakeState { + word, + lobes, + ref_point, + }, + // The emitters' half of the same committed words — a different + // curve, because a particle draw takes the fixed-function lane + // where a mesh batch takes the SH one ([`interior_light_up`]). + ParticleLight(emitter_light), + )); AppliedLaw::Bake(slot) } None => { @@ -860,7 +958,8 @@ fn resolve_anchor_law( commands .entity(anchor) .try_remove::() - .try_remove::(); + .try_remove::() + .try_remove::(); } _ => {} } @@ -978,6 +1077,49 @@ mod tests { ); } + /// **The light node is the object's, not its mesh's.** An object whose every mesh batch is + /// UNLIT registers no [`LitParts`] at all — and before this, the classifier's walk was over + /// part lists, so it never visited one. Onyxia's 104 lava traps are exactly that shape + /// (`m2batch`: all five batches flags 0x13) with three LIT emitters each, and they stood in a + /// WMO interior taking the day/night sun. The reference builds the node from the object's + /// TYPEID and fills it every frame regardless of what its batches do (wow-re + /// `part-lit-normal-space.md` §6.2), so a lit EMITTER is a consumer of it exactly as a lit + /// batch is. + /// + /// The control is the second anchor: an object with emitters but none of them lit registers + /// no edge, is not visited, and costs the walk nothing. + #[test] + fn an_object_whose_only_lit_consumer_is_an_emitter_is_still_classified() { + use bevy::ecs::system::RunSystemOnce; + + let mut world = classifier_world(); + // A GameObject: no mesh part joins the registry, one lit emitter does. + let trap = world + .spawn(( + GlobalTransform::default(), + ContainmentAttach, + BodyBakeCenter(Vec3::new(0.0, 1.0, 0.0)), + )) + .id(); + world.spawn(EmitterLitBy(trap)); + // The control: emitters, none lit, so nothing registers. + let unlit = world.spawn(GlobalTransform::default()).id(); + + world.run_system_once(classify_entity_interior).unwrap(); + + let state = world + .get::(trap) + .expect("a light node with a lit emitter is classified even with no lit mesh part"); + assert!( + state.kind_bake, + "with no part to ask, the node takes the footprint law from its own bake centre" + ); + assert!( + world.get::(unlit).is_none(), + "a node with no lit consumer is never visited" + ); + } + /// A part spawned onto a SETTLED anchor (the gear-swap-indoors case) takes the standing law /// through the `on_add` hook + drain — no law change, no anchor movement, and still the fresh /// part's material/tag land on the anchor's law the very next classifier run. diff --git a/crates/benilla-world/src/lighting/sh.rs b/crates/benilla-world/src/lighting/sh.rs index 177a5708..dcf3f8b5 100644 --- a/crates/benilla-world/src/lighting/sh.rs +++ b/crates/benilla-world/src/lighting/sh.rs @@ -77,6 +77,29 @@ mod tests { [0usize, 1, 2].map(|ch| c[ch].dot(n1) + c[3 + ch].dot(quad) + c[6][ch] * x2y2) } + /// GOLDEN — this lane's half of the two-curve split (decision 1709). An interior fold puts its + /// diffuse word on the fixed axis `(−0.30822, −0.30822, −0.9)` in WoW space, whose Bevy image + /// is unit and 0.9 up, so a world-up normal reads μ = 0.9 at every camera angle. A MESH batch + /// takes the SH lane and therefore `ambient + (4/17)·(0.375 + 2·0.9 + 1.875·0.81)·diffuse` + /// = `ambient + 0.869118·diffuse`; its own PARTICLES take the fixed-function lane and read + /// `ambient + 0.9·diffuse` off the same words + /// ([`crate::terrain_stream::interior_light_up`]). The two are meant to differ. + #[test] + fn a_world_up_normal_reads_the_fixed_interior_axis_at_a_constant() { + let u = Vec3::new(-0.30822, 0.9, -0.30822); + assert!((u.length() - 1.0).abs() < 1e-5, "the axis is authored unit"); + let c = prop_probe_coeffs([0.1, 0.2, 0.3], &[(u, [1.0, 1.0, 1.0])]); + let got = eval_probe(&c, Vec3::Y); + for (k, ambient) in [0.1f32, 0.2, 0.3].into_iter().enumerate() { + assert!( + (got[k] - (ambient + 0.869_118)).abs() < 1e-4, + "ch{k}: got {} want {}", + got[k], + ambient + 0.869_118 + ); + } + } + /// GOLDEN — the reference's closed form at the three anchor normals: facing the lobe the full /// colour arrives (`E(μ=1) = ambient + C` exactly — (4/17)·(0.375+2+1.875) = 1); side-on the /// authored wrap leaves (4/17)·0.375 = 0.0882·C; opposite, (4/17)·0.25 = 0.0588·C. The director's diff --git a/crates/benilla-world/src/particles.rs b/crates/benilla-world/src/particles.rs index 45d4a199..18b6e345 100644 --- a/crates/benilla-world/src/particles.rs +++ b/crates/benilla-world/src/particles.rs @@ -190,6 +190,12 @@ pub struct ParticleEmitter { /// `None` = sort at the spawn placement (a placed doodad whose emitter rides a joint sorts at /// the doodad, not the bone). anchor: Option, + /// The object's **light node** — the entity whose [`crate::interior::ParticleLight`] this + /// cloud takes when it is LIT and standing in a WMO room. Held so a recursion CHILD that + /// wires up lit under an unlit parent can still register the edge + /// ([`wire_child_emitters`]); the [`crate::interior::EmitterLitBy`] component on this entity + /// is what the classifier actually walks. + light_node: Option, /// The anchor's last-known world translation (kept when the entity vanishes so the pool /// drains in place; init = the spawn placement's translation). anchor_pos: Vec3, @@ -327,6 +333,13 @@ pub struct EmitterFrames { /// The MODEL INSTANCE whose render alpha multiplies these particles /// ([`ParticleEmitter::alpha_src`]). pub alpha: Option, + /// The **light node** this cloud shades under ([`ParticleEmitter::light_node`]): the net + /// entity root for a creature/GameObject, the WEARER for an equipped item (one node per + /// object — the reference aliases the wearer's collector into each attached model). `None` + /// for a lane with no entity light node of its own: a placed doodad or a WMO prop, whose + /// provider is the CMapDoodadDef twin rather than the WENTITY, and a booth, whose light is + /// its own buffer. + pub light_node: Option, /// What losing `owner` means ([`OwnerLoss`]). pub on_owner_loss: OwnerLoss, } @@ -635,45 +648,51 @@ pub fn spawn_emitter( let rng = (t.x.to_bits() ^ t.y.to_bits().rotate_left(11) ^ t.z.to_bits().rotate_left(22)) .wrapping_mul(0x9E37_79B9) | 1; - Some( - commands - .spawn(( - // The sim writes the anchor here each frame — the census/phase instruments' - // read point (the draw's own sort key rides the draw record instead). - Transform::IDENTITY, - ParticleEmitter { - def, - placement, - ride: crate::ride_frame::StoredFrame::default(), - owner, - on_owner_loss: frames.on_owner_loss, - draining: false, - alpha_src: frames.alpha, - alpha: 1.0, - anchor: frames.anchor, - anchor_pos: placement.translation, - particles: Vec::new(), - accumulator: 0.0, - emitter_prev: None, - inherit_accum: 0.0, - inherit_vel: Vec3::ZERO, - gate_prev: false, - age: 0.0, - host, - seq, - rng, - owner_reach, - water_bound: emitter.water_bound, - texture, - gated: false, - recursion: emitter.recursion.clone(), - children: Vec::new(), - geometry: emitter.geometry.clone(), - model_instances: Vec::new(), - }, - )) - .id(), - ) + // A LIT emitter is a consumer of its object's light node, exactly as a lit mesh batch is — + // and it is frequently the ONLY one (all five of the Onyxia lava trap's mesh batches are + // unlit). Registering the edge here is what puts the object in front of the interior + // classifier at all; see [`crate::interior::EmitterLitBy`]. + let lit_node = frames.light_node.filter(|_| def.lit); + let mut spawned = commands.spawn(( + // The sim writes the anchor here each frame — the census/phase instruments' + // read point (the draw's own sort key rides the draw record instead). + Transform::IDENTITY, + ParticleEmitter { + def, + placement, + ride: crate::ride_frame::StoredFrame::default(), + owner, + on_owner_loss: frames.on_owner_loss, + draining: false, + alpha_src: frames.alpha, + alpha: 1.0, + anchor: frames.anchor, + light_node: frames.light_node, + anchor_pos: placement.translation, + particles: Vec::new(), + accumulator: 0.0, + emitter_prev: None, + inherit_accum: 0.0, + inherit_vel: Vec3::ZERO, + gate_prev: false, + age: 0.0, + host, + seq, + rng, + owner_reach, + water_bound: emitter.water_bound, + texture, + gated: false, + recursion: emitter.recursion.clone(), + children: Vec::new(), + geometry: emitter.geometry.clone(), + model_instances: Vec::new(), + }, + )); + if let Some(node) = lit_node { + spawned.insert(crate::interior::EmitterLitBy(node)); + } + Some(spawned.id()) } /// The **owner-last** draw-order rung: how far to bias one of a model's EFFECTS past that model's @@ -719,10 +738,15 @@ pub(crate) fn owner_last_bias(reach: f32) -> f32 { /// (`0x7b5dd0`); this system is that completion hook. A child never self-emits ambiently — its /// only particle source is the per-parent-particle drive in the sim. pub(crate) fn wire_child_emitters( + mut commands: Commands, models: Res>, - mut emitters: Query<&mut ParticleEmitter>, + mut emitters: Query<( + Entity, + &mut ParticleEmitter, + Has, + )>, ) { - for mut emitter in &mut emitters { + for (entity, mut emitter, registered) in &mut emitters { let Some(model) = emitter.recursion.as_ref().and_then(|h| models.get(h)) else { continue; }; @@ -751,6 +775,18 @@ pub(crate) fn wire_child_emitters( .collect(); emitter.recursion = None; emitter.children = children; + // A child emitter is a whole emitter record of the recursion model with its own flag word, + // so it takes its own lighting verdict (the same rule its texture/blend/fog identity + // follows). A LIT child under an UNLIT parent is therefore a light-node consumer whose + // edge nothing registered at spawn — the parent's `def.lit` was false and the child did + // not exist yet. This is that late registration. + if !registered && emitter.children.iter().any(|c| c.def.lit) { + if let Some(node) = emitter.light_node { + commands + .entity(entity) + .insert(crate::interior::EmitterLitBy(node)); + } + } } } diff --git a/crates/benilla-world/src/particles/buffer.rs b/crates/benilla-world/src/particles/buffer.rs index 1dd3015e..46142864 100644 --- a/crates/benilla-world/src/particles/buffer.rs +++ b/crates/benilla-world/src/particles/buffer.rs @@ -187,6 +187,42 @@ impl EffectFog { } } +/// Where one draw's light comes from — the lane's third per-draw identity axis, beside +/// [`EffectBlend`] and [`EffectFog`]. +/// +/// The reference has no particle material: it synthesizes an `M2Material` from the emitter's file +/// record every draw and runs the ordinary per-batch state producer over it, so `GL_LIGHTING` +/// lands on a particle quad exactly as it does on a mesh submesh — and the light it lands with is +/// the **model's own light node**, not the scene's (wow-re `part-lit-normal-space.md` §5/§6). Which +/// node the model is on is what these three variants name. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)] +pub enum EffectLighting { + /// The authored colour burns as it is. The lane's default, and every family but one: ribbons, + /// decals, the rings/reticle and precipitation are all authored to burn at their own colour, + /// as is an M2 emitter that SETS the file's unlit bit 0x1 (the fire and spell corpus, which is + /// why it stays bright at night). + #[default] + None, + /// The **scene's** light, applied per view in the fragment shader against the world up axis — + /// the outdoor lane, and the one a booth's own light buffer overrides. 400 of the corpus's + /// 7792 emitters clear the unlit bit and land here, nearly all of them `World\` environment + /// sheets: waterfall spray, chimney smoke, blown dust, snow, which read as full-white cutouts + /// against shaded terrain when the term is missing (the Zul'Gurub waterfall foam). + /// [`EffectDraw::lit`] is this variant and only this variant. + Scene, + /// The emitter's model carries its **own committed light**: it stands inside a WMO room, where + /// the reference lights the object from its light node's footprint-MOCV words on a fixed axis + /// plus the room's MOLT points, and never from the day/night sun (`part-lit-normal-space.md` + /// §6.7, the WENTITY provider `0x6a7300`'s interior leg). + /// + /// A particle quad carries exactly one normal, so that whole node collapses to three floats + /// ([`crate::interior::ParticleLight`]) and the producer has **already folded them into this + /// draw's vertex colours** — which is why this shares a pipeline with [`Self::None`]: there is + /// nothing left for the shader to do. Per-draw constants belong in the vertex stream on a lane + /// whose whole design is one shared buffer of them. + Committed, +} + /// A per-emitter light-buffer override: this emitter's fragment reads THIS `WowLight` blob /// instead of the world's shared one. The glue-scene booths are the one author (decision 0539 /// §5 — their braziers are fogged by the SCENE's own light buffer, the ModelFFX fog that @@ -206,7 +242,10 @@ pub struct EffectDraw { pub(crate) blend: EffectBlend, pub(crate) topology: EffectTopology, pub(crate) fog: EffectFog, - /// Does the scene's light multiply this draw's RGB? See [`EffectDrawSpec::lit`]. + /// Does the SHADER multiply this draw's RGB by the scene's light? True for + /// [`EffectLighting::Scene`] alone — a pipeline-key axis, so the merge walk separates lit from + /// unlit runs through the pipeline id. A [`EffectLighting::Committed`] draw is `false`: its + /// light is already in the vertices. pub lit: bool, /// The cloud's sort point — the emitter anchor / ribbon head node / decal center, exactly /// the sort point the material path used. @@ -276,23 +315,15 @@ pub struct EffectDrawSpec { pub texture: AssetId, pub blend: EffectBlend, pub fog: EffectFog, - /// Multiply this draw's RGB by the scene's matte light — `clamp(ambient + diffuse·max(N·L,0))` - /// against the **world up axis**, the same term `wow_model.wgsl` applies to a mesh. The - /// reference's quad writer uploads one constant normal for the whole draw — world +Z carried - /// into eye space, against a light carried into the same frame, so the product is the - /// view-invariant `worldUp · worldLightDir` (wow-re `part-lit-normal-space.md`; decision 1696 - /// supersedes 0975's camera-facing reading). - /// - /// The lane is unlit by default and every family but one passes `false`: ribbons, decals, the - /// rings/reticle and precipitation are all authored to burn at their own colour. **M2 particle - /// emitters are the exception** — the reference has no particle material, it synthesizes an - /// `M2Material` from the emitter's file record every draw and runs the ordinary batch state - /// producer over it, so `GL_LIGHTING` lands on a particle exactly as on a mesh - /// ([`benilla_formats::ParticleEmitterDef::lit`], byte law there). 400 of the corpus's 7792 - /// emitters clear the unlit bit, nearly all of them `World\` environment sheets — waterfall - /// spray, chimney smoke, blown dust, snow — which read as full-white cutouts against shaded - /// terrain when the term is missing (the Zul'Gurub waterfall foam). - pub lit: bool, + /// Which light this draw's authored colour meets — see [`EffectLighting`]. The scene's term is + /// `clamp(ambient + diffuse·max(N·L,0))` against the **world up axis**, the same shape + /// `wow_model.wgsl` applies to a mesh: the reference's quad writer uploads one constant normal + /// for the whole draw — world +Z carried into eye space, against a light carried into the same + /// frame, so the product is the view-invariant `worldUp · worldLightDir` (wow-re + /// `part-lit-normal-space.md`; decision 1696 supersedes 0975's camera-facing reading). The + /// gate on whether an emitter takes any of it is the file's unlit bit + /// ([`benilla_formats::ParticleEmitterDef::lit`], byte law there). + pub lighting: EffectLighting, pub anchor: Vec3, pub bias: f32, pub raster_bias: i32, @@ -356,7 +387,7 @@ impl EffectQuads { blend: spec.blend, topology, fog: spec.fog, - lit: spec.lit, + lit: spec.lighting == EffectLighting::Scene, anchor: spec.anchor, bias: spec.bias, raster_bias: spec.raster_bias, @@ -470,7 +501,7 @@ mod tests { texture: AssetId::default(), blend: EffectBlend::Add, fog: EffectFog::Off, - lit: false, + lighting: EffectLighting::None, anchor: Vec3::ZERO, bias: 0.0, raster_bias: 0, @@ -543,7 +574,7 @@ mod tests { /// /// Six lanes push into this buffer and every one of them wrote the same eleven-field /// [`EffectDrawSpec`] literal, of which three fields are the same value at every gameplay site -/// (`lit: false`, `cam_relative: false`, `light: None` — the exceptions are all engine-internal: +/// (`lighting: None`, `cam_relative: false`, `light: None` — the exceptions are all engine-internal: /// M2 emitters light, the snow slab writes camera-relative, the booths bind their own buffer). /// A caller was choosing eight things and restating three. /// @@ -567,7 +598,7 @@ impl<'w> WorldEffectDraw<'w> { texture, blend: EffectBlend::Alpha, fog: EffectFog::Off, - lit: false, + lighting: EffectLighting::None, anchor: Vec3::ZERO, bias: 0.0, raster_bias: 0, diff --git a/crates/benilla-world/src/particles/sim.rs b/crates/benilla-world/src/particles/sim.rs index 4667a3c1..cc07d71c 100644 --- a/crates/benilla-world/src/particles/sim.rs +++ b/crates/benilla-world/src/particles/sim.rs @@ -7,7 +7,9 @@ use bevy::prelude::*; use crate::view::WorldCamera; -use super::buffer::{EffectDrawSpec, EffectFog, EffectLightOverride, EffectQuads}; +use super::buffer::{ + EffectDrawSpec, EffectFog, EffectLightOverride, EffectLighting, EffectQuads, EffectVertex, +}; use super::quads::{expand_quads, CamBasis, DrawFrame}; use super::{ accumulate_emission, emit_local, next_u32, rand01, rand_s11, ChildDraw, ChildEmitter, @@ -450,9 +452,8 @@ pub(super) fn simulate_particles( // The ground-snap probe (file 0x2000 births): terrain + WMO/doodad geometry, the walking // collision audience. spatial: SpatialQuery, - // The per-model render alpha an entity-owned cloud is multiplied by (decision 0827), composed - // along the attached-model chain (0833). - model_alphas: crate::model_fade::ModelAlphas, + // The two per-MODEL multipliers a cloud carries out of its owner (see [`OwnerMultipliers`]). + owner_mul: OwnerMultipliers, // `Without`: the `&mut GlobalTransform` must be provably disjoint from the // camera read above too (an emitter never rides the camera entity). mut emitters: Query< @@ -464,6 +465,7 @@ pub(super) fn simulate_particles( Option<&EmitterFade>, Option<&RenderLayers>, Option<&EffectLightOverride>, + Option<&crate::interior::EmitterLitBy>, ), Without, >, @@ -520,8 +522,16 @@ pub(super) fn simulate_particles( // `$WOW_EMIT_DUMP`: is this a dump tick? Decided once per frame, for the whole walk. let emit_dump = dumps.emit.due(time.elapsed_secs()); - for (entity, mut emitter, mut entity_tf, mut entity_global, fade, layers, light_override) in - &mut emitters + for ( + entity, + mut emitter, + mut entity_tf, + mut entity_global, + fade, + layers, + light_override, + lit_by, + ) in &mut emitters { // The camera this emitter's quads face + its emission-LOD distance origin — and, in the // shared lane, the view its draw record targets: a booth-layered emitter uses its @@ -699,6 +709,7 @@ pub(super) fn simulate_particles( water_bound, texture, recursion: _, + light_node: _, children, geometry: _, model_instances, @@ -780,7 +791,7 @@ pub(super) fn simulate_particles( // chain, so a weapon glow's reaches the item's and the item's the wearer's — 0833), and a // placed doodad's takes its own distance fade, whose cutoff the draw-set gate above // already applies as a hard stop. - *alpha = alpha_src.map_or(1.0, |e| model_alphas.get(e)) + *alpha = alpha_src.map_or(1.0, |e| owner_mul.alpha(e)) * fade.map_or(1.0, |f| f.distance_alpha(cam_pos)); // The cloud anchor (see the field doc): the model's live translation, or the last-known // one while the pool drains. A whole-model owner keeps anchor == owner — identical math. @@ -1261,6 +1272,11 @@ pub(super) fn simulate_particles( }, ); } + // The model's own committed light, if it has one. Resolved once per emitter and shared by + // the parent draw and every child draw: they are all batches of ONE model, and the + // reference has one light node per model. + let committed = owner_mul.committed_light(lit_by); + let lighting = fold_committed_light(&mut quads.verts[start as usize..], def.lit, committed); quads.commit_quads( start, EffectDrawSpec { @@ -1268,7 +1284,7 @@ pub(super) fn simulate_particles( texture: texture.id(), blend: def.blend.into(), fog: EffectFog::for_blend(def.flags, def.blend), - lit: def.lit, + lighting, anchor, bias, raster_bias: 0, @@ -1293,6 +1309,11 @@ pub(super) fn simulate_particles( &cam, &mut quads.verts, ); + let child_lighting = fold_committed_light( + &mut quads.verts[cstart as usize..], + child.def.lit, + committed, + ); quads.commit_quads( cstart, EffectDrawSpec { @@ -1303,7 +1324,8 @@ pub(super) fn simulate_particles( // A child emitter is a whole emitter record of the recursion model, with its // own flag word and blend field — so it takes its OWN lighting verdict, never // the parent's (the same rule its texture/blend/fog identity follows above). - lit: child.def.lit, + // The NODE is the parent's: one light node per model, children included. + lighting: child_lighting, anchor, bias, raster_bias: 0, @@ -1316,14 +1338,125 @@ pub(super) fn simulate_particles( } } +/// The two per-MODEL multipliers a cloud carries out of its owner — its render alpha and its +/// committed light. +/// +/// Bundled because they are the same shape: one lookup from the emitter's owning model, multiplied +/// into the cloud. (And because [`simulate_particles`] sits at bevy's 16-parameter ceiling — but +/// that is the forcing function, not the reason.) +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct OwnerMultipliers<'w, 's> { + /// The per-model render alpha (decision 0827), composed along the attached-model chain (0833). + alphas: crate::model_fade::ModelAlphas<'w, 's>, + /// The light node a LIT emitter shades under when its model stands in a WMO room: the + /// reference lights the object from its OWN node, never from the day/night sun (wow-re + /// `part-lit-normal-space.md` §6.7). Absent = the exterior lane, which the shader applies + /// per view by itself. + lights: Query<'w, 's, &'static crate::interior::ParticleLight>, +} + +impl OwnerMultipliers<'_, '_> { + /// `instance`'s composed render alpha. + fn alpha(&self, instance: Entity) -> f32 { + self.alphas.get(instance) + } + + /// The committed light of the node this emitter registered under, if it has one. + fn committed_light(&self, lit_by: Option<&crate::interior::EmitterLitBy>) -> Option<[f32; 3]> { + self.lights.get(lit_by?.0).ok().map(|l| l.0) + } +} + +/// Fold a model's **committed** light into the vertices this draw just pushed, and name what the +/// draw's colour has met — the producer's half of [`EffectLighting::Committed`]. +/// +/// An emitter that clears the file's unlit bit is lit by its model's light NODE, and indoors that +/// node is the room's, not the sky's (wow-re `part-lit-normal-space.md` §6.7). A particle quad +/// carries exactly one normal, so the node collapses to one RGB per draw +/// ([`crate::interior::ParticleLight`]) — and a per-draw constant on a lane whose whole design is +/// one shared vertex buffer belongs in the vertices. The multiply is per-channel, constant over +/// the draw and in the same gamma space the shader's own scene term uses, so it lands on exactly +/// the value the fragment would have computed. +/// +/// With no committed light the emitter is on the exterior lane and the shader applies the scene's +/// term per view — which is also what a booth's own light buffer overrides, so that path must stay +/// in the shader. +fn fold_committed_light( + verts: &mut [EffectVertex], + lit: bool, + committed: Option<[f32; 3]>, +) -> EffectLighting { + match (lit, committed) { + (false, _) => EffectLighting::None, + (true, None) => EffectLighting::Scene, + (true, Some(mul)) => { + for v in verts { + for (c, m) in v.color.iter_mut().zip(mul) { + // Clamped on the PRODUCT, never on the multiplier: the reference's committed + // light is unclamped arithmetic (`0x71c2f0`'s reconstruction has no saturate — + // `0x4549a0` is three dword moves and a `ret`) and it is GL that clamps the + // LIT VERTEX COLOUR at the end. Clamping the term instead would cap a bright + // room's multiplier at 1 and quietly lose the brightening a dim emitter is + // authored to receive (decision 1709). + *c = (*c * m).clamp(0.0, 1.0); + } + } + EffectLighting::Committed + } + } +} + #[cfg(test)] mod tests { use super::{ - booth_frozen, follow_fraction, inherit_trigger, integrate_particle, is_above, ChildEmitter, - Particle, StepEnv, Vec3, + booth_frozen, fold_committed_light, follow_fraction, inherit_trigger, integrate_particle, + is_above, ChildEmitter, EffectLighting, EffectVertex, Particle, StepEnv, Vec3, }; use bevy::prelude::{Quat, Transform}; + fn one_quad() -> Vec { + vec![ + EffectVertex { + pos: [0.0; 3], + uv: [0.0; 2], + color: [0.8, 0.4, 0.2, 0.5], + }; + 4 + ] + } + + /// The three lighting verdicts a draw can take, and what each does to the vertices the + /// producer just pushed. The one that matters is the third: a model standing in a WMO room + /// carries its OWN committed light, which for a particle is a per-draw constant — folded in + /// here, leaving the shader nothing to do. **Alpha is never touched**: it is the blend weight, + /// not a colour, and the additive fold `rgb·α` downstream would square the light if it were. + #[test] + fn a_committed_light_folds_into_the_rgb_and_leaves_the_blend_weight_alone() { + let mut verts = one_quad(); + assert_eq!( + fold_committed_light(&mut verts, false, Some([0.5, 0.5, 0.5])), + EffectLighting::None, + "an UNLIT emitter takes no light at all, committed or not" + ); + assert_eq!(verts[0].color, [0.8, 0.4, 0.2, 0.5]); + + assert_eq!( + fold_committed_light(&mut verts, true, None), + EffectLighting::Scene, + "lit with no node of its own is the exterior lane — the shader's, per view" + ); + assert_eq!(verts[0].color, [0.8, 0.4, 0.2, 0.5], "and it folds nothing"); + + assert_eq!( + fold_committed_light(&mut verts, true, Some([0.5, 0.25, 0.0])), + EffectLighting::Committed, + "lit under a light node folds that node's constant in" + ); + for v in &verts { + assert_eq!(v.color, [0.4, 0.1, 0.0, 0.5]); + } + } + /// The membership law (`0x7084cf`): `above ⇔ d ≥ −r` — the tie at `d == −r` lands ABOVE /// (the reference's `jp` keeps it), NaN lands BELOW (unordered → the below push), and with /// no slack (the mesh lane's r = 0) it degenerates to the sign test with `d = 0` above — diff --git a/crates/benilla-world/src/ribbons.rs b/crates/benilla-world/src/ribbons.rs index 91e205cb..a5dfa144 100644 --- a/crates/benilla-world/src/ribbons.rs +++ b/crates/benilla-world/src/ribbons.rs @@ -682,7 +682,7 @@ pub(crate) fn simulate_ribbons( // to read the particle path's unlit bit off, and the trail corpus is additive // weapon/spell art authored to burn at its own colour. Revisit only with a // byte law for the ribbon batch state, not by analogy with particles. - lit: false, + lighting: crate::particles::buffer::EffectLighting::None, anchor, // The owner rung, dropped under the water pass when the MODEL sits on the eye's // far side of its water plane — the model's bound centre with the bound-radius diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index a033beb8..e1420750 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -57,7 +57,7 @@ pub use window::StreamWindow; // The WMO prop-light machinery lives spawn-side (0830's named carve, executed in 0832); the two // outside consumers — `crate::interior` and `crate::entities`' `wmo_props` — keep their // `terrain_stream::X` paths, same as the `queries` items below. -pub use spawn::prop_light::{fold_interior_probe, PropLobeLight}; +pub use spawn::prop_light::{fold_interior_probe, interior_light_up, PropLobeLight}; // The shared placed-model assembler + the off-thread collider build — also the WMO-gameobject // doodad-prop path's spawner (`crate::entities`' `wmo_props`: the ship's sails ride the streamed // gameobject entity, and its cargo hulls ride the boat's kinematic body). diff --git a/crates/benilla-world/src/terrain_stream/spawn/prop_light.rs b/crates/benilla-world/src/terrain_stream/spawn/prop_light.rs index 0a533e2c..01951081 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/prop_light.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/prop_light.rs @@ -83,6 +83,70 @@ pub struct PropLobeLight { pub atten_end: f32, } +/// The **fixed-function** lane's committed light, evaluated at the world up axis — the single +/// constant every LIT particle of a model standing in a WMO room is multiplied by. +/// +/// A particle draw does **not** take the SH vertex program a mesh batch takes, and that is not an +/// approximation either way — it is a structural fork. The batch-record TYPE dispatch +/// (`70b613`/`70b61e jmp [eax*4+0x70b728]`) sends TYPE 0 (mesh) and TYPE 2 (batched doodads) to +/// `0x70baf0` with the frame's `M2UseShaders` mask bit cached at `[esi+0x32f0]`, but TYPE 4 +/// (**particles**, `0x70d8b0`) — with 1, 3 and 5 — through the shared helper `0x70ca50`, which +/// writes no such field and NULL-binds **both** program slots (`70ca74 SetState(0x40, 0)`, +/// `70ca80 SetState(0x3f, 0)`). `0x70b360` zeroes `[esi+0x32f0]` at the head of every batch +/// (`70b607`), and only `0x70cb30` (TYPE 0) and `0x70d330` (TYPE 2) ever write it back — so +/// `0x70baf0`'s `70bb9c test eax,eax` reads 0 for a particle and `70bba4 je 0x70bdf2` takes the +/// **fixed-function device-light commit** `0x71c730` unconditionally. wow-re +/// `m2-lighting-lane-selector.md` §1(c2), triple-derived. +/// +/// So the curve here is the hardware's `max(N·L, 0)`, **not** [`fold_interior_probe`]'s SH lobe — +/// a mesh and its own particles share the room's committed WORDS and legitimately differ in the +/// function applied to them (0.900 vs 0.869 on the key axis; the SH lobe also wraps around the +/// back, where this clamps to nothing). Decision 1709 supersedes 1705's reading. +/// +/// - **Slot 0** is the key light. `0x71c2f0` reconstructs it from the context's linear moments as +/// `1.25·P − 0.25·DC` with **no clamp** (`0x4549a0` is three dword moves and a `ret 0xc`); with +/// the interior leg's single directional `P == DC` exactly, so it reduces to the committed +/// diffuse itself, and the ambient's `0.25·(DC − P)` correction is zero. `N` is world up and the +/// axis is the fixed `(−0.30822, −0.30822, −0.9)`, so `N·L` is **0.9 at every camera angle**. +/// - **Slots 1..3** are the ≤3 nearest MOLT points, diffuse-only (ambient and specular forced to 0 +/// at `71c7e3`/`71c7e6`), under GL's own distance attenuation `1/(0.7d + 0.03d²)` — constant +/// term zero. Because N is world up, a room light at or below the emitter contributes exactly +/// nothing. +/// +/// Unclamped by design: the reference clamps the **product**, not the term (GL clamps the lit +/// vertex colour), which is where `sim`'s fold does it. +pub fn interior_light_up( + ambient: [f32; 3], + diffuse: [f32; 3], + ref_point: Vec3, + lights: &[PropLobeLight], +) -> [f32; 3] { + // Toward-light, Bevy space — the same axis the SH fold puts the diffuse word on. + let axis = Vec3::new(-0.30822, 0.9, -0.30822).normalize(); + let mut lit = Vec3::from_array(ambient) + Vec3::from_array(diffuse) * axis.y.max(0.0); + // The ≤3 NEAREST in range, which is the reference's 4-entry max-heap by squared distance + // (`0x71bf90`, `71bfca cmp esi,0x4`) minus slot 0. Membership is our own disk window — the + // reference's is `0x6a7ac0`'s radius test against the proxy's WMO instances, which we model + // the same way for the SH lane; see the residual in decision 1709. + let mut near: Vec<(f32, &PropLobeLight)> = lights + .iter() + .map(|l| ((l.pos - ref_point).length(), l)) + .filter(|(d, l)| *d < l.atten_end) + .collect(); + near.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (dist, l) in near.into_iter().take(3) { + let up_dot = (l.pos.y - ref_point.y) / dist.max(1e-4); + if up_dot <= 0.0 { + continue; + } + let atten = 0.7 * dist + 0.03 * dist * dist; + if atten > 0.0 { + lit += Vec3::from_array(l.color_i) * (up_dot / atten); + } + } + lit.to_array() +} + /// Fold one interior committed light into its 7-row SH probe: the ambient word + the diffuse word /// as a directional on the FIXED interior axis + each MOLR lobe windowed by its disk /// attenStart/attenEnd from `ref_point` (the byte-verified `0x69e1c0` falloff: d ≤ start → 1; @@ -113,3 +177,54 @@ pub fn fold_interior_probe( } crate::lighting::prop_probe_coeffs(ambient, &lobes) } + +#[cfg(test)] +mod tests { + use super::*; + + /// GOLDEN — the exact worked case wow-re returned for the WENTITY interior leg (`0x6a7300` + /// → `0x71bce0`/`0x71bc70` → `0x71c2f0` → `0x71c730`), reproduced end to end. A floor MOCV + /// sample of `(70, 60, 50)`: the HSV boost fires (`70 < 168`, a uniform ×2.4 to `(168,144,120)`) + /// and the cap does not (`70 <= 96`, so the ambient word is the sample itself). With one + /// directional and no points, `0x71c2f0`'s `P == DC` exactly, so the committed diffuse IS the + /// boosted word and the ambient correction is zero — leaving `sample/255 · (2.4·0.9 + 1.0)` + /// = `sample/255 · 3.16` per channel. The tone-shaping is the classifier's (`floor168`/`cap96`); + /// this pins the arithmetic below it. + #[test] + fn the_committed_interior_light_at_world_up_is_the_reference_worked_case() { + let sample = [70.0f32, 60.0, 50.0].map(|c| c / 255.0); + let ambient = sample; // cap96 does not fire at max 70 + let diffuse = sample.map(|c| c * 2.4); // floor168 boosts 70 -> 168 uniformly + let got = interior_light_up(ambient, diffuse, Vec3::ZERO, &[]); + for (k, s) in sample.into_iter().enumerate() { + let want = s * 3.16; + assert!( + (got[k] - want).abs() < 1e-5, + "ch{k}: got {} want {want}", + got[k] + ); + } + } + + /// A MOLT point light reaches a particle **only from above**. The normal is world up and the + /// term is the hardware's `max(N·L, 0)`, so a room light at or below the emitter contributes + /// exactly zero — the fixed-function lane has no wrap-around, which is precisely where it + /// parts company with the SH lobe a mesh batch takes. + #[test] + fn a_room_light_below_the_emitter_contributes_nothing() { + let lamp = |y: f32| PropLobeLight { + pos: Vec3::new(0.0, y, 0.0), + color_i: [1.0, 1.0, 1.0], + atten_start: 0.0, + atten_end: 100.0, + }; + let dark = [0.0; 3]; + let below = interior_light_up(dark, dark, Vec3::ZERO, &[lamp(-5.0)]); + assert_eq!(below, [0.0; 3], "a light under the floor lights nothing"); + let above = interior_light_up(dark, dark, Vec3::ZERO, &[lamp(5.0)]); + // N·L = 1 straight overhead; GL attenuation 1/(0.7·5 + 0.03·25) = 1/4.25. + for ch in above { + assert!((ch - 1.0 / 4.25).abs() < 1e-5, "got {ch}"); + } + } +} diff --git a/crates/benilla-world/src/water_fx/mod.rs b/crates/benilla-world/src/water_fx/mod.rs index 8ab7c37f..3fa1ac89 100644 --- a/crates/benilla-world/src/water_fx/mod.rs +++ b/crates/benilla-world/src/water_fx/mod.rs @@ -579,7 +579,7 @@ fn push_water_foam( fog: EffectFog::Off, // The reference's foam render is its own additive path (`0x68fae0`), not the // M2 batch state producer — no GL_LIGHTING on it. - lit: false, + lighting: crate::particles::buffer::EffectLighting::None, anchor: centroid / n as f32, bias: FOAM_BIAS, raster_bias: 0, diff --git a/crates/benilla-world/src/weather/precip/mod.rs b/crates/benilla-world/src/weather/precip/mod.rs index daaf76dc..f85b02e1 100644 --- a/crates/benilla-world/src/weather/precip/mod.rs +++ b/crates/benilla-world/src/weather/precip/mod.rs @@ -659,7 +659,7 @@ fn push_precip( fog, // Rain/snow ride the reference's own weather render state (its verified Mod2x / // forced-grey-fog trio), not the M2 batch state producer — no GL_LIGHTING on them. - lit: false, + lighting: crate::particles::buffer::EffectLighting::None, anchor: cam_pos, bias: 0.0, raster_bias: 0,