Files
benilla-octo/crates/benilla-formats/src/lib.rs
T
SamandClaude Fable 5 d7fb26713c cinematics play, and a lit particle stops brightening as you turn
- cinematics play: `SMSG_TRIGGER_CINEMATIC` resolves through `CinematicSequences.dbc` → `CinematicCamera.dbc` → a `Cameras\*.m2` whose camera record is an eye path, a look-at path and a roll — cubic tracks with the full step / linear / Bézier / Hermite dispatch the bone loops collapse to step-or-linear (every shipped fly-by is Bézier), planted at the row's origin and facing (`Rz(+facing)`, checked against vmangos's own waypoint table: 8.5 yd mean on the human path, 154 with the other sign); FOV is per shot (the Undead intro authors 90°, the rest 45°), roll is authored around whole turns, the flight ends at the sequence band's end, a trigger arriving before the loading cover lifts is latched and started when it does, ESC skips through `CinematicFrame`'s own `OnKeyDown` (`StopCinematic` has zero native callers), and every path acks — natural end, ESC, or an unresolvable trigger immediately, as before; `.debug play cinematic <id>` replays any of them
- a cinematic hides the HUD — `UIParent:Hide()` reaches nothing here because the HUD frames are not its children, so the ALT-Z draw-level hide is used and the letterbox moves out of FrameXML onto Bevy UI nodes where that hide cannot reach it, recomputed per frame (bars only wider than 4:3, the picture cropped to 2:1) — and the restore only un-hides a UI this module hid
- a lit particle's normal is world up, one constant per draw, not camera-facing: Onyxia's always-on floor haze read about four times brighter from a level camera than from above because the shader dotted a to-eye normal against a fixed light; the reference uploads row 2 of the view matrix — world +Z carried into eye space — on every vertex and carries the light into the same frame, so the rotation cancels; 400 lit emitters (the waterfalls, chimney and campfire smoke, Westfall dust, Dun Morogh snow) stop brightening and dimming as you turn
- the `play` profile drops ThinLTO — rustc #86049 was intermittently poisoning the incremental link with `_anon.<hash>.llvm.<id>` symbols promoted by an earlier compile (48 unresolved across 7 codegen units; 1065 such symbols → 0 after) — and `benilla-formats` stops taking `image` with default features, which had been compiling a complete AV1 encoder into a crate that writes two PNGs: 499 → 466 workspace crates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:24:50 +07:00

