- the action-bar cluster is the reference's own: `MainMenuBar.xml`, `ActionBarFrame.xml`, `MultiActionBars.xml` and `BonusActionBarFrame.xml` run off the player's own chain, and our `ActionBar.xml` (2047 lines), `MultiBars.xml` and `StanceBar.xml` are deleted — three, since the stock bonus-bar file declares the shapeshift bar too; one engine verb, `ChangeActionBarPage`, fires `ACTIONBAR_PAGE_CHANGED` and nothing else; one load blocker: stock `MultiActionBars.lua` writes the options window's table from its OnLoad, the reference's own load-order hack, so our options window moves to the reference's toc slot; and ten things the reference does that ours did not: an empty button is HIDDEN while showgrid is 0, the bonus overlay sits one pixel right of the main buttons, every slide takes one frame more than its time, and `ActionButton_ShowGrid` ghosts EVERY ring, occupied included (1938) - a gift can be wrapped: right-clicking wrapping paper fell through to plain use and sent `CMSG_USE_ITEM`, so nothing happened — you could receive a gift and never give one; the wrap is a displayed-cursor MODE plus a stashed slot, never a payload, so `CursorHasItem()` stays false and every stock gate on it stays shut: right-click arms it, a left-click on a bag slot sends `CMSG_WRAP_ITEM`, and the client applies no eligibility test — the server's `ERR_CANT_WRAP_*` reasons are the error line (1934); a duration-limited item counts down: `SMSG_ITEM_TIME_UPDATE` parks a deadline and the tooltip's `ITEM_DURATION` line runs the enchant countdown's bucket ladder, so "Duration: 1 days" is what the reference renders; an update for an item we do not hold is DROPPED, no queue (1933); and a renamed crowd-control refusal names its mechanic — "Can't do that while stunned" — off `SpellMechanic.dbc` (1943) - a creature paths on a boat: `SMSG_MONSTER_MOVE_TRANSPORT` carries the transport's guid and deck-local coordinates, and is the attach/detach edge; a deck sample lands on the rider or nowhere, never as a world write a few yards from the map origin; and "there is no terrain under a boat" turned out wrong: the ground probe admits GameObject meshes — a boat's `.wmo` is a GameObject, so the probe hits the deck (1936); `MSG_MOVE_TIME_SKIPPED` is read, not dropped — the reference adds the lag to the observed unit's last-wire stamp, so dropping it scheduled their next packet `lag` ms late, a hitch for every player who stalled zoning in near us; and our own settle hold reports once (1935); and the tradeskill tool order was backwards — focus FIRST, so every two-tool recipe had read "Blacksmith Hammer, Anvil" for the reference's "Anvil, Blacksmith Hammer"; the focus's `hasTool` is 1.0 in the reference too (1942) - the crowd's second sitting, instruments first: the probe line carries the tail — cpu p95/p99 and the six worst frames — because a mean under budget hides the one frame in twenty over it (raid legs railed at 16.67 with p95 at 18–20 ms) and whether that frame was work or a wait is the first question about a stutter, and the visible meshes are counted by lane; then two cuts — a terrain tile furnishes as 4×4 cells of 4×4 chunks, one mesh, one entity, one draw per 133-yd cell instead of per 33-yd chunk (a city horizon is over a thousand chunks a frame, and a cell changes nothing a pixel can see; Stormwind 10.55 → 10.32 `cpu_ms`), and an authored point light is our own `WorldPointLight`, not Bevy's `PointLight`, which was only a data carrier while Bevy ran its whole light lane over every lamp, ~2 % of the alone frame; the sitting's record is 1945, and the code says so (1945) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
646 lines
29 KiB
Rust
646 lines
29 KiB
Rust
//! `benilla-assets` — the bridge from the WoW 1.12.1 MPQ patch chain into Bevy's asset system.
|
||
//!
|
||
//! Registers an `mpq://` [`AssetSource`](bevy::asset::io::AssetSource) backed by
|
||
//! [`benilla_formats::Chain`], so every WoW asset loads through the standard
|
||
//! [`AssetServer`](bevy::asset::AssetServer) as a `Handle<T>` — gaining async loading, handle dedup,
|
||
//! a dependency graph, and hot-reload for free, instead of the bespoke caches and hand-rolled
|
||
//! worker/finalize pipeline the old client carried (decision 0005). The per-format
|
||
//! [`AssetLoader`](bevy::asset::AssetLoader)s (BLP→`Image`, M2/WMO→model, ADT→tile, DBC→catalog)
|
||
//! build on this foundation.
|
||
|
||
use std::path::Path;
|
||
use std::sync::Arc;
|
||
|
||
use anyhow::Result;
|
||
use benilla_formats::Chain;
|
||
use bevy::asset::io::{
|
||
AssetReader, AssetReaderError, AssetSourceBuilder, ErasedAssetReader, PathStream, Reader,
|
||
VecReader,
|
||
};
|
||
use bevy::asset::AssetApp;
|
||
use bevy::prelude::*;
|
||
|
||
pub mod column_grid;
|
||
pub mod coords;
|
||
pub mod materials;
|
||
pub mod minimap_grid;
|
||
mod spatial_cache;
|
||
pub mod trace;
|
||
pub use spatial_cache::SpatialCache;
|
||
mod world_assets;
|
||
pub use world_assets::*;
|
||
|
||
mod model;
|
||
pub use model::{
|
||
bone_target_id, merged_static_mesh_faded, submesh_to_skinned_mesh, submesh_to_static_mesh,
|
||
AnimClip, BillboardInfo, ClipEvent, GlobalBone, GlobalSeqChannel, ModelAnimations,
|
||
ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip,
|
||
PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_FADE_SPHERE, ATTRIBUTE_WOW_JOINT_INDEX,
|
||
ATTRIBUTE_WOW_JOINT_WEIGHT, ATTRIBUTE_WOW_MERGED_SLOT,
|
||
};
|
||
mod adt;
|
||
mod terrain;
|
||
mod wdt;
|
||
pub use adt::{chunk_to_mesh, chunks_to_mesh, AdtLoader, AdtTile, ChunkShading};
|
||
pub use wdt::{WdtIndex, WdtIndexLoader};
|
||
mod blp;
|
||
pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant};
|
||
/// Whether this run's GPU can eat WoW's stored DXT blocks, and the chain form that follows.
|
||
mod gpu_blp;
|
||
pub use gpu_blp::{bc_supported, for_upload, publish_bc_support, BlpGpuSupportPlugin, UploadChain};
|
||
mod tex_filter;
|
||
pub use tex_filter::{publish_tex_filter, tex_filter, TexFilterSetting, ANISO_RANGE};
|
||
mod m2;
|
||
pub use m2::{
|
||
EmitterBillboard, M2Model, M2ModelLoader, ModelEmitter, ModelLight, ModelRibbon, PortraitCamera,
|
||
};
|
||
mod wmo;
|
||
pub use benilla_formats::{WmoPortalInfo, WmoPortalRef};
|
||
pub use wmo::{
|
||
cap96, collision_tri_bounds, collision_tri_grids, floor168, footprint_tri_bounds,
|
||
footprint_tri_grids, DoodadBase, WmoGroupNav, WmoModel, WmoModelLoader,
|
||
};
|
||
|
||
/// The asset-source id for MPQ-backed assets: load paths look like `mpq://World/Azeroth/foo.adt`.
|
||
pub const MPQ_SOURCE: &str = "mpq";
|
||
|
||
/// A Bevy [`AssetReader`] over the vanilla MPQ patch chain. Cheap to clone — every clone shares the
|
||
/// one open chain ([`Chain`] reads through fresh per-call handles, so this is `Send + Sync`).
|
||
#[derive(Clone)]
|
||
pub struct MpqAssetReader {
|
||
chain: Arc<Chain>,
|
||
}
|
||
|
||
impl MpqAssetReader {
|
||
/// Open the patch chain from a `Data` directory (or a single `.MPQ`), ready to back an
|
||
/// `mpq://` source.
|
||
pub fn open(data_dir: &Path) -> Result<Self> {
|
||
Ok(Self {
|
||
chain: Arc::new(Chain::open(data_dir)?),
|
||
})
|
||
}
|
||
}
|
||
|
||
impl AssetReader for MpqAssetReader {
|
||
async fn read<'a>(&'a self, path: &'a Path) -> Result<impl Reader + 'a, AssetReaderError> {
|
||
let raw = path
|
||
.to_str()
|
||
.ok_or_else(|| AssetReaderError::NotFound(path.to_path_buf()))?;
|
||
// Strip the sampler-mode marker ([`texture_url`]) before the archive lookup — it is part of
|
||
// the ASSET identity, not of the file name.
|
||
let stripped = strip_sampler_marker(raw);
|
||
let internal = stripped.as_deref().unwrap_or(raw);
|
||
if !self.chain.contains(internal) {
|
||
return Err(AssetReaderError::NotFound(path.to_path_buf()));
|
||
}
|
||
let bytes = self
|
||
.chain
|
||
.read(internal)
|
||
.map_err(|e| AssetReaderError::Io(Arc::new(std::io::Error::other(format!("{e:#}")))))?;
|
||
Ok(VecReader::new(bytes))
|
||
}
|
||
|
||
async fn read_meta<'a>(&'a self, path: &'a Path) -> Result<impl Reader + 'a, AssetReaderError> {
|
||
// MPQ assets carry no sidecar `.meta`; Bevy falls back to default meta on `NotFound`.
|
||
Err::<VecReader, _>(AssetReaderError::NotFound(path.to_path_buf()))
|
||
}
|
||
|
||
async fn read_directory<'a>(
|
||
&'a self,
|
||
path: &'a Path,
|
||
) -> Result<Box<PathStream>, AssetReaderError> {
|
||
// We load by explicit path; directory enumeration / folder-watching isn't supported.
|
||
Err(AssetReaderError::NotFound(path.to_path_buf()))
|
||
}
|
||
|
||
async fn is_directory<'a>(&'a self, _path: &'a Path) -> Result<bool, AssetReaderError> {
|
||
Ok(false)
|
||
}
|
||
}
|
||
|
||
/// The sampler-mode marker separator in an `mpq://` texture URL (see [`texture_url`]).
|
||
const SAMPLER_MARKER: char = '@';
|
||
|
||
/// Build the `mpq://` URL for a model texture at a given sampler address mode.
|
||
///
|
||
/// The address mode lives on the GPU sampler, which in Bevy rides the `Image`, which is keyed by
|
||
/// asset path — so two modes of one `.blp` need two asset paths. **243 of the corpus's 4677 texture
|
||
/// paths are asked for more than one mode** (`benilla-extract texmodescan`), so this is not a
|
||
/// hypothetical: without it, whichever model loaded a shared sheet first would decide the mode for
|
||
/// every later one.
|
||
///
|
||
/// Repeat/repeat — the overwhelming majority — keeps the bare path, so the common case is one upload
|
||
/// and every pre-existing URL is unchanged. Any other mode gets a marker **before the extension**
|
||
/// (`…\leaves01@cc.blp`), because Bevy selects the loader by extension and a trailing marker would
|
||
/// stop `.blp` resolving. [`MpqAssetReader::read`] strips it back off for the archive lookup.
|
||
/// Decision 0763.
|
||
pub fn texture_url(internal: &str, wrap: (bool, bool)) -> String {
|
||
let path = internal.replace('\\', "/").to_ascii_lowercase();
|
||
if wrap == (true, true) {
|
||
return format!("mpq://{path}");
|
||
}
|
||
let tag = match wrap {
|
||
(true, false) => "rc",
|
||
(false, true) => "cr",
|
||
_ => "cc",
|
||
};
|
||
match path.rsplit_once('.') {
|
||
Some((stem, ext)) => format!("mpq://{stem}{SAMPLER_MARKER}{tag}.{ext}"),
|
||
None => format!("mpq://{path}{SAMPLER_MARKER}{tag}"),
|
||
}
|
||
}
|
||
|
||
/// Bevy resource wrapper around the format crate's [`MapCatalog`] (`mapId` -> directory +
|
||
/// `LoadingScreenID`), read out of `Map.dbc`.
|
||
///
|
||
/// The newtype exists because the orphan rule forbids `Resource` on a foreign type, and it lives
|
||
/// here rather than in the client for the same reason the rest of this crate does: a `.dbc` table
|
||
/// turned into something Bevy can hold is exactly this layer's job, and the three readers are the
|
||
/// WDL streamer, the world-map UI and the loading screen — one engine, two game (decision 1164).
|
||
/// The *loader* stays up top with the patch chain's plugin shell, which is what inserts it.
|
||
#[derive(Resource)]
|
||
pub struct MapCatalogRes(pub benilla_formats::MapCatalog);
|
||
|
||
/// The `mpq://` URL an authored **model** path resolves to.
|
||
///
|
||
/// The archive holds 1.12.1 `.mdx`/`.mdl`, the loader produces an `M2Model`, and Bevy picks a loader
|
||
/// by extension — so every reference is normalised to one lowercase `mpq://…m2`, which is also what
|
||
/// makes the handle dedup work (a path differing only in case or slash would load twice). The WMO and
|
||
/// skin builders are the same rewrite for their own extensions. They sit beside [`texture_url`]
|
||
/// because they are the same act: turning a path the game files wrote into the one URL this crate's
|
||
/// asset source answers to (decision 1164).
|
||
/// A model reference path (`.mdx`/`.mdl`, mixed case, backslashes) → its `mpq://…m2` load URL.
|
||
/// Lowercased so case variants share one `AssetServer` handle; the physical archive file is `.m2`.
|
||
pub fn m2_url(raw: &str) -> String {
|
||
let p = raw.to_ascii_lowercase().replace('\\', "/");
|
||
let stem = p
|
||
.strip_suffix(".mdx")
|
||
.or_else(|| p.strip_suffix(".mdl"))
|
||
.or_else(|| p.strip_suffix(".m2"))
|
||
.unwrap_or(&p);
|
||
format!("mpq://{stem}.m2")
|
||
}
|
||
|
||
/// A WMO root path → its `mpq://…wmo` load URL (already `.wmo`; lowercased for handle dedup).
|
||
pub fn wmo_url(raw: &str) -> String {
|
||
format!("mpq://{}", raw.to_ascii_lowercase().replace('\\', "/"))
|
||
}
|
||
|
||
/// A creature skin variation → its `mpq://…blp` URL: `<model-dir>\<name>.blp`. `model_dir` is the
|
||
/// directory of the creature's model path (where its `Monster1/2/3` skins live).
|
||
pub fn skin_url(model_dir: &str, name: &str) -> String {
|
||
let dir = model_dir.replace('\\', "/").to_ascii_lowercase();
|
||
let name = name.to_ascii_lowercase();
|
||
if dir.is_empty() {
|
||
format!("mpq://{name}.blp")
|
||
} else {
|
||
format!("mpq://{dir}/{name}.blp")
|
||
}
|
||
}
|
||
|
||
/// The address mode encoded in an asset path by [`texture_url`]; repeat/repeat when unmarked.
|
||
pub fn sampler_mode_of(path: &str) -> (bool, bool) {
|
||
let Some((stem, _)) = path.rsplit_once('.') else {
|
||
return (true, true);
|
||
};
|
||
match stem.rsplit_once(SAMPLER_MARKER) {
|
||
Some((_, "rc")) => (true, false),
|
||
Some((_, "cr")) => (false, true),
|
||
Some((_, "cc")) => (false, false),
|
||
_ => (true, true),
|
||
}
|
||
}
|
||
|
||
/// Remove a [`texture_url`] marker, yielding the real archive path. `None` when there is none.
|
||
fn strip_sampler_marker(path: &str) -> Option<String> {
|
||
let (stem, ext) = path.rsplit_once('.')?;
|
||
let (base, tag) = stem.rsplit_once(SAMPLER_MARKER)?;
|
||
matches!(tag, "rc" | "cr" | "cc").then(|| format!("{base}.{ext}"))
|
||
}
|
||
|
||
/// Register the `mpq://` asset source on `app`, backed by the patch chain at `data_dir`.
|
||
///
|
||
/// Must be called **before** Bevy's `AssetPlugin` builds (i.e. before `DefaultPlugins`): asset
|
||
/// sources are read when the plugin initializes the [`AssetServer`](bevy::asset::AssetServer).
|
||
pub fn register_mpq_source(app: &mut App, data_dir: &Path) -> Result<()> {
|
||
let reader = MpqAssetReader::open(data_dir)?;
|
||
app.register_asset_source(
|
||
MPQ_SOURCE,
|
||
AssetSourceBuilder::new(move || -> Box<dyn ErasedAssetReader> { Box::new(reader.clone()) }),
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Register benilla's asset loaders on `app`. Call **after** Bevy's `AssetPlugin` (loaders register
|
||
/// into the live [`AssetServer`](bevy::asset::AssetServer)); pair with [`register_mpq_source`], which
|
||
/// must run *before* `AssetPlugin`.
|
||
pub fn register_asset_loaders(app: &mut App) {
|
||
app.init_asset::<M2Model>();
|
||
app.init_asset::<WmoModel>();
|
||
app.init_asset::<AdtTile>();
|
||
app.init_asset::<WdtIndex>();
|
||
// Publishes whether this device can eat DXT blocks (decision 1626). A plugin, because the
|
||
// answer only exists after `RenderPlugin::finish` — see `BlpGpuSupportPlugin`.
|
||
app.add_plugins(BlpGpuSupportPlugin);
|
||
app.register_asset_loader(BlpImageLoader);
|
||
app.register_asset_loader(M2ModelLoader);
|
||
app.register_asset_loader(WmoModelLoader);
|
||
app.register_asset_loader(AdtLoader);
|
||
app.register_asset_loader(WdtIndexLoader);
|
||
// The render materials' WGSL, compiled in rather than served off the host binary's asset root
|
||
// (decision 1164) — same "after AssetPlugin" requirement as the loaders above, so it rides here.
|
||
materials::register_shaders(app);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use bevy::camera::primitives::MeshAabb;
|
||
use bevy::tasks::block_on;
|
||
|
||
/// The sampler-mode URL round-trips, and repeat/repeat stays byte-identical to the old bare
|
||
/// path — so the common case keeps ONE upload and no pre-existing URL changed (decision 0763).
|
||
#[test]
|
||
fn sampler_mode_rides_the_asset_path_and_round_trips() {
|
||
let tex = "World\\KhazModan\\Ironforge\\PassiveDoodads\\Trees\\IronForgeleaves01.blp";
|
||
// The default is the bare path — unchanged from before this scheme existed.
|
||
let repeat = texture_url(tex, (true, true));
|
||
assert_eq!(
|
||
repeat,
|
||
"mpq://world/khazmodan/ironforge/passivedoodads/trees/ironforgeleaves01.blp"
|
||
);
|
||
assert_eq!(sampler_mode_of(&repeat), (true, true));
|
||
// Every other mode marks the STEM, so the `.blp` extension still selects the loader.
|
||
for wrap in [(false, false), (true, false), (false, true)] {
|
||
let url = texture_url(tex, wrap);
|
||
assert!(url.ends_with(".blp"), "extension must survive: {url}");
|
||
assert_ne!(url, repeat, "a marked mode is a distinct asset path");
|
||
assert_eq!(sampler_mode_of(&url), wrap, "round-trip {wrap:?}");
|
||
// ...and the marker comes back off for the archive lookup.
|
||
assert_eq!(
|
||
strip_sampler_marker(url.strip_prefix("mpq://").unwrap()).as_deref(),
|
||
Some("world/khazmodan/ironforge/passivedoodads/trees/ironforgeleaves01.blp"),
|
||
);
|
||
}
|
||
// A bare path has nothing to strip, and an unrelated `@` is not a marker.
|
||
assert_eq!(strip_sampler_marker("world/foo.blp"), None);
|
||
assert_eq!(strip_sampler_marker("world/foo@bar.blp"), None);
|
||
assert_eq!(sampler_mode_of("world/foo@bar.blp"), (true, true));
|
||
}
|
||
|
||
#[test]
|
||
fn mpq_reader_loads_real_client_bytes_through_the_assetreader() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let reader = MpqAssetReader::open(&data).expect("open mpq reader");
|
||
|
||
// A real file resolves and drains to its bytes through the Bevy AssetReader/Reader traits.
|
||
let bytes = block_on(async {
|
||
let mut r = AssetReader::read(&reader, Path::new("DBFilesClient/Spell.dbc"))
|
||
.await
|
||
.expect("read Spell.dbc via AssetReader");
|
||
let mut buf = Vec::new();
|
||
r.read_to_end(&mut buf).await.expect("drain reader");
|
||
buf
|
||
});
|
||
assert_eq!(&bytes[..4], b"WDBC", "Spell.dbc starts with the WDBC magic");
|
||
assert!(bytes.len() > 1_000_000, "Spell.dbc should be sizable");
|
||
|
||
// A missing path yields NotFound — the variant Bevy relies on for meta fallback.
|
||
let missing = block_on(AssetReader::read(&reader, Path::new("does/not/exist.blp")));
|
||
assert!(
|
||
matches!(missing, Err(AssetReaderError::NotFound(_))),
|
||
"missing path should be NotFound"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn loads_a_blp_image_through_the_full_mpq_pipeline() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
// Full pipeline, headless: mpq:// source → AssetServer → BlpImageLoader → Handle<Image>.
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
register_mpq_source(&mut app, &data).expect("register mpq source");
|
||
app.add_plugins(bevy::asset::AssetPlugin::default());
|
||
app.init_asset::<Image>();
|
||
register_asset_loaders(&mut app);
|
||
|
||
let handle: Handle<Image> = app
|
||
.world()
|
||
.resource::<AssetServer>()
|
||
.load("mpq://Interface/Icons/Spell_Holy_ArcaneIntellect.blp");
|
||
|
||
let mut got = None;
|
||
// Same generous ceiling as the WMO test below — parallel-session load starves the IO pool.
|
||
for _ in 0..15_000 {
|
||
app.update();
|
||
if let Some(img) = app.world().resource::<Assets<Image>>().get(&handle) {
|
||
got = Some((
|
||
img.width(),
|
||
img.height(),
|
||
img.texture_descriptor.mip_level_count,
|
||
));
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
let (w, h, mips) =
|
||
got.expect("the spell icon should load via mpq:// + BlpImageLoader within 300 ticks");
|
||
assert_eq!((w, h), (64, 64), "spell icon is 64x64");
|
||
assert!(
|
||
mips >= 1,
|
||
"authored mip levels should be present, got {mips}"
|
||
);
|
||
}
|
||
|
||
/// Regression guard: `load_with_settings(Sprite)` must reach the loader on the async `mpq://`
|
||
/// path so a streamed tile lands as `Rgba8UnormSrgb`, not the loader's `WorldArt` default
|
||
/// (`Rgba8Unorm`). The minimap streams its tiles this way (`minimap.rs`), and the UI pass's
|
||
/// contract is sRGB textures (linearize → multiply → re-encode); a silent regression to the
|
||
/// gamma-space default re-encodes every tile ~2× brighter — the decision-0178 over-bright class
|
||
/// of bug, but for the minimap. Skips without the vanilla client data.
|
||
#[test]
|
||
fn minimap_tile_settings_reach_the_async_loader() {
|
||
use bevy::render::render_resource::TextureFormat;
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
register_mpq_source(&mut app, &data).expect("register mpq source");
|
||
app.add_plugins(bevy::asset::AssetPlugin::default());
|
||
app.init_asset::<Image>();
|
||
register_asset_loaders(&mut app);
|
||
|
||
// IN ISOLATION — the real app (minimap.rs) only ever loads a tile via
|
||
// `load_with_settings(Sprite)`, never a bare `load()` of the same path. Loading both here
|
||
// would let Bevy's path-dedup hand the second call the first's settings, masking the truth.
|
||
let tile = "mpq://textures/Minimap/ea283abc0bf9637c3fad5e840a65b38b.blp";
|
||
let server = app.world().resource::<AssetServer>().clone();
|
||
let sprite_h: Handle<Image> =
|
||
server.load_with_settings(tile, |s: &mut BlpLoaderSettings| {
|
||
s.variant = BlpVariant::Sprite;
|
||
});
|
||
|
||
let mut sprite_fmt = None;
|
||
// Same generous ceiling as the WMO test below — parallel-session load starves the IO pool.
|
||
for _ in 0..15_000 {
|
||
app.update();
|
||
if let Some(img) = app.world().resource::<Assets<Image>>().get(&sprite_h) {
|
||
sprite_fmt = Some(img.texture_descriptor.format);
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
eprintln!("Sprite settings (isolated) -> {sprite_fmt:?}");
|
||
assert_eq!(
|
||
sprite_fmt,
|
||
Some(TextureFormat::Rgba8UnormSrgb),
|
||
"load_with_settings(Sprite) must produce an sRGB tile on the async mpq:// path"
|
||
);
|
||
}
|
||
|
||
/// The WMO interior-minimap tile grid (`crate::minimap::group_axis_grid`) verified against real
|
||
/// authored data: load Ironforge and check the per-group tile count each axis matches the
|
||
/// `md5translate.trs` ground truth — group 66 = 2×2, 44 = 1(X)×2(Y), 89 = 2(X)×1(Y), and the
|
||
/// small groups 1×1. Grounds the (RE-inferred) footprint→grid bake the interior renderer rests on.
|
||
#[test]
|
||
fn ironforge_group_grid_matches_trs() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
register_mpq_source(&mut app, &data).expect("mpq");
|
||
app.add_plugins(bevy::asset::AssetPlugin::default());
|
||
app.init_asset::<Image>();
|
||
app.init_asset::<Mesh>();
|
||
register_asset_loaders(&mut app);
|
||
let h: Handle<WmoModel> = app
|
||
.world()
|
||
.resource::<AssetServer>()
|
||
.load("mpq://World/wmo/KhazModan/Cities/Ironforge/Ironforge.wmo");
|
||
let mut model = None;
|
||
// A generous ~30 s ceiling (healthy runs finish in well under a second): the async IO
|
||
// pool gets starved when a parallel session runs its own full gates on this machine, and
|
||
// the old ~4 s budget flaked exactly then (2026-07-10, two concurrent workspace runs).
|
||
for _ in 0..15_000 {
|
||
app.update();
|
||
if let Some(m) = app.world().resource::<Assets<WmoModel>>().get(&h) {
|
||
model = Some(m.clone());
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
let m = model.expect("Ironforge loads");
|
||
// (group index, expected X-count, expected Y-count) — read off the trs tile names.
|
||
let grid = |ext: f32| crate::minimap_grid::group_axis_grid(ext).0;
|
||
for &(g, ex_n, ey_n) in &[
|
||
(1, 1, 1),
|
||
(2, 1, 1),
|
||
(10, 1, 1),
|
||
(44, 1, 2),
|
||
(66, 2, 2),
|
||
(89, 2, 1),
|
||
] {
|
||
let gn = m.group_nav.get(g).expect("group present");
|
||
let nx = grid(gn.bbox_max[0] - gn.bbox_min[0]);
|
||
let ny = grid(gn.bbox_max[1] - gn.bbox_min[1]);
|
||
assert_eq!((nx, ny), (ex_n, ey_n), "group {g} grid");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn loads_an_m2_model_through_the_pipeline() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
register_mpq_source(&mut app, &data).expect("register mpq source");
|
||
app.add_plugins(bevy::asset::AssetPlugin::default());
|
||
app.init_asset::<Image>();
|
||
app.init_asset::<Mesh>();
|
||
// The M2 loader emits labeled sub-assets the skinned + animated path needs (decision 0019): the
|
||
// inverse bind poses, plus the idle `AnimationClip` + `AnimationGraph`. The real app registers
|
||
// these via `DefaultPlugins` (bevy_mesh's `MeshPlugin` + `AnimationPlugin`); the minimal harness
|
||
// registers them here so the campfire's M2 (which has bones + a Stand sequence) loads.
|
||
app.init_asset::<bevy::mesh::skinning::SkinnedMeshInverseBindposes>();
|
||
app.init_asset::<bevy::animation::AnimationClip>();
|
||
app.init_asset::<bevy::animation::graph::AnimationGraph>();
|
||
register_asset_loaders(&mut app); // inits M2Model + registers BLP/M2 loaders
|
||
|
||
// A doodad with embedded (hardcoded) textures — the campfire (`.mdx` ref → `.m2` physical file).
|
||
let handle: Handle<M2Model> = app
|
||
.world()
|
||
.resource::<AssetServer>()
|
||
.load("mpq://World/Azeroth/Elwynn/PassiveDoodads/Campfire/ElwynnCampfire.m2");
|
||
|
||
let mut info = None;
|
||
for _ in 0..600 {
|
||
app.update();
|
||
if let Some(m) = app.world().resource::<Assets<M2Model>>().get(&handle) {
|
||
let geometries: Vec<_> = m.submeshes.iter().map(|s| s.geometry.clone()).collect();
|
||
let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count();
|
||
info = Some((geometries, textured, m.bounds.is_some()));
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
let (geometries, textured, has_bounds) =
|
||
info.expect("the campfire M2 should load via mpq:// + M2ModelLoader within 600 ticks");
|
||
assert!(
|
||
!geometries.is_empty(),
|
||
"campfire should have render batches"
|
||
);
|
||
assert!(has_bounds, "M2 carries authored bounds");
|
||
assert!(textured > 0, "campfire batches reference embedded textures");
|
||
|
||
// The loader ships geometry, no meshes (decision 0834) — the app builds the render form.
|
||
// Exercise both builders per batch: non-empty, and the static form must yield the Aabb
|
||
// the spawn side inserts explicitly (RENDER_WORLD meshes race `calculate_bounds`).
|
||
for g in &geometries {
|
||
let mesh = submesh_to_static_mesh(g);
|
||
assert!(mesh.count_vertices() > 0, "submesh has vertices");
|
||
assert!(mesh.compute_aabb().is_some(), "static form yields an Aabb");
|
||
let skinned = submesh_to_skinned_mesh(g);
|
||
assert_eq!(
|
||
skinned.count_vertices(),
|
||
mesh.count_vertices(),
|
||
"the skinned twin bakes the same geometry"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn loads_a_wmo_model_through_the_pipeline() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
register_mpq_source(&mut app, &data).expect("register mpq source");
|
||
app.add_plugins(bevy::asset::AssetPlugin::default());
|
||
app.init_asset::<Image>();
|
||
app.init_asset::<Mesh>();
|
||
register_asset_loaders(&mut app);
|
||
|
||
// The Goldshire Inn — a root + group WMO; the loader reads the groups via read_asset_bytes.
|
||
let handle: Handle<WmoModel> = app
|
||
.world()
|
||
.resource::<AssetServer>()
|
||
.load("mpq://World/wmo/Azeroth/Buildings/GoldshireInn/GoldshireInn.wmo");
|
||
|
||
let mut info = None;
|
||
for _ in 0..600 {
|
||
app.update();
|
||
if let Some(m) = app.world().resource::<Assets<WmoModel>>().get(&handle) {
|
||
let geometries: Vec<_> = m.submeshes.iter().map(|s| s.geometry.clone()).collect();
|
||
let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count();
|
||
info = Some((geometries, textured));
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
let (geometries, textured) =
|
||
info.expect("the Goldshire Inn WMO should load via mpq:// + WmoModelLoader");
|
||
assert!(
|
||
!geometries.is_empty(),
|
||
"the inn has render batches across its groups"
|
||
);
|
||
assert!(textured > 0, "WMO batches reference textures");
|
||
|
||
// Geometry, no meshes (decision 0834): the static build is the WMO's one render form.
|
||
for g in &geometries {
|
||
let mesh = submesh_to_static_mesh(g);
|
||
assert!(mesh.count_vertices() > 0, "group submesh has vertices");
|
||
assert!(mesh.compute_aabb().is_some(), "static form yields an Aabb");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn loads_an_adt_terrain_tile_through_the_pipeline() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
// Find an existing Elwynn-area Azeroth tile (don't hardcode exact coords).
|
||
let reader = benilla_formats::Chain::open(&data).expect("open chain");
|
||
let mut url = None;
|
||
'find: for tx in 28..36u32 {
|
||
for ty in 46..52u32 {
|
||
if reader.contains(&format!("World\\Maps\\Azeroth\\Azeroth_{tx}_{ty}.adt")) {
|
||
url = Some(format!("mpq://World/Maps/Azeroth/Azeroth_{tx}_{ty}.adt"));
|
||
break 'find;
|
||
}
|
||
}
|
||
}
|
||
let url = url.expect("an Azeroth ADT tile should exist near Elwynn");
|
||
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
register_mpq_source(&mut app, &data).expect("register mpq source");
|
||
app.add_plugins(bevy::asset::AssetPlugin::default());
|
||
app.init_asset::<Image>();
|
||
app.init_asset::<Mesh>();
|
||
register_asset_loaders(&mut app);
|
||
|
||
let handle: Handle<AdtTile> = app.world().resource::<AssetServer>().load(url);
|
||
let mut info = None;
|
||
for _ in 0..2000 {
|
||
app.update();
|
||
if let Some(t) = app.world().resource::<Assets<AdtTile>>().get(&handle) {
|
||
info = Some((
|
||
t.chunks
|
||
.iter()
|
||
.zip(&t.shading)
|
||
.map(|(c, s)| (c.clone(), *s))
|
||
.collect::<Vec<_>>(),
|
||
t.shading.len(),
|
||
t.layer_array.clone(),
|
||
t.alpha_array.clone(),
|
||
t.shadow_array.clone(),
|
||
t.doodads.len(),
|
||
));
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
let (cells, n_shading, layer_h, alpha_h, shadow_h, n_doodads) =
|
||
info.expect("the ADT tile should load via mpq:// + AdtLoader");
|
||
|
||
// One drawn mesh per MCNK cell, never one merged slab (decision 0780) — the exterior-scene
|
||
// cull's unit is the chunk, and a tile that loads as a single object cannot be culled from
|
||
// inside a building. The loader ships the shading (index-parallel with the chunks); the
|
||
// mesh itself is the app's paced `chunk_to_mesh` build — exercised per cell here. Every
|
||
// drawn cell must be a 145-vertex 9×9+8×8 grid.
|
||
assert_eq!(n_shading, cells.len(), "one ChunkShading per decoded chunk");
|
||
let drawn: Vec<Mesh> = cells
|
||
.iter()
|
||
.filter_map(|(c, s)| chunk_to_mesh(c, s))
|
||
.collect();
|
||
assert!(
|
||
(1..=256).contains(&drawn.len()),
|
||
"a tile draws as its MCNK cells, got {} meshes",
|
||
drawn.len()
|
||
);
|
||
for mesh in &drawn {
|
||
assert_eq!(
|
||
mesh.count_vertices(),
|
||
145,
|
||
"an MCNK cell is 9×9 + 8×8 vertices"
|
||
);
|
||
assert!(
|
||
mesh.compute_aabb().is_some(),
|
||
"a cell mesh must yield the Aabb the exterior cull fails open without"
|
||
);
|
||
}
|
||
|
||
let images = app.world().resource::<Assets<Image>>();
|
||
let layer = images.get(&layer_h).expect("layer array present");
|
||
assert_eq!(
|
||
layer.texture_descriptor.size.width, 256,
|
||
"layer array packed at LAYER_TEX_SIZE"
|
||
);
|
||
assert!(
|
||
layer.texture_descriptor.mip_level_count > 1,
|
||
"layer array carries the authored mip chain"
|
||
);
|
||
assert!(images.get(&alpha_h).is_some(), "alpha array present");
|
||
assert!(images.get(&shadow_h).is_some(), "shadow array present");
|
||
// Placement lists are carried as data (count is tile-dependent — some tiles are bare).
|
||
eprintln!(
|
||
"ADT tile loaded: {} MCNK cells, {n_doodads} doodad placements",
|
||
drawn.len()
|
||
);
|
||
}
|
||
}
|