From 5f05465c8ef4a616afeed9a3567e5548eafc7991 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:13:15 +0700 Subject: [PATCH] A unit's reaction caps the reputation rank at Revered, as in 1.12.1, so an Exalted faction's NPC no longer breaks the target frame (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeting an NPC whose faction you are Exalted with no longer opens a script error. `UnitReaction` answered 8 there, and stock `TargetFrame_CheckFaction` indexes `UnitReactionColor[reaction]`, which has seven entries (`TargetFrame.lua:6-14`, `:183`). - **The cap.** `UnitReaction 0x6061e0` takes `0x606530`'s answer through `min(rank, 6)` at `0x606439` (`cmp esi,6` / `jl` / `mov esi,6`), so Revered and Exalted both read 6, and the Lua binding adds one (`0x51683e`), so Lua sees at most 7. `ring_reaction` now caps its answer there. - **The ladder stays whole.** `reputation_rank` is untouched: the reputation pane and the item requirement lines still read Exalted. - **Every caller** of `ring_reaction` models `0x6061e0` and tests at 1, 3, 4 or 5, so none changes but the four Lua unit feeds (target, targettarget, npc, mouseover), which now share one `unit_reaction` helper. The vendor discount's Exalted row reads as Revered, as in the reference, and still takes 10%. - **Comments** that gave the scale as 0..7 or 1..8 now say 0..=6 and 1..=7. Checked: `the_reputation_rank_is_capped_at_revered` walks both sides of the Honored, Revered and Exalted thresholds; `an_exalted_npc_target_reads_revered_and_its_plate_is_green` runs stock `TargetFrame` on an Exalted Stormwind unit. With the cap loosened to 7 both fail, the second with the issue's `TargetFrame.lua:183: attempt to index field '?'`; restored, both pass. Gates green on macOS; no platform seam touched. Fixes #440 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/debug_panel/inspect.rs | 4 +- .../benilla-app/src/target/price_discount.rs | 5 +- crates/benilla-app/src/target/ring.rs | 67 ++++++++++++-- crates/benilla-app/src/ui_reputation.rs | 2 +- .../src/ui_script/unit_frame_tests.rs | 89 ++++++++++++++++--- crates/benilla-app/src/ui_tooltip/mod.rs | 13 +-- crates/benilla-app/src/ui_unit.rs | 33 ++++--- crates/benilla-formats/src/factions.rs | 8 +- crates/benilla-ui/src/script/unit/mod.rs | 5 +- 9 files changed, 171 insertions(+), 55 deletions(-) diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs index 5cd74b11..e49bea0b 100644 --- a/crates/benilla-app/src/debug_panel/inspect.rs +++ b/crates/benilla-app/src/debug_panel/inspect.rs @@ -443,8 +443,8 @@ pub(super) fn inspect_ui( 3 => "neutral", 4 => "friendly", 5 => "honored", - 6 => "revered", - _ => "exalted", + // `0x606439` caps the rank at 6: Exalted reads here too. + _ => "revered+", }; // A faction with a reputation slot reacts by our standing (`0x605fc0` -> `0x4d63a0`), // before any template comparison; everything else by the template comparator. diff --git a/crates/benilla-app/src/target/price_discount.rs b/crates/benilla-app/src/target/price_discount.rs index 9f72b956..0ae1f763 100644 --- a/crates/benilla-app/src/target/price_discount.rs +++ b/crates/benilla-app/src/target/price_discount.rs @@ -119,8 +119,9 @@ mod tests { (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), + // Revered, which Exalted reads too (`0x606439`), passes the same signed `cmp eax,5`; + // Neutral does not. + (6, 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), diff --git a/crates/benilla-app/src/target/ring.rs b/crates/benilla-app/src/target/ring.rs index 2780d2a2..1bbebd10 100644 --- a/crates/benilla-app/src/target/ring.rs +++ b/crates/benilla-app/src/target/ring.rs @@ -118,11 +118,12 @@ impl RingVariant { } } -/// The selector's branch (`0x605960`) on the raw reaction rank `0..=7`. A player never grays: rank -/// 0-1 reads red, standing in for the reference's attackability matrix, else green when PvP-flagged -/// and blue when not, paler for a party member; self is not in the party table. An NPC reads gray -/// when dead, else the rank palette. The reference takes the player branch on `UNIT_FIELD_FLAGS` -/// bit 3 (`0x605baa`), which a player's pet carries too; callers pass the object type. +/// The selector's branch (`0x605960`) on [`ring_reaction`]'s rank `0..=6`. A player never grays: +/// rank 0-1 reads red, standing in for the reference's attackability matrix, else green when +/// PvP-flagged and blue when not, paler for a party member; self is not in the party table. An NPC +/// reads gray when dead, else the rank palette. The reference takes the player branch on +/// `UNIT_FIELD_FLAGS` bit 3 (`0x605baa`), which a player's pet carries too; callers pass the object +/// type. pub(crate) fn ring_variant( rank: u8, is_player: bool, @@ -400,13 +401,14 @@ pub(super) fn push_ring( batch.tris(); } -/// The target's reaction toward our player as a raw rank `0..=7`, the direction every NPC colour +/// The target's reaction toward our player as a rank `0..=6`, the direction every NPC colour /// uses (`0x605960` and `0x7cbaa0` ask `UnitReaction 0x6061e0` with the unit as `this`). When /// both carry `UNIT_FIELD_FLAGS` bit 3, a duel (`0x606296`) or mutual FFA decides first. Then /// `0x606530`: a faction with a reputation slot (`0x605fc0`) answers with our reputation rank /// (`0x4d63a0`), even in GM mode; any other goes to the template comparator (`0x606640`). Neutral -/// when anything is missing. Not applied: the party rung (`0x6062b0`), the contested guard, -/// forced reactions and the summon tail. +/// when anything is missing. `0x606439` caps that answer at 6, so Revered and Exalted both read 6. +/// Not applied: the party rung (`0x6062b0`), the contested guard, forced reactions and the summon +/// tail. pub(crate) fn ring_reaction( factions: Option<&Factions>, reputations: &Reputations, @@ -439,9 +441,13 @@ pub(crate) fn ring_reaction( let self_tpl = catalog.template(self_store.0.unit_faction_template()?)?; Some(target_tpl.reaction_toward(self_tpl) as u8) })(); - resolved.unwrap_or(Reaction::Neutral as u8) + // `0x606439` (`cmp esi,6` / `jl` / `mov esi,6`) caps `0x606530`'s answer: Exalted's 7 reads 6. + resolved.map_or(Reaction::Neutral as u8, |rank| rank.min(REACTION_CAP)) } +/// Revered, the highest rank `UnitReaction`'s generic leg answers (`0x606439`). +const REACTION_CAP: u8 = 6; + /// `UNIT_FIELD_FLAGS` bit 3, player-controlled in behaviour: players and their pets carry it, wild /// creatures do not. `UnitReaction` needs it on both sides before any player-vs-player rung /// (`0x606217`, `0x60622f`), and `CanAttack` picks its reaction arm by it (`0x606a13`). @@ -858,6 +864,49 @@ mod tests { assert_eq!(rank(&unit(12), &quiet), 4); } + /// The reputation leg walks the ladder up to Revered and stops: `0x606439` caps the rank at 6, + /// so a Stormwind unit reads the same to a Revered and an Exalted human, while the ladder + /// itself, which the reputation pane reads, still reaches 7. + #[test] + fn the_reputation_rank_is_capped_at_revered() { + use crate::net::ObjectStore; + use crate::target::{stormwind_fixture, HUMAN_WARRIOR}; + use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE; + use benilla_protocol::ObjectFields; + + /// `UNIT_FIELD_BYTES_0`, absolute descriptor index. + const BYTES_0: u16 = 36; + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)])); + // Both sides of the Honored, Revered and Exalted thresholds, and the wire's own ceiling. + for (total, want) in [ + (8_999, 4), + (9_000, 5), + (20_999, 5), + (21_000, 6), + (41_999, 6), + (42_000, 6), + (42_999, 6), + ] { + let (factions, template, reps) = stormwind_fixture(&mut chain, total); + let unit = ObjectStore(ObjectFields::from_pairs(&[( + FIELD_UNIT_FACTIONTEMPLATE, + template, + )])); + assert_eq!( + ring_reaction(Some(&factions), &reps, Some(&unit), Some(&me)), + want, + "standing {total}" + ); + } + assert_eq!( + benilla_formats::reputation_rank(42_000), + 7, + "the ladder is not capped" + ); + } + /// Cenarion Circle, faction 609 with reputation slot 36: not at war, its NPCs are neither /// attackable nor, without a service bit, interactable, while the rank stays neutral. #[test] diff --git a/crates/benilla-app/src/ui_reputation.rs b/crates/benilla-app/src/ui_reputation.rs index a24612fa..f152d286 100644 --- a/crates/benilla-app/src/ui_reputation.rs +++ b/crates/benilla-app/src/ui_reputation.rs @@ -10,7 +10,7 @@ //! The wire standing excludes the race/class base, so the pane's total is base plus wire: vmangos //! stores `standing - BaseRep` (`ReputationMgr.cpp:261`) and reports the sum //! (`ReputationMgr.cpp:82`). The rank is [`benilla_formats::reputation_rank`], shared with the -//! unit-reaction decode so the pane and the nameplate agree. +//! unit-reaction decode, which caps it at 6, Revered (`0x606439`); the pane shows it uncapped. use bevy::prelude::*; diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index 40cf8ccd..35adaa79 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -834,6 +834,22 @@ fn pvp_icon_follows_the_three_branch_law() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// The target frame's name plate tint, `TargetFrameNameBackground`'s vertex colour as drawn. +fn plate_color(s: &mut UiScript) -> [f32; 4] { + s.resolve(); + s.extract() + .into_iter() + .find_map(|q| match q.content { + QuadContent::Texture { + path: Some(p), + color: Some(c), + .. + } if p.contains("LevelBackground") => Some(c), + _ => None, + }) + .expect("target name-plate quad present") +} + /// A friendly player's plate is green when PvP-flagged, else blue (`TargetFrame.lua:163-172`). #[test] fn flagged_friendly_player_plate_is_green() { @@ -842,20 +858,6 @@ fn flagged_friendly_player_plate_is_green() { s.set_screen_size(1024.0, 768.0); load_unit_frames(&s); - let plate_color = |s: &mut UiScript| -> [f32; 4] { - s.resolve(); - s.extract() - .into_iter() - .find_map(|q| match q.content { - QuadContent::Texture { - path: Some(p), - color: Some(c), - .. - } if p.contains("LevelBackground") => Some(c), - _ => None, - }) - .expect("target name-plate quad present") - }; let friendly_player = |pvp: bool| UnitState { exists: true, name: Some("Guildmate".into()), @@ -889,6 +891,65 @@ fn flagged_friendly_player_plate_is_green() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// An NPC of a faction we are Exalted with reads as Revered: `0x606439` caps the rank at 6 and the +/// binding adds one (`0x51683e`), so `UnitReaction` answers 7 and stock `TargetFrame_CheckFaction` +/// finds the last of `UnitReactionColor`'s seven entries (`TargetFrame.lua:6-14`, `:183`). +#[test] +fn an_exalted_npc_target_reads_revered_and_its_plate_is_green() { + use crate::net::ObjectStore; + use crate::target::{stormwind_fixture, HUMAN_WARRIOR}; + use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE; + use benilla_protocol::ObjectFields; + + /// `UNIT_FIELD_BYTES_0`, absolute descriptor index. + const BYTES_0: u16 = 36; + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_unit_frames(&s); + let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)])); + + for total in [21_000, 42_000] { + let (factions, template, reps) = stormwind_fixture(&mut chain, total); + let guard = ObjectStore(ObjectFields::from_pairs(&[( + FIELD_UNIT_FACTIONTEMPLATE, + template, + )])); + let reaction = crate::ui_unit::unit_reaction(Some(&factions), &reps, &guard, Some(&me)); + s.set_unit( + "target", + Some(UnitState { + exists: true, + name: Some("Stormwind Guard".into()), + health: 100, + max_health: 100, + level: 55, + reaction, + is_connected: true, + ..UnitState::default() + }), + ); + s.fire_event("PLAYER_TARGET_CHANGED", vec![]); + assert!( + s.errors().is_empty(), + "standing {total}: script errors: {:?}", + s.errors() + ); + assert_eq!( + s.eval::(r#"return UnitReaction("target", "player")"#) + .unwrap(), + 7, + "standing {total}" + ); + let green = plate_color(&mut s); + assert!( + green[0].abs() < 1e-6 && (green[1] - 1.0).abs() < 1e-6 && green[2].abs() < 1e-6, + "standing {total}: the plate is UnitReactionColor[7], green, got {green:?}" + ); + } +} + /// Stock `TargetFrame_CheckClassification` (`TargetFrame.lua:205-218`), asserted on the drawn /// quads: elite, rare-elite and world boss share the Elite art (1.12 ships no rare-elite border), /// and `UNIT_CLASSIFICATION_CHANGED` alone repaints it. diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 34b741fc..f6122ccf 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -12,13 +12,11 @@ use benilla_ui::strings::Arg; use crate::items::Items; use crate::names::NameCache; use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer}; -use crate::target::{ - go_is_nearest, ring_reaction, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC, -}; +use crate::target::{go_is_nearest, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC}; use crate::ui_action::{PlayerActions, Spells}; use crate::ui_script::UiFeed; use crate::ui_trainer::{TrainerFeed, TrainerTooltipSubjects}; -use crate::ui_unit::{enrich_unit, snapshot, UnitFeed}; +use crate::ui_unit::{enrich_unit, snapshot, unit_reaction, UnitFeed}; pub struct UiTooltipPlugin; @@ -629,12 +627,7 @@ fn drive_mouseover_tooltip( let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); - let reaction = ring_reaction( - rx.factions.as_deref(), - &rx.reputations, - Some(store), - self_store, - ) + 1; + let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store); let mut s = snapshot(store, name, reaction, chr); enrich_unit( &mut s, diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 5132dd48..fa844a85 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -743,8 +743,20 @@ pub(crate) struct UnitStores<'w, 's> { edges: MessageReader<'w, 's, FieldChanged>, } +/// `UnitReaction(unit, "player")` (`0x5167e0`): [`ring_reaction`] plus one (`0x51683e`), so +/// `1..=7`, Hated to Revered, and Exalted reads 7 too (`0x606439`). Stock `UnitReactionColor` +/// has seven entries (`TargetFrame.lua:6-14`), one per value. +pub(crate) fn unit_reaction( + factions: Option<&Factions>, + reputations: &Reputations, + store: &ObjectStore, + self_store: Option<&ObjectStore>, +) -> u8 { + ring_reaction(factions, reputations, Some(store), self_store) + 1 +} + /// Build a unit snapshot from a streamed descriptor, its cached name and its `UnitReaction` -/// (`1..8`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column. +/// (`1..=7`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column. pub(crate) fn snapshot( store: &ObjectStore, name: Option, @@ -1193,13 +1205,12 @@ fn feed_units( let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); - // `UnitReaction` (1..8) is the selection ring's 0..7 rank plus one. - let reaction = ring_reaction( + let reaction = unit_reaction( factions.as_deref(), &reputations, - Some(store), + store, self_pair.map(|(s, _)| s), - ) + 1; + ); let mut s = snapshot(store, name, reaction, chr); s.guid = guid; s.is_connected = true; @@ -1238,12 +1249,12 @@ fn feed_units( let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); - let reaction = ring_reaction( + let reaction = unit_reaction( factions.as_deref(), &reputations, - Some(store), + store, self_pair.map(|(s, _)| s), - ) + 1; + ); let mut s = snapshot(store, name, reaction, chr); s.guid = guid; s.is_connected = true; @@ -1306,12 +1317,12 @@ fn feed_units( let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); - let reaction = ring_reaction( + let reaction = unit_reaction( factions.as_deref(), &reputations, - Some(store), + store, self_pair.map(|(s, _)| s), - ) + 1; + ); let mut s = snapshot(store, name, reaction, chr); s.guid = guid; s.is_connected = true; diff --git a/crates/benilla-formats/src/factions.rs b/crates/benilla-formats/src/factions.rs index 4bf41851..ef8f18eb 100644 --- a/crates/benilla-formats/src/factions.rs +++ b/crates/benilla-formats/src/factions.rs @@ -36,7 +36,7 @@ pub struct FactionTemplate { } /// A unit's base reaction toward another on the reference's scale: `0x606640` returns these three, -/// and the reputation path widens the scale to 0..7, hence the gaps. +/// and the reputation path widens the scale to 0..=6, hence the gaps. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Reaction { Hostile = 1, @@ -154,9 +154,9 @@ impl FactionInfo { } } -/// Total standing (base + wire) to rank 0..=7, hated to exalted, which is also the reference's -/// extended reaction scale. Widths from −42000: 36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000 -/// (vmangos `PointsInRank`). +/// Total standing (base + wire) to rank 0..=7, hated to exalted, the reference's ladder +/// (`0x4d63a0`); the unit reaction takes it capped at 6, Revered (`0x606439`). Widths from −42000: +/// 36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000 (vmangos `PointsInRank`). pub fn reputation_rank(total_standing: i32) -> u8 { match total_standing { i32::MIN..=-6001 => 0, // hated diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index b141be55..d6989874 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -91,8 +91,9 @@ pub struct UnitState { pub charmed: bool, /// `UnitIsGhost`: `PLAYER_FLAGS` bit `0x10`, so players only. pub ghost: bool, - /// The reaction toward the player on `UnitReaction`'s scale, 1 hated to 8 exalted, 0 unknown - /// (nil). Fed for the tokens naming another unit; `"player"` and `"pet"` stay 0. + /// The reaction toward the player on `UnitReaction`'s scale, 1 hated to 7 revered, which + /// Exalted reads too (`0x606439` caps the rank), 0 unknown (nil). Fed for the tokens naming + /// another unit; `"player"` and `"pet"` stay 0. pub reaction: u8, /// `UnitRace`'s localized first return, from `UNIT_FIELD_BYTES_0` byte 0; `None` answers /// `nil, nil`.