the missing bra, the robe's hem, and a doll that puts its arrow down
- the missing bra: the underwear section's CharSections row spends BOTH of its texture columns and the body compositor read only the first — the pelvis — so a bare-chested female composited panties and then bare skin above them; the second column is torso art authored on all eight female race rows and no male row, which is how the male-only regression test stayed green over the missing half, and the new test is byte-exact — the composite's torso tile must equal the shipped BLP's mip 0 verbatim - underwear is the tile's FALLBACK, not an under-layer: the reference tests a prefix of a tile's equipment cells and skips the underwear blit entirely when any is occupied, while cells past the prefix stack on top of an underwear that was allowed to draw — so a shirt or chest suppresses the bra, trousers the panties, but a belt never hides the panties and a plain tabard never hides the bra; benilla blitted underwear first and equipment over it, which coincides on most gear and is still the wrong law - a robe outranks footwear on the lower-leg tile: the equipment stacking table is a DEFAULT that two of the eight layer choosers overrule, and the robe bit lifts a chest to cell 4 over a boot's 3, so sandals stop repainting a robe's hem; and `_U` outranks the gendered file — the reference probes the universal name first and patches the gender letter in only on a miss (43 of 7944 shipped basenames carry both, and on those the gendered art is dead), which closes the bare-skin ring a shorter `_F` pant file left below the knee; `benilla-extract charatlas` prints the blits in order with the file each region resolved to, reading the same `equip_blits` law the compositor walks - a model widget edits its copy the way the reference does: fifteen attach ids reset off every duplicate — blood, breath, the nameplates, the spell hands, and the nocked arrow — and the hand grips arm from attachment occupancy, where ours sat hardcoded open under a comment claiming the pane sheaths its weapons (it never did); the character doll stops drawing a hunter's arrow and closes the hand around the bow, and the booth bake log now names its attach ids (`at=[2,35] grip=[false,true]`), which is also the reset's retest readout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -118,7 +118,7 @@ pub(crate) use attach::equip_slot;
|
||||
pub(crate) use equipment::ItemDisplays;
|
||||
pub(crate) use equipment::{BoneAttach, Equipment};
|
||||
// The instruments' read of what a body is actually WEARING vs what it resolved (`WOW_DRESS_CENSUS`).
|
||||
pub(crate) use equipment::{HeldAttached, ATTACH_SLOT_NAMES};
|
||||
pub(crate) use equipment::{attach_id, HeldAttached, ATTACH_SLOT_NAMES};
|
||||
|
||||
/// The overhead attachment slot (`PlayerName`, id 18). The anchor of the overhead name, the
|
||||
/// floating combat text, and the questgiver marker alike.
|
||||
|
||||
@@ -323,6 +323,9 @@ fn spawn_billboard_part(
|
||||
// glow card prunes with the mount (`DressedLook::collect`).
|
||||
seat: crate::portrait::PortraitSeat::Body,
|
||||
kind: info.kind,
|
||||
// A batch of the HOST model, not a sub-model in an attachment node — the
|
||||
// reference's attach reset walks the attachment list and cannot reach it.
|
||||
attach: None,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
@@ -51,38 +51,41 @@ const PLAYER_HELD_SLOTS: [u8; HELD_SLOTS] = [15, 16, 17];
|
||||
/// M2 attachment-point ids (empirically pinned on `HumanMale.m2` — decision 0072): the drawn-hand
|
||||
/// points, the shield forearm, and the sheathed family the client's `0x47a070` jump table selects
|
||||
/// from (`K − (mainhand)` over `K ∈ {27, 31, 33}`, const 28 for the shield).
|
||||
pub(in crate::entities) mod attach_id {
|
||||
/// `pub(crate)` because the ids are a shared vocabulary, not this module's private numbering:
|
||||
/// the model panes filter the reference's attach reset and probe hand occupancy by them
|
||||
/// ([`crate::portrait::attach_reset`], [`crate::portrait::hand_grip`]).
|
||||
pub(crate) mod attach_id {
|
||||
/// Left forearm — a *drawn* shield.
|
||||
pub(in crate::entities) const SHIELD: u16 = 0;
|
||||
pub(crate) const SHIELD: u16 = 0;
|
||||
/// Right/left shoulder (pivots at ∓0.21 Y, shoulder height) — the pauldron pair.
|
||||
pub(in crate::entities) const SHOULDER_RIGHT: u16 = 5;
|
||||
pub(in crate::entities) const SHOULDER_LEFT: u16 = 6;
|
||||
pub(crate) const SHOULDER_RIGHT: u16 = 5;
|
||||
pub(crate) const SHOULDER_LEFT: u16 = 6;
|
||||
/// Head — the helm.
|
||||
pub(in crate::entities) const HELM: u16 = 11;
|
||||
pub(crate) const HELM: u16 = 11;
|
||||
/// Right hand — the drawn mainhand (and a drawn ranged weapon).
|
||||
pub(in crate::entities) const HAND_RIGHT: u16 = 1;
|
||||
pub(crate) const HAND_RIGHT: u16 = 1;
|
||||
/// Left hand — a drawn non-shield offhand.
|
||||
pub(in crate::entities) const HAND_LEFT: u16 = 2;
|
||||
pub(crate) const HAND_LEFT: u16 = 2;
|
||||
/// Right/left shoulder-blade — the stowed two-hander family (and a stowed ranged weapon).
|
||||
pub(in crate::entities) const BACK_RIGHT: u16 = 26;
|
||||
pub(in crate::entities) const BACK_LEFT: u16 = 27;
|
||||
pub(crate) const BACK_RIGHT: u16 = 26;
|
||||
pub(crate) const BACK_LEFT: u16 = 27;
|
||||
/// Centre back — the stowed shield.
|
||||
pub(in crate::entities) const SHIELD_BACK: u16 = 28;
|
||||
pub(crate) const SHIELD_BACK: u16 = 28;
|
||||
/// Lower-back pair — the stowed staff family.
|
||||
pub(in crate::entities) const BACK_LOWER_MAIN: u16 = 30;
|
||||
pub(in crate::entities) const BACK_LOWER_OFF: u16 = 31;
|
||||
pub(crate) const BACK_LOWER_MAIN: u16 = 30;
|
||||
pub(crate) const BACK_LOWER_OFF: u16 = 31;
|
||||
/// Hip pair — the stowed one-hander family (mainhand on the *left* hip, drawn across the body).
|
||||
pub(in crate::entities) const HIP_MAIN: u16 = 32;
|
||||
pub(in crate::entities) const HIP_OFF: u16 = 33;
|
||||
pub(crate) const HIP_MAIN: u16 = 32;
|
||||
pub(crate) const HIP_OFF: u16 = 33;
|
||||
/// HandArrow (35, bone 126 — flag-0x04 ignore-parent-rotation) — the in-hand nocked arrow's
|
||||
/// ONE body-bone attach (wow-re `nocked-ammo-cancel.md` §E2: `0x712f70(body, 0x23)` from
|
||||
/// `0x60ba30`/the `$BWP` BowPull handler; bow/wand only). The old Special2/Special3 (0x18/
|
||||
/// 0x19) reading is REFUTED — those are the `0x479f40` model-DIRECTORY selectors
|
||||
/// (`Item\ObjectComponents\Ammo\` vs `…\Weapon\`), never attach ids (§E1).
|
||||
pub(in crate::entities) const HAND_ARROW: u16 = 0x23;
|
||||
pub(crate) const HAND_ARROW: u16 = 0x23;
|
||||
/// The quiver-on-back attach (wow-re §H2, byte-verified 3×): the worn ammo container's
|
||||
/// model parents at M2 attachment id 26 — the same point the stowed two-hander family uses.
|
||||
pub(in crate::entities) const QUIVER: u16 = 26;
|
||||
pub(crate) const QUIVER: u16 = 26;
|
||||
}
|
||||
|
||||
/// Item-display rendering: the `ItemDisplayInfo.dbc` catalog (held models + armor region textures,
|
||||
|
||||
@@ -70,15 +70,21 @@ impl SeatWriters<'_, '_> {
|
||||
/// attach point *plus* something model-local — a card's own pivot, a glow slot's point on the
|
||||
/// item — and only the attach-point term moves. The walk is recursive because an item's glow
|
||||
/// instances hang two levels down.
|
||||
fn reseat(&mut self, root: Entity, bone: u16, delta: Vec3) {
|
||||
///
|
||||
/// `attach` is assigned, not delta'd: it names WHICH attachment node the item now sits in, and
|
||||
/// that is what a widget's attach reset filters on ([`crate::portrait::attach_reset`]). A move
|
||||
/// that left it stale would keep, say, a stowed weapon marked with the hand id it left.
|
||||
fn reseat(&mut self, root: Entity, bone: u16, attach: u16, delta: Vec3) {
|
||||
let mut stack = vec![root];
|
||||
while let Some(e) = stack.pop() {
|
||||
if let Ok(mut r) = self.riders.get_mut(e) {
|
||||
r.bone = bone;
|
||||
r.offset += delta;
|
||||
r.attach = Some(attach);
|
||||
}
|
||||
if let Ok(mut c) = self.cards.get_mut(e) {
|
||||
c.bone = bone;
|
||||
c.attach = Some(attach);
|
||||
c.seat = match c.seat {
|
||||
crate::portrait::PortraitSeat::Body => crate::portrait::PortraitSeat::Body,
|
||||
crate::portrait::PortraitSeat::Rider(at) => {
|
||||
@@ -89,10 +95,12 @@ impl SeatWriters<'_, '_> {
|
||||
if let Ok(mut f) = self.effects.get_mut(e) {
|
||||
f.bone = bone;
|
||||
f.offset += delta;
|
||||
f.attach = Some(attach);
|
||||
}
|
||||
if let Ok(mut g) = self.glows.get_mut(e) {
|
||||
g.bone = bone;
|
||||
g.offset += delta;
|
||||
g.attach = attach;
|
||||
}
|
||||
if let Ok(kids) = self.children.get(e) {
|
||||
stack.extend(kids.iter());
|
||||
@@ -237,7 +245,7 @@ pub(in crate::entities) fn attach_held_items(
|
||||
rider.bone = bone;
|
||||
rider.local = offset;
|
||||
}
|
||||
seats.reseat(root, bone, offset - old);
|
||||
seats.reseat(root, bone, n.attach, offset - old);
|
||||
debug!(
|
||||
"held move: unit {entity} display {} → attach {} (bone {bone})",
|
||||
n.display, n.attach
|
||||
@@ -325,9 +333,12 @@ fn spawn_slot(
|
||||
visual: hs.visual,
|
||||
// The item's seat on the BODY, carried so the glow attach can publish its own
|
||||
// booth mirrors at a seat composed from it (decision 0822) — the glow spawns
|
||||
// asynchronously and knows only this root.
|
||||
// asynchronously and knows only this root. The attach id rides along for the
|
||||
// same reason: a glow is chained UNDER the item, so the reference's attach reset
|
||||
// takes it exactly when it takes the item ([`crate::portrait::attach_reset`]).
|
||||
bone,
|
||||
offset,
|
||||
attach: hs.attach,
|
||||
});
|
||||
}
|
||||
// The engine-drawn bowstring (0408 §G2) — for the drawn BOW only: the ranged slot's
|
||||
@@ -497,6 +508,7 @@ fn spawn_slot(
|
||||
material: part.material.clone(),
|
||||
bone,
|
||||
offset,
|
||||
attach: Some(hs.attach),
|
||||
},
|
||||
));
|
||||
if skinned.is_some() {
|
||||
@@ -583,6 +595,7 @@ fn spawn_slot(
|
||||
bone,
|
||||
seat: crate::portrait::PortraitSeat::Rider(offset + info.pivot),
|
||||
kind: info.kind,
|
||||
attach: Some(hs.attach),
|
||||
},
|
||||
));
|
||||
// A card is a batch of the item's model and joins the wearer's appear-fade exactly like
|
||||
@@ -658,6 +671,7 @@ fn spawn_slot(
|
||||
.insert(crate::portrait::PortraitEffects {
|
||||
bone,
|
||||
offset,
|
||||
attach: Some(hs.attach),
|
||||
emitters: dm.emitters.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +156,10 @@ pub(in crate::entities) struct ItemGlow {
|
||||
/// and by the time the glow models load the attach path that knew the body bone is long gone.
|
||||
pub(in crate::entities) bone: u16,
|
||||
pub(in crate::entities) offset: bevy::prelude::Vec3,
|
||||
/// The ITEM's **M2 attachment id** on the body, for the same reason: a glow is chained under
|
||||
/// the item (`0x712f70`), so a widget's attach reset takes the glow exactly when it takes the
|
||||
/// item it hangs on ([`crate::portrait::attach_reset`]).
|
||||
pub(in crate::entities) attach: u16,
|
||||
}
|
||||
|
||||
/// Set once an item root's glow instances are spawned (or resolved to nothing) — the once-only
|
||||
@@ -260,6 +264,7 @@ pub(super) fn attach_item_glows(
|
||||
bone: glow.bone,
|
||||
seat: crate::portrait::PortraitSeat::Rider(seat + info.pivot),
|
||||
kind: info.kind,
|
||||
attach: Some(glow.attach),
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -272,6 +277,7 @@ pub(super) fn attach_item_glows(
|
||||
material: p.material.clone(),
|
||||
bone: glow.bone,
|
||||
offset: seat,
|
||||
attach: Some(glow.attach),
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -283,6 +289,7 @@ pub(super) fn attach_item_glows(
|
||||
.insert(crate::portrait::PortraitEffects {
|
||||
bone: glow.bone,
|
||||
offset: seat,
|
||||
attach: Some(glow.attach),
|
||||
emitters: dm.emitters.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,6 +292,82 @@ pub(super) struct PillCache {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_ui::script::ScriptValue;
|
||||
|
||||
use crate::ui_script::world_state_tests::{harness, push, row};
|
||||
|
||||
/// The readout and the dev cost pill both want the top centre, and before this they drew straight
|
||||
/// through each other (the pill is anchored 8 px down; the readout's first row starts at 15). The
|
||||
/// pill asks the frame itself how far down the band is spoken for ([`top_centre_claimed`]) and
|
||||
/// seats itself below that — so this pins the probe against the shipped XML: nothing claimed while
|
||||
/// the readout is empty, and the readout's real resolved bottom once it is up.
|
||||
///
|
||||
/// **Why it lives here and not beside the frame it drives.** It was written in
|
||||
/// `ui_script/world_state_tests.rs`, next to the readout — but `perf` is compiled out of a player
|
||||
/// build, so the `player-tests` gate (`--no-default-features --lib`, decision 1175) could not
|
||||
/// compile that file, and gating the one test with `#[cfg(feature = "dev")]` trips `run_mode`'s
|
||||
/// dev-plane enforcer (1179: seam knowledge has exactly three addresses, and a gameplay module is
|
||||
/// not one of them). Both laws point the same way — a test of a dev instrument belongs in a dev
|
||||
/// root. It borrows the readout's own `#[cfg(test)]` helpers rather than copying them, so the XML
|
||||
/// it drives stays the shipped one.
|
||||
#[test]
|
||||
fn the_readout_tells_the_dev_pill_how_much_of_the_top_it_uses() {
|
||||
// NOT 768. The layout answers in WoW UI units — a screen that is always 768 units tall
|
||||
// whatever the window is (decision 0582) — and the pill draws in window px, so a probe that
|
||||
// subtracts one from the other is right only when the two happen to coincide. Feeding a
|
||||
// window height that is NOT the virtual one is the whole point of this test: it is what the
|
||||
// director's client does, and the first version of this probe put the pill back on top of the
|
||||
// readout's third row there while passing every test at 768.
|
||||
const SCREEN_H: f32 = 900.0;
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
top_centre_claimed(&s, SCREEN_H),
|
||||
0.0,
|
||||
"hidden readout claims nothing — the pill keeps its usual seat"
|
||||
);
|
||||
|
||||
push(
|
||||
&mut s,
|
||||
vec![
|
||||
row(
|
||||
"Interface\\WorldStateFrame\\AllianceTower",
|
||||
"Towers Controlled: 3",
|
||||
"Alliance Towers Controlled",
|
||||
),
|
||||
row(
|
||||
"Interface\\WorldStateFrame\\HordeTower",
|
||||
"Towers Controlled: 1",
|
||||
"Horde Towers Controlled",
|
||||
),
|
||||
],
|
||||
);
|
||||
s.resolve();
|
||||
let claimed = top_centre_claimed(&s, SCREEN_H);
|
||||
assert!(
|
||||
claimed > 8.0,
|
||||
"two rows reach past the pill's own seat, so the pill must move: {claimed}"
|
||||
);
|
||||
// The frame's own geometry, read back the way the probe reads it: the container's top offset
|
||||
// plus one row per pushed row. Asserted against the XML rather than restated as constants.
|
||||
let expected: f32 = s
|
||||
.eval::<f64>("return (20 + WORLD_STATE_ROW_HEIGHT * 2 + 15) / GetScreenHeight()")
|
||||
.expect("the frame's own numbers") as f32
|
||||
* SCREEN_H;
|
||||
assert!(
|
||||
(claimed - expected).abs() < 0.5,
|
||||
"the claim is the readout's resolved bottom: {claimed} vs {expected}"
|
||||
);
|
||||
|
||||
push(&mut s, Vec::new());
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
top_centre_claimed(&s, SCREEN_H),
|
||||
0.0,
|
||||
"and it hands the band back when the scope empties"
|
||||
);
|
||||
}
|
||||
|
||||
/// The view the HUD draws only advances on the refresh interval — between ticks it holds
|
||||
/// still, which is the entire cost argument (identical snapshots are what the quad cache can
|
||||
|
||||
@@ -54,11 +54,6 @@ use bevy::render::diagnostic::RenderDiagnosticsPlugin;
|
||||
|
||||
pub(crate) use clock::{process_cpu_secs, system_cpu_ticks};
|
||||
pub(crate) use gpu::GpuMsShared;
|
||||
/// The pill's "who else is using this band?" probe, reached by the world-state frame's own tests
|
||||
/// (`ui_script::world_state_tests`) so the two systems that share the top centre are pinned against
|
||||
/// each other rather than each against its own idea of the geometry.
|
||||
#[cfg(test)]
|
||||
pub(crate) use hud::top_centre_claimed;
|
||||
pub(crate) use hud::PerfHud;
|
||||
pub(crate) use journal::FpsJournalPlugin;
|
||||
|
||||
|
||||
@@ -390,8 +390,9 @@ pub(super) fn spawn_booth_model(
|
||||
motion: BoothMotion,
|
||||
// Per-hand weapon grip `[right, left]` — hold that hand's `HandsClosed` finger pose because a weapon
|
||||
// occupies its attach point (the paperdoll rule, wow-re `hand-grip-mechanism.md` §4c). The glue
|
||||
// preview draws its weapons into the hands, so it grips; the still portraits/paper-doll sheath theirs
|
||||
// (decision 0465) → `[false, false]`, hands stay open.
|
||||
// preview and the body panes both resolve it from **attachment occupancy**, which is the whole
|
||||
// rule (`crate::portrait::hand_grip`); the round portraits pass `[false, false]` because the
|
||||
// reference's portrait bake never arms a grip at all, not because they sheath anything.
|
||||
grip: [bool; 2],
|
||||
// Character billboard batches (the undead/night-elf eye-glow) — seated on their billboard bone's
|
||||
// joint and re-faced to the booth camera by [`face_booth_billboards`]. Needs the rig (no bones =
|
||||
|
||||
@@ -217,6 +217,10 @@ pub(crate) struct PortraitRider {
|
||||
pub(crate) bone: u16,
|
||||
/// The attach point's Bevy-space offset under that bone ([`crate::entities::BoneAttach`]).
|
||||
pub(crate) offset: Vec3,
|
||||
/// The **M2 attachment id** this rider's model hangs from on the body — what
|
||||
/// [`attach_reset`] filters on. `None` where the geometry is the host model's own and sits
|
||||
/// in no attachment node at all (see [`PortraitBillboard::attach`]).
|
||||
pub(crate) attach: Option<u16>,
|
||||
}
|
||||
|
||||
/// Stamped on a lightweight **anchor child** the attach path plants under a unit for each
|
||||
@@ -246,6 +250,10 @@ pub(crate) struct PortraitBillboard {
|
||||
pub(crate) bone: u16,
|
||||
pub(crate) seat: PortraitSeat,
|
||||
pub(crate) kind: benilla_formats::BillboardKind,
|
||||
/// The **M2 attachment id** the batch's model hangs from on the body, for [`attach_reset`].
|
||||
/// `None` for a batch of the host model ITSELF (the character's own eye-glow): the reference's
|
||||
/// reset walks the duplicate's *attachment list*, and a geoset batch of the body is not in it.
|
||||
pub(crate) attach: Option<u16>,
|
||||
}
|
||||
|
||||
/// Whose model a mirrored camera-facing batch belongs to — which decides both where its pivot comes
|
||||
@@ -292,6 +300,10 @@ impl PortraitSeat {
|
||||
pub(crate) struct PortraitEffects {
|
||||
/// The BODY bone whose booth joint the effect model's host seats on.
|
||||
pub(crate) bone: u16,
|
||||
/// The **M2 attachment id** the effect-bearing model hangs from on the body, for
|
||||
/// [`attach_reset`]. Every publisher of this marker is an item's model, so it is always
|
||||
/// `Some`; the option shape is the other two markers'.
|
||||
pub(crate) attach: Option<u16>,
|
||||
/// The host's offset in that bone's joint frame (Bevy axes) — the item's attach point, plus the
|
||||
/// glow slot's offset on the item's own model when this is a glow.
|
||||
pub(crate) offset: Vec3,
|
||||
@@ -588,13 +600,27 @@ fn log_bake(
|
||||
effects: &[&PortraitEffects],
|
||||
) {
|
||||
if booth_log() {
|
||||
// The **attach ids** the bake is dressed at, sorted and de-duplicated. Counts alone could
|
||||
// not answer the question `#bugs` B324 asked — *what is hanging on this doll?* — and the
|
||||
// answer is one line: a hunter mid-shot read `at=[2,35]` (bow at HandLeft, arrow at
|
||||
// HandArrow) where the reference can only ever show `[2]`. It is also the retest readout
|
||||
// for the reference's attach reset ([`attach_reset`]): no id in the cut family may appear.
|
||||
let mut at: Vec<u16> = riders
|
||||
.iter()
|
||||
.filter_map(|r| r.attach)
|
||||
.chain(billboards.iter().filter_map(|b| b.attach))
|
||||
.chain(effects.iter().filter_map(|f| f.attach))
|
||||
.collect();
|
||||
at.sort_unstable();
|
||||
at.dedup();
|
||||
eprintln!(
|
||||
"[booth] {token} {verb} parts={} riders={} billboards={} fx={}/{}",
|
||||
"[booth] {token} {verb} parts={} riders={} billboards={} fx={}/{} at={at:?} grip={:?}",
|
||||
parts.len(),
|
||||
riders.len(),
|
||||
billboards.len(),
|
||||
effects.len(),
|
||||
effects.iter().map(|e| e.emitters.len()).sum::<usize>(),
|
||||
hand_grip(riders, billboards, effects),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1223,6 +1249,56 @@ fn test_mode(cached: &mut Option<bool>) -> bool {
|
||||
*cached.get_or_insert_with(|| std::env::var("WOW_PORTRAIT_TEST").is_ok_and(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
/// The reference's **attach reset** — does `0x47a230` detach a sub-model hanging at this M2
|
||||
/// attachment id from the duplicate a model widget renders?
|
||||
///
|
||||
/// Every widget that shows a live unit builds its model by *duplicating* the unit's own
|
||||
/// `CM2Model`, attachment tree and all (`0x707400`/`0x70ea00` deep-copy the children at the same
|
||||
/// attach ids), and then runs this partial reset on the copy — the `<PlayerModel>` paper doll via
|
||||
/// `0x5059a0 → 0x47a230`, the round unit portrait via `0x525261` (wow-re
|
||||
/// `ui/scratch/dressup-model-equipment.md` §1, `ui/scratch/portrait-render.md` §4, both VERIFIED).
|
||||
///
|
||||
/// It detaches fifteen ids — `0xf` (twice), `0x10`, `0x11`, `0x12`, `0x13`–`0x19`, `0x1d`, `0x22`,
|
||||
/// `0x23` — and pointedly **keeps** the three hand points `0`/`1`/`2`, the sheath family
|
||||
/// `0x1a`/`0x1b`/`0x1c`/`0x1e`–`0x21`, and the worn slots (helm `0xb`, shoulders `5`/`6`,
|
||||
/// cape `0xc`). Read as a family, the cut is *everything transient*: chest blood front/back,
|
||||
/// breath, the two nameplates, Base, Head, the two spell hands, Special1–3, Chest — and
|
||||
/// **HandArrow (`0x23`)**, the nocked arrow. Equipment survives; effects do not.
|
||||
///
|
||||
/// `0x23` is the one that shows up in a bug report (`#bugs` B324): our booths mirrored the
|
||||
/// nocked arrow onto the character-window doll, where the reference can never draw one.
|
||||
fn attach_reset(attach: Option<u16>) -> bool {
|
||||
matches!(attach, Some(0xf..=0x19 | 0x1d | 0x22 | 0x23))
|
||||
}
|
||||
|
||||
/// The reference's **hand grip** for a widget's duplicate, from attachment occupancy alone
|
||||
/// (`0x5059a0` at `505a34`/`505a4d`: `GetAttachment(model, 2)` non-null → `CloseHand(1)`,
|
||||
/// `GetAttachment(model, 1)` non-null → `CloseHand(0)` — wow-re
|
||||
/// `animation/scratch/hand-grip-mechanism.md` §4c, which names this the cleanest expression of the
|
||||
/// rule and the one benilla should implement).
|
||||
///
|
||||
/// Returns [`booth::spawn_booth_model`]'s `[right, left]`. **Occupancy, not sheath state and not
|
||||
/// combat**: a hand holding anything closes; the shield's forearm point (`0`) closes nothing. The
|
||||
/// probe runs over the mirrored set *after* [`attach_reset`], exactly as the reference probes the
|
||||
/// duplicate after `0x47a230`.
|
||||
fn hand_grip(
|
||||
riders: &[&PortraitRider],
|
||||
billboards: &[&PortraitBillboard],
|
||||
effects: &[&PortraitEffects],
|
||||
) -> [bool; 2] {
|
||||
// A wand's whole model can be a single camera-facing batch and an emitter set, so the probe
|
||||
// has to see every mirror lane, not just the meshes.
|
||||
let occupied = |id: u16| {
|
||||
riders.iter().any(|r| r.attach == Some(id))
|
||||
|| billboards.iter().any(|b| b.attach == Some(id))
|
||||
|| effects.iter().any(|f| f.attach == Some(id))
|
||||
};
|
||||
[
|
||||
occupied(crate::entities::attach_id::HAND_RIGHT),
|
||||
occupied(crate::entities::attach_id::HAND_LEFT),
|
||||
]
|
||||
}
|
||||
|
||||
/// The three queries that read a unit's **dressed look** — the attach-spawned [`PortraitPart`] /
|
||||
/// [`PortraitRider`] descendants a booth mirrors. Bundled as one `SystemParam` so `sync_portraits`
|
||||
/// and `sync_paperdoll` stay under Bevy's 16-parameter system ceiling, and share the one
|
||||
@@ -1272,13 +1348,15 @@ impl DressedLook<'_, '_> {
|
||||
}
|
||||
}
|
||||
if let Ok(r) = self.riders.get(e) {
|
||||
riders.push(r);
|
||||
if !attach_reset(r.attach) {
|
||||
riders.push(r);
|
||||
}
|
||||
}
|
||||
// A camera-facing batch prunes by whose model it is ([`PortraitSeat`]): a mount's own
|
||||
// glow card goes with the mount's meshes, an item's card rides an attach joint that
|
||||
// re-roots INSIDE the mount subtree while mounted and must survive it like a rider.
|
||||
if let Ok(b) = self.billboards.get(e) {
|
||||
if !in_mount || b.seat != PortraitSeat::Body {
|
||||
if (!in_mount || b.seat != PortraitSeat::Body) && !attach_reset(b.attach) {
|
||||
billboards.push(b);
|
||||
}
|
||||
}
|
||||
@@ -1286,7 +1364,9 @@ impl DressedLook<'_, '_> {
|
||||
// its `ItemVisuals` glow's), seated on an attach joint — the rider rule. Nothing
|
||||
// publishes a mount's own emitters, so there is no mount case to exclude.
|
||||
if let Ok(fx) = self.effects.get(e) {
|
||||
effects.push(fx);
|
||||
if !attach_reset(fx.attach) {
|
||||
effects.push(fx);
|
||||
}
|
||||
}
|
||||
if let Ok(c) = self.children.get(e) {
|
||||
stack.extend(c.iter().map(|child| (child, in_mount)));
|
||||
@@ -1524,7 +1604,14 @@ fn sync_portraits(
|
||||
}),
|
||||
anim_data.as_deref().map(|a| &a.0),
|
||||
BoothMotion::Frozen,
|
||||
[false, false], // a still portrait sheaths its weapons — no in-hand grip
|
||||
// Open hands, and NOT because "a still portrait sheaths its weapons" — it does
|
||||
// not; it mirrors whatever the body holds, same as a pane. The reference's
|
||||
// portrait bake (`0x524f60`) duplicates and runs the attach reset `0x47a230`, and
|
||||
// that is *all*: unlike `0x5059a0` it never probes the hand points or calls
|
||||
// `0x479660`, and the duplicate starts from a fresh `0x70dd70` init that carries
|
||||
// no armed anim blocks across. So the reference's own portrait holds a weapon in
|
||||
// an open hand — rarely in frame on a bust, and its rule all the same.
|
||||
[false, false],
|
||||
&booth_billboards,
|
||||
);
|
||||
// Even a Frozen still re-evaluates its paused pose every frame — the park matters
|
||||
@@ -1867,7 +1954,11 @@ fn sync_body_booth(
|
||||
// reference's `<PlayerModel>` widget does (decision 0822 §4 read it as live-rendering
|
||||
// and left the pose as a look call; the director made that call — decision 1069).
|
||||
BoothMotion::Loop,
|
||||
[false, false], // the pane sheaths its weapons — no in-hand grip
|
||||
// The grip is the reference's own probe of the duplicate's hand attachment nodes
|
||||
// ([`hand_grip`]) — NOT a constant. The `[false, false]` this replaced rested on
|
||||
// "the pane sheaths its weapons", which was never true of this lane: the pane mirrors
|
||||
// whatever the body is holding, so a drawn weapon arrived in an open hand.
|
||||
hand_grip(&riders, &billboards, &effects),
|
||||
&booth_billboards,
|
||||
);
|
||||
// The item effects go up on the posed skeleton's anchors — the body pane is the lane that
|
||||
@@ -2176,20 +2267,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A camera-facing batch at attach `attach` — [`attach_id::SHOULDER_RIGHT`] by default, an id
|
||||
/// the reference's [`attach_reset`] keeps.
|
||||
fn card(seat: PortraitSeat) -> PortraitBillboard {
|
||||
card_at(seat, Some(SHOULDER))
|
||||
}
|
||||
|
||||
fn card_at(seat: PortraitSeat, attach: Option<u16>) -> PortraitBillboard {
|
||||
PortraitBillboard {
|
||||
mesh: Handle::default(),
|
||||
material: Handle::default(),
|
||||
bone: 4,
|
||||
seat,
|
||||
kind: benilla_formats::BillboardKind::Spherical,
|
||||
attach,
|
||||
}
|
||||
}
|
||||
|
||||
/// A worn attach id the reset KEEPS — the right pauldron. Every fixture that isn't testing the
|
||||
/// reset itself hangs from it, so the reset never silently eats a fixture.
|
||||
const SHOULDER: u16 = 5;
|
||||
|
||||
fn rider() -> PortraitRider {
|
||||
rider_at(SHOULDER)
|
||||
}
|
||||
|
||||
fn rider_at(attach: u16) -> PortraitRider {
|
||||
PortraitRider {
|
||||
static_mesh: Handle::default(),
|
||||
material: Handle::default(),
|
||||
bone: 4,
|
||||
offset: Vec3::new(0.21, 1.42, 0.06),
|
||||
attach: Some(attach),
|
||||
}
|
||||
}
|
||||
|
||||
fn effects(bone: u16, offset: Vec3, count: usize) -> PortraitEffects {
|
||||
effects_at(bone, offset, count, SHOULDER)
|
||||
}
|
||||
|
||||
fn effects_at(bone: u16, offset: Vec3, count: usize, attach: u16) -> PortraitEffects {
|
||||
PortraitEffects {
|
||||
bone,
|
||||
offset,
|
||||
attach: Some(attach),
|
||||
emitters: (0..count)
|
||||
.map(|_| benilla_assets::ModelEmitter {
|
||||
def: benilla_world::testing::plain_particle_def(),
|
||||
@@ -2213,6 +2334,8 @@ mod tests {
|
||||
riders: usize,
|
||||
billboards: Vec<PortraitSeat>,
|
||||
effects: Vec<(u16, usize)>,
|
||||
/// What the body panes would arm — [`hand_grip`] over the same collected set.
|
||||
grip: [bool; 2],
|
||||
}
|
||||
|
||||
/// Run the walk over `unit` in `app` and hand back what it collected.
|
||||
@@ -2228,6 +2351,7 @@ mod tests {
|
||||
riders: riders.len(),
|
||||
billboards: billboards.iter().map(|b| b.seat).collect(),
|
||||
effects: effects.iter().map(|e| (e.bone, e.emitters.len())).collect(),
|
||||
grip: hand_grip(&riders, &billboards, &effects),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -2266,15 +2390,7 @@ mod tests {
|
||||
// …and the seated rider's gear, re-rooted under the mount: a shoulder rider, its
|
||||
// camera-facing batch and its emitters.
|
||||
let seat = app.world_mut().spawn(ChildOf(mount)).id();
|
||||
app.world_mut().spawn((
|
||||
PortraitRider {
|
||||
static_mesh: Handle::default(),
|
||||
material: Handle::default(),
|
||||
bone: 4,
|
||||
offset: Vec3::new(0.21, 1.42, 0.06),
|
||||
},
|
||||
ChildOf(seat),
|
||||
));
|
||||
app.world_mut().spawn((rider(), ChildOf(seat)));
|
||||
app.world_mut().spawn((
|
||||
card(PortraitSeat::Rider(Vec3::new(0.15, 1.58, 0.05))),
|
||||
ChildOf(seat),
|
||||
@@ -2311,6 +2427,103 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`#bugs` B324 — the nocked arrow reached the character-window doll.** A hunter shooting in
|
||||
/// combat has a bow at HandLeft(2) and an arrow at HandArrow(0x23); our booths mirrored both,
|
||||
/// and the reference's doll can never draw the arrow: every model widget duplicates the unit's
|
||||
/// attachment tree and then detaches `0x23` with fourteen other transient ids
|
||||
/// (`0x47a230`, [`attach_reset`]). The bow is NOT ours to hide here — `0x47a230` keeps the hand
|
||||
/// points — which is why this asserts both halves.
|
||||
#[test]
|
||||
fn the_nocked_arrow_never_reaches_a_booth_but_the_bow_in_hand_does() {
|
||||
use crate::entities::attach_id::{HAND_ARROW, HAND_LEFT};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
let unit = app.world_mut().spawn(body_part()).id();
|
||||
// The drawn bow: mesh parts at HandLeft.
|
||||
app.world_mut().spawn((rider_at(HAND_LEFT), ChildOf(unit)));
|
||||
// The nocked arrow, at the one body-bone attach the whole ammo mechanism uses.
|
||||
app.world_mut().spawn((rider_at(HAND_ARROW), ChildOf(unit)));
|
||||
|
||||
let got = walk(&mut app, unit);
|
||||
assert_eq!(
|
||||
got.riders, 1,
|
||||
"the arrow is detached from the duplicate; the bow is not"
|
||||
);
|
||||
assert_eq!(
|
||||
got.grip,
|
||||
[false, true],
|
||||
"a bow at HandLeft closes the LEFT hand (0x5059a0's occupancy probe), \
|
||||
and nothing closes the right"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The reset's membership, against the byte list.** `0x47a230` detaches fifteen ids —
|
||||
/// `0xf` twice, `0x10`, `0x11`, `0x12`, `0x13`–`0x19`, `0x1d`, `0x22`, `0x23` — and keeps
|
||||
/// everything else. Spelled out rather than re-derived, because getting the boundary wrong
|
||||
/// silently deletes worn gear from every pane (`0xb` helm, `0xc` cape, `5`/`6` shoulders) or
|
||||
/// silently keeps an effect.
|
||||
#[test]
|
||||
fn the_attach_reset_cuts_the_transient_family_and_keeps_equipment() {
|
||||
for kept in [
|
||||
0, 1, 2, // the three hand points — the whole reason the widget shows weapons
|
||||
3, 4, 5, 6, 7, 8, 9, 10, // elbows, shoulders, knees, hips
|
||||
0xb, 0xc, 0xd, 0xe, // helm, cape, the two shoulder flaps
|
||||
0x1a, 0x1b, 0x1c, // sheath main/off, sheath shield (also the worn quiver, 0x1a)
|
||||
0x1e, 0x1f, 0x20, 0x21, // the large-weapon and hip-weapon sheath pairs
|
||||
] {
|
||||
assert!(!attach_reset(Some(kept)), "0x47a230 keeps attach {kept:#x}");
|
||||
}
|
||||
for cut in [
|
||||
0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1d, 0x22, 0x23,
|
||||
] {
|
||||
assert!(attach_reset(Some(cut)), "0x47a230 detaches attach {cut:#x}");
|
||||
}
|
||||
assert!(
|
||||
!attach_reset(None),
|
||||
"the host model's OWN batches sit in no attachment node — the reset walks the \
|
||||
attachment list and cannot reach them"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The grip is occupancy, not sheath state — and a shield never closes a hand.** The
|
||||
/// forearm point (`0`) is the shield's, and `0x479700`/`0x5059a0` fork on ids `1`/`2` alone
|
||||
/// (wow-re `hand-grip-mechanism.md` §4a/§4b: "a shield in the offhand NEVER closes the hand,
|
||||
/// in any stance").
|
||||
#[test]
|
||||
fn a_shield_on_the_forearm_closes_no_hand() {
|
||||
use crate::entities::attach_id::{HAND_RIGHT, SHIELD};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
let unit = app.world_mut().spawn(body_part()).id();
|
||||
app.world_mut().spawn((rider_at(SHIELD), ChildOf(unit)));
|
||||
app.world_mut().spawn((rider_at(HAND_RIGHT), ChildOf(unit)));
|
||||
|
||||
assert_eq!(
|
||||
walk(&mut app, unit).grip,
|
||||
[true, false],
|
||||
"the sword's hand closes, the shield's does not"
|
||||
);
|
||||
}
|
||||
|
||||
/// **A wand is a camera-facing batch and an emitter set, with no mesh rider at all** — so a
|
||||
/// grip probe that only walked the mesh lane would leave the hand open around it. The
|
||||
/// reference probes the attachment NODE, which every lane of ours hangs from.
|
||||
#[test]
|
||||
fn a_meshless_held_item_still_closes_its_hand() {
|
||||
use crate::entities::attach_id::{HAND_LEFT, HAND_RIGHT};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
let unit = app.world_mut().spawn(body_part()).id();
|
||||
app.world_mut().spawn((
|
||||
card_at(PortraitSeat::Rider(Vec3::ZERO), Some(HAND_RIGHT)),
|
||||
ChildOf(unit),
|
||||
));
|
||||
app.world_mut()
|
||||
.spawn((effects_at(4, Vec3::ZERO, 1, HAND_LEFT), ChildOf(unit)));
|
||||
|
||||
assert_eq!(walk(&mut app, unit).grip, [true, true]);
|
||||
}
|
||||
|
||||
/// **A key blind to effects would never re-bake for a glow.** An item glow resolves
|
||||
/// asynchronously (`entities::item_glow`: the item's template, then the effect model's own load),
|
||||
/// so its mirror lands *after* the meshes it rides with — a later frame, with every mesh handle
|
||||
@@ -2319,12 +2532,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_glow_arriving_after_its_item_still_changes_the_bake_key() {
|
||||
let parts = [body_part()];
|
||||
let riders = [PortraitRider {
|
||||
static_mesh: Handle::default(),
|
||||
material: Handle::default(),
|
||||
bone: 4,
|
||||
offset: Vec3::new(0.21, 1.42, 0.06),
|
||||
}];
|
||||
let riders = [rider()];
|
||||
let parts: Vec<&PortraitPart> = parts.iter().collect();
|
||||
let riders: Vec<&PortraitRider> = riders.iter().collect();
|
||||
|
||||
|
||||
@@ -859,8 +859,10 @@ mod quest_timer_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod durability_tests;
|
||||
// `pub(crate)` for its `harness`/`push`/`row` helpers: `perf::hud`'s own test drives the readout
|
||||
// through them rather than keeping a second copy of the XML-loading boilerplate.
|
||||
#[cfg(test)]
|
||||
mod world_state_tests;
|
||||
pub(crate) mod world_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod screenshot_tests;
|
||||
|
||||
@@ -25,7 +25,7 @@ fn load_xml(s: &UiScript, file: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
fn harness() -> UiScript {
|
||||
pub(crate) fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in [
|
||||
@@ -42,7 +42,7 @@ fn harness() -> UiScript {
|
||||
}
|
||||
|
||||
/// An Eastern Plaguelands-shaped row.
|
||||
fn row(icon: &str, text: &str, tooltip: &str) -> WorldStateUiView {
|
||||
pub(crate) fn row(icon: &str, text: &str, tooltip: &str) -> WorldStateUiView {
|
||||
WorldStateUiView {
|
||||
ui_state: 1,
|
||||
text: text.into(),
|
||||
@@ -76,7 +76,7 @@ fn textures(s: &mut UiScript) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn push(s: &mut UiScript, rows: Vec<WorldStateUiView>) {
|
||||
pub(crate) fn push(s: &mut UiScript, rows: Vec<WorldStateUiView>) {
|
||||
s.set_world_state_ui(rows);
|
||||
s.tick(0.0);
|
||||
s.fire_event("UPDATE_WORLD_STATES", vec![]);
|
||||
@@ -313,71 +313,6 @@ fn the_bindings_answer_the_reference_shape() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The readout and the dev cost pill both want the top centre, and before this they drew straight
|
||||
/// through each other (the pill is anchored 8 px down; the readout's first row starts at 15). The
|
||||
/// pill asks the frame itself how far down the band is spoken for
|
||||
/// ([`crate::perf::top_centre_claimed`]) and seats itself below that — so this pins the probe
|
||||
/// against the shipped XML: nothing claimed while the readout is empty, and the readout's real
|
||||
/// resolved bottom once it is up.
|
||||
#[test]
|
||||
fn the_readout_tells_the_dev_pill_how_much_of_the_top_it_uses() {
|
||||
// NOT 768. The layout answers in WoW UI units — a screen that is always 768 units tall
|
||||
// whatever the window is (decision 0582) — and the pill draws in window px, so a probe that
|
||||
// subtracts one from the other is right only when the two happen to coincide. Feeding a
|
||||
// window height that is NOT the virtual one is the whole point of this test: it is what the
|
||||
// director's client does, and the first version of this probe put the pill back on top of the
|
||||
// readout's third row there while passing every test at 768.
|
||||
const SCREEN_H: f32 = 900.0;
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
crate::perf::top_centre_claimed(&s, SCREEN_H),
|
||||
0.0,
|
||||
"hidden readout claims nothing — the pill keeps its usual seat"
|
||||
);
|
||||
|
||||
push(
|
||||
&mut s,
|
||||
vec![
|
||||
row(
|
||||
"Interface\\WorldStateFrame\\AllianceTower",
|
||||
"Towers Controlled: 3",
|
||||
"Alliance Towers Controlled",
|
||||
),
|
||||
row(
|
||||
"Interface\\WorldStateFrame\\HordeTower",
|
||||
"Towers Controlled: 1",
|
||||
"Horde Towers Controlled",
|
||||
),
|
||||
],
|
||||
);
|
||||
s.resolve();
|
||||
let claimed = crate::perf::top_centre_claimed(&s, SCREEN_H);
|
||||
assert!(
|
||||
claimed > 8.0,
|
||||
"two rows reach past the pill's own seat, so the pill must move: {claimed}"
|
||||
);
|
||||
// The frame's own geometry, read back the way the probe reads it: the container's top offset
|
||||
// plus one row per pushed row. Asserted against the XML rather than restated as constants.
|
||||
let expected: f32 = s
|
||||
.eval::<f64>("return (20 + WORLD_STATE_ROW_HEIGHT * 2 + 15) / GetScreenHeight()")
|
||||
.expect("the frame's own numbers") as f32
|
||||
* SCREEN_H;
|
||||
assert!(
|
||||
(claimed - expected).abs() < 0.5,
|
||||
"the claim is the readout's resolved bottom: {claimed} vs {expected}"
|
||||
);
|
||||
|
||||
push(&mut s, Vec::new());
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
crate::perf::top_centre_claimed(&s, SCREEN_H),
|
||||
0.0,
|
||||
"and it hands the band back when the scope empties"
|
||||
);
|
||||
}
|
||||
|
||||
/// The alpha bounding box of a shipped icon, in texel coordinates (`x0, y0, x1, y1`, y down).
|
||||
fn ink_box(chain: &mut benilla_formats::Chain, path: &str) -> (f32, f32, f32, f32, f32, f32) {
|
||||
let bytes = chain.read_file(path).expect("read icon");
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! `charatlas` — composite one character's body atlas off the chain and report **what painted
|
||||
//! what**.
|
||||
//!
|
||||
//! The instrument the two "outfit texture" reports needed and nobody had. A dressed character's
|
||||
//! body is one 256² atlas of ten fixed tiles (the RF-0062 bbox table), and every visible defect in
|
||||
//! that class — a garment that stops early, a boot repainting a robe's hem, a bare band below the
|
||||
//! knee — is one tile receiving the wrong contribution. Reading that off a screenshot means
|
||||
//! guessing; reading it off the atlas means measuring.
|
||||
//!
|
||||
//! Three things it prints, all derived from the composite's own law
|
||||
//! ([`benilla_formats::equip_blits`]), never a second transcription:
|
||||
//!
|
||||
//! 1. **The plan** — every equipment contribution in blit order, with the file each name actually
|
||||
//! resolved to (or `MISSING`, which is the silent skip that reads as "the texture ends early").
|
||||
//! 2. **The per-tile diff** vs the same character composited naked, per atlas ROW. A tile whose
|
||||
//! lower rows go unpainted is a garment that stops early; a row count that changes when one slot
|
||||
//! is added is that slot's footprint.
|
||||
//! 3. **The geosets** the same equipment selects, with the atlas rows each one samples — so "which
|
||||
//! tile does the robe's skirt read?" is answered next to "what is in that tile".
|
||||
//!
|
||||
//! The tile↔geoset pairing is the whole diagnosis: geoset 1302 (the robe skirt) samples atlas rows
|
||||
//! 112–223, which straddles the LegUpper **and LegLower** tiles — so a boot's LegLower contribution
|
||||
//! lands on a robe's hem even though the boot's own geometry is disabled under a robe.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_formats::{
|
||||
equip_blits, equip_region_candidates, equip_tile, load_item_display_catalog, Chain,
|
||||
CharSections, CharacterGeosets, EquipGeosets, ItemDisplay,
|
||||
};
|
||||
|
||||
/// The ten atlas tiles by group, for the per-tile report — the five head/left-column ones included,
|
||||
/// so a head-section regression shows up in the same table as an equipment one.
|
||||
const TILES: [(&str, u32, u32, u32, u32); 10] = [
|
||||
("g0 ArmUpper", 0, 0, 128, 64),
|
||||
("g1 ArmLower", 0, 64, 128, 64),
|
||||
("g2 Hand", 0, 128, 128, 32),
|
||||
("g8 HeadUpper", 0, 160, 128, 32),
|
||||
("g9 HeadLower", 0, 192, 128, 64),
|
||||
("g3 TorsoUpper", 128, 0, 128, 64),
|
||||
("g4 TorsoLower", 128, 64, 128, 32),
|
||||
("g5 LegUpper", 128, 96, 128, 64),
|
||||
("g6 LegLower", 128, 160, 128, 64),
|
||||
("g7 Foot", 128, 224, 128, 32),
|
||||
];
|
||||
|
||||
/// The worn bodyslots the composite takes, in `equipment` order (bodyslot − 2).
|
||||
const SLOT_NAMES: [&str; 8] = [
|
||||
"shirt", "chest", "belt", "pants", "boots", "wrist", "gloves", "tabard",
|
||||
];
|
||||
|
||||
/// One character appearance + what it wears — the whole input, so a report is reproducible from the
|
||||
/// command line that made it.
|
||||
pub struct Look {
|
||||
pub race: u8,
|
||||
pub sex: u8,
|
||||
pub skin: u8,
|
||||
pub face: u8,
|
||||
pub facial_hair: u8,
|
||||
pub hair_style: u8,
|
||||
pub hair_color: u8,
|
||||
/// The eight worn display ids in `equipment` order; `0` = the slot is empty.
|
||||
pub slots: [u32; 8],
|
||||
}
|
||||
|
||||
/// A BLP's header as the composite cares about it: the dimensions it blits at and the alpha depth
|
||||
/// that picks REPLACE (0) / 1-bit key (1) / blend (≥2) — the three that decide whether a
|
||||
/// contribution *covers* the one under it or lets it through.
|
||||
fn blp_shape(chain: &mut Chain, path: &str) -> Option<(u32, u32, u8)> {
|
||||
let b = chain.read_file(path).ok()?;
|
||||
if b.len() < 20 || &b[0..4] != b"BLP2" {
|
||||
return None;
|
||||
}
|
||||
let w = u32::from_le_bytes(b[12..16].try_into().ok()?);
|
||||
let h = u32::from_le_bytes(b[16..20].try_into().ok()?);
|
||||
Some((w, h, b[9]))
|
||||
}
|
||||
|
||||
pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>) -> Result<()> {
|
||||
let catalog = load_item_display_catalog(chain).context("loading ItemDisplayInfo")?;
|
||||
let sections = CharSections::load(chain).context("loading CharSections")?;
|
||||
let geosets = CharacterGeosets::load(chain).context("loading the customization tables")?;
|
||||
|
||||
let worn: Vec<Option<&ItemDisplay>> = look
|
||||
.slots
|
||||
.iter()
|
||||
.map(|id| (*id != 0).then(|| catalog.get(*id)).flatten())
|
||||
.collect();
|
||||
let equipment: [Option<&ItemDisplay>; 8] = std::array::from_fn(|i| worn[i]);
|
||||
|
||||
println!(
|
||||
"race {} sex {} skin {} face {} facialHair {} hair {}/{}",
|
||||
look.race,
|
||||
look.sex,
|
||||
look.skin,
|
||||
look.face,
|
||||
look.facial_hair,
|
||||
look.hair_style,
|
||||
look.hair_color
|
||||
);
|
||||
for (i, (id, d)) in look.slots.iter().zip(&equipment).enumerate() {
|
||||
match (id, d) {
|
||||
(0, _) => {}
|
||||
(id, None) => println!(" {:6} display {id} NOT IN CATALOG", SLOT_NAMES[i]),
|
||||
(id, Some(d)) => println!(
|
||||
" {:6} display {id} geosetGroups {:?}",
|
||||
SLOT_NAMES[i], d.geoset_groups
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// (1) The plan — the composite's own order, with what each name resolved to.
|
||||
println!("\nequipment blits (by ascending cell; later covers earlier within a tile):");
|
||||
for step in equip_blits(&equipment) {
|
||||
let (x, y, w, h) = equip_tile(step.layer).expect("layer < 8");
|
||||
let resolved = equip_region_candidates(step.layer, step.texture, look.sex)
|
||||
.into_iter()
|
||||
.find_map(|p| blp_shape(chain, &p).map(|s| (p, s)));
|
||||
match resolved {
|
||||
Some((path, (bw, bh, alpha))) => {
|
||||
let cover = match alpha {
|
||||
0 => "REPLACE",
|
||||
1 => "1-bit key",
|
||||
_ => "blend",
|
||||
};
|
||||
let fits = if (bw, bh) == (w, h) {
|
||||
""
|
||||
} else {
|
||||
" SIZE≠TILE"
|
||||
};
|
||||
println!(
|
||||
" g{} y{:>3}..{:<3} cell {} {:6} {:34} → {} ({}x{}, alpha {alpha} {cover}){fits}",
|
||||
step.layer,
|
||||
y,
|
||||
y + h,
|
||||
step.column,
|
||||
SLOT_NAMES[step.slot],
|
||||
step.texture,
|
||||
path.rsplit('\\').next().unwrap_or(&path),
|
||||
bw,
|
||||
bh,
|
||||
);
|
||||
let _ = x;
|
||||
}
|
||||
None => println!(
|
||||
" g{} y{:>3}..{:<3} cell {} {:6} {:34} → MISSING (region left as base skin)",
|
||||
step.layer,
|
||||
y,
|
||||
y + h,
|
||||
step.column,
|
||||
SLOT_NAMES[step.slot],
|
||||
step.texture,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// (2) The per-tile diff against the same appearance composited naked.
|
||||
let naked = sections
|
||||
.composite_body(
|
||||
chain,
|
||||
look.race,
|
||||
look.sex,
|
||||
look.skin,
|
||||
look.face,
|
||||
look.facial_hair,
|
||||
look.hair_style,
|
||||
look.hair_color,
|
||||
[None; 8],
|
||||
)?
|
||||
.context("no base skin row for this appearance")?;
|
||||
let dressed = sections
|
||||
.composite_body(
|
||||
chain,
|
||||
look.race,
|
||||
look.sex,
|
||||
look.skin,
|
||||
look.face,
|
||||
look.facial_hair,
|
||||
look.hair_style,
|
||||
look.hair_color,
|
||||
equipment,
|
||||
)?
|
||||
.context("no base skin row for this appearance")?;
|
||||
|
||||
let stride = dressed.width as usize;
|
||||
println!(
|
||||
"\natlas {}x{} ({} mips) — rows repainted vs naked, per tile:",
|
||||
dressed.width,
|
||||
dressed.height,
|
||||
dressed.mips.len()
|
||||
);
|
||||
for (name, x, y, tw, th) in TILES {
|
||||
let painted: Vec<u32> = (0..th)
|
||||
.map(|r| {
|
||||
(0..tw)
|
||||
.filter(|c| {
|
||||
let i = (((y + r) as usize) * stride + (x + c) as usize) * 4;
|
||||
dressed.mips[0][i..i + 4] != naked.mips[0][i..i + 4]
|
||||
})
|
||||
.count() as u32
|
||||
})
|
||||
.collect();
|
||||
let rows = painted.iter().filter(|n| **n > 0).count();
|
||||
// Where a tile stops is the diagnosis, so name the run rather than dumping 64 numbers.
|
||||
let first = painted.iter().position(|n| *n > 0);
|
||||
let last = painted.iter().rposition(|n| *n > 0);
|
||||
let span = match (first, last) {
|
||||
(Some(f), Some(l)) => format!("rows {}..{} of {th}", f, l + 1),
|
||||
_ => "untouched".into(),
|
||||
};
|
||||
println!(
|
||||
" {name:14} y{y:>3}..{:<3} {rows:>3}/{th} repainted {span}",
|
||||
y + th
|
||||
);
|
||||
}
|
||||
|
||||
// (3) The geosets this same equipment selects. The atlas rows each samples are the model's
|
||||
// (`benilla-extract m2batch <the race model>` prints the UV extents these come from), so the
|
||||
// two halves of "which art lands on which geometry" sit in one report.
|
||||
let mut eq = EquipGeosets::default();
|
||||
for (i, d) in equipment.iter().enumerate() {
|
||||
if let Some(d) = d {
|
||||
eq.bodyslots[i] = Some(d.geoset_groups);
|
||||
}
|
||||
}
|
||||
let mut ids =
|
||||
geosets.visible_geosets(look.race, look.sex, look.hair_style, look.facial_hair, &eq);
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
println!("\nvisible geosets: {ids:?}");
|
||||
|
||||
if let Some(path) = out {
|
||||
let img =
|
||||
image::RgbaImage::from_raw(dressed.width, dressed.height, dressed.mips[0].clone())
|
||||
.context("atlas mip 0 is not w*h*4 bytes")?;
|
||||
img.save(path)
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
println!("\nwrote {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use benilla_formats::open_chain;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod chaincensus;
|
||||
mod charatlas;
|
||||
mod charprocs;
|
||||
mod m2dump;
|
||||
mod scan;
|
||||
@@ -55,6 +56,44 @@ enum Command {
|
||||
/// Output `.png` file.
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Composite ONE character's body atlas off the chain and report what painted what: the
|
||||
/// equipment blits in blit order (with the file each region name resolved to, or `MISSING`),
|
||||
/// the per-tile rows repainted vs the same character naked, and the geoset set the same
|
||||
/// equipment selects. The "this garment stops early / that slot repainted this one" instrument
|
||||
/// — a dressed body is ten fixed atlas tiles, and every defect in that class is one tile
|
||||
/// receiving the wrong contribution.
|
||||
Charatlas {
|
||||
/// `ChrRaces` id (1 human · 2 orc · 3 dwarf · 4 night elf · 5 undead · 6 tauren · 7 gnome ·
|
||||
/// 8 troll).
|
||||
#[arg(long)]
|
||||
race: u8,
|
||||
/// 0 male · 1 female.
|
||||
#[arg(long)]
|
||||
sex: u8,
|
||||
/// `skinColor` — the CharSections variation the base skin + head sections key on.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
skin: u8,
|
||||
/// `faceType`.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
face: u8,
|
||||
/// `facialHairStyle`.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
facial_hair: u8,
|
||||
/// `hairStyle`.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
hair_style: u8,
|
||||
/// `hairColor`.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
hair_color: u8,
|
||||
/// The eight worn `ItemDisplayInfo` ids, comma-separated, in bodyslot order:
|
||||
/// shirt,chest,belt,pants,boots,wrist,gloves,tabard. `0` leaves a slot empty; a short list
|
||||
/// leaves the rest empty.
|
||||
#[arg(long, value_delimiter = ',', default_value = "0")]
|
||||
slots: Vec<u32>,
|
||||
/// Write the composited atlas here as a PNG (256²).
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Dump a DBC table to CSV using our schema for it (headers included).
|
||||
Dbc {
|
||||
/// Internal path to the `.dbc` (forward or back slashes accepted).
|
||||
@@ -659,6 +698,36 @@ fn main() -> Result<()> {
|
||||
}
|
||||
eprintln!("{} files", files.len());
|
||||
}
|
||||
Command::Charatlas {
|
||||
race,
|
||||
sex,
|
||||
skin,
|
||||
face,
|
||||
facial_hair,
|
||||
hair_style,
|
||||
hair_color,
|
||||
slots,
|
||||
out,
|
||||
} => {
|
||||
let mut worn = [0u32; 8];
|
||||
for (dst, src) in worn.iter_mut().zip(&slots) {
|
||||
*dst = *src;
|
||||
}
|
||||
charatlas::charatlas(
|
||||
&mut chain,
|
||||
&charatlas::Look {
|
||||
race,
|
||||
sex,
|
||||
skin,
|
||||
face,
|
||||
facial_hair,
|
||||
hair_style,
|
||||
hair_color,
|
||||
slots: worn,
|
||||
},
|
||||
out.as_deref(),
|
||||
)?;
|
||||
}
|
||||
Command::Extract {
|
||||
internal_path,
|
||||
output,
|
||||
|
||||
@@ -25,7 +25,10 @@ mod sections;
|
||||
|
||||
pub use customization::{CharCreateCatalog, DialRanges, StartOutfitItem};
|
||||
pub use geosets::{CharacterGeosets, EquipGeosets};
|
||||
pub use sections::CharSections;
|
||||
pub use sections::{
|
||||
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, CharSections,
|
||||
EquipBlit,
|
||||
};
|
||||
|
||||
// The appearance tables' schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`) —
|
||||
// the same constructors the loaders above use, never a second transcription of the same layout.
|
||||
|
||||
@@ -25,16 +25,47 @@ 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 + pelvis overlays composite into — three of the RF-0062 static 256²
|
||||
/// 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); g5 is the pelvis (right column, Y 96–160).
|
||||
/// 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);
|
||||
const TILE_G5: Tile = (128, 96, 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 g5 ==
|
||||
/// [`TILE_G5`] (equipment LegUpper shares the pelvis tile; underwear blits first, so it sits under).
|
||||
/// 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
|
||||
@@ -59,12 +90,18 @@ const EQUIP_TEX_DIRS: [&str; 8] = [
|
||||
"FootTexture",
|
||||
];
|
||||
|
||||
/// The `[0x803bf8]` bodyslot×layer stacking table (decision 0074), rows = bodyslots 2–9 (shirt,
|
||||
/// chest, belt, pants, boots, wrist, gloves, tabard), columns = layers 0–7: the priority a slot's
|
||||
/// contribution stacks at within the layer's tile (ascending blits later ⇒ on top), `-1` = this slot
|
||||
/// never touches the layer. Transcribed from the RE'd const table; every column's ordering is
|
||||
/// art-coherent (pants under robe under belt; shirt under chest under wrist under glove; …).
|
||||
const EQUIP_LAYER_PRIORITY: [[i8; 8]; 8] = [
|
||||
/// 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
|
||||
@@ -72,9 +109,15 @@ const EQUIP_LAYER_PRIORITY: [[i8; 8]; 8] = [
|
||||
[-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, 3, 4, -1, -1, -1], // tabard
|
||||
[-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;
|
||||
|
||||
/// 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).
|
||||
@@ -175,8 +218,9 @@ impl CharSections {
|
||||
///
|
||||
/// `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
|
||||
/// (so underwear sits under the pants), stacked per layer by the client's priority table.
|
||||
/// 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`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn composite_body(
|
||||
&self,
|
||||
@@ -195,21 +239,20 @@ impl CharSections {
|
||||
};
|
||||
let mut atlas = read_texture_mip_chain(chain, base_path)
|
||||
.with_context(|| format!("reading base skin '{base_path}'"))?;
|
||||
// (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. The pelvis (g5) is its own
|
||||
// tile. Note the columns differ by section: face/facial-hair use TextureName[0]/[1] (lower/upper),
|
||||
// hair uses [1]/[2]; underwear is TextureName[0]. 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); 7] = [
|
||||
// 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),
|
||||
(SECTION_UNDERWEAR, 0, skin, 0, TILE_G5),
|
||||
];
|
||||
for (ty, var, color, col, tile) in overlays {
|
||||
let Some(path) = self.tex(race, sex, ty, var, color, col) else {
|
||||
@@ -219,23 +262,29 @@ impl CharSections {
|
||||
blit_over(&mut atlas, &overlay, tile);
|
||||
}
|
||||
}
|
||||
// The equipment layers (decision 0074): per layer, the worn contributions stacked by the
|
||||
// priority table, each read gendered-first (`_M`/`_F` by the wearer's sex, `_U` fallback).
|
||||
for layer in 0..8 {
|
||||
let mut contributions: Vec<(i8, &str)> = equipment
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(slot, d)| {
|
||||
let prio = EQUIP_LAYER_PRIORITY[slot][layer];
|
||||
let name = d.as_ref()?.region_textures[layer].as_deref()?;
|
||||
(prio >= 0).then_some((prio, name))
|
||||
})
|
||||
.collect();
|
||||
contributions.sort_by_key(|(prio, _)| *prio);
|
||||
for (_, name) in contributions {
|
||||
if let Some(overlay) = read_equip_region(chain, layer, name, sex) {
|
||||
blit_over(&mut atlas, &overlay, EQUIP_TILES[layer]);
|
||||
}
|
||||
// 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);
|
||||
// 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), in the one order [`equip_blits`] decides, each read
|
||||
// `_U`-first with the wearer's gender letter as the fallback (RF-0088 §7).
|
||||
for step in &plan {
|
||||
if let Some(overlay) = read_equip_region(chain, step.layer, step.texture, sex) {
|
||||
blit_over(&mut atlas, &overlay, EQUIP_TILES[step.layer]);
|
||||
}
|
||||
}
|
||||
Ok(Some(atlas))
|
||||
@@ -281,15 +330,120 @@ impl CharSections {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 worn bodyslot, 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).
|
||||
pub slot: usize,
|
||||
/// 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, which is
|
||||
/// the `[0x803bf8]` default unless the layer's chooser overruled it. Ascending = blitted later.
|
||||
pub column: i8,
|
||||
/// The `ItemDisplayInfo` region-texture name (no directory, no gender suffix).
|
||||
pub texture: &'a str,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn equip_blits<'a>(equipment: &[Option<&'a ItemDisplay>; 8]) -> Vec<EquipBlit<'a>> {
|
||||
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 {
|
||||
slot,
|
||||
layer,
|
||||
column,
|
||||
texture,
|
||||
});
|
||||
}
|
||||
}
|
||||
plan.extend(row.into_iter().flatten());
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Read one equipment region texture off the chain: `Item\TextureComponents\<dir>\<name>_<L>.blp`,
|
||||
/// the gender letter by the wearer's sex (`M`/`F`), falling back to `_U` (unisex — the majority of
|
||||
/// the shipped files) then the bare name (defensive; nothing shipped is bare). `None` when no
|
||||
/// variant decodes — the layer is skipped, best-effort like the skin overlays.
|
||||
/// **`_U` first, the wearer's gender letter only if `_U` does not exist**. `None` when neither
|
||||
/// decodes — the layer is skipped, best-effort like the skin overlays.
|
||||
fn read_equip_region(chain: &mut Chain, layer: usize, name: &str, sex: u8) -> Option<BlpMipChain> {
|
||||
let dir = EQUIP_TEX_DIRS[layer];
|
||||
let letter = if sex == 1 { 'F' } else { 'M' };
|
||||
for suffix in [format!("_{letter}"), "_U".into(), String::new()] {
|
||||
let path = format!("Item\\TextureComponents\\{dir}\\{name}{suffix}.blp");
|
||||
for path in equip_region_candidates(layer, name, sex) {
|
||||
if let Ok(mips) = read_texture_mip_chain(chain, &path) {
|
||||
return Some(mips);
|
||||
}
|
||||
@@ -297,6 +451,29 @@ fn read_equip_region(chain: &mut Chain, layer: usize, name: &str, sex: u8) -> Op
|
||||
None
|
||||
}
|
||||
|
||||
/// 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`,
|
||||
@@ -391,6 +568,296 @@ mod tests {
|
||||
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)
|
||||
.into_iter()
|
||||
.filter(|s| s.layer == 1)
|
||||
.map(|s| (s.column, s.texture))
|
||||
.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)
|
||||
.into_iter()
|
||||
.filter(|s| s.layer == 1)
|
||||
.map(|s| (s.column, s.texture))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
g1,
|
||||
[(2, "bracer_al"), (5, "al"), (6, "glove_al")],
|
||||
"a sleeved chest paints over a bracer"
|
||||
);
|
||||
}
|
||||
|
||||
/// **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)
|
||||
.into_iter()
|
||||
.filter(|s| s.layer == 6)
|
||||
.map(|s| (s.column, s.texture.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).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).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 {
|
||||
@@ -473,8 +940,14 @@ mod tests {
|
||||
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");
|
||||
// A right-column tile with no naked-body overlay must be byte-identical to the base.
|
||||
assert_eq!(changed((128, 0, 128, 64)), 0, "g3 carries no naked overlay");
|
||||
// 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.
|
||||
@@ -572,6 +1045,194 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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])
|
||||
.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.chunks_exact(4)
|
||||
.zip(want.chunks_exact(4))
|
||||
.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)
|
||||
.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");
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -25,7 +25,9 @@ mod install;
|
||||
pub use install::{candidates, wow_data};
|
||||
mod characters;
|
||||
pub use characters::{
|
||||
CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EquipGeosets, StartOutfitItem,
|
||||
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile,
|
||||
CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EquipBlit, EquipGeosets,
|
||||
StartOutfitItem,
|
||||
};
|
||||
mod camera_shakes;
|
||||
mod creatures;
|
||||
|
||||
Reference in New Issue
Block a user