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

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

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

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

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

194 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The pet paper-doll feed: `PetPaperDollFrame`'s combat stats and its model booth.
//!
//! The pet's [`UnitCombatStats`] come from the character sheet's descriptor-only core and
//! bindings, as in the reference, whose page calls the `PaperDollFrame_Set*` setters with `"pet"`
//! (`PetPaperDollFrame.lua:73-81`). The stat events fire with `arg1 = "pet"` for the page's
//! registrations (`PetPaperDollFrame.lua:12-20`) except `UNIT_DEFENSE`, which nothing fires.
//! `UNIT_LEVEL` and the `UNIT_PET*` events come from [`crate::ui_pet`] and
//! [`crate::ui_pet_stats`]; nothing fires `PET_UI_UPDATE` or `PET_UI_CLOSE`.
use bevy::prelude::*;
use benilla_ui::script::{UiScript, UnitCombatStats};
use crate::portrait::PetDollBooth;
use crate::ui_char::{fire_stat_transitions, unit_combat_stats};
use crate::ui_pet::{PetBar, PetUnit};
use crate::ui_unit::UnitFeed;
pub(crate) struct UiPetDollPlugin;
impl Plugin for UiPetDollPlugin {
fn build(&self, app: &mut App) {
// In the unit feed, so the page repaints in the pass that pushes the pet's health.
app.add_systems(Update, feed_pet_doll.in_set(UnitFeed));
}
}
fn feed_pet_doll(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
pet: PetUnit,
mut booth: ResMut<PetDollBooth>,
mut last: Local<crate::ui_script::VmMemo<Option<UnitCombatStats>>>,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
// The pane's rotate buttons own the yaw. Written every frame, pet or not, so a new pet does
// not snap to a stale one.
booth.yaw = script.model_pane_facing("PetModelFrame");
let pet_guid = bar.spells.pet_guid;
let store = (pet_guid != 0).then(|| pet.store(pet_guid)).flatten();
// A guid whose object never streamed is no pet: `None` empties the booth.
booth.unit = (pet_guid != 0).then(|| pet.entity(pet_guid)).flatten();
let fresh = store.map(unit_combat_stats);
// Push only on change: the page repaints off the `UNIT_*` events below.
if *last == fresh {
return;
}
// Push, then fire: the handlers run synchronously, and fired first they would paint the old
// values for good.
let prev = last.take();
script.set_pet_combat_stats(fresh.clone());
if let Some(stats) = &fresh {
if prev.is_none() {
debug!(
"ui_pet_doll: pet stats resolved — {} armor, {}-{} damage",
stats.resistances[0], stats.min_damage, stats.max_damage
);
}
fire_stat_transitions(&mut script, "pet", prev.as_ref(), stats);
}
// A pet going away fires nothing here: the page hides on `crate::ui_pet`'s `UNIT_PET`.
*last = fresh;
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{ObjectFields, ObjectType};
use crate::net::ObjectStore;
// The UNIT-block field indices the core reads (`benilla_protocol`'s `fields::FIELD_UNIT_*`).
const BASEATTACKTIME: u16 = 126;
const MINDAMAGE: u16 = 134;
const MAXDAMAGE: u16 = 135;
const STAT0: u16 = 150;
const RESISTANCES0: u16 = 155;
const ATTACK_POWER: u16 = 165;
const ATTACK_POWER_MODS: u16 = 166;
/// A boar's descriptor: the UNIT block alone, built as a create block, because a live pet
/// arrives as one and a create reads an absent field as 0, not unknown.
fn boar() -> ObjectStore {
ObjectStore(
ObjectFields::from_pairs(&[
(STAT0, 63), // strength
(STAT0 + 1, 45), // agility
(STAT0 + 2, 68), // stamina
(STAT0 + 3, 32), // intellect
(STAT0 + 4, 42), // spirit
(RESISTANCES0, 1810),
(RESISTANCES0 + 2, 15), // fire
(BASEATTACKTIME, 2000),
(MINDAMAGE, 30.5f32.to_bits()),
(MAXDAMAGE, 44.5f32.to_bits()),
(ATTACK_POWER, 178),
// The MODS field is a packed signed pair: pos in the low word, neg in the high.
(ATTACK_POWER_MODS, (((-4i16) as u16 as u32) << 16) | 12),
])
.into_created(ObjectType::Unit),
)
}
/// PLAYER-block values keep their defaults: the reference's pet sheet shows no buff split.
#[test]
fn the_core_reads_a_creatures_unit_block_and_defaults_the_rest() {
let s = unit_combat_stats(&boar());
assert_eq!(s.stats, [63, 45, 68, 32, 42]);
assert_eq!(s.resistances, [1810, 0, 15, 0, 0, 0, 0]);
assert_eq!(s.min_damage, 30.5);
assert_eq!(s.max_damage, 44.5);
assert_eq!(s.main_attack_time_ms, 2000);
assert_eq!(
(s.attack_power, s.attack_power_pos, s.attack_power_neg),
(178, 12, -4)
);
assert_eq!(s.stat_pos, [0; 5]);
assert_eq!(s.stat_neg, [0; 5]);
assert_eq!(s.resistance_pos, [0; 7]);
assert_eq!(s.resistance_neg, [0; 7]);
assert_eq!((s.damage_bonus_pos, s.damage_bonus_neg), ([0; 7], [0; 7]));
// `damage_percent` stays 1.0: the stock Lua divides the damage range by it.
assert_eq!(s.damage_percent, 1.0);
assert!(!s.has_offhand && !s.has_wand);
assert_eq!(s.main_weapon_skill, (0, 0));
assert_eq!(s.ranged_weapon_skill, (0, 0));
assert_eq!(
s.defense_skill,
(0, 0),
"a pet's snapshot has no defense pair; `UnitDefense(\"pet\")` answers level × 5 itself"
);
}
#[test]
fn an_unstreamed_pet_reads_the_absent_shape() {
let s = unit_combat_stats(&ObjectStore(ObjectFields::from_pairs(&[])));
assert_eq!(s.stats, [0; 5]);
assert_eq!(s.resistances, [0; 7]);
assert_eq!(s.damage_percent, 1.0);
// The base-swing fallback, so `damage / speed` is finite on the first frames.
assert_eq!(s.main_attack_time_ms, 2000);
}
#[test]
fn the_feeds_composition_answers_the_pet_bindings() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_pet_combat_stats(Some(unit_combat_stats(&boar())));
// `PetPaperDollFrame.lua:149`'s read; stamina is the third, 1-based.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 3)"#)
.unwrap(),
(68, 68, 0, 0)
);
// `PetPaperDollFrame.lua:112`'s read; fire is school 2.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("pet", 2)"#)
.unwrap(),
(15, 15, 0, 0)
);
// `PaperDollFrame_SetArmor("pet", "Pet")`'s.
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("pet")"#)
.unwrap(),
(1810, 1810, 1810, 0, 0)
);
// `PaperDollFrame_SetDamage`'s, and the `/ percent` divisor it feeds.
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("pet")"#)
.unwrap(),
(30.5, 44.5, 0.0, 0.0, 0, 0, 1.0)
);
// `PaperDollFrame_SetAttackPower`'s.
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitAttackPower("pet")"#)
.unwrap(),
(178, 12, -4)
);
// Dismissing the pet pushes `None`: every line falls back to the absent shape.
s.set_pet_combat_stats(None);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("pet")"#)
.unwrap(),
(0, 0, 0, 0, 0)
);
}
}