From e29cb90ebd7671e5bfb1ffd9ac26cd5caddf3c78 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 27 Aug 2026 15:10:55 +0700 Subject: [PATCH] the missing bra, the robe's hem, and a doll that puts its arrow down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/benilla-app/src/entities.rs | 2 +- .../benilla-app/src/entities/attach/dress.rs | 3 + .../benilla-app/src/entities/equipment/mod.rs | 35 +- .../src/entities/equipment/spawn.rs | 20 +- crates/benilla-app/src/entities/item_glow.rs | 7 + crates/benilla-app/src/perf/hud.rs | 76 ++ crates/benilla-app/src/perf/mod.rs | 5 - crates/benilla-app/src/portrait/booth.rs | 5 +- crates/benilla-app/src/portrait/mod.rs | 250 +++++- crates/benilla-app/src/ui_script/mod.rs | 4 +- .../src/ui_script/world_state_tests.rs | 71 +- .../src/bin/benilla-extract/charatlas.rs | 240 ++++++ .../src/bin/benilla-extract/main.rs | 69 ++ crates/benilla-formats/src/characters/mod.rs | 5 +- .../src/characters/sections.rs | 759 ++++++++++++++++-- crates/benilla-formats/src/lib.rs | 4 +- 16 files changed, 1387 insertions(+), 168 deletions(-) create mode 100644 crates/benilla-formats/src/bin/benilla-extract/charatlas.rs diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 0f3badc0..9f7635a6 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -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. diff --git a/crates/benilla-app/src/entities/attach/dress.rs b/crates/benilla-app/src/entities/attach/dress.rs index fc8ca937..3c3e7d2a 100644 --- a/crates/benilla-app/src/entities/attach/dress.rs +++ b/crates/benilla-app/src/entities/attach/dress.rs @@ -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(); diff --git a/crates/benilla-app/src/entities/equipment/mod.rs b/crates/benilla-app/src/entities/equipment/mod.rs index 3a6e144b..af741a8d 100644 --- a/crates/benilla-app/src/entities/equipment/mod.rs +++ b/crates/benilla-app/src/entities/equipment/mod.rs @@ -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, diff --git a/crates/benilla-app/src/entities/equipment/spawn.rs b/crates/benilla-app/src/entities/equipment/spawn.rs index 38b7f0b7..edab76c4 100644 --- a/crates/benilla-app/src/entities/equipment/spawn.rs +++ b/crates/benilla-app/src/entities/equipment/spawn.rs @@ -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(), }); } diff --git a/crates/benilla-app/src/entities/item_glow.rs b/crates/benilla-app/src/entities/item_glow.rs index dd49db63..76a2e6ba 100644 --- a/crates/benilla-app/src/entities/item_glow.rs +++ b/crates/benilla-app/src/entities/item_glow.rs @@ -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(), }); } diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index d0207765..5c81409f 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -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::("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 diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index b30b46af..88b72771 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -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; diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index 6cdfdfd4..a7f4a2e4 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -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 = diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 2dee1f2b..ba4fc439 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -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, } /// 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, } /// 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, /// 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 = 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::(), + hand_grip(riders, billboards, effects), ); } } @@ -1223,6 +1249,56 @@ fn test_mode(cached: &mut Option) -> 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 `` 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) -> 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 `` 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) -> 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, 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(); diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index d68990a1..09b76cae 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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; diff --git a/crates/benilla-app/src/ui_script/world_state_tests.rs b/crates/benilla-app/src/ui_script/world_state_tests.rs index f15b3b64..4f49b3d9 100644 --- a/crates/benilla-app/src/ui_script/world_state_tests.rs +++ b/crates/benilla-app/src/ui_script/world_state_tests.rs @@ -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 { .collect() } -fn push(s: &mut UiScript, rows: Vec) { +pub(crate) fn push(s: &mut UiScript, rows: Vec) { 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::("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"); diff --git a/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs b/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs new file mode 100644 index 00000000..8ee70d8f --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs @@ -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> = 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 = (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 ` 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(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 6822fd69..b8e377f0 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -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, + /// Write the composited atlas here as a PNG (256²). + #[arg(long)] + out: Option, + }, /// 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, diff --git a/crates/benilla-formats/src/characters/mod.rs b/crates/benilla-formats/src/characters/mod.rs index f66750c2..fa913231 100644 --- a/crates/benilla-formats/src/characters/mod.rs +++ b/crates/benilla-formats/src/characters/mod.rs @@ -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. diff --git a/crates/benilla-formats/src/characters/sections.rs b/crates/benilla-formats/src/characters/sections.rs index 6ad03402..75793a35 100644 --- a/crates/benilla-formats/src/characters/sections.rs +++ b/crates/benilla-formats/src/characters/sections.rs @@ -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> { + let mut plan = Vec::new(); + for (layer, _tile) in EQUIP_TILES.iter().enumerate() { + let mut row: [Option>; 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\\_.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 { - 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) -> 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 { + (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; 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); + 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 diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 9dea381e..93f3f1e6 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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;