842 lines
37 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-formats` — adapters and CLI tooling over WoW 1.12.1 (build 5875) asset formats.
//!
//! Every asset format is **in-repo** (decision 0021): MPQ archives (`benilla-mpq`), BLP2 textures
//! (`benilla-blp`), DBC tables (`benilla-dbc`), WDT tile tables (`benilla-wdt`), ADT terrain
//! (`benilla-adt`), WMO buildings (`benilla-wmo`), and M2 models (`benilla-m2`). This crate adds our
//! schema layer, the patch-chain helper ([`Chain`]), and the `benilla-extract` CLI, and keeps data in
//! **raw WoW coordinates** (the Bevy transform lives in `benilla`).
//!
//! Phase 1: MPQ list/extract, BLP -> PNG, DBC -> CSV.
use std::io::Cursor;
use std::path::Path;
use anyhow::{Context, Result};
use benilla_dbc::{DbcParser, FieldType, Schema, SchemaField};
/// The BLP texel forms, re-exported so a consumer of [`BlpMipChain`] can switch on
/// [`BlpMipChain::texels`] without depending on `benilla-blp` directly.
pub use benilla_blp::BlpTexels;
mod chain;
pub use chain::{Chain, ChainEntry};
/// The UI texture table's other half — loose `.tga` art (addon folders; decision 1322).
mod tga;
pub use tga::tga_to_rgba;
/// Where the WoW install is — the one resolver (decision 1175). Paired with [`Chain`]: this says
/// *where*, that opens it.
mod install;
pub use install::{candidates, wow_data};
mod characters;
pub use characters::{
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile,
CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EquipBlit, EquipGeosets,
StartOutfitItem,
};
mod camera_shakes;
mod cinematics;
mod creatures;
mod dbc;
mod unit_blood;
pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog};
pub use cinematics::{
camera_model_path, load_cinematics, CinematicCameraRow, CinematicCatalog, CinematicPath,
CinematicSequence, CinematicView, SEQUENCE_CAMERAS,
};
pub use creatures::{
load_creature_catalog, CreatureCatalog, CreatureModel, FootprintParams, NpcAppearance,
};
pub use macro_icons::load_macro_icons;
mod macro_icons;
pub use unit_blood::{load_blood_catalog, BloodCatalog};
mod itemsets;
pub use itemsets::{load_item_sets, ItemSetCatalog, ItemSetInfo};
mod gm_ticket_category;
pub use gm_ticket_category::{
load_gm_ticket_categories, GmTicketCategory, GmTicketCategoryCatalog,
};
mod itembagfamily;
pub use itembagfamily::{load_item_bag_families, ItemBagFamilyCatalog};
mod itemclass;
pub use itemclass::{load_item_classes, ItemClassCatalog};
mod auction_house;
pub use auction_house::{load_auction_houses, AuctionHouseCatalog, AuctionHouseInfo};
mod itemsubclass;
pub use itemsubclass::{load_item_sub_classes, ItemSubClassCatalog, ItemSubClassInfo};
mod factions;
pub use factions::{
faction_flags, load_faction_catalog, reputation_rank, FactionCatalog, FactionInfo,
FactionTemplate, Reaction,
};
mod creature_types;
pub use creature_types::{load_creature_type_flags, CreatureTypeFlags};
/// The chat language scramble — the reference's `0x49b560` (B262). Reads [`LanguageWords`].
mod garble;
pub use garble::{garble, garble_chat, Garble, FLUENT_SKILL};
mod languages;
pub use languages::{
load_default_languages, load_language_words, DefaultLanguages, LanguagePool, LanguageWords,
};
mod creature_families;
pub use creature_families::{
load_creature_families, load_pet_food_names, CreatureFamilies, CreatureFamily, PetFoodNames,
};
mod gameobjects;
pub use gameobjects::{
go_sound_slot, load_gameobject_catalog, load_gameobject_sounds, GameObjectCatalog,
GameObjectSounds,
};
mod durability;
pub use durability::{load_durability_tables, DurabilityTables};
mod exhaustion;
pub use exhaustion::{load_exhaustion, ExhaustionRow};
mod bank_bag_slot_prices;
pub use bank_bag_slot_prices::{load_bank_bag_slot_prices, BankBagSlotPrices};
mod stable_slot_prices;
pub use stable_slot_prices::{load_stable_slot_prices, StableSlotPrices};
mod page_text_material;
pub use page_text_material::{load_page_text_material_catalog, PageTextMaterialCatalog};
mod stationery;
pub use stationery::{load_stationery_catalog, StationeryCatalog, STATIONERY_DEFAULT};
mod lock;
pub use lock::{
load_lock_catalog, LockCatalog, LockSlot, GO_STATE_ACTIVE, GO_STATE_ACTIVE_ALTERNATIVE,
GO_STATE_READY, LOCK_KEY_ITEM, LOCK_KEY_NONE, LOCK_KEY_SKILL, MAX_LOCK_SLOTS,
};
mod lock_type;
pub use lock_type::{load_lock_type_catalog, LockTypeCatalog};
mod pet_name_token;
pub use pet_name_token::{load_pet_name_tokens, PetNameTokens, PET_NAME_TOKEN_FALLBACK};
mod pet_stats;
pub use pet_stats::{
load_pet_loyalty_names, load_pet_personalities, PetHappiness, PetLoyaltyNames,
PetPersonalities, PetPersonality, FALLBACK_PERSONALITY,
};
mod items;
pub use items::{load_item_display_catalog, ItemDisplay, ItemDisplayCatalog};
mod item_sounds;
pub use item_sounds::{load_item_group_sounds, ItemGesture, ItemGroupSoundsCatalog};
mod item_visuals;
pub use item_visuals::{
load_enchant_catalog, load_item_visual_catalog, EnchantCatalog, ItemVisualCatalog,
ITEM_VISUAL_SLOTS,
};
mod item_random_properties;
pub use item_random_properties::{
load_random_property_catalog, RandomProperty, RandomPropertyCatalog,
RANDOM_PROPERTY_FIRST_SLOT, RANDOM_PROPERTY_SLOTS,
};
mod ground_effects;
pub use ground_effects::{
load_ground_effect_catalog, scatter_ground_doodads, GroundDoodadPlacement, GroundEffect,
GroundEffectCatalog,
};
mod light;
pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
mod loading_screen;
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
mod liquid;
pub use liquid::{LiquidKind, LiquidMesh};
mod maps;
pub use maps::{load_map_catalog, MapCatalog};
mod anim_data;
pub use anim_data::{load_anim_data_catalog, AnimDataCatalog, AnimEntry};
mod quest_headers;
pub use quest_headers::{load_quest_header_names, QuestHeaderNames};
mod area_trigger;
pub use area_trigger::{load_area_trigger_catalog, AreaTriggerCatalog, AreaTriggerRow};
mod area_sound;
pub use area_sound::{
load_area_sound_catalog, AreaAudio, AreaEntry, AreaSoundCatalog, SoundAmbienceEntry,
ZoneIntroEntry, ZoneMusicEntry,
};
mod creature_sound;
pub use creature_sound::{load_creature_voice_catalog, CreatureVoice, CreatureVoiceCatalog};
mod npc_greeting;
pub use npc_greeting::{load_npc_greeting_catalog, NpcGreeting, NpcGreetingCatalog};
mod emotes;
pub use emotes::{load_emote_sound_catalog, EmoteSoundCatalog};
mod emote_text;
pub use emote_text::{load_emote_text_catalog, EmoteLine, EmoteTextCatalog};
mod environmental_damage;
pub use environmental_damage::{load_environmental_damage, EnvironmentalDamageTable};
mod footsteps;
pub use footsteps::{load_footprint_textures, load_footstep_catalog, FootstepCatalog};
mod sound_entries;
pub use sound_entries::{load_sound_kit_catalog, sound_kit_flags, SoundKit, SoundKitCatalog};
mod sound_provider;
pub use sound_provider::{load_sound_provider_catalog, SoundProvider, SoundProviderCatalog};
mod sound_water;
pub use sound_water::{load_water_sound_catalog, WaterSoundCatalog};
mod weapon_impact;
pub use weapon_impact::{
impact_slot, load_weapon_impact_catalog, WeaponImpactCatalog, WeaponImpactRow,
};
mod sheathe;
pub use sheathe::{load_sheathe_sound_catalog, SheatheSoundCatalog, SheatheSounds};
mod weapon_swing;
pub use weapon_swing::{load_weapon_swing_catalog, WeaponSwingCatalog};
mod material;
pub use material::{load_material_catalog, MaterialCatalog};
mod wmo_area;
pub use wmo_area::{load_wmo_area_catalog, WmoArea, WmoAreaCatalog};
mod spells;
pub use spells::{
load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types,
load_spell_durations, load_spell_radii, load_spell_ranges, substitute, FormRefusal, OpenLock,
ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes,
SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
ATTR_ONLY_STEALTHED, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM,
SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL,
SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING,
SPELL_EFFECT_TRADE_SKILL,
};
mod skill_lines;
pub use skill_lines::{
load_skill_line_catalog, SkillLineCatalog, SkillLineInfo, SkillRaceClass, SlaInfo,
};
mod spell_focus;
pub use spell_focus::{load_spell_focus_catalog, SpellFocusCatalog};
mod talents;
pub use talents::{load_talent_catalog, Talent, TalentCatalog, TalentTabInfo, MAX_TALENT_RANK};
mod spell_visual;
pub use spell_visual::{
char_proc_small_int, char_proc_type, load_spell_visual_catalog, ChainEffect, ChainProc,
CharProc, SpellVisualCatalog, VisualKit, VisualStages, CHAIN_MAX_BEAMS, KIT_CHAR_PROCS,
KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, WORLD_EFFECT_TAG,
};
mod emit_timing;
pub use emit_timing::{EmitParams, EmitTiming, ParamsNow};
mod particles;
pub use particles::{
parse_m2_particle_emitters, CellRamp, OverLife, OverLifeSample, ParticleBlend,
ParticleEmitterDef, ParticleShape, SplineData,
};
mod ribbons;
pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef, RibbonVisibility};
mod value_track;
pub use value_track::{TrackValue, ValueTrack};
mod models;
pub use models::{
accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision,
accumulate_wmo_group_collision, authored_half_height, batch_footprint, glue_art_extent,
hand_grip_finger_poses, load_m2_animation_summary, load_m2_bone_spins, load_m2_bounds,
load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model, load_wmo,
load_wmo_collision_tris, m2_bone_spins, m2_owner_reach, m2_ribbon_emitter_count,
m2_sequence_visible_textures, m2_texture_transform_count, non_separable_billboard_bones,
owner_last_rung, owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary,
parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera,
parse_m2_cch_marker, parse_m2_collision_hull, parse_m2_event_markers,
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
shipped_glue_art_extent, wmo_group_doodad_refs, wmo_group_fixed_colors,
wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh,
wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaMap, AlphaSeq,
AnimEvent, ArtExtent, BatchFootprint, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
BoneSpin, CharSkinSlot, CollisionMesh, Coverage, CoverageReader, EmitterBoneLink, EventMarker,
FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary,
M2Attachment, M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm,
ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton,
SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID,
OWNER_RUNG_BUCKETS,
};
mod terrain;
pub use terrain::{
adt_to_tile_mesh, area_id_at, find_tile_near, ground_effect_at, impassable_at, load_tile_mesh,
load_tiles_around, mcsh_shadowed_at, terrain_height_at, triangle_z_at, ChunkMesh, Doodad,
MapTiles, TileMesh, WmoInstance, ALPHA_MAP_SIZE, CHUNK_SIZE, SHADOW_MAP_SIZE, STORMWIND_XY,
TILE_SIZE,
};
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_chunk, 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.
mod minimap_translate;
pub use minimap_translate::{load_minimap_translate, MinimapTranslate};
mod world_map_area;
pub use world_map_area::{load_world_map_area_catalog, WorldMapArea, WorldMapAreaCatalog};
mod world_map_overlay;
pub use world_map_overlay::{
load_world_map_overlay_catalog, WorldMapOverlay, WorldMapOverlayCatalog,
};
mod world_map_continent;
pub use world_map_continent::{
load_world_map_continent_catalog, WorldMapContinent, WorldMapContinentCatalog,
};
mod area_poi;
pub use area_poi::{load_area_poi_catalog, AreaPoi, AreaPoiCatalog};
mod world_state_ui;
pub use world_state_ui::{load_world_state_ui_catalog, WorldStateUiCatalog, WorldStateUiRow};
mod area_table;
pub use area_table::{load_area_table_catalog, AreaTableCatalog, AreaTableRow};
mod chat_channels;
pub use chat_channels::{
flags as chat_channel_flags, load_chat_channels_catalog, ChatChannelRow, ChatChannelsCatalog,
};
mod race_sound;
pub use race_sound::{load_exploration_sound_catalog, ExplorationSoundCatalog};
mod zone_map;
pub use zone_map::{load_zone_map, ZONE_MAP_EDGE};
// The transport arc (decision 0438), phase 0: the taxi/transport path DBC adapter + timetable.
mod taxi;
pub use taxi::{load_taxi_path_nodes, TaxiPathNode, TaxiPathNodes};
mod transport_period;
mod transports;
pub use transports::{TransportSample, TransportTimetable};
// The transport arc phase 3's second consumer: type-11 elevator/lift keyframe paths.
mod elevators;
pub use elevators::{
elevator_period_ms, elevator_sample, load_elevator_paths, ElevatorKeyframe, ElevatorPaths,
};
// The taxi arc (decision 0484), phase 1: the flight-master catalogs — node identity/position/
// per-team mount (`TaxiNodes.dbc`) and the direct-hop fare table (`TaxiPath.dbc`). Distinct from
// `taxi`/`TaxiPathNode.dbc` above, which carries a path's waypoints, not its node list or price.
mod taxi_nodes;
pub use taxi_nodes::{load_taxi_nodes, TaxiNode, TaxiNodes};
mod taxi_path;
pub use taxi_path::{load_taxi_paths, TaxiPath, TaxiPaths};
/// The ten vanilla base content archives, **lowest priority first** — the reference mounter's
/// table (`0x82e12c`) at its carved fixed priorities (`dbc.MPQ` = 0x36 … `model.MPQ` = 0x3f),
/// reversed so [`Chain`]'s later-wins order reproduces them (decision 1300).
///
/// This is only the *base* set: `patch.MPQ`, the `patch-?.MPQ` archives, and the optional
/// `speech2.MPQ` are **discovered**, not listed — the whole mount law lives in [`Chain::open`].
/// `base.MPQ` is deliberately absent: the reference opens it once for `telemetry.dat` and closes
/// it before the mounter runs, so its contents are unreachable as assets (wow-re, VERIFIED).
///
/// The base archives hold the bulk of the data but carry **no `(listfile)`**; the master
/// `(listfile)` (and most overrides) live in the patch archives, which is why the chain — not a
/// single archive — is the correct unit to read from. Verified against the build-5875 client
/// (`patch.MPQ` listfile = 26,350 entries).
pub const VANILLA_BASE_ORDER: &[&str] = &[
"dbc.MPQ",
"speech.MPQ",
"fonts.MPQ",
"interface.MPQ",
"misc.MPQ",
"sound.MPQ",
"wmo.MPQ",
"terrain.MPQ",
"texture.MPQ",
"model.MPQ",
];
/// Open a [`Chain`] from either a single `.MPQ` file or a vanilla `Data` directory. Thin wrapper over
/// [`Chain::open`], kept as the established entry point used across the crate and the CLI.
pub fn open_chain(path: &Path) -> Result<Chain> {
Chain::open(path)
}
/// Decode a BLP texture from raw archive bytes and write it as a PNG at `out`.
///
/// Returns the source `(width, height)`. Used by the `benilla-extract` CLI. **1.12 textures are BLP2**
/// (DXT-compressed or paletted Raw1 — NOT BLP1; verified). Decoded in-repo by [`benilla_blp`]
/// (decision 0021); mip level 0 (full resolution) is written.
pub fn blp_to_png(blp_bytes: &[u8], out: &Path) -> Result<(u32, u32)> {
let (w, h, rgba) = blp_to_rgba(blp_bytes)?;
image::RgbaImage::from_raw(w, h, rgba)
.ok_or_else(|| anyhow::anyhow!("BLP RGBA buffer size mismatch"))?
.save(out)
.with_context(|| format!("writing PNG {}", out.display()))?;
Ok((w, h))
}
/// Decode a BLP texture (raw bytes) to RGBA8: `(width, height, pixels)`.
///
/// For feeding GPU textures (the renderer) without a disk round-trip; mip level 0.
pub fn blp_to_rgba(blp_bytes: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
let decoded =
benilla_blp::decode(blp_bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?;
let level0 = decoded
.mips
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("BLP has no level 0"))?;
Ok((level0.width, level0.height, level0.rgba))
}
/// Read a texture from the chain by internal path (accepts `/` or `\`) and decode to RGBA8.
pub fn read_texture_rgba(chain: &mut Chain, path: &str) -> Result<(u32, u32, Vec<u8>)> {
let name = path.replace('/', "\\");
let bytes = chain
.read_file(&name)
.with_context(|| format!("reading texture '{name}'"))?;
blp_to_rgba(&bytes)
}
/// A BLP's authored mip pyramid, mip-major, level 0 first.
/// **Never empty**: the chain length is `BlpHeader::mipmaps_count().max(1)` — a no-mipmaps BLP (the
/// count formula reports 0) still yields a 1-level chain (level 0), matching `benilla_blp::decode`'s
/// own guarantee that `mips` is never empty (see [`blp_bytes_to_mip_chain`]).
///
/// [`texels`](Self::texels) says what the buffers hold. [`blp_bytes_to_mip_chain`] always decodes to
/// `Rgba8Unorm`; [`blp_bytes_to_native_chain`] keeps DXTC blocks verbatim for a GPU that can eat
/// them. A consumer that reads texels on the CPU must use the former (or check `texels`).
#[derive(Debug, Clone)]
pub struct BlpMipChain {
/// Mip-0 dimensions; level i's dims are `(width >> i).max(1) × (height >> i).max(1)`.
pub width: u32,
pub height: u32,
/// What every entry of `mips` holds — decoded pixels, or S3TC blocks.
pub texels: BlpTexels,
/// One buffer per authored mip level, level 0 first. **Authored, not regenerated** —
/// the BLP's pre-baked mip levels are what the real 1.12 client uploads ("the BLP's own stored mip
/// chain is used verbatim — no client-side mip regeneration"). Using these instead of
/// CPU-resampling mip 0 in gamma byte space is the
/// C2 fix: gamma-byte averaging of texels darkens mid-tones (the historical "dim/cool"
/// terrain tone gap).
pub mips: Vec<Vec<u8>>,
}
impl BlpMipChain {
/// Width × height of the mip level `i`, clamped to 1×1 minimum (wgpu / GL convention).
pub fn mip_size(&self, i: u32) -> (u32, u32) {
((self.width >> i).max(1), (self.height >> i).max(1))
}
/// Are these levels CPU-readable `Rgba8Unorm` pixels? The guard for any consumer that indexes
/// into a level (resample, alpha stomp, colour read) — block bytes are not pixels.
pub fn is_rgba8(&self) -> bool {
!self.texels.is_block_compressed()
}
/// This chain with every level decoded to `Rgba8Unorm`; a no-op if it already is.
///
/// The escape hatch for a consumer that asked for the passthrough form and then could not use
/// it — it costs the decode it was trying to avoid, but it needs no second read of the file and
/// it cannot fail, so the caller is never left holding blocks it must not upload.
pub fn into_rgba8(self) -> Self {
if self.is_rgba8() {
return self;
}
let mips = self
.mips
.iter()
.enumerate()
.map(|(i, level)| {
let (w, h) = self.mip_size(i as u32);
benilla_blp::decode_level(self.texels, w, h, level)
})
.collect();
Self {
texels: BlpTexels::Rgba8Unorm,
mips,
..self
}
}
}
/// Decode every authored mip level of a BLP texture from the chain into `Rgba8Unorm` buffers.
///
/// One RGBA8 allocation per mip level (8 for a 256² with 8 mips). Caller is responsible for any
/// further processing (concatenation, upload, BCn re-encode). The mip pyramid is the BLP's own,
/// not regenerated — the C2-faithful behaviour.
pub fn read_texture_mip_chain(chain: &mut Chain, path: &str) -> Result<BlpMipChain> {
let name = path.replace('/', "\\");
let bytes = chain
.read_file(&name)
.with_context(|| format!("reading texture '{name}'"))?;
blp_bytes_to_mip_chain(&bytes).with_context(|| format!("decoding texture '{name}'"))
}
/// Read a texture from the chain by internal path and keep its DXTC blocks verbatim —
/// [`blp_bytes_to_native_chain`]'s chain-reading wrapper, the twin of [`read_texture_mip_chain`].
pub fn read_texture_native_chain(chain: &mut Chain, path: &str) -> Result<BlpMipChain> {
let name = path.replace('/', "\\");
let bytes = chain
.read_file(&name)
.with_context(|| format!("reading texture '{name}'"))?;
blp_bytes_to_native_chain(&bytes).with_context(|| format!("decoding texture '{name}'"))
}
/// Decode every authored mip level of an **in-memory** BLP into `Rgba8Unorm` buffers — the BLP's own
/// stored pyramid, used verbatim (no client-side regeneration; the C2-faithful behaviour). This is the
/// bytes-in entry point used by the Bevy `AssetLoader`; [`read_texture_mip_chain`] is the
/// chain-reading wrapper.
pub fn blp_bytes_to_mip_chain(bytes: &[u8]) -> Result<BlpMipChain> {
let decoded = benilla_blp::decode(bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?;
// `decode` guarantees `mips` is never empty: it always decodes level 0 regardless of the
// authored mip-chain count (`benilla_blp::decode`, `n = chain.clamp(1, 16)`, so the loop that
// fills `mips` runs at least once) — a no-mipmaps BLP reports `mip_chain_count() == 0` but still
// carries `mips[0]`. Take at least that one level: the chain we return is never empty, so a
// mipmap-less BLP yields a 1-level chain here, not zero.
let (width, height, count) = (
decoded.width,
decoded.height,
decoded.mip_chain_count().max(1),
);
let mips: Vec<Vec<u8>> = decoded
.mips
.into_iter()
.take(count)
.map(|m| m.rgba)
.collect();
Ok(BlpMipChain {
width,
height,
texels: BlpTexels::Rgba8Unorm,
mips,
})
}
/// Every authored mip level of an in-memory BLP **with its DXTC blocks kept verbatim** — the form
/// the reference client uploads (`glCompressedTexImage2DARB`; wow-re `system/image/image.md`).
///
/// Raw1/Raw3 BLPs have no block form and come back decoded, reporting
/// [`BlpTexels::Rgba8Unorm`] — so a caller switches on [`BlpMipChain::texels`] rather than
/// assuming. Levels are padded to whole blocks, so the concatenated chain is exactly what wgpu
/// expects for the reported format.
pub fn blp_bytes_to_native_chain(bytes: &[u8]) -> Result<BlpMipChain> {
let decoded =
benilla_blp::decode_native(bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?;
// Same floor-at-one reasoning as `blp_bytes_to_mip_chain`.
let (width, height, count) = (
decoded.width,
decoded.height,
decoded.mip_chain_count().max(1),
);
let texels = decoded.texels;
let mips: Vec<Vec<u8>> = decoded
.mips
.into_iter()
.take(count)
.map(|m| m.bytes)
.collect();
Ok(BlpMipChain {
width,
height,
texels,
mips,
})
}
/// A hand-defined schema for a known 1.12 DBC, keyed by base filename, or `None`.
///
/// DBC files carry no column types, so the schema is ours to supply (from wowdev.wiki +
/// the verified file header). Vanilla localized strings occupy **9 dwords** (8 locale
/// offsets + 1 flags); only the enUS slot is populated in an enUS client. As we need more
/// DBCs (Map, AreaTable, ...) add them here.
fn schema_for(dbc_name: &str) -> Option<Schema> {
let base = dbc_name.rsplit(['/', '\\']).next().unwrap_or(dbc_name);
// Tables whose schema a typed loader in this crate already declares: reuse *that* constructor
// rather than re-transcribing the layout here, so a dump can never disagree with what the client
// actually reads (the six hand-written schemas below predate any loader for their table).
// The appearance family is registered because "which row does this NPC render from?" is a
// recurring question; the crate has ~30 more loader schemas that could join this list one line
// at a time as they are needed.
for (name, ctor) in [
(
"CreatureDisplayInfo.dbc",
creatures::creature_display_info_schema as fn() -> Schema,
),
(
"CreatureDisplayInfoExtra.dbc",
creatures::creature_display_info_extra_schema,
),
(
"CreatureModelData.dbc",
creatures::creature_model_data_schema,
),
("CharHairGeosets.dbc", characters::char_hair_geosets_schema),
(
"CharacterFacialHairStyles.dbc",
characters::char_facial_hair_schema,
),
("HelmetGeosetVisData.dbc", characters::helmet_vis_schema),
("CharSections.dbc", characters::char_sections_schema),
("ItemDisplayInfo.dbc", items::item_display_info_schema),
("ItemVisuals.dbc", item_visuals::item_visuals_schema),
(
"ItemVisualEffects.dbc",
item_visuals::item_visual_effects_schema,
),
(
"SpellItemEnchantment.dbc",
item_visuals::spell_item_enchantment_schema,
),
(
"ItemRandomProperties.dbc",
item_random_properties::item_random_properties_schema,
),
("AreaTrigger.dbc", area_trigger::area_trigger_schema),
] {
if base.eq_ignore_ascii_case(name) {
return Some(ctor());
}
}
if base.eq_ignore_ascii_case("TaxiNodes.dbc") {
// 16 fields (record_size 64), verified against build 5875.
let mut s = Schema::new("TaxiNodes");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("MapID", FieldType::UInt32));
s.add_field(SchemaField::new("X", FieldType::Float32));
s.add_field(SchemaField::new("Y", FieldType::Float32));
s.add_field(SchemaField::new("Z", FieldType::Float32));
s.add_field(SchemaField::new("Name", FieldType::String)); // enUS (locale 0)
s.add_field(SchemaField::new_array(
"NameOtherLocales",
FieldType::String,
7,
));
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
s.add_field(SchemaField::new("MountIdHorde", FieldType::UInt32));
s.add_field(SchemaField::new("MountIdAlliance", FieldType::UInt32));
s.set_key_field("ID");
return Some(s);
}
if base.eq_ignore_ascii_case("AreaTable.dbc") {
// 25 fields (record_size 100), verified against build 5875 (the layout
// `area_table.rs` / `area_sound.rs` load from).
let mut s = Schema::new("AreaTable");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("ContinentID", FieldType::UInt32));
s.add_field(SchemaField::new("ParentAreaID", FieldType::UInt32));
s.add_field(SchemaField::new("AreaBit", FieldType::UInt32));
s.add_field(SchemaField::new("Flags", FieldType::UInt32));
s.add_field(SchemaField::new("SoundProviderPref", FieldType::UInt32));
s.add_field(SchemaField::new(
"SoundProviderPrefUnderwater",
FieldType::UInt32,
));
s.add_field(SchemaField::new("AmbienceID", FieldType::UInt32));
s.add_field(SchemaField::new("ZoneMusic", FieldType::UInt32));
s.add_field(SchemaField::new("IntroSound", FieldType::UInt32));
s.add_field(SchemaField::new("ExplorationLevel", FieldType::UInt32));
s.add_field(SchemaField::new("AreaName", FieldType::String)); // enUS (locale 0)
s.add_field(SchemaField::new_array(
"AreaNameOtherLocales",
FieldType::String,
7,
));
s.add_field(SchemaField::new("AreaNameFlags", FieldType::UInt32));
s.add_field(SchemaField::new("FactionGroupMask", FieldType::UInt32));
s.add_field(SchemaField::new_array("LiquidTypeID", FieldType::UInt32, 4));
s.set_key_field("ID");
return Some(s);
}
if base.eq_ignore_ascii_case("GameObjectDisplayInfo.dbc") {
// 12 fields (ID, ModelName, Sound[10]) — the layout `gameobjects.rs`'s own typed adapter
// loads from (verified build 5875).
let mut s = Schema::new("GameObjectDisplayInfo");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("ModelName", FieldType::String));
for i in 0..10 {
s.add_field(SchemaField::new(format!("Sound{i}"), FieldType::UInt32));
}
s.set_key_field("ID");
return Some(s);
}
if base.eq_ignore_ascii_case("TaxiPath.dbc") {
// 4 fields: ID, FromTaxiNode, ToTaxiNode, Cost — verified build 5875.
let mut s = Schema::new("TaxiPath");
for name in ["ID", "FromTaxiNode", "ToTaxiNode", "Cost"] {
s.add_field(SchemaField::new(name, FieldType::UInt32));
}
s.set_key_field("ID");
return Some(s);
}
if base.eq_ignore_ascii_case("TaxiPathNode.dbc") {
// 9 fields (ID, PathID, NodeIndex, MapID, LocX/Y/Z, Flags, Delay) — the layout
// `taxi.rs`'s own typed adapter loads from (decision 0438 phase 0, verified build 5875).
let mut s = Schema::new("TaxiPathNode");
for name in ["ID", "PathID", "NodeIndex", "MapID"] {
s.add_field(SchemaField::new(name, FieldType::UInt32));
}
for name in ["LocX", "LocY", "LocZ"] {
s.add_field(SchemaField::new(name, FieldType::Float32));
}
for name in ["Flags", "Delay"] {
s.add_field(SchemaField::new(name, FieldType::UInt32));
}
s.set_key_field("ID");
return Some(s);
}
if base.eq_ignore_ascii_case("TransportAnimation.dbc") {
// 7 fields: ID, TransportID, TimeIndex, PosX, PosY, PosZ, SequenceID — matches vmangos's
// `TransportAnimationEntry` (`DBCStructure.h:709-718`), which only reads the middle five
// (TransportEntry/TimeSeg/X/Y/Z) but the row itself carries all 7 (its own `Id` and
// `MovementId` columns are commented out there, not absent from the file).
let mut s = Schema::new("TransportAnimation");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("TransportID", FieldType::UInt32));
s.add_field(SchemaField::new("TimeIndex", FieldType::UInt32));
for name in ["PosX", "PosY", "PosZ"] {
s.add_field(SchemaField::new(name, FieldType::Float32));
}
s.add_field(SchemaField::new("SequenceID", FieldType::UInt32));
s.set_key_field("ID");
return Some(s);
}
None
}
/// Parse a DBC (raw archive bytes) using our schema for it and write it as CSV with
/// column headers. Returns `(record_count, field_count)`. Errors if we have no schema for
/// the named DBC (with a hint), so a wrong/missing schema fails loudly rather than silently
/// misaligning columns.
pub fn dbc_to_csv(dbc_bytes: &[u8], dbc_name: &str, out: &Path) -> Result<(u32, u32)> {
let mut cursor = Cursor::new(dbc_bytes);
let parser =
DbcParser::parse(&mut cursor).map_err(|e| anyhow::anyhow!("parsing DBC header: {e}"))?;
let (records, fields) = (parser.header().record_count, parser.header().field_count);
let schema = schema_for(dbc_name).ok_or_else(|| {
anyhow::anyhow!(
"no schema defined for '{dbc_name}' ({fields} fields); known: TaxiNodes.dbc, \
AreaTable.dbc, GameObjectDisplayInfo.dbc, TaxiPath.dbc, TaxiPathNode.dbc, \
TransportAnimation.dbc, CreatureDisplayInfo.dbc, CreatureDisplayInfoExtra.dbc, \
CreatureModelData.dbc, CharHairGeosets.dbc, CharacterFacialHairStyles.dbc, \
HelmetGeosetVisData.dbc, CharSections.dbc, ItemDisplayInfo.dbc"
)
})?;
let parser = parser
.with_schema(schema)
.map_err(|e| anyhow::anyhow!("applying schema (field count mismatch?): {e}"))?;
let record_set = parser
.parse_records()
.map_err(|e| anyhow::anyhow!("parsing DBC records: {e}"))?;
let file = std::fs::File::create(out).with_context(|| format!("creating {}", out.display()))?;
benilla_dbc::export_to_csv(&record_set, std::io::BufWriter::new(file))
.with_context(|| format!("writing CSV {}", out.display()))?;
Ok((records, fields))
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal, complete BLP2 header (magic..=mip_sizes[16], 148 bytes) — mirrors the private
/// builder in `benilla_blp`'s own tests (that crate's helper isn't exported).
#[allow(clippy::too_many_arguments)]
fn blp2_header(
compression: u8,
alpha_bits: u8,
alpha_type: u8,
has_mipmaps: u8,
width: u32,
height: u32,
offsets: [u32; 16],
sizes: [u32; 16],
) -> Vec<u8> {
const HEADER_SIZE: usize = 148;
let mut b = vec![0u8; HEADER_SIZE];
b[0..4].copy_from_slice(b"BLP2");
b[4..8].copy_from_slice(&1u32.to_le_bytes()); // content: 1 = Direct
b[8] = compression;
b[9] = alpha_bits;
b[10] = alpha_type;
b[11] = has_mipmaps;
b[12..16].copy_from_slice(&width.to_le_bytes());
b[16..20].copy_from_slice(&height.to_le_bytes());
for (i, o) in offsets.iter().enumerate() {
b[20 + i * 4..24 + i * 4].copy_from_slice(&o.to_le_bytes());
}
for (i, s) in sizes.iter().enumerate() {
b[84 + i * 4..88 + i * 4].copy_from_slice(&s.to_le_bytes());
}
b
}
/// THE VERIFIED LATENT PANIC, regression-pinned: a no-mipmaps BLP2 (`has_mipmaps = 0`) makes
/// `benilla_blp::decode` report `mip_chain_count() == 0`, which `blp_bytes_to_mip_chain` used to
/// take literally (`.take(0)`), returning an **empty** `mips` — `benilla-assets`' `world_art_image`
/// then panics on `chain.mips[0]`. `decode` itself always keeps level 0 regardless of the count
/// (see the comment on `blp_bytes_to_mip_chain`), so the fix is to floor the take at 1: this must
/// yield a 1-level chain, never an empty one.
#[test]
fn no_mipmap_blp_yields_a_one_level_chain_not_empty() {
const HEADER_SIZE: usize = 148;
const PALETTE_SIZE: usize = 256 * 4;
let pixel_offset = (HEADER_SIZE + PALETTE_SIZE) as u32;
let pixel_size = 2 * 2 * 4;
let mut offsets = [0u32; 16];
let mut sizes = [0u32; 16];
offsets[0] = pixel_offset;
sizes[0] = pixel_size;
// compression 3 = Raw3 (uncompressed BGRA8); has_mipmaps 0.
let mut b = blp2_header(3, 8, 0, 0, 2, 2, offsets, sizes);
b.resize(HEADER_SIZE + PALETTE_SIZE, 0); // palette bytes: unused by Raw3, present anyway
b.extend_from_slice(&[
10, 20, 30, 40, // B G R A
50, 60, 70, 80, //
90, 100, 110, 120, //
130, 140, 150, 160,
]);
let chain = blp_bytes_to_mip_chain(&b).expect("valid no-mipmap BLP decodes");
assert_eq!(chain.width, 2);
assert_eq!(chain.height, 2);
assert_eq!(
chain.mips.len(),
1,
"no-mipmaps BLP must yield 1 level, not 0"
);
assert_eq!(chain.mips[0].len(), 2 * 2 * 4);
}
/// Every table the CSV dumper claims in its error hint really has a schema, and each dumps
/// against the **shipped** file — which is the only check that matters, because `with_schema`
/// rejects a field-count mismatch and `dbc_to_csv` would otherwise fail only when a session
/// reached for it mid-investigation. Skips without the client data.
#[test]
fn registered_dbc_schemas_dump_the_shipped_tables() {
let data = crate::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open chain");
let out = std::env::temp_dir().join("benilla-schema-registry-test.csv");
for table in [
"CreatureDisplayInfo",
"CreatureDisplayInfoExtra",
"CreatureModelData",
"CharHairGeosets",
"CharacterFacialHairStyles",
"HelmetGeosetVisData",
"CharSections",
"ItemDisplayInfo",
"ItemVisuals",
"ItemVisualEffects",
"SpellItemEnchantment",
"ItemRandomProperties",
"TaxiNodes",
"AreaTable",
"GameObjectDisplayInfo",
"TaxiPath",
"TaxiPathNode",
"TransportAnimation",
] {
let path = format!("DBFilesClient\\{table}.dbc");
let bytes = chain
.read_file(&path)
.unwrap_or_else(|e| panic!("{path}: {e}"));
let (rows, _) = dbc_to_csv(&bytes, &path, &out)
.unwrap_or_else(|e| panic!("{table} schema does not fit the shipped file: {e}"));
assert!(rows > 0, "{table} has rows");
}
let _ = std::fs::remove_file(&out);
}
}