Files
benilla-octo/crates/benilla-assets/src/lib.rs
T
Sam SungandClaude Opus 5.5 d69d09dfce Comments in benilla-assets: one line of fact each, no history, no record numbers (#95)
Part of #41: the comments of `benilla-assets`, Rust and WGSL, rewritten to the rule in `docs/METHOD.md`: what the code does and the reference fact behind it, in a line. 3,872 comment lines become 1,568.

- The citations that were only a private record number go first, mechanically, each edit shown to be a pure removal.
- The rewrite corrects the comments that stated wrong facts: the alpha-key cutoff now cites the reference's per-blend-mode table (`0x85ad20`), the texture-filter note names the subsystems that ignore the global setting, a test doc said an exhausted variation roll returns the last variation (it returns the first), and about twenty more found on the way (renamed or deleted items, a shader flag the doc misread, docs sitting on the wrong function). "Deviation:" now marks only a choice with a reason; an unbuilt behaviour says so plainly.
- Verified: every file strips to identical code, and `cargo fmt --check` is clean. A second reader compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference.

String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 16:27:58 +07:00

584 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `benilla-assets`: the WoW 1.12.1 MPQ patch chain as an `mpq://` Bevy asset source backed by
//! [`benilla_formats::Chain`], and the per-format loaders (BLP, M2, WMO, ADT, WDT) on top of it.
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;
mod anim_rng;
pub use anim_rng::AnimRng;
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 GPU takes 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, M2SequenceInfo, 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 MPQ patch chain; every clone shares the one open [`Chain`].
#[derive(Clone)]
pub struct MpqAssetReader {
chain: Arc<Chain>,
}
impl MpqAssetReader {
/// Open the patch chain from a `Data` directory or a single `.MPQ`.
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()))?;
// The sampler-mode marker (`texture_url`) names the asset, not the archive file.
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> {
// Loads are by explicit path; there is no directory listing.
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 in an `mpq://` texture URL.
const SAMPLER_MARKER: char = '@';
/// The `mpq://` URL of a model texture at a sampler address mode. Bevy keys the sampler to the
/// `Image` and the `Image` to its path, so a mode other than repeat/repeat marks the path before
/// the extension (`…/leaves01@cc.blp`), which must stay `.blp` to pick the loader.
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}"),
}
}
/// The `Map.dbc` [`MapCatalog`] (`mapId` to directory and `LoadingScreenID`) as a Bevy resource; a
/// newtype because the orphan rule forbids `Resource` on a foreign type.
#[derive(Resource)]
pub struct MapCatalogRes(pub benilla_formats::MapCatalog);
/// A model reference path (`.mdx`/`.mdl`, any case, backslashes) as its lowercase `mpq://…m2` URL:
/// the archive file is `.m2`, and one spelling per model keeps the handle dedup working.
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 as its `mpq://…wmo` URL, lowercased for handle dedup.
pub fn wmo_url(raw: &str) -> String {
format!("mpq://{}", raw.to_ascii_lowercase().replace('\\', "/"))
}
/// A creature skin variation's `mpq://` URL, `<model-dir>/<name>.blp`: the `Monster1/2/3` skins
/// live beside the model.
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),
}
}
/// The archive path under a [`texture_url`] marker; `None` when unmarked.
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://` source on `app`, backed by the patch chain at `data_dir`. Call it before
/// `AssetPlugin` builds (before `DefaultPlugins`), which reads the sources.
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`, after `AssetPlugin`, into its live `AssetServer`.
pub fn register_asset_loaders(app: &mut App) {
app.init_asset::<M2Model>();
app.init_asset::<WmoModel>();
app.init_asset::<AdtTile>();
app.init_asset::<WdtIndex>();
// A plugin: whether the device takes DXT blocks is known only after `RenderPlugin::finish`.
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 materials' WGSL, compiled in; it too needs `AssetPlugin` first.
materials::register_shaders(app);
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::camera::primitives::MeshAabb;
use bevy::tasks::block_on;
#[test]
fn sampler_mode_rides_the_asset_path_and_round_trips() {
let tex = "World\\KhazModan\\Ironforge\\PassiveDoodads\\Trees\\IronForgeleaves01.blp";
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 `.blp` still picks 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:?}");
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");
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 is `NotFound`, which Bevy's meta fallback relies on.
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!();
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;
// A generous ceiling: a parallel gate run 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}"
);
}
/// The minimap streams its tiles with `load_with_settings(Sprite)`; the UI pass wants sRGB, and
/// the `WorldArt` default would draw every tile about twice as bright.
#[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: a bare `load()` of the same path would share the handle and its settings.
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;
// A generous ceiling: a parallel gate run 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"
);
}
/// Ironforge's interior minimap tile counts per group, against `md5translate.trs`.
#[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;
// About 30 s, though a healthy run takes under one: a parallel gate run starves IO.
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, X tiles, Y tiles), 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 inverse bind poses, an `AnimationClip` and an `AnimationGraph`, which
// `DefaultPlugins` would register.
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);
// The campfire: a doodad with embedded textures.
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; the spawn side inserts the static form's Aabb itself, as
// `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 and its group files.
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");
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!();
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 mesh per drawn MCNK chunk, 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");
// Some tiles have no placements, so the count is only printed.
eprintln!(
"ADT tile loaded: {} MCNK cells, {n_doodads} doodad placements",
drawn.len()
);
}
}