- An honest correction first: the previous snapshot's message announced the owned skinning lane, but the code itself missed that snapshot by minutes — two sessions landed at once and the export carried the older tree. The skinning lane actually ships here, together with its follow-up. - The follow-up is the bone-entity collapse. A creature's ~75-bone skeleton existed as ~75 world entities, each paying transform propagation and bookkeeping every frame, to serve the ~5 bones anything actually hangs off (held weapons, glow emitters, the mount seat, overhead markers). A rig's pose now lives in plain arrays on the model itself; only the consumer anchor points remain as entities. At the crowded dungeon spot ~59,000 entities became ~28,000 anchors (80k total → 50k), palette uploads coalesce into a handful of writes, and another ~3 CPU ms came off the frame. - The movement echo the server sends about your own character is applied now, the way the original client always applied it — groundwork that GM flight cashes in later today. - The capture harness pins its clock per frame now, not just its frame count — a slow run used to drift every animation out from under a golden image. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
488 lines
21 KiB
Rust
488 lines
21 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 minimap_grid;
|
||
|
||
mod model;
|
||
pub use model::{
|
||
bone_target_id, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations,
|
||
ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip,
|
||
PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT,
|
||
};
|
||
mod adt;
|
||
mod terrain;
|
||
mod wdt;
|
||
pub use adt::{AdtLoader, AdtTile};
|
||
pub use wdt::{WdtIndex, WdtIndexLoader};
|
||
mod blp;
|
||
pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant};
|
||
mod m2;
|
||
pub use m2::{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 internal = path
|
||
.to_str()
|
||
.ok_or_else(|| AssetReaderError::NotFound(path.to_path_buf()))?;
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 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>();
|
||
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);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use bevy::tasks::block_on;
|
||
|
||
/// crates/benilla-assets -> repo root -> WoW/Data (gitignored; test skips when absent).
|
||
fn vanilla_data_dir() -> std::path::PathBuf {
|
||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
|
||
}
|
||
|
||
#[test]
|
||
fn mpq_reader_loads_real_client_bytes_through_the_assetreader() {
|
||
let data = vanilla_data_dir();
|
||
if !data.is_dir() {
|
||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||
return;
|
||
}
|
||
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 = vanilla_data_dir();
|
||
if !data.is_dir() {
|
||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||
return;
|
||
}
|
||
// 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 = vanilla_data_dir();
|
||
if !data.is_dir() {
|
||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||
return;
|
||
}
|
||
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 = vanilla_data_dir();
|
||
if !data.is_dir() {
|
||
eprintln!("skipping: no client data");
|
||
return;
|
||
}
|
||
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 = vanilla_data_dir();
|
||
if !data.is_dir() {
|
||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||
return;
|
||
}
|
||
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 meshes: Vec<Handle<Mesh>> =
|
||
m.submeshes.iter().map(|s| s.mesh.clone()).collect();
|
||
let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count();
|
||
info = Some((meshes, textured, m.bounds.is_some()));
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
let (meshes, textured, has_bounds) =
|
||
info.expect("the campfire M2 should load via mpq:// + M2ModelLoader within 600 ticks");
|
||
assert!(!meshes.is_empty(), "campfire should have render batches");
|
||
assert!(has_bounds, "M2 carries authored bounds");
|
||
assert!(textured > 0, "campfire batches reference embedded textures");
|
||
|
||
// Each submesh mesh sub-asset is present and non-empty (baked to Bevy space).
|
||
let mesh_assets = app.world().resource::<Assets<Mesh>>();
|
||
for h in &meshes {
|
||
let mesh = mesh_assets.get(h).expect("submesh mesh sub-asset present");
|
||
assert!(mesh.count_vertices() > 0, "submesh has vertices");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn loads_a_wmo_model_through_the_pipeline() {
|
||
let data = vanilla_data_dir();
|
||
if !data.is_dir() {
|
||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||
return;
|
||
}
|
||
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 meshes: Vec<Handle<Mesh>> =
|
||
m.submeshes.iter().map(|s| s.mesh.clone()).collect();
|
||
let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count();
|
||
info = Some((meshes, textured));
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
let (meshes, textured) =
|
||
info.expect("the Goldshire Inn WMO should load via mpq:// + WmoModelLoader");
|
||
assert!(
|
||
!meshes.is_empty(),
|
||
"the inn has render batches across its groups"
|
||
);
|
||
assert!(textured > 0, "WMO batches reference textures");
|
||
|
||
let mesh_assets = app.world().resource::<Assets<Mesh>>();
|
||
for h in &meshes {
|
||
let mesh = mesh_assets.get(h).expect("group submesh sub-asset present");
|
||
assert!(mesh.count_vertices() > 0, "group submesh has vertices");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn loads_an_adt_terrain_tile_through_the_pipeline() {
|
||
let data = vanilla_data_dir();
|
||
if !data.is_dir() {
|
||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||
return;
|
||
}
|
||
// 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.mesh.clone(),
|
||
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 (mesh_h, layer_h, alpha_h, shadow_h, n_doodads) =
|
||
info.expect("the ADT tile should load via mpq:// + AdtLoader");
|
||
|
||
let meshes = app.world().resource::<Assets<Mesh>>();
|
||
let vc = meshes
|
||
.get(&mesh_h)
|
||
.expect("merged terrain mesh present")
|
||
.count_vertices();
|
||
assert!(
|
||
vc > 0 && vc <= 256 * 145,
|
||
"merged tile mesh ≤ 256 chunks × 145 verts, got {vc}"
|
||
);
|
||
|
||
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: {vc} verts, {n_doodads} doodad placements");
|
||
}
|
||
}
|