Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/verification/watched-reputation-faction-research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Watched reputation faction research

Research status: complete. The reporter confirmed that the affected display is the watched bar near
the experience bar. Client Verification of the fix is outstanding. This note checks source at
LyraCore commit `46bf948b27b6d21555832b7505ce06ba5fae4f48`, the commit named in
[issue #650](https://github.com/LyraCoreProject/LyraCore/issues/650). It uses CMaNGOS Classic commit
`8ec338a1704e7dcb1c0213eb7ed58f9231ade40f` and the `wow_world_messages` 0.3.0 source commit
`9a1162cb1696518c4c38c794c18e693f0bd68f51`. No live Realm or client was used.

## Confirmed protocol behavior

CMaNGOS initializes `PLAYER_FIELD_WATCHED_FACTION_INDEX` to signed `-1`. It restores the field
from the saved Character value and saves its raw 32-bit representation. Its SQL column is unsigned,
so the saved clear value is `4294967295`. The SQL default of zero is not the Character creation
default. [Creation](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/Player.cpp#L804),
[restoration](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/Player.cpp#L14146),
[saving](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/Player.cpp#L15722-L15723),
[SQL column](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/sql/base/characters.sql#L684).

`CMSG_SET_WATCHED_FACTION` is opcode `0x0318`. The CMaNGOS handler reads one signed 32-bit
value and puts it into that field. It does not change reputation standing and has no range Gate.
[Opcode](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Server/Opcodes.h#L830),
[handler](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/CharacterHandler.cpp#L1119-L1125).

The field occupies one 32-bit descriptor at absolute index `0x4ED`, or `1261`. CMaNGOS marks it
private to the owning Character. The bundled update-mask setter accepts `i32` at the same index.
[CMaNGOS field](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/UpdateFields.cpp#L315),
[setter](https://github.com/gtker/wow_messages/blob/9a1162cb1696518c4c38c794c18e693f0bd68f51/wow_world_messages/src/helper/vanilla/update_mask/impls.rs#L1527-L1530).

The selection names a reputation-list slot. `Faction.dbc` has separate fields for the faction ID
and signed `reputationListID`. CMaNGOS keys its reputation state by `reputationListID` and sends
64 initial slots. This supports a safe selection range of `0..=63`, with `-1` for no selection.
Checking that range is a proposed Gate, not behavior copied from the upstream request handler.
[DBC fields](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Server/DBCStructure.h#L359-L385),
[state keys](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Reputation/ReputationMgr.cpp#L243-L262),
[initial slots](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Reputation/ReputationMgr.cpp#L197-L230).

The bundled generated request decoder disagrees with CMaNGOS. It expects a two-byte `Faction`
enum. Read exactly four little-endian bytes as `i32` for this opcode.
[Generated decoder](https://github.com/gtker/wow_messages/blob/9a1162cb1696518c4c38c794c18e693f0bd68f51/wow_world_messages/src/world/vanilla/cmsg_set_watched_faction.rs#L11-L29).

## Confirmed omissions and the reported symptom

At the inspected LyraCore commit, the Character CREATE builder does not set the watched field.
The opcode path checks for a four-byte body, then returns an unavailable notice. The durable
Character row has no watched selection. Login initializes reputation slots in a separate packet.
[CREATE](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/gateway/src/codec/entity.rs#L303-L484),
[request path](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/gateway/src/world/handlers/unavailable.rs#L206-L208),
[Character row](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/character.rs),
[initial factions](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/gateway/src/codec/entity.rs#L730-L757).

Those omissions establish missing initialization and persistence. The reporter later confirmed
that the Bloodsail Buccaneers display is the watched bar. We have not established that Bloodsail
occupies an omitted field's client default slot, or that standing changed. The original issue
leaves those facts unconfirmed.
[Reported symptom](https://github.com/LyraCoreProject/LyraCore/issues/650).

## Chosen implementation

Append `watched_faction_index: i32` to the durable Character row with a default of `-1`. This gives
fresh and preexisting Characters the same no-selection value. Normal Transfers carry the selection
with the whole Character row, which the existing Transfer code serializes and decodes.
[Serialization](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/transfer/transport.rs#L625-L649),
[decoding](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/transfer/mod.rs#L1492-L1500).

Old in-flight Escrow contains Character bytes without the appended field. Add a narrow decode
compatibility path for that prior row shape, restoring its watched selection as `-1`. Keep malformed
or otherwise unsupported rows as a Refusal. The baseline decoder reads directly into the current
Character type, so a durable column default alone does not handle those old bytes.
[Baseline decoder](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/transfer/mod.rs#L1492-L1500).

No separate preference table or deletion registration is needed. Character deletion removes the
whole durable row, including the selection.
[Character deletion](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/world.rs#L1243-L1245).

The Gateway should decode the intent and make a Durable Request. The Module should admit `-1`
and `0..=63`, refuse other values without changing the prior selection, and require current Actor
authority and a live Character entity. The live entity disappears before Transfer exports state.
Avoid adding a taxi restriction accidentally, because the general `actor` helper also refuses
actions during flight. Send the restored selection on every self CREATE, including zero and `-1`.
Use an owner-only field update if the durable selection changes during the World Session.
[Actor authority](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/account_ownership.rs#L481-L529),
[actor helper](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/gw.rs#L152-L160),
[Transfer freeze](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/transfer/mod.rs#L1146-L1160).

## Verification to perform

Automated checks below exercise protocol and durable state. The graphical client check remains
outstanding. The PR records the completed runs.

- At the protocol Seam, accept exactly four bytes for `-1`, `0` and `63`. Refuse truncation and
trailing bytes. At the Module boundary, refuse `-2`, `64` and extreme signed values without
changing the selected index or reputation standings.
- Decode self CREATE and owner updates. Assert field `1261` explicitly contains the expected value,
including `FFFFFFFF` for clear and `00000000` for slot zero. Check a fresh Character and a
preexisting Character with no saved selection.
- Select, change and clear a faction. Check each result after login, a same-Shard map change and a
cross-shard Transfer. Check that old Escrow without the appended Character field imports as no
selection, and that malformed rows still cause a Refusal.
- With a real 1.12.1 build-5875 client, check that the unwanted watched bar no longer
appears and that selecting and clearing survives the
lifecycle steps above. Record standing before and after. Keep this symptom outstanding until
that check establishes a connection.
12 changes: 7 additions & 5 deletions gateway/src/codec/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
/// A minimal mirror of the entity-row fields the encoder needs. The gateway fills this from
/// the `game_world_entity` subscription cache; it is decoupled from the module's table type.
///
/// `zone_id` is the one field not on the `game_world_entity` row — it rides along from the
/// `game_character` row at login so the gateway can address `SMSG_BINDPOINTUPDATE` (the world
/// entity carries only `map_id`/position; deriving a zone from position would need map/area
/// geometry the entity row deliberately doesn't carry, so the gateway rides the value in from
/// login instead).
/// Character fields supply the saved watched faction, hearth point and fallback zone at login.
#[derive(Clone, Debug, Default)]
pub struct EntityView {
pub guid: u64,
Expand All @@ -22,6 +18,8 @@ pub struct EntityView {
/// `player_login` hands back a view, not the row.
pub instance_id: u64,
pub zone_id: u32,
/// Saved Character selection, sent only to its own client. None clears the watched bar.
pub watched_faction_index: Option<i32>,
pub x: f32,
pub y: f32,
pub z: f32,
Expand Down Expand Up @@ -415,6 +413,10 @@ pub fn build_create_object(
builder = builder
.set_player_guildid(entity.guild_id as i32)
.set_player_guildrank(entity.guild_rank as i32);
if matches!(kind, CreateKind::SelfPlayer) {
builder = builder
.set_player_field_watched_faction_index(entity.watched_faction_index.unwrap_or(-1));
}
if entity.mount_display_id != 0 {
builder = builder.set_unit_mountdisplayid(entity.mount_display_id as i32);
}
Expand Down
37 changes: 37 additions & 0 deletions gateway/src/codec/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,43 @@ fn warrior_entity() -> EntityView {
}
}

#[test]
fn fresh_character_create_has_no_watched_faction() {
let message = build_create_object(&warrior_entity(), CreateKind::SelfPlayer, &[], &[]).unwrap();
let Object::CreateObject2 {
mask2: UpdateMask::Player(fields),
..
} = &message.objects[0]
else {
panic!("expected Character CREATE");
};
assert_eq!(fields.player_field_watched_faction_index(), Some(-1));
}

#[test]
fn character_create_restores_the_private_watched_faction() {
for index in [-1, 0, 19, 63] {
let entity = EntityView {
watched_faction_index: Some(index),
..warrior_entity()
};
for (kind, expected) in [
(CreateKind::SelfPlayer, Some(index)),
(CreateKind::Peer, None),
] {
let message = build_create_object(&entity, kind, &[], &[]).unwrap();
let Object::CreateObject2 {
mask2: UpdateMask::Player(fields),
..
} = &message.objects[0]
else {
panic!("expected Character CREATE");
};
assert_eq!(fields.player_field_watched_faction_index(), expected);
}
}
}

/// Reputation relay: a known faction id maps to its `Faction` enum value and the signed standing
/// survives the wire `u32` bit-cast (the client reads it back as `i32`); an unknown faction id yields
/// `None` so the relay skips silently instead of sending garbage.
Expand Down
7 changes: 7 additions & 0 deletions gateway/src/codec/values.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ fn player_values(guid: u64, apply: impl FnOnce(&mut UpdatePlayer)) -> SMSG_UPDAT
}
}

/// Update the Character's private watched reputation selection. -1 clears the bar.
pub fn build_watched_faction_values(guid: u64, reputation_index: i32) -> SMSG_UPDATE_OBJECT {
player_values(guid, |player| {
player.set_player_field_watched_faction_index(reputation_index);
})
}

/// Build a VALUES partial-update (`Object::Values`) carrying only `UNIT_FIELD_HEALTH` (Tier 3
/// combat). Unlike `build_create_object`, this pushes a *single changed field* to observers via a
/// sparse update mask — no re-create, no flicker. The foundation for combat damage (and any future
Expand Down
6 changes: 6 additions & 0 deletions gateway/src/stdb/bindings/character_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub struct Character {
pub pending_godmode: bool,
pub pending_run_speed_mult_bp: u32,
pub bank_bag_slots: u8,
pub watched_faction_index: i32,
}

impl __sdk::InModule for Character {
Expand Down Expand Up @@ -107,6 +108,7 @@ pub struct CharacterCols {
pub pending_godmode: __sdk::__query_builder::Col<Character, bool>,
pub pending_run_speed_mult_bp: __sdk::__query_builder::Col<Character, u32>,
pub bank_bag_slots: __sdk::__query_builder::Col<Character, u8>,
pub watched_faction_index: __sdk::__query_builder::Col<Character, i32>,
}

impl __sdk::__query_builder::HasCols for Character {
Expand Down Expand Up @@ -173,6 +175,10 @@ impl __sdk::__query_builder::HasCols for Character {
"pending_run_speed_mult_bp",
),
bank_bag_slots: __sdk::__query_builder::Col::new(table_name, "bank_bag_slots"),
watched_faction_index: __sdk::__query_builder::Col::new(
table_name,
"watched_faction_index",
),
}
}
}
Expand Down
83 changes: 83 additions & 0 deletions gateway/src/stdb/bindings/gw_set_watched_faction_reducer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.

#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};

use super::session_actor_type::SessionActor;

#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub(super) struct GwSetWatchedFactionArgs {
pub request_actor: SessionActor,
pub reputation_index: i32,
}

impl From<GwSetWatchedFactionArgs> for super::Reducer {
fn from(args: GwSetWatchedFactionArgs) -> Self {
Self::GwSetWatchedFaction {
request_actor: args.request_actor,
reputation_index: args.reputation_index,
}
}
}

impl __sdk::InModule for GwSetWatchedFactionArgs {
type Module = super::RemoteModule;
}

#[allow(non_camel_case_types)]
/// Extension trait for access to the reducer `gw_set_watched_faction`.
///
/// Implemented for [`super::RemoteReducers`].
pub trait gw_set_watched_faction {
/// Request that the remote module invoke the reducer `gw_set_watched_faction` to run as soon as possible.
///
/// This method returns immediately, and errors only if we are unable to send the request.
/// The reducer will run asynchronously in the future,
/// and this method provides no way to listen for its completion status.
/// /// Use [`gw_set_watched_faction:gw_set_watched_faction_then`] to run a callback after the reducer completes.
fn gw_set_watched_faction(
&self,
request_actor: SessionActor,
reputation_index: i32,
) -> __sdk::Result<()> {
self.gw_set_watched_faction_then(request_actor, reputation_index, |_, _| {})
}

/// Request that the remote module invoke the reducer `gw_set_watched_faction` to run as soon as possible,
/// registering `callback` to run when we are notified that the reducer completed.
///
/// This method returns immediately, and errors only if we are unable to send the request.
/// The reducer will run asynchronously in the future,
/// and its status can be observed with the `callback`.
fn gw_set_watched_faction_then(
&self,
request_actor: SessionActor,
reputation_index: i32,

callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()>;
}

impl gw_set_watched_faction for super::RemoteReducers {
fn gw_set_watched_faction_then(
&self,
request_actor: SessionActor,
reputation_index: i32,

callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()> {
self.imp.invoke_reducer_with_callback(
GwSetWatchedFactionArgs {
request_actor,
reputation_index,
},
callback,
)
}
}
14 changes: 14 additions & 0 deletions gateway/src/stdb/bindings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,7 @@ pub mod gw_set_sheathed_reducer;
pub mod gw_set_target_reducer;
pub mod gw_set_trade_gold_reducer;
pub mod gw_set_trade_item_reducer;
pub mod gw_set_watched_faction_reducer;
pub mod gw_skin_reducer;
pub mod gw_spirit_res_reducer;
pub mod gw_split_item_reducer;
Expand Down Expand Up @@ -2009,6 +2010,7 @@ pub use gw_set_sheathed_reducer::gw_set_sheathed;
pub use gw_set_target_reducer::gw_set_target;
pub use gw_set_trade_gold_reducer::gw_set_trade_gold;
pub use gw_set_trade_item_reducer::gw_set_trade_item;
pub use gw_set_watched_faction_reducer::gw_set_watched_faction;
pub use gw_skin_reducer::gw_skin;
pub use gw_spirit_res_reducer::gw_spirit_res;
pub use gw_split_item_reducer::gw_split_item;
Expand Down Expand Up @@ -3837,6 +3839,10 @@ pub enum Reducer {
trade_slot: u8,
inv_slot: u8,
},
GwSetWatchedFaction {
request_actor: SessionActor,
reputation_index: i32,
},
GwSkin {
request_actor: SessionActor,
corpse_guid: u64,
Expand Down Expand Up @@ -4814,6 +4820,7 @@ impl __sdk::Reducer for Reducer {
Reducer::GwSetTarget { .. } => "gw_set_target",
Reducer::GwSetTradeGold { .. } => "gw_set_trade_gold",
Reducer::GwSetTradeItem { .. } => "gw_set_trade_item",
Reducer::GwSetWatchedFaction { .. } => "gw_set_watched_faction",
Reducer::GwSkin { .. } => "gw_skin",
Reducer::GwSpiritRes { .. } => "gw_spirit_res",
Reducer::GwSplitItem { .. } => "gw_split_item",
Expand Down Expand Up @@ -7573,6 +7580,13 @@ Reducer::GwIgnoreTrade{
request_actor: request_actor.clone(),
trade_slot: trade_slot.clone(),
inv_slot: inv_slot.clone(),
}),
Reducer::GwSetWatchedFaction{
request_actor,
reputation_index,
} => __sats::bsatn::to_vec(&gw_set_watched_faction_reducer::GwSetWatchedFactionArgs {
request_actor: request_actor.clone(),
reputation_index: reputation_index.clone(),
}),
Reducer::GwSkin{
request_actor,
Expand Down
Loading
Loading