diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 9bad174d..5e54c3af 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -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}; diff --git a/crates/benilla-app/src/target/price_discount.rs b/crates/benilla-app/src/target/price_discount.rs new file mode 100644 index 00000000..9f72b956 --- /dev/null +++ b/crates/benilla-app/src/target/price_discount.rs @@ -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}"); + } + } +} diff --git a/crates/benilla-app/src/ui_merchant/mod.rs b/crates/benilla-app/src/ui_merchant/mod.rs index a6d4b281..de508c35 100644 --- a/crates/benilla-app/src/ui_merchant/mod.rs +++ b/crates/benilla-app/src/ui_merchant/mod.rs @@ -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>, +) -> 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>, ) -> 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, self_q: Query<&ObjectStore, With>, units: Query<(&Guid, &ObjectStore), Without>, + reactions: crate::target::ReactionInputs, tables: Option>, mut last: Local>>, ) { @@ -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::() + .init_resource::() + .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::() + .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::(); + let got = script.eval::("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, diff --git a/crates/benilla-app/src/ui_taxi/mod.rs b/crates/benilla-app/src/ui_taxi/mod.rs index 3b65c387..5e71d788 100644 --- a/crates/benilla-app/src/ui_taxi/mod.rs +++ b/crates/benilla-app/src/ui_taxi/mod.rs @@ -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, commands: Res, mut cache: ResMut, + index: Res, + stores: Query<&ObjectStore>, + self_q: Query<&ObjectStore, With>, + reactions: crate::target::ReactionInputs, mut last: Local>>, mut last_name: Local>>, 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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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::() + .0 + .insert(MASTER, master); + // At Stormwind (2), knowing Sentinel Hill (4), Ironforge (6) and Thelsamar (8). + app.world_mut().resource_mut::().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::() + .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::() + .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::() + .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(); diff --git a/crates/benilla-app/src/ui_taxi/routing.rs b/crates/benilla-app/src/ui_taxi/routing.rs index 49785beb..ec3f463b 100644 --- a/crates/benilla-app/src/ui_taxi/routing.rs +++ b/crates/benilla-app/src/ui_taxi/routing.rs @@ -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 { + 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); /// `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, ) -> Option<(u32, Vec, Vec)> { 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, diff --git a/crates/benilla-formats/src/durability.rs b/crates/benilla-formats/src/durability.rs index 802fae26..939dc8ed 100644 --- a/crates/benilla-formats/src/durability.rs +++ b/crates/benilla-formats/src/durability.rs @@ -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); + } }