- a panic writes benilla-config/Diagnostics/crash-<time>.txt with the build id, the location, a backtrace and the last 200 log lines, and names the file as its last line, so a bug report can attach it (2266) - every wire-count allocation hint is capped and the compressed update object reads through the cursor, so a malformed packet can no longer ask for gigabytes (2266) - the 23 in-world run conditions are one set, the 45 probe variables one registry, the install-gated tests say when they skip, and every gate is timed (2266) - one workspace-level lint allowance replaces 413 per-site attributes, and the decision corpus gets a lint in the gate (2266) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1999 lines
90 KiB
Rust
1999 lines
90 KiB
Rust
use std::collections::HashMap;
|
||
|
||
use anyhow::{Context, Result};
|
||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||
|
||
use crate::dbc::{parse, str_at, u32_at};
|
||
use crate::items::ItemDisplay;
|
||
use crate::{read_texture_mip_chain, BlpMipChain, Chain};
|
||
|
||
const CHAR_SECTIONS: &str = "DBFilesClient\\CharSections.dbc";
|
||
|
||
/// CharSections `sectionType` values (wow-re RF-0074): the layer a row supplies. `skin` is the full
|
||
/// base atlas; the rest are region overlays the body composite blends on top (decision 0044).
|
||
const SECTION_SKIN: u8 = 0;
|
||
const SECTION_FACE: u8 = 1;
|
||
const SECTION_FACIAL_HAIR: u8 = 2;
|
||
const SECTION_HAIR: u8 = 3;
|
||
const SECTION_UNDERWEAR: u8 = 4;
|
||
|
||
/// The hair variation the client's type-6 binder substitutes when the selected style resolves no
|
||
/// texture — a **literal 1** in the binary at two of its three call sites (`0x478445`, `0x4786f2`),
|
||
/// not a search. See [`CharSections::hair_mesh_texture`]; wow-re `rf84`, decision 0536.
|
||
const HAIR_SUBSTITUTE_VARIATION: u8 = 1;
|
||
|
||
/// An atlas rect `(x, y, w, h)` in pixels — a composite destination tile.
|
||
type Tile = (u32, u32, u32, u32);
|
||
|
||
/// The body-atlas tiles the head + underwear overlays composite into — four of the RF-0062 static 256²
|
||
/// partition's 10 rects. g8/g9 are the head strip (left column, Y 160–256: g8 the 32-tall upper band, g9
|
||
/// the 64-tall lower band); g3 (torso upper) and g5 (pelvis) are the right column's Y 0–64 and Y 96–160 —
|
||
/// the two tiles the underwear section dresses, one per texture column (RF-0062's group→cell map: the
|
||
/// group-3 handler `0x4772f0` takes underwear cell `cc+0x20c`, the group-5 handler `0x4773a0` takes
|
||
/// `cc+0x208`, and `0x478790` builds those two from the sectionType-4 row's `TextureName[1]`/`[0]`).
|
||
const TILE_G8: Tile = (0, 160, 128, 32);
|
||
const TILE_G9: Tile = (0, 192, 128, 64);
|
||
// The underwear's two tiles ARE two of the equipment tiles — the composite reaches them through
|
||
// `EQUIP_TILES` so there is one transcription of the rects, and these names exist for the tests,
|
||
// which read far better naming the group than indexing the array.
|
||
#[cfg(test)]
|
||
const TILE_G3: Tile = EQUIP_TILES[3];
|
||
#[cfg(test)]
|
||
const TILE_G5: Tile = EQUIP_TILES[5];
|
||
|
||
/// The underwear section's two blits: `(textureColumn, equipLayer, columnsTested)` — which
|
||
/// `TextureName` column dresses which equipment tile, and **how far into that tile's equipment
|
||
/// columns the client looks before it draws at all** (wow-re RF-0086, byte-derived from the two
|
||
/// handlers).
|
||
///
|
||
/// The underwear is not an under-layer — it is the tile's **fallback**. `0x4772f0` (TorsoUpper)
|
||
/// tests `cc+0x2e8/0x2ec/0x2f0` = columns 0–2 and `0x4773a0` (LegUpper) tests `cc+0x368/0x36c` =
|
||
/// columns 0–1, each `jne` straight past the underwear blit; only with every tested cell null does
|
||
/// the handler blit `cc+0x20c`/`cc+0x208`. So a shirt, a chest/robe or a guild tabard's background
|
||
/// hides the bra, and legs or a robe hide the panties — but the tested set is a strict **prefix**,
|
||
/// and the columns past it are not consulted: **a belt never hides the panties, and a plain tabard
|
||
/// never hides the bra**, even though both repaint their tile afterwards.
|
||
///
|
||
/// The base-skin blit is unconditional in both branches, so a suppressed tile shows *skin*, not a
|
||
/// hole — and the underwear itself is a REPLACE (alphaDepth 0, an opaque full-tile paste), never a
|
||
/// blend.
|
||
const UNDERWEAR_TILES: [(usize, usize, i8); 2] = [
|
||
(0, 5, 2), // TextureName[0] → LegUpper: {Legs, Chest/Robe} tested; the belt (col 2) is not
|
||
(1, 3, 3), // TextureName[1] → TorsoUpper: {Shirt, Chest/Robe, guild-tabard Background} tested
|
||
];
|
||
|
||
/// The eight equipment tiles g0–g7 (RF-0062), in **layer order** — the load-bearing identity of
|
||
/// decision 0074: ItemDisplayInfo texture column *i* = compositor layer *i* = tile g*i*. Note g3 ==
|
||
/// [`TILE_G3`] and g5 == [`TILE_G5`] — equipment TorsoUpper/LegUpper share the underwear's two tiles;
|
||
/// underwear blits first, so it sits under.
|
||
const EQUIP_TILES: [Tile; 8] = [
|
||
(0, 0, 128, 64), // g0 ArmUpper
|
||
(0, 64, 128, 64), // g1 ArmLower
|
||
(0, 128, 128, 32), // g2 Hand
|
||
(128, 0, 128, 64), // g3 TorsoUpper
|
||
(128, 64, 128, 32), // g4 TorsoLower
|
||
(128, 96, 128, 64), // g5 LegUpper
|
||
(128, 160, 128, 64), // g6 LegLower
|
||
(128, 224, 128, 32), // g7 Foot
|
||
];
|
||
|
||
/// The `Item\TextureComponents\` region directory per layer/column (decision 0074; empirically
|
||
/// suffix-matched — the on-disk dirs are exactly these eight).
|
||
const EQUIP_TEX_DIRS: [&str; 8] = [
|
||
"ArmUpperTexture",
|
||
"ArmLowerTexture",
|
||
"HandTexture",
|
||
"TorsoUpperTexture",
|
||
"TorsoLowerTexture",
|
||
"LegUpperTexture",
|
||
"LegLowerTexture",
|
||
"FootTexture",
|
||
];
|
||
|
||
/// The `[0x803bf8]` bodyslot×layer table (wow-re RF-0088), rows = bodyslots 2–9 (shirt, chest, belt,
|
||
/// pants, boots, wrist, gloves, tabard), columns = layers 0–7: the **cell** within the layer's row
|
||
/// that this slot's contribution occupies, `-1` = this slot never touches the layer.
|
||
///
|
||
/// It is a **default, not a fixed priority** — that was decision 0074's mistake and B326/B327's
|
||
/// cause. `0x478ad0` tests the cell for `-1` and for a non-empty texture name, then hands the slot to
|
||
/// the layer's own **chooser** `[0xb42424 + layer*4]`, and two of the eight choosers overrule the
|
||
/// table from the item's `geosetGroup` (see [`equip_column`]). Layers 0/2/3/4/5/7 take it verbatim.
|
||
///
|
||
/// The row is 16 cells and holds **one record per cell**; the composite handler fans out over it by
|
||
/// ascending cell index, so a higher cell is blitted later and covers a lower one.
|
||
const EQUIP_LAYER_COLUMN: [[i8; 8]; 8] = [
|
||
[0, 0, -1, 0, 0, -1, -1, -1], // shirt
|
||
[1, 1, -1, 1, 1, 1, 1, -1], // chest (robes reach the legs)
|
||
[-1, -1, -1, -1, -1, 2, -1, -1], // belt
|
||
[-1, -1, -1, -1, -1, 0, 0, -1], // pants
|
||
[-1, -1, -1, -1, -1, -1, 2, 0], // boots
|
||
[-1, 2, -1, -1, -1, -1, -1, -1], // wrist
|
||
[-1, 3, 0, -1, -1, -1, -1, -1], // gloves
|
||
[-1, -1, -1, 4, 4, -1, -1, -1], // tabard (RF-0088: TorsoUpper is cell 4, not 3 — 0074 mis-read it)
|
||
];
|
||
|
||
/// The worn-slot indices the two overruling choosers name, in `equipment` order (bodyslot − 2).
|
||
const SLOT_CHEST: usize = 1;
|
||
const SLOT_PANTS: usize = 3;
|
||
const SLOT_BOOTS: usize = 4;
|
||
const SLOT_GLOVES: usize = 6;
|
||
/// The tabard's worn-slot index (bodyslot 9 − 2) — the slot whose display decides whether the
|
||
/// guild-emblem layers install at all ([`ItemDisplay::takes_guild_emblem`]).
|
||
const SLOT_TABARD: usize = 7;
|
||
|
||
/// Where the guild tabard's art lives.
|
||
const GUILD_EMBLEM_DIR: &str = "Textures\\GuildEmblems";
|
||
|
||
/// The two compositor layers the guild tabard paints, and the filename half each names: TorsoUpper
|
||
/// takes the `_TU_` files, TorsoLower the `_TL_` ones (wow-re RF-0086: `0x47a610` stores the `_TU_`
|
||
/// trio into group 3's cells and the `_TL_` twins into group 4's). No other layer is touched — the
|
||
/// emblem is a torso garment, and the arms/legs keep whatever the rest of the outfit painted.
|
||
const EMBLEM_LAYERS: [(usize, &str); 2] = [(3, "TU"), (4, "TL")];
|
||
|
||
/// The filename half layer `layer` reads its guild-emblem art from, or `None` for a layer the
|
||
/// tabard never reaches.
|
||
fn emblem_half(layer: usize) -> Option<&'static str> {
|
||
EMBLEM_LAYERS
|
||
.iter()
|
||
.find_map(|&(l, half)| (l == layer).then_some(half))
|
||
}
|
||
|
||
/// CharSections texture lookup: (race, sex, sectionType, variation, colorIndex) → that row's up-to-3
|
||
/// `TextureName` columns (empty strings for absent columns). Feeds both the base body skin
|
||
/// (`sectionType 0`) and the head/pelvis region overlays the body composite blends on top (decision 0044).
|
||
pub struct CharSections {
|
||
sections: HashMap<(u8, u8, u8, u8, u8), [String; 3]>,
|
||
}
|
||
|
||
impl CharSections {
|
||
/// The base body-skin BLP for an appearance — CharSections `sectionType 0`, the single full 256²
|
||
/// body-layout texture keyed by skinColor. `None` if the row is absent.
|
||
pub fn skin_texture(&self, race: u8, sex: u8, skin_color: u8) -> Option<&str> {
|
||
self.tex(race, sex, SECTION_SKIN, 0, skin_color, 0)
|
||
}
|
||
|
||
/// The standalone **extra-skin** BLP for an appearance — CharSections `sectionType 0`,
|
||
/// `TextureName[1]`, keyed by skinColor (`…Skin00_NN_Extra.blp`). The texture the client binds to a
|
||
/// character body's M2 texture type 8 batches, loaded plain — never composited (the client's extra
|
||
/// loader is a bare TextureCreate). Only fur races author the column: tauren bind their head/leg fur
|
||
/// to it; every other race's rows leave it empty (and their models carry no type-8 batch).
|
||
pub fn skin_extra_texture(&self, race: u8, sex: u8, skin_color: u8) -> Option<&str> {
|
||
self.tex(race, sex, SECTION_SKIN, 0, skin_color, 1)
|
||
}
|
||
|
||
/// The hair-**mesh** texture for an appearance — CharSections `sectionType 3`, `TextureName[0]`,
|
||
/// keyed by hairStyle (variation) + hairColor. The single BLP the client binds to the hair geometry's
|
||
/// M2 texture type 6 (decision 0045); the colour is baked into the chosen file, not a runtime tint.
|
||
/// `None` when the row/column is empty (e.g. a bald style, variation 0 — its columns are blank).
|
||
/// (`TextureName[1]/[2]` of the same row are the scalp-on-skin overlays the body composite blends into
|
||
/// the head atlas — those go through [`Self::composite_body`], not here.)
|
||
pub fn hair_texture(&self, race: u8, sex: u8, hair_style: u8, hair_color: u8) -> Option<&str> {
|
||
self.tex(race, sex, SECTION_HAIR, hair_style, hair_color, 0)
|
||
}
|
||
|
||
/// The texture to bind to a character body's M2 **type 6** batches — the hair sheet, which on
|
||
/// several races dresses the *facial* hair geometry (beard/mustache geosets) as well as the
|
||
/// scalp hair. Prefer this over [`Self::hair_texture`] anywhere a mesh is being textured;
|
||
/// `hair_texture` is the raw row accessor, and a bald row is genuinely blank.
|
||
///
|
||
/// **The mechanism** (byte-verified, wow-re `rf84-hair-texture-type6-resolution.md`; decision
|
||
/// 0536). The client has no fallback *lookup*. `0x478220(cc, variationIdx)` is the sole type-6
|
||
/// binder, it always reads `TextureName[0]` (never column-indexed), and an **empty name is a
|
||
/// no-op that leaves the slot untouched** (`0x47827d cmp BYTE PTR [ecx],0x0` → `je 0x4782d8`) —
|
||
/// not a null bind. Three sites call it, in build order skin → hairStyle → facialHair:
|
||
///
|
||
/// 1. `0x478445` — variation **literal 1**, gated `sex==0 && hairStyle==0 && ChrRaces.Flags & 8`
|
||
/// 2. `0x478450` — variation `hairStyle`, unconditional
|
||
/// 3. `0x4786f2` — variation **literal 1**, gated on the selected beard having geoset geometry
|
||
/// and the hairStyle row missing a detail column; no sex/race/hairStyle test
|
||
///
|
||
/// Because no site ever clears the slot, the result is simply the **last site that resolved a
|
||
/// non-empty name** — which reduces to: take the hairStyle row, and when it is blank take
|
||
/// **variation 1** at the same colour. That is the fixpoint of the client's incremental apply
|
||
/// pipeline, and it is what this implements; we resolve a whole look at once rather than
|
||
/// re-applying per dial, so emulating the three sites separately would add machinery without
|
||
/// changing an output.
|
||
///
|
||
/// The literal 1 is load-bearing where a race authors several sheets per colour: Human male
|
||
/// resolves `Hair03_<colour>.blp` specifically, not an arbitrary non-blank row.
|
||
///
|
||
/// **Where this can diverge** (verified inert on 1.12.1 data, kept honest rather than dropped):
|
||
/// site 3's trigger is broader than "the hairStyle row is blank" — it also fires for Dwarf M
|
||
/// {0,2,4,9}, Scourge M {1,3,4,8} and Orc F {7}, i.e. 14 of 145 variation groups rather than the
|
||
/// 5 blank ones. In every one of those the variation-1 sheet is byte-identical to what the real
|
||
/// hairStyle already bound, so the reduction above is exact on the shipped build. It would only
|
||
/// part company with the client on data where a site-3 group's variation-1 sheet differs from
|
||
/// its own — which build 5875 does not contain.
|
||
pub fn hair_mesh_texture(
|
||
&self,
|
||
race: u8,
|
||
sex: u8,
|
||
hair_style: u8,
|
||
hair_color: u8,
|
||
) -> Option<&str> {
|
||
self.hair_texture(race, sex, hair_style, hair_color)
|
||
.or_else(|| self.hair_texture(race, sex, HAIR_SUBSTITUTE_VARIATION, hair_color))
|
||
}
|
||
|
||
/// One `TextureName` column of a section row, or `None` if the row/column is absent or empty.
|
||
fn tex(&self, race: u8, sex: u8, ty: u8, var: u8, color: u8, col: usize) -> Option<&str> {
|
||
self.sections
|
||
.get(&(race, sex, ty, var, color))
|
||
.map(|t| t[col].as_str())
|
||
.filter(|s| !s.is_empty())
|
||
}
|
||
|
||
/// Composite a character's full body-skin atlas: the base 256² skin with the face / facial-hair /
|
||
/// hair / underwear region overlays blended in at their fixed atlas tiles, returned as one mip
|
||
/// pyramid ready to upload (decision 0044). `Ok(None)` if the base skin row is absent; a missing or
|
||
/// undecodable overlay is skipped (best-effort, like the rest of the asset pipeline).
|
||
///
|
||
/// Each overlay is read from its own origin and source-over blitted onto the base at the verified
|
||
/// tile (RF-0062 bbox table + RF-0067 per-layer src rect + RF-0074 head-section map). The overlay
|
||
/// BLP's own alpha is the blend control, so an opaque overlay (`alphaDepth 0` → the real client's
|
||
/// REPLACE: face, pelvis) overwrites the base and an alpha one (facial hair) blends — at full 8-bit
|
||
/// precision, not the client's 16-bit RGB565 / 2-bit-coverage memory format (the modern-client
|
||
/// choice; the decision has the why). Composited per **authored** mip level — no gamma-byte CPU
|
||
/// downsample, the same C2 rule [`read_texture_mip_chain`]/the world-art upload follow.
|
||
///
|
||
/// `equipment` is the dressed extension (decision 0074): the worn ItemDisplayInfo rows by
|
||
/// **bodyslot − 2** (shirt, chest, belt, pants, boots, wrist, gloves, tabard; `None` = the slot
|
||
/// is empty). Their region textures blit into the eight equipment tiles after the skin sections,
|
||
/// stacked per layer by the client's priority table — and they also **gate** the underwear, which
|
||
/// is the tile's fallback rather than an under-layer ([`UNDERWEAR_TILES`]).
|
||
///
|
||
/// `emblem` is the wearer's guild tabard (decision 1704), which paints the torso layers' cells
|
||
/// 2/3/4 over the garment's own — see [`equip_blits`] for when it installs and when it does not.
|
||
pub fn composite_body(
|
||
&self,
|
||
chain: &mut Chain,
|
||
race: u8,
|
||
sex: u8,
|
||
skin: u8,
|
||
face: u8,
|
||
facial_hair: u8,
|
||
hair_style: u8,
|
||
hair_color: u8,
|
||
equipment: [Option<&ItemDisplay>; 8],
|
||
emblem: Option<GuildEmblem>,
|
||
tabard_preview: bool,
|
||
) -> Result<Option<BlpMipChain>> {
|
||
let Some(base_path) = self.skin_texture(race, sex, skin) else {
|
||
return Ok(None);
|
||
};
|
||
let mut atlas = read_texture_mip_chain(chain, base_path)
|
||
.with_context(|| format!("reading base skin '{base_path}'"))?;
|
||
// The head overlays: (sectionType, variation, color, texColumn, destTile) — the verified fan-out
|
||
// (RF-0067 §"section → cell core" + RF-0074 head map). Within a tile, order matters (later
|
||
// overwrites/blends over earlier): base skin (already the canvas) → face → facial hair → hair.
|
||
// Note the columns differ by section: face/facial-hair use TextureName[0]/[1] (lower/upper),
|
||
// hair uses [1]/[2]. Hair is blank for e.g. Human male (its texid columns are empty), so those
|
||
// reads no-op there; included so the path is correct for races whose hairline does composite
|
||
// into the head.
|
||
let overlays: [(u8, u8, u8, usize, Tile); 6] = [
|
||
(SECTION_FACE, face, skin, 0, TILE_G9),
|
||
(SECTION_FACE, face, skin, 1, TILE_G8),
|
||
(SECTION_FACIAL_HAIR, facial_hair, hair_color, 0, TILE_G9),
|
||
(SECTION_FACIAL_HAIR, facial_hair, hair_color, 1, TILE_G8),
|
||
(SECTION_HAIR, hair_style, hair_color, 1, TILE_G9),
|
||
(SECTION_HAIR, hair_style, hair_color, 2, TILE_G8),
|
||
];
|
||
for (ty, var, color, col, tile) in overlays {
|
||
let Some(path) = self.tex(race, sex, ty, var, color, col) else {
|
||
continue;
|
||
};
|
||
if let Ok(overlay) = read_texture_mip_chain(chain, path) {
|
||
blit_over(&mut atlas, &overlay, tile);
|
||
}
|
||
}
|
||
// The equipment plan, built once: the blits below consume it, and the underwear reads it as its
|
||
// gate. One plan, so "what dresses this tile?" has a single answer (decision 0074's `equip_blits`).
|
||
let plan = equip_blits(&equipment, emblem, tabard_preview);
|
||
// The underwear ([`UNDERWEAR_TILES`]) — the tile's fallback, not an under-layer: a contribution
|
||
// in any of the group's TESTED columns and the client draws no underwear there at all, leaving
|
||
// the base skin it already blitted. Drawn before the equipment because the untested columns (the
|
||
// belt, the tabard) still stack on top of it.
|
||
for (col, layer, tested) in UNDERWEAR_TILES {
|
||
if plan.iter().any(|s| s.layer == layer && s.column < tested) {
|
||
continue;
|
||
}
|
||
let Some(path) = self.tex(race, sex, SECTION_UNDERWEAR, 0, skin, col) else {
|
||
continue;
|
||
};
|
||
if let Ok(overlay) = read_texture_mip_chain(chain, path) {
|
||
blit_over(&mut atlas, &overlay, EQUIP_TILES[layer]);
|
||
}
|
||
}
|
||
// The equipment layers (decision 0074) and the guild tabard's three (decision 1704), in the
|
||
// one order [`equip_blits`] decides, each taking the first of its own
|
||
// [`EquipBlit::candidates`] that decodes. A name that resolves to nothing is skipped —
|
||
// best-effort, like the skin overlays; on the model it reads as a garment that stops early,
|
||
// which is what the instrument's `MISSING` line is for.
|
||
for step in &plan {
|
||
if let Some(overlay) = step
|
||
.candidates(sex)
|
||
.iter()
|
||
.find_map(|path| read_texture_mip_chain(chain, path).ok())
|
||
{
|
||
blit_over(&mut atlas, &overlay, EQUIP_TILES[step.layer]);
|
||
}
|
||
}
|
||
Ok(Some(atlas))
|
||
}
|
||
|
||
/// Load CharSections.dbc from the patch chain.
|
||
pub fn load(chain: &mut Chain) -> Result<Self> {
|
||
let bytes = chain
|
||
.read_file(CHAR_SECTIONS)
|
||
.with_context(|| format!("reading {CHAR_SECTIONS}"))?;
|
||
let rs = parse(&bytes, char_sections_schema(), "CharSections")?;
|
||
let mut sections = HashMap::with_capacity(rs.records().len());
|
||
for r in rs.records() {
|
||
// fields: ID, Race, Sex, SectionType, Variation, Color, Tex0, Tex1, Tex2, Flags.
|
||
if let (Some(race), Some(sex), Some(ty), Some(var), Some(color)) = (
|
||
u32_at(r, 1),
|
||
u32_at(r, 2),
|
||
u32_at(r, 3),
|
||
u32_at(r, 4),
|
||
u32_at(r, 5),
|
||
) {
|
||
let texs = [
|
||
str_at(&rs, r, 6).unwrap_or_default(),
|
||
str_at(&rs, r, 7).unwrap_or_default(),
|
||
str_at(&rs, r, 8).unwrap_or_default(),
|
||
];
|
||
let key = (race as u8, sex as u8, ty as u8, var as u8, color as u8);
|
||
// The standard player sections are `flags == 0`. Some (race,sex,type,color) keys ALSO have
|
||
// an `EXTRA`-flagged row (`flags & 0x1`) sharing the same colorIndex — e.g. human-male
|
||
// skinColor 0/1 carry both `HumanMaleSkin00_00` (flags 0) and the late `…_100` (flags 1).
|
||
// Those are not the chargen-selectable skins; a player with skinColor 0/1 must get `…_00`.
|
||
// So a standard row always wins; an extra row only fills a key no standard row provides
|
||
// (order-independent — robust to the extras appearing before or after the standards).
|
||
let extra = u32_at(r, 9).unwrap_or(0) & 0x1 != 0;
|
||
if extra {
|
||
sections.entry(key).or_insert(texs);
|
||
} else {
|
||
sections.insert(key, texs);
|
||
}
|
||
}
|
||
}
|
||
Ok(Self { sections })
|
||
}
|
||
}
|
||
|
||
/// One equipment contribution the body composite blits, carrying the worn bodyslot it came from,
|
||
/// the layer/tile it lands in, and the `ItemDisplayInfo` region-texture name it draws.
|
||
///
|
||
/// This is the *plan* [`CharSections::composite_body`] executes, exposed so the instrument
|
||
/// (`benilla-extract charatlas`) reads the composite off the **same law the composite runs** rather
|
||
/// than a second transcription that can drift. "Which worn slot repainted this tile?" is the
|
||
/// question both reported outfit bugs turned on, and it was unanswerable without this.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct EquipBlit<'a> {
|
||
/// The compositor layer — `ItemDisplayInfo` texture column *i* = layer *i* = atlas tile g*i*.
|
||
pub layer: usize,
|
||
/// The cell this contribution occupies in the layer's row — [`equip_column`]'s answer for a worn
|
||
/// garment, or the guild tabard's fixed 2/3/4. Ascending = blitted later.
|
||
pub column: i8,
|
||
/// Where the art comes from.
|
||
pub source: BlitSource<'a>,
|
||
}
|
||
|
||
/// What one [`EquipBlit`] paints with — a worn garment's own region texture, or one of the three
|
||
/// guild-tabard layers. Two different filename laws, which is why this is an enum and not a path:
|
||
/// a garment resolves `Item\TextureComponents\<dir>\<name>_<U|M|F>.blp` (`_U` first, the
|
||
/// wearer's gender letter as the fallback), the tabard `Textures\GuildEmblems\<…>_<TU|TL>_U.blp`
|
||
/// with no gender variant at all.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum BlitSource<'a> {
|
||
/// A worn garment's `ItemDisplayInfo` region texture: the bodyslot it came from, indexed as
|
||
/// [`CharSections::composite_body`]'s `equipment` is (bodyslot − 2: 0 shirt · 1 chest · 2 belt ·
|
||
/// 3 pants · 4 boots · 5 wrist · 6 gloves · 7 tabard), and the row's name for this layer (no
|
||
/// directory, no gender suffix).
|
||
Worn { slot: usize, texture: &'a str },
|
||
/// One of the wearer's guild-tabard layers — see [`EmblemLayer`].
|
||
Emblem {
|
||
part: EmblemLayer,
|
||
emblem: GuildEmblem,
|
||
},
|
||
}
|
||
|
||
impl EquipBlit<'_> {
|
||
/// The chain paths this contribution is looked up under, **in the order the composite tries
|
||
/// them**, first hit wins. Two for a worn garment (`_U`, then the wearer's gender letter — the
|
||
/// RF-0088 §7 order); one for a guild-tabard layer, which ships a single ungendered name.
|
||
///
|
||
/// The composite runs this and the instrument reports it, so "which file did that cell take?"
|
||
/// has one answer and cannot drift into two transcriptions.
|
||
pub fn candidates(&self, sex: u8) -> Vec<String> {
|
||
match self.source {
|
||
BlitSource::Worn { texture, .. } => {
|
||
equip_region_candidates(self.layer, texture, sex).into()
|
||
}
|
||
BlitSource::Emblem { part, emblem } => emblem_half(self.layer)
|
||
.map(|half| vec![part.path(&emblem, half)])
|
||
.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A guild's tabard — the five indices `SMSG_GUILD_QUERY_RESPONSE` carries, and the whole input to
|
||
/// the guild-emblem composite. They are file indices, not colours: each names a shipped BLP under
|
||
/// `Textures\GuildEmblems\`, and an index with no file simply paints nothing (the client's
|
||
/// `**** Unable to Load Texture %s` leaves the cell null), so nothing here is range-checked.
|
||
///
|
||
/// **Signed on purpose.** A guild that has never bought a tabard carries `-1` in all five (vmangos
|
||
/// `Guild/Guild.cpp:86`; `int32` on the wire), and `{:02}` renders that as `-1` — the same
|
||
/// `Emblem_-1_-1_TU_U` the client's own `%02d` builds, which resolves to no file and so paints
|
||
/// nothing. Clamping, or casting through unsigned, would invent a crest for a guild that has none.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub struct GuildEmblem {
|
||
/// The emblem symbol — `Emblem_<style>_<color>_…`; 170 shipped (000–169), a complete 170 × 17.
|
||
pub emblem_style: i32,
|
||
/// The emblem symbol's colour — 17 shipped (00–16), every style authoring all of them.
|
||
pub emblem_color: i32,
|
||
/// The border style — `Border_<style>_<color>_…`. **Only 00–05 are reachable**: the client's own
|
||
/// count table `[0x808220] = {170, 17, 6, 17, 51}` caps the designer at six, and the 52
|
||
/// `Border_06..09_*` files it ships are dead art (wow-re `rf89`). A server that put 7 on the
|
||
/// wire would simply resolve nothing, which is the same outcome the reference reaches.
|
||
pub border_style: i32,
|
||
/// The border's colour — 17 for the six reachable styles; the shipped table is ragged (118
|
||
/// files per half, not 170) only because the dead styles author 4 colours each.
|
||
pub border_color: i32,
|
||
/// The background colour — `Background_<color>_…`; 51 shipped (00–50), and the only layer with
|
||
/// a single index. **29 ships uppercase** (`BACKGROUND_29_TU_U.blp`, no lowercase sibling), so
|
||
/// the chain lookup this feeds must stay case-insensitive — pinned by a test.
|
||
pub background_color: i32,
|
||
}
|
||
|
||
impl GuildEmblem {
|
||
/// Whether this guild has actually **designed** a tabard — every one of the five indices is a
|
||
/// real one. `false` = the `-1` sentinel is present, and the crest must not install at all.
|
||
///
|
||
/// This is the client's own guard, not a convenience: `0x6d6d20` returns success **iff** the
|
||
/// guild record is cached AND none of its five fields is `-1` (five `cmp …,-1; je` at
|
||
/// `0x6d6d73`/`0x6d6d78`/`0x6d6d81`/`0x6d6d8a`/`0x6d6d93`, before any value reaches a path
|
||
/// builder, over out-params pre-initialised to `-1`). On failure the install `0x47a610` is never
|
||
/// entered — and since the clear of cells 4→2 lives *inside* that function
|
||
/// (`0x47a616 push 4; call 0x47a5c0`), the equipped tabard's own art is left standing.
|
||
///
|
||
/// **Getting this wrong is visible, not academic.** Painting the crest unconditionally builds
|
||
/// `Emblem_-1_-1_TU_U`, which names no file — but the plan has already taken cell 4 away from
|
||
/// the garment, so an undesigned guild's tabard renders as bare skin. It is `-1` on **any** of
|
||
/// the five, not all: the reference tests them one at a time and bails on the first.
|
||
/// (wow-re `rf89-guild-tabard-emblem-install.md` §Q6.)
|
||
pub fn is_designed(&self) -> bool {
|
||
[
|
||
self.emblem_style,
|
||
self.emblem_color,
|
||
self.border_style,
|
||
self.border_color,
|
||
self.background_color,
|
||
]
|
||
.iter()
|
||
.all(|&i| i != NO_TABARD)
|
||
}
|
||
}
|
||
|
||
/// The "this guild has no tabard" sentinel, in every one of [`GuildEmblem`]'s five fields — the
|
||
/// value vmangos constructs a guild with (`Guild/Guild.cpp:86`) and the one the reference's
|
||
/// `0x6d6d20` tests for. See [`GuildEmblem::is_designed`].
|
||
const NO_TABARD: i32 = -1;
|
||
|
||
/// One of the three layers a guild tabard paints, in blit order — the cells `0x47a610` refills
|
||
/// after clearing 4→2, so background sits under border sits under symbol.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum EmblemLayer {
|
||
/// Cell 2 — the tabard's field colour, and the **only emblem layer inside TorsoUpper's
|
||
/// underwear-suppression prefix**: cell 2 < the 3 tested columns, so a guild tabard hides the
|
||
/// bra where a plain one (cell 4) does not ([`UNDERWEAR_TILES`], decision 1614).
|
||
///
|
||
/// That gate is on the *cell being filled*, not on coverage, and the distinction is real here:
|
||
/// the background is a **cutout**, not a full-tile paste — `Background_12_TU_U` measures 79.5%
|
||
/// opaque, 16.8% fully transparent, the transparent part being outside the tabard's silhouette.
|
||
/// So the suppressed bra leaves bare skin at the sides, which is the unconditional base-skin
|
||
/// blit showing through, exactly as RF-0086 describes.
|
||
Background,
|
||
/// Cell 3 — the trim around the field, mostly transparent (`Border_01_07_TU_U`: 65% clear).
|
||
Border,
|
||
/// Cell 4 — the symbol itself, over everything, and over the tabard garment's own art (the
|
||
/// cell the equipped tabard would otherwise own).
|
||
Symbol,
|
||
}
|
||
|
||
impl EmblemLayer {
|
||
/// The three layers in blit order.
|
||
pub const ALL: [EmblemLayer; 3] = [
|
||
EmblemLayer::Background,
|
||
EmblemLayer::Border,
|
||
EmblemLayer::Symbol,
|
||
];
|
||
|
||
/// The cell this layer occupies in its row — fixed, not chosen: no bodyslot chooser can write
|
||
/// cells 2/3/4 of layers 3/4, so these three are the guild tabard's alone (wow-re RF-0088).
|
||
pub fn column(self) -> i8 {
|
||
match self {
|
||
EmblemLayer::Background => 2,
|
||
EmblemLayer::Border => 3,
|
||
EmblemLayer::Symbol => 4,
|
||
}
|
||
}
|
||
|
||
/// This layer's chain path for one emblem and one tabard half (`"TU"` / `"TL"`).
|
||
///
|
||
/// The reference's own format strings carry **no extension** (`0x838dd8` is
|
||
/// `Textures\GuildEmblems\Background_%02d_TU_U`, flat); its texture loader appends `".blp"`
|
||
/// at `0x449641` after truncating any trailing `.tga`/`.blp`. We build the finished path in one
|
||
/// step, which is the same string — and `%02d` is a **minimum** width, not a truncation, so
|
||
/// emblem style 169 is `Emblem_169_…` and not `Emblem_16_…`.
|
||
pub fn path(self, emblem: &GuildEmblem, half: &str) -> String {
|
||
match self {
|
||
EmblemLayer::Background => format!(
|
||
"{GUILD_EMBLEM_DIR}\\Background_{:02}_{half}_U.blp",
|
||
emblem.background_color
|
||
),
|
||
EmblemLayer::Border => format!(
|
||
"{GUILD_EMBLEM_DIR}\\Border_{:02}_{:02}_{half}_U.blp",
|
||
emblem.border_style, emblem.border_color
|
||
),
|
||
EmblemLayer::Symbol => format!(
|
||
"{GUILD_EMBLEM_DIR}\\Emblem_{:02}_{:02}_{half}_U.blp",
|
||
emblem.emblem_style, emblem.emblem_color
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The cell a worn slot's contribution to `layer` occupies — the `[0x803bf8]` default, unless the
|
||
/// layer's chooser overrules it (wow-re RF-0088 §5, byte-true; `0x479210` and `0x4793f0`).
|
||
///
|
||
/// **This is the fix for B327 and half of B326.** Only two of the eight choosers look past the
|
||
/// table, and both read an `ItemDisplayInfo.geosetGroup` — so what a garment *is* moves where it
|
||
/// paints:
|
||
///
|
||
/// - **ArmLower** (`0x479210`): gloves with a glove geoset, and a chest with a sleeve geoset, are
|
||
/// promoted clear of the plain cells (to 6 and 5) so a sleeved chest paints over a bracer.
|
||
/// - **LegLower** (`0x4793f0`): a **robe** (chest `geosetGroup[2]`) is promoted to **4**, above a
|
||
/// boot's 3-or-2 — so footwear paints *under* a robe's skirt, never over its hem. Trousers
|
||
/// carrying their own robe bit take 3 when the chest is a robe too, else 4.
|
||
///
|
||
/// Note the asymmetry that makes the bug: a *plain* chest stays at 1 and a boot sits at 2 or 3, so
|
||
/// boots do cover ordinary trousers on the shin — which is right, and is why reading the table as a
|
||
/// fixed priority looked correct for years. Only the robe inverts it.
|
||
pub fn equip_column(equipment: &[Option<&ItemDisplay>; 8], slot: usize, layer: usize) -> i8 {
|
||
let group = |s: usize, j: usize| equipment[s].is_some_and(|d| d.geoset_groups[j] != 0);
|
||
match (layer, slot) {
|
||
// ArmLower — `0x479210`, one `0x4774f0(rec, 0)` up front, applied on two bodyslots.
|
||
(1, SLOT_GLOVES) if group(slot, 0) => 6,
|
||
(1, SLOT_CHEST) if group(slot, 0) => 5,
|
||
// LegLower — `0x4793f0`. The boot geoset lifts a boot 2 → 3; the robe bit lifts a chest
|
||
// 1 → 4, over both. The trouser leg reads the CHEST's robe bit as well: a robe over robe-
|
||
// trousers puts the trousers at 3 (under the robe), otherwise they take 4 themselves.
|
||
(6, SLOT_BOOTS) if group(slot, 0) => 3,
|
||
(6, SLOT_CHEST) if group(slot, 2) => 4,
|
||
(6, SLOT_PANTS) if group(slot, 2) => {
|
||
if group(SLOT_CHEST, 2) {
|
||
3
|
||
} else {
|
||
4
|
||
}
|
||
}
|
||
_ => EQUIP_LAYER_COLUMN[slot][layer],
|
||
}
|
||
}
|
||
|
||
/// The ordered equipment blits a dressed composite performs: per layer, each worn slot that clears
|
||
/// `0x478ad0`'s two gates (a cell that is not `-1` in the default table, and a **non-empty** texture
|
||
/// name — the client's test is on the string's first byte, not on the pointer) placed in the cell
|
||
/// [`equip_column`] gives it, then emitted by ascending cell index.
|
||
///
|
||
/// A row holds **one record per cell**, so two slots landing on the same cell do not stack — the
|
||
/// later writer replaces the earlier, which is what the client's `0x478900` store does. Reachable
|
||
/// only when robe-trousers meet a robe chest and booted feet; `equipment` order decides it here,
|
||
/// where the client's is equip order.
|
||
///
|
||
/// `emblem` is the wearer's guild tabard, or `None` when they have no guild / the guild's identity
|
||
/// has not arrived yet. It installs **only** over a tabard whose display asks for it
|
||
/// ([`ItemDisplay::takes_guild_emblem`]) — a faction tabard that clears the flag keeps its own art,
|
||
/// and a guild member wearing no tabard shows no emblem. When it does install it writes cells 2/3/4
|
||
/// of layers 3 and 4 **last**, so the symbol replaces the tabard garment's own cell-4 contribution:
|
||
/// that is the client's ordering, not a priority rule (`0x478a53` clears cells 4→2 of both layers
|
||
/// when the tabard slot is set, then `0x47a610` refills all three — wow-re RF-0086/RF-0088).
|
||
pub fn equip_blits<'a>(
|
||
equipment: &[Option<&'a ItemDisplay>; 8],
|
||
emblem: Option<GuildEmblem>,
|
||
tabard_preview: bool,
|
||
) -> Vec<EquipBlit<'a>> {
|
||
// The tabard designer's preview (decision 1977, wow-re RF-0089 §6/§7c): the reference's
|
||
// `0x47a610` installs the five onto the character component with no ItemDisplayInfo test —
|
||
// the previewed tabard is the geoset flap over an EMPTY slot — so the emblem paints whenever
|
||
// the preview flag is up, and otherwise only over a worn tabard whose display asks for it.
|
||
let emblem = emblem.filter(|_| {
|
||
tabard_preview
|
||
|| equipment[SLOT_TABARD].is_some_and(|d: &ItemDisplay| d.takes_guild_emblem())
|
||
});
|
||
let mut plan = Vec::new();
|
||
for (layer, _tile) in EQUIP_TILES.iter().enumerate() {
|
||
let mut row: [Option<EquipBlit<'a>>; 8] = [None; 8];
|
||
for (slot, display) in equipment.iter().enumerate() {
|
||
let Some(display) = display else { continue };
|
||
if EQUIP_LAYER_COLUMN[slot][layer] < 0 {
|
||
continue;
|
||
}
|
||
let Some(texture) = display.region_textures[layer]
|
||
.as_deref()
|
||
.filter(|name| !name.is_empty())
|
||
else {
|
||
continue;
|
||
};
|
||
let column = equip_column(equipment, slot, layer);
|
||
if let Some(cell) = row.get_mut(column as usize) {
|
||
*cell = Some(EquipBlit {
|
||
layer,
|
||
column,
|
||
source: BlitSource::Worn { slot, texture },
|
||
});
|
||
}
|
||
}
|
||
if let Some(emblem) = emblem.filter(|_| emblem_half(layer).is_some()) {
|
||
for part in EmblemLayer::ALL {
|
||
let column = part.column();
|
||
if let Some(cell) = row.get_mut(column as usize) {
|
||
*cell = Some(EquipBlit {
|
||
layer,
|
||
column,
|
||
source: BlitSource::Emblem { part, emblem },
|
||
});
|
||
}
|
||
}
|
||
}
|
||
plan.extend(row.into_iter().flatten());
|
||
}
|
||
plan
|
||
}
|
||
|
||
/// The **ArmLower** layer — the tile whose occupancy gates the shirt's sleeve geoset (below).
|
||
const LAYER_ARM_LOWER: usize = 1;
|
||
|
||
/// Whether anything **other than the shirt** dresses the forearm — the byte-true gate on the geoset
|
||
/// dispatch's B3 arm (`0x4775bd`, the six dwords `cc+0x26c..cc+0x280` = cells 1..6 of the ArmLower
|
||
/// equip row; the shirt's own default cell is 0, which the loop skips).
|
||
///
|
||
/// The shirt carries a sleeve geoset (802/803, the cuff flare) that must not survive under a
|
||
/// garment painting the same skin: a chest with sleeve art, a bracer, or a glove takes the tile and
|
||
/// the cuff underneath it is dropped. Reading the *grid* rather than "is a chest equipped" is what
|
||
/// makes both directions right — 533 of the 637 shipped chest displays leave `ArmLowerTexture`
|
||
/// empty (the cuff survives them), while 566 of 570 wrist displays fill it (the cuff goes).
|
||
///
|
||
/// Off [`equip_blits`], so the promotion rules stay in one place: a sleeved chest and a
|
||
/// glove-geoset glove are lifted to cells 5 and 6, still inside the tested range. The guild emblem
|
||
/// is passed as `None` because it only ever writes layers 3 and 4 ([`EMBLEM_LAYERS`]) — this row
|
||
/// reads the same either way.
|
||
pub fn forearm_dressed(equipment: &[Option<&ItemDisplay>; 8]) -> bool {
|
||
equip_blits(equipment, None, false)
|
||
.iter()
|
||
.any(|b| b.layer == LAYER_ARM_LOWER && (1..=6).contains(&b.column))
|
||
}
|
||
|
||
/// The atlas rect layer `layer`'s equipment contributions composite into — `(x, y, w, h)` in pixels
|
||
/// of the 256² body atlas (the RF-0062 bbox table). Out of range past layer 7.
|
||
pub fn equip_tile(layer: usize) -> Option<(u32, u32, u32, u32)> {
|
||
EQUIP_TILES.get(layer).copied()
|
||
}
|
||
|
||
/// The `Item\TextureComponents\` subdirectory layer `layer` reads its art from.
|
||
pub fn equip_tex_dir(layer: usize) -> Option<&'static str> {
|
||
EQUIP_TEX_DIRS.get(layer).copied()
|
||
}
|
||
|
||
/// The chain paths a region texture is looked up under, in the order the composite tries them:
|
||
/// **`_U`, then the wearer's gender letter** (wow-re RF-0088 §7, byte-true).
|
||
///
|
||
/// The order is load-bearing and it is the inverse of what benilla shipped, which was **B326**.
|
||
/// `0x476e20` stages the literal `"U"` as the format's third `%s` before it computes anything else,
|
||
/// probes that exact path (`0x648a10`, an existence test), and patches the character's gender letter
|
||
/// over the `U` at `strlen-5` **only on a miss** — one byte, one branch, and no second attempt
|
||
/// anywhere in the image. So a gendered file that exists **loses to `_U` whenever `_U` also exists**.
|
||
///
|
||
/// Of 7944 shipped basenames, 5920 are `_U`-only (the probe hits), 1937 are `_M`+`_F` with no `_U`
|
||
/// (the patch is what those are for), and **43 ship both** — on those 43 the gendered art is dead.
|
||
/// `Leather_A_02_Pant_LL` is one: its `_F` is 18 rows shorter than its `_U`, so preferring `_F` left
|
||
/// a bare-skin ring below a night elf female's knee that the reference does not have.
|
||
///
|
||
/// Public so the instrument can report **which** candidate a contribution actually resolved to, and
|
||
/// name the ones that resolved to nothing — a silently-skipped region reads on the model as a
|
||
/// garment that stops early, which is exactly how it was first reported.
|
||
pub fn equip_region_candidates(layer: usize, name: &str, sex: u8) -> [String; 2] {
|
||
let dir = EQUIP_TEX_DIRS[layer];
|
||
let letter = if sex == 1 { 'F' } else { 'M' };
|
||
['U', letter].map(|c| format!("Item\\TextureComponents\\{dir}\\{name}_{c}.blp"))
|
||
}
|
||
|
||
/// Source-over composite of one region overlay's authored mip pyramid onto the body atlas at a fixed
|
||
/// tile. Per mip level `i` the destination tile is `(x>>i, y>>i, w>>i, h>>i)` and the overlay's level-`i`
|
||
/// pixels are read from its **own origin** — the RF-0067 overlay src rect (`src = (0,0)`, `dst = tile.xy`,
|
||
/// extent `tile.wh`), so the overlay BLP is authored exactly tile-sized. Straight 8-bit source-over per
|
||
/// channel (`out = src·a + dst·(1−a)`, `out_a = a + dst_a·(1−a)`): an opaque overlay (`a == 255`) is a
|
||
/// plain copy (the client's REPLACE), an alpha one blends. Levels past either pyramid's end, and the
|
||
/// sub-pixel remainder of a degenerate deep-mip tile, are clamped/skipped.
|
||
fn blit_over(dst: &mut BlpMipChain, src: &BlpMipChain, tile: Tile) {
|
||
// The composite is a per-texel source-over blend, so both sides must be decoded pixels. Every
|
||
// reader here goes through `read_texture_mip_chain` (never the block-passthrough twin) — this
|
||
// says so out loud, because a chain that arrived as DXT blocks would blend garbage silently
|
||
// rather than fail (decision 1626).
|
||
debug_assert!(
|
||
dst.is_rgba8() && src.is_rgba8(),
|
||
"character-skin compositing needs decoded chains on both sides"
|
||
);
|
||
let (tx, ty, tw, th) = tile;
|
||
let levels = dst.mips.len().min(src.mips.len());
|
||
for i in 0..levels {
|
||
let dw = (dst.width >> i).max(1) as usize;
|
||
let dh = (dst.height >> i).max(1) as usize;
|
||
let sw = (src.width >> i).max(1) as usize;
|
||
let sh = (src.height >> i).max(1) as usize;
|
||
let (ox, oy) = ((tx >> i) as usize, (ty >> i) as usize);
|
||
// Copy extent: the tile size at this level, clamped to what both the source and the dest hold.
|
||
let cw = ((tw >> i).max(1) as usize)
|
||
.min(sw)
|
||
.min(dw.saturating_sub(ox));
|
||
let ch = ((th >> i).max(1) as usize)
|
||
.min(sh)
|
||
.min(dh.saturating_sub(oy));
|
||
let (d, s) = (&mut dst.mips[i], &src.mips[i]);
|
||
for row in 0..ch {
|
||
for col in 0..cw {
|
||
let si = (row * sw + col) * 4;
|
||
let di = ((oy + row) * dw + (ox + col)) * 4;
|
||
if si + 4 > s.len() || di + 4 > d.len() {
|
||
continue;
|
||
}
|
||
let a = s[si + 3] as u32;
|
||
if a == 0 {
|
||
continue; // fully transparent overlay texel — leave the base
|
||
}
|
||
if a == 255 {
|
||
d[di..di + 4].copy_from_slice(&s[si..si + 4]);
|
||
continue;
|
||
}
|
||
let ia = 255 - a;
|
||
for c in 0..3 {
|
||
d[di + c] = ((s[si + c] as u32 * a + d[di + c] as u32 * ia + 127) / 255) as u8;
|
||
}
|
||
d[di + 3] = (a + (d[di + 3] as u32 * ia + 127) / 255).min(255) as u8;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// CharSections.dbc — 10 fields in build 5875 (verified); 3 string columns are texture-name offsets.
|
||
pub(crate) fn char_sections_schema() -> Schema {
|
||
let mut s = Schema::new("CharSections");
|
||
for (name, ty) in [
|
||
("ID", FieldType::UInt32),
|
||
("RaceID", FieldType::UInt32),
|
||
("SexID", FieldType::UInt32),
|
||
("SectionType", FieldType::UInt32),
|
||
("VariationIndex", FieldType::UInt32),
|
||
("ColorIndex", FieldType::UInt32),
|
||
("TextureName0", FieldType::String),
|
||
("TextureName1", FieldType::String),
|
||
("TextureName2", FieldType::String),
|
||
("Flags", FieldType::UInt32),
|
||
] {
|
||
s.add_field(SchemaField::new(name, ty));
|
||
}
|
||
s
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
/// The `(cell, texture)` pair of a **worn** contribution — the shape every cell-order assertion
|
||
/// below reads. These fixtures pass no emblem, so a guild-tabard layer here is a bug in the
|
||
/// test, not a case to handle.
|
||
fn worn_cell<'a>(step: &EquipBlit<'a>) -> (i8, &'a str) {
|
||
match step.source {
|
||
BlitSource::Worn { texture, .. } => (step.column, texture),
|
||
BlitSource::Emblem { .. } => panic!("unexpected guild-emblem layer in this fixture"),
|
||
}
|
||
}
|
||
use super::*;
|
||
|
||
/// The base skin resolves the full-body BLP for (race, sex, skinColor) — the path the renderer
|
||
/// applies to the body-skin batches (value = the real build-5875 row for Human male skinColor 3).
|
||
#[test]
|
||
fn base_skin_texture_resolves() {
|
||
let mut sections = HashMap::new();
|
||
sections.insert(
|
||
(1, 0, SECTION_SKIN, 0, 3),
|
||
[
|
||
"Character\\Human\\Male\\HumanMaleSkin00_03.blp".to_string(),
|
||
String::new(),
|
||
String::new(),
|
||
],
|
||
);
|
||
let cs = CharSections { sections };
|
||
assert_eq!(
|
||
cs.skin_texture(1, 0, 3),
|
||
Some("Character\\Human\\Male\\HumanMaleSkin00_03.blp")
|
||
);
|
||
assert_eq!(cs.skin_texture(1, 0, 99), None, "absent color → no texture");
|
||
}
|
||
|
||
/// A bare [`ItemDisplay`] carrying only the region textures and geoset groups a test cares about.
|
||
fn worn(regions: [Option<&str>; 8], geoset_groups: [u32; 3]) -> ItemDisplay {
|
||
ItemDisplay {
|
||
region_textures: regions.map(|r| r.map(str::to_string)),
|
||
geoset_groups,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Legwear + footwear + a chest, named so the cases below read as outfits.
|
||
fn leg(tex: &str) -> [Option<&str>; 8] {
|
||
[None, None, None, None, None, Some("lu"), Some(tex), None]
|
||
}
|
||
|
||
/// [`equip_blits`] is the composite's whole equipment law, so the two things a reported outfit
|
||
/// defect turns on are pinned here: **which** slots reach a tile, and in **what cell order**.
|
||
///
|
||
/// The `[0x803bf8]` value is a **default**, not a fixed priority (wow-re RF-0088 §5): the layer's
|
||
/// chooser may overrule it from the item's `geosetGroup`, and the row is walked by ascending cell
|
||
/// with later covering earlier. Reading it as a fixed priority is what shipped **B327**.
|
||
#[test]
|
||
fn equip_blits_places_each_slot_in_its_chooser_cell() {
|
||
let plain_chest = worn(
|
||
[
|
||
Some("au"),
|
||
Some("al"),
|
||
None,
|
||
Some("tu"),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
],
|
||
[0, 0, 0],
|
||
);
|
||
let sleeved_chest = worn(
|
||
[
|
||
Some("au"),
|
||
Some("al"),
|
||
None,
|
||
Some("tu"),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
],
|
||
[1, 0, 0],
|
||
);
|
||
let bracer = worn(
|
||
[None, Some("bracer_al"), None, None, None, None, None, None],
|
||
[0, 0, 0],
|
||
);
|
||
let plain_gloves = worn(
|
||
[
|
||
None,
|
||
Some("glove_al"),
|
||
Some("glove_ha"),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
],
|
||
[0, 0, 0],
|
||
);
|
||
let geoset_gloves = worn(
|
||
[
|
||
None,
|
||
Some("glove_al"),
|
||
Some("glove_ha"),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
],
|
||
[1, 0, 0],
|
||
);
|
||
|
||
// ArmLower (`0x479210`): plain items take the table (shirt 0, chest 1, wrist 2, gloves 3);
|
||
// a glove/sleeve geoset lifts gloves to 6 and the chest to 5, clear of the plain cells.
|
||
let eq = [
|
||
None,
|
||
Some(&plain_chest),
|
||
None,
|
||
None,
|
||
None,
|
||
Some(&bracer),
|
||
Some(&plain_gloves),
|
||
None,
|
||
];
|
||
let g1: Vec<_> = equip_blits(&eq, None, false)
|
||
.iter()
|
||
.filter(|s| s.layer == 1)
|
||
.map(worn_cell)
|
||
.collect();
|
||
assert_eq!(g1, [(1, "al"), (2, "bracer_al"), (3, "glove_al")]);
|
||
let eq = [
|
||
None,
|
||
Some(&sleeved_chest),
|
||
None,
|
||
None,
|
||
None,
|
||
Some(&bracer),
|
||
Some(&geoset_gloves),
|
||
None,
|
||
];
|
||
let g1: Vec<_> = equip_blits(&eq, None, false)
|
||
.iter()
|
||
.filter(|s| s.layer == 1)
|
||
.map(worn_cell)
|
||
.collect();
|
||
assert_eq!(
|
||
g1,
|
||
[(2, "bracer_al"), (5, "al"), (6, "glove_al")],
|
||
"a sleeved chest paints over a bracer"
|
||
);
|
||
}
|
||
|
||
/// The guild tabard's three layers (decision 1704). Two facts, and they are the whole law:
|
||
/// the emblem installs **only** over a tabard whose display asks for it
|
||
/// ([`ItemDisplay::takes_guild_emblem`]), and when it does it takes cells 2/3/4 of layers 3 and
|
||
/// 4 — **including the cell the tabard garment itself had**, because the client clears 4→2 and
|
||
/// refills all three (wow-re RF-0086/RF-0088).
|
||
#[test]
|
||
fn the_guild_emblem_replaces_the_tabard_it_is_worn_on() {
|
||
let torso = [
|
||
None,
|
||
None,
|
||
None,
|
||
Some("tabard_tu"),
|
||
Some("tabard_tl"),
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
let guild_tabard = ItemDisplay {
|
||
flags: 1,
|
||
..worn(torso, [1, 0, 0])
|
||
};
|
||
let plain_tabard = worn(torso, [1, 0, 0]);
|
||
let shirt = worn(
|
||
[
|
||
Some("shirt_au"),
|
||
Some("shirt_al"),
|
||
None,
|
||
Some("shirt_tu"),
|
||
Some("shirt_tl"),
|
||
None,
|
||
None,
|
||
None,
|
||
],
|
||
[0, 0, 0],
|
||
);
|
||
let emblem = GuildEmblem {
|
||
emblem_style: 42,
|
||
emblem_color: 3,
|
||
border_style: 1,
|
||
border_color: 7,
|
||
background_color: 12,
|
||
};
|
||
// The `(layer, cell)` pairs a plan fills, and what filled each.
|
||
fn cells(plan: &[EquipBlit<'_>]) -> Vec<(usize, i8, String)> {
|
||
plan.iter()
|
||
.map(|s| {
|
||
let what = match s.source {
|
||
BlitSource::Worn { texture, .. } => texture.to_string(),
|
||
BlitSource::Emblem { part, .. } => format!("{part:?}"),
|
||
};
|
||
(s.layer, s.column, what)
|
||
})
|
||
.collect()
|
||
}
|
||
let want = |v: &[(usize, i8, &str)]| -> Vec<(usize, i8, String)> {
|
||
v.iter().map(|(l, c, t)| (*l, *c, t.to_string())).collect()
|
||
};
|
||
|
||
// Worn alone with a guild: background/border/symbol, ascending, on BOTH torso layers — and
|
||
// the garment's own `tabard_tu`/`tabard_tl` are gone, not stacked under.
|
||
let eq = [
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some(&guild_tabard),
|
||
];
|
||
assert_eq!(
|
||
cells(&equip_blits(&eq, Some(emblem), false)),
|
||
want(&[
|
||
(3, 2, "Background"),
|
||
(3, 3, "Border"),
|
||
(3, 4, "Symbol"),
|
||
(4, 2, "Background"),
|
||
(4, 3, "Border"),
|
||
(4, 4, "Symbol"),
|
||
]),
|
||
"the emblem owns cells 2/3/4 of the two torso layers, the tabard's own art none"
|
||
);
|
||
// No guild (or the query hasn't answered yet) — the garment keeps its own art. This is the
|
||
// look a guildless player wearing a Guild Tabard gets, and the look everyone gets for the
|
||
// frames between spawning and `SMSG_GUILD_QUERY_RESPONSE`.
|
||
assert_eq!(
|
||
cells(&equip_blits(&eq, None, false)),
|
||
want(&[(3, 4, "tabard_tu"), (4, 4, "tabard_tl")])
|
||
);
|
||
// A tabard that does not ask for the emblem keeps its art even for a guilded wearer: the
|
||
// gate is the DISPLAY's flag, not "does this player have a guild".
|
||
let plain = [
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some(&plain_tabard),
|
||
];
|
||
assert_eq!(
|
||
cells(&equip_blits(&plain, Some(emblem), false)),
|
||
want(&[(3, 4, "tabard_tu"), (4, 4, "tabard_tl")])
|
||
);
|
||
// And no tabard at all is no emblem, however guilded the wearer.
|
||
assert!(equip_blits(&[None; 8], Some(emblem), false).is_empty());
|
||
|
||
// Under a shirt: the shirt keeps cell 0, the emblem stacks above it — and nothing reaches
|
||
// the arm layers, which the shirt still owns alone.
|
||
let eq = [
|
||
Some(&shirt),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some(&guild_tabard),
|
||
];
|
||
assert_eq!(
|
||
cells(&equip_blits(&eq, Some(emblem), false)),
|
||
want(&[
|
||
(0, 0, "shirt_au"),
|
||
(1, 0, "shirt_al"),
|
||
(3, 0, "shirt_tu"),
|
||
(3, 2, "Background"),
|
||
(3, 3, "Border"),
|
||
(3, 4, "Symbol"),
|
||
(4, 0, "shirt_tl"),
|
||
(4, 2, "Background"),
|
||
(4, 3, "Border"),
|
||
(4, 4, "Symbol"),
|
||
])
|
||
);
|
||
}
|
||
|
||
/// The six filenames an emblem builds, byte-for-byte — the one place a `%02d` transcription
|
||
/// error would show up as "the crest is just missing" and nothing else. Three cases:
|
||
///
|
||
/// - a real crest, with the **two-digit** padding the format string's `%02d` gives;
|
||
/// - a three-digit style, which `%02d` does **not** truncate (170 emblem styles ship, so
|
||
/// `Emblem_169_…` is a real file and a `{:02.2}`-shaped mistake would break the top third of
|
||
/// the table);
|
||
/// - the **undesigned** guild, whose five `-1`s render as `-1` and resolve to nothing — which
|
||
/// is why [`GuildEmblem`] is signed. Casting through unsigned would build
|
||
/// `Emblem_4294967295_…`: also a miss, but for the wrong reason and off a value that could
|
||
/// collide with a real index if anything ever clamped it.
|
||
#[test]
|
||
fn emblem_layer_paths_are_the_clients_own_format() {
|
||
let e = GuildEmblem {
|
||
emblem_style: 7,
|
||
emblem_color: 3,
|
||
border_style: 1,
|
||
border_color: 12,
|
||
background_color: 5,
|
||
};
|
||
assert_eq!(
|
||
EmblemLayer::ALL.map(|p| p.path(&e, "TU")),
|
||
[
|
||
"Textures\\GuildEmblems\\Background_05_TU_U.blp",
|
||
"Textures\\GuildEmblems\\Border_01_12_TU_U.blp",
|
||
"Textures\\GuildEmblems\\Emblem_07_03_TU_U.blp",
|
||
]
|
||
);
|
||
assert_eq!(
|
||
EmblemLayer::Symbol.path(&e, "TL"),
|
||
"Textures\\GuildEmblems\\Emblem_07_03_TL_U.blp",
|
||
"the TorsoLower half is the same name with the other tag"
|
||
);
|
||
// `%02d` is a MINIMUM width, not a truncation — the 170th emblem style is three digits.
|
||
assert_eq!(
|
||
EmblemLayer::Symbol.path(
|
||
&GuildEmblem {
|
||
emblem_style: 169,
|
||
emblem_color: 16,
|
||
..e
|
||
},
|
||
"TU"
|
||
),
|
||
"Textures\\GuildEmblems\\Emblem_169_16_TU_U.blp"
|
||
);
|
||
// A guild that never bought a tabard: `-1` in all five, and no file for any of them.
|
||
let none = GuildEmblem {
|
||
emblem_style: -1,
|
||
emblem_color: -1,
|
||
border_style: -1,
|
||
border_color: -1,
|
||
background_color: -1,
|
||
};
|
||
assert_eq!(
|
||
EmblemLayer::ALL.map(|p| p.path(&none, "TU")),
|
||
[
|
||
"Textures\\GuildEmblems\\Background_-1_TU_U.blp",
|
||
"Textures\\GuildEmblems\\Border_-1_-1_TU_U.blp",
|
||
"Textures\\GuildEmblems\\Emblem_-1_-1_TU_U.blp",
|
||
],
|
||
"the sentinel renders as the client's own `%02d` does, and names nothing"
|
||
);
|
||
}
|
||
|
||
/// The `-1` guard (wow-re `rf89` §Q6). A guild that has never designed a tabard carries the
|
||
/// sentinel, and the crest must not install **at all** — not "install and resolve to nothing",
|
||
/// which is the failure this pins: the plan takes cell 4 away from the garment before the file
|
||
/// lookup happens, so painting through the sentinel leaves a blank tabard rather than the
|
||
/// default one. The reference bails on the **first** `-1` of the five, so a half-designed
|
||
/// record (impossible from its own designer UI, but the wire is the server's) is also nil.
|
||
#[test]
|
||
fn an_undesigned_guild_has_no_crest_to_paint() {
|
||
let designed = GuildEmblem {
|
||
emblem_style: 42,
|
||
emblem_color: 3,
|
||
border_style: 1,
|
||
border_color: 7,
|
||
background_color: 12,
|
||
};
|
||
assert!(designed.is_designed());
|
||
// Index 0 is a real index in every field — `Emblem_00_00` etc. all ship.
|
||
assert!(GuildEmblem::default().is_designed());
|
||
// The fresh-guild record: every field the sentinel.
|
||
assert!(!GuildEmblem {
|
||
emblem_style: -1,
|
||
emblem_color: -1,
|
||
border_style: -1,
|
||
border_color: -1,
|
||
background_color: -1,
|
||
}
|
||
.is_designed());
|
||
// …and any ONE field is enough, which is the half the "all five" reading gets wrong.
|
||
for spoil in [
|
||
GuildEmblem {
|
||
emblem_style: -1,
|
||
..designed
|
||
},
|
||
GuildEmblem {
|
||
emblem_color: -1,
|
||
..designed
|
||
},
|
||
GuildEmblem {
|
||
border_style: -1,
|
||
..designed
|
||
},
|
||
GuildEmblem {
|
||
border_color: -1,
|
||
..designed
|
||
},
|
||
GuildEmblem {
|
||
background_color: -1,
|
||
..designed
|
||
},
|
||
] {
|
||
assert!(!spoil.is_designed(), "{spoil:?} still reads as designed");
|
||
}
|
||
}
|
||
|
||
/// The underwear consequence, which is the one thing about the emblem that is *not* confined to
|
||
/// its own cells: TorsoUpper's bra is suppressed by a contribution in cells 0/1/**2**, and cell 2
|
||
/// is the guild tabard's **background** ([`UNDERWEAR_TILES`]). So a guild tabard hides the bra
|
||
/// and a plain one — which only ever reaches cell 4 — does not, exactly as wow-re RF-0086 says.
|
||
#[test]
|
||
fn only_a_guild_tabards_background_reaches_the_underwear_prefix() {
|
||
let torso = [
|
||
None,
|
||
None,
|
||
None,
|
||
Some("tabard_tu"),
|
||
Some("tabard_tl"),
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
let guild_tabard = ItemDisplay {
|
||
flags: 1,
|
||
..worn(torso, [1, 0, 0])
|
||
};
|
||
let plain_tabard = worn(torso, [1, 0, 0]);
|
||
let emblem = GuildEmblem::default();
|
||
let suppresses = |eq: &[Option<&ItemDisplay>; 8], em: Option<GuildEmblem>| {
|
||
let (col, layer, tested) = UNDERWEAR_TILES[1];
|
||
assert_eq!(
|
||
(col, layer),
|
||
(1, 3),
|
||
"the bra is TextureName[1] into TorsoUpper"
|
||
);
|
||
equip_blits(eq, em, false)
|
||
.iter()
|
||
.any(|s| s.layer == layer && s.column < tested)
|
||
};
|
||
let guild = [
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some(&guild_tabard),
|
||
];
|
||
let plain = [
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some(&plain_tabard),
|
||
];
|
||
assert!(
|
||
suppresses(&guild, Some(emblem)),
|
||
"a guild tabard's background sits in cell 2, inside the tested prefix"
|
||
);
|
||
assert!(
|
||
!suppresses(&guild, None),
|
||
"with no guild the same tabard only reaches cell 4, past the prefix"
|
||
);
|
||
assert!(
|
||
!suppresses(&plain, Some(emblem)),
|
||
"a plain tabard never hides the bra"
|
||
);
|
||
}
|
||
|
||
/// **B327, the carve.** On LegLower the chooser `0x4793f0` lifts a *robe* (chest `geosetGroup[2]`)
|
||
/// to cell 4 — above a boot's 3-or-2 — so footwear paints under a robe's skirt and can never
|
||
/// repaint its hem. The control that must not move: a **plain** chest stays at 1 and boots still
|
||
/// cover ordinary trousers, which is why the fixed-priority reading looked right for years.
|
||
#[test]
|
||
fn a_robe_outranks_footwear_on_leglower() {
|
||
let robe = worn(leg("robe_ll"), [1, 0, 1]);
|
||
let plain_chest = worn(leg("chest_ll"), [0, 0, 0]);
|
||
let trousers = worn(leg("pant_ll"), [0, 0, 0]);
|
||
let robe_trousers = worn(leg("robetrouser_ll"), [0, 0, 1]);
|
||
let shoes = worn(
|
||
[
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some("shoe_ll"),
|
||
Some("shoe_fo"),
|
||
],
|
||
[0, 0, 0],
|
||
);
|
||
let boots = worn(
|
||
[
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some("boot_ll"),
|
||
Some("boot_fo"),
|
||
],
|
||
[3, 0, 0],
|
||
);
|
||
|
||
fn g6(eq: &[Option<&ItemDisplay>; 8]) -> Vec<(i8, String)> {
|
||
equip_blits(eq, None, false)
|
||
.iter()
|
||
.filter(|s| s.layer == 6)
|
||
.map(|s| {
|
||
let (c, t) = worn_cell(s);
|
||
(c, t.to_string())
|
||
})
|
||
.collect()
|
||
}
|
||
/// The expected cells, spelled the way the assertions read.
|
||
fn cells(want: &[(i8, &str)]) -> Vec<(i8, String)> {
|
||
want.iter().map(|(c, t)| (*c, t.to_string())).collect()
|
||
}
|
||
|
||
// The report: a robe with sandals (no boot geoset) and with real boots. Either way the robe
|
||
// is last, so the skirt's hem is the robe's own art.
|
||
let eq = [
|
||
None,
|
||
Some(&robe),
|
||
None,
|
||
None,
|
||
Some(&shoes),
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
assert_eq!(
|
||
g6(&eq),
|
||
cells(&[(2, "shoe_ll"), (4, "robe_ll")]),
|
||
"sandals under the robe"
|
||
);
|
||
let eq = [
|
||
None,
|
||
Some(&robe),
|
||
None,
|
||
None,
|
||
Some(&boots),
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
assert_eq!(
|
||
g6(&eq),
|
||
cells(&[(3, "boot_ll"), (4, "robe_ll")]),
|
||
"boots under the robe too"
|
||
);
|
||
|
||
// The control: a plain chest does NOT get lifted, so boots still cover ordinary trousers.
|
||
let eq = [
|
||
None,
|
||
Some(&plain_chest),
|
||
None,
|
||
Some(&trousers),
|
||
Some(&boots),
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
assert_eq!(
|
||
g6(&eq),
|
||
cells(&[(0, "pant_ll"), (1, "chest_ll"), (3, "boot_ll")]),
|
||
"footwear still paints over trousers"
|
||
);
|
||
|
||
// Trousers carrying their own robe bit: 3 under a robe chest, 4 when there is no robe over
|
||
// them. The first case collides with a geoset-boot's 3 — one cell holds one record, and the
|
||
// later writer (the boots) wins, exactly as `0x478900` overwrites.
|
||
let eq = [
|
||
None,
|
||
Some(&robe),
|
||
None,
|
||
Some(&robe_trousers),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
assert_eq!(g6(&eq), cells(&[(3, "robetrouser_ll"), (4, "robe_ll")]));
|
||
let eq = [
|
||
None,
|
||
Some(&plain_chest),
|
||
None,
|
||
Some(&robe_trousers),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
assert_eq!(g6(&eq), cells(&[(1, "chest_ll"), (4, "robetrouser_ll")]));
|
||
}
|
||
|
||
/// `0x478ad0`'s two gates: a `-1` cell is a hard "this slot never touches this layer", and the
|
||
/// texture test is on the **string**, not the pointer — an empty name contributes nothing.
|
||
#[test]
|
||
fn equip_blits_drops_ungated_and_empty_contributions() {
|
||
let odd = worn(
|
||
[None, None, None, Some("boot_tu"), None, None, None, None],
|
||
[0, 0, 0],
|
||
);
|
||
let eq = [None, None, None, None, Some(&odd), None, None, None];
|
||
assert!(
|
||
equip_blits(&eq, None, false).is_empty(),
|
||
"a -1 cell drops the contribution entirely"
|
||
);
|
||
|
||
let blank = worn(
|
||
[None, None, None, Some(""), None, None, None, None],
|
||
[0, 0, 0],
|
||
);
|
||
let eq = [None, Some(&blank), None, None, None, None, None, None];
|
||
assert!(
|
||
equip_blits(&eq, None, false).is_empty(),
|
||
"an empty texture name is not a contribution"
|
||
);
|
||
}
|
||
|
||
/// **B326.** The region filename resolves `_U` FIRST and falls back to the wearer's gender letter
|
||
/// only when `_U` is absent (wow-re RF-0088 §7) — the inverse of what benilla shipped. On the 43
|
||
/// basenames that carry both, the gendered art is dead: `Leather_A_02_Pant_LL_F` is 18 rows
|
||
/// shorter than its `_U`, and preferring it left a bare ring below a night elf female's knee.
|
||
#[test]
|
||
fn region_textures_resolve_unisex_before_the_gender_letter() {
|
||
let female = equip_region_candidates(6, "Leather_A_02_Pant_LL", 1);
|
||
assert_eq!(
|
||
female,
|
||
[
|
||
"Item\\TextureComponents\\LegLowerTexture\\Leather_A_02_Pant_LL_U.blp",
|
||
"Item\\TextureComponents\\LegLowerTexture\\Leather_A_02_Pant_LL_F.blp",
|
||
]
|
||
);
|
||
let male = equip_region_candidates(6, "Leather_A_02_Pant_LL", 0);
|
||
assert!(male[0].ends_with("_U.blp") && male[1].ends_with("_M.blp"));
|
||
// There is no third attempt anywhere in the image — a bare name never resolves.
|
||
assert_eq!(male.len(), 2);
|
||
}
|
||
|
||
/// A single-level RGBA helper for the blit test.
|
||
fn chain(width: u32, height: u32, px: Vec<u8>) -> BlpMipChain {
|
||
BlpMipChain {
|
||
width,
|
||
height,
|
||
texels: crate::BlpTexels::Rgba8Unorm,
|
||
mips: vec![px],
|
||
}
|
||
}
|
||
|
||
/// `blit_over`: an opaque overlay REPLACEs the tile; an alpha overlay source-over blends; a fully
|
||
/// transparent texel leaves the base; pixels outside the tile are untouched.
|
||
#[test]
|
||
fn blit_over_replaces_blends_and_clamps() {
|
||
// 2×2 mid-grey opaque base (4 px of RGBA [128,128,128,255]).
|
||
let mut dst = chain(2, 2, [128, 128, 128, 255].repeat(4));
|
||
|
||
// 1×1 opaque red at tile (0,0,1,1) → replaces pixel 0, leaves the rest.
|
||
blit_over(&mut dst, &chain(1, 1, vec![255, 0, 0, 255]), (0, 0, 1, 1));
|
||
assert_eq!(
|
||
&dst.mips[0][0..4],
|
||
&[255, 0, 0, 255],
|
||
"opaque overlay replaces"
|
||
);
|
||
assert_eq!(
|
||
&dst.mips[0][4..8],
|
||
&[128, 128, 128, 255],
|
||
"neighbour untouched"
|
||
);
|
||
|
||
// 1×1 half-alpha blue at (1,1) → blends 50/50 with grey: R≈64, B≈191, stays opaque.
|
||
blit_over(&mut dst, &chain(1, 1, vec![0, 0, 255, 128]), (1, 1, 1, 1));
|
||
let px = &dst.mips[0][12..16];
|
||
assert!((px[0] as i32 - 64).abs() <= 1, "blended R ≈ 128·(1−a)");
|
||
assert!(
|
||
(px[2] as i32 - 191).abs() <= 1,
|
||
"blended B ≈ 255·a + 128·(1−a)"
|
||
);
|
||
assert_eq!(px[3], 255, "over an opaque base the result stays opaque");
|
||
|
||
// A fully transparent overlay texel is a no-op (the base shows through).
|
||
let before = dst.mips[0].clone();
|
||
blit_over(&mut dst, &chain(1, 1, vec![1, 2, 3, 0]), (0, 0, 1, 1));
|
||
assert_eq!(dst.mips[0], before, "transparent texel is a no-op");
|
||
}
|
||
|
||
/// End-to-end regression on the **real** build-5875 files: compositing a Human-male body must yield
|
||
/// a 256² mip pyramid whose head (g8/g9) + pelvis (g5) tiles are overlaid from the base, while a
|
||
/// control tile that carries no naked-body overlay (g3) is untouched. Guards the section→tile map +
|
||
/// the CharSections schema against a silent break (a wrong tile or a shifted column would move/lose
|
||
/// these diffs). Skips when the client data isn't present.
|
||
#[test]
|
||
fn composite_body_overlays_land_on_real_human_male() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let cs = CharSections::load(&mut chain).expect("load CharSections");
|
||
let base =
|
||
read_texture_mip_chain(&mut chain, "Character\\Human\\Male\\HumanMaleSkin00_03.blp")
|
||
.expect("read base skin");
|
||
let comp = cs
|
||
.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, [None; 8], None, false)
|
||
.expect("composite ok")
|
||
.expect("base skin row present");
|
||
|
||
assert_eq!((comp.width, comp.height), (256, 256), "atlas is 256²");
|
||
assert_eq!(comp.mips.len(), base.mips.len(), "keeps the base mip count");
|
||
|
||
// Count mip-0 pixels in a tile that differ from the base atlas.
|
||
let changed = |t: Tile| {
|
||
let (x, y, w, h) = t;
|
||
let (b, c) = (&base.mips[0], &comp.mips[0]);
|
||
(y..y + h)
|
||
.flat_map(|row| (x..x + w).map(move |col| (row, col)))
|
||
.filter(|&(row, col)| {
|
||
let i = ((row * 256 + col) * 4) as usize;
|
||
b[i..i + 4] != c[i..i + 4]
|
||
})
|
||
.count()
|
||
};
|
||
// Face replaces the head tiles; pelvis replaces g5 — each should change most of its tile.
|
||
assert!(changed(TILE_G9) > 4000, "face lower overlaid into g9");
|
||
assert!(changed(TILE_G8) > 2000, "face upper overlaid into g8");
|
||
assert!(changed(TILE_G5) > 4000, "pelvis overlaid into g5");
|
||
// The torso tile is the underwear's second column — and **no male row authors it** (the bra is
|
||
// female-only; every sectionType-4 male row leaves `TextureName[1]` empty), so a human male's g3
|
||
// must stay byte-identical to the base. The female half is the next test.
|
||
assert_eq!(
|
||
changed(TILE_G3),
|
||
0,
|
||
"no male underwear row authors the naked-torso column"
|
||
);
|
||
|
||
// Hair-mesh texture (decision 0045): a real hairstyle resolves a `Hair…` BLP; the bald style
|
||
// (variation 0) has none. Guards the `SECTION_HAIR` constant + the type-3 row keying.
|
||
let hair = cs
|
||
.hair_texture(1, 0, 1, 0)
|
||
.expect("hairStyle 1 has a hair texture");
|
||
assert!(
|
||
hair.contains("Hair"),
|
||
"hair texture is a Hair BLP, got {hair:?}"
|
||
);
|
||
assert_eq!(
|
||
cs.hair_texture(1, 0, 0, 0),
|
||
None,
|
||
"bald style has no hair texture"
|
||
);
|
||
|
||
// The type-6 MESH resolver's substitute (decision 0536, byte-verified wow-re `rf84`): when the
|
||
// selected style resolves nothing, the client's binder has already bound variation **1** and an
|
||
// empty name leaves that slot untouched. A bald orc/gnome male still wears a beard, and on those
|
||
// races the beard is geometry on the hair unit — so the blank bald row must not leave it
|
||
// untextured (the flat-white bug: decision 0157's fallback showing through).
|
||
for (race, sex, name) in [(2u8, 0u8, "Orc"), (7, 0, "Gnome")] {
|
||
let styled = cs
|
||
.hair_mesh_texture(race, sex, 1, 0)
|
||
.unwrap_or_else(|| panic!("{name} hairStyle 1 has a hair sheet"));
|
||
let bald = cs
|
||
.hair_mesh_texture(race, sex, 0, 0)
|
||
.unwrap_or_else(|| panic!("{name} bald must still resolve a sheet for the beard"));
|
||
assert_eq!(
|
||
bald, styled,
|
||
"{name} carries the style in the geometry — one sheet per colour"
|
||
);
|
||
assert!(
|
||
bald.contains("Hair00_00"),
|
||
"{name} sheet is Hair00_00, got {bald:?}"
|
||
);
|
||
// The fallback tracks hair COLOUR (the beard is hair-coloured), not just any row.
|
||
let bald_c2 = cs
|
||
.hair_mesh_texture(race, sex, 0, 2)
|
||
.unwrap_or_else(|| panic!("{name} bald at colour 2 resolves"));
|
||
assert!(
|
||
bald_c2.contains("Hair00_02"),
|
||
"{name} colour 2 sheet, got {bald_c2:?}"
|
||
);
|
||
assert_ne!(bald, bald_c2, "{name} colour must change the sheet");
|
||
}
|
||
|
||
// Human authors TWO distinct sheets per colour, which is exactly where the literal-1 substitute
|
||
// is load-bearing: a bald human male resolves variation 1's sheet specifically, not "whichever
|
||
// non-blank row we happened to find". The earlier interim searched for a unique non-blank
|
||
// variation and returned None here — behaviourally inert (a bald human's facial hair is a
|
||
// painted overlay, so there is no type-6 geometry to texture) but not the client's mechanism.
|
||
let human_bald = cs
|
||
.hair_mesh_texture(1, 0, 0, 0)
|
||
.expect("the substitute resolves variation 1 even where a race authors several sheets");
|
||
assert_eq!(
|
||
Some(human_bald),
|
||
cs.hair_texture(1, 0, HAIR_SUBSTITUTE_VARIATION, 0),
|
||
"the substitute is variation 1, not an arbitrary non-blank row"
|
||
);
|
||
assert!(
|
||
human_bald.contains("Hair03_00"),
|
||
"human male variation 1 is the Hair03 sheet, got {human_bald:?}"
|
||
);
|
||
// A real style is never touched by the substitute, on any race.
|
||
assert_eq!(
|
||
cs.hair_mesh_texture(1, 0, 1, 0),
|
||
cs.hair_texture(1, 0, 1, 0)
|
||
);
|
||
|
||
// Extra-skin texture (the tauren fur, M2 type 8): the type-0 row's SECOND column. Tauren male
|
||
// skinColor 0 resolves the real build-5875 `_Extra` BLP; a race that doesn't author the column
|
||
// (human) yields None — its models carry no type-8 batch.
|
||
assert_eq!(
|
||
cs.skin_extra_texture(6, 0, 0),
|
||
Some("Character\\Tauren\\Male\\TaurenMaleSkin00_00_Extra.blp"),
|
||
"tauren male extra skin resolves"
|
||
);
|
||
assert_eq!(
|
||
cs.skin_extra_texture(1, 0, 0),
|
||
None,
|
||
"human male authors no extra skin"
|
||
);
|
||
|
||
// skinColor 0/1 must resolve the standard chargen skin, NOT the EXTRA-flagged `…_100/_101` row
|
||
// sharing the same colorIndex (the flags-precedence fix).
|
||
assert_eq!(
|
||
cs.skin_texture(1, 0, 0),
|
||
Some("Character\\Human\\Male\\HumanMaleSkin00_00.blp"),
|
||
"skinColor 0 is the standard skin, not the EXTRA-flagged _100"
|
||
);
|
||
assert_eq!(
|
||
cs.skin_texture(1, 0, 1),
|
||
Some("Character\\Human\\Male\\HumanMaleSkin00_01.blp")
|
||
);
|
||
}
|
||
|
||
/// The underwear section spends **both** its texture columns (bug B325): `TextureName[0]` is the
|
||
/// pelvis (g5) and `TextureName[1]` the naked **torso** (g3) — the bra, authored only on the female
|
||
/// rows. Before this, a bare-chested female composited panties and nothing above them.
|
||
///
|
||
/// The pin is byte-exact rather than a diff count: `…NakedTorsoSkin00_00.blp` is a 128×64 fully
|
||
/// opaque sheet (BLP alphaDepth 0 → the client's REPLACE), so if it lands at the right tile from its
|
||
/// own origin the composite's g3 rect must equal the file's mip 0 pixel-for-pixel. A wrong tile, a
|
||
/// wrong column, or a src-origin slip all break that equality.
|
||
#[test]
|
||
fn composite_body_dresses_the_female_torso_underwear() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let cs = CharSections::load(&mut chain).expect("load CharSections");
|
||
// Night elf female, skinColor 0 — the appearance the B325 report shipped a screenshot of.
|
||
let (race, sex, skin) = (4u8, 1u8, 0u8);
|
||
assert_eq!(
|
||
cs.tex(race, sex, SECTION_UNDERWEAR, 0, skin, 1),
|
||
Some("Character\\NightElf\\Female\\NightElfFemaleNakedTorsoSkin00_00.blp"),
|
||
"the sectionType-4 row's second column is the naked torso"
|
||
);
|
||
let torso = read_texture_mip_chain(
|
||
&mut chain,
|
||
"Character\\NightElf\\Female\\NightElfFemaleNakedTorsoSkin00_00.blp",
|
||
)
|
||
.expect("read naked torso");
|
||
assert_eq!(
|
||
(torso.width, torso.height),
|
||
(128, 64),
|
||
"the sheet is authored exactly tile-sized"
|
||
);
|
||
let comp = cs
|
||
.composite_body(
|
||
&mut chain, race, sex, skin, 0, 0, 0, 0, [None; 8], None, false,
|
||
)
|
||
.expect("composite ok")
|
||
.expect("base skin row present");
|
||
|
||
let (tx, ty, tw, th) = TILE_G3;
|
||
for row in 0..th {
|
||
let d = ((ty + row) * 256 + tx) as usize * 4;
|
||
let s = (row * tw) as usize * 4;
|
||
assert_eq!(
|
||
&comp.mips[0][d..d + tw as usize * 4],
|
||
&torso.mips[0][s..s + tw as usize * 4],
|
||
"g3 row {row} is the naked-torso sheet verbatim"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The underwear is the tile's **fallback**, and only the group's TESTED columns suppress it
|
||
/// (wow-re RF-0086; the byte-derived prefix in [`UNDERWEAR_TILES`]). This is the discriminating
|
||
/// pin, because the shipped art cannot make it: a real chest or a real pair of pants repaints
|
||
/// its whole tile opaquely, so "the underwear was suppressed" and "the underwear was painted
|
||
/// over" produce the same pixels.
|
||
///
|
||
/// So the fixture is a display that **occupies a region column while painting nothing** — its
|
||
/// texture name resolves to no shipped file, so `read_equip_region` finds nothing to blit while
|
||
/// the column is still taken. That is the client's own gate: `0x4772f0`/`0x4773a0` test the
|
||
/// composite *cell*, not the equipped item. The same name at a different bodyslot lands in a
|
||
/// different column, and the outcome flips — which is the whole claim.
|
||
#[test]
|
||
fn only_the_tested_equipment_columns_suppress_the_underwear() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let cs = CharSections::load(&mut chain).expect("load CharSections");
|
||
let (race, sex, skin) = (4u8, 1u8, 0u8); // night elf female, skinColor 0
|
||
let dir = "Character\\NightElf\\Female\\NightElfFemale";
|
||
let mut read = |n: &str| {
|
||
read_texture_mip_chain(&mut chain, &format!("{dir}{n}00_00.blp")).expect("read sheet")
|
||
};
|
||
let (base, torso, pelvis) = (
|
||
read("Skin"),
|
||
read("NakedTorsoSkin"),
|
||
read("NakedPelvisSkin"),
|
||
);
|
||
|
||
// The mip-0 pixels of a tile, out of a `stride`-wide image.
|
||
let rect = |img: &BlpMipChain, (x, y, w, h): Tile, stride: u32| -> Vec<u8> {
|
||
(0..h)
|
||
.flat_map(|r| {
|
||
let o = (((y + r) * stride + x) * 4) as usize;
|
||
img.mips[0][o..o + (w * 4) as usize].to_vec()
|
||
})
|
||
.collect()
|
||
};
|
||
let sheet = |img: &BlpMipChain| rect(img, (0, 0, 128, 64), 128);
|
||
let (torso_sheet, pelvis_sheet) = (sheet(&torso), sheet(&pelvis));
|
||
let (base_g3, base_g5) = (rect(&base, TILE_G3, 256), rect(&base, TILE_G5, 256));
|
||
|
||
// A display that TAKES the column without painting it (see the doc above).
|
||
let occupies = |layers: &[usize]| {
|
||
let mut region_textures: [Option<String>; 8] = Default::default();
|
||
for l in layers {
|
||
region_textures[*l] = Some("benilla-no-such-region".into());
|
||
}
|
||
ItemDisplay {
|
||
region_textures,
|
||
..Default::default()
|
||
}
|
||
};
|
||
// bodyslot − 2: 0 shirt · 1 chest · 2 belt · 3 pants · 7 tabard.
|
||
let (shirt, chest, belt, pants, tabard) = (
|
||
occupies(&[3]),
|
||
occupies(&[3, 5]), // a robe reaches both groups
|
||
occupies(&[5]),
|
||
occupies(&[5]),
|
||
occupies(&[3]),
|
||
);
|
||
fn worn<'a>(slots: &[(usize, &'a ItemDisplay)]) -> [Option<&'a ItemDisplay>; 8] {
|
||
let mut e: [Option<&ItemDisplay>; 8] = [None; 8];
|
||
for (i, d) in slots {
|
||
e[*i] = Some(d);
|
||
}
|
||
e
|
||
}
|
||
|
||
// Byte-exact, but reported as a texel count and a name — a raw 32 KiB `assert_eq!` dump of a
|
||
// 128×64 RGBA tile is unreadable, and the useful fact is *which* of the three it is not.
|
||
let differing = |got: &[u8], want: &[u8]| {
|
||
got.as_chunks::<4>()
|
||
.0
|
||
.iter()
|
||
.zip(want.as_chunks::<4>().0)
|
||
.filter(|(a, b)| a != b)
|
||
.count()
|
||
};
|
||
// (label, equipment, expected g3, expected g5)
|
||
type Want<'a> = (&'a str, &'a Vec<u8>);
|
||
let cases: [(&str, [Option<&ItemDisplay>; 8], Want, Want); 6] = [
|
||
(
|
||
"naked",
|
||
worn(&[]),
|
||
("the bra", &torso_sheet),
|
||
("the panties", &pelvis_sheet),
|
||
),
|
||
// Shirt is TorsoUpper column 0 — tested. Torso suppressed; the pelvis is a different group.
|
||
(
|
||
"shirt",
|
||
worn(&[(0, &shirt)]),
|
||
("bare skin", &base_g3),
|
||
("the panties", &pelvis_sheet),
|
||
),
|
||
// A tabard occupies TorsoUpper too, but past the tested prefix: the bra still draws.
|
||
(
|
||
"tabard",
|
||
worn(&[(7, &tabard)]),
|
||
("the bra", &torso_sheet),
|
||
("the panties", &pelvis_sheet),
|
||
),
|
||
// Legs is LegUpper column 0 — tested. Pelvis suppressed, torso untouched.
|
||
(
|
||
"pants",
|
||
worn(&[(3, &pants)]),
|
||
("the bra", &torso_sheet),
|
||
("bare skin", &base_g5),
|
||
),
|
||
// A belt is LegUpper column 2, past the prefix: the panties survive it.
|
||
(
|
||
"belt",
|
||
worn(&[(2, &belt)]),
|
||
("the bra", &torso_sheet),
|
||
("the panties", &pelvis_sheet),
|
||
),
|
||
// A robe reaches both groups at column 1, and suppresses both.
|
||
(
|
||
"chest",
|
||
worn(&[(1, &chest)]),
|
||
("bare skin", &base_g3),
|
||
("bare skin", &base_g5),
|
||
),
|
||
];
|
||
for (label, equipment, (g3_want, g3), (g5_want, g5)) in cases {
|
||
let comp = cs
|
||
.composite_body(
|
||
&mut chain, race, sex, skin, 0, 0, 0, 0, equipment, None, false,
|
||
)
|
||
.expect("composite ok")
|
||
.expect("base skin row present");
|
||
for (tile, name, want_name, want) in [
|
||
(TILE_G3, "torso", g3_want, g3),
|
||
(TILE_G5, "pelvis", g5_want, g5),
|
||
] {
|
||
let n = differing(&rect(&comp, tile, 256), want);
|
||
assert_eq!(
|
||
n, 0,
|
||
"{label}: the {name} tile is not {want_name} ({n}/8192 texels)"
|
||
);
|
||
}
|
||
}
|
||
// The fixture only means something if the three expectations are actually distinguishable.
|
||
assert_ne!(torso_sheet, base_g3, "the bra differs from bare skin");
|
||
assert_ne!(pelvis_sheet, base_g5, "the panties differ from bare skin");
|
||
}
|
||
|
||
/// The guild emblem on the **real** files (decision 1704). Display **20621** is the row item
|
||
/// 5976 *Guild Tabard* wears, and the only guild-emblem display any 1.12.1 item template points
|
||
/// at. Three things this pins that the synthetic plan tests cannot:
|
||
///
|
||
/// 1. the shipped row really does set the flag (so the gate is not vacuous on real data);
|
||
/// 2. all six `Textures\GuildEmblems\` names an emblem builds resolve, and each is authored
|
||
/// **exactly tile-sized** — the RF-0067 overlay src convention (`src = (0,0)`, extent =
|
||
/// the tile), which is what lets them blit from their own origin;
|
||
/// 3. the emblem repaints the two torso tiles and **nothing else** — no arm, leg, head or foot
|
||
/// texel moves — and a different index set paints a different tabard.
|
||
#[test]
|
||
fn composite_body_paints_the_guild_emblem_on_the_real_tabard() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let cs = CharSections::load(&mut chain).expect("load CharSections");
|
||
let items = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo");
|
||
let tabard = items.get(20621).expect("the Guild Tabard display");
|
||
assert!(
|
||
tabard.takes_guild_emblem(),
|
||
"display 20621 is the guild-emblem tabard"
|
||
);
|
||
assert!(
|
||
!items.get(9891).expect("shirt display").takes_guild_emblem(),
|
||
"an ordinary garment is not"
|
||
);
|
||
|
||
let emblem = GuildEmblem {
|
||
emblem_style: 42,
|
||
emblem_color: 3,
|
||
border_style: 1,
|
||
border_color: 7,
|
||
background_color: 12,
|
||
};
|
||
let equipment: [Option<&ItemDisplay>; 8] =
|
||
[None, None, None, None, None, None, None, Some(tabard)];
|
||
|
||
// (2) — every layer resolves, and each is authored at its tile's exact extent.
|
||
for step in equip_blits(&equipment, Some(emblem), false) {
|
||
let path = step
|
||
.candidates(0)
|
||
.into_iter()
|
||
.find(|p| read_texture_mip_chain(&mut chain, p).is_ok())
|
||
.unwrap_or_else(|| panic!("{:?} resolves no file", step.source));
|
||
let mips = read_texture_mip_chain(&mut chain, &path).expect("re-read");
|
||
let (_, _, tw, th) = EQUIP_TILES[step.layer];
|
||
assert_eq!(
|
||
(mips.width, mips.height),
|
||
(tw, th),
|
||
"{path} is authored tile-sized"
|
||
);
|
||
}
|
||
|
||
// (3) — the repaint is confined to the two torso tiles.
|
||
let mut compose = |em: Option<GuildEmblem>| {
|
||
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment, em, false)
|
||
.expect("composite ok")
|
||
.expect("base skin row present")
|
||
};
|
||
let plain = compose(None);
|
||
let crested = compose(Some(emblem));
|
||
let in_torso = |i: usize| {
|
||
let (x, y) = ((i % 256) as u32, (i / 256) as u32);
|
||
[EQUIP_TILES[3], EQUIP_TILES[4]]
|
||
.iter()
|
||
.any(|(tx, ty, tw, th)| x >= *tx && x < tx + tw && y >= *ty && y < ty + th)
|
||
};
|
||
let moved: Vec<usize> = plain.mips[0]
|
||
.as_chunks::<4>()
|
||
.0
|
||
.iter()
|
||
.zip(crested.mips[0].as_chunks::<4>().0)
|
||
.enumerate()
|
||
.filter(|(_, (a, b))| a != b)
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
assert!(
|
||
!moved.is_empty(),
|
||
"the emblem must repaint something — a silent no-op is the bug this fixes"
|
||
);
|
||
let strays: Vec<usize> = moved.iter().copied().filter(|i| !in_torso(*i)).collect();
|
||
assert!(
|
||
strays.is_empty(),
|
||
"the emblem repainted {} texel(s) outside the torso tiles, first at {:?}",
|
||
strays.len(),
|
||
strays.first().map(|i| (i % 256, i / 256))
|
||
);
|
||
|
||
// …and the indices are actually consumed: a different background is a different tabard.
|
||
let other = compose(Some(GuildEmblem {
|
||
background_color: 30,
|
||
..emblem
|
||
}));
|
||
assert_ne!(
|
||
crested.mips[0], other.mips[0],
|
||
"the background index must reach the file name"
|
||
);
|
||
|
||
// **Background 29 ships UPPERCASE and has no lowercase sibling** — `BACKGROUND_29_TU_U.blp`
|
||
// is the one odd name in 6118 files (wow-re `rf89`). A case-SENSITIVE chain lookup would
|
||
// lose exactly one background colour on exactly one tile, which is the kind of defect that
|
||
// reads as "that guild's tabard is subtly wrong" and nothing else. Ours is insensitive;
|
||
// this is the tripwire that keeps it that way.
|
||
let odd = GuildEmblem {
|
||
background_color: 29,
|
||
..emblem
|
||
};
|
||
for half in ["TU", "TL"] {
|
||
let path = EmblemLayer::Background.path(&odd, half);
|
||
assert!(
|
||
read_texture_mip_chain(&mut chain, &path).is_ok(),
|
||
"{path} must resolve despite the shipped name's casing"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Equipment layers on the **real** files (decision 0074): dressing the Human male in One's
|
||
/// starter kit (shirt 9891 / pants 9892 / boots 10141) must repaint exactly the tiles those
|
||
/// displays' region columns name — torso from the shirt, LegUpper from the pants, Foot from the
|
||
/// boots — and leave a tile no item touches (g2 Hand) byte-identical to the naked composite.
|
||
/// Also pins the boots-over-pants stacking on the shared LegLower tile (both contribute; the
|
||
/// priority table puts the boot layer on top, so g6 must differ from the pants-only composite).
|
||
#[test]
|
||
fn composite_body_equipment_layers_land_on_real_human_male() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let cs = CharSections::load(&mut chain).expect("load CharSections");
|
||
let items = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo");
|
||
let (shirt, pants, boots) = (
|
||
items.get(9891).expect("shirt display"),
|
||
items.get(9892).expect("pants display"),
|
||
items.get(10141).expect("boots display"),
|
||
);
|
||
let mut compose = |equipment: [Option<&ItemDisplay>; 8]| {
|
||
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment, None, false)
|
||
.expect("composite ok")
|
||
.expect("base skin row present")
|
||
};
|
||
let naked = compose([None; 8]);
|
||
// bodyslot-2 indexing: shirt = slot 2 (idx 0), pants = slot 5 (idx 3), boots = slot 6 (idx 4).
|
||
let mut equipment: [Option<&ItemDisplay>; 8] = [None; 8];
|
||
equipment[0] = Some(shirt);
|
||
equipment[3] = Some(pants);
|
||
let pants_only = compose(equipment);
|
||
equipment[4] = Some(boots);
|
||
let dressed = compose(equipment);
|
||
|
||
let changed = |a: &BlpMipChain, b: &BlpMipChain, t: Tile| {
|
||
let (x, y, w, h) = t;
|
||
(y..y + h)
|
||
.flat_map(|row| (x..x + w).map(move |col| (row, col)))
|
||
.filter(|&(row, col)| {
|
||
let i = ((row * 256 + col) * 4) as usize;
|
||
a.mips[0][i..i + 4] != b.mips[0][i..i + 4]
|
||
})
|
||
.count()
|
||
};
|
||
assert!(
|
||
changed(&naked, &dressed, EQUIP_TILES[3]) > 2000,
|
||
"shirt repaints TorsoUpper (g3)"
|
||
);
|
||
assert!(
|
||
changed(&naked, &dressed, EQUIP_TILES[5]) > 2000,
|
||
"pants repaint LegUpper (g5)"
|
||
);
|
||
assert!(
|
||
changed(&naked, &dressed, EQUIP_TILES[7]) > 1000,
|
||
"boots repaint Foot (g7)"
|
||
);
|
||
assert_eq!(
|
||
changed(&naked, &dressed, EQUIP_TILES[2]),
|
||
0,
|
||
"nothing touches Hand (g2)"
|
||
);
|
||
assert!(
|
||
changed(&pants_only, &dressed, EQUIP_TILES[6]) > 1000,
|
||
"boots stack over the pants' LegLower (g6)"
|
||
);
|
||
}
|
||
}
|