your corpse is your own dead body, and particles take the room's light
- corpses render: a corpse create spawned a real entity, latched its guid for the reclaim send, and drew nothing — one missing EntityKind variant folded it into the catch-all and four downstream lanes then failed by default, not even the debug cube fired; the reference forks once, on the bones flag: a fresh body builds a character composite from the corpse's OWN appearance bytes and 19 item slots through the same compositor a living player is dressed by (its own hide-helm/hide-cloak bits honoured, the ranged slot never dresses, and weapons never dress because the client looks a packed display word up as a guid — we reproduce the outcome, not the dead lookup), while a bone pile takes its race and sex's DeathSkeleton, sixteen of which ship; the pose is one settled arm — Dead, or Drowned when the body lies more than 0.66666669 yd under a liquid surface — and a corpse is not a unit, so clicking one no longer sends a selection for a guid no unit owns
- a lit particle takes its MODEL's committed light node, not the sky's: indoors the reference lights an object's particles from the object's own node — the footprint's baked floor colours on the fixed interior axis plus the room's placed lights — never the day/night sun; a particle quad carries exactly one normal, world up, one constant per draw, so the whole node collapses to three floats, published off the same fold the model's mesh renders from so a mesh and its own particles can never disagree about the room they stand in; in Onyxia's Lair the 78 lit falling-rock emitters on the 26 anchors that classify interior now take the room's light
- and that lit lane is FIXED FUNCTION, not the mesh's soft SH curve: the shipped client null-binds both shader slots for a particle batch and re-zeroes the shader mask at the head of every batch, so a model and its own particles share the same committed light words and legitimately differ in the curve applied to them — the mesh's wrapping lobe against the hardware's max(N·L, 0) is 0.900 vs 0.869 on the key axis, up to the three nearest room lights arrive diffuse-only under GL's own 1/(0.7d + 0.03d²) falloff (a light at or below the emitter cannot reach it at all), and the clamp sits on the product, never the multiplier, because it is GL that clamps the lit vertex colour
- the cinematic letterbox has no aspect test in it: CinematicFrame.lua's if guards a recompute, not whether there are bars — below 4:3 the reference's bar textures simply stand at their declared 1024x128 on the client's native 1024x768 sheet, which is height/6, the same formula evaluated at exactly 4:3 — so one expression covers every window shape, min((h − min(w/2, h))/2, h/6): a 2:1 picture when wide, bars at 4:3 too; and a shown keyboard frame swallows EVERY key, not the ten names the host used to feed it — you could walk around underneath your own intro; the proof is the reference's own handler, which has to RunBinding("SCREENSHOT") by hand to get one key back; ESC still skips, and a W already held when the shot starts keeps running, which is faithful too
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,9 +66,13 @@
|
||||
**width**; the value marker scales by the **wheel's** height while anchoring to the strip).
|
||||
|
||||
OTHER DEVIATIONS, each forced:
|
||||
· **`<OnKeyDown>` (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.
|
||||
· **`<OnKeyDown>` (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
|
||||
|
||||
@@ -761,9 +761,10 @@
|
||||
end
|
||||
-- The colour picker next (ColorPickerFrame.xml). The reference hooks ESC on the frame
|
||||
-- itself (`<OnKeyDown>` 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
|
||||
|
||||
@@ -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<u32> = 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() => "<bones>",
|
||||
(EntityKind::Corpse, Some(_)) => "<corpse body>",
|
||||
_ => names.peek(guid.0).unwrap_or("?"),
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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<Cinematic>,
|
||||
mut hidden: ResMut<crate::ui_hide::UiHidden>,
|
||||
mut bars: Query<(&mut Node, &mut Visibility), With<LetterboxBar>>,
|
||||
windows: Query<&Window>,
|
||||
mut ours: Local<bool>,
|
||||
mut logged: Local<f32>,
|
||||
) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// `<Race><Sex>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<ResMut<GameObjects>>,
|
||||
items: Option<ResMut<equipment::ItemDisplays>>,
|
||||
glows: Option<ResMut<ItemGlows>>,
|
||||
// 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<corpse::BonesModels>,
|
||||
) {
|
||||
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::<SkinComposites>()
|
||||
// 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::<corpse::BonesModels>()
|
||||
.init_resource::<spell_fx::SpellFx>()
|
||||
.init_resource::<spell_fx::FxTintAnims>()
|
||||
// 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<ResMut<Creatures>>,
|
||||
mut gameobjects: Option<ResMut<GameObjects>>,
|
||||
mut held: Option<ResMut<ItemDisplays>>,
|
||||
@@ -1065,6 +1085,8 @@ fn update_display_models(
|
||||
mut forms: ResMut<benilla_world::model_forms::ModelForms>,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut mats: benilla_world::model_render::M2BatchMaterials,
|
||||
// The bone-pile display cache + the ChrRaces fileStrings its paths are built from (1706).
|
||||
mut bones: ResMut<corpse::BonesModels>,
|
||||
// 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<Res<crate::portrait::GluePreview>>,
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<benilla_world::rig_anim::AnimParked>();
|
||||
}
|
||||
_ => {
|
||||
commands
|
||||
.entity(entity)
|
||||
@@ -268,6 +279,8 @@ pub(super) fn attach_entity_visuals(
|
||||
assets: Res<CubeAssets>,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
gameobjects: Option<Res<GameObjects>>,
|
||||
// The bone-pile body cache (decision 1706) — the corpse lane's second model source.
|
||||
bones: Res<super::corpse::BonesModels>,
|
||||
// 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<Res<Characters>>,
|
||||
@@ -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
|
||||
// `<Race><Sex>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 {
|
||||
|
||||
@@ -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` — `<Race><Sex>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<CorpseModel> {
|
||||
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<CorpseLook> {
|
||||
let s = &store?.0;
|
||||
(!s.corpse_is_bones()).then(|| s.corpse_look())?
|
||||
}
|
||||
|
||||
/// `World\Generic\PassiveDoodads\DeathSkeletons\<Race><Sex>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<CorpsePosed>,
|
||||
>,
|
||||
anim_data: Option<Res<AnimData>>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<ObjectStore>, Option<&Equipment>),
|
||||
Or<(Changed<ObjectStore>, Without<Equipment>)>,
|
||||
>,
|
||||
held: Option<ResMut<ItemDisplays>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
) {
|
||||
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<HeldSlot>; 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::<Equipment>(corpse).expect("corpse dressed"),
|
||||
w.get::<HeldItems>(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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<u32> {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -172,7 +172,11 @@ pub(super) fn update_hover(
|
||||
),
|
||||
With<CreaturePickPart>,
|
||||
>,
|
||||
units: Query<&Guid, Without<SelfPlayer>>,
|
||||
// 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<SelfPlayer>>,
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -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::<i64>("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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -745,6 +745,9 @@ impl benilla_ui::script::TextMeasure for FixedWidthFont {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cinematic_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cast_tests;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<Race><Sex>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<u32> {
|
||||
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<u32> {
|
||||
/// 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)
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u32> {
|
||||
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<u32> {
|
||||
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<u32> {
|
||||
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<CorpseLook> {
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Entity>);
|
||||
|
||||
/// 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<Entity>);
|
||||
|
||||
/// 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<Assets<AdtTile>>,
|
||||
lighting: Res<crate::lighting::WowLighting>,
|
||||
mut probes: ResMut<PropProbes>,
|
||||
mut anchors: Query<(
|
||||
Entity,
|
||||
&GlobalTransform,
|
||||
&LitParts,
|
||||
Option<&mut InteriorAnchor>,
|
||||
Has<ContainmentAttach>,
|
||||
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<ContainmentAttach>,
|
||||
Option<&BodyBakeCenter>,
|
||||
Option<&LitEmitters>,
|
||||
Option<&mut ParticleLight>,
|
||||
),
|
||||
Or<(With<LitParts>, With<LitEmitters>)>,
|
||||
>,
|
||||
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 <model>`).
|
||||
//
|
||||
// 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<Vec3>,
|
||||
seated: Option<u16>,
|
||||
) -> 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::<PropProbeSlot>()
|
||||
.try_remove::<BakeState>();
|
||||
.try_remove::<BakeState>()
|
||||
.try_remove::<ParticleLight>();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -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::<InteriorAnchor>(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::<InteriorAnchor>(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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Entity>,
|
||||
/// 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<Entity>,
|
||||
/// 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<Entity>,
|
||||
/// 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<Entity>,
|
||||
/// 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<Assets<benilla_assets::M2Model>>,
|
||||
mut emitters: Query<&mut ParticleEmitter>,
|
||||
mut emitters: Query<(
|
||||
Entity,
|
||||
&mut ParticleEmitter,
|
||||
Has<crate::interior::EmitterLitBy>,
|
||||
)>,
|
||||
) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Image>,
|
||||
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,
|
||||
|
||||
@@ -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<WorldCamera>`: 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<WorldCamera>,
|
||||
>,
|
||||
@@ -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<EffectVertex> {
|
||||
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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user