Files
benilla-octo/crates/benilla-formats/src/lib.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

955 lines
39 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`: the schema layer over the 1.12.1 (build 5875) asset formats, the patch chain
//! ([`Chain`]) and the `benilla-extract` CLI. Data stays in raw WoW coordinates; the Bevy transform
//! lives in `benilla`.
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 for [`BlpMipChain::texels`].
pub use benilla_blp::BlpTexels;
mod chain;
pub use chain::{Chain, ChainEntry};
/// Loose `.tga` art, as addon folders ship it.
mod tga;
pub use tga::tga_to_rgba;
/// Where the WoW install is; [`Chain`] opens it.
mod install;
pub use install::{addon_corpus, addon_corpus_candidates, candidates, skipped, wow_data};
mod characters;
pub use characters::{
atlas_rect, equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile,
forearm_dressed, BlitSource, CharCreateCatalog, CharSections, CharacterGeosets, DialRanges,
EmblemLayer, EquipBlit, EquipGeosets, GuildEmblem, StartOutfitItem,
};
mod camera_shakes;
mod cinematics;
mod creatures;
mod dbc;
mod monkey_zone_grade;
pub use monkey_zone_grade::{
load_monkey_zone_grades, parse_monkey_zone_grades, MonkeyZoneGrade, MonkeyZoneGrades,
};
mod unit_blood;
pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog, SpellShakeGroup};
pub use cinematics::{
camera_model_path, load_cinematics, CinematicCameraRow, CinematicCatalog, CinematicPath,
CinematicSequence, CinematicView, SEQUENCE_CAMERAS,
};
pub use creatures::{
baked_atlas_path, load_creature_catalog, CreatureCatalog, CreatureModel, FootprintParams,
NpcAppearance,
};
pub use macro_icons::{load_macro_icon_catalog, load_macro_icons, MacroIconCatalog};
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 cfg_categories;
pub use cfg_categories::{load_realm_categories, RealmCategory};
mod wow_ini;
pub use wow_ini::client_region;
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`.
mod garble;
pub use garble::{garble, garble_chat, Garble, FLUENT_SKILL};
mod languages;
pub use languages::{
load_default_languages, load_language_words, load_languages, DefaultLanguages, LanguagePool,
LanguageWords, Languages,
};
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, StationeryRow, 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 chr_races;
pub use chr_races::{load_chr_races, ChrRace, ChrRaces};
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, FRILL_DENSITY, FRILL_DENSITY_MAX,
};
mod light;
pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
// MONKEY (fog): `LightFogBand.dbc`.
pub use light::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
// MONKEY (skybox): the skybox row and the zone walk's entries.
// MONKEY (leftovers): + the 0x8 / 0x10 flags.
pub use light::{
SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FORCE_SUN_SHAFTS, SKYBOX_FULL_DAY,
SKYBOX_KEEP_CELESTIAL, SKYBOX_NO_SUN_FOG,
};
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, MapBattlegroundColumns, 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 quest_info;
pub use quest_info::{load_quest_tag_names, QuestTagNames};
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 death_thud;
pub use death_thud::{load_death_thud_catalog, DeathThudCatalog};
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 vocal_ui_sounds;
pub use vocal_ui_sounds::{
load_vocal_ui_sounds, VocalUiSound, VocalUiSoundCatalog, VOCAL_UI_LINES,
};
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::{
cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times,
load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii,
load_spell_ranges, min_max_range, substitute, CcExemption, FormRefusal, LearnAnnouncement,
LearnEffect, 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, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR,
ON_NEXT_SWING_RANGE, 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_PROSPECTING, 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;
mod spell_mechanic;
pub use spell_focus::{load_spell_focus_catalog, SpellFocusCatalog};
pub use spell_mechanic::{load_spell_mechanic_catalog, SpellMechanicCatalog};
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, TrailProc, 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,
};
// MONKEY (fire GO lights): the SYNTHESISED-light rule for fire props that author no light block —
// derived from the model's own flame emitter. Beside `particles` because that is its whole input.
pub mod fire_light;
pub use fire_light::{synthesize_fire_light, SyntheticFire};
// MONKEY (lamp lights): the SECOND route in that same module — an emissive lamp/lantern
// GEOSET rather than a flame emitter (a lamppost authors no particles at all).
pub use fire_light::{synthesize_lamp_light, EmissiveBatch, LightRoute, SyntheticLamp};
// MONKEY (spell light): the THIRD route in that same module — a SPELL EFFECT's / FIREWORK's
// emitter, which the two above veto outright. Fire, holy and fel burn; frost, nature, arcane and
// shadow emit nothing at all.
pub use fire_light::{is_spell_light_path, synthesize_spell_light, SpellLightKind, SyntheticSpell};
// MONKEY (area spell light): the same route's PERSISTENT-GROUND-EFFECT arm — a DynamicObject's
// area model, which is routinely a flat decal with no emitter for the rule above to read, so its
// school comes off the model path / the spell's `Spell.dbc` school and its pool is sized from the
// wire AoE radius.
pub use fire_light::{
area_color, area_light_kind, area_reach, spell_school_kind, AREA_FALLBACK_INTENSITY,
AREA_LIGHT_LIFT,
};
// MONKEY (portal claims): which ROOMS one fixture may light -- containment, MOLR, and one portal
// hop. Shared by the runtime spawner and the `wmolights`/`wmolamps` audits so the gate the shader
// enforces and the gate the instrument prints can never be two different rules.
pub mod room_claim;
pub use room_claim::{claim_groups, room_claims, Claim, ClaimHow, PortalGraph};
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_pane_cameras,
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, rotation_2x2, shipped_glue_art_extent, uv_transform,
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, M2CameraTracks,
M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis,
PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton,
SkeletonBone, StageTwo, StringAnchors, UvAnim, UvRotAnim, 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, SHIPPED_GLUE_SCENES,
};
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,
TERRAIN_LAYER_TILES, TILE_SIZE,
};
mod wdl;
/// World (x, y) to ADT tile `(col, row)`; minimap `map<X>_<Y>.blp` names use the same order.
pub use benilla_wdt::{
tile_to_world, world_to_chunk, world_to_tile, GlobalWmo, WdtFile, WdtReader, WowVersion,
};
pub use wdl::{WdlFile, WdlTileMesh};
// The minimap tile hash catalog and 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 game_tips;
pub use game_tips::{load_game_tips, GameTipsCatalog};
mod text_filter_lists;
pub use text_filter_lists::{load_chat_profanity, load_spam_messages, FilterPattern};
mod race_sound;
pub use race_sound::{load_exploration_sound_catalog, ExplorationSoundCatalog};
mod race_pvp_team;
pub use race_pvp_team::load_race_pvp_teams;
mod zone_map;
pub use zone_map::{load_zone_map, ZONE_MAP_EDGE};
// Taxi and transport paths, and the transport timetable.
mod taxi;
pub use taxi::{load_taxi_path_nodes, TaxiPathNode, TaxiPathNodes};
mod transport_period;
mod transports;
pub use transports::{TransportSample, TransportTimetable};
// Type-11 elevator and lift keyframe paths.
mod elevators;
pub use elevators::{
elevator_period_ms, elevator_sample, load_elevator_paths, ElevatorKeyframe, ElevatorPaths,
};
// The flight-master catalogs: nodes (`TaxiNodes.dbc`) and direct-hop fares (`TaxiPath.dbc`).
// `taxi` above holds a path's waypoints.
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 base archives, lowest priority first: the reference mounter's table (`0x82e12c`),
/// priority 0x36 (`dbc.MPQ`) to 0x3f (`model.MPQ`), in [`Chain`]'s later-wins order. The patch
/// archives and `speech2.MPQ` are found by [`Chain::open`]; `base.MPQ` is left out, as the
/// reference closes it before mounting (`0x5aa2d0`).
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",
];
/// [`Chain::open`] on one `.MPQ` file or a vanilla `Data` directory.
pub fn open_chain(path: &Path) -> Result<Chain> {
Chain::open(path)
}
/// Write a BLP2 texture's level 0 (1.12 ships no BLP1) as a PNG at `out`; returns its size.
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))
}
/// One authored mip level's texel census, split by alpha: the multiply blends (Mod2x,
/// `2·src·dst`) read no alpha, so a transparent texel's colour still modulates the scene.
#[derive(Debug, Clone, PartialEq)]
pub struct BlpMipStats {
pub level: u32,
pub width: u32,
pub height: u32,
/// Texels with alpha 0 (the authored "outside") and with alpha > 0 (the "inside").
pub outside: usize,
pub inside: usize,
/// `(min, mean, max)` luma, `(r+g+b)/3`, over the outside and the inside texels.
pub outside_luma: Option<(u8, f32, u8)>,
pub inside_luma: Option<(u8, f32, u8)>,
/// Texels with luma below 128, each of which darkens the scene under Mod2x.
pub below_128: usize,
}
/// [`BlpMipStats`] for every authored level, decoded as CPU consumers decode: a neutral level 0
/// can hide dark tail levels, which are all a minified sprite samples.
pub fn blp_mip_stats(blp_bytes: &[u8]) -> Result<Vec<BlpMipStats>> {
let decoded =
benilla_blp::decode(blp_bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?;
Ok(decoded
.mips
.iter()
.enumerate()
.map(|(level, m)| {
let mut outside = (0usize, u8::MAX, 0u64, u8::MIN);
let mut inside = (0usize, u8::MAX, 0u64, u8::MIN);
let mut below_128 = 0usize;
for px in m.rgba.as_chunks::<4>().0 {
let luma = ((px[0] as u32 + px[1] as u32 + px[2] as u32) / 3) as u8;
if luma < 128 {
below_128 += 1;
}
let acc = if px[3] == 0 {
&mut outside
} else {
&mut inside
};
acc.0 += 1;
acc.1 = acc.1.min(luma);
acc.2 += luma as u64;
acc.3 = acc.3.max(luma);
}
let fold = |(n, lo, sum, hi): (usize, u8, u64, u8)| {
(n > 0).then(|| (lo, sum as f32 / n as f32, hi))
};
BlpMipStats {
level: level as u32,
width: m.width,
height: m.height,
outside: outside.0,
inside: inside.0,
outside_luma: fold(outside),
inside_luma: fold(inside),
below_128,
}
})
.collect())
}
/// Write every authored level as `<stem>.mip<N>.png` beside `out`, level 0 at `out` itself.
pub fn blp_mips_to_png(blp_bytes: &[u8], out: &Path) -> Result<Vec<BlpMipStats>> {
let decoded =
benilla_blp::decode(blp_bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?;
let stem = out.with_extension("");
for (level, m) in decoded.mips.iter().enumerate() {
let path = if level == 0 {
out.to_path_buf()
} else {
let mut p = stem.as_os_str().to_owned();
p.push(format!(".mip{level}.png"));
std::path::PathBuf::from(p)
};
image::RgbaImage::from_raw(m.width, m.height, m.rgba.clone())
.ok_or_else(|| anyhow::anyhow!("BLP RGBA buffer size mismatch at level {level}"))?
.save(&path)
.with_context(|| format!("writing PNG {}", path.display()))?;
}
blp_mip_stats(blp_bytes)
}
/// Decode a BLP's level 0 to RGBA8: `(width, height, pixels)`.
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 levels, level 0 first, never empty: a no-mipmaps BLP is one level.
/// [`texels`](Self::texels) says whether they hold pixels or DXTC blocks.
#[derive(Debug, Clone)]
pub struct BlpMipChain {
/// Level 0's dimensions.
pub width: u32,
pub height: u32,
/// What every level holds: decoded pixels or S3TC blocks.
pub texels: BlpTexels,
/// One buffer per authored level; the 1.12 client uploads the BLP's stored levels and never
/// regenerates them.
pub mips: Vec<Vec<u8>>,
}
impl BlpMipChain {
/// Level `i`'s size, at least 1×1.
pub fn mip_size(&self, i: u32) -> (u32, u32) {
((self.width >> i).max(1), (self.height >> i).max(1))
}
/// Whether the levels are `Rgba8Unorm` pixels a CPU reader can index, not blocks.
pub fn is_rgba8(&self) -> bool {
!self.texels.is_block_compressed()
}
/// Every level decoded to `Rgba8Unorm` without rereading the file; a no-op if already so.
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
}
}
}
/// Read a texture from the chain and decode every authored level to `Rgba8Unorm`.
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, keeping its DXTC blocks ([`blp_bytes_to_native_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 level of an in-memory BLP to `Rgba8Unorm`, as stored.
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}"))?;
// A no-mipmaps BLP counts 0 levels, yet `decode` always fills `mips[0]`: take at least one.
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 level of an in-memory BLP with its DXTC blocks kept, as the reference uploads
/// them (`glCompressedTexImage2DARB`, `0x59f5b0`). Raw1 and Raw3 come back decoded; levels are
/// padded to whole blocks.
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}"))?;
// At least one level, as in `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,
})
}
/// Our schema for a known 1.12 DBC by base filename, as DBC files carry no column types. A
/// localized string is 9 dwords: 8 locale offsets and a flags word.
fn schema_for(dbc_name: &str) -> Option<Schema> {
let base = dbc_name.rsplit(['/', '\\']).next().unwrap_or(dbc_name);
// A table with a typed loader reuses its schema, so a dump reads what the loader reads.
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, 64-byte records.
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, 100-byte records, as `area_table.rs` and `area_sound.rs` load them.
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, as `gameobjects.rs` loads them.
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") {
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, as `taxi.rs` loads them.
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; vmangos's `TransportAnimationEntry` (`DBCStructure.h:709-718`) reads the
// middle five, leaving the first and last out.
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
}
/// Write a DBC as CSV through our schema for it, returning `(record_count, field_count)`; an error
/// when there is no schema or its field count is wrong.
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 complete 148-byte BLP2 header, magic through `mip_sizes[16]`.
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
}
/// `has_mipmaps = 0` counts 0 levels, and consumers index `mips[0]`.
#[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);
}
/// `RainDrop01.blp` (16×128 DXT3) stores 16 bytes for its 2×16 and 1×8 levels, which need 64
/// and 32. The reference completes a short level from the bytes after it (`0x5a5780`); a zero
/// fill would darken the Mod2x rain streak toward black.
#[test]
fn the_rain_streak_texture_is_neutral_on_every_level_the_sampler_reaches() {
let data = crate::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open chain");
let path = "textures\\Weather\\RainDrop01.blp";
let bytes = chain
.read_file(path)
.unwrap_or_else(|e| panic!("{path}: {e}"));
let native = blp_bytes_to_native_chain(&bytes).expect("decodes natively");
assert_eq!((native.width, native.height), (16, 128));
assert_eq!(native.texels, benilla_blp::BlpTexels::Bc2);
assert!(
native.mips.len() >= 5,
"the tail levels are what this is about"
);
// Level 3 (2×16) needs four blocks and stores one; its second is level 4's first.
assert_eq!(native.mips[3].len(), 64);
assert_eq!(
&native.mips[3][16..32],
&native.mips[4][..16],
"level 3's second block must be the file's next 16 bytes — level 4's block"
);
assert!(
native.mips[3][16..].iter().any(|&x| x != 0),
"level 3's completion is never zero-filled"
);
// No texel on any level falls below luma 120; the authored band is 125 to 164.
let stats = blp_mip_stats(&bytes).expect("census");
for s in &stats {
for (class, luma) in [("outside", s.outside_luma), ("inside", s.inside_luma)] {
if let Some((lo, _, _)) = luma {
assert!(
lo >= 120,
"level {} ({}x{}) {class} texels reach luma {lo} — a Mod2x streak sampling \
this level darkens the scene",
s.level,
s.width,
s.height
);
}
}
}
}
#[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(format!("benilla-schema-reg-{}.csv", std::process::id()));
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);
}
}