diff --git a/crates/benilla-app/src/lanes/guard_tests.rs b/crates/benilla-app/src/lanes/guard_tests.rs index 287ebd533..aa256dbf4 100644 --- a/crates/benilla-app/src/lanes/guard_tests.rs +++ b/crates/benilla-app/src/lanes/guard_tests.rs @@ -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. diff --git a/crates/benilla-app/src/lanes/unit_feed_live_tests.rs b/crates/benilla-app/src/lanes/unit_feed_live_tests.rs index cb6fde600..975ac067e 100644 --- a/crates/benilla-app/src/lanes/unit_feed_live_tests.rs +++ b/crates/benilla-app/src/lanes/unit_feed_live_tests.rs @@ -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::::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::::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::().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::::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 }))); +} diff --git a/crates/benilla-app/src/lanes/unit_view_tests.rs b/crates/benilla-app/src/lanes/unit_view_tests.rs index 57c831ec3..3b9b3c2d1 100644 --- a/crates/benilla-app/src/lanes/unit_view_tests.rs +++ b/crates/benilla-app/src/lanes/unit_view_tests.rs @@ -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::().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::().0[&WOLF]; *world.resource_mut::() = crate::target::Selection { target: Some(wolf), diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs index 36e17df53..e90292d17 100644 --- a/crates/benilla-app/src/minimap/blips/mod.rs +++ b/crates/benilla-app/src/minimap/blips/mod.rs @@ -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, + 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!( diff --git a/crates/benilla-app/src/minimap/blips/tooltip_tests.rs b/crates/benilla-app/src/minimap/blips/tooltip_tests.rs index 342203075..4874582b4 100644 --- a/crates/benilla-app/src/minimap/blips/tooltip_tests.rs +++ b/crates/benilla-app/src/minimap/blips/tooltip_tests.rs @@ -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, diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 131b8b361..c23d52ebb 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -360,7 +360,7 @@ fn emit_minimap( wmos: Res>, world: benilla_world::world_point::WorldPoint, asset_server: Res, - corpse: Res, + statics: Res, blip_inputs: blips::BlipInputs, mut composite: ResMut, rig: Option>, @@ -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); +pub(crate) struct MinimapStatics { + pub(crate) quest: Option, + pub(crate) corpse: Option, +} /// 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>, - mut corpse: ResMut, + mut statics: ResMut, inside: Res, mut listed: Local>>>, ) { @@ -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, + 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::() .init_resource::() @@ -964,7 +1004,7 @@ impl Plugin for MinimapPlugin { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .add_systems(Startup, setup_minimap.after(AssetSet::Open)) .add_systems(Startup, composite::setup_composite) diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index d5283d0ef..6d584ab37 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -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; diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 61510b3e1..dd85a23a2 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -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, diff --git a/crates/benilla-app/src/net/session.rs b/crates/benilla-app/src/net/session.rs index d07194ba6..02ddf0db9 100644 --- a/crates/benilla-app/src/net/session.rs +++ b/crates/benilla-app/src/net/session.rs @@ -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, 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, + mut e: Edges, + frame: Res, + mut stored: Local>, + // 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, mut e: Edges, @@ -315,7 +350,6 @@ fn on_worldport( view_log: Option>, player: Option>, selection: Option>, - owed_releases: Option>, 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, + mut e: Edges, + mut vm: crate::script_calls::VmWith>, +) { + 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, 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); } } diff --git a/crates/benilla-app/src/player/wire_in.rs b/crates/benilla-app/src/player/wire_in.rs index 6dd7da812..de8060cbe 100644 --- a/crates/benilla-app/src/player/wire_in.rs +++ b/crates/benilla-app/src/player/wire_in.rs @@ -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 => { diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index 0f23d98c6..94c0351db 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -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 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 { - 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 { + 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::() + .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::()); + settle(f); + *f.app.world_mut().resource_mut::() = selected; } /// The unit feed, which signals the selection's change; the calls its handlers make apply diff --git a/crates/benilla-app/src/script_calls/tests/selection.rs b/crates/benilla-app/src/script_calls/tests/selection.rs index 72a3c06d1..e09279a59 100644 --- a/crates/benilla-app/src/script_calls/tests/selection.rs +++ b/crates/benilla-app/src/script_calls/tests/selection.rs @@ -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::().guid = Some(MOB); settle(&mut f); let family = diff --git a/crates/benilla-app/src/spell/drop_hold_tests.rs b/crates/benilla-app/src/spell/drop_hold_tests.rs new file mode 100644 index 000000000..7df1f5501 --- /dev/null +++ b/crates/benilla-app/src/spell/drop_hold_tests.rs @@ -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, +} + +/// 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::().0 = Some(ME); + world.resource_mut::().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::().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::() + .push_cast(FIREBALL, None, None); + self.sent(); + } + + fn seen(&mut self) -> Vec { + crate::script_calls::eval_in_vm(self.world(), "local s = SEEN SEEN = {} return s") + } + + fn sent(&self) -> Vec { + 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::().current(), None); + + // The cursor refuses a payload while control is down, so a cast stands for what is held. + c.world() + .resource_mut::() + .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::().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} }}")]); + } +} diff --git a/crates/benilla-app/src/spell/inflight.rs b/crates/benilla-app/src/spell/inflight.rs index 74609d0fd..bd2283d1d 100644 --- a/crates/benilla-app/src/spell/inflight.rs +++ b/crates/benilla-app/src/spell/inflight.rs @@ -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( + 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) { diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index e2b18257b..24f060caa 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -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)] diff --git a/crates/benilla-app/src/target/front_tests.rs b/crates/benilla-app/src/target/front_tests.rs index 14f66560a..a4c1007fd 100644 --- a/crates/benilla-app/src/target/front_tests.rs +++ b/crates/benilla-app/src/target/front_tests.rs @@ -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) -> Vec { .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::()); crate::ui_unit::run_feed_units_once(f.app.world_mut()); + *f.app.world_mut().resource_mut::() = 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") diff --git a/crates/benilla-app/src/target/release.rs b/crates/benilla-app/src/target/release.rs index 40d93e45a..0090ad099 100644 --- a/crates/benilla-app/src/target/release.rs +++ b/crates/benilla-app/src/target/release.rs @@ -44,7 +44,6 @@ pub(super) fn register(app: &mut App) { .init_resource::() .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, world: &mut World) { } } -/// The transfer's owed teardown ([`run_owed`]). -fn on_worldport(In(ev): In, 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) { diff --git a/crates/benilla-app/src/target/unheld_tests.rs b/crates/benilla-app/src/target/unheld_tests.rs index 83cc553e6..f4100047e 100644 --- a/crates/benilla-app/src/target/unheld_tests.rs +++ b/crates/benilla-app/src/target/unheld_tests.rs @@ -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::::new(), "the port sends nothing"); + let seen: u32 = crate::script_calls::eval_in_vm(f.app.world_mut(), "return SEEN"); + assert_eq!(seen, 0); + } +} diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs index 0044dba8e..6194b8312 100644 --- a/crates/benilla-app/src/ui_chat/edit.rs +++ b/crates/benilla-app/src/ui_chat/edit.rs @@ -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); } } diff --git a/crates/benilla-app/src/ui_chat/event.rs b/crates/benilla-app/src/ui_chat/event.rs index 997e1917b..cf2250f0b 100644 --- a/crates/benilla-app/src/ui_chat/event.rs +++ b/crates/benilla-app/src/ui_chat/event.rs @@ -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 { 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, diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index 2bdbfbd89..605b13d41 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -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 { + 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 { @@ -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); diff --git a/crates/benilla-app/src/ui_chat/net.rs b/crates/benilla-app/src/ui_chat/net.rs index 15a04c187..ad7985282 100644 --- a/crates/benilla-app/src/ui_chat/net.rs +++ b/crates/benilla-app/src/ui_chat/net.rs @@ -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::().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::>(); + 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, + social: Res, + net_commands: Res, + mut server_said: MessageWriter| { + chat( + m.clone(), + &chat_log, + &social, + &net_commands, + &mut server_said, + ); + }, + ) + .unwrap(); + let wire = world.resource::().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. diff --git a/crates/benilla-app/src/ui_chat/notice_tests.rs b/crates/benilla-app/src/ui_chat/notice_tests.rs index 9d0ada4e9..5824ee31a 100644 --- a/crates/benilla-app/src/ui_chat/notice_tests.rs +++ b/crates/benilla-app/src/ui_chat/notice_tests.rs @@ -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::(); + 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!( diff --git a/crates/benilla-app/src/ui_item_text.rs b/crates/benilla-app/src/ui_item_text.rs index a3605e19f..0a0d32751 100644 --- a/crates/benilla-app/src/ui_item_text.rs +++ b/crates/benilla-app/src/ui_item_text.rs @@ -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, + /// 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, } /// 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) { + 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, 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::().landing = landing; @@ -326,6 +364,10 @@ fn fire_item_text(In(until): In, 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, mut open: ResMut {} } } } @@ -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::() + .0 + .get(&guid) + .copied() + else { + return; + }; + let Some(mut store) = world.get_mut::(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::().show(guid, None); + } + ReadReply::Failed { + guid, + reason, + milliseconds, + } => match reason { + 0 => world.resource_mut::().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::().object() == Some(guid) { + world + .resource_mut::() + .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::().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, + mut open: ResMut, + objects: Objects, + net: Res, +) { + 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 { + 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, mut lane: ResMut) { + 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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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::() + .insert_template(5_000, Some(template)); + world + .resource_mut::() + .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::() + .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::() + .eval::("local s = table.concat(SEEN, ',') SEEN = {} return s") + .expect("runs") + }; + let reads = |rx: &crossbeam_channel::Receiver| -> 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::() + .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::() + .0 + .get(&BOOK) + .and_then(|&e| app.world().get::(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::() + .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 diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 3bfbeb922..5634612fb 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -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); diff --git a/crates/benilla-app/src/ui_loot/call_tests.rs b/crates/benilla-app/src/ui_loot/call_tests.rs index 69e1badf1..a813bd5ae 100644 --- a/crates/benilla-app/src/ui_loot/call_tests.rs +++ b/crates/benilla-app/src/ui_loot/call_tests.rs @@ -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::() + .insert_template(UNCACHED, Some(crate::items::test_template("Silk Cloth"))); + world.resource_scope(|world, mut loot: Mut| { + loot.row_answered(world.resource::()) + }); + 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 diff --git a/crates/benilla-app/src/ui_loot/calls.rs b/crates/benilla-app/src/ui_loot/calls.rs index d88845e3f..6e3631a2e 100644 --- a/crates/benilla-app/src/ui_loot/calls.rs +++ b/crates/benilla-app/src/ui_loot/calls.rs @@ -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 { + 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)], + )) +} diff --git a/crates/benilla-app/src/ui_loot/mod.rs b/crates/benilla-app/src/ui_loot/mod.rs index b9b12909d..c8f7f058c 100644 --- a/crates/benilla-app/src/ui_loot/mod.rs +++ b/crates/benilla-app/src/ui_loot/mod.rs @@ -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| { diff --git a/crates/benilla-app/src/ui_loot/net.rs b/crates/benilla-app/src/ui_loot/net.rs index cb30186d6..e04380ac9 100644 --- a/crates/benilla-app/src/ui_loot/net.rs +++ b/crates/benilla-app/src/ui_loot/net.rs @@ -142,9 +142,14 @@ pub(super) fn on_removed(In(ev): In, mut window: WindowPacket) { } } -fn on_money_notify(In(ev): In) { +fn on_money_notify( + In(ev): In, + mut vm: crate::script_calls::HostedVm, + chat: Res, +) { 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`) diff --git a/crates/benilla-app/src/ui_loot/sweep_tests.rs b/crates/benilla-app/src/ui_loot/sweep_tests.rs index 8cf855bba..d904a638b 100644 --- a/crates/benilla-app/src/ui_loot/sweep_tests.rs +++ b/crates/benilla-app/src/ui_loot/sweep_tests.rs @@ -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" + ); +} diff --git a/crates/benilla-app/src/ui_loot_roll.rs b/crates/benilla-app/src/ui_loot_roll.rs index a6a4cf018..9f0072e24 100644 --- a/crates/benilla-app/src/ui_loot_roll.rs +++ b/crates/benilla-app/src/ui_loot_roll.rs @@ -275,16 +275,33 @@ mod net { .net_handler(K::Disconnected, on_session_end); } - fn on_packet(In(ev): In, mut rolls: ResMut) { + fn on_packet( + In(ev): In, + mut rolls: ResMut, + loot: Option>, + ) { 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>, 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, mut rolls: ResMut) { rolls.clear(); } diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index a4002059c..f6788536b 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -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, } } diff --git a/crates/benilla-app/src/ui_script/delete_item_tests.rs b/crates/benilla-app/src/ui_script/delete_item_tests.rs index 4bda11a85..c1b18d736 100644 --- a/crates/benilla-app/src/ui_script/delete_item_tests.rs +++ b/crates/benilla-app/src/ui_script/delete_item_tests.rs @@ -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. diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 9814eb10d..1df44e93d 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -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 \ diff --git a/crates/benilla-app/src/ui_social/mod.rs b/crates/benilla-app/src/ui_social/mod.rs index d2d3df917..9a01f0a03 100644 --- a/crates/benilla-app/src/ui_social/mod.rs +++ b/crates/benilla-app/src/ui_social/mod.rs @@ -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)); diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 0feb43a47..3dcb89d25 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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 diff --git a/crates/benilla-formats/src/area_poi.rs b/crates/benilla-formats/src/area_poi.rs index 15b82bc3f..95fdc9d64 100644 --- a/crates/benilla-formats/src/area_poi.rs +++ b/crates/benilla-formats/src/area_poi.rs @@ -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, diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index e9d855d25..a3660ca44 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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 }, diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 689001c64..500797751 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -933,6 +933,16 @@ pub fn decode(packet: ServerPacket) -> Vec { 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 }] diff --git a/crates/benilla-protocol/src/messages/channel.rs b/crates/benilla-protocol/src/messages/channel.rs index e31c6d4ca..5cbb399c8 100644 --- a/crates/benilla-protocol/src/messages/channel.rs +++ b/crates/benilla-protocol/src/messages/channel.rs @@ -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 { 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 diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index 5310e8afb..2ecf9a86f 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -574,6 +574,29 @@ pub fn open_item(bag_index: u8, slot: u8) -> Vec { 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 { + 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`. diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 980d2f976..090df0df9 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index ebf41bc3f..b0f49dc9a 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 2f4228012..ccdbb9af7 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index 8b326e102..2c7666a02 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -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)?, }, diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index dda164abc..58db3d534 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -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( diff --git a/crates/benilla-protocol/src/world/writer/items.rs b/crates/benilla-protocol/src/world/writer/items.rs index 285402865..dee19469a 100644 --- a/crates/benilla-protocol/src/world/writer/items.rs +++ b/crates/benilla-protocol/src/world/writer/items.rs @@ -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( diff --git a/crates/benilla-protocol/tests/chat.rs b/crates/benilla-protocol/tests/chat.rs index 9c5c669b2..17f62e035 100644 --- a/crates/benilla-protocol/tests/chat.rs +++ b/crates/benilla-protocol/tests/chat.rs @@ -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. diff --git a/crates/benilla-protocol/tests/simple_packets.rs b/crates/benilla-protocol/tests/simple_packets.rs index c7d80f6c7..9eb70af2a 100644 --- a/crates/benilla-protocol/tests/simple_packets.rs +++ b/crates/benilla-protocol/tests/simple_packets.rs @@ -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::>() + }; + 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)]); +} diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 06b3f110c..98d4cff3d 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -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::("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 diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 3e53ece01..12f2c14aa 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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, diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 14f4ec564..12cae384b 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, /// Item id to its tooltip view. pub(crate) item_templates: HashMap, /// 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(), diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index ad859f1be..a7e04ffb1 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -128,6 +128,22 @@ pub struct QuestRecordView { pub description: String, pub objectives_text: String, pub reward_spell: Option, + /// 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) { 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) { .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 { + 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)> { @@ -1032,9 +1075,18 @@ impl super::UiScript { let store = &mut model.quest_store; let ids: Vec = 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 = 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::() { 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 { + 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::().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() { diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index e05ca01e2..85953701a 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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::().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(); diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 0cffa42b3..b299cc04d 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -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))