particles keep time, and the maps with no terrain appear
- Twenty of the game's forty-three maps are not made of terrain at all — Molten Core, Onyxia's Lair, Dire Maul, Gnomeregan, Uldaman, the Deeprun Tram and both Blackrocks among them. Their whole world is one building named by the map file, and that line was being thrown away, so those maps loaded nothing and a teleport in fell forever. The building is now placed as an ordinary WMO placement — so collision, props, liquid, interior lighting and portals all come along on the existing path — verified on seven of the twenty against the server's own independent floor heights. - Particles keep the model's time. Emitter rate and on/off now bake per animation sequence and follow the clip that is actually playing, so a quest object's explosion choreography no longer burns at full roar while the object idles. And the flipbook cell ramp is computed the way the reference computes it: sheets that play backwards no longer crash the client (a certain Winterspring brazier fired this on every approach), the tail streak reads its own ramp instead of the head's, and the per-segment repeat count works — the Insect Swarm's wings finally flap. - A culled dungeon room takes its furniture with it: lanterns, crates, barrels and cobwebs now cull with the WMO group that owns them, instead of hanging lit in the void where their room used to be. The group's own liquid goes along too. - Under the floor: the archive chain pins that a file the patch deletes can never fall through to the stale copy in a base archive — a silent failure mode that would serve deleted content looking perfectly healthy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,12 @@
|
||||
//! Decodes a map's tiny `.wdt` into a [`WdtIndex`] — the 64×64 `MAIN` tile-existence grid the
|
||||
//! terrain streamer consults before requesting any `.adt` (decision 0476): open ocean authors no
|
||||
//! tiles, and probing them wholesale spammed asset-server NotFound errors on every boat crossing.
|
||||
//! It also carries the map's **global WMO** where there is one (decision 0688): on the 20 shipped
|
||||
//! maps that author no terrain at all, that single placement is the entire world.
|
||||
//! The parse itself is `benilla-wdt`'s [`WdtReader`] (oracle-tested in the 0021 migration); this
|
||||
//! module only wraps it in the Bevy asset machinery.
|
||||
|
||||
use benilla_formats::{WdtFile, WdtReader, WowVersion};
|
||||
use benilla_formats::{GlobalWmo, WdtFile, WdtReader, WowVersion};
|
||||
use bevy::asset::io::Reader;
|
||||
use bevy::asset::{Asset, AssetLoader, LoadContext};
|
||||
use bevy::reflect::TypePath;
|
||||
@@ -24,6 +26,12 @@ impl WdtIndex {
|
||||
.get_tile(tile_x as usize, tile_y as usize)
|
||||
.is_some_and(|t| t.has_adt)
|
||||
}
|
||||
|
||||
/// The map's single global building, on a map with no terrain (`MPHD` bit 0) — see
|
||||
/// [`GlobalWmo`]. `None` on an ADT map.
|
||||
pub fn global_wmo(&self) -> Option<&GlobalWmo> {
|
||||
self.0.global_wmo()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevy [`AssetLoader`] decoding a vanilla `*.wdt` → [`WdtIndex`].
|
||||
|
||||
@@ -113,6 +113,16 @@ pub struct WmoModel {
|
||||
/// attach path — a WMO MODD doodad never reaches it (create never calls SetMatrix; wow-re
|
||||
/// `trace-forensics-abbey-interior-d3d` §1.1).
|
||||
pub doodad_base: Vec<DoodadBase>,
|
||||
/// Per-MODD **owning group** (the first group whose MODR refs name it), parallel to
|
||||
/// [`Self::doodads`]; `None` for a MODD no group references.
|
||||
///
|
||||
/// This is the prop's portal-cull key. The reference commits a WMO's doodads **per visible
|
||||
/// group** — `0x695aa0` loops the group's own MODR refs at `group+0xe8`/count `+0x144`, called
|
||||
/// from the visible-group walk `0x698720` (wow-re `m2-interior-doodad-base-light.md` §453,
|
||||
/// `trace-forensics-abbey-interior-d3d.md` §90) — so a group the portal flood culled draws none
|
||||
/// of its furniture. Without this the props outlive their own building: cull every group of a
|
||||
/// dungeon and its lanterns, crates and cobwebs hang in the void (decision 0689).
|
||||
pub doodad_owner: Vec<Option<u16>>,
|
||||
/// Per-group FOOTPRINT face set (render tris + baked MOCV + MOPY flags), indexed by absolute
|
||||
/// group index; `None` for exterior groups / groups without MOCV. The **GameObject M2** light
|
||||
/// lane samples it: the entity node's env-update attach down-rays the render mesh under the
|
||||
@@ -278,22 +288,11 @@ pub fn floor168(c: [u8; 3]) -> [f32; 3] {
|
||||
floor_raise(c, 168)
|
||||
}
|
||||
|
||||
/// Resolve every MODD placement's lighting base once, at load. Ownership and interior class follow
|
||||
/// the faithful create path: a doodad belongs to the group(s) whose **MODR** references it (the
|
||||
/// instantiate loop is per-group over MODR — never a spatial test), the owning group's
|
||||
/// interior/exterior class (`MOGI flags & 0x48`) picks the lane, and the owning group's **MOLR**
|
||||
/// list is its complete point-light candidate set. A MODD referenced by no group is never
|
||||
/// instantiated by the reference at all — Exterior here (the harmless default if our spawner shows
|
||||
/// it anyway).
|
||||
fn resolve_doodad_bases(
|
||||
doodads: &[WmoDoodad],
|
||||
groups: &[WmoGroupInfo],
|
||||
group_doodad_refs: &[Vec<u16>],
|
||||
group_light_refs: &[Vec<u16>],
|
||||
) -> Vec<DoodadBase> {
|
||||
// Invert MODR: MODD index → owning group (first referencing group wins; a doodad shared by two
|
||||
// groups gets one def per group in the reference — one instance and its first owner here).
|
||||
let mut owner: Vec<Option<u16>> = vec![None; doodads.len()];
|
||||
/// Invert MODR: MODD index → its owning group. First referencing group wins — a doodad named by two
|
||||
/// groups gets one def *per group* in the reference; we instantiate it once, under its first owner.
|
||||
/// `None` = referenced by no group at all (the reference never instantiates such a MODD).
|
||||
fn modr_owners(doodad_count: usize, group_doodad_refs: &[Vec<u16>]) -> Vec<Option<u16>> {
|
||||
let mut owner: Vec<Option<u16>> = vec![None; doodad_count];
|
||||
for (gi, refs) in group_doodad_refs.iter().enumerate() {
|
||||
for &di in refs {
|
||||
if let Some(slot) = owner.get_mut(di as usize) {
|
||||
@@ -301,9 +300,28 @@ fn resolve_doodad_bases(
|
||||
}
|
||||
}
|
||||
}
|
||||
owner
|
||||
}
|
||||
|
||||
/// Resolve every MODD placement's lighting base once, at load. Ownership and interior class follow
|
||||
/// the faithful create path: a doodad belongs to the group(s) whose **MODR** references it (the
|
||||
/// instantiate loop is per-group over MODR — never a spatial test), the owning group's
|
||||
/// interior/exterior class (`MOGI flags & 0x48`) picks the lane, and the owning group's **MOLR**
|
||||
/// list is its complete point-light candidate set. A MODD referenced by no group is never
|
||||
/// instantiated by the reference at all — Exterior here (the harmless default if our spawner shows
|
||||
/// it anyway).
|
||||
///
|
||||
/// `owner` is [`modr_owners`]' inversion, shared with the portal-cull key ([`WmoModel::doodad_owner`])
|
||||
/// so the lighting lane and the cull can never disagree about which group a prop belongs to.
|
||||
fn resolve_doodad_bases(
|
||||
doodads: &[WmoDoodad],
|
||||
groups: &[WmoGroupInfo],
|
||||
owner: &[Option<u16>],
|
||||
group_light_refs: &[Vec<u16>],
|
||||
) -> Vec<DoodadBase> {
|
||||
doodads
|
||||
.iter()
|
||||
.zip(&owner)
|
||||
.zip(owner)
|
||||
.map(|(d, owner)| {
|
||||
let Some(gi) = owner else {
|
||||
return DoodadBase::Exterior;
|
||||
@@ -495,11 +513,13 @@ impl AssetLoader for WmoModelLoader {
|
||||
}
|
||||
}
|
||||
// Resolve every MODD placement's base once — placements, colours, MODR ownership, and MOLR
|
||||
// light lists are all fixed in the root/groups, so nothing here is ever re-derived.
|
||||
// light lists are all fixed in the root/groups, so nothing here is ever re-derived. The MODR
|
||||
// inversion is shared with the portal-cull key below, so the two can't disagree.
|
||||
let doodad_owner = modr_owners(root.doodads().len(), &group_doodad_refs);
|
||||
let doodad_base = resolve_doodad_bases(
|
||||
root.doodads(),
|
||||
root.group_infos(),
|
||||
&group_doodad_refs,
|
||||
&doodad_owner,
|
||||
&group_light_refs,
|
||||
);
|
||||
|
||||
@@ -534,6 +554,7 @@ impl AssetLoader for WmoModelLoader {
|
||||
lights: parse_wmo_lights(&bytes),
|
||||
group_bounds: root.group_infos().to_vec(),
|
||||
doodad_base,
|
||||
doodad_owner,
|
||||
group_footprints,
|
||||
group_footprint_bounds,
|
||||
group_light_refs,
|
||||
@@ -615,7 +636,11 @@ mod doodad_base_tests {
|
||||
let groups = [grp(true), grp(false)];
|
||||
let modr = vec![vec![0u16], vec![1u16]];
|
||||
let molr = vec![vec![7u16, 9u16], vec![3u16]];
|
||||
let bases = resolve_doodad_bases(&doodads, &groups, &modr, &molr);
|
||||
let owner = modr_owners(doodads.len(), &modr);
|
||||
// The same inversion the portal cull keys props on (decision 0689): owned props name their
|
||||
// group, the unreferenced one names none — so it is the one prop the cull can't hide.
|
||||
assert_eq!(owner, vec![Some(0), Some(1), None]);
|
||||
let bases = resolve_doodad_bases(&doodads, &groups, &owner, &molr);
|
||||
match &bases[0] {
|
||||
DoodadBase::Interior(b) => {
|
||||
assert_eq!(b.light_refs, vec![7, 9]);
|
||||
@@ -636,7 +661,7 @@ mod doodad_base_tests {
|
||||
let bases = resolve_doodad_bases(
|
||||
&[prop([120, 110, 150, 255])],
|
||||
&[grp(true)],
|
||||
&[vec![0u16]],
|
||||
&[Some(0)],
|
||||
&[Vec::new()],
|
||||
);
|
||||
match &bases[0] {
|
||||
@@ -644,4 +669,21 @@ mod doodad_base_tests {
|
||||
other => panic!("expected interior base, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A MODD named by several groups resolves to its **first** referencing group, and the mapping is
|
||||
/// dense over the doodad list — the portal cull keys every prop off this (decision 0689), so a
|
||||
/// shared prop is culled with one real room rather than never culled at all. Out-of-range MODR
|
||||
/// entries (a malformed group) are ignored rather than panicking.
|
||||
#[test]
|
||||
fn a_shared_modd_belongs_to_its_first_referencing_group() {
|
||||
// g0 names doodad 2; g1 names 0 and 2; g2 names 1 — plus a ref past the end of the list.
|
||||
let modr = vec![vec![2u16], vec![0u16, 2u16], vec![1u16, 99u16]];
|
||||
assert_eq!(
|
||||
modr_owners(3, &modr),
|
||||
vec![Some(1), Some(2), Some(0)],
|
||||
"doodad 2 is named by g0 and g1 — g0 wins"
|
||||
);
|
||||
// No groups at all ⇒ nothing is owned, and nothing is cullable.
|
||||
assert_eq!(modr_owners(2, &[]), vec![None, None]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,19 @@ fn main() -> anyhow::Result<()> {
|
||||
e.lifespan,
|
||||
e.drag
|
||||
);
|
||||
println!(" rate {:?}", e.emission_rate);
|
||||
match e.timing.constant_rate() {
|
||||
Some(r) => println!(" rate {r}/s (constant, every sequence)"),
|
||||
None => {
|
||||
for (slot, (looping, rate, enabled)) in e.timing.slot_views().iter().enumerate() {
|
||||
println!(
|
||||
" seq {slot}{}: rate {:?} enabled {:?}",
|
||||
if *looping { "" } else { " (clamped)" },
|
||||
rate,
|
||||
enabled
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" area {}x{} zsrc {} tail {} spin {}",
|
||||
e.area_length, e.area_width, e.z_source, e.tail_time, e.spin
|
||||
|
||||
@@ -211,24 +211,52 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) {
|
||||
// and never pours — reading its keys as a continuous rate is the exact misdiagnosis
|
||||
// behind the Eviscerate 0.5s-vs-2s gap (wow-re part-emission-burst-flag.md).
|
||||
let burst = if d.burst() { "BURST " } else { "" };
|
||||
let rate = if d.emission_rate.keys.len() == 1 {
|
||||
format!("{burst}rate {:.1}/s", d.emission_rate.first())
|
||||
} else {
|
||||
format!(
|
||||
"{burst}rate keys {:?}",
|
||||
d.emission_rate
|
||||
.keys
|
||||
// PER-SEQUENCE timing (the runtime's actual sampling unit — the old print showed the two
|
||||
// tracks rebased onto sequence 0's band, which read as authoritative and was exactly the
|
||||
// B27 misparse). The quiet case stays quiet: one constant rate, no gate anywhere.
|
||||
let views = d.timing.slot_views();
|
||||
let rate = match d.timing.constant_rate() {
|
||||
Some(r) => format!("{burst}rate {r:.1}/s"),
|
||||
None => {
|
||||
let per: Vec<String> = views
|
||||
.iter()
|
||||
.map(|&(t, v)| (t, v as i32))
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
.enumerate()
|
||||
.map(|(s, (_, rate, _))| match rate {
|
||||
Some(keys) => format!(
|
||||
"s{s} {:?}",
|
||||
keys.iter().map(|&(t, v)| (t, v as i32)).collect::<Vec<_>>()
|
||||
),
|
||||
None => format!("s{s} -"),
|
||||
})
|
||||
.collect();
|
||||
format!("{burst}rate/seq [{}]", per.join(" "))
|
||||
}
|
||||
};
|
||||
// The enabled gate (clip-relative ms, like the rate keys): a one-shot effect's
|
||||
// choreography — "why does this emitter only flash for 200 ms" reads right here.
|
||||
let rate = if d.enabled.keys.len() == 1 {
|
||||
rate // always-on (the overwhelmingly common shape) — no noise
|
||||
// The enabled gate, per sequence slot (seconds from the slot's band start) — a one-shot
|
||||
// effect's choreography, and a state GameObject's "which clips actually fire this".
|
||||
let rate = if views.iter().all(|(_, _, e)| e.is_none()) {
|
||||
rate // no gate authored anywhere (the overwhelmingly common shape) — no noise
|
||||
} else {
|
||||
format!("{rate} enabled {:?}", d.enabled.keys)
|
||||
let per: Vec<String> = views
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(s, (looping, _, enabled))| {
|
||||
let clock = if *looping { "" } else { "!" };
|
||||
match enabled {
|
||||
Some(keys) => {
|
||||
let w: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|&(t, v)| {
|
||||
format!("{t:.2}:{}", if v > 0.5 { "on" } else { "off" })
|
||||
})
|
||||
.collect();
|
||||
format!("s{s}{clock} {}", w.join(" "))
|
||||
}
|
||||
None => format!("s{s}{clock} on"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{rate} enabled/seq [{}]", per.join(" "))
|
||||
};
|
||||
// A tail's streak length is |velocity|·tail_time — without it "how long is this
|
||||
// streak" needs a hand-parse of the raw record (the Eviscerate diagnosis gap).
|
||||
|
||||
@@ -132,6 +132,12 @@ enum Command {
|
||||
/// `0x134`) — the population instrument for the ribbon subsystem (weapon trails, streamers):
|
||||
/// which content actually authors one, before we build for it.
|
||||
Ribbonscan,
|
||||
/// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields: cell
|
||||
/// ramps that play BACKWARDS (`begin > end` — legal, shipped, and fatal to a reader that
|
||||
/// clamps into the pair), tail streaks whose ramp differs from the head's, indices past the
|
||||
/// atlas (the reference wraps the column and lets the row run off), per-segment repeat counts,
|
||||
/// and the two degenerate shapes the reference itself falls back on. Decision 0685.
|
||||
Cellscan,
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and list the models whose MATERIAL
|
||||
/// table authors the MULTIPLY blend modes 5 (Mod) / 6 (Mod2x) — the ARMORREFLECT sheen
|
||||
/// family (decision 0528): the population instrument for the multiply-blend mechanism.
|
||||
@@ -440,6 +446,7 @@ fn main() -> Result<()> {
|
||||
Command::M2batch { internal_path } => m2dump::m2batch(&mut chain, &internal_path)?,
|
||||
Command::M2alpha { internal_path } => m2dump::m2alpha(&mut chain, &internal_path)?,
|
||||
Command::Ribbonscan => scan::ribbonscan(&mut chain)?,
|
||||
Command::Cellscan => scan::cellscan(&mut chain)?,
|
||||
Command::Blendscan { prefix } => scan::blendscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Bbscan { prefix } => scan::bbscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Bbfacescan { prefix } => scan::bbfacescan(&mut chain, prefix.as_deref())?,
|
||||
|
||||
@@ -2124,3 +2124,114 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
|
||||
println!(" hitting a rate-0 freeze leg (a motion clip standing in for a missing rest pose): {rate0}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields — the
|
||||
/// population instrument behind decision 0685 (the reverse-playing cell ramp).
|
||||
///
|
||||
/// One line per emitter that is interesting on any axis, then the totals. The axes are exactly the
|
||||
/// ways a flipbook reader can be wrong, each of which shipped data does exercise:
|
||||
///
|
||||
/// - `INVERTED` — a `begin > end` pair. Legal, and it means *play the sheet backwards*; a reader
|
||||
/// that clamps into `[begin, end]` mangles it (and in Rust panics outright).
|
||||
/// - `TAIL-RAMP` — the tail streak's own ramp differs from the head's, on an emitter that draws a
|
||||
/// tail. The two are independently authored (file +0x168.. vs +0x174..); handing the head's cell
|
||||
/// to the streak animates it through a sheet the author pinned to one cell.
|
||||
/// - `PAST-ATLAS` — a cell index beyond `rows·cols`. The reference masks the COLUMN and leaves the
|
||||
/// ROW unbounded, so the index wraps to row 0 rather than holding the last cell.
|
||||
/// - `REPEAT` — a per-segment repeat count ≠ 1, i.e. the sheet cycles more than once per segment.
|
||||
/// - `NON-POW2` / `MID` — the two shapes the reference itself degrades on (a 1×1 fallback, and a
|
||||
/// `mid` of 0/1 that walks its own sampler into a NaN). Both are empty in 1.12.1 and are here so
|
||||
/// that stays checkable.
|
||||
pub fn cellscan(chain: &mut Chain) -> Result<()> {
|
||||
let names: Vec<String> = chain
|
||||
.list()
|
||||
.context("listing chain contents")?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.filter(|n| n.to_ascii_lowercase().ends_with(".m2"))
|
||||
.collect();
|
||||
|
||||
let (mut models, mut emitters) = (0u32, 0u32);
|
||||
let (mut inverted, mut tail_ramp, mut repeat_ne1) = (0u32, 0u32, 0u32);
|
||||
let (mut past_atlas, mut past_atlas_real, mut bad_tiles, mut bad_mid) =
|
||||
(0u32, 0u32, 0u32, 0u32);
|
||||
|
||||
for name in names {
|
||||
let Ok(bytes) = chain.read_file(&name) else {
|
||||
continue;
|
||||
};
|
||||
models += 1;
|
||||
let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
for (i, e) in defs.iter().enumerate() {
|
||||
emitters += 1;
|
||||
let ol = &e.over_life;
|
||||
let at = format!("{name} [{i}] {}x{}", e.tile_rows, e.tile_cols);
|
||||
let ramps = [
|
||||
ol.head_cells[0],
|
||||
ol.head_cells[1],
|
||||
ol.tail_cells[0],
|
||||
ol.tail_cells[1],
|
||||
];
|
||||
let pair = |r: &benilla_formats::CellRamp| (r.begin, r.end);
|
||||
|
||||
if ramps.iter().any(|r| r.begin > r.end) {
|
||||
inverted += 1;
|
||||
println!(
|
||||
"INVERTED {at} head {:?}/{:?}",
|
||||
pair(&ol.head_cells[0]),
|
||||
pair(&ol.head_cells[1])
|
||||
);
|
||||
}
|
||||
// Only a tail-drawing emitter (particleType 1/2) can show a tail-ramp difference.
|
||||
if e.head_tail >= 1 && ol.tail_cells != ol.head_cells {
|
||||
tail_ramp += 1;
|
||||
println!(
|
||||
"TAIL-RAMP {at} head {:?}/{:?} tail {:?}/{:?}",
|
||||
pair(&ol.head_cells[0]),
|
||||
pair(&ol.head_cells[1]),
|
||||
pair(&ol.tail_cells[0]),
|
||||
pair(&ol.tail_cells[1])
|
||||
);
|
||||
}
|
||||
if ol.repeat.iter().any(|&r| r != 1.0) {
|
||||
repeat_ne1 += 1;
|
||||
println!("REPEAT {at} {:?}", ol.repeat);
|
||||
}
|
||||
let atlas = e.tile_rows * e.tile_cols;
|
||||
if ramps.iter().any(|r| r.begin >= atlas || r.end >= atlas) {
|
||||
past_atlas += 1;
|
||||
// On a 1×1 sheet every index resolves to the same texture, so only a real atlas
|
||||
// can show the wrap.
|
||||
if atlas > 1 {
|
||||
past_atlas_real += 1;
|
||||
println!(
|
||||
"PAST-ATLAS {at} head {:?}/{:?}",
|
||||
pair(&ol.head_cells[0]),
|
||||
pair(&ol.head_cells[1])
|
||||
);
|
||||
}
|
||||
}
|
||||
if !e.tile_rows.is_power_of_two() || !e.tile_cols.is_power_of_two() {
|
||||
bad_tiles += 1;
|
||||
println!("NON-POW2 {at}");
|
||||
}
|
||||
if !(ol.mid > 0.0 && ol.mid < 1.0) {
|
||||
bad_mid += 1;
|
||||
println!("MID {at} mid {}", ol.mid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"{models} models / {emitters} emitters scanned\n \
|
||||
INVERTED {inverted}\n \
|
||||
TAIL-RAMP {tail_ramp}\n \
|
||||
REPEAT {repeat_ne1}\n \
|
||||
PAST-ATLAS {past_atlas} ({past_atlas_real} on a real >1x1 atlas)\n \
|
||||
NON-POW2 {bad_tiles}\n \
|
||||
MID 0 or 1 {bad_mid}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Per-sequence **particle emission timing** — the FN1 bake of the emitter's two
|
||||
//! per-frame-sampled M2Tracks (spawn rate `+0xdc`, enabled gate `+0x1dc`), one loop per FILE
|
||||
//! sequence slot. Split from [`crate::particles`] (the raw record parse) because it is the
|
||||
//! *runtime sampling* face: decision 0641's material-alpha structure, one channel over.
|
||||
|
||||
use benilla_m2::M2ScalarTrack;
|
||||
|
||||
use crate::models::{bake_track, ScalarAnim, SeqSlot};
|
||||
|
||||
/// Per-sequence **emission timing**: the emitter's two per-frame-sampled M2Tracks — spawn rate
|
||||
/// (`+0xdc`) and the enabled gate (`+0x1dc`) — baked one loop per FILE sequence slot through the
|
||||
/// FN1 kernel ([`crate::models::bake_track`]), exactly the material-alpha structure of decision
|
||||
/// 0641 one channel over. The reference's emitter phase of `m2_animate` samples both through the
|
||||
/// **playing** sequence's key window every frame and forces the spawn rate to 0 while the gate is
|
||||
/// off (wow-re `part-emission-rate-animated.md` §2/§3, byte-verified `0x717d90`/`0x718f32`).
|
||||
///
|
||||
/// The clock law rides the slot: a **looping** sequence wraps its band (`t mod period` — a
|
||||
/// windowed gate re-fires every pass), a **clamped** one parks at the band end and holds the
|
||||
/// tail value (never aliasing back to the band start). Consumers resolve `seq` from whatever
|
||||
/// clock they run: a placed doodad passes `None` (slot 0 — its one-time arm), an effect its
|
||||
/// armed slot, a unit/GameObject its live playing sequence.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EmitTiming {
|
||||
/// Baked rate loop per file slot; `None` = the track keys nothing there (spawn rate 0).
|
||||
rate: Vec<Option<ScalarAnim>>,
|
||||
/// Baked gate per file slot (step, 0/1); `None` = no gate authored — the loader default is
|
||||
/// ON (`0x710092`: `block+0x14c = 1` for every emitter).
|
||||
enabled: Vec<Option<ScalarAnim>>,
|
||||
/// Per file slot: sequence flags bit 0 CLEAR = the band loops (the kernel's modulo wrap).
|
||||
looping: Vec<bool>,
|
||||
}
|
||||
|
||||
impl EmitTiming {
|
||||
/// Bake both tracks against every file sequence slot. `slots`/`looping` are parallel
|
||||
/// (one per file sequence); `gseq` is the global-sequence duration table.
|
||||
pub(crate) fn bake(
|
||||
rate: &M2ScalarTrack,
|
||||
enabled: &M2ScalarTrack,
|
||||
slots: &[SeqSlot],
|
||||
looping: Vec<bool>,
|
||||
gseq: &[u32],
|
||||
) -> Self {
|
||||
// Keep every baked shape, constants included — this channel has no static fallback path,
|
||||
// so a held value must survive the bake (`|_| false` on both predicates).
|
||||
let per_slot = |t: &M2ScalarTrack| -> Vec<Option<ScalarAnim>> {
|
||||
slots
|
||||
.iter()
|
||||
.map(|&s| bake_track(t, gseq, Some(s), |v| v, |_| false, |_| false))
|
||||
.collect()
|
||||
};
|
||||
Self {
|
||||
rate: per_slot(rate),
|
||||
enabled: per_slot(enabled),
|
||||
looping,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a consumer's sequence to a file slot: out-of-range / unknown degrades to slot 0
|
||||
/// (the doodad lane's one-time arm, and the old single-band behaviour).
|
||||
fn idx(&self, seq: Option<usize>) -> usize {
|
||||
match seq {
|
||||
Some(i) if i < self.looping.len() => i,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the gate ON, `elapsed` seconds into sequence slot `seq`? A slot with no baked gate is
|
||||
/// ON (the loader default).
|
||||
pub fn emitting(&self, seq: Option<usize>, elapsed: f32) -> bool {
|
||||
let i = self.idx(seq);
|
||||
let wrap = self.looping.get(i).copied().unwrap_or(true);
|
||||
self.enabled
|
||||
.get(i)
|
||||
.and_then(|o| o.as_ref())
|
||||
.is_none_or(|a| a.sample_clocked(elapsed, wrap, 1.0) > 0.5)
|
||||
}
|
||||
|
||||
/// The spawn rate (particles/sec), `elapsed` seconds into sequence slot `seq`. A slot with no
|
||||
/// baked rate spawns nothing. Floored at 0 (a track tail may legitimately go negative).
|
||||
pub fn rate(&self, seq: Option<usize>, elapsed: f32) -> f32 {
|
||||
let i = self.idx(seq);
|
||||
let wrap = self.looping.get(i).copied().unwrap_or(true);
|
||||
self.rate
|
||||
.get(i)
|
||||
.and_then(|o| o.as_ref())
|
||||
.map_or(0.0, |a| a.sample_clocked(elapsed, wrap, 0.0))
|
||||
.max(0.0)
|
||||
}
|
||||
|
||||
/// The rate track's peak over every slot — the "can this emitter ever contribute" spawn gate
|
||||
/// (a burst emitter keys `0 → peak → 0`, so its first key is 0 but it absolutely emits).
|
||||
pub fn peak_rate(&self) -> f32 {
|
||||
self.rate
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|a| a.keys.iter().map(|&(_, v)| v))
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// `Some(rate)` when every slot bakes the same single-key rate — the overwhelmingly common
|
||||
/// shape, and the dump instruments' quiet case.
|
||||
pub fn constant_rate(&self) -> Option<f32> {
|
||||
let mut it = self.rate.iter();
|
||||
let first = it.next()?.as_ref()?;
|
||||
let &(_, v) = (first.keys.len() == 1).then(|| first.keys.first())??;
|
||||
it.all(|a| a.as_ref().is_some_and(|a| a.keys == first.keys))
|
||||
.then_some(v)
|
||||
}
|
||||
|
||||
/// Per-slot read view for the dump instruments: `(looping, rate keys, enabled keys)`, one per
|
||||
/// file sequence slot (`None` = the track keys nothing in that slot). Times are seconds from
|
||||
/// the slot's band start — the values the runtime actually samples, not the raw file keys.
|
||||
#[allow(clippy::type_complexity)] // a read-only tuple view for the dumps
|
||||
pub fn slot_views(&self) -> Vec<(bool, Option<&[(f32, f32)]>, Option<&[(f32, f32)]>)> {
|
||||
fn keys(list: &[Option<ScalarAnim>], i: usize) -> Option<&[(f32, f32)]> {
|
||||
list.get(i)
|
||||
.and_then(|o| o.as_ref())
|
||||
.map(|a| a.keys.as_slice())
|
||||
}
|
||||
(0..self.looping.len())
|
||||
.map(|i| (self.looping[i], keys(&self.rate, i), keys(&self.enabled, i)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Test/tool constructor: a single always-on slot at a constant `rate`, looping.
|
||||
pub fn constant(rate: f32) -> Self {
|
||||
Self {
|
||||
rate: vec![Some(ScalarAnim {
|
||||
period: 0.0,
|
||||
step: true,
|
||||
keys: vec![(0.0, rate)],
|
||||
})],
|
||||
enabled: vec![None],
|
||||
looping: vec![true],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track(interp: u16, keys: &[(u32, f32)], ranges: &[(u32, u32)]) -> M2ScalarTrack {
|
||||
M2ScalarTrack {
|
||||
interp,
|
||||
gseq: 0xffff,
|
||||
ranges: ranges.to_vec(),
|
||||
keys: keys.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn slots(bands: &[(u32, u32)]) -> Vec<SeqSlot> {
|
||||
bands
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, &band)| SeqSlot { index, band })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The STEP rate law: a `{0:0, 67:30}` track is silent before its 67 ms key — the burst
|
||||
/// fires AT the key, full-count — and holds 30 after (moved here from the runtime's
|
||||
/// `accumulate_emission` tests when the sampling moved into the bake).
|
||||
#[test]
|
||||
fn step_rate_is_silent_before_its_key_and_holds_after() {
|
||||
let t = EmitTiming::bake(
|
||||
&track(0, &[(0, 0.0), (67, 30.0)], &[]),
|
||||
&M2ScalarTrack::default(),
|
||||
&slots(&[(0, 1000)]),
|
||||
vec![true],
|
||||
&[],
|
||||
);
|
||||
assert_eq!(t.rate(None, 0.050), 0.0, "before the key: step holds 0");
|
||||
assert_eq!(t.rate(None, 0.070), 30.0, "at/after the key: 30");
|
||||
assert_eq!(t.rate(None, 0.500), 30.0, "held to the band end");
|
||||
assert!(t.emitting(None, 0.5), "no gate track = always on");
|
||||
}
|
||||
|
||||
/// The LINEAR ramp law (the BloodSpurt shape `0 → 100 → 0`, interp 1): mid-ramp pours the
|
||||
/// interpolated rate, and the self-closing tail really falls back to 0.
|
||||
#[test]
|
||||
fn lerp_ramp_interpolates_and_self_closes() {
|
||||
let t = EmitTiming::bake(
|
||||
&track(1, &[(0, 0.0), (100, 100.0), (200, 0.0)], &[]),
|
||||
&M2ScalarTrack::default(),
|
||||
&slots(&[(0, 1000)]),
|
||||
vec![true],
|
||||
&[],
|
||||
);
|
||||
assert!((t.rate(None, 0.050) - 50.0).abs() < 1e-4, "rising mid-ramp");
|
||||
assert!(
|
||||
(t.rate(None, 0.150) - 50.0).abs() < 1e-4,
|
||||
"falling mid-ramp"
|
||||
);
|
||||
assert_eq!(t.rate(None, 0.500), 0.0, "the ramp self-closes");
|
||||
}
|
||||
|
||||
/// The per-sequence window law — the B27 shape, synthesized: an enabled gate authored ON
|
||||
/// inside a clamped one-shot clip (slot 0) whose window in the idle loop (slot 1) resolves
|
||||
/// to OFF. The old seq-0-only rebase parked the clamped clock at its end value; the bake
|
||||
/// must read OFF at idle, run slot 0's window, and HOLD (not wrap) slot 0's tail.
|
||||
#[test]
|
||||
fn idle_window_is_off_and_a_clamped_clip_holds_its_tail() {
|
||||
// Absolute timeline: clip A (one-shot) band 1000..2000, idle band 2333..2667.
|
||||
// Gate keys: on@1000, off@1333, on@3800 (a later clip's window, outside both bands).
|
||||
let gate = track(
|
||||
0,
|
||||
&[(1000, 1.0), (1333, 0.0), (3800, 1.0)],
|
||||
&[(0, 1), (1, 1), (2, 2)],
|
||||
);
|
||||
let t = EmitTiming::bake(
|
||||
&track(0, &[(0, 20.0)], &[]),
|
||||
&gate,
|
||||
&slots(&[(1000, 2000), (2333, 2667), (3800, 4100)]),
|
||||
vec![false, true, false],
|
||||
&[],
|
||||
);
|
||||
// Idle (slot 1): the collapsed window (1,1) resolves to keys[1] = OFF — at every time,
|
||||
// wrap included.
|
||||
assert!(!t.emitting(Some(1), 0.0));
|
||||
assert!(!t.emitting(Some(1), 0.25));
|
||||
assert!(!t.emitting(Some(1), 400.0));
|
||||
// The one-shot clip (slot 0): ON at its start, OFF from 333 ms — and the clamped clock
|
||||
// HOLDS that tail at/after the band end instead of aliasing back to the ON start.
|
||||
assert!(t.emitting(Some(0), 0.1));
|
||||
assert!(!t.emitting(Some(0), 0.5));
|
||||
assert!(!t.emitting(Some(0), 1.0), "t == period must not alias to 0");
|
||||
assert!(
|
||||
!t.emitting(Some(0), 5.0),
|
||||
"parked long past the end: still off"
|
||||
);
|
||||
// The later clip (slot 2): its degenerate window is the ON key.
|
||||
assert!(t.emitting(Some(2), 0.05));
|
||||
// Unknown/out-of-range degrades to slot 0 (the doodad law).
|
||||
assert!(t.emitting(None, 0.1));
|
||||
assert!(!t.emitting(Some(9), 0.5));
|
||||
// The rate is a whole-track constant: same in every slot.
|
||||
assert_eq!(t.constant_rate(), Some(20.0));
|
||||
assert_eq!(t.peak_rate(), 20.0);
|
||||
}
|
||||
|
||||
/// A LOOPING slot wraps its band — a windowed gate re-fires every pass (the precast hold's
|
||||
/// pulsing hand flash), where a clamped slot would have parked.
|
||||
#[test]
|
||||
fn a_looping_band_wraps_its_gate_window() {
|
||||
let gate = track(0, &[(0, 1.0), (200, 0.0)], &[]);
|
||||
let t = EmitTiming::bake(
|
||||
&track(0, &[(0, 40.0)], &[]),
|
||||
&gate,
|
||||
&slots(&[(0, 1000)]),
|
||||
vec![true],
|
||||
&[],
|
||||
);
|
||||
assert!(t.emitting(None, 0.1), "first pass: inside the window");
|
||||
assert!(!t.emitting(None, 0.8), "first pass: past it");
|
||||
assert!(t.emitting(None, 1.1), "second pass: the window re-fires");
|
||||
}
|
||||
}
|
||||
@@ -129,15 +129,17 @@ pub use spell_visual::{
|
||||
load_spell_visual_catalog, SpellVisualCatalog, VisualKit, VisualStages, KIT_SLOT_TAGS,
|
||||
MISSILE_ATTACH_TABLE,
|
||||
};
|
||||
mod emit_timing;
|
||||
pub use emit_timing::EmitTiming;
|
||||
mod particles;
|
||||
pub use particles::{
|
||||
parse_m2_particle_emitters, OverLife, ParticleBlend, ParticleEmitterDef, ParticleShape,
|
||||
SplineData,
|
||||
parse_m2_particle_emitters, CellRamp, OverLife, OverLifeSample, ParticleBlend,
|
||||
ParticleEmitterDef, ParticleShape, SplineData,
|
||||
};
|
||||
mod ribbons;
|
||||
pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef};
|
||||
mod value_track;
|
||||
pub use value_track::{OnOffTrack, TrackValue, ValueTrack};
|
||||
pub use value_track::{TrackValue, ValueTrack};
|
||||
mod models;
|
||||
pub use models::{
|
||||
accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision, hand_grip_finger_poses,
|
||||
@@ -168,7 +170,7 @@ mod wdl;
|
||||
/// World (x, y) ↔ ADT tile `(col, row)` — the same mapping the streamer uses to pick tiles
|
||||
/// (and the minimap uses to place its tile art, decision 0203: minimap `map<X>_<Y>.blp` names
|
||||
/// share the ADT index order, chain-verified — `AhnQiraj_27_46.adt` exists, the swap doesn't).
|
||||
pub use benilla_wdt::{tile_to_world, world_to_tile, WdtFile, WdtReader, WowVersion};
|
||||
pub use benilla_wdt::{tile_to_world, world_to_tile, GlobalWmo, WdtFile, WdtReader, WowVersion};
|
||||
pub use wdl::{WdlFile, WdlTileMesh};
|
||||
|
||||
// The map arc (decision 0203), phase 0: the minimap tile hash catalog + the world-map DBCs.
|
||||
|
||||
@@ -55,7 +55,7 @@ impl Lerp for [f32; 4] {
|
||||
/// is what indexes the track's [`M2Track::ranges`] — NOT an index into a filtered animation list)
|
||||
/// and that sequence's absolute `(start_ms, end_ms)` band.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct SeqSlot {
|
||||
pub(crate) struct SeqSlot {
|
||||
pub index: usize,
|
||||
pub band: (u32, u32),
|
||||
}
|
||||
@@ -76,14 +76,27 @@ pub struct KeyAnim<V> {
|
||||
impl<V: Lerp> KeyAnim<V> {
|
||||
/// Sample at `elapsed` seconds on the loop clock; `empty` is the channel's identity for the
|
||||
/// keyless case (the bake never emits that — each channel's `sample` supplies it).
|
||||
pub(super) fn sample_or(&self, elapsed: f32, empty: V) -> V {
|
||||
pub(crate) fn sample_or(&self, elapsed: f32, empty: V) -> V {
|
||||
self.sample_clocked(elapsed, true, empty)
|
||||
}
|
||||
|
||||
/// [`Self::sample_or`] with the clock law explicit: `wrap` = the playing sequence LOOPS
|
||||
/// (`t mod period` — the kernel's modulo wrap re-fires windowed tracks every pass); `!wrap` =
|
||||
/// it CLAMPS (`min(t, period)` — the clock parks at the band end and the track holds its tail
|
||||
/// value there, never wrapping back to the band-start value). The distinction is what keeps a
|
||||
/// clamped one-shot clip's end state honest: at `t == period` a modulo would alias to `0`.
|
||||
pub(crate) fn sample_clocked(&self, elapsed: f32, wrap: bool, empty: V) -> V {
|
||||
let Some(&(t0, v0)) = self.keys.first() else {
|
||||
return empty;
|
||||
};
|
||||
if self.period <= 0.0 || self.keys.len() == 1 {
|
||||
return v0;
|
||||
}
|
||||
let t = elapsed.rem_euclid(self.period);
|
||||
let t = if wrap {
|
||||
elapsed.rem_euclid(self.period)
|
||||
} else {
|
||||
elapsed.clamp(0.0, self.period)
|
||||
};
|
||||
if t <= t0 {
|
||||
return v0;
|
||||
}
|
||||
@@ -169,7 +182,7 @@ pub(super) fn sample_window<V: Lerp>(
|
||||
/// `gseq_durations` is the model's global-sequence table (ms); `seq` the sequence to bake for
|
||||
/// (see [`SeqSlot`]). A `gseq`-tagged track ignores `seq` entirely — it runs on the global
|
||||
/// sequence's own free clock, the same loop in every animation.
|
||||
pub(super) fn bake_track<T: Copy, V: Lerp + PartialEq>(
|
||||
pub(crate) fn bake_track<T: Copy, V: Lerp + PartialEq>(
|
||||
track: &M2Track<T>,
|
||||
gseq_durations: &[u32],
|
||||
seq: Option<SeqSlot>,
|
||||
|
||||
@@ -33,6 +33,7 @@ pub use anim::*;
|
||||
pub use anim_summary::*;
|
||||
pub use bounds::*;
|
||||
pub use collision::*;
|
||||
pub(crate) use key_anim::{bake_track, SeqSlot};
|
||||
pub use m2_batches::*;
|
||||
pub use mat_anim::{AlphaAnim, AlphaSeq, RgbAnim, ScalarAnim};
|
||||
pub use records::*;
|
||||
|
||||
@@ -24,10 +24,13 @@
|
||||
//! +0x1dc enabled(M2Track<u8>, step) — the emission ON/OFF gate, closing the record at 0x1f8.
|
||||
//! ```
|
||||
//!
|
||||
//! The keyed tracks here (emission rate, enabled) parse through the shared [`crate::value_track`]
|
||||
//! kernel: **rebased to the first sequence's band** at parse (see its module doc for the
|
||||
//! global-timeline law), so the runtime emitter clock (seconds since the effect spawned) samples
|
||||
//! them directly.
|
||||
//! The two keyed tracks here (emission rate, enabled) bake **one loop per sequence** into
|
||||
//! [`EmitTiming`] through the same FN1 kernel as the material alpha (`models::key_anim`,
|
||||
//! decision 0641): the reference samples both per frame through the *playing* sequence's own key
|
||||
//! window (`0x713d50` — wow-re `part-emission-rate-animated.md` §2/§3), so what an emitter does
|
||||
//! is a function of which sequence its model instance is playing. A quest GameObject authors its
|
||||
//! explosion inside one-shot clips and an OFF window in every idle sequence; baking only
|
||||
//! sequence 0's band parked that choreography at its end value forever (bug B27).
|
||||
//!
|
||||
//! The record tail (+0x180..) is the wow-re `part-simspace-fields.md` §5-verified map (their
|
||||
//! `ac915a7d`): the loader `0x70ebd0` remaps file+0x188/+0x18c → runtime twinkleScale min/max
|
||||
@@ -36,9 +39,10 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::Result;
|
||||
use benilla_m2::parse_m2;
|
||||
use benilla_m2::{parse_m2, M2ScalarTrack};
|
||||
|
||||
use crate::value_track::{seq0_band, track_enabled, track_keys_with, OnOffTrack, ValueTrack};
|
||||
use crate::emit_timing::EmitTiming;
|
||||
use crate::models::SeqSlot;
|
||||
|
||||
/// Emitter spawn shape (file `emitterType` @ +0x2a). Only `Plane` is needed for campfires/torches;
|
||||
/// `Sphere` (e.g. dust clouds) and `Spline` follow.
|
||||
@@ -62,47 +66,154 @@ pub enum ParticleBlend {
|
||||
Opaque,
|
||||
}
|
||||
|
||||
/// One segment's integer **flipbook cell ramp** — the authored `(begin, end)` pair and the
|
||||
/// `(base, span)` the reference derives from it at load (`0x7b9da0` head / `0x7b9de0` tail, both
|
||||
/// pure-integer; wow-re `part-cell-flipbook-ramp.md` §2).
|
||||
///
|
||||
/// The two build arms are deliberately **asymmetric**, and that asymmetry is the whole mechanism:
|
||||
/// the ramp covers `N = |end − begin| + 1` cells, each getting `1/N` of the segment, travelling in
|
||||
/// the **authored direction**.
|
||||
///
|
||||
/// ```text
|
||||
/// end >= begin : base = begin, span = end − begin + 1 (forward)
|
||||
/// end < begin : base = begin + 1, span = end − begin − 1 (NEGATIVE — plays backwards)
|
||||
/// ```
|
||||
///
|
||||
/// **A decreasing pair is legal, shipped, and means "run the flipbook in reverse"** — four models
|
||||
/// author one (`DwarvenBrazier01`'s settling flame, `ShadowWordSilence_Breath`'s 32-frame reverse
|
||||
/// sweep). There is no swap and no clamp anywhere on the reference's path; an earlier reading here
|
||||
/// clamped into `[begin, end]`, which both mangled the reverse ramps and panicked outright on them
|
||||
/// (decision 0685).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CellRamp {
|
||||
/// The authored pair, verbatim (the reference archives it at `rec+0x3c/+0x40` and never reads
|
||||
/// it back — kept here because it is what a dump should show).
|
||||
pub begin: u16,
|
||||
pub end: u16,
|
||||
base: i32,
|
||||
span: i32,
|
||||
}
|
||||
|
||||
impl CellRamp {
|
||||
/// Build from an authored `(begin, end)` pair — `0x7b9da0`'s two arms, verbatim.
|
||||
pub fn new(begin: u16, end: u16) -> Self {
|
||||
let (b, e) = (i32::from(begin), i32::from(end));
|
||||
let (base, span) = if e >= b {
|
||||
(b, e - b + 1)
|
||||
} else {
|
||||
(b + 1, e - b - 1)
|
||||
};
|
||||
Self {
|
||||
begin,
|
||||
end,
|
||||
base,
|
||||
span,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell index at segment fraction `t` — **`t` must already carry the endpoint inset** (and
|
||||
/// the repeat wrap, if any); see [`OverLife::sample`].
|
||||
///
|
||||
/// `floor(base + span·t) & 0xFF`. The mask is the reference's mod-256 wrap (its fast-floor
|
||||
/// idiom `bits(x + 512.0) >> 14` yields a byte), **not** a clamp into the authored range or the
|
||||
/// atlas — bounding the index is the atlas walk's business, and it only masks the column.
|
||||
pub fn sample(&self, t: f32) -> u16 {
|
||||
// Saturating float→int (NaN → 0) rather than the reference's raw mantissa read: a `mid` of
|
||||
// 0 or 1 makes the reference's own segment span 1/0 and walks NaN into this floor, and we
|
||||
// will not reproduce a fault. No shipped emitter authors that mid (corpus-swept).
|
||||
let v = self.base as f32 + self.span as f32 * t;
|
||||
((v.floor() as i32) & 0xFF) as u16
|
||||
}
|
||||
}
|
||||
|
||||
/// The baked **over-life** ramps (record tail, +0x14c..) sampled by a particle's normalized age `u =
|
||||
/// age/lifespan`. Each of color, size and cell is a **3-key ramp** evaluated as two linear segments
|
||||
/// split at [`Self::mid`]: key0→key1 over `u∈[0,mid]`, then key1→key2 over `u∈[mid,1]`. Byte layout +
|
||||
/// math verified in `wow-5875-re` (`FUN_0070ebd0` builder, `FUN_007b9b10` evaluator) and difftested
|
||||
/// age/lifespan`. Colour and size are **3-key ramps** evaluated as two linear segments split at
|
||||
/// [`Self::mid`]: key0→key1 over `u∈[0,mid]`, then key1→key2 over `u∈[mid,1]`. The flipbook cells
|
||||
/// are a different shape — a `(begin, end)` [`CellRamp`] **per segment**, and there are two
|
||||
/// independent sets of them (head quad, tail streak). Byte layout + math verified in `wow-5875-re`
|
||||
/// (`FUN_0070ebd0` builder, `FUN_007b9b10` evaluator, `part-cell-flipbook-ramp.md`) and difftested
|
||||
/// against `ElwynnCampfire.m2`. These drive the believable flame fade / grow / flicker.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OverLife {
|
||||
/// Normalized age (`+0x14c`) splitting the two ramp segments.
|
||||
/// Normalized age (`+0x14c`) splitting the two ramp segments. The reference's split is
|
||||
/// **inclusive toward segment A** (`age > lifespan·mid` is the only way into B).
|
||||
pub mid: f32,
|
||||
/// RGBA keys k0/k1/k2 (`+0x150/+0x154/+0x158`, packed BGRA u32 → linear 0..1). The **A** channel
|
||||
/// is the particle's additive weight (its over-life opacity); there is no separate alpha array.
|
||||
pub color: [[f32; 4]; 3],
|
||||
/// Uniform particle size keys k0/k1/k2 (`+0x15c/+0x160/+0x164`, yards).
|
||||
pub scale: [f32; 3],
|
||||
/// Head texture-cell index range `[begin, end]` for segment A (`u≤mid`, `+0x168/+0x16a`) and
|
||||
/// segment B (`u>mid`, `+0x16e/+0x170`) — the cell animated across the `rows×cols` atlas.
|
||||
pub cell_a: [u16; 2],
|
||||
pub cell_b: [u16; 2],
|
||||
/// The **head** quad's flipbook ramp, per segment: A (`+0x168/+0x16a`), B (`+0x16e/+0x170`).
|
||||
pub head_cells: [CellRamp; 2],
|
||||
/// The **tail** streak's own, independent flipbook ramp: A (`+0x174/+0x176`), B
|
||||
/// (`+0x178/+0x17a`). The reference emits two cell indices per particle and the tail quad reads
|
||||
/// this one — a model whose tail ramp differs from its head ramp animates the streak
|
||||
/// separately (wow-re `part-cell-flipbook-ramp.md` §4, correcting `part-quad-tail-twinkle.md`).
|
||||
pub tail_cells: [CellRamp; 2],
|
||||
/// Per-segment flipbook **repeat count** (`+0x16c`/`+0x172`, `fild`-converted at load). `1.0`
|
||||
/// (the ctor default, and what nearly all content authors) is one pass; anything else cycles
|
||||
/// the cell ramp `fmod(t·repeat, 1.0)` times across the segment. Colour and size do not cycle.
|
||||
pub repeat: [f32; 2],
|
||||
}
|
||||
|
||||
/// One evaluation of the over-life ramps — the reference's `0x7b9b10` writes exactly these four
|
||||
/// outputs (colour, **two** cell indices, size) from one call.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OverLifeSample {
|
||||
/// Linear RGBA; the A channel is the particle's over-life opacity/additive weight.
|
||||
pub color: [f32; 4],
|
||||
/// Half-extent in yards.
|
||||
pub size: f32,
|
||||
/// Atlas cell for the head quad.
|
||||
pub head_cell: u16,
|
||||
/// Atlas cell for the tail streak (independent of [`Self::head_cell`]).
|
||||
pub tail_cell: u16,
|
||||
}
|
||||
|
||||
impl OverLife {
|
||||
/// Sample the ramps at normalized age `u` (0..1): `(rgba, size_yards, cell_index)`.
|
||||
pub fn sample(&self, u: f32) -> ([f32; 4], f32, u16) {
|
||||
/// Sample every ramp at normalized age `u` (0..1).
|
||||
pub fn sample(&self, u: f32) -> OverLifeSample {
|
||||
let u = u.clamp(0.0, 1.0);
|
||||
// A `mid` of 0 divides by zero in the reference (§7 of the wow-re note: it walks NaN into
|
||||
// the sampler); no shipped emitter authors one, and we refuse to reproduce the fault.
|
||||
let mid = self.mid.clamp(1e-3, 1.0);
|
||||
let (a, b, t, cell) = if u <= mid {
|
||||
(0, 1, u / mid, self.cell_a)
|
||||
let (k0, k1, t, seg) = if u <= mid {
|
||||
(0, 1, u / mid, 0)
|
||||
} else {
|
||||
(1, 2, (u - mid) / (1.0 - mid).max(1e-3), self.cell_b)
|
||||
(1, 2, (u - mid) / (1.0 - mid).max(1e-3), 1)
|
||||
};
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
// **The endpoint inset, and it is the only normalized time the reference has.** `0x7b9b10`
|
||||
// computes `t·0.99 + 0.005` once, over its own `age` argument slot, and every consumer
|
||||
// reloads that slot — all four colour channels, the size, and both cell ramps (verified
|
||||
// instruction by instruction, wow-re `part-cell-flipbook-ramp.md` §3a "scope of the
|
||||
// inset"). So a particle at a segment's start sits **0.5 % along** its colour and size
|
||||
// ramps, never exactly on the authored key, and 99.5 % at the end.
|
||||
//
|
||||
// For the CELLS the inset is not a rounding artefact but load-bearing: co-designed with
|
||||
// the ramp's ±1, it is the only thing making `cell(0) == begin` and `cell(1) == end` hold
|
||||
// in BOTH directions (a raw `t` floors one past the end going forward, one past the begin
|
||||
// going backward). Exact while `0.005·N < 1` — 200 cells; the largest shipped atlas is 8×8.
|
||||
let t = t.clamp(0.0, 1.0) * 0.99 + 0.005;
|
||||
let mut color = [0.0; 4];
|
||||
for (c, slot) in color.iter_mut().enumerate() {
|
||||
*slot = self.color[a][c] + (self.color[b][c] - self.color[a][c]) * t;
|
||||
*slot = self.color[k0][c] + (self.color[k1][c] - self.color[k0][c]) * t;
|
||||
}
|
||||
let size = self.scale[k0] + (self.scale[k1] - self.scale[k0]) * t;
|
||||
// The repeat count cycles the FLIPBOOK ONLY — an explicit verified negative, and visible in
|
||||
// the reference's own ordering: colour and size are computed and stored out before the
|
||||
// `rec+0x50 != 1.0` branch is even evaluated, and the wrapped coefficient never becomes `t`
|
||||
// (§3b). So a 5× swarm emitter flaps its wings five times while fading exactly once.
|
||||
let ct = if self.repeat[seg] != 1.0 {
|
||||
(t * self.repeat[seg]).fract()
|
||||
} else {
|
||||
t
|
||||
};
|
||||
OverLifeSample {
|
||||
color,
|
||||
size,
|
||||
head_cell: self.head_cells[seg].sample(ct),
|
||||
tail_cell: self.tail_cells[seg].sample(ct),
|
||||
}
|
||||
let size = self.scale[a] + (self.scale[b] - self.scale[a]) * t;
|
||||
// cell = floor(begin + (end-begin+1)·t), clamped to [begin, end].
|
||||
let span = (i32::from(cell[1]) - i32::from(cell[0]) + 1).max(1) as f32;
|
||||
let idx = (f32::from(cell[0]) + span * t).floor() as i32;
|
||||
let cell_index = idx.clamp(i32::from(cell[0]), i32::from(cell[1])) as u16;
|
||||
(color, size, cell_index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +336,11 @@ pub struct ParticleEmitterDef {
|
||||
pub recursion_model: Option<String>,
|
||||
/// Particle texture (`.blp` path, as embedded in the M2 textures table). `None` if unresolved.
|
||||
pub texture: Option<String>,
|
||||
/// Texture atlas size for cell animation (`1×1` = a single static cell).
|
||||
/// Texture atlas size for cell animation (`1×1` = a single static cell). **Both are non-zero
|
||||
/// powers of two**: the reference's setter (`0x7b4ed0`) demands it — it derives `log2(cols)`
|
||||
/// and the two reciprocals — and on a bad pair writes *none* of its five fields, leaving the
|
||||
/// ctor's `1×1`. [`parse_m2_particle_emitters`] mirrors that fallback, so the atlas walk can
|
||||
/// mask instead of divide. No shipped emitter trips it (corpus-swept, decision 0685).
|
||||
pub tile_rows: u16,
|
||||
pub tile_cols: u16,
|
||||
/// 0 = head (camera quad), 1 = tail (speed-stretched), 2 = both. We render head first.
|
||||
@@ -243,21 +358,11 @@ pub struct ParticleEmitterDef {
|
||||
pub gravity: f32,
|
||||
/// Particle lifetime (seconds) — killed at this age.
|
||||
pub lifespan: f32,
|
||||
/// Particles spawned per second — the one emission parameter parsed as its **full keyed track**
|
||||
/// ([`ValueTrack`]), because the one-shot combat effects author their bursts on it (see
|
||||
/// [`ValueTrack`]'s doc). The other nine stay `value[0]`-baked: constant in every model this
|
||||
/// renderer draws (verified across the spurt family + the ambient props).
|
||||
pub emission_rate: ValueTrack,
|
||||
/// The emission ON/OFF gate ([`OnOffTrack`], file `+0x1dc`) — the one-shot effects'
|
||||
/// choreography track. Sampled on the same clip clock as `emission_rate`.
|
||||
pub enabled: OnOffTrack,
|
||||
/// The clip clock's WRAP: `Some(first sequence's span ms)` when that sequence LOOPS — the
|
||||
/// client samples the two tracks above on `m2_animate`'s sequence time, which wraps there,
|
||||
/// re-firing windowed tracks every pass (a precast hold's pulsing hand flash). A CLAMPED
|
||||
/// sequence holds its end instead — and a discrete kit instance is reaped at exactly one
|
||||
/// pass (`spell_fx`), so its boundary never renders; `None` keeps the end-clamp hold.
|
||||
/// (wow-re `part-emission-rate-animated.md`: the per-frame `m2_animate` sampler.)
|
||||
pub clip_wrap_ms: Option<f32>,
|
||||
/// The two per-frame-sampled emission tracks — spawn rate (`+0xdc`) and the ON/OFF gate
|
||||
/// (`+0x1dc`) — baked **one loop per sequence** ([`EmitTiming`]). The other nine emission
|
||||
/// M2Tracks stay `value[0]`-baked: constant in every model this renderer draws (verified
|
||||
/// across the spurt family + the ambient props; a named residual if a model ever authors one).
|
||||
pub timing: EmitTiming,
|
||||
/// Plane emitter full extents (yards); spawn points are drawn from the `±½·extent` rectangle.
|
||||
pub area_length: f32,
|
||||
pub area_width: f32,
|
||||
@@ -484,10 +589,46 @@ fn track_first(b: &[u8], track: usize, default: f32) -> f32 {
|
||||
le_f32(b, ofs)
|
||||
}
|
||||
|
||||
/// Read a vanilla M2Track's full `(timestamp ms, f32)` key list, band-rebased — the shared
|
||||
/// kernel's reader with the scalar decode ([`track_keys_with`]).
|
||||
fn track_keys(b: &[u8], track: usize, default: f32, band: (u32, u32)) -> ValueTrack {
|
||||
track_keys_with(b, track, default, band, 4, le_f32)
|
||||
/// Read one raw vanilla M2Track (28 bytes at `track`) into the shared typed shape — **absolute**
|
||||
/// key timestamps plus the per-sequence `ranges` windows the FN1 bake indexes. `benilla-m2` skips
|
||||
/// the cosmetic chunks, so the two emitter timing tracks are lifted here; `elem`/`read` decode one
|
||||
/// value (`f32`, or the enabled gate's `u8` projected to 0/1).
|
||||
fn read_raw_track(
|
||||
b: &[u8],
|
||||
track: usize,
|
||||
elem: usize,
|
||||
read: impl Fn(&[u8], usize) -> f32,
|
||||
) -> M2ScalarTrack {
|
||||
let mut out = M2ScalarTrack {
|
||||
gseq: 0xffff,
|
||||
..M2ScalarTrack::default()
|
||||
};
|
||||
if track + 0x1c > b.len() {
|
||||
return out;
|
||||
}
|
||||
out.interp = le_u16(b, track);
|
||||
out.gseq = le_u16(b, track + 2);
|
||||
let (rn, ro) = (le_u32(b, track + 4) as usize, le_u32(b, track + 8) as usize);
|
||||
let (tn, to) = (
|
||||
le_u32(b, track + 0x0c) as usize,
|
||||
le_u32(b, track + 0x10) as usize,
|
||||
);
|
||||
let (vn, vo) = (
|
||||
le_u32(b, track + 0x14) as usize,
|
||||
le_u32(b, track + 0x18) as usize,
|
||||
);
|
||||
if ro + rn * 8 <= b.len() {
|
||||
out.ranges = (0..rn)
|
||||
.map(|i| (le_u32(b, ro + i * 8), le_u32(b, ro + i * 8 + 4)))
|
||||
.collect();
|
||||
}
|
||||
let n = tn.min(vn);
|
||||
if n > 0 && to + n * 4 <= b.len() && vo + n * elem <= b.len() {
|
||||
out.keys = (0..n)
|
||||
.map(|i| (le_u32(b, to + i * 4), read(b, vo + i * elem)))
|
||||
.collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn blend_of(v: u8) -> ParticleBlend {
|
||||
@@ -538,10 +679,42 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result<Vec<ParticleEmitterDef
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// The first sequence's absolute time band — the window the keyed tracks below are rebased
|
||||
// onto (`value_track::seq0_band`).
|
||||
let band = seq0_band(bytes);
|
||||
let clip_wrap_ms = crate::value_track::seq0_wrap_ms(bytes);
|
||||
// Every FILE sequence slot's band + loop flag (entry stride 0x44: band start/end @ +0x04/+0x08,
|
||||
// flags @ +0x10, bit 0 CLEAR = loops) — the addressing unit the per-sequence timing bake needs,
|
||||
// the same walk as `m2_batches`. A sequence-less model gets one synthetic whole-timeline slot
|
||||
// so an authored track still bakes (raw timestamps, the pre-sequence degenerate).
|
||||
let (mut seq_slots, mut looping) = {
|
||||
let (n, o) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize);
|
||||
(0..n)
|
||||
.map_while(|i| {
|
||||
let e = o + i * 0x44;
|
||||
(e + 0x44 <= bytes.len()).then(|| {
|
||||
(
|
||||
SeqSlot {
|
||||
index: i,
|
||||
band: (le_u32(bytes, e + 0x04), le_u32(bytes, e + 0x08)),
|
||||
},
|
||||
le_u32(bytes, e + 0x10) & 1 == 0,
|
||||
)
|
||||
})
|
||||
})
|
||||
.unzip::<_, _, Vec<_>, Vec<_>>()
|
||||
};
|
||||
if seq_slots.is_empty() {
|
||||
seq_slots.push(SeqSlot {
|
||||
index: 0,
|
||||
band: (0, u32::MAX),
|
||||
});
|
||||
looping.push(true);
|
||||
}
|
||||
// The global-sequence duration table (header +0x14/+0x18) — a gseq-tagged timing track loops
|
||||
// on its own free clock, same law as every other channel.
|
||||
let gseq: Vec<u32> = {
|
||||
let (n, o) = (le_u32(bytes, 0x14) as usize, le_u32(bytes, 0x18) as usize);
|
||||
(0..n)
|
||||
.map_while(|i| (o + i * 4 + 4 <= bytes.len()).then(|| le_u32(bytes, o + i * 4)))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Decode a packed BGRA `CImVector` color key → linear RGBA 0..1 (A = over-life opacity).
|
||||
let color_key = |o: usize| -> [f32; 4] {
|
||||
@@ -588,6 +761,12 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result<Vec<ParticleEmitterDef
|
||||
SplineData::new((0..n).map(|p| le_vec3(bytes, ofs + p * 12)).collect())
|
||||
})
|
||||
.flatten();
|
||||
// ROWS @+0x30 / COLUMNS @+0x32, both required non-zero powers of two — the reference's
|
||||
// `0x7b4ed0` bails on a bad pair having written nothing, leaving its ctor's 1×1.
|
||||
let tiles = match (le_u16(bytes, e + 0x30), le_u16(bytes, e + 0x32)) {
|
||||
(r, c) if r.is_power_of_two() && c.is_power_of_two() => (r, c),
|
||||
_ => (1, 1),
|
||||
};
|
||||
let over_life = OverLife {
|
||||
mid: le_f32(bytes, e + 0x14c),
|
||||
color: [
|
||||
@@ -600,8 +779,22 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result<Vec<ParticleEmitterDef
|
||||
le_f32(bytes, e + 0x160),
|
||||
le_f32(bytes, e + 0x164),
|
||||
],
|
||||
cell_a: [le_u16(bytes, e + 0x168), le_u16(bytes, e + 0x16a)],
|
||||
cell_b: [le_u16(bytes, e + 0x16e), le_u16(bytes, e + 0x170)],
|
||||
// The +0x168..+0x17b block is TEN u16s, read `{head A, head B, tail A, tail B}` with a
|
||||
// repeat count wedged after each head pair — not the eight that wowdev's
|
||||
// `lifespanUVAnim[3]/decayUVAnim[3]/tailUVAnim[2]` naming implies (wow-re
|
||||
// `part-cell-flipbook-ramp.md` §1; +0x17c is already the tail LENGTH dword).
|
||||
head_cells: [
|
||||
CellRamp::new(le_u16(bytes, e + 0x168), le_u16(bytes, e + 0x16a)),
|
||||
CellRamp::new(le_u16(bytes, e + 0x16e), le_u16(bytes, e + 0x170)),
|
||||
],
|
||||
tail_cells: [
|
||||
CellRamp::new(le_u16(bytes, e + 0x174), le_u16(bytes, e + 0x176)),
|
||||
CellRamp::new(le_u16(bytes, e + 0x178), le_u16(bytes, e + 0x17a)),
|
||||
],
|
||||
repeat: [
|
||||
f32::from(le_u16(bytes, e + 0x16c)),
|
||||
f32::from(le_u16(bytes, e + 0x172)),
|
||||
],
|
||||
};
|
||||
out.push(ParticleEmitterDef {
|
||||
flags: le_u32(bytes, e + 0x04),
|
||||
@@ -611,11 +804,10 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result<Vec<ParticleEmitterDef
|
||||
spline,
|
||||
geometry_model,
|
||||
recursion_model,
|
||||
clip_wrap_ms,
|
||||
blend: blend_of(bytes[e + 0x28]),
|
||||
texture,
|
||||
tile_rows: le_u16(bytes, e + 0x30).max(1),
|
||||
tile_cols: le_u16(bytes, e + 0x32).max(1),
|
||||
tile_rows: tiles.0,
|
||||
tile_cols: tiles.1,
|
||||
head_tail: bytes[e + 0x2c],
|
||||
emission_speed: track_first(bytes, e + 0x34, 0.0),
|
||||
speed_variation: track_first(bytes, e + 0x50, 0.0),
|
||||
@@ -623,8 +815,13 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result<Vec<ParticleEmitterDef
|
||||
horizontal_range: track_first(bytes, e + 0x88, 0.0),
|
||||
gravity: track_first(bytes, e + 0xa4, 0.0),
|
||||
lifespan: track_first(bytes, e + 0xc0, 1.0),
|
||||
emission_rate: track_keys(bytes, e + 0xdc, 0.0, band),
|
||||
enabled: track_enabled(bytes, e + 0x1dc, band),
|
||||
timing: EmitTiming::bake(
|
||||
&read_raw_track(bytes, e + 0xdc, 4, le_f32),
|
||||
&read_raw_track(bytes, e + 0x1dc, 1, |b, o| f32::from(b[o] != 0)),
|
||||
&seq_slots,
|
||||
looping.clone(),
|
||||
&gseq,
|
||||
),
|
||||
area_length: track_first(bytes, e + 0xf8, 0.0),
|
||||
area_width: track_first(bytes, e + 0x114, 0.0),
|
||||
z_source: track_first(bytes, e + 0x130, 0.0),
|
||||
@@ -700,7 +897,7 @@ mod tests {
|
||||
let defs = parse_m2_particle_emitters(&bytes).expect("parse emitters");
|
||||
assert_eq!(defs.len(), 4);
|
||||
// Emitter 0 — the red spray: starts hot (100/s at t=0), the old bake's one working case.
|
||||
assert_eq!(defs[0].emission_rate.first(), 100.0);
|
||||
assert_eq!(defs[0].timing.rate(None, 0.0), 100.0);
|
||||
// Emitter 3 — the starflash: additive, STARFLASH texture, burst-keyed (first key 0,
|
||||
// peak 20/s inside the 67–100 ms window, dead again by 133 ms).
|
||||
let flash = &defs[3];
|
||||
@@ -709,17 +906,17 @@ mod tests {
|
||||
.as_deref()
|
||||
.is_some_and(|t| t.to_ascii_uppercase().contains("STARFLASH")));
|
||||
assert_eq!(flash.blend, ParticleBlend::Add);
|
||||
assert_eq!(flash.emission_rate.first(), 0.0, "value[0] bake = silence");
|
||||
assert_eq!(flash.emission_rate.peak(), 20.0);
|
||||
assert_eq!(flash.emission_rate.step_ms(80.0), 20.0);
|
||||
assert_eq!(flash.emission_rate.step_ms(200.0), 0.0);
|
||||
assert_eq!(flash.timing.rate(None, 0.0), 0.0, "silent at t=0");
|
||||
assert_eq!(flash.timing.peak_rate(), 20.0);
|
||||
assert_eq!(flash.timing.rate(None, 0.080), 20.0);
|
||||
assert_eq!(flash.timing.rate(None, 0.200), 0.0);
|
||||
// Emitters 1/2 — the glowball droplets, same burst shape, peaks 200.
|
||||
assert_eq!(defs[1].emission_rate.peak(), 200.0);
|
||||
assert_eq!(defs[2].emission_rate.peak(), 200.0);
|
||||
assert_eq!(defs[1].timing.peak_rate(), 200.0);
|
||||
assert_eq!(defs[2].timing.peak_rate(), 200.0);
|
||||
// The spray's enabled track cuts emission at 500 ms — exactly where its rate track goes
|
||||
// negative (the authored tail the old always-on read let the floor-at-0 hide).
|
||||
assert!(defs[0].enabled.on_at(400.0));
|
||||
assert!(!defs[0].enabled.on_at(600.0));
|
||||
assert!(defs[0].timing.emitting(None, 0.4));
|
||||
assert!(!defs[0].timing.emitting(None, 0.6));
|
||||
}
|
||||
|
||||
/// The real `Feint_Impact_Chest.m2` (the Eviscerate/Feint impact) — the BURST-flag split
|
||||
@@ -749,9 +946,9 @@ mod tests {
|
||||
assert!(impact[3].burst(), "crescents are a one-shot burst");
|
||||
// The STEP rate law that arms the burst count: silent before the 67 ms key, the full
|
||||
// value at it, held past it (where the burst latch keeps it from ever mattering).
|
||||
assert_eq!(impact[0].emission_rate.step_ms(50.0), 0.0);
|
||||
assert_eq!(impact[0].emission_rate.step_ms(67.0), 30.0);
|
||||
assert_eq!(impact[0].emission_rate.step_ms(1500.0), 30.0);
|
||||
assert_eq!(impact[0].timing.rate(None, 0.050), 0.0);
|
||||
assert_eq!(impact[0].timing.rate(None, 0.067), 30.0);
|
||||
assert_eq!(impact[0].timing.rate(None, 1.500), 30.0);
|
||||
let cast = parse_m2_particle_emitters(
|
||||
&chain
|
||||
.read_file("Spells\\Eviscerate_Cast_Hands.m2")
|
||||
@@ -827,9 +1024,12 @@ mod tests {
|
||||
.expect("parse emitters");
|
||||
assert_eq!(cast.len(), 3);
|
||||
for em in &cast {
|
||||
assert!(em.enabled.on_at(100.0), "the flash is ON at its start");
|
||||
assert!(
|
||||
!em.enabled.on_at(250.0),
|
||||
em.timing.emitting(None, 0.1),
|
||||
"the flash is ON at its start"
|
||||
);
|
||||
assert!(
|
||||
!em.timing.emitting(None, 0.25),
|
||||
"and OFF from 200 ms — the 1.0 s clip does not burn through"
|
||||
);
|
||||
}
|
||||
@@ -840,19 +1040,22 @@ mod tests {
|
||||
)
|
||||
.expect("parse emitters");
|
||||
assert_eq!(impact.len(), 6);
|
||||
assert_eq!(impact[0].emission_rate.step_ms(0.0), 0.0);
|
||||
assert_eq!(impact[0].timing.rate(None, 0.0), 0.0);
|
||||
assert_eq!(
|
||||
impact[0].emission_rate.step_ms(133.0),
|
||||
impact[0].timing.rate(None, 0.133),
|
||||
60.0,
|
||||
"the plume bursts at impact"
|
||||
);
|
||||
assert!(impact[1].enabled.on_at(100.0));
|
||||
assert!(impact[1].timing.emitting(None, 0.1));
|
||||
assert!(
|
||||
!impact[1].enabled.on_at(400.0),
|
||||
!impact[1].timing.emitting(None, 0.4),
|
||||
"shockwave window is 300 ms"
|
||||
);
|
||||
assert!(!impact[4].enabled.on_at(50.0), "smoke starts staggered…");
|
||||
assert!(impact[4].enabled.on_at(200.0));
|
||||
assert!(!impact[4].enabled.on_at(700.0), "…and ends by 567 ms");
|
||||
assert!(
|
||||
!impact[4].timing.emitting(None, 0.05),
|
||||
"smoke starts staggered…"
|
||||
);
|
||||
assert!(impact[4].timing.emitting(None, 0.2));
|
||||
assert!(!impact[4].timing.emitting(None, 0.7), "…and ends by 567 ms");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,4 +767,55 @@ mod tests {
|
||||
// A column past the chunk's east edge is off the footprint entirely.
|
||||
assert_eq!(c.height_at([quarter_x, far_y - 5.0, 0.0]), None);
|
||||
}
|
||||
|
||||
/// The real 5875 map set: which maps have terrain and which are a single global WMO
|
||||
/// (decision 0688). 20 of the 43 shipped WDTs author **zero** ADT tiles — on those maps the
|
||||
/// global WMO is the entire world, and a client that ignores it renders a void you fall
|
||||
/// through. Skips without client data.
|
||||
#[test]
|
||||
fn real_wdts_split_into_adt_maps_and_wmo_only_maps() {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
|
||||
// A WMO-only map, an ADT map, and the one map whose placement is NOT identity — the three
|
||||
// cases the streamer has to tell apart.
|
||||
let jail = read_wdt(&mut chain, "StormwindJail").expect("StormwindJail.wdt");
|
||||
let g = jail
|
||||
.global_wmo()
|
||||
.expect("the Stockade is one WMO, no terrain");
|
||||
assert_eq!(
|
||||
g.model.to_ascii_lowercase(),
|
||||
"world\\wmo\\dungeon\\az_stormwindprisons\\stormwindjail.wmo"
|
||||
);
|
||||
// Authored at the world origin, unrotated: the placement the whole dungeon hangs off.
|
||||
assert_eq!(g.position, [0.0, 0.0, 0.0]);
|
||||
assert_eq!(g.rotation, [0.0, 0.0, 0.0]);
|
||||
assert!(
|
||||
(0..64).all(|y| (0..64).all(|x| !tile_exists(&jail, x, y))),
|
||||
"a WMO-only map authors no ADT tiles at all"
|
||||
);
|
||||
|
||||
// Deadmines is the control: the reports that opened 0688 said it alone kept you standing,
|
||||
// and this is why — it ships real terrain, so the missing global-WMO branch never bit.
|
||||
let deadmines = read_wdt(&mut chain, "DeadminesInstance").expect("DeadminesInstance.wdt");
|
||||
assert!(deadmines.global_wmo().is_none());
|
||||
let tiles = (0..64)
|
||||
.flat_map(|y| (0..64).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| tile_exists(&deadmines, x, y))
|
||||
.count();
|
||||
assert_eq!(tiles, 36, "Deadmines ships 36 ADT tiles");
|
||||
|
||||
// Dire Maul is the reason the rotation column is read rather than assumed: it is the only
|
||||
// shipped map placed at a heading, and dropping it would spin the dungeon 180° about the
|
||||
// origin — every room mirrored onto the wrong side of the map.
|
||||
let dm = read_wdt(&mut chain, "DireMaul").expect("DireMaul.wdt");
|
||||
assert_eq!(
|
||||
dm.global_wmo().expect("WMO-only").rotation,
|
||||
[0.0, 180.0, 0.0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,51 +159,6 @@ impl ValueTrack<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// An **ON/OFF gate** M2Track (`M2Track<u8>`, step-interpolated): the emission gate on particle
|
||||
/// (`+0x1dc`) and ribbon (`+0xc0` visibility) records. This is how one-shot effect models
|
||||
/// choreograph their emitters inside the clip — `Fire_Cast_Hand.m2` enables its hand-flash
|
||||
/// emitters for exactly the first 200 ms of its 1.0 s clip; `MoltenBlast_Impact_Chest.m2`
|
||||
/// staggers six emitters across 200–570 ms windows. A cleared gate stops NEW emission only; live
|
||||
/// particles finish their lifespan (the same law as the ribbon `block+0xbc` gate, wow-re
|
||||
/// `ribbon-emitter-spec.md` §6.3).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OnOffTrack {
|
||||
/// `(timestamp ms, on)` keys, ascending — rebased to the first sequence's band like
|
||||
/// [`ValueTrack`]. Empty never occurs from the parsers (the fallback is a single always-on
|
||||
/// key).
|
||||
pub keys: Vec<(u32, bool)>,
|
||||
}
|
||||
|
||||
impl Default for OnOffTrack {
|
||||
fn default() -> Self {
|
||||
Self::always_on()
|
||||
}
|
||||
}
|
||||
|
||||
impl OnOffTrack {
|
||||
/// The parse-fallback shape: a single key at t=0, on.
|
||||
pub(crate) fn always_on() -> Self {
|
||||
Self {
|
||||
keys: vec![(0, true)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Step-sample at `ms` since clip start: the last key at or before `ms` (M2 step
|
||||
/// interpolation — nearest-previous), the first key's value before it, `true` for a keyless
|
||||
/// track (never emitted by the parse).
|
||||
pub fn on_at(&self, ms: f32) -> bool {
|
||||
let mut on = self.keys.first().is_none_or(|&(_, v)| v);
|
||||
for &(t, v) in &self.keys {
|
||||
if (t as f32) <= ms {
|
||||
on = v;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
on
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase one keyed track from the vanilla global timeline onto a sequence's `[start, end]` band
|
||||
/// (clip-relative ms): in-band keys shift by `−start`; of the keys at/before `start` only the
|
||||
/// last survives, collapsed to `t = 0` (it IS the value the band starts on, per the
|
||||
@@ -244,20 +199,6 @@ pub(crate) fn seq0_band(bytes: &[u8]) -> (u32, u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The emitter clip clock's wrap width: `Some(first sequence's span ms)` when that sequence
|
||||
/// LOOPS (flags bit0 clear — the verified polarity, `models/anim.rs`), else `None`. The client
|
||||
/// samples emitter tracks on `m2_animate`'s sequence time, which wraps at a looping sequence's
|
||||
/// end — windowed rate/enabled tracks re-fire every pass; a clamped sequence holds instead.
|
||||
pub(crate) fn seq0_wrap_ms(bytes: &[u8]) -> Option<f32> {
|
||||
let (n_seq, o_seq) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize);
|
||||
if n_seq == 0 || o_seq + 0x44 > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let span = le_u32(bytes, o_seq + 8).saturating_sub(le_u32(bytes, o_seq + 4));
|
||||
let looping = le_u32(bytes, o_seq + 0x10) & 1 == 0;
|
||||
(looping && span > 0).then_some(span as f32)
|
||||
}
|
||||
|
||||
/// A vanilla M2Track's key sub-arrays: `(gseq, n, timestamps offset, values offset)` — `None` if
|
||||
/// the track is out of range or keyless.
|
||||
fn track_arrays(b: &[u8], track: usize) -> Option<(u16, usize, usize, usize)> {
|
||||
@@ -304,25 +245,6 @@ pub(crate) fn track_keys_with<V: TrackValue>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an ON/OFF gate M2Track (u8 values) — see [`OnOffTrack`]. Same band rebase as
|
||||
/// [`track_keys_with`]; a keyless/out-of-range track is always-on (the loader's default,
|
||||
/// `0x70f80e: mov byte[...+0xbc], 1`).
|
||||
pub(crate) fn track_enabled(b: &[u8], track: usize, band: (u32, u32)) -> OnOffTrack {
|
||||
let Some((gseq, n, tofs, vofs)) = track_arrays(b, track) else {
|
||||
return OnOffTrack::always_on();
|
||||
};
|
||||
if vofs + n > b.len() {
|
||||
return OnOffTrack::always_on();
|
||||
}
|
||||
let mut keys: Vec<(u32, bool)> = (0..n)
|
||||
.map(|i| (le_u32(b, tofs + i * 4), b[vofs + i] != 0))
|
||||
.collect();
|
||||
if gseq == 0xffff {
|
||||
rebase_keys_to_band(&mut keys, band.0, band.1);
|
||||
}
|
||||
OnOffTrack { keys }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -437,18 +359,4 @@ mod tests {
|
||||
rebase_keys_to_band(&mut keys, 0, 667);
|
||||
assert_eq!(keys, vec![(0, 100.0), (500, -100.0), (667, -100.0)]);
|
||||
}
|
||||
|
||||
/// Step sampling on the enabled gate: nearest-previous key, first value before the first key.
|
||||
#[test]
|
||||
fn enabled_track_steps() {
|
||||
let t = OnOffTrack {
|
||||
keys: vec![(0, false), (133, true), (567, false)],
|
||||
};
|
||||
assert!(!t.on_at(0.0));
|
||||
assert!(!t.on_at(100.0), "step, not lerp — off until the 133 ms key");
|
||||
assert!(t.on_at(133.0));
|
||||
assert!(t.on_at(500.0));
|
||||
assert!(!t.on_at(600.0));
|
||||
assert!(OnOffTrack::always_on().on_at(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use benilla_formats::open_chain;
|
||||
use benilla_formats::{open_chain, Chain};
|
||||
|
||||
fn vanilla_data_dir() -> PathBuf {
|
||||
// crates/benilla-formats -> repo root -> WoW/Data
|
||||
@@ -1136,3 +1136,69 @@ fn the_wdl_window_contains_the_cameras_own_tile() {
|
||||
.count();
|
||||
assert_eq!(window.len(), expected, "every present tile in the window");
|
||||
}
|
||||
|
||||
/// A **tombstoned path must never fall through to the live copy a base archive still holds.**
|
||||
///
|
||||
/// `patch.MPQ` deletes 26 paths that `model.MPQ` and friends still carry in full — cut content the
|
||||
/// 1.12.1 client does not load (decision 0246). The failure this pins is silent and points the
|
||||
/// wrong way: a chain that resolved *past* the delete-marker would serve `OgreMage.m2`'s 386 KB as
|
||||
/// if it were current, looking entirely healthy while rendering a model the reference never draws.
|
||||
/// Until now the property was held by a comment in `Chain::read` and nothing else.
|
||||
///
|
||||
/// Found by comparing our corpus census against wow-re's: their chain over-enumerated by exactly
|
||||
/// these 26, because `list` took the winning listfile entry whatever its flags. Ours already
|
||||
/// filtered — the point of this test is that it stays that way.
|
||||
///
|
||||
/// Non-vacuous by construction: each name is first read straight out of the base archive that still
|
||||
/// holds it, so the test fails loudly if the pairing ever stops being a real tombstone-over-content
|
||||
/// case rather than passing on an absent file.
|
||||
#[test]
|
||||
fn tombstoned_paths_never_fall_through_to_the_base_copy() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
|
||||
// Tombstoned by `patch.MPQ`; still present, in full, in `model.MPQ` (measured: 386416 / 437456).
|
||||
const DELETED: [&str; 2] = [
|
||||
"Creature\\OgreMage\\OgreMage.m2",
|
||||
"Creature\\OgreWarlord\\OgreWarlord.m2",
|
||||
];
|
||||
|
||||
let base = Chain::open(&data.join("model.MPQ")).expect("open model.MPQ alone");
|
||||
let chain = Chain::open(&data).expect("open the vanilla patch chain");
|
||||
let listed: std::collections::HashSet<String> = chain
|
||||
.list()
|
||||
.expect("list the chain")
|
||||
.into_iter()
|
||||
.map(|e| e.name.replace('/', "\\").to_ascii_lowercase())
|
||||
.collect();
|
||||
|
||||
for name in DELETED {
|
||||
// The positive control: the content really is still there to be served by mistake.
|
||||
let live = base
|
||||
.read(name)
|
||||
.unwrap_or_else(|e| panic!("{name} must still exist in model.MPQ: {e}"));
|
||||
assert!(
|
||||
live.len() > 100_000,
|
||||
"{name} is a real model in the base archive, got {} bytes",
|
||||
live.len()
|
||||
);
|
||||
|
||||
// And the composite refuses it on all three paths.
|
||||
assert!(!chain.contains(name), "{name} is deleted from the chain");
|
||||
let err = chain
|
||||
.read(name)
|
||||
.expect_err("reading a tombstoned path must fail, not return the base copy")
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("deleted from patch chain"),
|
||||
"the error must name the tombstone, not a generic miss: {err}"
|
||||
);
|
||||
assert!(
|
||||
!listed.contains(&name.to_ascii_lowercase()),
|
||||
"{name} must not be enumerated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use benilla_formats::{open_chain, parse_m2_particle_emitters, ParticleBlend, ParticleShape};
|
||||
use benilla_formats::{
|
||||
open_chain, parse_m2_particle_emitters, CellRamp, OverLife, ParticleBlend, ParticleShape,
|
||||
};
|
||||
|
||||
fn vanilla_data_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
|
||||
@@ -31,12 +33,11 @@ fn campfire_emitters_match_real_bytes() {
|
||||
assert_eq!(e.shape, ParticleShape::Plane);
|
||||
assert_eq!(e.blend, ParticleBlend::Add);
|
||||
assert!(e.lifespan > 0.0 && e.lifespan.is_finite());
|
||||
assert!(e.emission_rate.first() > 0.0 && e.emission_rate.first().is_finite());
|
||||
assert_eq!(
|
||||
e.emission_rate.keys.len(),
|
||||
1,
|
||||
"ambient prop rates are constant tracks"
|
||||
);
|
||||
let rate = e
|
||||
.timing
|
||||
.constant_rate()
|
||||
.expect("ambient prop rates are constant tracks");
|
||||
assert!(rate > 0.0 && rate.is_finite());
|
||||
assert!(e.horizontal_range > 6.0, "campfire emits in a full ring");
|
||||
// Texture resolves to a real .blp via the M2 textures table.
|
||||
assert!(
|
||||
@@ -54,9 +55,9 @@ fn campfire_emitters_match_real_bytes() {
|
||||
glow.lifespan
|
||||
);
|
||||
assert!(
|
||||
(glow.emission_rate.first() - 6.0).abs() < 1e-3,
|
||||
"glow rate ~6, got {}",
|
||||
glow.emission_rate.first()
|
||||
(glow.timing.constant_rate().unwrap() - 6.0).abs() < 1e-3,
|
||||
"glow rate ~6, got {:?}",
|
||||
glow.timing.constant_rate()
|
||||
);
|
||||
assert_eq!((glow.tile_rows, glow.tile_cols), (1, 1));
|
||||
assert!(glow.vertical_range > 0.3, "glow has a wide cone");
|
||||
@@ -69,9 +70,9 @@ fn campfire_emitters_match_real_bytes() {
|
||||
flame.lifespan
|
||||
);
|
||||
assert!(
|
||||
(flame.emission_rate.first() - 20.0).abs() < 1e-3,
|
||||
"flame rate ~20, got {}",
|
||||
flame.emission_rate.first()
|
||||
(flame.timing.constant_rate().unwrap() - 20.0).abs() < 1e-3,
|
||||
"flame rate ~20, got {:?}",
|
||||
flame.timing.constant_rate()
|
||||
);
|
||||
assert_eq!(
|
||||
(flame.tile_rows, flame.tile_cols),
|
||||
@@ -107,18 +108,21 @@ fn campfire_emitters_match_real_bytes() {
|
||||
e.blend,
|
||||
e.texture,
|
||||
e.lifespan,
|
||||
e.emission_rate.keys,
|
||||
e.timing.constant_rate(),
|
||||
e.vertical_range,
|
||||
e.tile_rows,
|
||||
e.tile_cols
|
||||
);
|
||||
eprintln!(
|
||||
" mid={:.2} color={:?} scale={:?} cells A{:?} B{:?}",
|
||||
ol.mid, ol.color, ol.scale, ol.cell_a, ol.cell_b
|
||||
" mid={:.2} color={:?} scale={:?} head{:?} tail{:?} repeat{:?}",
|
||||
ol.mid, ol.color, ol.scale, ol.head_cells, ol.tail_cells, ol.repeat
|
||||
);
|
||||
for u in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
|
||||
let (c, s, cell) = ol.sample(u);
|
||||
eprintln!(" u={u:.2}: rgba={c:?} size={s:.3} cell={cell}");
|
||||
let s = ol.sample(u);
|
||||
eprintln!(
|
||||
" u={u:.2}: rgba={:?} size={:.3} cell head={} tail={}",
|
||||
s.color, s.size, s.head_cell, s.tail_cell
|
||||
);
|
||||
}
|
||||
|
||||
// Sizes are finite, non-negative, and small (campfire props are ~sub-yard to a couple yards).
|
||||
@@ -145,8 +149,8 @@ fn campfire_emitters_match_real_bytes() {
|
||||
|
||||
// The glow/smoke plume fades out: its additive weight (alpha) at end-of-life is below its peak.
|
||||
let glow_ol = &emitters[0].over_life;
|
||||
let a_start = glow_ol.sample(0.0).0[3];
|
||||
let a_end = glow_ol.sample(1.0).0[3];
|
||||
let a_start = glow_ol.sample(0.0).color[3];
|
||||
let a_end = glow_ol.sample(1.0).color[3];
|
||||
assert!(
|
||||
a_end <= a_start,
|
||||
"glow alpha should not rise over life ({a_start} -> {a_end})"
|
||||
@@ -154,14 +158,181 @@ fn campfire_emitters_match_real_bytes() {
|
||||
|
||||
// The flame's cell index advances across its 4×4 atlas over life (the flicker animation).
|
||||
let flame_ol = &emitters[1].over_life;
|
||||
let cell_start = flame_ol.sample(0.0).2;
|
||||
let cell_end = flame_ol.sample(1.0).2;
|
||||
let cell_start = flame_ol.sample(0.0).head_cell;
|
||||
let cell_end = flame_ol.sample(1.0).head_cell;
|
||||
assert!(
|
||||
cell_end >= cell_start,
|
||||
"flame cell advances ({cell_start} -> {cell_end})"
|
||||
);
|
||||
}
|
||||
|
||||
/// The flipbook **cell ramp**, against the reference's own emulated output (wow-re
|
||||
/// `part-cell-flipbook-ramp.md` §5 — `emu.call(0x7b9da0)` to build the record, `emu.call(0x7b9b10)`
|
||||
/// to sample it). Two properties, and the second is the one that crashed us (decision 0685):
|
||||
///
|
||||
/// 1. **The endpoint law** — `cell(0) == begin` and `cell(1) == end`, EXACTLY, in both directions.
|
||||
/// That is what the `±1` in the build arms and the evaluator's `0.99·t + 0.005` inset exist to
|
||||
/// buy; either one alone is off by one at an endpoint.
|
||||
/// 2. **A DECREASING pair plays the flipbook backwards** — it is not swapped, not clamped, not
|
||||
/// sign-normalized. Shipped data authors it, so a `clamp(begin, end)` here is both wrong and
|
||||
/// (in Rust) a panic.
|
||||
#[test]
|
||||
fn cell_ramp_matches_the_reference_including_backwards() {
|
||||
let at = |begin: u16, end: u16| {
|
||||
let r = CellRamp::new(begin, end);
|
||||
// The sample points the wow-re oracle tabulates, through the same inset the evaluator
|
||||
// applies (u = 0, ¼, ½, ¾, 1 of the segment).
|
||||
[0.0_f32, 0.25, 0.5, 0.75, 1.0]
|
||||
.map(|t| r.sample(t * 0.99 + 0.005))
|
||||
.to_vec()
|
||||
};
|
||||
|
||||
// Forward, and the three inverted pairs the shipped corpus actually authors.
|
||||
assert_eq!(at(0, 15), vec![0, 4, 8, 11, 15], "ascending 0..15");
|
||||
assert_eq!(at(6, 5), vec![6, 6, 6, 5, 5], "DwarvenBrazier01 (6,5)");
|
||||
assert_eq!(
|
||||
at(31, 16),
|
||||
vec![31, 27, 24, 20, 16],
|
||||
"ShadowWordSilence (31,16)"
|
||||
);
|
||||
assert_eq!(at(15, 0), vec![15, 11, 8, 4, 0], "ShadowWordSilence (15,0)");
|
||||
|
||||
// The endpoint law over every direction and the degenerate equal pair.
|
||||
for (b, e) in [(0, 15), (8, 16), (31, 16), (15, 0), (6, 5), (7, 7), (0, 63)] {
|
||||
let r = CellRamp::new(b, e);
|
||||
assert_eq!(r.sample(0.005), b, "cell(u=0) == begin for ({b},{e})");
|
||||
assert_eq!(r.sample(0.995), e, "cell(u=1) == end for ({b},{e})");
|
||||
}
|
||||
|
||||
// …and END-TO-END through `OverLife::sample`, which is where the inset is actually applied.
|
||||
// Asserting the law on `CellRamp` alone passes even with the inset deleted (it feeds one in
|
||||
// itself) — a mutation check caught exactly that hole. The reference has ONE normalized time
|
||||
// and every consumer reloads it, so the endpoints must land on a whole-life sample too.
|
||||
let ol = OverLife {
|
||||
mid: 0.5,
|
||||
color: [[1.0; 4]; 3],
|
||||
scale: [1.0; 3],
|
||||
head_cells: [CellRamp::new(0, 7), CellRamp::new(31, 16)],
|
||||
tail_cells: [CellRamp::new(3, 3), CellRamp::new(9, 4)],
|
||||
repeat: [1.0; 2],
|
||||
};
|
||||
assert_eq!(ol.sample(0.0).head_cell, 0, "u=0 sits on segment A's begin");
|
||||
assert_eq!(ol.sample(1.0).head_cell, 16, "u=1 sits on segment B's end");
|
||||
assert_eq!(ol.sample(0.0).tail_cell, 3, "the tail ramp is sampled too");
|
||||
assert_eq!(ol.sample(1.0).tail_cell, 4, "…and backwards, independently");
|
||||
// The split is inclusive toward A (`age > lifespan·mid` is the only way into B).
|
||||
assert_eq!(
|
||||
ol.sample(0.5).head_cell,
|
||||
7,
|
||||
"u==mid is still segment A, at its end"
|
||||
);
|
||||
}
|
||||
|
||||
/// Colour and size ride the **same inset** as the cells — `0x7b9b10` computes `t·0.99 + 0.005`
|
||||
/// once, into its own `age` slot, and all four colour channels and the size reload that slot
|
||||
/// (wow-re `part-cell-flipbook-ramp.md` §3a). So a particle never sits exactly on an authored key:
|
||||
/// it starts 0.5 % into the ramp and ends 0.5 % short. Lerping on a raw `t` is wrong at both ends
|
||||
/// of both segments — a small error, but a systematic one, and free to get right.
|
||||
#[test]
|
||||
fn colour_and_size_ride_the_same_inset() {
|
||||
let ol = OverLife {
|
||||
mid: 0.5,
|
||||
color: [[0.0; 4], [1.0; 4], [1.0; 4]],
|
||||
scale: [0.0, 100.0, 100.0],
|
||||
head_cells: [CellRamp::new(0, 0); 2],
|
||||
tail_cells: [CellRamp::new(0, 0); 2],
|
||||
repeat: [1.0; 2],
|
||||
};
|
||||
// Segment A runs 0 → 100 over u ∈ [0, 0.5]; at u=0 the inset puts us 0.5 % along it.
|
||||
let start = ol.sample(0.0);
|
||||
assert!(
|
||||
(start.size - 0.5).abs() < 1e-4,
|
||||
"u=0 is 0.5 % into the ramp, not on the key: got {}",
|
||||
start.size
|
||||
);
|
||||
assert!(
|
||||
(start.color[0] - 0.005).abs() < 1e-4,
|
||||
"colour takes the same inset: got {}",
|
||||
start.color[0]
|
||||
);
|
||||
// …and 99.5 % of the way at the segment's end, not 100 %.
|
||||
let end = ol.sample(0.5);
|
||||
assert!(
|
||||
(end.size - 99.5).abs() < 1e-3,
|
||||
"u=mid is 99.5 % along, not 100 %: got {}",
|
||||
end.size
|
||||
);
|
||||
}
|
||||
|
||||
/// The crash reported from Winterspring (B88): `DwarvenBrazier01`'s settling flame authors the
|
||||
/// inverted segment-B pair `(6,5)`, which the old `idx.clamp(begin, end)` met with a panic —
|
||||
/// `clamp` requires `min <= max`. Sampling the real record across life must simply work.
|
||||
#[test]
|
||||
fn inverted_ramp_on_real_data_does_not_panic() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||||
let bytes = chain
|
||||
.read_file("World\\Generic\\Dwarf\\Passive Doodads\\Braziers\\DwarvenBrazier01.m2")
|
||||
.expect("read DwarvenBrazier01.m2");
|
||||
let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters");
|
||||
let ol = &emitters[1].over_life;
|
||||
assert_eq!(
|
||||
(ol.head_cells[0].begin, ol.head_cells[0].end),
|
||||
(0, 5),
|
||||
"segment A runs forward"
|
||||
);
|
||||
assert_eq!(
|
||||
(ol.head_cells[1].begin, ol.head_cells[1].end),
|
||||
(6, 5),
|
||||
"segment B is the INVERTED pair — the whole point of this test"
|
||||
);
|
||||
// Every point of the particle's life, at the granularity a 60 Hz frame would visit.
|
||||
for i in 0..=1000 {
|
||||
let s = ol.sample(i as f32 / 1000.0);
|
||||
assert!(s.head_cell <= 6, "cell stays inside the authored pair");
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-segment flipbook **repeat count** (file +0x16c/+0x172): `fmod(t·repeat, 1.0)` cycles the
|
||||
/// cell ramp that many times across the segment. 18 shipped emitters author one — all of them the
|
||||
/// druid Insect Swarm visuals, whose insects flap by cycling a 5- or 8-pass flipbook. A reader that
|
||||
/// ignores the field runs the sheet exactly once and the swarm never flaps.
|
||||
#[test]
|
||||
fn repeat_count_cycles_the_flipbook() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||||
let bytes = chain
|
||||
.read_file("SPELLS\\InsectSwarm_State_Chest.m2")
|
||||
.expect("read InsectSwarm_State_Chest.m2");
|
||||
let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters");
|
||||
let ol = &emitters[0].over_life;
|
||||
assert_eq!(ol.repeat, [5.0, 5.0], "the swarm authors a 5× cycle");
|
||||
|
||||
// Five passes across segment A means the cell index resets five times: count the drops.
|
||||
let mid = ol.mid;
|
||||
let mut drops = 0;
|
||||
let mut prev = ol.sample(0.0).head_cell;
|
||||
for i in 1..=2000 {
|
||||
let c = ol.sample(mid * (i as f32 / 2000.0)).head_cell;
|
||||
if c < prev {
|
||||
drops += 1;
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
assert_eq!(
|
||||
drops, 4,
|
||||
"5 passes over the segment == 4 wraps, got {drops}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The record-tail **twinkle** fields (wow-re `part-simspace-fields.md`, their `ac915a7d`):
|
||||
/// file +0x188/+0x18c are twinkleScale **{min, max}** — a GATED per-frame size flicker, skipped
|
||||
/// when the range is degenerate — NOT a spawn-time size multiplier. The discriminating real-data
|
||||
@@ -193,7 +364,7 @@ fn twinkle_fields_gate_not_scale() {
|
||||
);
|
||||
// Its base size is the over-life ramp alone — nonzero at mid-life.
|
||||
assert!(
|
||||
candle.over_life.sample(0.5).1 > 0.0,
|
||||
candle.over_life.sample(0.5).size > 0.0,
|
||||
"the candle flame's over-life size ramp is nonzero"
|
||||
);
|
||||
|
||||
@@ -256,3 +427,57 @@ fn flag_remap_reads_the_file_bits() {
|
||||
"campfire: size-by-scale (0x20) set, model-space (0x10) clear"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The B27 pin, at the real bytes** — `G_BarrelExplode.m2` (GameObject 20737, "Keenly
|
||||
/// Disguised Barrel"): all 7 emitters author their explosion inside the one-shot clips
|
||||
/// (slot 0 = anim 157, slot 3 = anim 150 Destroy) and an OFF window in both idle sequences
|
||||
/// (slot 1 = Stand, slot 2 = anim 147 Closed). The reference shows a quiet barrel at rest; the
|
||||
/// old seq-0-only rebase parked the clamped explode clock at its end value and the barrel
|
||||
/// burned permanently at 748 live particles.
|
||||
#[test]
|
||||
fn barrel_explode_emitters_are_off_at_rest_and_fire_in_their_clips() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
||||
let emitters = parse_m2_particle_emitters(
|
||||
&chain
|
||||
.read_file("World\\Goober\\G_BarrelExplode.m2")
|
||||
.expect("read G_BarrelExplode.m2"),
|
||||
)
|
||||
.expect("parse emitters");
|
||||
assert_eq!(emitters.len(), 7, "the barrel authors seven emitters");
|
||||
for (i, e) in emitters.iter().enumerate() {
|
||||
assert!(e.timing.peak_rate() > 0.0, "emitter {i} can emit");
|
||||
// OFF through both idle windows — Stand (slot 1) and Closed (slot 2), at any time.
|
||||
for slot in [1usize, 2] {
|
||||
for t in [0.0f32, 0.1, 0.3, 5.0] {
|
||||
assert!(
|
||||
!e.timing.emitting(Some(slot), t),
|
||||
"emitter {i} must be OFF at rest (slot {slot}, t {t})"
|
||||
);
|
||||
}
|
||||
}
|
||||
// The one-shot explode clip (slot 0, anim 157, 1 s clamped): the choreography fires
|
||||
// somewhere inside it…
|
||||
let fires = (0..100).any(|k| e.timing.emitting(Some(0), k as f32 * 0.01));
|
||||
assert!(fires, "emitter {i} fires inside the explode clip");
|
||||
// …and the clamped clock PARKS OFF at/after the clip end — the exact end state the old
|
||||
// rebase got wrong (it clamped a later clip's ON key onto the band end).
|
||||
assert!(
|
||||
!e.timing.emitting(Some(0), 1.0),
|
||||
"emitter {i} must be off at the clip end"
|
||||
);
|
||||
assert!(
|
||||
!e.timing.emitting(Some(0), 30.0),
|
||||
"emitter {i} must stay off parked past the clip"
|
||||
);
|
||||
}
|
||||
// The Destroy clip (slot 3, anim 150) fires the explosion too — the quest's actual payoff.
|
||||
let destroy_fires = emitters
|
||||
.iter()
|
||||
.any(|e| (0..100).any(|k| e.timing.emitting(Some(3), k as f32 * 0.01)));
|
||||
assert!(destroy_fires, "the Destroy clip plays the explosion");
|
||||
}
|
||||
|
||||
+192
-26
@@ -1,10 +1,19 @@
|
||||
//! WDT (map tile table) reader + tile↔world coords for **WoW 1.12.1 (build 5875)** — in-repo,
|
||||
//! replacing `wow-wdt` (decision 0021).
|
||||
//!
|
||||
//! A vanilla `.wdt` is a tiny chunked file (`MVER`, `MPHD`, then `MAIN`); the only part the client
|
||||
//! streamer needs is **`MAIN`** — a 64×64 table of 8-byte entries whose flag bit 0 (`0x1`) marks
|
||||
//! "this tile has an `.adt`". We read that grid and ignore the rest. The coord helpers map a world
|
||||
//! `(x, y)` to its `(tile_x, tile_y)` and back, on the 64-tile, 533⅓-yd grid.
|
||||
//! A vanilla `.wdt` is a tiny chunked file (`MVER`, `MPHD`, `MAIN`, and — on a **WMO-only map** —
|
||||
//! `MWMO` + one `MODF`). Two things in it matter to the streamer:
|
||||
//!
|
||||
//! - **`MAIN`** — a 64×64 table of 8-byte entries whose flag bit 0 (`0x1`) marks "this tile has an
|
||||
//! `.adt`".
|
||||
//! - **The global WMO** — `MPHD` dword0 bit 0 marks a map with **no terrain at all**, whose whole
|
||||
//! world is one building placed by the single `MODF` entry after `MWMO`. 20 of the 43 shipped
|
||||
//! maps are built this way (every WMO dungeon: the Blackrock pair, Gnomeregan, Dire Maul, the
|
||||
//! Stockade…); on those maps `MAIN` is entirely empty and this placement *is* the map
|
||||
//! (decision 0688).
|
||||
//!
|
||||
//! The coord helpers map a world `(x, y)` to its `(tile_x, tile_y)` and back, on the 64-tile,
|
||||
//! 533⅓-yd grid.
|
||||
//!
|
||||
//! Proven against `wow-wdt` over a real map's WDT during the decision-0021 migration (oracle test in
|
||||
//! git history); the `benilla-formats` terrain tests pin tile selection + world coords end-to-end.
|
||||
@@ -56,11 +65,37 @@ pub struct TileInfo {
|
||||
pub has_adt: bool,
|
||||
}
|
||||
|
||||
/// A parsed WDT: the 64×64 tile-existence grid.
|
||||
/// The single building that **is** a WMO-only map — the `MWMO` path plus the one `MODF` entry that
|
||||
/// follows it (decision 0688). Position/rotation are already in the same convention as an ADT's own
|
||||
/// MODF placements (`benilla_formats::WmoInstance`), so the streamer places it through the exact
|
||||
/// path it places any other building with.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GlobalWmo {
|
||||
/// `MWMO` root path (e.g. `world\wmo\dungeon\az_stormwindprisons\stormwindjail.wmo`).
|
||||
pub model: String,
|
||||
/// Position in WoW world coords (X north, Y west, Z up).
|
||||
///
|
||||
/// Taken **raw** from the MODF, without the `32·533⅓ − v` corner-origin remap an ADT's
|
||||
/// placements get: this entry is authored in world coords already. Every shipped WMO-only map
|
||||
/// stores `(0, 0, 0)` here, and the remap would put the whole dungeon 24 km from its own
|
||||
/// entrance — falsified against all 26 of the server's real entry points (decision 0688).
|
||||
pub position: [f32; 3],
|
||||
/// Euler rotation in **degrees** (X, Y, Z); Y is the heading about the up axis. Live data, not
|
||||
/// always identity: Dire Maul is authored at `ry = 180°` while the other 19 maps sit at 0.
|
||||
pub rotation: [f32; 3],
|
||||
/// MODF doodad-set index — which *additional* prop set to show beyond the always-on set 0.
|
||||
pub doodad_set: u16,
|
||||
/// MODF name-set index — the placement's `WMOAreaTable.NameSetID` naming/audio variant.
|
||||
pub name_set: u16,
|
||||
}
|
||||
|
||||
/// A parsed WDT: the 64×64 tile-existence grid, plus the global WMO on a WMO-only map.
|
||||
#[derive(Debug)]
|
||||
pub struct WdtFile {
|
||||
/// `has_adt` per tile, indexed `y * 64 + x` (the on-disk MAIN order).
|
||||
has_adt: Vec<bool>,
|
||||
/// The whole map, on a map with no terrain; `None` on an ADT map.
|
||||
global_wmo: Option<GlobalWmo>,
|
||||
}
|
||||
|
||||
impl WdtFile {
|
||||
@@ -75,6 +110,12 @@ impl WdtFile {
|
||||
has_adt: self.has_adt[y * MAP_SIZE + x],
|
||||
})
|
||||
}
|
||||
|
||||
/// The map's single global building, if this is a WMO-only map (`MPHD` bit 0) — the entire
|
||||
/// world on such a map. `None` on an ADT map, where the ground comes from the tiles instead.
|
||||
pub fn global_wmo(&self) -> Option<&GlobalWmo> {
|
||||
self.global_wmo.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a [`WdtFile`] from a chunked WDT stream.
|
||||
@@ -91,9 +132,13 @@ impl<R: Read + Seek> WdtReader<R> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the WDT, returning the tile grid. Walks chunks until EOF, reading only `MAIN`.
|
||||
/// Parse the WDT, returning the tile grid and (on a WMO-only map) the global WMO placement.
|
||||
/// Walks chunks until EOF; everything but `MPHD`/`MAIN`/`MWMO`/`MODF` is skipped.
|
||||
pub fn read(&mut self) -> std::io::Result<WdtFile> {
|
||||
let mut has_adt: Option<Vec<bool>> = None;
|
||||
let mut wmo_only = false;
|
||||
let mut wmo_path: Option<String> = None;
|
||||
let mut modf: Option<Vec<u8>> = None;
|
||||
loop {
|
||||
let mut hdr = [0u8; 8];
|
||||
if !read_full(&mut self.reader, &mut hdr)? {
|
||||
@@ -101,34 +146,92 @@ impl<R: Read + Seek> WdtReader<R> {
|
||||
}
|
||||
// Chunk magics are stored reversed on disk; MAIN ⇒ "NIAM".
|
||||
let size = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as u64;
|
||||
if &hdr[0..4] == b"NIAM" {
|
||||
// 64×64 entries × 8 bytes (flags u32, area_id u32); bit 0 of flags = has_adt.
|
||||
let count = MAP_SIZE * MAP_SIZE;
|
||||
let mut buf = vec![0u8; count * 8];
|
||||
self.reader.read_exact(&mut buf)?;
|
||||
let grid = (0..count)
|
||||
.map(|i| {
|
||||
let flags = u32::from_le_bytes([
|
||||
buf[i * 8],
|
||||
buf[i * 8 + 1],
|
||||
buf[i * 8 + 2],
|
||||
buf[i * 8 + 3],
|
||||
]);
|
||||
flags & 0x1 != 0
|
||||
})
|
||||
.collect();
|
||||
has_adt = Some(grid);
|
||||
} else {
|
||||
self.reader.seek(SeekFrom::Current(size as i64))?;
|
||||
match &hdr[0..4] {
|
||||
// MPHD — only dword0 bit 0 is ever read: "this map has no terrain, its world is
|
||||
// the global WMO below" (the reference reads exactly this one bit, @0x694810).
|
||||
b"DHPM" if size >= 4 => {
|
||||
let mut buf = vec![0u8; size as usize];
|
||||
self.reader.read_exact(&mut buf)?;
|
||||
wmo_only = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) & 0x1 != 0;
|
||||
}
|
||||
b"NIAM" => {
|
||||
// 64×64 entries × 8 bytes (flags u32, area_id u32); bit 0 of flags = has_adt.
|
||||
let count = MAP_SIZE * MAP_SIZE;
|
||||
let mut buf = vec![0u8; count * 8];
|
||||
self.reader.read_exact(&mut buf)?;
|
||||
let grid = (0..count)
|
||||
.map(|i| {
|
||||
let flags = u32::from_le_bytes([
|
||||
buf[i * 8],
|
||||
buf[i * 8 + 1],
|
||||
buf[i * 8 + 2],
|
||||
buf[i * 8 + 3],
|
||||
]);
|
||||
flags & 0x1 != 0
|
||||
})
|
||||
.collect();
|
||||
has_adt = Some(grid);
|
||||
}
|
||||
// MWMO — one NUL-terminated path on a WMO-only map, and a zero-length stub on an
|
||||
// ADT map (which is why the `wmo_only` flag, not this chunk, is the gate).
|
||||
b"OMWM" if size > 0 => {
|
||||
let mut buf = vec![0u8; size as usize];
|
||||
self.reader.read_exact(&mut buf)?;
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
let path = String::from_utf8_lossy(&buf[..end]).into_owned();
|
||||
if !path.is_empty() {
|
||||
wmo_path = Some(path);
|
||||
}
|
||||
}
|
||||
// MODF — exactly one 64-byte placement here (the ADT variant carries many).
|
||||
b"FDOM" if size >= 64 => {
|
||||
let mut buf = vec![0u8; size as usize];
|
||||
self.reader.read_exact(&mut buf)?;
|
||||
modf = Some(buf);
|
||||
}
|
||||
_ => {
|
||||
self.reader.seek(SeekFrom::Current(size as i64))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let has_adt = has_adt.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "WDT missing MAIN chunk")
|
||||
})?;
|
||||
Ok(WdtFile { has_adt })
|
||||
// A WMO-only map with no placement to show would be an empty void, so demand both parts
|
||||
// before claiming one — a malformed header bit alone must not suppress the tile grid.
|
||||
let global_wmo = match (wmo_only, wmo_path, modf) {
|
||||
(true, Some(model), Some(e)) => Some(GlobalWmo {
|
||||
model,
|
||||
position: [f32_at(&e, 8), f32_at(&e, 12), f32_at(&e, 16)],
|
||||
rotation: [f32_at(&e, 20), f32_at(&e, 24), f32_at(&e, 28)],
|
||||
// 0x20..0x38 is the bounding box; the placement words follow it. (`uniqueId` at
|
||||
// +4 is dead here — the reference overwrites it from its own counter @0xc9a320.)
|
||||
doodad_set: u16_at(&e, 58),
|
||||
name_set: u16_at(&e, 60),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(WdtFile {
|
||||
has_adt,
|
||||
global_wmo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Little-endian `f32` at `off` (0.0 past the end — callers have already length-checked the entry).
|
||||
fn f32_at(b: &[u8], off: usize) -> f32 {
|
||||
b.get(off..off + 4)
|
||||
.and_then(|s| s.try_into().ok())
|
||||
.map_or(0.0, f32::from_le_bytes)
|
||||
}
|
||||
|
||||
/// Little-endian `u16` at `off` (0 past the end).
|
||||
fn u16_at(b: &[u8], off: usize) -> u16 {
|
||||
b.get(off..off + 2)
|
||||
.and_then(|s| s.try_into().ok())
|
||||
.map_or(0, u16::from_le_bytes)
|
||||
}
|
||||
|
||||
/// Read exactly `buf.len()` bytes; `Ok(false)` on a clean EOF before any byte, `Ok(true)` on success.
|
||||
fn read_full<R: Read>(reader: &mut R, buf: &mut [u8]) -> std::io::Result<bool> {
|
||||
let mut filled = 0;
|
||||
@@ -194,6 +297,69 @@ mod tests {
|
||||
WdtReader::new(Cursor::new(bytes), WowVersion::Classic).read()
|
||||
}
|
||||
|
||||
/// A WMO-only map's on-disk shape: `MPHD` bit 0, an all-empty `MAIN`, then `MWMO` + one 64-byte
|
||||
/// `MODF`. Field values are distinct primes-ish so a column slip can't pass.
|
||||
fn synth_wmo_only_wdt(mphd_bit0: bool, with_modf: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let chunk = |magic: &[u8; 4], payload: &[u8], out: &mut Vec<u8>| {
|
||||
out.extend_from_slice(magic);
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
};
|
||||
chunk(b"REVM", &18u32.to_le_bytes(), &mut out);
|
||||
let mut mphd = [0u8; 32];
|
||||
mphd[0] = u8::from(mphd_bit0);
|
||||
chunk(b"DHPM", &mphd, &mut out);
|
||||
chunk(b"NIAM", &vec![0u8; MAP_SIZE * MAP_SIZE * 8], &mut out); // no tiles at all
|
||||
chunk(b"OMWM", b"world\\wmo\\dungeon\\test\\t.wmo\0", &mut out);
|
||||
if with_modf {
|
||||
let mut e = [0u8; 64];
|
||||
e[0..4].copy_from_slice(&0u32.to_le_bytes()); // nameId
|
||||
e[4..8].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // uniqueId (dead)
|
||||
for (i, v) in [11.0f32, 22.0, 33.0].iter().enumerate() {
|
||||
e[8 + i * 4..12 + i * 4].copy_from_slice(&v.to_le_bytes()); // position
|
||||
}
|
||||
for (i, v) in [1.0f32, 180.0, 2.0].iter().enumerate() {
|
||||
e[20 + i * 4..24 + i * 4].copy_from_slice(&v.to_le_bytes()); // rotation
|
||||
}
|
||||
// 0x20..0x38 bounding box left zero — we don't read it.
|
||||
e[56..58].copy_from_slice(&7u16.to_le_bytes()); // flags (dead)
|
||||
e[58..60].copy_from_slice(&3u16.to_le_bytes()); // doodadSet
|
||||
e[60..62].copy_from_slice(&5u16.to_le_bytes()); // nameSet
|
||||
chunk(b"FDOM", &e, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The WMO-only branch end to end: the `MPHD` gate, the `MWMO` path, and every `MODF` column
|
||||
/// the streamer places the building with. Position is read **raw** — no corner-origin remap.
|
||||
#[test]
|
||||
fn reads_the_global_wmo_of_a_wmo_only_map() {
|
||||
let wdt = parse(synth_wmo_only_wdt(true, true)).expect("parses");
|
||||
let g = wdt.global_wmo().expect("MPHD bit 0 ⇒ a global WMO");
|
||||
assert_eq!(g.model, "world\\wmo\\dungeon\\test\\t.wmo");
|
||||
assert_eq!(g.position, [11.0, 22.0, 33.0]);
|
||||
assert_eq!(g.rotation, [1.0, 180.0, 2.0]);
|
||||
assert_eq!((g.doodad_set, g.name_set), (3, 5));
|
||||
// Such a map authors no tiles — the grid still parses, it's just empty.
|
||||
assert!(!wdt.get_tile(32, 32).expect("in range").has_adt);
|
||||
}
|
||||
|
||||
/// An ADT map carries a zero-length `MWMO` stub and no `MODF`; it must not claim a global WMO
|
||||
/// (which would spawn a phantom building over real terrain).
|
||||
#[test]
|
||||
fn an_adt_map_has_no_global_wmo() {
|
||||
let wdt = parse(synth_wdt(&[(30, 30)], false)).expect("parses");
|
||||
assert!(wdt.global_wmo().is_none());
|
||||
// …and neither does a malformed file that sets the bit but ships no placement: a stray
|
||||
// header bit must degrade to "no global WMO", never to a half-built one.
|
||||
let half = parse(synth_wmo_only_wdt(true, false)).expect("parses");
|
||||
assert!(half.global_wmo().is_none());
|
||||
// …nor one that ships the placement without the flag.
|
||||
let unflagged = parse(synth_wmo_only_wdt(false, true)).expect("parses");
|
||||
assert!(unflagged.global_wmo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_origin_is_map_center_tile() {
|
||||
// World (0, 0) sits at the 32,32 tile boundary — the documented map center.
|
||||
|
||||
@@ -771,12 +771,18 @@ fn fire_particle_census(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let d = e.def();
|
||||
let rate_keys: Vec<String> = d
|
||||
.emission_rate
|
||||
.keys
|
||||
.iter()
|
||||
.map(|(t, v)| format!("{t}:{v:.1}"))
|
||||
.collect();
|
||||
// The rate summary: the constant (the common shape), else each slot's key count — the
|
||||
// full per-sequence choreography lives in `benilla-extract m2anim`, not a census line.
|
||||
let rate_keys: Vec<String> = match d.timing.constant_rate() {
|
||||
Some(r) => vec![format!("{r:.1}")],
|
||||
None => d
|
||||
.timing
|
||||
.slot_views()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(s, (_, r, _))| format!("s{s}:{}k", r.map_or(0, <[(f32, f32)]>::len)))
|
||||
.collect(),
|
||||
};
|
||||
// The orientation fingerprint (world plane normal + thickness/radius) is the numeric
|
||||
// "which way does this cloud face" — the flat-vs-standing question a screenshot can
|
||||
// only suggest (the InstancePortal swirl-plane investigation).
|
||||
|
||||
@@ -44,6 +44,12 @@ pub(super) fn apply_model_visibility(
|
||||
Option<&WmoGroupVis>,
|
||||
Option<&crate::doodad_anim::MatAnim>,
|
||||
)>,
|
||||
// The building's own MLIQ surfaces (canals, dungeon pools, Ragefire's lava). They carry a
|
||||
// `WmoGroupVis` like every other piece of their group but no `ModelPart` — they are not model
|
||||
// submeshes and take none of the toggle/far-clip/fade rules above. A second QUERY, deliberately
|
||||
// not a second SYSTEM: decision 0025 wants one `Visibility` authority, and this keeps it
|
||||
// (decision 0689 — a culled room's water must go with the room, same as its furniture).
|
||||
mut group_only: Query<(&WmoGroupVis, &mut Visibility), Without<ModelPart>>,
|
||||
) {
|
||||
let m = &debug.models;
|
||||
// The (single) world Camera3d; the egui overlay is a Camera2d. None before it spawns → no distance
|
||||
@@ -174,4 +180,23 @@ pub(super) fn apply_model_visibility(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The group-only audience: portal cull and nothing else. Serial — a building has a handful of
|
||||
// liquid surfaces, not the ~100k submeshes above — and change-gated like every write here.
|
||||
for (gv, mut vis) in &mut group_only {
|
||||
let want = if !m.portal_cull
|
||||
|| instances
|
||||
.get(gv.instance)
|
||||
.ok()
|
||||
.and_then(|inst| inst.visible.get(gv.group as usize).copied())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +360,10 @@ impl MatAnim {
|
||||
/// base clips are live and the heavier one wins; the reference instead blends the two sampled
|
||||
/// scalars by λ (wow-re `eval.md` FN 0x71af20's blend leg), a sub-blend-time difference on tracks
|
||||
/// the corpus authors as 0/1 steps — recorded, not modelled.
|
||||
fn playing_seq(player: &AnimationPlayer, anims: &ModelAnimations) -> Option<(usize, f32)> {
|
||||
pub(crate) fn playing_seq(
|
||||
player: &AnimationPlayer,
|
||||
anims: &ModelAnimations,
|
||||
) -> Option<(usize, f32)> {
|
||||
player
|
||||
.playing_animations()
|
||||
.filter_map(|(node, active)| {
|
||||
|
||||
@@ -909,6 +909,11 @@ pub(super) fn attach_entity_visuals(
|
||||
Some(owner),
|
||||
None, // a unit's OWN model is not an attached model (`[model+0x17c]` = 0)
|
||||
Some(entity), // the cloud anchors at the unit; bones compose births only
|
||||
// The emitters' rate/enabled read this instance's PLAYING sequence — a
|
||||
// unit's or GameObject's `AnimationPlayer` on the root. A quest object's
|
||||
// explosion is authored inside its one-shot clips with an OFF window at
|
||||
// idle (B27); a creature's death-only smoke is the same shape.
|
||||
particles::EmitClock::Host(entity),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +83,10 @@ pub(super) struct EntityPart {
|
||||
/// 0153: the brazier/torch "glow on the ground" family). `None` for ordinary geometry.
|
||||
pub(super) billboard: Option<benilla_assets::BillboardInfo>,
|
||||
/// The part's animated material-alpha loops (colour-alpha × transparency-weight, decision 0130
|
||||
/// phase 2). The **effect lane** ([`super::spell_fx`]) samples them per instance (a spell
|
||||
/// effect's staggered fade pulses); the unit/GameObject lane doesn't yet (its alpha channel is
|
||||
/// owned by the appear-fade/interior writers — a named deferral). `None` for static batches.
|
||||
/// phase 2, per-sequence since 0641). The **effect lane** ([`super::spell_fx`]) samples them
|
||||
/// per instance on its armed slot; the unit/GameObject lane follows the host's live playing
|
||||
/// sequence (`MatAnim::following` — 0641 collected the old deferral here). `None` for static
|
||||
/// batches.
|
||||
pub(super) alpha_anim: Option<std::sync::Arc<benilla_formats::AlphaAnim>>,
|
||||
/// The part's animated RGB tint (the M2Color colour track, time-varying only). The static
|
||||
/// vertex tint was skipped at parse for these parts: `material`/its variants are built with
|
||||
|
||||
@@ -923,6 +923,9 @@ pub(super) fn attach_held_items(
|
||||
Some((root, [0.0; 3])),
|
||||
Some(root), // a held item is an attached model — the flame fans with the swing
|
||||
Some(root), // whole-model owner: anchor == owner, the plain carry
|
||||
// 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).
|
||||
crate::particles::EmitClock::Pinned,
|
||||
);
|
||||
}
|
||||
// The item's own M2 point light — **the held torch's glow** (decision 0016's law on the
|
||||
|
||||
@@ -461,6 +461,12 @@ pub(super) fn attach_effect_visuals(
|
||||
// The cloud anchors at the instance root — the emitter's bone only composes births
|
||||
// (the food sparkle's bone orbits; the risen stars must not swirl with it).
|
||||
Some(root),
|
||||
// The effect's rig arms ONE clip; the emitters' rate/enabled windows ride that slot
|
||||
// (a missile's InFlight is not file-order-first), on the instance's own spawn clock.
|
||||
played_seq.map_or(
|
||||
particles::EmitClock::Pinned,
|
||||
particles::EmitClock::PinnedSeq,
|
||||
),
|
||||
);
|
||||
}
|
||||
// The same pick, as an `AnimationData` id — the ribbons' per-sequence visibility keys off it,
|
||||
|
||||
@@ -321,6 +321,7 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
// reference re-anchors every cloud to the emitter's live position), while
|
||||
// an animated bone still never drags it.
|
||||
Some(root),
|
||||
particles::EmitClock::Pinned, // a placed prop: the doodad law
|
||||
) {
|
||||
// Parented: the off-map hide reaches the flame, and despawn cascades
|
||||
// (the sim's direct GlobalTransform write runs post-propagation, so the
|
||||
|
||||
@@ -236,6 +236,7 @@ mod tests {
|
||||
group_light_refs: Vec::new(),
|
||||
group_liquids: Vec::new(),
|
||||
doodad_base: Default::default(),
|
||||
doodad_owner: Default::default(),
|
||||
};
|
||||
|
||||
let drawable = interior_group_selection(&model, &Affine3A::IDENTITY, Vec3::ZERO, 25.0, 0);
|
||||
@@ -315,6 +316,7 @@ mod tests {
|
||||
group_light_refs: Vec::new(),
|
||||
group_liquids: Vec::new(),
|
||||
doodad_base: Default::default(),
|
||||
doodad_owner: Default::default(),
|
||||
};
|
||||
|
||||
let drawable = interior_group_selection(&model, &Affine3A::IDENTITY, Vec3::ZERO, 25.0, 0);
|
||||
|
||||
@@ -160,6 +160,16 @@ pub struct ParticleEmitter {
|
||||
/// against (an effect model's emitters spawn at its clip start, so age == clip time). Ambient
|
||||
/// props' constant tracks are age-invariant.
|
||||
age: f32,
|
||||
/// The emission clock's SEQUENCE source (`EmitTiming`, decision 0641's host resolution one
|
||||
/// channel over): the entity whose live `AnimationPlayer` names the playing sequence + clip
|
||||
/// time — a unit's / GameObject's emitters read the *playing* sequence's key window, so a
|
||||
/// quest object's explosion fires in its one-shot clips and stays OFF at idle. `None` = a
|
||||
/// pinned lane (placed doodad, effect rig, booth) on the spawn-age clock.
|
||||
host: Option<Entity>,
|
||||
/// The sequence FILE slot the timing samples: the pinned slot an effect's rig armed, or the
|
||||
/// host's last resolved slot (kept across frames where the host has no live player — the
|
||||
/// pre-arm frame reads that slot's opening pose). `None` ⇒ slot 0 (the doodad law).
|
||||
seq: Option<usize>,
|
||||
rng: u32,
|
||||
mesh: Handle<Mesh>,
|
||||
/// The particle texture — also held here (not just on the material) so the sim can withhold drawing
|
||||
@@ -517,6 +527,7 @@ pub fn spawn_emitter(
|
||||
owner: Option<(Entity, [f32; 3])>,
|
||||
attach: Option<Entity>,
|
||||
anchor: Option<Entity>,
|
||||
clock: EmitClock,
|
||||
) -> Option<Entity> {
|
||||
// Perf-bisect kill-switch: $WOW_NO_PARTICLES spawns no emitters at all.
|
||||
if std::env::var_os("WOW_NO_PARTICLES").is_some() {
|
||||
@@ -542,12 +553,17 @@ pub fn spawn_emitter(
|
||||
];
|
||||
entity
|
||||
});
|
||||
// Gate on the rate track's PEAK, not its first key: a one-shot burst emitter (the blood
|
||||
// spurt's starflash/glowball, 0140 fold-back) keys `0 → 200 → 0` — value[0] is 0 but it
|
||||
// absolutely emits.
|
||||
if def.lifespan <= 0.0 || def.emission_rate.peak() <= 0.0 {
|
||||
// Gate on the rate track's PEAK over every sequence, not its first key: a one-shot burst
|
||||
// emitter (the blood spurt's starflash/glowball, 0140 fold-back) keys `0 → 200 → 0` — value[0]
|
||||
// is 0 but it absolutely emits.
|
||||
if def.lifespan <= 0.0 || def.timing.peak_rate() <= 0.0 {
|
||||
return None; // emits nothing
|
||||
}
|
||||
let (host, seq) = match clock {
|
||||
EmitClock::Pinned => (None, None),
|
||||
EmitClock::PinnedSeq(s) => (None, Some(s)),
|
||||
EmitClock::Host(h) => (Some(h), None),
|
||||
};
|
||||
let material = particle_material(&def, &texture, materials, light);
|
||||
let mesh = meshes.add(blank_particle_mesh());
|
||||
// Seed the RNG from the placement position so two campfires don't flicker in lockstep.
|
||||
@@ -580,6 +596,8 @@ pub fn spawn_emitter(
|
||||
inherit_vel: Vec3::ZERO,
|
||||
gate_prev: false,
|
||||
age: 0.0,
|
||||
host,
|
||||
seq,
|
||||
rng,
|
||||
mesh,
|
||||
texture,
|
||||
@@ -660,7 +678,7 @@ pub(crate) fn wire_child_emitters(
|
||||
.take(4)
|
||||
.filter_map(|em| {
|
||||
let texture = em.texture.clone()?;
|
||||
if em.def.lifespan <= 0.0 || em.def.emission_rate.peak() <= 0.0 {
|
||||
if em.def.lifespan <= 0.0 || em.def.timing.peak_rate() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let material = particle_material(&em.def, &texture, &mut materials, &light);
|
||||
@@ -694,15 +712,31 @@ pub(crate) fn wire_child_emitters(
|
||||
}
|
||||
}
|
||||
|
||||
/// Which sequence clock an emitter's rate/enabled tracks sample against
|
||||
/// ([`benilla_formats::EmitTiming`] — one baked loop per sequence, so the consumer names the
|
||||
/// sequence). Callers pick per lane at [`spawn_emitter`].
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub enum EmitClock {
|
||||
/// Slot 0 on the spawn-age clock — a placed doodad's one-time arm (the pre-per-sequence law,
|
||||
/// still correct there), the portrait booths, and any lane with no rig to ask.
|
||||
#[default]
|
||||
Pinned,
|
||||
/// A fixed armed slot on the spawn-age clock — a spell effect's rig arms one clip at spawn
|
||||
/// and the emitters ride it (a missile's InFlight is not file-order-first).
|
||||
PinnedSeq(usize),
|
||||
/// A live host's `AnimationPlayer` decides the slot **and the clip time** each frame — units
|
||||
/// and GameObjects, whose playing sequence changes; the reference's `m2_animate` emitter
|
||||
/// phase samples the CURRENT sequence record (wow-re `part-emission-rate-animated.md` §2).
|
||||
Host(Entity),
|
||||
}
|
||||
|
||||
/// One frame of the emission front end: how many births the pool is owed, loaded into
|
||||
/// `accumulator` (fractional; the caller's birth loop drains whole particles). The reference's
|
||||
/// per-frame emitter pass + spawn driver (`0x718960` / `0x7b5550`, wow-re
|
||||
/// `part-emission-burst-flag.md` + `part-emission-rate-animated.md`):
|
||||
///
|
||||
/// - The rate is sampled at the clip clock by the reference's own track law
|
||||
/// ([`ValueTrack::sampled_ms`] — STEP for interp-0 tracks, linear for the rest, held past the
|
||||
/// last key; the corpus authors bursts as step and pour ramps as linear) and the gate is
|
||||
/// `(enabled && rate > 0)`.
|
||||
/// - `rate`/`emitting` arrive already sampled from the playing sequence's key window
|
||||
/// ([`benilla_formats::EmitTiming`]) and the gate is `(enabled && rate > 0)`.
|
||||
/// - A **continuous** emitter (file flag 0x8000 clear) pours `rate · density·LOD · dt`.
|
||||
/// - A **BURST** emitter (flag set) loads `ftol(rate · density·LOD)` ONCE on the gate's rising
|
||||
/// edge and re-arms when it falls (a looping clip's next pass re-fires it); the pour never
|
||||
@@ -711,8 +745,8 @@ pub(crate) fn wire_child_emitters(
|
||||
///
|
||||
/// Returns the burst count loaded this frame (0.0 otherwise) — the `fx` trace's measurement hook.
|
||||
fn accumulate_emission(
|
||||
def: &ParticleEmitterDef,
|
||||
clip_ms: f32,
|
||||
is_burst: bool,
|
||||
rate: f32,
|
||||
emitting: bool,
|
||||
scale: f32,
|
||||
dt: f32,
|
||||
@@ -722,14 +756,10 @@ fn accumulate_emission(
|
||||
if !emitting {
|
||||
*accumulator = 0.0;
|
||||
}
|
||||
let rate = if emitting {
|
||||
def.emission_rate.sampled_ms(clip_ms).max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rate = if emitting { rate.max(0.0) } else { 0.0 };
|
||||
let gate = rate > 0.0;
|
||||
let mut burst = 0.0;
|
||||
if def.burst() {
|
||||
if is_burst {
|
||||
if gate && !*gate_prev {
|
||||
burst = (rate * scale).trunc();
|
||||
*accumulator = burst;
|
||||
@@ -772,7 +802,7 @@ impl Plugin for ParticlePlugin {
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use benilla_formats::{OverLife, ParticleShape, ValueTrack};
|
||||
use benilla_formats::{CellRamp, OverLife, ParticleShape};
|
||||
|
||||
/// The minimal def, exposed for sibling-module tests (the sim's child-drive test).
|
||||
pub(crate) fn plain_def() -> ParticleEmitterDef {
|
||||
@@ -875,18 +905,13 @@ pub(crate) mod tests {
|
||||
tile_rows: 1,
|
||||
tile_cols: 1,
|
||||
head_tail: 0,
|
||||
clip_wrap_ms: None,
|
||||
emission_speed: 1.0,
|
||||
speed_variation: 0.0,
|
||||
vertical_range: 0.5,
|
||||
horizontal_range: std::f32::consts::PI,
|
||||
gravity: 0.0,
|
||||
lifespan: 1.0,
|
||||
emission_rate: ValueTrack {
|
||||
keys: vec![(0, 10.0)],
|
||||
interp: 0,
|
||||
},
|
||||
enabled: benilla_formats::OnOffTrack::default(),
|
||||
timing: benilla_formats::EmitTiming::constant(10.0),
|
||||
area_length: 2.0,
|
||||
area_width: 4.0,
|
||||
z_source: 0.0,
|
||||
@@ -911,90 +936,59 @@ pub(crate) mod tests {
|
||||
mid: 0.5,
|
||||
color: [[1.0; 4]; 3],
|
||||
scale: [1.0; 3],
|
||||
cell_a: [0, 0],
|
||||
cell_b: [0, 0],
|
||||
head_cells: [CellRamp::new(0, 0); 2],
|
||||
tail_cells: [CellRamp::new(0, 0); 2],
|
||||
repeat: [1.0; 2],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The BURST emission model (file flag 0x8000 — Feint/Eviscerate impact's plume+crescents):
|
||||
/// one `ftol(rate·scale)` puff on the rising edge of `(enabled && step-sampled rate > 0)`,
|
||||
/// latched while the gate holds, re-armed when it falls. The STEP rate law means a
|
||||
/// `{0:0, 67:30}` track is silent before 67 ms — the puff fires AT the key, full-count.
|
||||
/// one `ftol(rate·scale)` puff on the rising edge of `(enabled && rate > 0)`, latched while
|
||||
/// the gate holds, re-armed when it falls. (The rate/enabled *sampling* laws — step vs lerp
|
||||
/// vs held tail, per sequence window — are pinned in `benilla-formats`' `emit_timing` tests,
|
||||
/// where the bake lives.)
|
||||
#[test]
|
||||
fn burst_emitter_fires_once_on_the_rising_edge() {
|
||||
let mut d = def(ParticleShape::Plane);
|
||||
d.flags = 0x8000;
|
||||
d.emission_rate = ValueTrack {
|
||||
keys: vec![(0, 0.0), (67, 30.0)],
|
||||
interp: 0, // every corpus burst authors a STEP rate track (census-verified)
|
||||
};
|
||||
let (mut acc, mut prev) = (0.0, false);
|
||||
assert_eq!(
|
||||
accumulate_emission(&d, 50.0, true, 1.0, 0.016, &mut acc, &mut prev),
|
||||
accumulate_emission(true, 0.0, true, 1.0, 0.016, &mut acc, &mut prev),
|
||||
0.0,
|
||||
"step-sampled rate is 0 before the 67 ms key — no gate, no burst"
|
||||
"rate 0 — no gate, no burst"
|
||||
);
|
||||
assert_eq!(acc, 0.0);
|
||||
assert_eq!(
|
||||
accumulate_emission(&d, 70.0, true, 1.0, 0.016, &mut acc, &mut prev),
|
||||
accumulate_emission(true, 30.0, true, 1.0, 0.016, &mut acc, &mut prev),
|
||||
30.0,
|
||||
"the frame the sample steps to 30: one full-count burst"
|
||||
"the frame the rate rises: one full-count burst"
|
||||
);
|
||||
assert_eq!(acc, 30.0);
|
||||
acc = 0.0; // the birth loop drains it
|
||||
accumulate_emission(&d, 500.0, true, 1.0, 0.016, &mut acc, &mut prev);
|
||||
accumulate_emission(&d, 1500.0, true, 1.0, 0.016, &mut acc, &mut prev);
|
||||
accumulate_emission(true, 30.0, true, 1.0, 0.016, &mut acc, &mut prev);
|
||||
accumulate_emission(true, 30.0, true, 1.0, 0.016, &mut acc, &mut prev);
|
||||
assert_eq!(acc, 0.0, "held-high gate stays latched — never a pour");
|
||||
// The gate falls (drain/disable) → re-arms → the next rise bursts again (a looping
|
||||
// clip's next pass), the count ftol-truncated through density·LOD.
|
||||
accumulate_emission(&d, 1600.0, false, 1.0, 0.016, &mut acc, &mut prev);
|
||||
accumulate_emission(true, 30.0, false, 1.0, 0.016, &mut acc, &mut prev);
|
||||
assert_eq!(
|
||||
accumulate_emission(&d, 70.0, true, 0.55, 0.016, &mut acc, &mut prev),
|
||||
accumulate_emission(true, 30.0, true, 0.55, 0.016, &mut acc, &mut prev),
|
||||
16.0,
|
||||
"ftol(30 · 0.55) = 16"
|
||||
);
|
||||
}
|
||||
|
||||
/// The continuous emission model (flag clear) still pours `rate·dt` — and drops its owed
|
||||
/// The continuous emission model (flag clear) pours `rate·dt` — and drops its owed
|
||||
/// fraction while disabled (the pre-existing drain hygiene).
|
||||
#[test]
|
||||
fn continuous_emitter_pours_rate_dt() {
|
||||
let mut d = def(ParticleShape::Plane);
|
||||
d.emission_rate = ValueTrack {
|
||||
keys: vec![(0, 0.0), (67, 30.0)],
|
||||
interp: 0,
|
||||
};
|
||||
let (mut acc, mut prev) = (0.0, false);
|
||||
accumulate_emission(&d, 100.0, true, 1.0, 0.1, &mut acc, &mut prev);
|
||||
accumulate_emission(&d, 200.0, true, 1.0, 0.1, &mut acc, &mut prev);
|
||||
accumulate_emission(false, 30.0, true, 1.0, 0.1, &mut acc, &mut prev);
|
||||
accumulate_emission(false, 30.0, true, 1.0, 0.1, &mut acc, &mut prev);
|
||||
assert!((acc - 6.0).abs() < 1e-4, "30/s × 0.2 s, no burst latch");
|
||||
accumulate_emission(&d, 300.0, false, 1.0, 0.1, &mut acc, &mut prev);
|
||||
accumulate_emission(false, 30.0, false, 1.0, 0.1, &mut acc, &mut prev);
|
||||
assert_eq!(acc, 0.0, "disabled zeroes the owed fraction");
|
||||
}
|
||||
|
||||
/// A LINEAR (interp 1) rate ramp pours the lerped envelope — the BloodSpurt shape (`0 → 100
|
||||
/// → … → 0` authored interp 1 corpus-wide): mid-ramp the pour runs at the interpolated rate,
|
||||
/// not the previous key's step (which over-poured every falling tail and muted every rise).
|
||||
#[test]
|
||||
fn continuous_lerp_ramp_pours_the_interpolated_rate() {
|
||||
let mut d = def(ParticleShape::Plane);
|
||||
d.emission_rate = ValueTrack {
|
||||
keys: vec![(0, 0.0), (100, 100.0), (200, 0.0)],
|
||||
interp: 1,
|
||||
};
|
||||
let (mut acc, mut prev) = (0.0, false);
|
||||
accumulate_emission(&d, 50.0, true, 1.0, 0.1, &mut acc, &mut prev);
|
||||
assert!((acc - 5.0).abs() < 1e-4, "rising mid-ramp: 50/s × 0.1 s");
|
||||
accumulate_emission(&d, 150.0, true, 1.0, 0.1, &mut acc, &mut prev);
|
||||
assert!((acc - 10.0).abs() < 1e-4, "falling mid-ramp: 50/s again");
|
||||
accumulate_emission(&d, 500.0, true, 1.0, 0.1, &mut acc, &mut prev);
|
||||
assert!(
|
||||
(acc - 10.0).abs() < 1e-4,
|
||||
"held last key 0: the ramp self-closes"
|
||||
);
|
||||
}
|
||||
|
||||
/// The follow-delta response line (`0x7b5d30`): the line through the two authored
|
||||
/// (speed, fraction) samples; equal speeds degenerate to no response (the reference zeroes
|
||||
/// slope and intercept).
|
||||
|
||||
@@ -133,7 +133,9 @@ pub(super) fn update_model_particles(
|
||||
continue;
|
||||
};
|
||||
let u = (p.age / emitter.def.lifespan).clamp(0.0, 1.0);
|
||||
let (rgba, size, _) = emitter.def.over_life.sample(u);
|
||||
// 3-D model particles carry no texture atlas — only colour and size reach them.
|
||||
let ol = emitter.def.over_life.sample(u);
|
||||
let (rgba, size) = (ol.color, ol.size);
|
||||
let tf = if anchored {
|
||||
Transform {
|
||||
translation: emitter.anchor_pos + emitter.attach_rot * p.pos,
|
||||
|
||||
@@ -108,9 +108,24 @@ pub(super) fn expand_quads(
|
||||
placement.rotation * (wow_to_bevy([-1.0, 0.0, 0.0]) * s),
|
||||
)
|
||||
});
|
||||
let cols = def.tile_cols.max(1);
|
||||
let rows = def.tile_rows.max(1);
|
||||
// Atlas walk (byte-verified `0x7b2a50` @0x7b2bd5 head / @0x7b304e tail, wow-re
|
||||
// `part-cell-flipbook-ramp.md` §4): `col = idx & (COLUMNS−1)`, `row = idx >> log2(COLUMNS)`.
|
||||
// COLUMNS is a power of two by construction — the reference validates it at load and falls
|
||||
// back to a 1×1 grid otherwise, which the parse mirrors. **The column WRAPS and the row does
|
||||
// NOT**: there is no clamp into the atlas anywhere on the reference's path, so an index past
|
||||
// the last cell yields `V ≥ 1.0` and is handed to the sampler's (repeat) addressing — landing
|
||||
// back on row 0, not on the final cell. 553 emitters author exactly that, at the tail of a
|
||||
// flame's flipbook (decision 0685); a `min(rows·cols − 1)` here held the last cell instead.
|
||||
let (cols, rows) = (def.tile_cols, def.tile_rows);
|
||||
let (inv_cols, inv_rows) = (1.0 / cols as f32, 1.0 / rows as f32);
|
||||
let cell_uv = |idx: u16| {
|
||||
let cx = f32::from(idx & (cols - 1));
|
||||
let cy = f32::from(idx >> cols.trailing_zeros());
|
||||
(
|
||||
(cx * inv_cols, (cx + 1.0) * inv_cols),
|
||||
(cy * inv_rows, (cy + 1.0) * inv_rows),
|
||||
)
|
||||
};
|
||||
for p in particles {
|
||||
let noise = twinkle_noise(def.twinkle_speed, p.age, p.phase);
|
||||
// The twinklePercent draw-gate (byte-verified `0x7b2adc`): while percent < 1 (never on
|
||||
@@ -120,7 +135,8 @@ pub(super) fn expand_quads(
|
||||
continue;
|
||||
}
|
||||
let u_age = (p.age / def.lifespan).clamp(0.0, 1.0);
|
||||
let (rgba, size, cell) = def.over_life.sample(u_age);
|
||||
let ol = def.over_life.sample(u_age);
|
||||
let (rgba, size) = (ol.color, ol.size);
|
||||
// RAW authored RGB — the gamma-space decode happens ONCE, in the material's fragment
|
||||
// (`wow_particle.wgsl`, decision 0152), where it also covers the texture term that a
|
||||
// CPU-side vertex linearisation (0150) could not reach. Alpha is a blend weight — raw
|
||||
@@ -144,12 +160,6 @@ pub(super) fn expand_quads(
|
||||
// alike burn steady; the old base+rand reading collapsed the kobold candle to zero),
|
||||
// × the instance scale iff flagged.
|
||||
let half = size * def.twinkle(noise) * scale;
|
||||
// Texture-atlas cell → sub-rect UV (v increases downward in image space).
|
||||
let cell = cell.min(rows * cols - 1);
|
||||
let cx = (cell % cols) as f32;
|
||||
let cy = (cell / cols) as f32;
|
||||
let (u0, u1) = (cx * inv_cols, (cx + 1.0) * inv_cols);
|
||||
let (v0, v1) = (cy * inv_rows, (cy + 1.0) * inv_rows);
|
||||
let mut push_quad = |corners: [Vec3; 4], quv: [[f32; 2]; 4]| {
|
||||
let b = positions.len() as u32;
|
||||
for (c, t) in corners.iter().zip(quv) {
|
||||
@@ -169,6 +179,7 @@ pub(super) fn expand_quads(
|
||||
// HEAD quad (particleType 0/2), drawn first (`0x7b2bc9`): a camera-facing billboard,
|
||||
// or — XY-quad emitters — the flat plane basis computed above.
|
||||
if def.head_tail != 1 {
|
||||
let ((u0, u1), (v0, v1)) = cell_uv(ol.head_cell);
|
||||
let (base_r, base_u) = plane_basis.unwrap_or((cam_right, cam_up));
|
||||
// Quad spin (file +0x198): rotate in-plane by `angle = spin·age` (the fcos/fsin
|
||||
// fold at `0x7b2ddc`; on an XY quad the same form IS the reference's Rodrigues
|
||||
@@ -196,9 +207,18 @@ pub(super) fn expand_quads(
|
||||
// TAIL quad (particleType 1/2, byte-verified `0x7b3041`): a velocity-projected streak
|
||||
// trailing the motion — world length |velocity|·tailTime (flag 0x400 grows it from
|
||||
// zero with age), width 2·half perpendicular IN SCREEN SPACE, U running along the
|
||||
// tail (0 at the particle → 1 at the tip), same flipbook cell. A view-parallel
|
||||
// velocity (degenerate screen length) falls back to a plain billboard.
|
||||
// tail (0 at the particle → 1 at the tip). A view-parallel velocity (degenerate screen
|
||||
// length) falls back to a plain billboard.
|
||||
//
|
||||
// The streak reads its **own** flipbook cell — a second, independently authored ramp
|
||||
// (file+0x174..+0x17b), not the head's. The reference emits two cell indices per particle
|
||||
// and the tail block reads the second (`[ebp-0x40]` @0x7b3054). 14 shipped emitters on a
|
||||
// real atlas — Fire Bolt / Flamestrike / Banish / Hellfire / Vanish impacts, the elemental
|
||||
// totems — author a head ramp that walks all 64 cells and a tail ramp pinned at cell 0;
|
||||
// handing the head's cell to the streak animated it through the whole sheet (decision
|
||||
// 0685, correcting wow-re's own `part-quad-tail-twinkle.md` §2).
|
||||
if def.head_tail >= 1 {
|
||||
let ((u0, u1), (v0, v1)) = cell_uv(ol.tail_cell);
|
||||
let vel_world = if anchored {
|
||||
frame.attach_rot * p.vel
|
||||
} else {
|
||||
|
||||
@@ -109,7 +109,7 @@ fn inherit_trigger(accum: &mut f32, held: &mut Vec3, dt: f32, delta: Vec3, live:
|
||||
fn drive_child(
|
||||
child: &mut ChildEmitter,
|
||||
parent: &[Particle],
|
||||
clip_ms: f32,
|
||||
rate: f32,
|
||||
emitting: bool,
|
||||
scale: f32,
|
||||
dt: f32,
|
||||
@@ -120,8 +120,8 @@ fn drive_child(
|
||||
let origin = Vec3::from(child.def.position);
|
||||
for p in parent {
|
||||
accumulate_emission(
|
||||
&child.def,
|
||||
clip_ms,
|
||||
child.def.burst(),
|
||||
rate,
|
||||
emitting,
|
||||
scale,
|
||||
dt,
|
||||
@@ -214,6 +214,10 @@ pub(super) fn simulate_particles(
|
||||
Without<ChildDraw>,
|
||||
),
|
||||
>,
|
||||
// Hosted emitters' sequence source (the unit/GameObject lane): the host root's live
|
||||
// `AnimationPlayer` names the playing sequence + clip time, same resolution as the
|
||||
// material-alpha sampler (`doodad_anim::playing_seq`).
|
||||
hosts: Query<(&AnimationPlayer, &benilla_assets::ModelAnimations)>,
|
||||
) {
|
||||
let Ok((cam_tf, frustum)) = cam.single() else {
|
||||
return;
|
||||
@@ -384,6 +388,8 @@ pub(super) fn simulate_particles(
|
||||
inherit_vel,
|
||||
gate_prev,
|
||||
age,
|
||||
host,
|
||||
seq,
|
||||
rng,
|
||||
mesh,
|
||||
texture,
|
||||
@@ -549,23 +555,38 @@ pub(super) fn simulate_particles(
|
||||
// byte-verified): spawn count × clamp(1 − (camDist − 50)·0.02, 0.25, 1.0) — full rate
|
||||
// inside 50 yd, linear falloff, a 25% floor from 87.5 yd out, never zero — and × the
|
||||
// `particleDensity` CVar.
|
||||
// The enabled M2Track (file +0x1dc) gates NEW emission on the same clip clock — the
|
||||
// The enabled M2Track (file +0x1dc) gates NEW emission on the same clock — the
|
||||
// one-shot effects' choreography (a 200 ms hand flash inside a 1.0 s clip; the impact
|
||||
// model's six staggered windows). Live particles are untouched — they finish their
|
||||
// lifespans exactly like the drain above.
|
||||
// The clip clock: emitter age in ms, WRAPPED when the model's first sequence loops
|
||||
// (the client's m2_animate sequence time — a hold kit's windowed flash re-fires every
|
||||
// pass); a clamped sequence's tracks end-hold, bounded by the instance reap.
|
||||
let clip_ms = {
|
||||
let ms = *age * 1000.0;
|
||||
def.clip_wrap_ms.map_or(ms, |w| ms % w)
|
||||
// The sequence clock (`EmitTiming`, decision 0641's structure one channel over): a HOSTED
|
||||
// emitter reads its host's live playing sequence + clip time each frame — the reference's
|
||||
// `m2_animate` emitter phase samples the CURRENT sequence record, which is why a quest
|
||||
// GameObject's explosion fires in its one-shot clips and reads OFF in every idle window
|
||||
// (bug B27). A host with no live player yet keeps its last slot at that slot's opening
|
||||
// pose. Pinned lanes (doodads, effect rigs, booths) run their slot on the spawn-age
|
||||
// clock; `EmitTiming` wraps a looping band and end-holds a clamped one.
|
||||
let (clock_seq, elapsed_s) = match *host {
|
||||
Some(h) => match hosts
|
||||
.get(h)
|
||||
.ok()
|
||||
.and_then(|(p, a)| crate::doodad_anim::playing_seq(p, a))
|
||||
{
|
||||
Some((s, t)) => {
|
||||
*seq = Some(s);
|
||||
(Some(s), t)
|
||||
}
|
||||
None => (*seq, 0.0),
|
||||
},
|
||||
None => (*seq, *age),
|
||||
};
|
||||
let emitting = !*draining && def.enabled.on_at(clip_ms);
|
||||
let emitting = !*draining && def.timing.emitting(clock_seq, elapsed_s);
|
||||
let rate = def.timing.rate(clock_seq, elapsed_s);
|
||||
let dist_lod =
|
||||
(1.0 - (placement.translation.distance(e_cam_pos) - 50.0) * 0.02).clamp(0.25, 1.0);
|
||||
let burst = accumulate_emission(
|
||||
def,
|
||||
clip_ms,
|
||||
def.burst(),
|
||||
rate,
|
||||
emitting,
|
||||
density * dist_lod,
|
||||
dt,
|
||||
@@ -573,7 +594,7 @@ pub(super) fn simulate_particles(
|
||||
gate_prev,
|
||||
);
|
||||
if burst > 0.0 && crate::dbg_trace::enabled() {
|
||||
crate::dbg_trace::line("fx", &format!("burst n={burst} clip_ms={clip_ms:.0}"));
|
||||
crate::dbg_trace::line("fx", &format!("burst n={burst} t={elapsed_s:.2}s"));
|
||||
}
|
||||
while *accumulator >= 1.0 && particles.len() < MAX_PARTICLES {
|
||||
*accumulator -= 1.0;
|
||||
@@ -607,7 +628,7 @@ pub(super) fn simulate_particles(
|
||||
if anchored && def.ground_snap() {
|
||||
let world = *anchor_pos + *attach_rot * pos;
|
||||
if let Some(hit) = spatial.cast_ray(world, Dir3::NEG_Y, 20.0, true, &snap_filter) {
|
||||
let lifted = world.y - hit.distance + def.over_life.sample(0.0).1;
|
||||
let lifted = world.y - hit.distance + def.over_life.sample(0.0).size;
|
||||
pos = attach_inv * (Vec3::new(world.x, lifted, world.z) - *anchor_pos);
|
||||
}
|
||||
}
|
||||
@@ -669,17 +690,17 @@ pub(super) fn simulate_particles(
|
||||
|
||||
// 2b. CHILD emitters (wow-re `part-child-recursion.md`): each drives off the live pool
|
||||
// — post-integration, post-birth, exactly the window the reference's per-particle
|
||||
// child loop sees — with the child's OWN rate/enabled tracks on its own clip wrap.
|
||||
// child loop sees — with the child's OWN rate/enabled tracks on its own model's
|
||||
// slot-0 clock (a recursion model has no host of its own; the parent's age drives).
|
||||
// A draining parent stops driving; child pools live out their spans (the despawn
|
||||
// above waits for them).
|
||||
for child in children.iter_mut() {
|
||||
let raw_ms = *age * 1000.0;
|
||||
let c_ms = child.def.clip_wrap_ms.map_or(raw_ms, |w| raw_ms % w);
|
||||
let c_emitting = !*draining && child.def.enabled.on_at(c_ms);
|
||||
let c_emitting = !*draining && child.def.timing.emitting(None, *age);
|
||||
let c_rate = child.def.timing.rate(None, *age);
|
||||
drive_child(
|
||||
child,
|
||||
particles,
|
||||
c_ms,
|
||||
c_rate,
|
||||
c_emitting,
|
||||
density * dist_lod,
|
||||
dt,
|
||||
@@ -876,10 +897,7 @@ mod tests {
|
||||
area_width: 0.0,
|
||||
..crate::particles::tests::plain_def()
|
||||
});
|
||||
child.def.emission_rate = benilla_formats::ValueTrack {
|
||||
keys: vec![(0, 100.0)],
|
||||
interp: 0,
|
||||
};
|
||||
child.def.timing = benilla_formats::EmitTiming::constant(100.0);
|
||||
// Two live parents, far apart, distinct velocities.
|
||||
let parents = [
|
||||
particle(Vec3::X * 10.0, Vec3::Y * 3.0),
|
||||
@@ -889,7 +907,7 @@ mod tests {
|
||||
super::drive_child(
|
||||
&mut child,
|
||||
&parents,
|
||||
0.0,
|
||||
100.0,
|
||||
true,
|
||||
1.0,
|
||||
0.1,
|
||||
@@ -914,7 +932,7 @@ mod tests {
|
||||
super::drive_child(
|
||||
&mut child,
|
||||
&[],
|
||||
0.0,
|
||||
100.0,
|
||||
true,
|
||||
1.0,
|
||||
0.1,
|
||||
|
||||
@@ -624,6 +624,8 @@ pub(super) fn sync_glue_scene(
|
||||
Some(owner),
|
||||
None, // a backdrop scene is never an attached model
|
||||
Some(scene.root),
|
||||
// A glue scene loops its one authored clip forever — the doodad law.
|
||||
crate::particles::EmitClock::Pinned,
|
||||
) {
|
||||
commands
|
||||
.entity(e)
|
||||
|
||||
@@ -78,8 +78,18 @@ pub(crate) struct TerrainStreamer {
|
||||
/// The WDT failed to load (unheard of for a shipped map): stream ungated like pre-0476 rather
|
||||
/// than showing no world. Reset on map change.
|
||||
wdt_ungated: bool,
|
||||
/// `true` once this map's global WMO has been registered as a placement (decision 0688) — on a
|
||||
/// WMO-only map that one building is the whole world, so there is nothing else to stream.
|
||||
/// Cleared on map change, which also releases the placement.
|
||||
global_wmo: bool,
|
||||
}
|
||||
|
||||
/// The placement id the map-global WMO is registered under. A WMO-only map authors **no** ADT tiles
|
||||
/// at all, so it contributes no MODF/MDDF uniqueIds this could collide with — and the value is the
|
||||
/// one the file itself carries in the dead `uniqueId` slot (the reference overwrites it from its own
|
||||
/// counter at `0xc9a320`, having no more use for it than we do).
|
||||
const GLOBAL_WMO_UID: u32 = u32::MAX;
|
||||
|
||||
impl TerrainStreamer {
|
||||
/// Residency counts for the debug panel's World readout: `(spawned, requested)` — tiles whose
|
||||
/// terrain entity exists vs. every tile in the stream window (spawned + still loading).
|
||||
@@ -134,6 +144,10 @@ struct Placement {
|
||||
/// WMO doodad props (candle stands, banners) resolved once the WMO root loads — each its own M2,
|
||||
/// spawned across frames as its asset arrives. Empty for M2-doodad placements.
|
||||
doodads: Vec<WmoDoodadInst>,
|
||||
/// The placement's [`crate::wmo_portal::WmoPortalInstance`] entity, spawned with the building's
|
||||
/// groups. Held so the props — which spawn later, as their own M2 assets land — can be tagged
|
||||
/// with the same instance and cull alongside the group that owns them.
|
||||
portal_instance: Option<Entity>,
|
||||
/// How many loaded tiles reference this placement; despawned when it hits zero.
|
||||
refs: u32,
|
||||
}
|
||||
@@ -143,6 +157,10 @@ struct Placement {
|
||||
struct WmoDoodadInst {
|
||||
handle: Handle<M2Model>,
|
||||
transform: Transform,
|
||||
/// The prop's owning WMO group (its MODR referencer) — the portal-cull key, so a prop is hidden
|
||||
/// with the room it furnishes. `None` for a MODD no group references (the reference never
|
||||
/// instantiates one at all; we still show it, uncullable, rather than change what draws today).
|
||||
group: Option<u16>,
|
||||
/// The prop's lighting ([`PropLight`], from `WmoModel::doodad_base` composed with this
|
||||
/// placement): exterior sky-lit, or the interior MODD-colour base + its owning group's MOLR
|
||||
/// lights placed in world space — folded into the prop's SH probe once its M2 loads (the fold
|
||||
@@ -301,6 +319,9 @@ fn stream_terrain(
|
||||
release_placement(&mut commands, placements, uid);
|
||||
}
|
||||
}
|
||||
if std::mem::take(&mut state.global_wmo) {
|
||||
release_placement(&mut commands, placements, GLOBAL_WMO_UID);
|
||||
}
|
||||
state.map_dir = Some(dir.clone());
|
||||
state.wdt = Some(asset_server.load(format!("mpq://World/Maps/{dir}/{dir}.wdt")));
|
||||
state.wdt_ungated = false;
|
||||
@@ -320,6 +341,34 @@ fn stream_terrain(
|
||||
}
|
||||
}
|
||||
|
||||
// A WMO-only map (decision 0688): the WDT says this map authors no terrain and its entire world
|
||||
// is one building. Register it exactly like any ADT-placed WMO — same `Placement`, same
|
||||
// spawn/collider/doodad/portal path — because that is what the reference does: its WDT branch
|
||||
// hands the global MODF entry to the SAME consumer (`0x695650`) an ADT's MCRF walk does. It is
|
||||
// registered once for the map and released on the map change above; nothing streams it in or
|
||||
// out, since there is no "range" for a building that is the map.
|
||||
if let Some(g) = wdt_index.and_then(|w| w.global_wmo()) {
|
||||
if !state.global_wmo {
|
||||
info!(
|
||||
"terrain: map {dir} has no tiles — its world is one WMO ({})",
|
||||
g.model
|
||||
);
|
||||
register_wmo(
|
||||
placements,
|
||||
&asset_server,
|
||||
&WmoInstance {
|
||||
model: g.model.clone(),
|
||||
position: g.position,
|
||||
rotation: g.rotation,
|
||||
unique_id: GLOBAL_WMO_UID,
|
||||
doodad_set: g.doodad_set,
|
||||
name_set: g.name_set,
|
||||
},
|
||||
);
|
||||
state.global_wmo = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream around the *view focus*: the avatar in third-person, but the free-flying camera itself
|
||||
// while detached or before we've connected — so the world loads around wherever you look.
|
||||
let center = if player.active && !player.detached {
|
||||
@@ -488,6 +537,26 @@ fn stream_terrain(
|
||||
.tiles
|
||||
.get(&(cx, cy))
|
||||
.is_none_or(|t| t.entity.is_some());
|
||||
// Until the WDT answers we don't yet know what this map is made of, so nothing under the
|
||||
// focus can be called resident. Without this the post-worldport frames before the index
|
||||
// lands read as "ground is up" — harmless on an ADT map (the tile entries below close the
|
||||
// gap a frame later) but on a WMO-only map, where `desired` is empty forever, it is the
|
||||
// difference between a loading screen and a glimpse of the void.
|
||||
if wdt_index.is_none() && !state.wdt_ungated {
|
||||
p.focus_resident = false;
|
||||
}
|
||||
// A WMO-only map's residency IS its one building (0688) — there are no tiles to count, so
|
||||
// the bar and the clear-condition ride the placement instead. Counting it keeps
|
||||
// `total > 0`, which is what `is_ready` requires before it will clear the screen at all.
|
||||
if state.global_wmo {
|
||||
let up = placements
|
||||
.by_id
|
||||
.get(&GLOBAL_WMO_UID)
|
||||
.is_some_and(|p| p.spawned);
|
||||
p.total += 1;
|
||||
p.ready += usize::from(up);
|
||||
p.focus_resident &= up;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +582,7 @@ fn register_doodad(placements: &mut Placements, asset_server: &AssetServer, d: &
|
||||
doodad_set: 0, // M2 doodads carry no doodad set
|
||||
name_set: 0,
|
||||
doodads: Vec::new(),
|
||||
portal_instance: None,
|
||||
refs: 1,
|
||||
},
|
||||
);
|
||||
@@ -539,6 +609,7 @@ fn register_wmo(placements: &mut Placements, asset_server: &AssetServer, w: &Wmo
|
||||
doodad_set: w.doodad_set,
|
||||
name_set: w.name_set,
|
||||
doodads: Vec::new(),
|
||||
portal_instance: None,
|
||||
refs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -143,8 +143,9 @@ pub(super) fn spawn_emitters_for(
|
||||
em,
|
||||
transform,
|
||||
owner,
|
||||
None, // placed doodads/props are never attached models
|
||||
None, // placed doodads/props are never attached models
|
||||
None, // anchor at the placement: an animated bone never drags the risen cloud
|
||||
particles::EmitClock::Pinned, // a placed doodad's one-time arm: slot 0, spawn clock
|
||||
) {
|
||||
commands.entity(e).insert(particles::EmitterFade {
|
||||
radius: fade_radius,
|
||||
|
||||
@@ -218,6 +218,9 @@ pub(super) fn spawn_loaded_placements(
|
||||
.entity(entity)
|
||||
.insert(WmoGroupVis { instance, group });
|
||||
}
|
||||
// The props spawn later — each waits on its own M2 — so they can't be
|
||||
// tagged here; hold the instance for them (decision 0689).
|
||||
p.portal_instance = Some(instance);
|
||||
}
|
||||
ents.push(instance); // despawns with the placement
|
||||
}
|
||||
@@ -226,14 +229,29 @@ pub(super) fn spawn_loaded_placements(
|
||||
// spawned at the placement transform on the shared per-kind material. Appended
|
||||
// AFTER the portal-instance zip above, which requires `ents` to still be exactly the
|
||||
// submeshes in order.
|
||||
spawn_wmo_liquids(
|
||||
&mut commands,
|
||||
m.group_liquids.iter().flatten(),
|
||||
liquid_assets.as_deref(),
|
||||
&mut meshes,
|
||||
p.transform,
|
||||
&mut ents,
|
||||
);
|
||||
// Spawned a group at a time so each surface can take that group's cull key: a
|
||||
// pool belongs to the room it sits in, and a culled room's lava must go with it
|
||||
// (decision 0689 — the same defect as the props, on the same building).
|
||||
for (gi, lq) in m.group_liquids.iter().enumerate() {
|
||||
let Some(lq) = lq else { continue };
|
||||
let first = ents.len();
|
||||
spawn_wmo_liquids(
|
||||
&mut commands,
|
||||
std::iter::once(lq),
|
||||
liquid_assets.as_deref(),
|
||||
&mut meshes,
|
||||
p.transform,
|
||||
&mut ents,
|
||||
);
|
||||
if let Some(instance) = p.portal_instance {
|
||||
for &e in &ents[first..] {
|
||||
commands.entity(e).insert(WmoGroupVis {
|
||||
instance,
|
||||
group: gi as u16,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Building colliders (avian): the flattened group tris baked at this placement's
|
||||
// transform — *two* meshes, because the client gathers different WMO faces for the
|
||||
// player body (walking: drops DETAIL) and the camera/LOS (drops NOCAMCOLLIDE, keeps
|
||||
@@ -302,6 +320,9 @@ pub(super) fn spawn_loaded_placements(
|
||||
|
||||
// 2. Spawn each WMO doodad prop as its M2 asset finishes loading (across frames). Their
|
||||
// entities join `p.entities`, so they despawn with the placement. Empty for M2 doodads.
|
||||
// Copied out before the loop borrows `p.doodads`: the props tag onto the same portal
|
||||
// instance their building's groups did (decision 0689).
|
||||
let portal_instance = p.portal_instance;
|
||||
for d in &mut p.doodads {
|
||||
if d.spawned {
|
||||
continue;
|
||||
@@ -375,6 +396,17 @@ pub(super) fn spawn_loaded_placements(
|
||||
if let (Some(slot), Some(&first)) = (interior_slot, ents.first()) {
|
||||
commands.entity(first).insert(PropProbeSlot(slot));
|
||||
}
|
||||
// Portal-cull the prop with the group that owns it — the reference commits a WMO's
|
||||
// doodads per VISIBLE group (`0x695aa0` over the group's MODR refs, from the
|
||||
// visible-group walk `0x698720`), so furniture never outlives its room. Every submesh
|
||||
// spawned above is this one prop, so they all take the same key (decision 0689).
|
||||
if let (Some(instance), Some(group)) = (portal_instance, d.group) {
|
||||
for &entity in &ents {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(WmoGroupVis { instance, group });
|
||||
}
|
||||
}
|
||||
// Prop collider (avian): a static trimesh from the prop's collision hull at its world
|
||||
// transform — collide-iff-hull, exactly like a map doodad, so a weapon rack / crate / cargo
|
||||
// net is solid to both the player and the camera while a hull-less prop (banner, small
|
||||
@@ -491,6 +523,9 @@ fn resolve_wmo_doodads(
|
||||
out.push(WmoDoodadInst {
|
||||
handle: asset_server.load(m2_url(&d.model)),
|
||||
transform: wmo_world.mul_transform(local),
|
||||
// The MODR owner, resolved with the lighting base at load — the prop's portal-cull
|
||||
// key, so a lantern is hidden with the room it hangs in (decision 0689).
|
||||
group: wmo.doodad_owner.get(di).copied().flatten(),
|
||||
light,
|
||||
spawned: false,
|
||||
});
|
||||
|
||||
@@ -223,6 +223,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
|
||||
group_light_refs: Vec::new(),
|
||||
group_liquids: Vec::new(),
|
||||
doodad_base: Vec::new(),
|
||||
doodad_owner: Vec::new(),
|
||||
};
|
||||
Subject {
|
||||
model,
|
||||
|
||||
@@ -439,6 +439,7 @@ mod tests {
|
||||
group_light_refs: Vec::new(),
|
||||
group_liquids: Vec::new(),
|
||||
doodad_base: Default::default(),
|
||||
doodad_owner: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -755,6 +755,7 @@ mod tests {
|
||||
group_light_refs: Vec::new(),
|
||||
group_liquids: Vec::new(),
|
||||
doodad_base: Default::default(),
|
||||
doodad_owner: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user