Files
benilla-octo/crates/benilla-formats/src/lib.rs
T
SamandClaude Fable 5.1 f01a964e6e druids get their relic slot, and the loot window is the reference's own
- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference
- the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil
- the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them
- the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:00:40 +07:00

844 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, BlitSource,
CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EmblemLayer, EquipBlit,
EquipGeosets, GuildEmblem, 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 chr_classes;
pub use chr_classes::{load_chr_classes, ChrClasses, 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 server_messages;
pub use server_messages::{load_server_messages_catalog, ServerMessagesCatalog};
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);
}
}