Files
benilla-octo/crates/benilla-formats/src/durability.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

186 lines
7.6 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.
//! `DurabilityCosts.dbc` + `DurabilityQualities.dbc` — the client-side repair-cost tables.
//!
//! The 5875 client computes the *displayed* repair price itself (`0x5da330`, wrapped by
//! `0x4faf30` — §5-verified in wow-re `system/ui/scratch/repair-machinery.md`):
//!
//! ```text
//! perItem = round_nearest_even( max(1, round_half_away(
//! (maxDurability − durability)
//! · DurabilityQualities[2·Quality + 1].mult (float)
//! · DurabilityCosts[ItemLevel].column (int) ))
//! · (1.0 − vendorReputationDiscount) )
//! ```
//!
//! The `DurabilityCosts` column is picked by item class/subclass: WEAPON (class 2) reads the 21
//! weapon columns (fields 1–21, byte `0x04 + subclass·4`), ARMOR (class 4) the 8 armor columns
//! (fields 22–29, byte `0x58 + subclass·4`). Anything else costs nothing (never has durability).
//!
//! ⚠ The client's `2·Quality + 1` quality-row key differs from vmangos's `(Quality+1)·2` — the
//! server may *charge* a different amount than the client *displays* (flagged INFERRED-boundary
//! in the wow-re note; a live A/B at repair bring-up settles whether the DBC rows make them
//! equivalent). We implement the client's verified key — benilla displays what 5875 displays.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{f32_at, parse, u32_at};
const DURABILITY_COSTS: &str = "DBFilesClient\\DurabilityCosts.dbc";
const DURABILITY_QUALITIES: &str = "DBFilesClient\\DurabilityQuality.dbc";
/// The two repair tables, id-keyed.
pub struct DurabilityTables {
/// Row id (= item level) → the 29 per-point cost columns (21 weapon, then 8 armor).
costs: HashMap<u32, Vec<u32>>,
/// Row id → the quality multiplier (the client keys this with `2·Quality + 1`).
qualities: HashMap<u32, f32>,
}
impl DurabilityTables {
/// The per-point integer cost column for an item's class/subclass at `item_level`, or `None`
/// when the item can't be repaired (not a weapon/armor, subclass out of the column range, or
/// the level row is missing).
fn column(&self, item_level: u32, class: u32, subclass: u32) -> Option<u32> {
let row = self.costs.get(&item_level)?;
let idx = match class {
2 if subclass < 21 => subclass as usize,
4 if subclass < 8 => 21 + subclass as usize,
_ => return None,
};
row.get(idx).copied()
}
/// The displayed repair cost for one item, in copper — the client's exact arithmetic (see the
/// module doc). `points_lost` = max − current durability; 0 lost (or an unrepairable item)
/// costs 0. `discount` is the vendor reputation discount (0.0 until benilla models
/// reputation), applied with the client's final round-to-nearest-even.
pub fn repair_cost(
&self,
points_lost: u32,
item_level: u32,
quality: u32,
class: u32,
subclass: u32,
discount: f32,
) -> u32 {
if points_lost == 0 {
return 0;
}
let Some(col) = self.column(item_level, class, subclass) else {
return 0;
};
let Some(&mult) = self.qualities.get(&(2 * quality + 1)) else {
return 0;
};
let base = f64::from(points_lost) * f64::from(mult) * f64::from(col);
// round_half_away for a positive value = floor(x + 0.5) (the client's ±0.5 + _ftol),
// then the max(1, …) floor.
let per = ((base + 0.5).floor() as i64).max(1);
// The reputation-discount multiply ends in round-to-nearest-even.
let discounted = per as f64 * f64::from(1.0 - discount);
let f = discounted.floor();
let frac = discounted - f;
// Exact .5 goes to the even neighbour; anything else to the nearer.
let up = frac > 0.5 || (frac == 0.5 && (f as i64) % 2 != 0);
let rounded = if up { f + 1.0 } else { f };
rounded.max(0.0) as u32
}
}
fn costs_schema() -> Schema {
let mut s = Schema::new("DurabilityCosts");
s.add_field(SchemaField::new("id", FieldType::UInt32));
s.add_field(SchemaField::new_array("weapon", FieldType::UInt32, 21));
s.add_field(SchemaField::new_array("armor", FieldType::UInt32, 8));
s.set_key_field("id");
s
}
fn qualities_schema() -> Schema {
let mut s = Schema::new("DurabilityQuality");
s.add_field(SchemaField::new("id", FieldType::UInt32));
s.add_field(SchemaField::new("mult", FieldType::Float32));
s.set_key_field("id");
s
}
/// Load both durability tables off the patch chain.
pub fn load_durability_tables(chain: &mut Chain) -> Result<DurabilityTables> {
let bytes = chain
.read_file(DURABILITY_COSTS)
.with_context(|| format!("reading {DURABILITY_COSTS}"))?;
let rs = parse(&bytes, costs_schema(), "DurabilityCosts")?;
let mut costs = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let row: Vec<u32> = (1..30).map(|i| u32_at(r, i).unwrap_or(0)).collect();
costs.insert(id, row);
}
let bytes = chain
.read_file(DURABILITY_QUALITIES)
.with_context(|| format!("reading {DURABILITY_QUALITIES}"))?;
let rs = parse(&bytes, qualities_schema(), "DurabilityQuality")?;
let mut qualities = HashMap::new();
for r in rs.records() {
let (Some(id), Some(mult)) = (u32_at(r, 0), f32_at(r, 1)) else {
continue;
};
qualities.insert(id, mult);
}
Ok(DurabilityTables { costs, qualities })
}
#[cfg(test)]
mod tests {
use super::*;
fn tables() -> DurabilityTables {
let mut costs = HashMap::new();
// Item level 23: weapon subclass 7 (sword) costs 4/point, armor subclass 3 (mail) 2/point.
let mut row = vec![0u32; 29];
row[7] = 4;
row[21 + 3] = 2;
costs.insert(23, row);
let mut qualities = HashMap::new();
qualities.insert(3, 0.6f32); // the client's row for quality 1 (2·1+1)
DurabilityTables { costs, qualities }
}
#[test]
fn per_item_cost_is_the_clients_arithmetic() {
let t = tables();
// 10 points lost on a common (q1) sword at level 23: 10 · 0.6 · 4 = 24.
assert_eq!(t.repair_cost(10, 23, 1, 2, 7, 0.0), 24);
// Mail gloves, 7 points: 7 · 0.6 · 2 = 8.4 → round-half-away → 8.
assert_eq!(t.repair_cost(7, 23, 1, 4, 3, 0.0), 8);
// The max(1, …) floor: 1 point · 0.6 · … → 2.4 rounds to 2; a fractional sub-1 base
// still charges 1 (1 point on the 2/point mail with a tiny mult).
let mut t2 = tables();
t2.qualities.insert(3, 0.1);
assert_eq!(t2.repair_cost(1, 23, 1, 4, 3, 0.0), 1, "min 1 copper");
// Undamaged or unrepairable: free.
assert_eq!(t.repair_cost(0, 23, 1, 2, 7, 0.0), 0);
assert_eq!(t.repair_cost(10, 23, 1, 15, 0, 0.0), 0, "not weapon/armor");
assert_eq!(t.repair_cost(10, 99, 1, 2, 7, 0.0), 0, "no level row");
}
#[test]
fn discount_rounds_to_nearest_even() {
let mut t = tables();
t.qualities.insert(3, 1.0);
let mut row = vec![0u32; 29];
row[7] = 1; // 1 copper/point so points == the pre-discount price
t.costs.insert(23, row);
// A 50% discount (exactly representable): 41 → 20.5, the tie rounds to EVEN 20;
// 43 → 21.5 → 22; 42 → 21.0, no tie.
assert_eq!(t.repair_cost(41, 23, 1, 2, 7, 0.5), 20);
assert_eq!(t.repair_cost(43, 23, 1, 2, 7, 0.5), 22);
assert_eq!(t.repair_cost(42, 23, 1, 2, 7, 0.5), 21);
}
}