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)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-27 10:13:15 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 79702e8921
commit 5f05465c8e
9 changed files with 171 additions and 55 deletions
@@ -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.
@@ -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),
+58 -9
View File
@@ -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]
+1 -1
View File
@@ -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::*;
@@ -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::<i64>(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.
+3 -10
View File
@@ -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,
+22 -11
View File
@@ -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<String>,
@@ -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;
+4 -4
View File
@@ -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
+3 -2
View File
@@ -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`.