Cursor and cast drops, arrival target clear, loot and chat follow 1.12.1 (#2699)

Thirteen fixes to the cursor, world arrival, loot and chat.

- Losing control (`0x495901`) or a world port drops the cursor and the cast, inside the packet.
- Every arrival clears the target (`0x490927`), just before the world-entry events.
- A resolved roll frees its loot row, and a group coin loot prints its share line.
- A boss whisper becomes a whisper, and channel chat carries its instance number.
- The delete confirm names an uncached item.
- Bag books send CMSG_READ_ITEM and handle both replies.
- The spellbook pickup and drop sounds play.
- The quest log's gold guide arrow shows, cleared first on each select (`0x4def39`).
- A cleared loot row records its `Deviation:`.

Each change has a test that fails without it.

Fixes #2573
Fixes #2576
Fixes #2446
Fixes #2578
Fixes #2584
Fixes #2577
Fixes #2558
Fixes #2564
Fixes #2562
Fixes #2563
Fixes #2627
Fixes #2679
Fixes #2680

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-27 14:21:17 +07:00
committed by GitHub
parent 395974a2fc
commit c5fc904f8c
55 changed files with 1790 additions and 177 deletions
@@ -478,6 +478,8 @@ const FILLED_THROUGH_A_READ: &[&str] = &[
/// in system order, or a system that pushes onto a lane off it at its own place, passes every
/// check here unseen.
const READ_ONLY_TO_HANDLERS: &[&str] = &[
// The frame's number, which the control packet's handler tells its frame's packets apart by.
"::FrameCount",
// The login pick, which names the character the account-data sections belong to.
"::glue_session::roster::Roster",
// `0xb4e314`, which the party snapshot reads; only the zone feed commits it.
@@ -330,6 +330,25 @@ fn a_language_learn_signals_at_its_packet() {
);
}
/// The signal is the spell registrar's alone, never a diff of the known languages: a removal
/// fires nothing (`0x4b2c50`), nor does a skill line arriving after its spell, and a learn of a
/// language already known fires again (`0x4b2659`).
#[test]
fn only_a_language_learn_signals_never_a_removal_or_a_skill_line() {
let mut c = client();
let learn = |spell_id| vec![SessionEvent::SpellLearned { spell_id }];
c.frame(vec![learn(COMMON)]);
assert_eq!(c.seen(), ["LANGUAGE_LIST_CHANGED"]);
c.frame(vec![vec![SessionEvent::SpellRemoved { spell_id: COMMON }]]);
assert_eq!(c.seen(), Vec::<String>::new(), "an unlearn");
c.frame(vec![vec![values(ME, &[(SKILL_1, 0)])]]);
c.frame(vec![vec![values(ME, &[(SKILL_1, 98)])]]);
assert_eq!(c.seen(), Vec::<String>::new(), "a skill line");
c.frame(vec![learn(COMMON)]);
c.frame(vec![learn(COMMON)]);
assert_eq!(c.seen(), ["LANGUAGE_LIST_CHANGED", "LANGUAGE_LIST_CHANGED"]);
}
/// A unit created in the frame reads its real distance before the scene's propagation has run:
/// the host composes its position from its `Transform` at the call, so the create's own packet
/// left it where it stands, not at the origin.
@@ -485,3 +504,48 @@ fn getter_cost() {
}
}
}
/// A transfer releases no target, so the cascade's `SelectTarget(0)` (`0x490927`) clears whatever
/// the guid still names, with its send, ahead of `PLAYER_ENTERING_WORLD` (`0x490965`) and signalled
/// once; the port itself sends and signals nothing.
#[test]
fn the_arrival_clears_the_target_before_entering_the_world() {
let mut c = client();
c.eval::<()>(
true,
"local f = CreateFrame('Frame') f:RegisterEvent('PLAYER_TARGET_CHANGED') \
f:SetScript('OnEvent', function() table.insert(Seen, event) end)",
);
c.frame(vec![]);
let _ = c.seen();
let selected = |c: &Client| c.app.world().resource::<crate::target::Selection>().guid;
assert_eq!(selected(&c), Some(WOLF));
c.frame(vec![vec![SessionEvent::Worldport {
map_id: 1,
position: [0.0; 3],
orientation: 0.0,
needs_ack: true,
}]]);
assert_eq!(selected(&c), Some(WOLF), "the port releases no target");
assert_eq!(c.seen(), Vec::<String>::new());
let _ = c.sent.try_iter().count();
let unit = [(FIELD_UNIT_HEALTH, 100), (FIELD_UNIT_MAXHEALTH, 100)];
c.frame(vec![vec![created(
ME,
EntityKind::Player,
&[
unit[0],
unit[1],
(TEMPLATE, HUMAN),
(BYTES_0, 1),
(SKILL_1, 98),
],
)]]);
c.frame(vec![]);
assert_eq!(c.seen(), ["PLAYER_TARGET_CHANGED", "PLAYER_ENTERING_WORLD"]);
assert_eq!(selected(&c), None);
assert!(c
.sent
.try_iter()
.any(|s| matches!(s, ClientCommand::SetSelection { guid: 0 })));
}
@@ -172,6 +172,9 @@ pub(super) fn client() -> App {
// Our create tagged as ours, as the net stage's pass does (`net::apply`).
let me = world.resource::<crate::net::GuidIndex>().0[&ME];
world.entity_mut(me).insert(crate::net::SelfPlayer);
// The world entry first, as a session has it: the target comes after.
frame(&mut app, vec![]);
let world = app.world_mut();
let wolf = world.resource::<crate::net::GuidIndex>().0[&WOLF];
*world.resource_mut::<crate::target::Selection>() = crate::target::Selection {
target: Some(wolf),
+138 -17
View File
@@ -9,8 +9,9 @@
//! each a rim arrow: one of the minimap's engine `Model` children, which the VM aims at its POI
//! and the model pane renderer draws in its source's art ([`RimArrow`]), at a level above the
//! minimap's own draw, so above every dot.
//! - After the DBC rows, the static records: the gossip marker (slot 1) and the corpse (slot 2,
//! icon 7, flags 2, rank −1, `0x6dacd0`), which alone skips the 694.444-yd cut (`0x6d9cc2`).
//! - After the DBC rows, the static records: the selected quest's point (slot 0, icon 5, flags 0,
//! `0x6dacb0`), the gossip marker (slot 1) and the corpse (slot 2, icon 7, flags 2, rank −1,
//! `0x6dacd0`), which alone skips the 694.444-yd cut (`0x6d9cc2`).
//!
//! Dots come from the ObjectIcons classifier `0x4eaa90`. A unit or player must first be alive, not
//! our own charm or summon, and trackable ([`dots`]'s `unit_dot_eligible`). Then quest status 7
@@ -22,8 +23,7 @@
//!
//! Blip sizes are frozen once by the CGMinimapFrame ctor (`0x4edbc0`) against a 140.8-px basis.
//!
//! Not built: the landmark `WorldStateID` gate (`0x6d9b27`) and static blip slot 0,
//! `SelectQuestLogEntry()`'s gold guide arrow at the quest's POI (`0x4def70`).
//! Not built: the landmark `WorldStateID` gate (`0x6d9b27`).
//!
//! Deviation: dots re-project every frame, where the reference draws coordinates about 1 s
//! stale, because that staleness is a throttle quirk.
@@ -73,6 +73,8 @@ const ARROW_QUAD_PX: f32 = 38.4;
pub(crate) enum RimArrow {
/// An `AreaPOI.dbc` landmark.
Landmark,
/// The selected quest's point ([`quest_landmark`]).
Quest,
/// The guard's directions ([`crate::poi_marker`]).
Guide,
/// The player's corpse ([`corpse_landmark`]).
@@ -84,12 +86,31 @@ impl RimArrow {
fn slot(self) -> i32 {
match self {
RimArrow::Landmark => -2,
RimArrow::Quest => 0,
RimArrow::Guide => 1,
RimArrow::Corpse => 2,
}
}
}
/// The selected quest's static blip (`0x6dacb0`): slot 0, icon 5, no flags, ranked by the record's
/// `PointOpt` and named by its title, on the map the player is on as the setter takes it
/// (`0x6dac10` stores `[0x86f694]`).
pub(crate) fn quest_landmark(blip: &benilla_ui::script::QuestBlip, map_id: u32) -> AreaPoi {
AreaPoi {
importance: blip.rank,
icon: 5,
faction_id: 0,
pos: [blip.x, blip.y, 0.0],
continent_id: map_id,
flags: 0,
area_id: 0,
name: blip.name.clone(),
description: String::new(),
world_state_id: 0,
}
}
/// The corpse's static blip (`0x6dacd0`): slot 2, icon 7, flags 2, rank −1, named by
/// `CORPSE_RED`, present only while the corpse is on the map the player is on (`0x492010`).
pub(crate) fn corpse_landmark(position: [f32; 3], map_id: u32, name: String) -> AreaPoi {
@@ -229,11 +250,13 @@ pub(super) struct LandmarkSelection<'a> {
pub(super) arrows: Vec<(f32, &'a AreaPoi, RimArrow)>,
}
/// The landmark selection (module doc). `marker`, the guard-directions POI, and `corpse` are
/// appended after the DBC scan as the reference appends its static blip slots 1 and 2: both skip
/// the candidacy gate, and only the corpse (`0xcea848`) skips the 694.444-yd cut (`0x6d9cc2`).
/// The landmark selection (module doc). `quest`, the selected quest's POI, `marker`, the
/// guard-directions POI, and `corpse` are appended after the DBC scan as the reference appends its
/// static blip slots 0 to 2: all skip the candidacy gate, and only the corpse (`0xcea848`) skips
/// the 694.444-yd cut (`0x6d9cc2`).
pub(super) fn select_landmarks<'a>(
pois: impl Iterator<Item = &'a AreaPoi>,
quest: Option<&'a AreaPoi>,
marker: Option<&'a AreaPoi>,
corpse: Option<&'a AreaPoi>,
map_id: u32,
@@ -246,6 +269,7 @@ pub(super) fn select_landmarks<'a>(
let candidates = pois
.filter(|p| p.continent_id == map_id && p.flags & FLAG_CANDIDATE != 0)
.map(|p| (p, RimArrow::Landmark))
.chain(quest.map(|q| (q, RimArrow::Quest)))
.chain(marker.map(|m| (m, RimArrow::Guide)))
.chain(corpse.map(|c| (c, RimArrow::Corpse)));
for (p, art) in candidates {
@@ -280,6 +304,7 @@ fn poi_icon_cell(icon: u32) -> Option<[f32; 4]> {
pub(super) fn emit_landmarks(
ctx: &BlipCtx,
cat: Option<&AreaPoiCatalog>,
quest: Option<&AreaPoi>,
marker: Option<&AreaPoi>,
corpse: Option<&AreaPoi>,
map_id: u32,
@@ -289,6 +314,7 @@ pub(super) fn emit_landmarks(
) {
let sel = select_landmarks(
cat.into_iter().flat_map(|c| c.rows().map(|(_, p)| p)),
quest,
marker,
corpse,
map_id,
@@ -366,8 +392,8 @@ pub(super) fn rim_arrow_records(
bearing: bearing(wx, wy, poi.pos[0], poi.pos[1]),
slot: art.slot(),
// The gossip marker and the corpse show indoors too (`+0x44`, `0x6daaf4`,
// `0x6daafb`).
indoors: *art != RimArrow::Landmark,
// `0x6daafb`); the quest's and the DBC rows' do not.
indoors: matches!(art, RimArrow::Guide | RimArrow::Corpse),
},
(
poi.name.clone(),
@@ -576,7 +602,7 @@ mod tests {
poi(3, 0x1d, 556.0, "Stormwind"),
poi(3, 0x5, 601.0, "Goldshire"),
];
let sel = select_landmarks(rows.iter(), None, None, 0, 0.0, 0.0, 133.0);
let sel = select_landmarks(rows.iter(), None, None, None, 0, 0.0, 0.0, 133.0);
assert!(sel.icons.is_empty(), "no Flags&2 row is in range");
let names: Vec<&str> = sel.arrows.iter().map(|(_, p, _)| p.name.as_str()).collect();
assert_eq!(names, ["Stormwind", "Goldshire"]);
@@ -592,7 +618,7 @@ mod tests {
poi(3, 1, 400.0, "city-mid"),
poi(0, 1, 695.0, "beyond-rank"),
];
let sel = select_landmarks(rows.iter(), None, None, 0, 0.0, 0.0, 100.0);
let sel = select_landmarks(rows.iter(), None, None, None, 0, 0.0, 0.0, 100.0);
let names: Vec<&str> = sel.arrows.iter().map(|(_, p, _)| p.name.as_str()).collect();
assert_eq!(names, ["minor-near", "minor-far", "city-near"]);
}
@@ -606,7 +632,7 @@ mod tests {
poi(3, 0x5, 400.0, "Northshire Abbey"),
];
let marker = poi(0, 0x63, 500.0, "Stormwind Warrior Trainer");
let sel = select_landmarks(cities.iter(), Some(&marker), None, 0, 0.0, 0.0, 133.0);
let sel = select_landmarks(cities.iter(), None, Some(&marker), None, 0, 0.0, 0.0, 133.0);
let names: Vec<&str> = sel.arrows.iter().map(|(_, p, _)| p.name.as_str()).collect();
assert_eq!(
names,
@@ -621,11 +647,79 @@ mod tests {
);
}
/// The selected quest's point (`0x6dacb0`, static slot 0) is a rim arrow like any landmark: no
/// candidacy gate, the 694.444-yd cut, its `PointOpt` as the rank key, the gold guide art, no
/// indoors showing; with no `Flags & 2` it draws no icon in range.
#[test]
fn the_selected_quests_point_is_a_gold_rim_arrow_ranked_by_its_point_opt() {
let blip = |x: f32, rank: u32| benilla_ui::script::QuestBlip {
x,
y: 0.0,
rank,
name: "Wolves".into(),
};
let cities = [poi(3, 0x1d, 200.0, "Stormwind")];
let quest = quest_landmark(&blip(300.0, 0), 0);
assert_eq!((quest.icon, quest.flags), (5, 0));
let sel = select_landmarks(cities.iter(), Some(&quest), None, None, 0, 0.0, 0.0, 133.0);
let names: Vec<&str> = sel.arrows.iter().map(|(_, p, _)| p.name.as_str()).collect();
assert_eq!(
names,
["Wolves", "Stormwind"],
"PointOpt 0 outranks the city"
);
let (rim, _) = rim_arrow_records(&sel, 0.0, 0.0);
assert_eq!((rim[0].slot, rim[0].indoors), (0, false));
let after = quest_landmark(&blip(300.0, 7), 0);
let sel = select_landmarks(cities.iter(), Some(&after), None, None, 0, 0.0, 0.0, 133.0);
assert_eq!(
sel.arrows[0].1.name, "Stormwind",
"a higher rank key sorts later"
);
let far = quest_landmark(&blip(700.0, 0), 0);
let sel = select_landmarks(
std::iter::empty(),
Some(&far),
None,
None,
0,
0.0,
0.0,
133.0,
);
assert!(sel.arrows.is_empty(), "past the rank cut");
let near = quest_landmark(&blip(50.0, 0), 0);
let sel = select_landmarks(
std::iter::empty(),
Some(&near),
None,
None,
0,
0.0,
0.0,
133.0,
);
assert!(
sel.arrows.is_empty() && sel.icons.is_empty(),
"in range, no icon"
);
}
/// Every 1.12 `points_of_interest` row carries flags 99 (0x63), so `Flags & 2` is set.
#[test]
fn the_guard_marker_draws_its_icon_in_range() {
let marker = poi(0, 0x63, 50.0, "The Bank");
let sel = select_landmarks(std::iter::empty(), Some(&marker), None, 0, 0.0, 0.0, 133.0);
let sel = select_landmarks(
std::iter::empty(),
None,
Some(&marker),
None,
0,
0.0,
0.0,
133.0,
);
assert!(sel.arrows.is_empty(), "in range — no rim arrow");
assert_eq!(sel.icons.len(), 1);
assert_eq!(
@@ -640,7 +734,16 @@ mod tests {
fn the_guard_marker_skips_the_candidacy_filter() {
let mut marker = poi(0, 0, 300.0, "The Inn"); // Flags bit 0 clear: a DBC row would drop
marker.continent_id = 571; // and a continent that isn't the displayed one
let sel = select_landmarks(std::iter::empty(), Some(&marker), None, 0, 0.0, 0.0, 133.0);
let sel = select_landmarks(
std::iter::empty(),
None,
Some(&marker),
None,
0,
0.0,
0.0,
133.0,
);
assert_eq!(sel.arrows.len(), 1, "appended unconditionally");
}
@@ -650,7 +753,7 @@ mod tests {
tower.icon = 9; // col 1, row 1
let plain = poi(3, 0x5, 50.0, "Northshire Abbey");
let rows = [tower, plain];
let sel = select_landmarks(rows.iter(), None, None, 0, 0.0, 0.0, 133.0);
let sel = select_landmarks(rows.iter(), None, None, None, 0, 0.0, 0.0, 133.0);
assert_eq!(sel.arrows.len(), 0);
assert_eq!(sel.icons.len(), 1, "only the Flags&2 tower draws in range");
assert_eq!(
@@ -673,7 +776,16 @@ mod tests {
];
let far = corpse_landmark([2000.0, 0.0, 0.0], 0, "Corpse".into());
let marker = poi(0, 0x63, 700.0, "The Bank");
let sel = select_landmarks(cities.iter(), Some(&marker), Some(&far), 0, 0.0, 0.0, 133.0);
let sel = select_landmarks(
cities.iter(),
None,
Some(&marker),
Some(&far),
0,
0.0,
0.0,
133.0,
);
let names: Vec<&str> = sel.arrows.iter().map(|(_, p, _)| p.name.as_str()).collect();
assert_eq!(names, ["Corpse", "Goldshire", "Northshire Abbey"]);
let (rim, _) = rim_arrow_records(&sel, 0.0, 0.0);
@@ -681,7 +793,16 @@ mod tests {
assert_eq!((rim[1].slot, rim[1].indoors), (-2, false));
let near = corpse_landmark([50.0, 0.0, 0.0], 0, "Corpse".into());
let sel = select_landmarks(std::iter::empty(), None, Some(&near), 0, 0.0, 0.0, 133.0);
let sel = select_landmarks(
std::iter::empty(),
None,
None,
Some(&near),
0,
0.0,
0.0,
133.0,
);
assert!(sel.arrows.is_empty());
assert_eq!(sel.icons.len(), 1);
assert_eq!(
@@ -174,6 +174,7 @@ fn stacked_landmarks_each_get_a_line_in_list_order() {
super::emit_landmarks(
&ctx_at_centre(),
None,
None,
Some(&marker),
Some(&corpse),
0,
+50 -10
View File
@@ -360,7 +360,7 @@ fn emit_minimap(
wmos: Res<Assets<WmoModel>>,
world: benilla_world::world_point::WorldPoint,
asset_server: Res<AssetServer>,
corpse: Res<MinimapCorpse>,
statics: Res<MinimapStatics>,
blip_inputs: blips::BlipInputs,
mut composite: ResMut<composite::MinimapComposite>,
rig: Option<Res<composite::CompositeRig>>,
@@ -673,8 +673,9 @@ fn emit_minimap(
blips::emit_landmarks(
ctx,
pois.as_ref().map(|p| &p.0),
statics.quest.as_ref(),
poi_marker.on_map(map.0),
corpse.0.as_ref(),
statics.corpse.as_ref(),
map.0,
assets.tex(MinimapArtSlot::Icons),
&mut quads,
@@ -844,10 +845,14 @@ fn stage_masks(
(blit, tile)
}
/// The corpse's static blip while the corpse is on the player's map, set by
/// [`feed_minimap_arrows`] (which reads its `CORPSE_RED` name off the VM) for the landmark draw.
/// The static blips the VM names, set by [`feed_minimap_arrows`] for the landmark draw: the
/// selected quest's point (slot 0) while the quest log holds one, and the corpse (slot 2) while the
/// corpse is on the player's map, read with its `CORPSE_RED` name off the VM.
#[derive(Resource, Default)]
pub(crate) struct MinimapCorpse(pub(crate) Option<benilla_formats::AreaPoi>);
pub(crate) struct MinimapStatics {
pub(crate) quest: Option<benilla_formats::AreaPoi>,
pub(crate) corpse: Option<benilla_formats::AreaPoi>,
}
/// Aim the minimap's arrow children, the arrow half of its render (`0x4ec950`), after the tick and
/// before the paint that draws them: the landmark selection's three records (rebuilt when its
@@ -872,7 +877,7 @@ fn feed_minimap_arrows(
),
>,
self_store: Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
mut corpse: ResMut<MinimapCorpse>,
mut statics: ResMut<MinimapStatics>,
inside: Res<MinimapInside>,
mut listed: Local<crate::ui_script::VmMemo<Option<Vec<blips::ArrowKey>>>>,
) {
@@ -885,11 +890,18 @@ fn feed_minimap_arrows(
let dead = corpse_point
.filter(|cp| cp.display_map == map.0 as i32)
.map(|cp| cp.position);
let named = corpse.0.as_ref().map(|c| (c.pos, c.continent_id));
let named = statics.corpse.as_ref().map(|c| (c.pos, c.continent_id));
if dead.map(|p| (p, map.0)) != named {
corpse.0 =
statics.corpse =
dead.map(|p| blips::corpse_landmark(p, map.0, script.global_text("CORPSE_RED")));
}
// The selected quest's slot-0 blip, on the map the player is on as its setter takes it.
let quest = script
.quest_blip()
.map(|b| blips::quest_landmark(&b, map.0));
if quest != statics.quest {
statics.quest = quest;
}
if assets.tex(MinimapArtSlot::Mask).is_none() || !player.active {
return;
}
@@ -900,8 +912,9 @@ fn feed_minimap_arrows(
pois.as_ref()
.into_iter()
.flat_map(|p| p.0.rows().map(|(_, poi)| poi)),
statics.quest.as_ref(),
poi_marker.on_map(map.0),
corpse.0.as_ref(),
statics.corpse.as_ref(),
map.0,
wx,
wy,
@@ -919,6 +932,27 @@ fn feed_minimap_arrows(
})
}
/// The world entry rebuilds the landmark candidates and zeroes the static records (`0x6d8f92`,
/// `0x6d8fb0`), the selected quest's blip with them: it comes back at the next
/// `SelectQuestLogEntry`, which the stock log runs as it updates.
fn clear_quest_blip_on_world_entry(
In(ev): In<benilla_protocol::SessionEvent>,
mut hosted_vm: crate::script_calls::HostedVm,
) {
if !matches!(
ev,
benilla_protocol::SessionEvent::Connected { .. }
| benilla_protocol::SessionEvent::Worldport { .. }
) {
return;
}
hosted_vm.run(|script| {
if let Some(script) = script {
script.clear_quest_blip();
}
})
}
/// Push the player's facing onto the Minimap's player-arrow `Model` every frame, as the reference's
/// per-frame `CMinimap::SetPlayerFacing` (`0x4eb8e0`) writes `[+0x39c]`, what `Model:GetFacing()`
/// reads. `player.facing()` is an unbounded accumulator, so it is normalized to `[0, 2π)`, the
@@ -957,6 +991,12 @@ pub(crate) struct MinimapPlugin;
impl Plugin for MinimapPlugin {
fn build(&self, app: &mut App) {
ping::register(app);
{
use crate::net::NetHandlerApp;
use benilla_protocol::SessionEventKind as K;
app.net_handler(K::Connected, clear_quest_blip_on_world_entry)
.net_handler(K::Worldport, clear_quest_blip_on_world_entry);
}
app.add_observer(on_cvar);
app.init_resource::<MinimapWidget>()
.init_resource::<MinimapZoom>()
@@ -964,7 +1004,7 @@ impl Plugin for MinimapPlugin {
.init_resource::<blips::MinimapBlipHover>()
.init_resource::<MinimapInside>()
.init_resource::<MinimapPing>()
.init_resource::<MinimapCorpse>()
.init_resource::<MinimapStatics>()
.init_resource::<composite::MinimapComposite>()
.add_systems(Startup, setup_minimap.after(AssetSet::Open))
.add_systems(Startup, composite::setup_composite)
+8 -1
View File
@@ -1072,6 +1072,13 @@ pub(crate) enum ClientCommand {
bag_index: u8,
slot: u8,
},
/// `CMSG_READ_ITEM`: the bag (255 for the player's own slots, else the equipped bag's inventory
/// slot) and the slot; the server answers `SMSG_READ_ITEM_OK` or `SMSG_READ_ITEM_FAILED` on the
/// item's guid.
ReadItem {
bag: u8,
slot: u8,
},
/// `CMSG_WRAP_ITEM`, the paper first as on the wire. The client filters nothing; the server
/// refuses with an `ERR_CANT_WRAP_*`, and success is silent.
WrapItem {
@@ -2315,7 +2322,7 @@ pub(crate) struct EnteredWorldMessage {
}
/// The reference's world-enter cascade (`0x4908c0`) ran: our own player's create, at login and
/// every cross-map worldport ([`enter_world_on_self_create`]), or a `/reload`. Its sends wait for
/// every cross-map worldport ([`enter_world_on_self_create`]). Its sends wait for
/// this, not [`EnteredWorldMessage`], which fires before the server has seated the player.
#[derive(Message)]
pub(crate) struct WorldEnterCascadeMessage;
+1
View File
@@ -1465,6 +1465,7 @@ pub(super) fn writer_loop(
target,
} => w.use_item(bag_index, slot, spell_index, target),
ClientCommand::OpenItem { bag_index, slot } => w.open_item(bag_index, slot),
ClientCommand::ReadItem { bag, slot } => w.read_item(bag, slot),
ClientCommand::WrapItem {
gift_bag,
gift_slot,
+67 -36
View File
@@ -46,9 +46,9 @@ pub(super) fn register(app: &mut App) {
.net_handler(K::Disconnected, on_disconnected)
.net_handler(K::Teleport, on_mover_edge)
.net_handler(K::KnockBack, on_mover_edge)
.net_handler(K::ClientControl, on_mover_edge)
.net_handler(K::ClientControl, on_client_control)
.net_handler(K::Worldport, on_worldport)
.net_handler(K::TransferPending, on_transfer)
.net_handler(K::TransferPending, on_transfer_pending)
.net_handler(K::TransferAborted, on_transfer)
.net_handler(K::TimeSpeed, on_clock)
.net_handler(K::GameSpeedSet, on_clock)
@@ -297,14 +297,49 @@ fn on_mover_edge(In(ev): In<SessionEvent>, mut e: Edges, b: Bridge) {
counter,
launch,
} => knock_back(guid, counter, launch, &b.self_guid, &mut e.knockbacks),
SessionEvent::ClientControl { mover, allow_move } => {
e.client_control
.write(ClientControlMessage { mover, allow_move });
}
_ => {}
}
}
/// `SMSG_CLIENT_CONTROL_UPDATE` (`0x5fa6b1` → `SetPlayerControl 0x4958e0`), judged by the
/// controller later. For our own mover the fall of the flag `[0xb4b3e4]` drops the cursor's payload
/// and the cast (`0x4958e8`-`0x495901` → `0x495920`) ahead of `PLAYER_CONTROL_LOST` (`0x49590b`); a
/// store that changes nothing does nothing (`0x4958e6`). The controller stores the flag past the
/// drain, so `stored` follows the packets of this frame, which it has not yet applied.
fn on_client_control(
In(ev): In<SessionEvent>,
mut e: Edges,
frame: Res<bevy::diagnostic::FrameCount>,
mut stored: Local<Option<(u32, bool)>>,
// The flag and our guid are the host's too, so they sit in the set it shares with the cast.
mut vm: crate::script_calls::VmWith<(
crate::spell::SelfCancel<'static>,
Res<'static, SelfGuid>,
Res<'static, crate::player::OwnControlLost>,
)>,
) {
let SessionEvent::ClientControl { mover, allow_move } = ev else {
return;
};
e.client_control
.write(ClientControlMessage { mover, allow_move });
let fell = {
let (_, self_guid, own) = vm.own();
if self_guid.0 != Some(mover) {
return;
}
let down = match *stored {
Some((at, down)) if at == frame.0 => down,
_ => own.0,
};
*stored = Some((frame.0, !allow_move));
!allow_move && !down
};
if fell {
crate::spell::drop_hold(&mut vm, |(mut cancel, ..)| cancel.abort_cast());
}
}
fn on_worldport(
In(ev): In<SessionEvent>,
mut e: Edges,
@@ -315,7 +350,6 @@ fn on_worldport(
view_log: Option<ResMut<crate::ui_unit::views::ViewLog>>,
player: Option<Res<crate::player::Player>>,
selection: Option<ResMut<crate::target::Selection>>,
owed_releases: Option<ResMut<crate::target::OwedReleases>>,
place: crate::ui_party::net::SnapshotPlace,
transforms: Query<&Transform>,
) {
@@ -344,27 +378,12 @@ fn on_worldport(
log.records(&before, &group.stats);
}
log_teardown(view_log, &b);
// The transfer frees every object with no release (`0x467700` → `0x467800`), and the
// reference's own clear is the arrival's `SetSelection(0, 0)` (`0x490927`), which benilla
// does not run: the selected unit's object is torn down here as its release would.
let mut selection = selection;
if let Some(guid) = selection.as_ref().and_then(|s| s.guid) {
if b.index.0.contains_key(&guid) {
let held = |g: u64| {
b.index
.0
.get(&g)
.and_then(|e| b.stores.get(*e).ok())
.map(|s| &s.0)
};
let me = b.self_guid.0;
crate::target::unit_released(
selection.as_mut(),
owed_releases.map(|o| o.into_inner()),
guid,
|| crate::target::raid_member_or_pet(&group, guid, me, &held),
);
}
// The transfer frees every object with no release (`0x467700` → `0x467800`): the selection
// keeps its guid, its object gone, until the arrival's `SelectTarget(0)` (`0x490927`)
// clears it (the unit feed's world-enter turn).
if let Some(mut selection) = selection {
selection.target = None;
selection.lit = None;
}
worldport(
map_id,
@@ -383,15 +402,27 @@ fn on_worldport(
}
}
/// The handler `0x401900` reaches `0x495920` on both of its arms (`0x40194c`), ahead of the
/// loading screen: the cursor's payload and the cast are dropped.
fn on_transfer_pending(
In(ev): In<SessionEvent>,
mut e: Edges,
mut vm: crate::script_calls::VmWith<crate::spell::SelfCancel<'static>>,
) {
if let SessionEvent::TransferPending {
map_id,
transport_entry,
from_map,
} = ev
{
crate::spell::drop_hold(&mut vm, |mut cancel| cancel.abort_cast());
transfer_pending(map_id, transport_entry, from_map, &mut e.transfer);
}
}
fn on_transfer(In(ev): In<SessionEvent>, mut e: Edges) {
match ev {
SessionEvent::TransferPending {
map_id,
transport_entry,
from_map,
} => transfer_pending(map_id, transport_entry, from_map, &mut e.transfer),
SessionEvent::TransferAborted { reason } => transfer_aborted(reason, &mut e.transfer),
_ => {}
if let SessionEvent::TransferAborted { reason } = ev {
transfer_aborted(reason, &mut e.transfer);
}
}
+2
View File
@@ -67,6 +67,8 @@ fn yield_own_body(net_cmds: &NetCommands, player: &mut Player, self_guid: Option
/// our body revoked, or our possessed creature released while feared or confused (the possession
/// standing). `own` is only `[0xb4b3e4]` (`0x4958e0`), which `0x5fa600` writes for our own
/// character alone (`0x5fa6d0`): a grant or a release naming another unit never touches it.
/// The packet's handler already stored the flag and dropped the hold on its fall
/// (`on_client_control`).
fn track_control(player: &mut Player, own: &mut OwnControlLost, verdict: ControlVerdict) {
match verdict {
ControlVerdict::Revoked => {
+59 -18
View File
@@ -227,6 +227,25 @@ impl ScriptCallHost<'_, '_> {
script.hosted(self, |_| ());
}
/// `SelectTarget(0)` (`0x493540`) from an engine writer outside any call, such as the
/// world-enter cascade's (`0x490927`): the deselect and its teardown signal in place, a
/// charmed player's refused (`0x493590`) and an empty selection a no-op.
pub(crate) fn engine_clear_target(&mut self, script: &mut UiScript, mark: crate::lanes::Mark) {
#[cfg(test)]
self.census_probe();
script.host_out(|script| {
let mut at = Signalled {
selected: self.appliers.select().selected(),
latched: self.appliers.pet().attack_latched(),
mark: self.appliers.reports().mark_at(mark),
};
let owed = self.appliers.select().clear_target();
self.settle_selection(script, &mut at, owed);
self.commit_views(script);
});
script.hosted(self, |_| ());
}
/// A field watcher's usable refresh (`0x4b31c0`) outside any call, inside its packet: the bar's
/// states and cache derived off the stores as of the drain's place and pushed, then its signals
/// with the host lent, so their listeners' calls apply inside the verb. A harness without the
@@ -1104,6 +1123,17 @@ impl<P: SystemParam + 'static> VmWith<'_, '_, P> {
}
}
/// An engine writer's `SelectTarget(0)`, signalled in place with the host
/// ([`ScriptCallHost::engine_clear_target`]); with no VM or no host nobody listens and the
/// selection stands.
pub(crate) fn engine_clear_target(&mut self, mark: crate::lanes::Mark) {
if let Some(script) = self.script.as_deref_mut() {
if let Some(host) = self.set.p1().as_mut() {
host.engine_clear_target(script, mark);
}
}
}
/// An engine writer's StartAttack at `guid`, its select signalled in place with the host
/// ([`ScriptCallHost::engine_start_attack`]); with no VM or no host, `start` runs it on `P`.
pub(crate) fn engine_start_attack(
@@ -1365,13 +1395,6 @@ mod tests {
status: 1,
flags: 0,
});
if selected {
*world.resource_mut::<Selection>() = Selection {
target: Some(old),
guid: Some(OLD),
..Default::default()
};
}
let mut script = UiScript::new().expect("a VM");
script.set_spellbook(SpellBookState {
tabs: vec![SpellTabView {
@@ -1386,19 +1409,32 @@ mod tests {
..Default::default()
}],
});
if selected {
// The frame's unit snapshot, as the last feed pushed it.
script.set_unit(
"target",
Some(benilla_ui::script::UnitState {
exists: true,
..Default::default()
}),
);
}
record_reports(&script);
world.insert_non_send_resource(script);
Frame { app, rx }
let mut f = Frame { app, rx };
if selected {
// The world entry first, as a session has it (`0x490927` clears a target there): the
// target comes after.
settle(&mut f);
f.rx.try_iter().for_each(drop);
*f.app.world_mut().resource_mut::<Selection>() = Selection {
target: Some(old),
guid: Some(OLD),
..Default::default()
};
// The frame's unit snapshot, as the last feed pushed it.
f.app
.world_mut()
.non_send_resource_mut::<UiScript>()
.set_unit(
"target",
Some(benilla_ui::script::UnitState {
exists: true,
..Default::default()
}),
);
}
f
}
/// Every catalog line and cast-failure string defined as its own key, but the default
@@ -2706,6 +2742,11 @@ mod tests {
}],
});
f.app.world_mut().insert_non_send_resource(script);
// This VM's world entry first, as a session has it (`0x490927` clears a target there):
// the selection comes after.
let selected = std::mem::take(&mut *f.app.world_mut().resource_mut::<Selection>());
settle(f);
*f.app.world_mut().resource_mut::<Selection>() = selected;
}
/// The unit feed, which signals the selection's change; the calls its handlers make apply
@@ -904,6 +904,8 @@ fn a_creature_family_is_read_off_the_held_units_record() {
&mut f,
&[(OBJECT_FIELD_TYPE, 0x9), (OBJECT_FIELD_ENTRY, BOAR)],
);
// The world entry first, as a session has it: the target comes after.
settle(&mut f);
f.app.world_mut().resource_mut::<Selection>().guid = Some(MOB);
settle(&mut f);
let family =
@@ -0,0 +1,144 @@
//! `0x495920`: losing control and a world port both put the cursor's payload back and cancel the
//! cast (`ClearCursor(1, 1)`, `CancelCast(1, 1, 0x1c)`).
use benilla_protocol::field::FIELD_PLAYER_FIELD_COINAGE;
use benilla_protocol::messages::ObjectType;
use benilla_protocol::{ObjectFields, SessionEvent};
use bevy::prelude::*;
use crossbeam_channel::Receiver;
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::spell::PendingCast;
const ME: u64 = 0x10;
const FIREBALL: u32 = 133;
struct Client {
app: App,
sent: Receiver<ClientCommand>,
}
/// The whole client headless with our player streamed, holding 300 copper on the cursor, a
/// Fireball in flight, and a listener that logs the cursor's money beside each event.
fn client() -> Client {
let mut app = crate::game_plugins::schedule_tests::headless_client_unrun();
let (tx, sent) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
let world = app.world_mut();
world.resource_mut::<SelfGuid>().0 = Some(ME);
world.resource_mut::<crate::net::NetStatus>().connected = true;
crate::script_calls::insert_test_vm(world);
let me = world
.spawn((
SelfPlayer,
Guid(ME),
Transform::default(),
ObjectStore(ObjectFields::from_pairs(&[(2, 0x19)]).into_created(ObjectType::Player)),
))
.id();
world.resource_mut::<GuidIndex>().0.insert(ME, me);
let mut c = Client { app, sent };
let block = SessionEvent::ObjectValues {
guid: ME,
fields: ObjectFields::from_pairs(&[(FIELD_PLAYER_FIELD_COINAGE, 500)]),
};
crate::net::handlers::dispatch(c.world(), crate::net::wire([block]));
crate::lanes::fire_in_order(c.world());
c.lua(
r#"SEEN = {} local f = CreateFrame("Frame")
for _, e in ipairs({"CURSOR_UPDATE", "PLAYER_CONTROL_LOST", "PLAYER_CONTROL_GAINED"}) do
f:RegisterEvent(e)
end
f:SetScript("OnEvent", function() table.insert(SEEN, event .. " " .. GetCursorMoney()) end)"#,
);
c.hold();
c
}
impl Client {
fn world(&mut self) -> &mut World {
self.app.world_mut()
}
fn lua(&mut self, script: &str) {
crate::script_calls::with_hosted_vm(self.world(), |s| s.expect("a VM").run(script))
.expect("the chunk runs");
}
/// 300 copper on the cursor and a Fireball in flight, nothing logged or sent yet.
fn hold(&mut self) {
self.lua("PickupPlayerMoney(300) SEEN = {}");
self.world()
.resource_mut::<PendingCast>()
.push_cast(FIREBALL, None, None);
self.sent();
}
fn seen(&mut self) -> Vec<String> {
crate::script_calls::eval_in_vm(self.world(), "local s = SEEN SEEN = {} return s")
}
fn sent(&self) -> Vec<String> {
self.sent.try_iter().map(|c| format!("{c:?}")).collect()
}
}
/// `SMSG_CLIENT_CONTROL_UPDATE` for our own mover: its fall drops the cursor's payload
/// (`ClearCursor(1, 1)`, whose `CURSOR_UPDATE` reads it empty) and the cast in flight
/// (`CancelCast(1, 1, 0x1c)`), in that order and once, in the packet (`0x495901` → `0x495920`);
/// a store that changes nothing, regaining control and another unit's verdict drop nothing. The
/// controller applies the flag later, so the packets of one frame are told apart by their own
/// order.
#[test]
fn losing_control_drops_the_cursor_and_the_cast_in_its_packet() {
let mut c = client();
let control = |c: &mut Client, mover: u64, allow_move: bool| {
let ev = SessionEvent::ClientControl { mover, allow_move };
crate::net::handlers::dispatch(c.world(), crate::net::wire([ev]));
};
control(&mut c, 0x99, false);
assert_eq!((c.seen(), c.sent()), (vec![], vec![]), "another unit's");
control(&mut c, ME, false);
assert_eq!(c.seen(), ["CURSOR_UPDATE 0"]);
assert_eq!(c.sent(), [format!("CancelCast {{ spell_id: {FIREBALL} }}")]);
assert_eq!(c.world().resource::<PendingCast>().current(), None);
// The cursor refuses a payload while control is down, so a cast stands for what is held.
c.world()
.resource_mut::<PendingCast>()
.push_cast(FIREBALL, None, None);
control(&mut c, ME, false);
assert_eq!((c.seen(), c.sent()), (vec![], vec![]), "the flag was down");
control(&mut c, ME, true);
assert_eq!(
(c.seen(), c.sent()),
(vec![], vec![]),
"regaining drops nothing"
);
assert_eq!(
c.world().resource::<PendingCast>().current(),
Some(FIREBALL)
);
control(&mut c, ME, false);
assert_eq!(
c.sent(),
[format!("CancelCast {{ spell_id: {FIREBALL} }}")],
"a new fall"
);
}
/// Both arms of `SMSG_TRANSFER_PENDING`'s handler reach `0x495920` (`0x40194c`).
#[test]
fn a_transfer_pending_drops_the_cursor_and_the_cast() {
for transport_entry in [None, Some(176_080)] {
let mut c = client();
let pending = SessionEvent::TransferPending {
map_id: 36,
transport_entry,
from_map: None,
};
crate::net::handlers::dispatch(c.world(), crate::net::wire([pending]));
assert_eq!(c.seen(), ["CURSOR_UPDATE 0"], "{transport_entry:?}");
assert_eq!(c.sent(), [format!("CancelCast {{ spell_id: {FIREBALL} }}")]);
}
}
+15
View File
@@ -432,6 +432,21 @@ pub(crate) struct SelfCancel<'w> {
ground: ResMut<'w, super::targeting::SpellTargeting>,
}
/// `0x495920`, the drop that losing control (`0x495901`) and a world port (`0x40194c`) both make:
/// `ClearCursor(1, 1)` (`0x495927`), signalled with the host lent, then `CancelCast(1, 1, 0x1c)`
/// (`0x49592c`-`0x495932`), which `cancel` runs on the cast state of `P`.
pub(crate) fn drop_hold<P: bevy::ecs::system::SystemParam + 'static>(
vm: &mut crate::script_calls::VmWith<'_, '_, P>,
cancel: impl FnOnce(bevy::ecs::system::SystemParamItem<'_, '_, P>),
) {
vm.run(|script| {
if let Some(script) = script {
script.clear_cursor_payload();
}
});
cancel(vm.own());
}
impl SelfCancel<'_> {
/// `SpellStopCasting()` (`0x6e6e80`), the ESC chain's cast rung.
pub(crate) fn stop_casting(&mut self) {
+4 -2
View File
@@ -14,6 +14,8 @@ mod bind_gates;
mod cast_send;
pub(crate) mod cast_target;
pub(crate) mod cooldowns;
#[cfg(test)]
mod drop_hold_tests;
pub(crate) mod group_relation;
mod inflight;
pub(crate) mod known;
@@ -36,8 +38,8 @@ pub(crate) use cast_send::{
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use cooldowns::{CooldownLive, Cooldowns, Signal};
pub(crate) use inflight::{
ActiveChannel, AutoRepeatActive, CastState, LocalMoveStart, PendingCast, QueuedMeleeSpell,
SelfCancel, SPELL_INTERRUPT_MOVEMENT,
drop_hold, ActiveChannel, AutoRepeatActive, CastState, LocalMoveStart, PendingCast,
QueuedMeleeSpell, SelfCancel, SPELL_INTERRUPT_MOVEMENT,
};
// The command press's harness runs the self-cancel between its two systems.
#[cfg(test)]
+12 -6
View File
@@ -814,10 +814,7 @@ fn a_world_click_signals_its_target_change_before_the_re_swing() {
let mob = f.mob;
f.make_unit(old);
f.make_unit(mob);
f.app
.world_mut()
.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("a VM"));
crate::ui_unit::run_feed_units_once(f.app.world_mut());
vm_in_the_world(&mut f);
crate::script_calls::with_hosted_vm(f.app.world_mut(), |s| {
s.expect("a VM").run(
r#"HEARD = 0
@@ -921,12 +918,21 @@ fn wire_order(rx: &Receiver<ClientCommand>) -> Vec<String> {
.collect()
}
/// A VM in `f` whose `PLAYER_TARGET_CHANGED` listener rolls, so the wire shows where it ran.
fn rolling_listener(f: &mut Fix) {
/// A VM in `f`, past its world entry as a session has it (`0x490927` clears a target there): the
/// target comes after, and the feed hears it before any listener is in.
fn vm_in_the_world(f: &mut Fix) {
f.app
.world_mut()
.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("a VM"));
let selected = std::mem::take(&mut *f.app.world_mut().resource_mut::<Selection>());
crate::ui_unit::run_feed_units_once(f.app.world_mut());
*f.app.world_mut().resource_mut::<Selection>() = selected;
crate::ui_unit::run_feed_units_once(f.app.world_mut());
}
/// A VM in `f` whose `PLAYER_TARGET_CHANGED` listener rolls, so the wire shows where it ran.
fn rolling_listener(f: &mut Fix) {
vm_in_the_world(f);
crate::script_calls::with_hosted_vm(f.app.world_mut(), |s| {
s.expect("a VM").run(
r#"local r = CreateFrame("Frame")
-8
View File
@@ -44,7 +44,6 @@ pub(super) fn register(app: &mut App) {
.init_resource::<Releases>()
.net_handler(SessionEventKind::ObjectsRemoved, on_objects_removed)
.net_handler(SessionEventKind::ObjectDestroyed, on_object_destroyed)
.net_handler(SessionEventKind::Worldport, on_worldport)
.add_exclusive_lane("releases", peek_releases, fire_releases, |_| {
Default::default()
});
@@ -110,13 +109,6 @@ fn on_object_destroyed(In(ev): In<SessionEvent>, world: &mut World) {
}
}
/// The transfer's owed teardown ([`run_owed`]).
fn on_worldport(In(ev): In<SessionEvent>, world: &mut World) {
if let SessionEvent::Worldport { .. } = ev {
run_owed(world);
}
}
/// The packet's owed teardowns, each run as the firing of its release's stamp, so its sends go
/// out in the packet's place and what it signals fires among the other lanes' items there.
fn run_owed(world: &mut World) {
@@ -698,3 +698,39 @@ fn a_far_spell_at_a_selection_with_no_object_sends_with_no_target() {
assert_eq!(cast(EAGLE_EYE), (vec![None], false), "Eagle Eye");
assert_eq!(cast(MIND_VISION_1), (vec![], true), "Mind Vision 1");
}
/// A transfer releases no target (`~CGUnit_C 0x5fb5e0` never reaches `0x493910`): a groupmate
/// selected out of sight, with no object to deactivate, and a held unit both stay selected through
/// the port, by guid, with nothing sent or signalled; the arrival's `SelectTarget(0)` (`0x490927`)
/// clears them (`unit_feed_live_tests`).
#[test]
fn a_world_port_keeps_the_selection_by_guid() {
for held in [false, true] {
let mut f = grouped();
run(
&mut f,
r#"SEEN = 0 local f = CreateFrame("Frame") f:RegisterEvent("PLAYER_TARGET_CHANGED")
f:SetScript("OnEvent", function() SEEN = SEEN + 1 end)"#,
);
let guid = if held {
OLD
} else {
select_unheld(&mut f, MEMBER);
MEMBER
};
f.wire();
deliver(
&mut f,
SessionEvent::Worldport {
map_id: 1,
position: [0.0; 3],
orientation: 0.0,
needs_ack: true,
},
);
assert_eq!(selection(&f), (Some(guid), None), "held: {held}");
assert_eq!(picks(&f), Vec::<u64>::new(), "the port sends nothing");
let seen: u32 = crate::script_calls::eval_in_vm(f.app.world_mut(), "return SEEN");
assert_eq!(seen, 0);
}
}
+19 -1
View File
@@ -114,6 +114,10 @@ pub(crate) struct ChannelState {
/// gate. Only a confirm (`0x49bbbf`) and a free (`0x49bc41`) recompute it, so it holds
/// through a rename, a suspend or a pending join.
pub defense_channel: bool,
/// Each slot's instance number (`+0x98`), stored by the join notice's confirm (`0x49bb20`) and
/// written by no allocator, so a freed and reused slot keeps the last one; the session's end
/// frees the array.
pub instances: [u32; MAX_CHANNELS],
}
/// One slot, the reference's `[0xb4fe04] + n*0xa0` record, in the fields this client uses.
@@ -252,6 +256,7 @@ impl ChannelState {
self.ended_custom = Some(self.live_custom_channels());
}
self.joined.clear();
self.instances = [0; MAX_CHANNELS];
}
/// Whether the mask carries `id`'s bit, the walk's live predicate `0x49a494`.
@@ -335,6 +340,16 @@ impl ChannelState {
Some(i as u32 + 1)
}
/// `0x49bb20`'s store of the join notice's instance (`+0x98`) on slot `number`.
pub(crate) fn store_instance(&mut self, number: u32, instance: u32) {
if let Some(cell) = (number as usize)
.checked_sub(1)
.and_then(|i| self.instances.get_mut(i))
{
*cell = instance;
}
}
/// `0x49bd50`: whether a slot with a number (`0x49bd72`) in state 0 (`0x49bd7c`) has a
/// `+0x94` id naming a row (`0x49bd83`-`0x49bd96`) with both `ZONE_DEP` and `DEFENSE`
/// (`0x49bd9b`, `0x49bda2`), which is row 22 alone; a custom channel's id 0 names no row.
@@ -464,6 +479,7 @@ impl ChannelState {
Some(ChannelStamp {
number,
id: self.joined[number as usize - 1].id(),
instance: self.instances[number as usize - 1],
})
}
@@ -481,16 +497,18 @@ impl ChannelState {
pub(crate) struct ChannelStamp {
number: u32,
id: u32,
instance: u32,
}
impl ChannelStamp {
/// Stamp `event`, whose `channel` is the record's name: arg4 becomes `"N. Name"` (`0x8445c8`,
/// the hit leg `0x49aa48`), arg8 the number, arg9 the name with its zone tail and arg7 the
/// slot's id.
/// slot's id; arg10 is its instance.
pub(crate) fn apply(self, event: &mut super::event::ChatEvent) {
event.channel_base = event.channel.clone();
event.zone_channel_id = self.id;
event.channel_number = self.number;
event.instance = self.instance;
event.channel = format!("{}. {}", self.number, event.channel_base);
}
}
+7 -4
View File
@@ -304,7 +304,7 @@ impl ChatEventKind {
/// | 7 | `zone_channel_id` | the ChannelID, 0 for a custom channel (`ChatFrame.lua:1379`) |
/// | 8 | `channel_number` | the local slot, `ChatTypeInfo["CHANNEL"..arg8]` |
/// | 9 | `channel_base` | the channel name without its number |
/// | 10 | none | the split index, 0 from vmangos (`Chat/Channel.cpp:823-827`) |
/// | 10 | `instance` | the channel record's instance number (`+0x98`, `0x49b115`), 0 from vmangos (`Chat/Channel.cpp:827`) |
///
/// arg4, arg7, arg8 and arg9 are one record ([`super::edit::ChannelState::stamp_channel`]). arg1
/// is the garbled text: the reference fills one buffer (`SStrCopy` at `0x49a9f0`, the garble
@@ -322,6 +322,10 @@ pub(crate) struct ChatEvent {
pub zone_channel_id: u32,
pub channel_number: u32,
pub channel_base: String,
/// arg10, the instance number the channel's record holds, stamped with arg4 and arg7-arg9.
pub instance: u32,
/// A `YOU_JOINED` notice's instance word, which the confirm stores on the slot.
pub joined_instance: u32,
pub notice: String,
/// The notice's token when its byte alone does not name it: a mode change (`0x0C`) shows up
/// to four, `SET_`/`UNSET_` `MODERATOR`/`VOICE`, one per bit (`0x49e8a0`).
@@ -373,8 +377,7 @@ impl ChatEvent {
ScriptValue::Int(i64::from(self.zone_channel_id)),
ScriptValue::Int(i64::from(self.channel_number)),
ScriptValue::Str(self.channel_base.clone()),
// arg10: always 0, see the struct doc.
ScriptValue::Int(0),
ScriptValue::Int(i64::from(self.instance)),
]
}
}
@@ -646,7 +649,7 @@ pub(crate) fn kind_of_wire(chat_type: u8) -> Option<ChatEventKind> {
m::CHAT_MSG_AFK => K::Afk,
m::CHAT_MSG_DND => K::Dnd,
m::CHAT_MSG_IGNORED => K::Ignored,
m::CHAT_MSG_MONSTER_WHISPER | m::CHAT_MSG_RAID_BOSS_WHISPER => K::MonsterWhisper,
m::CHAT_MSG_MONSTER_WHISPER => K::MonsterWhisper,
m::CHAT_MSG_RAID_LEADER => K::RaidLeader,
m::CHAT_MSG_RAID_WARNING => K::RaidWarning,
m::CHAT_MSG_RAID_BOSS_EMOTE => K::RaidBossEmote,
+25 -5
View File
@@ -533,7 +533,10 @@ impl ChatLog {
tries: 0,
});
} else {
if let Some(event) = notice_event(notice_byte, channel, name, None) {
if let Some(mut event) = notice_event(notice_byte, channel, name, None) {
if let ChannelNoticeTail::YouJoined { instance, .. } = tail {
event.joined_instance = *instance;
}
self.q().push(Pending::Event(event));
}
}
@@ -848,6 +851,19 @@ impl ChatLog {
.count()
}
/// Every wire line waiting to render, in order.
#[cfg(test)]
pub(crate) fn pending_wire(&self) -> Vec<ChatMessage> {
self.q()
.pending
.iter()
.filter_map(|q| match &q.item {
Pending::Wire { msg, .. } => Some(msg.clone()),
_ => None,
})
.collect()
}
/// The text of every composed line waiting to render, in order.
#[cfg(test)]
pub(crate) fn pending_lines(&self) -> Vec<String> {
@@ -1254,10 +1270,14 @@ fn deliver_before(
// The slot was taken when the join was sent; the notice only confirms it, which a
// handler's `SendChatMessage` to it reads from this event on.
match channels.confirm_slot(&event.channel) {
Some(slot) => debug!(
"chat: server confirms channel {:?} joined (slot {slot})",
event.channel
),
Some(slot) => {
// `0x49bb20` stores the notice's instance with the confirm.
channels.store_instance(slot, event.joined_instance);
debug!(
"chat: server confirms channel {:?} joined (slot {slot})",
event.channel
);
}
None => debug!("chat: {:?} joined with no slot to confirm", event.channel),
}
channels.mirror_into(script);
+68 -1
View File
@@ -2,7 +2,7 @@
//! chat. `SMSG_NOTIFICATION` and `SMSG_AREA_TRIGGER_MESSAGE` are `UIErrorsFrame` toasts instead,
//! queued on [`UiErrorTexts`].
use benilla_protocol::messages::{ChatMessage, CHAT_MSG_WHISPER};
use benilla_protocol::messages::{ChatMessage, CHAT_MSG_RAID_BOSS_WHISPER, CHAT_MSG_WHISPER};
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
@@ -167,6 +167,16 @@ fn chat(
&format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")),
);
}
// Type 0x59 takes its own branch (`0x49d60c`): the line is dropped silently when the guid it
// carries is ignored (`0x49d671` → `IsIgnored 0x5ae5a0`), else the type becomes 6 (`0x49d67e`),
// a plain whisper (`CHAT_MSG_WHISPER`, `0x8062e0`) with its inline sender name.
let mut m = m;
if m.chat_type == CHAT_MSG_RAID_BOSS_WHISPER {
if social.is_ignored(m.target_guid) {
return;
}
m.chat_type = CHAT_MSG_WHISPER;
}
// An ignored speaker is dropped silently (`FriendList::IsIgnored`, `0x5ae5a0`); a dropped
// whisper also sends `CMSG_CHAT_IGNORED`, so the sender hears "is ignoring you".
if social.is_ignored(m.sender_guid) {
@@ -269,6 +279,63 @@ mod tests {
world.resource::<ChatLog>().pending_len()
}
/// A raid boss whisper (type 0x59) is a plain whisper after the parser (`0x49d67e`): the
/// `CHAT_MSG_WHISPER` event, the inline sender name kept, and no `CMSG_CHAT_IGNORED`. A line
/// whose carried guid is ignored drops silently (`0x49d671`).
#[test]
fn a_raid_boss_whisper_is_queued_as_a_whisper_unless_its_guid_is_ignored() {
use benilla_protocol::messages::CHAT_MSG_RAID_BOSS_WHISPER;
let boss = ChatMessage {
chat_type: CHAT_MSG_RAID_BOSS_WHISPER,
sender_guid: 0,
target_guid: 0x77,
sender_name: Some("Ragnaros".into()),
..a_party_line(0, "Too soon!")
};
for ignored in [false, true] {
let mut world = World::new();
world.init_resource::<Messages<ServerSaidMessage>>();
let (tx, rx) = crossbeam_channel::unbounded();
world.insert_resource(NetCommands(tx));
let mut social = SocialState::default();
if ignored {
social.ignore_for_test(0x77);
}
world.insert_resource(social);
world.insert_resource(ChatLog::default());
let m = boss.clone();
world
.run_system_once(
move |chat_log: Res<ChatLog>,
social: Res<SocialState>,
net_commands: Res<NetCommands>,
mut server_said: MessageWriter<ServerSaidMessage>| {
chat(
m.clone(),
&chat_log,
&social,
&net_commands,
&mut server_said,
);
},
)
.unwrap();
let wire = world.resource::<ChatLog>().pending_wire();
if ignored {
assert!(wire.is_empty(), "dropped");
} else {
assert_eq!(wire.len(), 1);
assert_eq!(wire[0].chat_type, CHAT_MSG_WHISPER);
assert_eq!(wire[0].sender_name.as_deref(), Some("Ragnaros"));
assert_eq!(
crate::ui_chat::event::kind_of_wire(wire[0].chat_type),
Some(crate::ui_chat::ChatEventKind::Whisper)
);
}
assert!(rx.try_iter().next().is_none(), "no CMSG_CHAT_IGNORED");
}
}
/// `SMSG_PLAYED_TIME` (`0x401850`) fills the mailbox `TIME_PLAYED_MSG` fires from (`0x4018df`)
/// and prints nothing to chat itself: its own lines go to the console (`0x401878`-`0x4018c8`),
/// and the stock `ChatFrame_DisplayTimePlayed` is the answer's one printer.
+43 -1
View File
@@ -139,6 +139,45 @@ fn a_members_join_reaches_the_stock_frame_numbered() {
assert!(f.script().errors().is_empty(), "{:?}", f.script().errors());
}
/// The join notice's second word is the slot's instance (`0x49bb20`, `+0x98`), which every event
/// the channel's record stamps reads as arg10 (`0x49b115`): our own join, another member's, and
/// a reused slot's, since no allocator writes it (`0x49ba01`).
#[test]
fn a_joins_instance_number_is_arg10_of_the_slots_events() {
let _data = benilla_formats::wow_data_or_skip!();
let mut f = Feed::new();
f.name(ANN, "Ann");
f.push(
n::YOU_JOINED,
"Mychan",
ChannelNoticeTail::YouJoined {
flags: 0,
instance: 7,
},
);
f.drain();
assert_eq!(f.spy().1, "YOU_JOINED|||3. Mychan|||0|3|Mychan|7");
f.push(n::JOINED, "Mychan", ChannelNoticeTail::Guid(ANN));
f.drain();
assert_eq!(f.spy().1, "|Ann||3. Mychan|||0|3|Mychan|7");
// Freed and claimed again by another name, the slot keeps its instance until a join stores one.
let mut channels = f.world.resource_mut::<super::edit::ChannelState>();
channels.free_slot("Mychan");
channels.claim_slot("Otherchan", 0);
assert_eq!(channels.instances[2], 7);
let mut line =
super::event::ChatEvent::text_only(super::event::ChatEventKind::Channel, "".into());
line.channel = "Otherchan".into();
channels.stamp_channel(&mut line);
assert_eq!((line.channel_number, line.instance), (3, 7));
let mut missing =
super::event::ChatEvent::text_only(super::event::ChatEventKind::Channel, "".into());
missing.channel = "Nowhere".into();
channels.stamp_channel(&mut missing);
assert_eq!(missing.instance, 0, "no record, no instance");
}
/// A join whose name is not cached waits on the name query, then prints stamped, as the
/// reference's queued node carries the channel name to the callback (`0x49cfb4`-`0x49cfc1`).
#[test]
@@ -331,7 +370,10 @@ fn our_own_join_notice_is_unchanged() {
f.push(
n::YOU_JOINED,
"Mychan",
ChannelNoticeTail::YouJoined { flags: 0 },
ChannelNoticeTail::YouJoined {
flags: 0,
instance: 0,
},
);
f.drain();
assert_eq!(
+384 -7
View File
@@ -2,13 +2,16 @@
//! opens in. Every route ends at `0x4e32e0(guid, flag)`, which looks the guid up as any object and
//! asks it for its text (`vtbl+0x74`), a mail-made letter's `ITEM_FIELD_ITEM_TEXT_ID` or else a
//! page chain from an item template's `PageText` or a GameObject's pageID column (TEXT `data[0]`,
//! GOOBER `data[7]`). A bag item or a TEXT object opens locally, with no packet: vmangos has no use
//! GOOBER `data[7]`). A letter or a TEXT object opens locally, with no packet: vmangos has no use
//! arm for a type-9 object and refuses `CMSG_READ_ITEM` without a template `PageText`, which the
//! Plain Letter lacks (`ItemHandler.cpp:421`). A GOOBER's opens on `SMSG_GAMEOBJECT_PAGETEXT`
//! (`0x5f88c0`), the server's answer to its use.
//! (`0x5f88c0`), the server's answer to its use. A bag book, an item with a page chain and no
//! item-text id whose `ITEM_FIELD_FLAGS` lack bit 0x2, sends `CMSG_READ_ITEM` after its begin
//! (`0x4e3586` → `0x5ec580`) and shows only on the reply ([`request_read`], `0x5ec600`):
//! `SMSG_READ_ITEM_OK` marks the item read and shows, `SMSG_READ_ITEM_FAILED` shows (reason 0),
//! shows untranslated with `ITEM_TEXT_TRANSLATION` (reason 1) or closes (any other).
//!
//! The events follow `ItemTextFrame.lua:10-96`; `ITEM_TEXT_TRANSLATION`, the foreign-language
//! bar, never fires, as neither source carries the language column.
//! The events follow `ItemTextFrame.lua:10-96`.
//!
//! The reader is a window on the interaction latch. Its cell is written at the read
//! (`0x4e3364`), but it registers only once the text resolves (`0x4e349a`), right before
@@ -101,6 +104,15 @@ pub(crate) struct ReadSession {
/// The text record the open asked for and waits on (`0x4e3465`, `0x4e347c`), whose answer's
/// callback `0x4e3280` opens again (`0x4e32a5`), on the item-text lane at the answer's place.
awaiting: Option<TextRecord>,
/// A bag book's show waits on the server's reply to its read (`0x4e3586`), or on a read the
/// open could not send.
awaiting_reply: bool,
/// The reply showed the page again (`0x4e35b0`): the next feed repaints and signals
/// `ITEM_TEXT_READY`.
reshow: bool,
/// The translation time of a reply of reason 1, in seconds (`0x5ec6ac`), signalled after the
/// show.
translation: Option<f64>,
}
/// The record a reader's text comes from: a page of the page cache (`0xc0e174`), or a letter's
@@ -170,9 +182,22 @@ impl ItemTextOpen {
told: Default::default(),
opened: false,
awaiting: None,
awaiting_reply: false,
reshow: false,
translation: None,
});
}
/// `0x4e35b0` from a read reply: the page shows again when the guid is the open one, with the
/// translation time of a reply that carries one.
fn show(&mut self, guid: u64, translation: Option<f64>) {
if let Some(sess) = self.pending.as_mut().filter(|s| s.object_guid == guid) {
sess.awaiting_reply = false;
sess.reshow = true;
sess.translation = translation;
}
}
/// A bag letter's session written directly, for a test that seats it.
#[cfg(test)]
pub(crate) fn open_letter(&mut self, item_guid: u64, text_id: u32) {
@@ -227,6 +252,19 @@ enum TextStep {
/// A text record's answer (`SMSG_PAGE_TEXT_QUERY_RESPONSE`, `SMSG_ITEM_TEXT_QUERY_RESPONSE`),
/// whose callback `0x4e3280` opens the reader again when it waits on that record.
Landed(TextRecord),
/// A read's reply (`SMSG_READ_ITEM_OK`, `SMSG_READ_ITEM_FAILED`), which `0x5ec600` applies.
Reply(ReadReply),
}
/// What a read item's reply says (`0x5ec600`).
#[derive(Clone, Copy, Debug)]
enum ReadReply {
Ok(u64),
Failed {
guid: u64,
reason: u8,
milliseconds: i32,
},
}
/// The item-text lane: each step stamped at its packet ([`crate::lanes::stamp`]).
@@ -314,7 +352,7 @@ fn fire_item_text(In(until): In<crate::lanes::Until>, world: &mut World) {
.iter()
.filter_map(|(_, s)| match s {
TextStep::Landed(record) => Some(*record),
TextStep::Read(_) => None,
TextStep::Read(_) | TextStep::Reply(_) => None,
})
.collect();
world.resource_mut::<ItemTextOpen>().landing = landing;
@@ -326,6 +364,10 @@ fn fire_item_text(In(until): In<crate::lanes::Until>, world: &mut World) {
_ => return,
}
}
TextStep::Reply(reply) => {
apply_reply(world, reply);
return;
}
}
if let Err(e) = world.run_system_cached(begin_item_text) {
debug!("item text: the begin did not run: {e}");
@@ -352,6 +394,8 @@ fn spill_item_text_lane(mut lane: ResMut<ItemTextLane>, mut open: ResMut<ItemTex
sess.awaiting = None;
}
}
// No reader is open with no interface up to hear it.
TextStep::Reply(_) => {}
}
}
}
@@ -482,6 +526,141 @@ fn begin_core(world: &mut World, object: u64, begun: ItemTextState) {
sess.told.get(script).begun = true;
}
});
if let Err(e) = world.run_system_cached_with(request_read, object) {
debug!("item text: the read request did not run: {e}");
}
}
/// `0x5ec600` on a read's reply, at its packet's place on the item-text lane. OK finds the item
/// (mask 2, `0x5ec635`), marks it read (`0x5d9a80`, `ITEM_FIELD_FLAGS` bit 0x2) and shows its page
/// translated (`0x4e35b0`); a missing item does neither. FAILED reads a reason: 0 shows, 1 shows
/// untranslated and signals `ITEM_TEXT_TRANSLATION` with the time in seconds, whatever item it
/// names, any other signals
/// `ITEM_TEXT_CLOSED` when the guid is the open item's, closing nothing else.
fn apply_reply(world: &mut World, reply: ReadReply) {
match reply {
ReadReply::Ok(guid) => {
let Some(entity) = world
.resource::<crate::net::GuidIndex>()
.0
.get(&guid)
.copied()
else {
return;
};
let Some(mut store) = world.get_mut::<crate::net::ObjectStore>(entity) else {
return;
};
if !store.0.is_item() {
return;
}
let flags = store.0.item_flags().unwrap_or(0) | ITEM_FLAG_READ;
store.0.merge(benilla_protocol::ObjectFields::from_pairs(&[(
ITEM_FIELD_FLAGS,
flags,
)]));
world.resource_mut::<ItemTextOpen>().show(guid, None);
}
ReadReply::Failed {
guid,
reason,
milliseconds,
} => match reason {
0 => world.resource_mut::<ItemTextOpen>().show(guid, None),
1 => {
// The signal follows the show but is not gated by it (`0x5ec6a1`-`0x5ec6c5`): an
// open item's comes after its `ITEM_TEXT_READY`, any other's at once.
let seconds = f64::from(milliseconds) * 0.001;
if world.resource::<ItemTextOpen>().object() == Some(guid) {
world
.resource_mut::<ItemTextOpen>()
.show(guid, Some(seconds));
} else {
crate::ui_session::fire(world, |script| {
script.fire_event(
"ITEM_TEXT_TRANSLATION",
vec![benilla_ui::script::ScriptValue::Number(seconds)],
);
});
}
}
_ if world.resource::<ItemTextOpen>().object() == Some(guid) => {
crate::ui_session::fire(world, |script| {
script.fire_event("ITEM_TEXT_CLOSED", vec![]);
});
}
_ => {}
},
}
}
/// The open's last step for an item (`0x4e34a9`-`0x4e3586`), after `ITEM_TEXT_BEGIN`: one with a
/// page chain and no item-text id (`+0x9c`) whose `ITEM_FIELD_FLAGS` lack bit 0x2, which a read
/// reply sets (`0x5d9a80`), sends `CMSG_READ_ITEM` from its place in its container (`0x5ec580`)
/// and shows on the reply; with no active player, no container or no place in it the open ends
/// there, and nothing shows. Any other object goes straight to the show (`0x4e359b`).
fn request_read(
In(object): In<u64>,
mut open: ResMut<ItemTextOpen>,
objects: Objects,
net: Res<NetCommands>,
) {
let Some(sess) = open.pending.as_mut().filter(|s| s.object_guid == object) else {
return;
};
let Some(item) = objects.object(object).filter(|f| f.is_item()) else {
return;
};
let reads_by_server = matches!(sess.source, ReadSource::Pages { .. })
&& item.item_text_id().unwrap_or(0) == 0
&& item.item_flags().unwrap_or(0) & ITEM_FLAG_READ == 0;
if !reads_by_server {
return;
}
sess.awaiting_reply = true;
match read_position(object, &objects) {
Some((bag, slot)) => {
let _ = net.0.send(ClientCommand::ReadItem { bag, slot });
}
None => debug!("item text: {object:#x} has no place in a container to read from"),
}
}
/// `ITEM_FIELD_FLAGS` (field 21) and its bit 0x2, which `SMSG_READ_ITEM_OK` sets on the item
/// (`0x5d9a80`).
const ITEM_FIELD_FLAGS: u16 = 21;
const ITEM_FLAG_READ: u32 = 0x2;
/// The container byte and slot `0x5ec580` sends for the item: the player's own slots as 255 and the
/// index in the player's 113 guids, an equipped bag (inventory slots 19 to 22) as its slot and the
/// index in the bag (`0x5ec598`, `0x5ec5b3`-`0x5ec5e5`); a bank bag or an unequipped container
/// sends nothing.
fn read_position(item: u64, objects: &Objects) -> Option<(u8, u8)> {
let contained = objects.object(item)?.item_contained()?;
let player = objects.player_fields()?;
if objects.entity(contained) == objects.player() {
let index = (0..=112u8).find(|&i| player_slot(player, i) == Some(item))?;
return Some((0xFF, index));
}
let bag = (19..23u8).find(|&i| player.player_inv_slot(i) == Some(contained))?;
let container = objects.object(contained)?;
let index = (0..36u8).find(|&i| container.container_slot(i) == Some(item))?;
Some((bag, index))
}
/// Slot `i` of the player's guid array: equipment and equipped bags (0 to 22), the backpack
/// (23 to 38), the bank (39 to 62), the bank bags (63 to 68), the buyback (69 to 80) and the
/// keyring (81 to 112).
fn player_slot(player: &benilla_protocol::ObjectFields, i: u8) -> Option<u64> {
match i {
0..=22 => player.player_inv_slot(i),
23..=38 => player.player_pack_slot(i - 23),
39..=62 => player.player_bank_slot(i - 39),
63..=68 => player.player_bank_bag_slot(i - 63),
69..=80 => player.player_buyback_slot(i - 69),
81..=112 => player.player_keyring_slot(i - 81),
_ => None,
}
}
/// A read of `object_guid`'s pages run to its begin, as a click and the next feed's reads and
@@ -510,7 +689,28 @@ mod net {
SessionEventKind::GameObjectPageText,
on_gameobject_page_text,
)
.net_handler(SessionEventKind::MailItemText, on_item_text);
.net_handler(SessionEventKind::MailItemText, on_item_text)
.net_handler(SessionEventKind::ReadItemOk, on_read_item)
.net_handler(SessionEventKind::ReadItemFailed, on_read_item);
}
/// `SMSG_READ_ITEM_OK` and `SMSG_READ_ITEM_FAILED` (`0x5e7d90` → `0x5ec600`), applied at their
/// place on the item-text lane ([`super::apply_reply`]).
fn on_read_item(In(ev): In<SessionEvent>, mut lane: ResMut<super::ItemTextLane>) {
let reply = match ev {
SessionEvent::ReadItemOk { guid } => super::ReadReply::Ok(guid),
SessionEvent::ReadItemFailed {
guid,
reason,
milliseconds,
} => super::ReadReply::Failed {
guid,
reason,
milliseconds,
},
_ => return,
};
lane.push(super::TextStep::Reply(reply));
}
/// `SMSG_ITEM_TEXT_QUERY_RESPONSE`: a letter's body landing, whose callback `0x4e3280` opens
@@ -682,7 +882,10 @@ fn feed_item_text(
return;
};
// The begin ([`begin_item_text`]) fires first, latching the reader.
if sess.told.get(script).ready || !sess.told.get(script).begun {
if std::mem::take(&mut sess.reshow) {
sess.told.get(script).ready = false;
}
if sess.told.get(script).ready || !sess.told.get(script).begun || sess.awaiting_reply {
return;
}
let ask = Ask::at(
@@ -770,6 +973,12 @@ fn feed_item_text(
material: readable.material,
}));
fires.post("ITEM_TEXT_READY", vec![]);
if let Some(seconds) = sess.translation.take() {
fires.post(
"ITEM_TEXT_TRANSLATION",
vec![benilla_ui::script::ScriptValue::Number(seconds)],
);
}
sess.told.get(script).ready = true;
})
}
@@ -1062,6 +1271,174 @@ mod tests {
assert_eq!(text, "Oh darn it.");
}
/// A bag book (a page chain, no item-text id, `ITEM_FIELD_FLAGS` bit 0x2 clear) sends
/// `CMSG_READ_ITEM` from its pack slot once its begin has run (`0x4e3586` → `0x5ec580`) and
/// shows nothing until the reply: OK marks the item read and shows (`0x5ec640`), a later read
/// of it sends nothing; FAILED shows (reason 0), shows untranslated and signals
/// `ITEM_TEXT_TRANSLATION` in seconds (1) or only signals `ITEM_TEXT_CLOSED` (2) (`0x5ec600`).
#[test]
fn a_bag_book_asks_the_server_and_shows_on_the_reply() {
use benilla_protocol::SessionEvent as E;
const BOOK: u64 = 0x4000_0000_0000_0021;
const ME: u64 = 0x10;
const PACK_SLOT_1: u16 = 532;
for reply in ["ok", "reason 0", "reason 1", "reason 2"] {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.init_resource::<ItemTextOpen>()
.init_resource::<ItemTextLane>()
.init_resource::<PageTexts>()
.init_resource::<MailOpen>()
.init_resource::<Items>()
.init_resource::<NameCache>()
.init_resource::<GameObjectTemplates>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::world_state::WorldStates>()
.add_systems(
Update,
(apply_reads, begin_item_text, feed_item_text).chain(),
);
net::register(&mut app);
let script = UiScript::new().unwrap();
script
.run(
r#"SEEN = {} local f = CreateFrame("Frame")
for _, e in ipairs({"ITEM_TEXT_BEGIN", "ITEM_TEXT_READY",
"ITEM_TEXT_TRANSLATION", "ITEM_TEXT_CLOSED"}) do f:RegisterEvent(e) end
f:SetScript("OnEvent", function()
table.insert(SEEN, event .. (arg1 and (":" .. arg1) or ""))
end)"#,
)
.unwrap();
app.insert_non_send_resource(script);
let world = app.world_mut();
let mut template = crate::items::test_template("Gnarled Book");
template.page_text = 358;
world
.resource_mut::<Items>()
.insert_template(5_000, Some(template));
world
.resource_mut::<PageTexts>()
.insert(358, "Once upon a time.".into(), 0);
let player = world
.spawn((
crate::net::Guid(ME),
crate::net::SelfPlayer,
crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(PACK_SLOT_1 + 6, BOOK as u32),
(PACK_SLOT_1 + 7, (BOOK >> 32) as u32),
])),
))
.id();
world
.resource_mut::<crate::net::GuidIndex>()
.0
.insert(ME, player);
crate::items::test_spawn_item(
world,
BOOK,
benilla_protocol::ObjectFields::from_pairs(&[
(2, 0x3),
(3, 5_000),
(8, ME as u32),
(9, (ME >> 32) as u32),
]),
false,
);
let seen = |app: &mut App| -> String {
app.world()
.non_send_resource::<UiScript>()
.eval::<String>("local s = table.concat(SEEN, ',') SEEN = {} return s")
.expect("runs")
};
let reads = |rx: &crossbeam_channel::Receiver<ClientCommand>| -> Vec<(u8, u8)> {
rx.try_iter()
.filter_map(|c| match c {
ClientCommand::ReadItem { bag, slot } => Some((bag, slot)),
_ => None,
})
.collect()
};
app.world_mut()
.resource_mut::<ItemTextOpen>()
.read_pages(BOOK);
for _ in 0..4 {
app.update();
}
assert_eq!(
reads(&rx),
[(0xFF, 23 + 3)],
"{reply}: the read, from pack slot 4"
);
assert_eq!(
seen(&mut app),
"ITEM_TEXT_BEGIN",
"{reply}: no page before the reply"
);
let reply_events = match reply {
"ok" => vec![E::ReadItemOk { guid: BOOK }],
other => vec![E::ReadItemFailed {
guid: BOOK,
reason: other.strip_prefix("reason ").unwrap().parse().unwrap(),
milliseconds: 2_500,
}],
};
crate::net::handlers::dispatch(app.world_mut(), reply_events);
app.world_mut()
.run_system_cached_with(fire_item_text, crate::lanes::Until::all())
.expect("the item-text lane fires");
for _ in 0..4 {
app.update();
}
let want = match reply {
"ok" | "reason 0" => "ITEM_TEXT_READY",
"reason 1" => "ITEM_TEXT_READY,ITEM_TEXT_TRANSLATION:2.5",
_ => "ITEM_TEXT_CLOSED",
};
assert_eq!(seen(&mut app), want, "{reply}");
if reply == "reason 1" {
// The signal does not wait on the item being the open one (`0x5ec6a1`).
let stranger = vec![E::ReadItemFailed {
guid: BOOK + 1,
reason: 1,
milliseconds: 175,
}];
crate::net::handlers::dispatch(app.world_mut(), stranger);
app.world_mut()
.run_system_cached_with(fire_item_text, crate::lanes::Until::all())
.expect("the item-text lane fires");
assert_eq!(seen(&mut app), "ITEM_TEXT_TRANSLATION:0.175");
}
if reply == "ok" {
let flags = app
.world()
.resource::<crate::net::GuidIndex>()
.0
.get(&BOOK)
.and_then(|&e| app.world().get::<crate::net::ObjectStore>(e))
.and_then(|s| s.0.item_flags());
assert_eq!(flags, Some(2), "the item is marked read (`0x5d9a80`)");
// A read of it again: no request, the page at once (`0x4e359b`).
close_core(app.world_mut());
app.world_mut()
.resource_mut::<ItemTextOpen>()
.read_pages(BOOK);
for _ in 0..4 {
app.update();
}
assert_eq!(reads(&rx), Vec::<(u8, u8)>::new());
let events = seen(&mut app);
assert!(
events.ends_with("ITEM_TEXT_BEGIN,ITEM_TEXT_READY"),
"{events}"
);
}
}
}
/// `SMSG_GAMEOBJECT_PAGETEXT` (`0x5f88c0`) resolves the GameObject and opens the reader on it
/// (`0x4e32e0(guid, 0)`), whose page is the object's pageID column (`0x5f5900`, a GOOBER's
/// `data[7]`) and its material the pageMaterial column (`0x5f5950`, `data[9]`): "The Lay of
+2 -1
View File
@@ -299,7 +299,8 @@ impl ScriptItemUse<'_, '_> {
ui_errors,
);
}
// The reader, with no packet (`0x4e32e0`); a re-click closes it.
// The reader (`0x4e32e0`), whose open sends `CMSG_READ_ITEM` for a book not yet read;
// a re-click closes it.
(UseFork::Read, Some(c)) => {
if item_text.read_pages(c.guid) {
debug!("ui_items: re-click closes the book {:#x}", c.guid);
@@ -123,6 +123,7 @@ pub(super) fn client() -> Loot {
ERR_LOOT_NO_UI = "ERR_LOOT_NO_UI"
ERR_LOOT_DIDNT_KILL = "ERR_LOOT_DIDNT_KILL"
YOU_LOOT_MONEY = "You loot %s"
LOOT_MONEY_SPLIT = "Your share of the loot is %s."
GOLD = "Gold" SILVER = "Silver" COPPER = "Copper"
SEEN = {}
local f = CreateFrame("Frame")
@@ -318,6 +319,63 @@ fn a_roll_or_master_rows_click_answers_inside_it() {
assert!(sent(&l).is_empty());
}
/// `SMSG_LOOT_ALL_PASSED` and `SMSG_LOOT_ROLL_WON` both run `ClearLootRollState` (`0x61b5dd`,
/// `0x61b97c` → `0x5ebcf0`), which zeroes the type of the open source's row: the next click on it
/// takes, where the pending row refused. Another source's packet, or another slot's, frees nothing.
#[test]
fn a_resolved_roll_frees_the_open_row() {
let packet = |won: bool, guid: u64, slot: u32| {
if won {
SessionEvent::LootRollWon(benilla_protocol::messages::LootRollWon {
looted_target: guid,
item_slot: slot,
item_id: LINEN,
random_property_id: 0,
suffix_factor: 0,
winner: THRALL,
roll_number: 50,
roll_type: 2,
})
} else {
SessionEvent::LootAllPassed(benilla_protocol::messages::LootAllPassed {
looted_target: guid,
item_slot: slot,
item_id: LINEN,
random_property_id: 0,
suffix_factor: 0,
})
}
};
for won in [false, true] {
let mut l = client();
let (mut first, mut second) = (row(0, LINEN), row(1, WOOL));
first.slot_type = slot_type::ROLL_ONGOING;
second.slot_type = slot_type::ROLL_ONGOING;
open(&mut l, CORPSE, 1, 0, vec![first, second]);
let _ = (seen(&mut l), sent(&l));
packets(&mut l, vec![packet(won, GONE, 0), packet(won, CORPSE, 7)]);
click(&mut l, 1);
assert_eq!(
seen(&mut l),
"UI_ERROR_MESSAGE:ERR_LOOT_ROLL_PENDING",
"won: {won}"
);
packets(&mut l, vec![packet(won, CORPSE, 0)]);
let _ = seen(&mut l);
click(&mut l, 1);
assert!(
matches!(sent(&l)[..], [ClientCommand::AutostoreLootItem { slot: 0 }]),
"won: {won}"
);
click(&mut l, 2);
assert_eq!(
seen(&mut l),
"UI_ERROR_MESSAGE:ERR_LOOT_ROLL_PENDING",
"the other row"
);
}
}
/// `CloseLoot` closes inside its call (`0x4c2ec0` → `0x48f200` → `0x4c1cb0`): the release is out,
/// `LOOT_CLOSED` has run and the window reads closed on the next line.
#[test]
@@ -885,6 +943,29 @@ fn a_replaced_sessions_close_keeps_the_next_copiers_end() {
assert_eq!(seen(&mut l), "LOOT_OPENED");
}
/// A row another member takes before its item record answers stops the window waiting: the
/// window opens on the rows left, where 1.12.1's count stays above 0 and the corpse never opens
/// (`ClearLootRow 0x4c1f00` lowers nothing), a deviation `records_pending` records.
#[test]
fn a_row_cleared_while_its_record_is_pending_does_not_hold_the_window_shut() {
const UNCACHED: u32 = 40_000;
let mut l = client();
open(&mut l, CORPSE, 1, 0, vec![row(0, LINEN), row(1, UNCACHED)]);
assert_eq!(seen(&mut l), "", "the copier waits on the record");
packets(&mut l, vec![SessionEvent::LootRemoved { slot: 1 }]);
feed(&mut l);
assert_eq!(seen(&mut l), "LOOT_SLOT_CLEARED:2");
let world = l.app.world_mut();
world
.resource_mut::<Items>()
.insert_template(UNCACHED, Some(crate::items::test_template("Silk Cloth")));
world.resource_scope(|world, mut loot: Mut<LootState>| {
loot.row_answered(world.resource::<Items>())
});
feed(&mut l);
assert_eq!(seen(&mut l), "LOOT_OPENED");
}
/// An inventory failure ends by signalling `LOOT_OPENED` again while a window is open
/// (`0xb71b48`) and the auto-loot byte is set (`0x5e3aff`-`0x5e3b20`): a window whose copier
/// waits on a row's record keeps the byte its interaction stored (`0x4c1ea6`, before the sweep's
+27
View File
@@ -458,3 +458,30 @@ fn money_line(script: &UiScript, copper: u32) -> String {
&[benilla_ui::strings::Arg::S(&coins)],
)
}
/// The group share's line (`0x5ebf20`): `LOOT_MONEY_SPLIT` over the coins, gold first, each nonzero
/// one `"%d %s"` (`0x847fdc`) with its `GOLD`, `SILVER` or `COPPER` global (`0x6c6230`) and every
/// later one `", %d %s"` (`0x860698`). Nothing for 0 (`0x5ebf3a`); the amount splits as a signed
/// word (`SplitCopper 0x6c6260`), so a negative one is all copper.
pub(super) fn share_line(script: &UiScript, amount: u32) -> Option<String> {
let copper = amount as i32;
if copper == 0 {
return None;
}
let (g, s, c) = if copper < 0 {
(0, 0, copper)
} else {
(copper / 10_000, copper / 100 % 100, copper % 100)
};
let mut coins = String::new();
for (n, key) in [(g, "GOLD"), (s, "SILVER"), (c, "COPPER")] {
if n != 0 {
let sep = if coins.is_empty() { "" } else { ", " };
coins.push_str(&format!("{sep}{n} {}", script.global_text(key)));
}
}
Some(benilla_ui::strings::fill(
&script.global_text("LOOT_MONEY_SPLIT"),
&[benilla_ui::strings::Arg::S(&coins)],
))
}
+17 -1
View File
@@ -614,6 +614,18 @@ impl LootState {
self.window.source
}
/// `ClearLootRollState` (`0x5ebcf0`), from `SMSG_LOOT_ALL_PASSED` (`0x61b5dd`) and
/// `SMSG_LOOT_ROLL_WON` (`0x61b97c`): the row at `slot` of the open source `guid` goes back to
/// the type 0 its click re-reads (`0x4c28a9` → `0x5ebce0`), so the freed item loots.
pub(crate) fn roll_resolved(&mut self, guid: u64, slot: u8) {
if self.window.source != Some(guid) {
return;
}
if let Some(row) = self.window.items.iter_mut().find(|i| i.slot == slot) {
row.slot_type = benilla_protocol::messages::slot_type::ALLOW_LOOT;
}
}
/// `LootHasContents` (`0x4c2a70`): the source resolves as any object (`0x4c2a8c`, mask 1) and
/// has money above 0 or a row left.
fn has_contents(&self, objects: &crate::net::Objects) -> bool {
@@ -643,7 +655,11 @@ impl LootState {
/// `0xb71b44` above 0: a row's item record is still being fetched, counted from the copier
/// (`0x4c1e77`) until its callback (`0x4c2ac0`); a negative answer releases the row, as
/// [`templates_outstanding`] has it.
/// [`templates_outstanding`] has it. Deviation: a row cleared meanwhile stops counting, where
/// `ClearLootRow` (`0x4c1f00`) cancels its callback (`0x4c1f52`) and never lowers the count
/// (its writers are `0x4c1def`, `0x4c1e94` and `0x4c2af6` alone), so in 1.12.1 a group
/// member's take of an uncached item before the answer leaves that corpse's window shut until
/// the next loot response resets the count (`0x4c1def`), and ours opens.
fn records_pending(&self, items: &Items) -> bool {
let w = &self.window;
w.items.iter().any(|it| {
+23 -7
View File
@@ -142,9 +142,14 @@ pub(super) fn on_removed(In(ev): In<SessionEvent>, mut window: WindowPacket) {
}
}
fn on_money_notify(In(ev): In<SessionEvent>) {
fn on_money_notify(
In(ev): In<SessionEvent>,
mut vm: crate::script_calls::HostedVm,
chat: Res<crate::ui_chat::ChatLog>,
) {
if let SessionEvent::LootMoneyNotify { amount } = ev {
loot_money_notify(amount);
debug!("net: loot money {amount}");
loot_money_notify(amount, &mut vm, &chat);
}
}
@@ -385,11 +390,22 @@ fn loot_error(
}
}
/// `SMSG_LOOT_MONEY_NOTIFY`, our share of a group's coin: only logged, as the coin row drops on
/// `SMSG_LOOT_CLEAR_MONEY` and the purse rides the coinage field. vmangos sends none for a solo
/// loot (`LootHandler.cpp:331`).
fn loot_money_notify(amount: u32) {
debug!("net: loot money {amount}");
/// `SMSG_LOOT_MONEY_NOTIFY` (`0x5ebf20`), our share of a group's coin: `LOOT_MONEY_SPLIT` as a
/// `CHAT_MSG_MONEY` line (`0x5ec011` → `AddChatLine 0x49a870`), queued behind the lines of the
/// packets before it; the coin row drops on `SMSG_LOOT_CLEAR_MONEY` and the purse rides the
/// coinage field. vmangos sends none for a solo loot (`LootHandler.cpp:331`).
fn loot_money_notify(
amount: u32,
vm: &mut crate::script_calls::HostedVm,
chat: &crate::ui_chat::ChatLog,
) {
let text = vm.run(|script| super::calls::share_line(script?, amount));
if let Some(text) = text {
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::Money,
text,
));
}
}
/// `SMSG_LOOT_RELEASE_RESPONSE` (`0x5ec090`): acted on only with a nonzero result (`0x5ec0b6`)
@@ -279,3 +279,35 @@ fn auto_loot_default_sweeps_a_pickpocket_and_a_disenchant_window() {
}
}
}
/// `SMSG_LOOT_MONEY_NOTIFY` queues `LOOT_MONEY_SPLIT` over the coins as a `CHAT_MSG_MONEY` line at
/// its packet's place in the chat queue (`0x5ebf20` → `0x5ec011`): the first nonzero coin as `"%d %s"` and each later
/// one as `", %d %s"` (`0x5ebf88`, `0x5ebf9f`), so a zero middle coin leaves no empty separator;
/// an amount of 0 shows nothing (`0x5ebf3a`).
#[test]
fn a_group_coin_share_queues_its_line_at_its_packet() {
let mut l = client();
note_money(&mut l);
for amount in [10_203, 10_003, 500, 7, 0] {
packets(&mut l, vec![SessionEvent::LootMoneyNotify { amount }]);
}
assert_eq!(
seen(&mut l),
"",
"queued behind the packets before it, not shown in place"
);
crate::ui_chat::feed_chat_once(l.app.world_mut());
assert_eq!(
money_lines(&mut l),
[
"Your share of the loot is 1 Gold, 2 Silver, 3 Copper.",
"Your share of the loot is 1 Gold, 3 Copper.",
"Your share of the loot is 5 Silver.",
"Your share of the loot is 7 Copper.",
]
);
assert_eq!(
seen(&mut l),
"CHAT_MSG_MONEY,CHAT_MSG_MONEY,CHAT_MSG_MONEY,CHAT_MSG_MONEY"
);
}
+20 -3
View File
@@ -275,16 +275,33 @@ mod net {
.net_handler(K::Disconnected, on_session_end);
}
fn on_packet(In(ev): In<SessionEvent>, mut rolls: ResMut<LootRolls>) {
fn on_packet(
In(ev): In<SessionEvent>,
mut rolls: ResMut<LootRolls>,
loot: Option<ResMut<crate::ui_loot::LootState>>,
) {
match ev {
SessionEvent::LootStartRoll(p) => loot_start_roll(p, &mut rolls),
SessionEvent::LootRoll(p) => loot_roll(p, &mut rolls),
SessionEvent::LootRollWon(p) => loot_roll_won(p, &mut rolls),
SessionEvent::LootAllPassed(p) => loot_all_passed(p, &mut rolls),
SessionEvent::LootRollWon(p) => {
resolved(loot, p.looted_target, p.item_slot);
loot_roll_won(p, &mut rolls);
}
SessionEvent::LootAllPassed(p) => {
resolved(loot, p.looted_target, p.item_slot);
loot_all_passed(p, &mut rolls);
}
_ => {}
}
}
/// The window's row stops waiting on the roll (`ClearLootRollState 0x5ebcf0`).
fn resolved(loot: Option<ResMut<crate::ui_loot::LootState>>, guid: u64, slot: u32) {
if let (Some(mut loot), Ok(slot)) = (loot, u8::try_from(slot)) {
loot.roll_resolved(guid, slot);
}
}
fn on_session_end(In(_): In<SessionEvent>, mut rolls: ResMut<LootRolls>) {
rolls.clear();
}
+2
View File
@@ -676,6 +676,8 @@ fn record_view(t: &QuestTemplate, tables: &RecordTables) -> QuestRecordView {
description: crate::npc_text::substitute(&t.details, tables.macros),
objectives_text: crate::npc_text::substitute(&t.objectives_text, tables.macros),
reward_spell: crate::ui_quest::reward_spell_view(t.reward_spell, tables.spells),
point: (t.point_x, t.point_y),
point_opt: t.point_opt,
}
}
@@ -55,10 +55,18 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
}
/// Seat the backpack of [`one_item_backpack`] and its item's record: the world drop raises its
/// popup only for a cached record (`0x4953a5`, `0x4953ac`).
/// popup only for a cached record (`0x4953a5`, `0x4953ac`), and names the item and its quality
/// from it (`0x4953ae`-`0x4953c0`).
fn seat_backpack(s: &mut UiScript, item_id: u32, name: &str, quality: u32) {
s.set_container(0, Some(one_item_backpack(item_id, name, quality)));
s.set_item_template(item_id, Default::default());
s.set_item_template(
item_id,
benilla_ui::script::ItemTemplateView {
name: name.into(),
quality,
..Default::default()
},
);
}
/// The stock popup stack and the world frame the drop is clicked on.
@@ -1940,7 +1940,6 @@ mod tests {
a divergence, not a fix",
),
("DISPLAY_SIZE_CHANGED", "the four paperdoll files"),
("ITEM_TEXT_TRANSLATION", "ItemTextFrame.lua"),
(
"SHOW_COMPARE_TOOLTIP",
"PaperDollFrame.lua — the second `TRADE_REQUEST`: event 377 is \
+6
View File
@@ -199,6 +199,12 @@ impl SocialState {
guid != 0 && self.ignores.contains(&guid)
}
/// Test-only: `guid` in an ignore slot.
#[cfg(test)]
pub(crate) fn ignore_for_test(&mut self, guid: u64) {
self.ignores[0] = guid;
}
/// Signal `event` with the lists as they stand, for the lane to fire in order.
fn signal(&mut self, event: ListEvent) {
self.step(Heard::Event(event));
+12 -5
View File
@@ -1754,7 +1754,7 @@ fn feed_units(
&& enter_usable
.as_deref_mut()
.is_some_and(|u| std::mem::take(&mut u.0));
let world_enter = vm.run(|script| {
vm.run(|script| {
let script = script?;
if owed.book_sorted {
// The language legs, fired ahead of the form edges, which the replay interleaves with
@@ -1771,13 +1771,20 @@ fn feed_units(
if owed.unghost {
script.fire_event("PLAYER_UNGHOST", vec![]);
}
let enter = owed.world_enter?;
script.fire_world_enter_head(enter.bonus_bar_offset, usable_changed);
Some(enter)
Some(())
});
let Some(enter) = world_enter else {
let Some(enter) = owed.world_enter else {
return;
};
// The cascade's `SelectTarget(0)` (`0x490927`) comes ahead of `PLAYER_LOGIN` and
// `PLAYER_ENTERING_WORLD` (`0x490965`): a transfer released no target, so whatever the guid
// still names goes here, as an engine turn on the selection.
vm.engine_clear_target(crate::lanes::mark());
vm.run(|script| {
if let Some(script) = script {
script.fire_world_enter_head(enter.bonus_bar_offset, usable_changed);
}
});
// `0x4909c9` → `0x4bc6f0`: `SetPetGuid(0, 0)` as an engine turn on the bar and the VM, its
// signals, then `CMSG_REQUEST_PET_INFO` (`0x4bc738`), then the cascade's rest. Its trainer
// refresh (`0x4bc872`) never runs here: the cascade zeroes the trainer cell before it
+1 -1
View File
@@ -15,7 +15,7 @@ use crate::Chain;
const AREA_POI: &str = "DBFilesClient\\AreaPOI.dbc";
/// One `AreaPOI.dbc` row.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct AreaPoi {
/// Prominence tier, `{0, 2, 3, 4}` in 5875; the minimap ranks its landmark arrows by it.
pub importance: u32,
+11
View File
@@ -546,6 +546,17 @@ pub enum SessionEvent {
/// `SMSG_GAMEOBJECT_PAGETEXT`: the reference resolves the GameObject and opens the item-text
/// reader on it (`0x5f88c0` → `0x4e32e0(guid, 0)`), as a right-click on a TEXT object does.
GameObjectPageText { guid: u64 },
/// `SMSG_READ_ITEM_OK` (`0x5ec600`): the book may be read; the reference marks the item read
/// and shows its page.
ReadItemOk { guid: u64 },
/// `SMSG_READ_ITEM_FAILED` (`0x5ec600`): reason 0 shows the page, 1 shows it untranslated and
/// signals `ITEM_TEXT_TRANSLATION` with `milliseconds`, any other closes the reader when the
/// guid is its item.
ReadItemFailed {
guid: u64,
reason: u8,
milliseconds: i32,
},
/// `SMSG_OPEN_CONTAINER`: the reference fires `BAG_OPEN` with 0 for our own guid (the
/// backpack), 1..10 for a bag-cache slot, and nothing for a miss.
OpenContainer { item: u64 },
@@ -933,6 +933,16 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::GameObjectPageText { guid } => {
vec![SessionEvent::GameObjectPageText { guid }]
}
ServerPacket::ReadItemOk { guid } => vec![SessionEvent::ReadItemOk { guid }],
ServerPacket::ReadItemFailed {
guid,
reason,
milliseconds,
} => vec![SessionEvent::ReadItemFailed {
guid,
reason,
milliseconds,
}],
ServerPacket::OpenContainer { item } => vec![SessionEvent::OpenContainer { item }],
ServerPacket::StandStateUpdate { state } => {
vec![SessionEvent::StandStateUpdate { state }]
@@ -45,8 +45,9 @@ pub mod channel_notice {
pub enum ChannelNoticeTail {
/// The member who joined or left.
Guid(u64),
/// Our channel flags; the `u32` after them is always 0.
YouJoined { flags: u32 },
/// Our channel flags, then the instance number the client keeps on the slot (`+0x98`,
/// `0x49bb20`); vmangos always sends 0 (`Chat/Channel.cpp:827`).
YouJoined { flags: u32, instance: u32 },
/// Nothing follows the channel name.
Empty,
/// The player who made the change or sent the invite.
@@ -81,8 +82,8 @@ pub(super) fn read_channel_notify(r: &mut &[u8]) -> io::Result<ChannelNotify> {
n::JOINED | n::LEFT => ChannelNoticeTail::Guid(read_u64_le(r)?),
n::YOU_JOINED => {
let flags = read_u32_le(r)?;
let _reserved = read_u32_le(r)?; // always 0 on the wire
ChannelNoticeTail::YouJoined { flags }
let instance = read_u32_le(r)?;
ChannelNoticeTail::YouJoined { flags, instance }
}
n::YOU_LEFT
| n::WRONG_PASSWORD
@@ -574,6 +574,29 @@ pub fn open_item(bag_index: u8, slot: u8) -> Vec<u8> {
vec![bag_index, slot]
}
/// Body of `CMSG_READ_ITEM` (opcode 173, `0x5ec500`): the container byte, 255 for the player's own
/// slots or the equipped bag's inventory slot, then the slot, as in [`open_item`].
pub fn read_item(bag: u8, slot: u8) -> Vec<u8> {
vec![bag, slot]
}
/// `SMSG_READ_ITEM_FAILED`'s tail (`0x5ec600`): the reason byte (0 shows the page, 1 shows it
/// untranslated with the translation time, anything else closes the reader), and for 1 the time in
/// milliseconds. vmangos writes neither (`ItemHandler.cpp:445-452`): the reference's failed read of
/// the byte leaves its stack slot as it was, the high byte of an address, 0, so an absent reason is
/// 0; a failed read of the time (`0x418e30`) leaves the handler's first argument in its slot, the
/// opcode 0xaf, so an absent time is 175 ms.
pub fn read_read_item_failed(r: &mut &[u8]) -> io::Result<(u64, u8, i32)> {
let guid = read_u64_le(r)?;
let reason = read_u8(r).unwrap_or(0);
let milliseconds = if reason == 1 {
read_i32_le(r).unwrap_or(i32::from(super::opcode::SMSG_READ_ITEM_FAILED))
} else {
0
};
Ok((guid, reason, milliseconds))
}
/// Body of `CMSG_WRAP_ITEM` (opcode 467, `Server/Packets/Item.cpp:121-127`): the paper's bag and
/// slot, then the target's. The server refuses with `SMSG_INVENTORY_CHANGE_FAILURE`; success is
/// only field updates, the target taking the paper's gift entry and `ITEM_DYNFLAG_WRAPPED`.
+4 -4
View File
@@ -157,10 +157,10 @@ pub use instance::{
};
pub use items::{
auto_equip_item, auto_store_bag_item, destroy_item, item_name_query, item_query, open_item,
set_ammo, split_item, swap_inv_item, swap_item, use_item, wrap_item, ItemDamage, ItemInfo,
ItemSpellEntry, ItemUseSpell, UseItemTarget, BAG_PLAYER_INVENTORY, ITEM_DYNFLAG_UNLOCKED,
ITEM_DYNFLAG_WRAPPED, ITEM_FLAG_LOOTABLE, ITEM_FLAG_PLAYERCAST, ITEM_FLAG_WRAPPER,
SLOT_BAG_FIRST, SLOT_PACK_FIRST,
read_item, set_ammo, split_item, swap_inv_item, swap_item, use_item, wrap_item, ItemDamage,
ItemInfo, ItemSpellEntry, ItemUseSpell, UseItemTarget, BAG_PLAYER_INVENTORY,
ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED, ITEM_FLAG_LOOTABLE, ITEM_FLAG_PLAYERCAST,
ITEM_FLAG_WRAPPER, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
};
pub use loot::{
autostore_loot_item, loot, loot_error, loot_master_give, loot_money, loot_release, loot_roll,
@@ -362,6 +362,13 @@ pub const CMSG_USE_ITEM: u16 = 0x00AB; // 171
/// Open an openable item (a clam, a lockbox, a gift) by bag position; the loot that answers names
/// the item's own guid (`HandleOpenItemOpcode`).
pub const CMSG_OPEN_ITEM: u16 = 0x00AC; // 172
/// Read a book from a bag position (`HandleReadItemOpcode`, `ItemHandler.cpp:410-436`): `bag`,
/// `slot`, as in [`CMSG_OPEN_ITEM`]; sent by the reader's open (`0x4e3586` → `0x5ec580`).
pub const CMSG_READ_ITEM: u16 = 0x00AD; // 173
/// The book may be read: the item's guid (`0x5ec600`).
pub const SMSG_READ_ITEM_OK: u16 = 0x00AE; // 174
/// The book may not: the item's guid, then a reason byte vmangos does not write.
pub const SMSG_READ_ITEM_FAILED: u16 = 0x00AF; // 175
/// Gift-wrap an item: `giftBag`, `giftSlot`, `itemBag`, `itemSlot`, paper first. Sent when the
/// wrap cursor, armed locally by right-clicking the paper (`0x5edea0`), clicks the item.
pub const CMSG_WRAP_ITEM: u16 = 0x01D3; // 467
@@ -398,6 +398,17 @@ pub enum ServerPacket {
GameObjectPageText {
guid: u64,
},
/// `SMSG_READ_ITEM_OK`: the book may be read (the item's guid).
ReadItemOk {
guid: u64,
},
/// `SMSG_READ_ITEM_FAILED`: the item's guid, the reason byte and, for reason 1, the translation
/// time in milliseconds (`items::read_read_item_failed`).
ReadItemFailed {
guid: u64,
reason: u8,
milliseconds: i32,
},
/// `SMSG_FISH_NOT_HOOKED`: the channel ended unhooked (empty body; `ERR_FISH_NOT_HOOKED`).
FishNotHooked,
/// `SMSG_FISH_ESCAPED`: the skill roll failed on the click (empty body; `ERR_FISH_ESCAPED`).
@@ -1401,6 +1412,8 @@ impl ServerPacket {
ServerPacket::GameObjectCustomAnim { .. } => "SMSG_GAMEOBJECT_CUSTOM_ANIM".into(),
ServerPacket::GameObjectDespawnAnim { .. } => "SMSG_GAMEOBJECT_DESPAWN_ANIM".into(),
ServerPacket::GameObjectPageText { .. } => "SMSG_GAMEOBJECT_PAGETEXT".into(),
ServerPacket::ReadItemOk { .. } => "SMSG_READ_ITEM_OK".into(),
ServerPacket::ReadItemFailed { .. } => "SMSG_READ_ITEM_FAILED".into(),
ServerPacket::OpenContainer { .. } => "SMSG_OPEN_CONTAINER".into(),
ServerPacket::Inspect { .. } => "SMSG_INSPECT".into(),
ServerPacket::StandStateUpdate { .. } => "SMSG_STANDSTATE_UPDATE".into(),
@@ -1332,6 +1332,17 @@ fn parse_server_body(
opcode::SMSG_GAMEOBJECT_PAGETEXT => ServerPacket::GameObjectPageText {
guid: gameobject::read_gameobject_page_text(&mut r)?,
},
opcode::SMSG_READ_ITEM_OK => ServerPacket::ReadItemOk {
guid: read_u64_le(&mut r)?,
},
opcode::SMSG_READ_ITEM_FAILED => {
let (guid, reason, milliseconds) = items::read_read_item_failed(&mut r)?;
ServerPacket::ReadItemFailed {
guid,
reason,
milliseconds,
}
}
opcode::SMSG_OPEN_CONTAINER => ServerPacket::OpenContainer {
item: items::read_open_container(&mut r)?,
},
@@ -526,6 +526,11 @@ impl WorldSession {
)
}
/// Read a book by bag position (`CMSG_READ_ITEM`).
pub fn read_item(&mut self, bag: u8, slot: u8) -> Result<()> {
self.send(opcode::CMSG_READ_ITEM, &messages::read_item(bag, slot))
}
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`).
pub fn auto_equip_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
self.send(
@@ -48,6 +48,12 @@ impl WorldWriter {
)
}
/// `CMSG_READ_ITEM`: read a book; the server answers `SMSG_READ_ITEM_OK` or
/// `SMSG_READ_ITEM_FAILED` on the item's guid.
pub fn read_item(&mut self, bag: u8, slot: u8) -> Result<()> {
self.send(opcode::CMSG_READ_ITEM, &messages::read_item(bag, slot))
}
/// `CMSG_WRAP_ITEM`: wrap an item in the `ITEM_FLAG_WRAPPER` paper; success is silent and
/// consumes one paper.
pub fn wrap_item(
+18 -7
View File
@@ -145,14 +145,25 @@ fn channel_notify_decodes_each_tail_shape() {
_ => panic!("expected ChannelNotify"),
}
// 0x02 YOU_JOINED: u32 flags + u32 0 (reserved).
let body = hx("0247656e6572616c001800000000000000");
match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() {
ServerPacket::ChannelNotify(n) => {
assert_eq!(n.notice, messages::channel_notice::YOU_JOINED);
assert_eq!(n.tail, ChannelNoticeTail::YouJoined { flags: 0x18 });
// 0x02 YOU_JOINED: u32 flags + u32 instance (`0x49bb20` stores it on the slot).
for (body, instance) in [
("0247656e6572616c001800000000000000", 0),
("0247656e6572616c001800000007000000", 7),
] {
let body = hx(body);
match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() {
ServerPacket::ChannelNotify(n) => {
assert_eq!(n.notice, messages::channel_notice::YOU_JOINED);
assert_eq!(
n.tail,
ChannelNoticeTail::YouJoined {
flags: 0x18,
instance
}
);
}
_ => panic!("expected ChannelNotify"),
}
_ => panic!("expected ChannelNotify"),
}
// 0x05 NOT_MEMBER: empty tail; `MakeNotMember` and `MakeNotOnPacket` both send it.
@@ -600,3 +600,45 @@ fn pet_feedback_family_parses_and_decodes() {
}]
));
}
/// `CMSG_READ_ITEM` is the container byte and the slot (`0x5ec500` writes opcode 0xad and the two
/// bytes); `SMSG_READ_ITEM_OK` is the item's guid, `SMSG_READ_ITEM_FAILED` the guid and a reason
/// byte that vmangos leaves out (`ItemHandler.cpp:445-452`), read as 0 (`0x5ec664`), with the
/// translation time in milliseconds after a reason of 1 (`0x5ec6a5`), the opcode's 0xaf when the
/// packet ends before it.
#[test]
fn read_item_bodies_encode_parse_and_decode() {
assert_eq!(messages::read_item(255, 25), [255, 25]);
assert_eq!(messages::opcode::CMSG_READ_ITEM, 0xAD);
let ok = messages::parse_server(messages::opcode::SMSG_READ_ITEM_OK, &hx("0700000000000040"))
.unwrap();
assert!(matches!(
decode(ok).as_slice(),
[SessionEvent::ReadItemOk {
guid: 0x4000_0000_0000_0007
}]
));
let failed = |body: &str| {
let packet =
messages::parse_server(messages::opcode::SMSG_READ_ITEM_FAILED, &hx(body)).unwrap();
decode(packet)
.into_iter()
.map(|e| format!("{e:?}"))
.collect::<Vec<_>>()
};
let guid = "0700000000000040";
let event = |reason: u8, ms: i32| {
format!(
"ReadItemFailed {{ guid: 4611686018427387911, reason: {reason}, milliseconds: {ms} }}"
)
};
assert_eq!(failed(guid), [event(0, 0)], "vmangos writes no reason");
assert_eq!(failed(&format!("{guid}00")), [event(0, 0)]);
assert_eq!(failed(&format!("{guid}01e8030000")), [event(1, 1000)]);
assert_eq!(
failed(&format!("{guid}01")),
[event(1, 0xAF)],
"the opcode stays in the slot"
);
assert_eq!(failed(&format!("{guid}02")), [event(2, 0)]);
}
+42 -4
View File
@@ -669,11 +669,11 @@ fn raise_delete_item_confirm(model: &mut Model, item: &CursorItem) {
model.push_ui_error(CANT_BE_DESTROYED);
return;
}
if !model.item_templates.contains_key(&item.item_id) {
// The name and quality are the record's (`+0x8`, `+0x1c`, `0x4953ae`-`0x4953c0`), read here.
let Some(template) = model.item_templates.get(&item.item_id) else {
return;
}
let name = item_link_name(item.link.as_deref());
let quality = item.quality.unwrap_or(0);
};
let (name, quality) = (template.name.clone(), template.quality);
model.raise(
"DELETE_ITEM_CONFIRM",
vec![ScriptValue::Str(name), ScriptValue::Int(i64::from(quality))],
@@ -1710,6 +1710,44 @@ mod tests {
assert!(s.cursor_item().is_some());
}
/// `DELETE_ITEM_CONFIRM` carries the record's name and quality (`0x4953ae`-`0x4953c0`), not
/// the link and quality the pickup captured: a link naming something else, or none, changes
/// neither.
#[test]
fn the_delete_confirm_carries_the_records_name_and_quality() {
use super::{world_drop_click, WorldPick};
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.run(
r#"
seen = {}
local f = CreateFrame("Frame")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function() table.insert(seen, arg1 .. ":" .. arg2) end)
"#,
)
.unwrap();
let mut backpack = one_item_backpack();
backpack.slots.get_mut(&1).unwrap().link = None;
s.set_container(0, Some(backpack));
s.set_item_template(
117,
crate::script::ItemTemplateView {
name: "Tough Jerky".into(),
quality: 2,
..Default::default()
},
);
s.run("PickupContainerItem(0, 1)").unwrap();
s.model_mut().world_pick = WorldPick::Nothing;
assert!(world_drop_click(&mut s.model_mut()));
s.tick(0.01);
assert_eq!(
s.eval::<String>("return table.concat(seen, ',')").unwrap(),
"Tough Jerky:2"
);
}
/// The record's flag `0x20` refuses both destroy paths with error `0x152`: the world drop
/// raises no popup (`0x495370`-`0x495379`) and `DeleteCursorItem` sends nothing
/// (`0x489836`-`0x48983f`), the item held throughout. An item the cache does not flag gets
+1 -1
View File
@@ -415,7 +415,7 @@ pub use quest_live::{
QuestClock, QuestClockRead, QuestCoinage, QuestHeld, QuestObjectName, QuestSlots, QuestStanding,
};
pub use quest_log::{
QuestLogCarry, QuestObjectiveView, QuestRecordView, QuestSlotView, QUEST_LOG_SLOTS,
QuestBlip, QuestLogCarry, QuestObjectiveView, QuestRecordView, QuestSlotView, QUEST_LOG_SLOTS,
};
pub(crate) use region::{
apply_texture_name, apply_wrap_attrs, implicit_creation_anchor_lua, install_loaded_texture,
+3
View File
@@ -929,6 +929,8 @@ pub(crate) struct Model {
pub(crate) quest_log: quest_log::QuestLogState,
pub(crate) quest_log_selection: u32,
pub(crate) quest_log_abandon_mark: u32,
/// The selected quest's minimap blip (static slot 0).
pub(crate) quest_blip: Option<quest_log::QuestBlip>,
/// Item id to its tooltip view.
pub(crate) item_templates: HashMap<u32, ItemTemplateView>,
/// Item ids asked for and missing, for the app to answer: a lookup with a callback, which
@@ -1642,6 +1644,7 @@ impl Model {
quest_log: quest_log::QuestLogState::default(),
quest_log_selection: 0,
quest_log_abandon_mark: 0,
quest_blip: None,
item_templates: HashMap::new(),
item_sets: HashMap::new(),
random_properties: HashMap::new(),
+132 -3
View File
@@ -128,6 +128,22 @@ pub struct QuestRecordView {
pub description: String,
pub objectives_text: String,
pub reward_spell: Option<QuestRewardSpell>,
/// The quest's map point, the template's `PointX`, `PointY` (`+0x90`, `+0x94`) and `PointOpt`
/// (`+0x98`), which the selected quest's minimap arrow takes ([`QuestBlip`]).
pub point: (f32, f32),
pub point_opt: u32,
}
/// The selected quest's static minimap blip, slot 0 (`0x6dacb0`: icon 5, no flags): the record's
/// point, its `PointOpt` as the rank and its title as the name, set by `SelectQuestLogEntry` and
/// by the selected record's arrival (`0x4def70`), cleared when the quest leaves the log
/// (`0x4df0e0`). The point (0, 0) hides it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct QuestBlip {
pub x: f32,
pub y: f32,
pub rank: u32,
pub name: String,
}
/// One row of the display list `0xbb71c0`: a header, or a quest and its slot (`+0x4`).
@@ -396,6 +412,7 @@ fn sort_rows(model: &mut Model, selected: Option<u32>) {
quest_log_collapsed: collapsed,
quest_store,
quest_log_selection,
quest_blip,
..
} = model;
let records = &quest_store.records;
@@ -466,6 +483,10 @@ fn sort_rows(model: &mut Model, selected: Option<u32>) {
.position(|e| !e.is_header && e.quest_id == id)
})
.map_or(0, |i| i as u32 + 1);
// The selected quest left the log: its blip goes (`0x4df0e0`, on the selected id).
if selected.is_some() && *quest_log_selection == 0 {
*quest_blip = None;
}
}
/// Tutorial 0x21, "Quest Completion": a turn-in test that passes triggers it (`0x4df78d`-`0x4df792`
@@ -582,6 +603,28 @@ impl Model {
.map(|(record, _)| record)
}
/// The selected quest's id (`0xbb7480`): the row the selection names.
fn selected_quest_id(&self) -> Option<u32> {
self.quest_row(self.quest_log_selection as usize)
.filter(|r| !r.is_header)
.map(|r| r.quest_id)
}
/// `0x4def70`: with the selected quest's record held, static blip 0 is set at its point
/// (`0x4defb9`-`0x4defd3`); with none the callback `0x4defe0` runs it again when the record
/// lands, and the blip stays as it was.
fn set_quest_blip(&mut self) {
let Some(record) = self.selected_quest_record() else {
return;
};
self.quest_blip = Some(QuestBlip {
x: record.point.0,
y: record.point.1,
rank: record.point_opt,
name: record.title.clone(),
});
}
/// An item's record with a callback (`0x55ba30` with `0x4e0830`): the name, quality and icon,
/// or, on a miss, the ask and the record held as awaited.
fn quest_item_lookup(&mut self, item_id: u32) -> Option<(String, u32, Option<String>)> {
@@ -1032,9 +1075,18 @@ impl super::UiScript {
let store = &mut model.quest_store;
let ids: Vec<u32> = records.iter().map(|&(id, _)| id).collect();
store.records.extend(records);
ids.into_iter()
.map(|id| store.record_waits.remove(&id).unwrap_or(0))
.collect()
let waits: Vec<u32> = ids
.iter()
.map(|id| store.record_waits.remove(id).unwrap_or(0))
.collect();
// The selected quest's record arriving is the blip's callback (`0x4defe0`).
if model
.selected_quest_id()
.is_some_and(|id| ids.contains(&id))
{
model.set_quest_blip();
}
waits
};
for _ in 0..waits.into_iter().sum::<u32>() {
let ran = {
@@ -1183,6 +1235,20 @@ impl super::UiScript {
})
}
/// The selected quest's blip for the minimap's rim arrow, nothing at the point (0, 0)
/// (`0x6dac10`'s hidden position).
pub fn quest_blip(&self) -> Option<QuestBlip> {
self.model_ref()
.quest_blip
.clone()
.filter(|b| (b.x, b.y) != (0.0, 0.0))
}
/// The static blips are cleared when the world is entered (`0x6d8f92`, `0x6d8fb0`).
pub fn clear_quest_blip(&mut self) {
self.model_mut().quest_blip = None;
}
/// Set the selection.
pub fn set_quest_log_selection(&mut self, i: u32) {
self.model_mut().quest_log_selection = i;
@@ -1413,8 +1479,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `is-number` else `Usage: SelectQuestLogEntry(index)` (`0x84b540`, `0x4dfb0e`).
let i = binding_abi::index_arg32(lua, i, "Usage: SelectQuestLogEntry(index)")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The old selection's blip goes first, ahead of the index test (`0x4def39` →
// `0x4df0e0`): a header, a bad index or a quest not yet cached leaves no arrow.
if model.selected_quest_id().is_some() {
model.quest_blip = None;
}
if model.quest_row(i as usize).is_some_and(|r| !r.is_header) {
model.quest_log_selection = i;
// `0x4def63` stores the selection, then `0x4def70` sets the blip.
model.set_quest_blip();
}
Ok(())
})?,
@@ -2337,6 +2410,62 @@ mod tests {
);
}
/// `SelectQuestLogEntry` sets the minimap's static blip 0 at the quest's point, ranked by its
/// `PointOpt` and named by its title (`0x4def63` → `0x4def70` → `0x6dacb0`); a quest with the
/// point (0, 0) hides it; the quest leaving the log clears it (`0x4df0e0`); a record landing
/// after the selection sets it from the callback (`0x4defe0`).
#[test]
fn the_selected_quests_point_is_the_static_minimap_blip() {
let mut s = UiScript::new().unwrap();
let (mut a, b) = (threat_within(), camp_cleanup());
a.point = (-8900.0, 600.0);
a.point_opt = 3;
seat(&mut s, &[(5, a, slot(5, 0, 0)), (6, b, slot(6, 0, 0))]);
assert_eq!(s.quest_blip(), None, "nothing selected");
// The header row selects nothing (`0x4def55`).
s.run("SelectQuestLogEntry(1)").unwrap();
assert_eq!(s.quest_blip(), None);
s.run("SelectQuestLogEntry(2)").unwrap();
let blip = s.quest_blip().expect("the quest's point");
assert_eq!(
(blip.x, blip.y, blip.rank, blip.name.as_str()),
(-8900.0, 600.0, 3, "A Threat Within")
);
s.run("SelectQuestLogEntry(3)").unwrap();
assert_eq!(s.quest_blip(), None, "the point (0, 0) is hidden");
s.run("SelectQuestLogEntry(2)").unwrap();
assert!(s.quest_blip().is_some());
// The old selection's blip goes before the new index is judged (`0x4def39`): a header
// click hides the arrow, and the selection stays.
s.run("SelectQuestLogEntry(1)").unwrap();
assert_eq!(s.quest_blip(), None);
assert_eq!(s.quest_log_selection(), 2);
s.run("SelectQuestLogEntry(2)").unwrap();
assert!(s.quest_blip().is_some());
s.clear_quest_blip();
assert_eq!(
s.quest_blip(),
None,
"a world entry zeroes the static records"
);
// A record that lands after the selection sets it from the callback (`0x4defe0`).
let mut late = threat_within();
late.point = (10.0, 20.0);
s.refuse_quest_record(5);
s.run("SelectQuestLogEntry(2)").unwrap();
assert_eq!(s.quest_blip(), None, "no record, no blip");
s.land_quest_records(vec![(5, late)]);
assert_eq!(s.quest_blip().map(|b| b.x), Some(10.0));
// The quest leaving the log takes its blip.
let mut slots = [QuestSlotView::default(); QUEST_LOG_SLOTS];
slots[0] = slot(6, 0, 0);
s.set_quest_slots(slots);
s.quest_log_changed();
assert_eq!(s.quest_blip(), None);
}
/// The selection follows its quest across a fold and a rebuild, the reference's quest id.
#[test]
fn the_selection_follows_its_quest_across_folds() {
+56 -3
View File
@@ -19,7 +19,7 @@ use super::cursor::{clear_cursor, set_cursor, CursorPayload, CursorSpell, Setter
use super::live::{ask, LiveQuery};
use super::protected::{self, Tag};
use super::tick::verb;
use super::{Model, ScriptCall};
use super::{Model, ScriptCall, SoundRequest};
mod by_name;
pub use by_name::{resolve_spell_in_books, NamedSpell};
@@ -377,10 +377,32 @@ fn pickup_spell(lua: &Lua, id: u32, book_type: &str) -> bool {
(payload, slot.spell_id, held)
};
verb(lua, clear_cursor);
if target == held || held != 0 {
if target == held {
// The spell already held goes back with the drop kit (`0x4b32a3` → `0x4b32e0`).
if target != 0 {
play_book_kit(lua, "igSpellBookSpellIconDrop");
}
return false;
}
payload.is_some_and(|payload| set_cursor(lua, true, Setter::of(&payload), |_| Some(payload)))
if held != 0 || target == 0 {
return false;
}
// The setter, then the pickup kit whether or not it took (`0x4b32c5`, `0x4b32d2` → `0x4b32f0`).
let put = payload
.is_some_and(|payload| set_cursor(lua, true, Setter::of(&payload), |_| Some(payload)));
play_book_kit(lua, "igSpellBookSpellIconPickup");
put
}
/// `PlaySoundByName` (`0x458030`) from the book's pickup, which a UI load's play drops
/// (`0x458046`).
fn play_book_kit(lua: &Lua, name: &str) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.sound_suppression == 0 {
model
.sound_queue
.push(SoundRequest::KitName(name.to_string()));
}
}
/// The head of the cast dispatcher `0x4b3300`, which `CastSpell` and `CastSpellByName` both run
@@ -1199,6 +1221,37 @@ mod tests {
assert!(s.cursor_payload().is_none(), "{:?}", s.cursor_payload());
}
/// `PickupSpell` plays the book's own kits: `igSpellBookSpellIconPickup` when the spell goes on
/// an empty cursor (`0x4b32d2` → `0x4b32f0`), `igSpellBookSpellIconDrop` when the held spell
/// goes back (`0x4b32a3` → `0x4b32e0`), and nothing when another spell is held (`0x4b32aa`),
/// or while a UI load's plays are dropped (`0x458046`).
#[test]
fn picking_up_and_dropping_a_book_spell_plays_its_kits() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
let kit = |name: &str| vec![crate::script::SoundRequest::KitName(name.into())];
s.run("PickupSpell(1, BOOKTYPE_SPELL)").unwrap();
assert_eq!(s.take_sounds(), kit("igSpellBookSpellIconPickup"));
s.run("PickupSpell(3, BOOKTYPE_SPELL)").unwrap();
assert!(
s.take_sounds().is_empty(),
"another spell held: the clear is all"
);
s.run("PickupSpell(1, BOOKTYPE_SPELL) PickupSpell(1, BOOKTYPE_SPELL)")
.unwrap();
assert_eq!(
s.take_sounds(),
[
kit("igSpellBookSpellIconPickup"),
kit("igSpellBookSpellIconDrop")
]
.concat()
);
s.push_sound_suppression();
s.run("PickupSpell(1, BOOKTYPE_SPELL)").unwrap();
assert!(s.take_sounds().is_empty(), "dropped during the UI load");
}
#[test]
fn pickup_spell_clears_a_held_payload_first() {
let mut s = UiScript::new().unwrap();
+14 -13
View File
@@ -29,7 +29,7 @@ A setting boots at the reference's default unless it is listed here (`docs/METHO
Each entry is the first sentence of a `Deviation:` comment, linking to where it lives.
- [benilla-app](#benilla-app): 246
- [benilla-app](#benilla-app): 247
- [benilla-assets](#benilla-assets): 6
- [benilla-blp](#benilla-blp): 1
- [benilla-formats](#benilla-formats): 22
@@ -209,7 +209,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- A settle hold is summed and sent once on release, without the push, because it stands in for the reference's blocking load (`0x694920`), not a refused substep, and lasts seconds. ([`src/player/movement_net.rs:470`](../crates/benilla-app/src/player/movement_net.rs#L470))
- The reference's blocking load (`0x694920`) waits out its reads before the frame goes on and has no such release; without one, a model that never loads would freeze our body for good. ([`src/player/state.rs:120`](../crates/benilla-app/src/player/state.rs#L120))
- Root is not merged, because a bare pose unrooting us while the server still roots us streams motion into `CHEAT_TYPE_ROOT_MOVE`; vmangos keeps a rooted mover's root bit (`MovementHandler.cpp:1064`), so the reference's merge of it is a no-op anyway. ([`src/player/state.rs:202`](../crates/benilla-app/src/player/state.rs#L202))
- Applied at arrival, because our own avatar has no motion to de-jitter; the reference routes it through the replay chain remotes use, which paces to the sender's cadence and can hold an early packet (up to 1000 ms in `net::motion`). ([`src/player/wire_in.rs:497`](../crates/benilla-app/src/player/wire_in.rs#L497))
- Applied at arrival, because our own avatar has no motion to de-jitter; the reference routes it through the replay chain remotes use, which paces to the sender's cadence and can hold an early packet (up to 1000 ms in `net::motion`). ([`src/player/wire_in.rs:499`](../crates/benilla-app/src/player/wire_in.rs#L499))
#### portrait
@@ -380,9 +380,10 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
#### ui_loot
- It is the display row, where the reference's is the item index plus one (`0x4c2885`), so an addon's `GetLootSlotInfo(arg1)` reads the row the dialog is for. ([`src/ui_loot/mod.rs:265`](../crates/benilla-app/src/ui_loot/mod.rs#L265))
- `autoLootDefault` is benilla's own option, 1.12 auto-looting only on a shift-click, and off is 1.12's behaviour. ([`src/ui_loot/mod.rs:679`](../crates/benilla-app/src/ui_loot/mod.rs#L679))
- Our close paths clear it guid-matched, where `CloseInteraction` clears it unconditionally (`0x48f2c9`), so closing an old window keeps a newer `CMSG_LOOT`'s latch. ([`src/ui_loot/mod.rs:707`](../crates/benilla-app/src/ui_loot/mod.rs#L707))
- A negative answer releases the row, where the reference's callback `0x4c2ac0` returns on a failed query and the window never opens, because an entry the server cannot describe would otherwise keep the loot shut for good. ([`src/ui_loot/mod.rs:888`](../crates/benilla-app/src/ui_loot/mod.rs#L888))
- A row cleared meanwhile stops counting, where `ClearLootRow` (`0x4c1f00`) cancels its callback (`0x4c1f52`) and never lowers the count (its writers are `0x4c1def`, `0x4c1e94` and `0x4c2af6` alone), so in 1.12.1 a group member's take of an uncached item before the answer leaves that corpse's window shut until the next loot response resets the count (`0x4c1def`), and ours opens. ([`src/ui_loot/mod.rs:658`](../crates/benilla-app/src/ui_loot/mod.rs#L658))
- `autoLootDefault` is benilla's own option, 1.12 auto-looting only on a shift-click, and off is 1.12's behaviour. ([`src/ui_loot/mod.rs:695`](../crates/benilla-app/src/ui_loot/mod.rs#L695))
- Our close paths clear it guid-matched, where `CloseInteraction` clears it unconditionally (`0x48f2c9`), so closing an old window keeps a newer `CMSG_LOOT`'s latch. ([`src/ui_loot/mod.rs:723`](../crates/benilla-app/src/ui_loot/mod.rs#L723))
- A negative answer releases the row, where the reference's callback `0x4c2ac0` returns on a failed query and the window never opens, because an entry the server cannot describe would otherwise keep the loot shut for good. ([`src/ui_loot/mod.rs:904`](../crates/benilla-app/src/ui_loot/mod.rs#L904))
#### ui_loot_roll
@@ -647,9 +648,9 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
#### events
- `benilla::spell_mods` refuses an out-of-range pair, because the reference would overrun it. ([`src/events.rs:763`](../crates/benilla-protocol/src/events.rs#L763))
- An out-of-range list past the first block reads no body in the write passes (`0x466076`), which misread every block behind it, while the notify pass reads past it (`0x465fd0`) and stays in step; benilla reads the list and drops it, keeping the blocks behind it whole. ([`src/events/decode.rs:1138`](../crates/benilla-protocol/src/events/decode.rs#L1138))
- The flagged pass reads a movement block's guid as 8 raw bytes (`0x467140`, `0x46714d`) where the wire packs it, so unless the packed guid happens to be 8 bytes the reference misreads the rest of that pass; benilla reads the block once, packed, as pass 0 does (`0x467080`). ([`src/events/decode.rs:1155`](../crates/benilla-protocol/src/events/decode.rs#L1155))
- `benilla::spell_mods` refuses an out-of-range pair, because the reference would overrun it. ([`src/events.rs:774`](../crates/benilla-protocol/src/events.rs#L774))
- An out-of-range list past the first block reads no body in the write passes (`0x466076`), which misread every block behind it, while the notify pass reads past it (`0x465fd0`) and stays in step; benilla reads the list and drops it, keeping the blocks behind it whole. ([`src/events/decode.rs:1148`](../crates/benilla-protocol/src/events/decode.rs#L1148))
- The flagged pass reads a movement block's guid as 8 raw bytes (`0x467140`, `0x46714d`) where the wire packs it, so unless the packed guid happens to be 8 bytes the reference misreads the rest of that pass; benilla reads the block once, packed, as pass 0 does (`0x467080`). ([`src/events/decode.rs:1165`](../crates/benilla-protocol/src/events/decode.rs#L1165))
#### examples
@@ -666,8 +667,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- The 1.12 client appends a zlib chat transcript to a category 2 ticket (`0x5ef936`); we omit it because vmangos never reads it and logs the leftover bytes. ([`src/messages/gm_ticket.rs:48`](../crates/benilla-protocol/src/messages/gm_ticket.rs#L48))
- String reads are unbounded. The reference `0x4191b0` caps each field, counting the NUL (roster name `0x30`, notes `0x80`, MOTD `0x200`, info `0x7d0`, guild name `0x60`, rank name `0x40`), and on overflow empties it and no-ops every later read in the packet. ([`src/messages/guild.rs:4`](../crates/benilla-protocol/src/messages/guild.rs#L4))
- A reason of 3 or more is silent, where the reference prints an uninitialized stack buffer, because 3 is the server's `INSTANCERESET_FAIL_SILENTLY`. ([`src/messages/instance.rs:70`](../crates/benilla-protocol/src/messages/instance.rs#L70))
- 2 or more prints nothing, because the reference prints an uninitialized buffer. ([`src/messages/opcode.rs:1052`](../crates/benilla-protocol/src/messages/opcode.rs#L1052))
- The consumer refuses 64 and up, because there the reference's store overruns its table. ([`src/messages/packet.rs:642`](../crates/benilla-protocol/src/messages/packet.rs#L642))
- 2 or more prints nothing, because the reference prints an uninitialized buffer. ([`src/messages/opcode.rs:1059`](../crates/benilla-protocol/src/messages/opcode.rs#L1059))
- The consumer refuses 64 and up, because there the reference's store overruns its table. ([`src/messages/packet.rs:653`](../crates/benilla-protocol/src/messages/packet.rs#L653))
- A body too short for the position gives `None` where the client redisplays a stale global, because showing a stale queue position as current is a client bug. ([`src/messages/parse.rs:285`](../crates/benilla-protocol/src/messages/parse.rs#L285))
- A type outside 1-7 reads as nothing rather than the reference's default arm, which under-pushes its returns. ([`src/messages/pet.rs:71`](../crates/benilla-protocol/src/messages/pet.rs#L71))
- Both are read, told apart exactly by the tail length (`12n` vs `1 + 14n`), since the 1.12 client mis-parses vmangos's. ([`src/messages/pet.rs:191`](../crates/benilla-protocol/src/messages/pet.rs#L191))
@@ -784,9 +785,9 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- A token resolving to guid 0 answers nil too. The reference jumps from the guid test (`0x5172f6`) to `return 1` (`0x517343`) with nothing pushed, so the one value is its top argument, the token it read (`0x5172e6`); that is a stack quirk, not an answer. ([`src/script/pvp/mod.rs:567`](../crates/benilla-ui/src/script/pvp/mod.rs#L567))
- With `UNIT_PVP_NAME` or the title missing from `_G` (no install) this answers the plain name, where the reference would format an empty string, because the decoration is install data. ([`src/script/pvp/mod.rs:571`](../crates/benilla-ui/src/script/pvp/mod.rs#L571))
- An in-flight name reads `...`, where the reference's reads `" "` (`0x82ee00`), because a blank looks like a nameless objective. ([`src/script/quest_log.rs:54`](../crates/benilla-ui/src/script/quest_log.rs#L54))
- One signal per missed record, where the reference runs every callback of every miss as its record lands (creatures in `0x556e20` at `0x556f91`, items in `0x55bc70` via `0x55bdb0` from `0x555140`; `0x4e07ea`, `0x4e083a`), 2^k - 1 updates for a walking handler over k first-seen records. ([`src/script/quest_log.rs:1138`](../crates/benilla-ui/src/script/quest_log.rs#L1138))
- A local clock step mid-session (an NTP correction) jumps the reference's countdown but not ours, because the step changes no time the quest has left. ([`src/script/quest_log.rs:1194`](../crates/benilla-ui/src/script/quest_log.rs#L1194))
- Four set ids count 4, where the loop's exit `jmp 0x4e0a4c` (`0x4e0a42`) skips the store at `0x4e0a49` and answers 0, a client bug. ([`src/script/quest_log.rs:1554`](../crates/benilla-ui/src/script/quest_log.rs#L1554))
- One signal per missed record, where the reference runs every callback of every miss as its record lands (creatures in `0x556e20` at `0x556f91`, items in `0x55bc70` via `0x55bdb0` from `0x555140`; `0x4e07ea`, `0x4e083a`), 2^k - 1 updates for a walking handler over k first-seen records. ([`src/script/quest_log.rs:1190`](../crates/benilla-ui/src/script/quest_log.rs#L1190))
- A local clock step mid-session (an NTP correction) jumps the reference's countdown but not ours, because the step changes no time the quest has left. ([`src/script/quest_log.rs:1260`](../crates/benilla-ui/src/script/quest_log.rs#L1260))
- Four set ids count 4, where the loop's exit `jmp 0x4e0a4c` (`0x4e0a42`) skips the store at `0x4e0a49` and answers 0, a client bug. ([`src/script/quest_log.rs:1627`](../crates/benilla-ui/src/script/quest_log.rs#L1627))
- The reference bounds the index with `jbe` against the entry count (`0xb73760`), so an index one past the last entry reads the pool entry after it, which only an earlier, longer list wrote, or uninitialised heap (`0x4d5310`, the rebuild resetting the count without clearing, `0x4d547f`); that stale read answers 0 here, as an index further on does. ([`src/script/reputation.rs:563`](../crates/benilla-ui/src/script/reputation.rs#L563))
- The folds are keyed by header, not by the reference's mask positions, whose rebuild folds bit N-1 by registration index before the sort (`0x4d5d49`-`0x4d5d68`) and whose fold verbs search `0xb736c0` before `0x4d5c40` sorts it (`0x4d6400`), so Inactive opens at login whenever a childless header exists and inactivating a header's last child shows it expanded; that is a reference bug, and the folds follow the header the player clicked. ([`src/script/reputation.rs:631`](../crates/benilla-ui/src/script/reputation.rs#L631))
- Nesting stops at 32 where the reference recurses uncapped (its indent saturates at `0x80`), so a hostile structure cannot exhaust the stack. ([`src/script/saved.rs:21`](../crates/benilla-ui/src/script/saved.rs#L21))