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