Repair costs and flight fares take 1.12.1's price discount: 10% at Honored, up to 10% more by honor rank at a PvP-flagged vendor (#439)

- Repair costs take the vendor's price discount, 10% off at Honored and up to 10% more by honor rank at a PvP-flagged vendor, rounded as 1.12.1 rounds them
- Flight map fares take the flight master's price discount, as 1.12.1 shows them, while an express flight still sends the full fare

Fixes #411
Fixes #434

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-26 17:20:10 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2376fff914
commit 7c967eaf8d
6 changed files with 557 additions and 42 deletions
+5
View File
@@ -34,6 +34,7 @@ pub(crate) fn hover_probe_armed() -> bool {
hover_probe::armed()
}
pub(crate) mod lock;
mod price_discount;
mod relations;
mod reticle;
// `pub(crate)` for the hover inspector too: its faction catalog feeds `go_highlightable`.
@@ -55,6 +56,10 @@ pub(crate) use cursor_mode::{
pub(crate) use lock::GO_FLAG_LOCKED;
// This frame's combat-flash verdict, for the ring's material and the nameplate colour gate.
pub(crate) use flash::CombatFlash;
// The discount `0x612b80` the repair cost and the taxi fare take off their DBC prices.
pub(crate) use price_discount::vendor_price_discount;
#[cfg(test)]
pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR};
// The attack-with-no-target request, and the same nearest-enemy core called synchronously for the
// pet bar's Attack, whose order must leave in the frame it was pressed.
pub(crate) use relations::{can_assist, can_attack, can_interact};
@@ -0,0 +1,200 @@
//! The reference's price discount `0x612b80(vendor, player)`, which the client takes off the two
//! prices it computes from its own DBCs: the repair cost (`0x4faf30`, caller `0x4faf8a`) and the
//! taxi map's fare (`0x4dc3d0`, callers `0x4dc45d` and `0x4dc4a2`). The merchant's goods and the
//! trainer's services are the server's prices, shown as sent.
use crate::net::{ObjectStore, Reputations};
use super::{ring_reaction, Factions};
/// `0x801620`, the f32 0.1 the reputation term loads (`0x612b9d`).
const REPUTATION_TERM: f32 = 0.1;
/// `0x8029c8`, the f32 0.05 each honor step adds (`0x612bcf`, `0x612bd7`).
const HONOR_STEP: f32 = 0.05;
/// `UNIT_FLAG_PVP`, bit 12 of the vendor's `UNIT_FIELD_FLAGS`, which the honor steps need
/// (`0x612bb4 shr ecx,0xc`).
const UNIT_FLAG_PVP: u32 = 0x1000;
/// `0x612b80`: 0.1 when the vendor's reaction to the player is at least 5, Honored (`0x612b98`,
/// signed), then, only at a vendor flagged `UNIT_FLAG_PVP`, 0.05 at an honor rank byte of 6 and
/// 0.05 more at 8 (`0x612bc9`, `0x612bcd`, unsigned), whatever the reaction. The f32 terms are
/// summed in f64 and never narrowed, so 0.15 is no f32.
fn price_discount(reaction: u8, vendor_unit_flags: u32, pvp_rank: u8) -> f64 {
let mut d = if reaction >= 5 {
f64::from(REPUTATION_TERM)
} else {
0.0
};
if vendor_unit_flags & UNIT_FLAG_PVP != 0 && pvp_rank >= 6 {
d += f64::from(HONOR_STEP);
if pvp_rank >= 8 {
d += f64::from(HONOR_STEP);
}
}
d
}
/// [`price_discount`] for `vendor` and the player: the vendor's reaction toward the player
/// ([`ring_reaction`], `vendor->UnitReaction(player)` at `0x612b93`), the vendor's own
/// `UNIT_FIELD_FLAGS` (`[[vendor+0x110]+0xa0]`) and the player's current honor rank,
/// `PLAYER_BYTES_3` byte 3 (`[[player+0xe68]+0x1f]`), not the highest rank held.
pub(crate) fn vendor_price_discount(
factions: Option<&Factions>,
reputations: &Reputations,
vendor: &ObjectStore,
player: &ObjectStore,
) -> f64 {
price_discount(
ring_reaction(factions, reputations, Some(vendor), Some(player)),
vendor.0.unit_flags(),
player.0.player_pvp_rank().unwrap_or(0),
)
}
/// A fixture off the install's `Faction.dbc`: the catalog, a `FactionTemplate` of Stormwind
/// (faction 72, which has a reputation slot) and a reputation table putting a human warrior
/// (`UNIT_FIELD_BYTES_0` = [`HUMAN_WARRIOR`]) at `total` standing with it.
#[cfg(test)]
pub(crate) fn stormwind_fixture(
chain: &mut benilla_formats::Chain,
total: i32,
) -> (Factions, u32, Reputations) {
const STORMWIND: u32 = 72;
let catalog = benilla_formats::load_faction_catalog(chain).expect("Faction.dbc");
let template = (1u32..4096)
.find(|&id| catalog.template(id).is_some_and(|t| t.faction == STORMWIND))
.expect("a Stormwind template");
let info = catalog
.reputation_faction(STORMWIND)
.expect("Stormwind has a reputation slot");
let slot = usize::try_from(info.rep_index).expect("a slot index");
let mut reps = vec![(0u8, 0i32); slot + 1];
reps[slot].1 = total - info.base_for(1, 1);
(Factions::from_catalog(catalog), template, Reputations(reps))
}
/// `UNIT_FIELD_BYTES_0` of a human (race 1) warrior (class 1), for [`stormwind_fixture`].
#[cfg(test)]
pub(crate) const HUMAN_WARRIOR: u32 = 1 | (1 << 8);
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::field::{FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS};
use benilla_protocol::ObjectFields;
/// The five values `0x612b80` can return, as the f64 bits its `st0` holds.
const ZERO: u64 = 0;
const D05: u64 = 0x3fa9_9999_a000_0000;
const D10: u64 = 0x3fb9_9999_a000_0000;
const D15: u64 = 0x3fc3_3333_3800_0000;
const D20: u64 = 0x3fc9_9999_a000_0000;
/// Every branch of `0x612b80`: the reputation term at reaction 5 only, the honor steps only at a
/// PvP-flagged vendor, rank 6 and 8 the thresholds, and a Friendly (4) player still earning them.
#[test]
fn the_discount_is_the_references_sum_bit_for_bit() {
for (reaction, flags, rank, want) in [
(4u8, 0u32, 0u8, ZERO),
(4, 0, 5, ZERO),
(4, 0, 6, ZERO),
(4, 0, 7, ZERO),
(4, 0, 8, ZERO),
(4, 0, 19, ZERO),
(4, UNIT_FLAG_PVP, 0, ZERO),
(4, UNIT_FLAG_PVP, 5, ZERO),
(4, UNIT_FLAG_PVP, 6, D05),
(4, UNIT_FLAG_PVP, 7, D05),
(4, UNIT_FLAG_PVP, 8, D10),
(4, UNIT_FLAG_PVP, 19, D10),
(5, 0, 0, D10),
(5, 0, 5, D10),
(5, 0, 6, D10),
(5, 0, 7, D10),
(5, 0, 8, D10),
(5, 0, 19, D10),
(5, UNIT_FLAG_PVP, 0, D10),
(5, UNIT_FLAG_PVP, 5, D10),
(5, UNIT_FLAG_PVP, 6, D15),
(5, UNIT_FLAG_PVP, 7, D15),
(5, UNIT_FLAG_PVP, 8, D20),
(5, UNIT_FLAG_PVP, 19, D20),
// Revered and up pass the same signed `cmp eax,5`; Neutral does not.
(7, UNIT_FLAG_PVP, 19, D20),
(3, UNIT_FLAG_PVP, 19, D10),
// Every other flag bit set but the PvP one: no honor step.
(5, !UNIT_FLAG_PVP, 19, D10),
// The rank compares are unsigned: a byte past 19 takes both steps.
(4, UNIT_FLAG_PVP, 255, D10),
] {
let got = price_discount(reaction, flags, rank);
assert_eq!(
got.to_bits(),
want,
"reaction {reaction}, flags {flags:#x}, rank {rank}: got {got:e}"
);
}
}
/// The 0.15 case is the f64 sum of the two f32 constants, which no f32 holds: narrowing it would
/// move the taxi fare.
#[test]
fn the_fifteen_percent_discount_is_an_f64() {
let d = price_discount(5, UNIT_FLAG_PVP, 6);
assert_eq!(d.to_bits(), (0.1f32 as f64 + 0.05f32 as f64).to_bits());
assert_ne!(f64::from(d as f32).to_bits(), d.to_bits());
}
/// `PLAYER_BYTES_3`, `PLAYER_FIELD_BYTES`: absolute descriptor indices.
const PLAYER_BYTES_3: u16 = 195;
const PLAYER_FIELD_BYTES: u16 = 1222;
/// The flag is read off the vendor and the rank off the player's current-rank byte, never the
/// highest-rank-held byte of `PLAYER_FIELD_BYTES`. With no faction catalog the reaction is
/// Neutral, so only the honor steps can show.
#[test]
fn the_inputs_are_the_vendors_flag_and_the_players_current_rank() {
let reps = Reputations::default();
let store = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs));
let pvp_vendor = store(&[(FIELD_UNIT_FLAGS, UNIT_FLAG_PVP)]);
let plain_vendor = store(&[(FIELD_UNIT_FLAGS, 0)]);
let rank_8 = store(&[(PLAYER_BYTES_3, 8 << 24), (PLAYER_FIELD_BYTES, 0)]);
let held_8 = store(&[(PLAYER_BYTES_3, 0), (PLAYER_FIELD_BYTES, 8 << 24)]);
let flagged_player = store(&[(PLAYER_BYTES_3, 8 << 24), (FIELD_UNIT_FLAGS, UNIT_FLAG_PVP)]);
let disc =
|v: &ObjectStore, p: &ObjectStore| vendor_price_discount(None, &reps, v, p).to_bits();
assert_eq!(disc(&pvp_vendor, &rank_8), D10, "two honor steps");
assert_eq!(
disc(&pvp_vendor, &held_8),
ZERO,
"the highest rank held is not the byte"
);
assert_eq!(
disc(&plain_vendor, &flagged_player),
ZERO,
"the player's own PvP flag is not the vendor's"
);
}
/// `UNIT_FIELD_BYTES_0`, absolute descriptor index.
const BYTES_0: u16 = 36;
/// The reputation term reads the vendor's reaction to the player, the standing rank: Friendly
/// (8999) earns nothing, Honored (9000) the 0.1. The player's reaction to the vendor would be
/// 4 at both (not at war), so the other direction never discounts.
#[test]
fn the_reputation_term_is_the_vendors_reaction_to_the_player() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
for (total, want) in [(8999, ZERO), (9000, D10), (42000, D10)] {
let (factions, template, reps) = stormwind_fixture(&mut chain, total);
let vendor = ObjectStore(ObjectFields::from_pairs(&[(
FIELD_UNIT_FACTIONTEMPLATE,
template,
)]));
let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)]));
let got = vendor_price_discount(Some(&factions), &reps, &vendor, &me);
assert_eq!(got.to_bits(), want, "standing {total}: got {got:e}");
}
}
}
+129 -11
View File
@@ -271,13 +271,14 @@ fn resolve_buyback(
}
/// One item's repair cost, by the reference's `0x4faf30` arithmetic
/// ([`benilla_formats::DurabilityTables`]); its reputation discount is not applied (passed as 0).
/// ([`benilla_formats::DurabilityTables`]), after the price `discount` (`0x4faf8a`).
fn item_repair_cost(
guid: u64,
objects: &Objects,
items: &Items,
tables: &RepairTables,
commands: &NetCommands,
discount: f64,
) -> u32 {
let Some(obj) = objects.object(guid) else {
return 0;
@@ -295,22 +296,26 @@ fn item_repair_cost(
let (level, quality, class, subclass) = (t.item_level, t.quality, t.class, t.subclass);
tables
.0
.repair_cost(points, level, quality, class, subclass, 0.0)
.repair_cost(points, level, quality, class, subclass, discount)
}
/// The repair-all total over the reference's three sweeps (`0x4fbd60`): equipped slots 0-18, the
/// backpack and the four bags' contents; never the bank, keyring or buyback.
/// backpack and the four bags' contents; never the bank, keyring or buyback. It sums the
/// discounted, rounded per-item costs (`0x4fbe0b`); the total itself is never discounted.
fn repair_all_cost(
store: &benilla_protocol::ObjectFields,
objects: &Objects,
items: &Items,
tables: &RepairTables,
commands: &NetCommands,
discount: f64,
) -> u32 {
let mut total: u64 = 0;
let mut add = |guid: u64, items: &Items| {
if guid != 0 {
total += u64::from(item_repair_cost(guid, objects, items, tables, commands));
total += u64::from(item_repair_cost(
guid, objects, items, tables, commands, discount,
));
}
};
for i in 0..19u8 {
@@ -338,15 +343,22 @@ fn repair_all_cost(
total.min(u64::from(u32::MAX)) as u32
}
/// The open vendor's unit, `None` with none open or its unit not streamed.
fn vendor_store<'a>(
open: &MerchantOpen,
units: &'a Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
) -> Option<&'a ObjectStore> {
open.vendor
.and_then(|g| units.iter().find(|(guid, _)| guid.0 == g))
.map(|(_, store)| store)
}
/// The open vendor's `UNIT_NPC_FLAGS`, 0 with none open or its unit not streamed.
fn vendor_npc_flags(
open: &MerchantOpen,
units: &Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
) -> u32 {
open.vendor
.and_then(|g| units.iter().find(|(guid, _)| guid.0 == g))
.map(|(_, store)| store.0.unit_npc_flags())
.unwrap_or(0)
vendor_store(open, units).map_or(0, |store| store.0.unit_npc_flags())
}
/// Push `GetRepairAllCost`'s total, which the reference sweeps at the call (`0x4fbd60`): every
@@ -359,6 +371,7 @@ fn feed_repair_all_cost(
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
units: Query<(&Guid, &ObjectStore), Without<SelfPlayer>>,
reactions: crate::target::ReactionInputs,
tables: Option<Res<RepairTables>>,
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
) {
@@ -366,9 +379,19 @@ fn feed_repair_all_cost(
return;
};
let last = last.get(&script);
let can_repair = vendor_npc_flags(&open, &units) & NPC_FLAG_REPAIR != 0;
let total = match (can_repair, self_q.iter().next(), tables.as_deref()) {
(true, Some(store), Some(t)) => repair_all_cost(&store.0, &objects, &items, t, &commands),
let vendor =
vendor_store(&open, &units).filter(|store| store.0.unit_npc_flags() & NPC_FLAG_REPAIR != 0);
let total = match (vendor, self_q.iter().next(), tables.as_deref()) {
(Some(vendor), Some(store), Some(t)) => {
// The open merchant's discount for the player (`0x4faf8a`).
let discount = crate::target::vendor_price_discount(
reactions.factions.as_deref(),
&reactions.reputations,
vendor,
store,
);
repair_all_cost(&store.0, &objects, &items, t, &commands, discount)
}
_ => 0,
};
if *last != Some(total) {
@@ -663,6 +686,101 @@ mod tests {
));
}
/// `GetRepairAllCost` sums each item's cost after the open merchant's discount (`0x4faf8a`):
/// at Honored with a PvP-flagged vendor and honor rank byte 8, 0.2 comes off each item before
/// its rounding, and the undiscounted total is not what the VM reads.
#[test]
fn the_repair_all_total_takes_the_merchants_discount() {
use benilla_protocol::field::{
FIELD_PLAYER_INV_SLOT_HEAD, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS,
FIELD_UNIT_NPC_FLAGS,
};
use benilla_protocol::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
/// Absolute descriptor indices.
const OBJECT_ENTRY: u16 = 3;
const ITEM_DURABILITY: u16 = 46;
const ITEM_MAXDURABILITY: u16 = 47;
const BYTES_0: u16 = 36;
const PLAYER_BYTES_3: u16 = 195;
const VENDOR: u64 = 0xF130_0000_0000_0042;
const SWORD: u64 = 0x4000_0000_0000_0007;
const SWORD_ENTRY: u32 = 2488;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let tables = benilla_formats::load_durability_tables(&mut chain).expect("durability");
let (factions, template, reps) = crate::target::stormwind_fixture(&mut chain, 9000);
// A level 20 green sword (class 2, subclass 7) 40 points down.
let disc = f64::from(0.1f32) + f64::from(0.05f32) + f64::from(0.05f32);
let want = tables.repair_cost(40, 20, 2, 2, 7, disc);
let full = tables.repair_cost(40, 20, 2, 2, 7, 0.0);
assert!(
want > 0 && want < full,
"the case discriminates: {want} of {full}"
);
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx))
.insert_resource(RepairTables(tables))
.insert_resource(factions)
.insert_resource(reps);
let mut open = MerchantOpen::default();
open.open(VENDOR, Vec::new());
app.insert_resource(open);
let mut sword = crate::items::test_template("Sword");
(sword.class, sword.subclass, sword.quality, sword.item_level) = (2, 7, 2, 20);
app.world_mut()
.resource_mut::<Items>()
.insert_template(SWORD_ENTRY, Some(sword));
crate::items::test_spawn_item(
app.world_mut(),
SWORD,
ObjectFields::from_pairs(&[
(OBJECT_ENTRY, SWORD_ENTRY),
(ITEM_DURABILITY, 35),
(ITEM_MAXDURABILITY, 75),
]),
false,
);
// The sword in the main hand (slot 15); the current honor rank byte 8.
let main_hand = FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15;
app.world_mut().spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[
(BYTES_0, crate::target::HUMAN_WARRIOR),
(PLAYER_BYTES_3, 8 << 24),
(main_hand, SWORD as u32),
(main_hand + 1, (SWORD >> 32) as u32),
])),
));
app.world_mut().spawn((
Guid(VENDOR),
ObjectStore(ObjectFields::from_pairs(&[
(FIELD_UNIT_NPC_FLAGS, NPC_FLAG_REPAIR),
(FIELD_UNIT_FACTIONTEMPLATE, template),
(FIELD_UNIT_FLAGS, 0x1000),
])),
));
let mut script = UiScript::new().unwrap();
script.set_merchant(Some(MerchantState {
can_repair: true,
..Default::default()
}));
app.insert_non_send_resource(script);
app.world_mut()
.run_system_once(feed_repair_all_cost)
.unwrap();
let script = app.world_mut().non_send_resource_mut::<UiScript>();
let got = script.eval::<i64>("return (GetRepairAllCost())").unwrap();
assert_eq!(got, i64::from(want), "the undiscounted total is {full}");
}
fn row(entry: u32, slot: u32, count: u32) -> VendorItem {
VendorItem {
slot,
+129 -3
View File
@@ -8,7 +8,7 @@ use bevy::prelude::*;
use benilla_ui::script::{TaxiUiState, UiScript};
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::player::UNIT_FLAG_TAXI_FLIGHT;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
@@ -89,6 +89,10 @@ fn feed_taxi(
names: Res<NameCache>,
commands: Res<NetCommands>,
mut cache: ResMut<TaxiRouteCache>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
reactions: crate::target::ReactionInputs,
mut last: Local<crate::ui_script::VmMemo<Option<TaxiUiState>>>,
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
mut sink: crate::ui_action::MessageSink,
@@ -145,9 +149,22 @@ fn feed_taxi(
return;
};
// The continent is the current node's own, as the packet left it (`build_nodes`).
// The continent is the current node's own, as the packet left it (`build_nodes`). The fares
// take the flight master's price discount for the player (`0x4dc45d`, `0x4dc4a2`).
let fresh = state.open.as_ref().and_then(|open| {
let (map_id, nodes, resolved) = build_nodes(open, &catalogs)?;
let flightmaster = index
.0
.get(&open.flightmaster)
.and_then(|&e| stores.get(e).ok());
let discount = flightmaster.zip(self_q.iter().next()).map(|(master, me)| {
crate::target::vendor_price_discount(
reactions.factions.as_deref(),
&reactions.reputations,
master,
me,
)
});
let (map_id, nodes, resolved) = build_nodes(open, &catalogs, discount)?;
cache.0 = resolved;
Some(TaxiUiState {
art: format!("Interface\\TaxiFrame\\TAXIMAP{map_id}"),
@@ -334,6 +351,115 @@ mod tests {
assert!(!on_taxi(&mut app), "landed: nil again");
}
/// The map's fares take the flight master's discount and the express leg sends the raw route
/// sum (`0x4dc3d0` against `0x4dc96f`). Honored, a PvP-flagged flight master and honor rank
/// byte 6 make 0.15: Sentinel Hill's direct 110 shows 94, and Thelsamar's route through
/// Ironforge, 50 + 110, shows 136 and sends 160. With the flight master unresolved the fares
/// show 0 (`0x4dc423`).
#[test]
fn the_map_shows_discounted_fares_and_the_express_sends_the_raw_sum() {
use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE;
use bevy::ecs::system::RunSystemOnce;
/// `UNIT_FIELD_BYTES_0`, `PLAYER_BYTES_3`: absolute descriptor indices.
const BYTES_0: u16 = 36;
const PLAYER_BYTES_3: u16 = 195;
const MASTER: u64 = 0xF130_0000_0000_0099;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalogs = TaxiCatalogs::load(&mut chain).expect("taxi DBCs");
let (factions, template, reps) = crate::target::stormwind_fixture(&mut chain, 9000);
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<TaxiState>()
.init_resource::<TaxiRouteCache>()
.init_resource::<GuidIndex>()
.init_resource::<NameCache>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.insert_resource(NetCommands(tx))
.insert_resource(catalogs)
.insert_resource(factions)
.insert_resource(reps)
.insert_non_send_resource(UiScript::new().unwrap());
app.world_mut().spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[
(BYTES_0, crate::target::HUMAN_WARRIOR),
(PLAYER_BYTES_3, 6 << 24),
])),
));
let master = app
.world_mut()
.spawn((
crate::net::Guid(MASTER),
ObjectStore(ObjectFields::from_pairs(&[
(FIELD_UNIT_FACTIONTEMPLATE, template),
(FIELD_UNIT_FLAGS, 0x1000),
])),
))
.id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(MASTER, master);
// At Stormwind (2), knowing Sentinel Hill (4), Ironforge (6) and Thelsamar (8).
app.world_mut().resource_mut::<TaxiState>().open(
MASTER,
2,
TaxiMask([0b1010_1010, 0, 0, 0, 0, 0, 0, 0]),
);
let node = |app: &mut App, name: &str| -> (i64, i64) {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval(&format!(
"for i = 1, NumTaxiNodes() do \
if TaxiNodeName(i) == {name:?} then return i, TaxiNodeCost(i) end \
end"
))
.unwrap()
};
app.world_mut().run_system_once(feed_taxi).unwrap();
assert_eq!(node(&mut app, "Sentinel Hill, Westfall").1, 94);
assert_eq!(node(&mut app, "Ironforge, Dun Morogh").1, 42);
let (thelsamar, shown) = node(&mut app, "Thelsamar, Loch Modan");
assert_eq!(shown, 136);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(&format!("TakeTaxiNode({thelsamar})"))
.unwrap();
app.world_mut().run_system_once(drain_taxi).unwrap();
// The feed's name lookup queries the flight master; only the activates matter here.
let sent: Vec<_> = rx
.try_iter()
.filter(|c| {
matches!(
c,
ClientCommand::ActivateTaxi { .. } | ClientCommand::ActivateTaxiExpress { .. }
)
})
.collect();
assert!(
matches!(
sent.as_slice(),
[ClientCommand::ActivateTaxiExpress { total_cost: 160, nodes, .. }]
if nodes == &[2, 6, 8]
),
"the express carries the undiscounted sum, got {sent:?}"
);
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.remove(&MASTER);
app.world_mut().run_system_once(feed_taxi).unwrap();
assert_eq!(node(&mut app, "Sentinel Hill, Westfall").1, 0);
}
#[test]
fn open_close_and_session_clear() {
let mut state = TaxiState::default();
+56 -17
View File
@@ -41,30 +41,33 @@ pub(super) fn load_taxi_catalogs(
};
*done = true;
let mut chain = assets.chain.lock_recover();
let loaded = load_taxi_nodes(&mut chain).and_then(|nodes| {
let paths = load_taxi_paths(&mut chain)?;
let continents = load_world_map_continent_catalog(&mut chain)?;
Ok((nodes, paths, continents))
});
let loaded = TaxiCatalogs::load(&mut chain);
drop(chain);
match loaded {
Ok((nodes, paths, continents)) => {
Ok(cat) => {
info!(
"ui_taxi: catalogs loaded — {} nodes, {} paths, {} continents",
nodes.len(),
paths.len(),
continents.len()
cat.nodes.len(),
cat.paths.len(),
cat.continents.len()
);
commands.insert_resource(TaxiCatalogs {
nodes,
paths,
continents,
});
commands.insert_resource(cat);
}
Err(e) => error!("ui_taxi: DBC catalogs failed to load, taxi map disabled: {e:#}"),
}
}
impl TaxiCatalogs {
/// The three tables off the patch chain.
pub(super) fn load(chain: &mut benilla_formats::Chain) -> anyhow::Result<Self> {
Ok(Self {
nodes: load_taxi_nodes(chain)?,
paths: load_taxi_paths(chain)?,
continents: load_world_map_continent_catalog(chain)?,
})
}
}
/// A node's world `(x, y)` on the taxi map's 0..1 space, as the reference computes it
/// (`0x4db958`), with the rect from `WorldMapContinent.dbc` fields 9-12 of the node's continent:
///
@@ -145,6 +148,14 @@ fn shortest_route(
Some((chain, total_fare))
}
/// `TaxiNodeCost`'s fare (`0x4dc3d0`): the price times `1 - discount`, both in f64 with nothing
/// stored (`fsubr`, `fmul st,st(1)`), the product stored as an f32 and rounded half to even
/// (`fstp dword`, `fistp dword`).
fn shown_fare(price: u32, discount: f64) -> u32 {
let product = (f64::from(price) * (1.0 - discount)) as f32;
f64::from(product).round_ties_even() as u32
}
/// A listed node's chain from the nearest node (the node alone for `Current`) and its fare, for
/// `drain_taxi`.
pub(super) struct ResolvedTaxiNode {
@@ -161,9 +172,14 @@ pub(super) struct TaxiRouteCache(pub(super) Vec<ResolvedTaxiNode>);
/// `Reachable` with its fare and route segments, and an unroutable one is left out, where the
/// reference keeps it typed `NONE`, which stock `TaxiFrame.lua` hides; its `DISTANT` type is
/// never produced. Returns the continent's map id, the Lua nodes and their routes, in one order.
///
/// A Lua node's fare takes the flight master's price `discount` ([`shown_fare`]), and is 0 with
/// none, when the flight master or the player does not resolve (`0x4dc3fa`, `0x4dc423`); the
/// route's fare stays the raw sum, which `CMSG_ACTIVATETAXIEXPRESS` carries (`0x4dc96f`).
pub(super) fn build_nodes(
open: &TaxiOpen,
cat: &TaxiCatalogs,
discount: Option<f64>,
) -> Option<(u32, Vec<TaxiUiNode>, Vec<ResolvedTaxiNode>)> {
let map_id = cat.nodes.get(open.nearest_node)?.map_id;
let cont = cat.continents.get(map_id)?;
@@ -230,7 +246,7 @@ pub(super) fn build_nodes(
name: n.name.clone(),
node_type: TaxiNodeType::Reachable,
pos,
cost,
cost: discount.map_or(0, |d| shown_fare(cost, d)),
routes,
});
resolved.push(ResolvedTaxiNode { chain, cost });
@@ -351,6 +367,29 @@ mod tests {
);
}
/// `0x4dc3d0`'s rounding: `1 - discount` stays f64 and only the product is stored as f32, so a
/// near-tie becomes an exact `.5` and rounds to even where the f64 shortcut rounds down.
#[test]
fn the_shown_fare_stores_only_the_product_as_f32() {
let (d05, d10) = (f64::from(0.05f32), f64::from(0.1f32));
let d15 = d10 + d05;
// 0.1: 15 × 0.89999999850988388 = 13.4999999776, stored 13.5, to even 14; the f64
// shortcut gives 13.
assert_eq!(shown_fare(15, d10), 14);
// 0.15: 30 × 0.84999999776482582 = 25.4999999329, stored 25.5, to even 26; and 110
// (Stormwind to Sentinel Hill) = 93.4999997541, stored 93.5, to even 94.
assert_eq!(shown_fare(30, d15), 26);
assert_eq!(shown_fare(110, d15), 94);
// 0.15 at 786437 stores 668471.4375, so 668471; repair's f32 multiplier gives 668472.
assert_eq!(shown_fare(786_437, d15), 668_471);
// 0.15 at 2097157 stores 1782583.5, to even 1782584; the discount narrowed to f32 first
// (0.15000000596) would store 1782583.375, so 1782583.
assert_eq!(shown_fare(2_097_157, d15), 1_782_584);
// A plain case: 0.2 at 110 is 88, and no discount is the price.
assert_eq!(shown_fare(110, d15 + d05), 88);
assert_eq!(shown_fare(110, 0.0), 110);
}
/// A non-square rect, where cross-axis and same-axis denominators differ.
#[test]
fn projection_uses_cross_axis_denominators() {
@@ -439,7 +478,7 @@ mod tests {
nearest_node: 2,
known: mask_of(&[2, 4]),
};
let (map_id, ui, resolved) = build_nodes(&open, &cat).expect("map builds");
let (map_id, ui, resolved) = build_nodes(&open, &cat, Some(0.0)).expect("map builds");
assert_eq!(map_id, 0, "the continent is the nearest node's own map");
assert_eq!(ui.len(), 2, "only the two known EK nodes are visible");
@@ -467,7 +506,7 @@ mod tests {
nearest_node: 2,
known: all_known,
};
let (_, ui, _) = build_nodes(&open, &cat).expect("map builds");
let (_, ui, _) = build_nodes(&open, &cat, Some(0.0)).expect("map builds");
let ek_known_total = cat.nodes.rows().filter(|n| n.map_id == 0).count();
assert!(
ui.len() < ek_known_total,
+38 -11
View File
@@ -1,8 +1,9 @@
//! `DurabilityCosts.dbc` and `DurabilityQuality.dbc`, behind the repair price the reference
//! computes itself (`0x5da330`, wrapped by `0x4faf30`): points lost × the quality row
//! `2·Quality + 1` × the item level's weapon or armor column by subclass, rounded half away from
//! zero to at least 1, then × `(1 - discount)` rounded half to even. vmangos keys the quality row
//! `(Quality + 1)·2` (`Player.cpp:4996`), so its charge may differ from the price shown.
//! zero to at least 1, then × `f32(1 - discount)`, stored as f32 and rounded half to even. vmangos
//! keys the quality row `(Quality + 1)·2` (`Player.cpp:4996`), so its charge may differ from the
//! price shown.
use std::collections::HashMap;
@@ -35,7 +36,8 @@ impl DurabilityTables {
row.get(idx).copied()
}
/// The displayed repair cost of one item in copper, after the vendor's reputation `discount`.
/// The displayed repair cost of one item in copper, `0x4faf30`, after `discount`, the price
/// discount `0x612b80` returns (0 with no merchant open), kept in f64 as the reference's `st0`.
pub fn repair_cost(
&self,
points_lost: u32,
@@ -43,7 +45,7 @@ impl DurabilityTables {
quality: u32,
class: u32,
subclass: u32,
discount: f32,
discount: f64,
) -> u32 {
if points_lost == 0 {
return 0;
@@ -57,13 +59,11 @@ impl DurabilityTables {
let base = f64::from(points_lost) * f64::from(mult) * f64::from(col);
// Half away from zero, as the reference's ±0.5 then `_ftol`, then at least 1.
let per = ((base + 0.5).floor() as i64).max(1);
// The discount multiply rounds half to even.
let discounted = per as f64 * f64::from(1.0 - discount);
let f = discounted.floor();
let frac = discounted - f;
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
// `1 - discount` stored as an f32 (`0x4faf8f fsubr`, `0x4faf95 fstp dword`).
let factor = (1.0 - discount) as f32;
// The product stored as an f32 (`0x4fafaf`), then `fistp` rounds half to even (`0x4fafb5`).
let product = (per as f64 * f64::from(factor)) as f32;
f64::from(product).round_ties_even() as u32
}
}
@@ -155,4 +155,31 @@ mod tests {
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);
}
/// `0x4faf30` at the discounts `0x612b80` returns: the f32 multiplier and the f32 product make
/// a near-tie an exact `.5`, which then rounds to even where the f64 shortcut rounds down.
#[test]
fn the_discounted_cost_stores_the_multiplier_and_the_product_as_f32() {
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 base price
t.costs.insert(23, row);
let cost = |base: u32, disc: f64| t.repair_cost(base, 23, 1, 2, 7, disc);
let (d05, d10) = (f64::from(0.05f32), f64::from(0.1f32));
// 0.1: f32(0.9) = 0.89999997615814208984375; × 15 = 13.49999964237213134765625, stored
// 13.5, to even 14. The f64 shortcut `rint(15 × 0.8999999985)` gives 13.
assert_eq!(cost(15, d10), 14);
// 0.05: f32(0.95) = 0.949999988079071044921875; × 10 = 9.49999988079071044921875, stored
// 9.5, to even 10. The f64 shortcut gives 9.
assert_eq!(cost(10, d05), 10);
// 0.15, the f64 sum: f32(0.85) = 0.85000002384185791015625; × 30 =
// 25.5000007152557373046875, stored 25.5, to even 26. The f64 shortcut gives 25.
assert_eq!(cost(30, d10 + d05), 26);
// 0.15 at 786437: × f32(0.85) = 668471.46875012, stored 668471.5, to even 668472; the
// taxi's f64 multiplier stores 668471.4375 there, so 668471.
assert_eq!(cost(786_437, d10 + d05), 668_472);
// No discount: the base itself.
assert_eq!(cost(15, 0.0), 15);
}
}