the channel bar says Channeling, and eight caches become one
- a channelled spell's bar reads "Channeling" rather than the spell's name, and stays orange: 1.12 tells a channel from a cast by its label and its drain, not by a green bar (2284) - a shot's muzzle blast fires only when a projectile is queued, the reference's own gate, so a target-less shot shows none (2289) - the eight hand-copied query caches, names, items, objects, page texts, quests, guilds, petitions and mail, are one generic cache, and a read that asks no longer takes the whole store exclusively (2288) - the schedule's ambiguity ratchet counts by class, and the cast-fail argument table is closed: MIN_SKILL is the prospecting leg and cannot fire on 1.12 (2287, 2292) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -134,7 +134,7 @@ fn fire_live_shot(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
subjects: Query<(&Guid, &Transform), (With<NetEntity>, Without<SelfPlayer>)>,
|
||||
camera: Query<(&Camera, &Transform), With<WorldCamera>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
net_commands: Res<NetCommands>,
|
||||
mut refused: Local<bool>,
|
||||
mut next_require_warn: Local<f32>,
|
||||
|
||||
@@ -458,7 +458,7 @@ fn charter_probe(
|
||||
mut probe: ResMut<CharterProbe>,
|
||||
gossip: Res<GossipState>,
|
||||
registrar: Res<GuildRegistrarState>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
player: Res<Player>,
|
||||
|
||||
@@ -184,7 +184,7 @@ fn window_open(script: &UiScript, loot: &LootState) -> bool {
|
||||
fn find_in_backpack(
|
||||
store: &ObjectStore,
|
||||
entry: u32,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
net: &NetCommands,
|
||||
) -> Option<(u64, u32)> {
|
||||
let (i, guid) = (0..16u8)
|
||||
@@ -209,7 +209,7 @@ fn clam_probe(
|
||||
mut probe: ResMut<ClamProbe>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
mut cfg: ResMut<LootConfig>,
|
||||
loot: Res<LootState>,
|
||||
latch: Res<LootLatch>,
|
||||
@@ -240,7 +240,7 @@ fn clam_probe(
|
||||
probe.phase = Phase::Stocking { sent_at: now };
|
||||
}
|
||||
Phase::Stocking { sent_at } => {
|
||||
if let Some((guid, slot)) = find_in_backpack(store, entry, &mut items, &net) {
|
||||
if let Some((guid, slot)) = find_in_backpack(store, entry, &items, &net) {
|
||||
info!(
|
||||
"PROBE_CLAM: item {entry} is guid {guid:#x} in backpack slot {slot} — \
|
||||
sampling {CONTROL_FRAMES} control frames with it UNCLICKED"
|
||||
|
||||
@@ -377,7 +377,7 @@ fn mail_probe(
|
||||
};
|
||||
let landed = entry_of(&mail, letter_id).and_then(|e| {
|
||||
(e.item_text_id != 0)
|
||||
.then(|| mail.bodies.get(&e.item_text_id).cloned())
|
||||
.then(|| mail.bodies.get(e.item_text_id).cloned())
|
||||
.flatten()
|
||||
.map(|body| (body, e.checked & CHECKED_READ != 0))
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ fn vendor_swap_probe(
|
||||
mut probe: ResMut<VendorSwapProbe>,
|
||||
merchant: Res<MerchantOpen>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
panes: Res<BoothPanes>,
|
||||
images: Res<PortraitImages>,
|
||||
self_player: Query<(), With<SelfPlayer>>,
|
||||
@@ -219,7 +219,7 @@ fn vendor_swap_probe(
|
||||
let (brog, dobbins) = (find(BROG_ENTRY), find(DOBBINS_ENTRY));
|
||||
// Both names must be cached before the legs read them back out of the VM — the
|
||||
// ask-once resolve is what the unit feed itself would send on the open frame.
|
||||
let mut named = |g: Option<u64>| g.is_some_and(|g| names.resolve(g, &net).is_some());
|
||||
let named = |g: Option<u64>| g.is_some_and(|g| names.resolve(g, &net).is_some());
|
||||
let (brog_named, dobbins_named) = (named(brog), named(dobbins));
|
||||
if brog_named && dobbins_named {
|
||||
probe.brog = brog;
|
||||
|
||||
@@ -121,7 +121,7 @@ fn fire_ground_census(
|
||||
// reader back to the server's own tables to answer it — and the cache is already warm: it is
|
||||
// asked at first *sight* for every unit that streams in. A `None` is a name still in flight,
|
||||
// never a missing one.
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
net_commands: Res<NetCommands>,
|
||||
world: benilla_world::collision::WorldCollision,
|
||||
point: benilla_world::world_point::WorldPoint,
|
||||
|
||||
@@ -334,7 +334,7 @@ pub(super) fn load_spell_visuals(mut commands: Commands, assets: Option<Res<Worl
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(super) struct WeaponVisualSrc<'w, 's> {
|
||||
displays: Option<Res<'w, ItemDisplays>>,
|
||||
items: Option<ResMut<'w, Items>>,
|
||||
items: Option<Res<'w, Items>>,
|
||||
net: Option<Res<'w, NetCommands>>,
|
||||
units: Query<'w, 's, (Option<&'static NetEntity>, &'static ObjectStore)>,
|
||||
}
|
||||
@@ -355,7 +355,7 @@ impl WeaponVisualSrc<'_, '_> {
|
||||
EntityKind::Player => s
|
||||
.player_visible_item_entry(17)
|
||||
.filter(|e| *e != 0)
|
||||
.and_then(|entry| self.items.as_deref_mut()?.held(entry, self.net.as_deref()?))
|
||||
.and_then(|entry| self.items.as_deref()?.held(entry, self.net.as_deref()?))
|
||||
.map(|t| t.display_info_id),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -187,14 +187,14 @@ fn feed_death(
|
||||
// comparison must be the same clock.
|
||||
time: Res<Time<Real>>,
|
||||
mut feed: ResMut<DeathFeedState>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
names: Res<crate::names::NameCache>,
|
||||
net: Res<NetCommands>,
|
||||
index: Res<GuidIndex>,
|
||||
transforms: Query<&Transform>,
|
||||
map: Option<Res<benilla_world::world_map::CurrentMap>>,
|
||||
status: Res<crate::net::NetStatus>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut items: ResMut<crate::items::Items>,
|
||||
items: Res<crate::items::Items>,
|
||||
) {
|
||||
// **Only a LIVE session's descriptor is a snapshot** (decision 1732). A reconnect-able
|
||||
// disconnect keeps the self avatar as the local puppet (0065) — descriptor and all — so
|
||||
@@ -259,7 +259,7 @@ fn feed_death(
|
||||
// `HasSoulstone()` — see [`resolve_self_res`] for the three gates and their order. Not a
|
||||
// per-frame inventory walk in general: the dead gate is first, so while alive this is one
|
||||
// health read, and while dead-unreleased the walk only runs on a zero field.
|
||||
self_res_label: resolve_self_res(&store.0, &mut items, spells.as_deref(), &net)
|
||||
self_res_label: resolve_self_res(&store.0, &items, spells.as_deref(), &net)
|
||||
.map(|r| r.label().to_owned()),
|
||||
});
|
||||
|
||||
@@ -470,7 +470,7 @@ impl SelfRes {
|
||||
/// the lookup fires answers within a frame or two and the next resolve sees it.
|
||||
fn resolve_self_res(
|
||||
store: &benilla_protocol::ObjectFields,
|
||||
items: &mut crate::items::Items,
|
||||
items: &crate::items::Items,
|
||||
spells: Option<&Spells>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<SelfRes> {
|
||||
@@ -628,7 +628,7 @@ fn drain_death(
|
||||
match store.as_ref().and_then(|store| {
|
||||
resolve_self_res(
|
||||
store,
|
||||
&mut ladder.items,
|
||||
&ladder.items,
|
||||
ladder.spells.as_deref(),
|
||||
&ladder.commands,
|
||||
)
|
||||
@@ -837,7 +837,7 @@ mod self_res_tests {
|
||||
#[test]
|
||||
fn the_dead_gate_precedes_the_field() {
|
||||
let (net, _rx) = commands();
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let spells = catalog([(REINCARNATION, named("Reincarnation", [94, 0, 0]))]);
|
||||
|
||||
let alive = ObjectFields::from_pairs(&[
|
||||
@@ -846,7 +846,7 @@ mod self_res_tests {
|
||||
(F_SELF_RES, REINCARNATION),
|
||||
]);
|
||||
assert_eq!(
|
||||
resolve_self_res(&alive, &mut items, Some(&spells), &net),
|
||||
resolve_self_res(&alive, &items, Some(&spells), &net),
|
||||
None,
|
||||
"alive with a self-res owed: nil"
|
||||
);
|
||||
@@ -859,7 +859,7 @@ mod self_res_tests {
|
||||
(F_SELF_RES, REINCARNATION),
|
||||
]);
|
||||
assert_eq!(
|
||||
resolve_self_res(&ghost, &mut items, Some(&spells), &net),
|
||||
resolve_self_res(&ghost, &items, Some(&spells), &net),
|
||||
None,
|
||||
"a released ghost still holds the field, and still answers nil"
|
||||
);
|
||||
@@ -870,7 +870,7 @@ mod self_res_tests {
|
||||
(F_SELF_RES, REINCARNATION),
|
||||
]);
|
||||
assert_eq!(
|
||||
resolve_self_res(&dead, &mut items, Some(&spells), &net),
|
||||
resolve_self_res(&dead, &items, Some(&spells), &net),
|
||||
Some(SelfRes::Spell {
|
||||
spell: REINCARNATION,
|
||||
label: "Reincarnation".into(),
|
||||
@@ -883,12 +883,12 @@ mod self_res_tests {
|
||||
#[test]
|
||||
fn an_unresolvable_spell_id_reads_unknown_not_nil() {
|
||||
let (net, _rx) = commands();
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let dead =
|
||||
ObjectFields::from_pairs(&[(F_MAXHEALTH, 4000), (F_HEALTH, 0), (F_SELF_RES, 999_999)]);
|
||||
for spells in [None, Some(catalog([]))] {
|
||||
assert_eq!(
|
||||
resolve_self_res(&dead, &mut items, spells.as_ref(), &net),
|
||||
resolve_self_res(&dead, &items, spells.as_ref(), &net),
|
||||
Some(SelfRes::Spell {
|
||||
spell: 999_999,
|
||||
label: "UNKNOWN".into(),
|
||||
@@ -948,7 +948,7 @@ mod self_res_tests {
|
||||
}
|
||||
let dead = ObjectFields::from_pairs(&pairs);
|
||||
|
||||
match resolve_self_res(&dead, &mut items, Some(&spells), &net) {
|
||||
match resolve_self_res(&dead, &items, Some(&spells), &net) {
|
||||
Some(SelfRes::Item { entry, label, .. }) => {
|
||||
assert_eq!(
|
||||
entry, 12,
|
||||
@@ -961,9 +961,6 @@ mod self_res_tests {
|
||||
|
||||
// Take it away and the answer is nil again — not "UNKNOWN", which is the spell leg's.
|
||||
let bare = ObjectFields::from_pairs(&[(F_MAXHEALTH, 4000), (F_HEALTH, 0)]);
|
||||
assert_eq!(
|
||||
resolve_self_res(&bare, &mut items, Some(&spells), &net),
|
||||
None
|
||||
);
|
||||
assert_eq!(resolve_self_res(&bare, &items, Some(&spells), &net), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ pub(super) fn inspect_ui(
|
||||
drivers: Query<&crate::creature_anim::AnimDriver>,
|
||||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
names: Res<crate::names::NameCache>,
|
||||
net_commands: Res<crate::net::NetCommands>,
|
||||
// Bundled into one param (Bevy's system-function arity ceiling): the copy-click button, and
|
||||
// the flag it must yield to — a left press this frame the UI already consumed as a
|
||||
|
||||
@@ -161,7 +161,7 @@ pub(super) fn journal_ui(
|
||||
inspect: Res<InspectMode>,
|
||||
journal: Res<CastJournal>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
names: Res<crate::names::NameCache>,
|
||||
net_commands: Res<NetCommands>,
|
||||
time: Res<Time>,
|
||||
// The copied row's edge stamp (`CastRecord::at` is unique per row) + when the copy happened,
|
||||
|
||||
@@ -45,7 +45,7 @@ pub(super) struct WorldReadout<'w, 's> {
|
||||
skybox_weight: Res<'w, benilla_world::skybox::SkyboxWeight>,
|
||||
streamer: Res<'w, benilla_world::terrain_stream::TerrainStreamer>,
|
||||
self_guid: Res<'w, crate::net::SelfGuid>,
|
||||
names: ResMut<'w, crate::names::NameCache>,
|
||||
names: Res<'w, crate::names::NameCache>,
|
||||
net_commands: Res<'w, crate::net::NetCommands>,
|
||||
camera: Query<'w, 's, &'static Transform, With<benilla_world::view::WorldCamera>>,
|
||||
land: MessageWriter<'w, LandHere>,
|
||||
|
||||
@@ -94,7 +94,7 @@ pub(crate) use collision_height::CollisionHeight;
|
||||
/// spawned under the same attach-point joints as held items, lifetime per the kit stage.
|
||||
mod missile;
|
||||
use missile::{attach_missile_models, move_missiles, spawn_missiles};
|
||||
pub(crate) use missile::{MissileMiss, MissileSound};
|
||||
pub(crate) use missile::{MissileMiss, MissileSound, PendingMissiles};
|
||||
|
||||
/// WMO-display GameObject doodad props (the ship's sails / the zeppelin's rotor): the WMO's MODD
|
||||
/// M2s spawned as children of the streamed gameobject, so they ride a moving transport.
|
||||
|
||||
@@ -192,7 +192,7 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
Has<crate::net::SelfPlayer>,
|
||||
)>,
|
||||
held: Option<ResMut<ItemDisplays>>,
|
||||
mut templates: ResMut<Items>,
|
||||
templates: Res<Items>,
|
||||
net: Res<NetCommands>,
|
||||
asset_server: Res<AssetServer>,
|
||||
// The creature display cache — a character-model NPC's helm/shoulder ids + race/sex live on its
|
||||
@@ -638,7 +638,7 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
EntityKind::Player if char_component => {
|
||||
let race = s.unit_race().unwrap_or(1);
|
||||
let sex = s.unit_gender().unwrap_or(0).min(1);
|
||||
let mut resolve = |slot: u8| {
|
||||
let resolve = |slot: u8| {
|
||||
s.player_visible_item_entry(slot)
|
||||
.filter(|e| *e != 0)
|
||||
.and_then(|entry| templates.held(entry, &net))
|
||||
|
||||
@@ -293,8 +293,58 @@ struct QueuedGo {
|
||||
|
||||
/// Every caster's pending queue (the client's per-unit `+0xac` list heads). A caster that
|
||||
/// streams out drops its queue with it.
|
||||
///
|
||||
/// **It is also a GATE, not only a queue** (decision 2288, wow-re
|
||||
/// `missile-queue-gates-the-release-event.md`). `[CGUnit+0xac]` holds `CMissile` nodes — the
|
||||
/// binary names them itself (`Missile_C.cpp`) — inserted by the missile spawner `0x60a3d0` and
|
||||
/// drained by the release event, and the `$BWR` handler reads it *before* draining it
|
||||
/// (`0x600182 mov eax,[esi+0xac]; test eax,eax; je 0x600299`). Everything between those two points
|
||||
/// — the ranged prop's own re-anim and the cast-sound reposition — happens **only when a
|
||||
/// projectile is actually waiting to be released**. So a shot's flex and its launch are two
|
||||
/// effects of one act, and a consumer that arms the prop without asking this has half a mechanism.
|
||||
#[derive(Resource, Default)]
|
||||
pub(super) struct PendingMissiles(EntityHashMap<Vec<QueuedGo>>);
|
||||
pub(crate) struct PendingMissiles(EntityHashMap<Vec<QueuedGo>>);
|
||||
|
||||
impl PendingMissiles {
|
||||
/// Is a projectile queued on `caster`, waiting for its release keyframe? The reference's
|
||||
/// `test eax,eax` on the list head — asked by [`crate::ranged_flex`] before the drain, which is
|
||||
/// the order `0x600182` and `0x600294` sit in.
|
||||
pub(crate) fn releasing(&self, caster: Entity) -> bool {
|
||||
self.0.get(&caster).is_some_and(|q| !q.is_empty())
|
||||
}
|
||||
|
||||
/// Queue one projectile on `caster` — **test-only**, for the consumers of the gate above
|
||||
/// ([`crate::ranged_flex`]), which need the queue non-empty without standing up the whole GO
|
||||
/// path to put something in it. The node's contents are irrelevant to every reader of
|
||||
/// [`Self::releasing`]; only its presence is.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn queue_a_shot(app: &mut bevy::app::App, caster: Entity) {
|
||||
let spawn = MissileSpawn {
|
||||
caster,
|
||||
spell_id: 75,
|
||||
path: None,
|
||||
ammo_display_id: None,
|
||||
dest_tag: None,
|
||||
speed: 40.0,
|
||||
targets: Vec::new(),
|
||||
ground_aim: None,
|
||||
weapon_visual: None,
|
||||
missile_sound: None,
|
||||
awaits_release: true,
|
||||
};
|
||||
app.world_mut()
|
||||
.resource_mut::<Self>()
|
||||
.0
|
||||
.entry(caster)
|
||||
.or_default()
|
||||
.push(QueuedGo {
|
||||
spawn,
|
||||
key: None,
|
||||
queued: 0.0,
|
||||
saw_oneshot: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The ammo display's flight model as a [`SpellFx`] cache entry: the **shape rule** (module
|
||||
/// docs — right slot = `Ammo\`, left slot = `Weapon\`, thrown), with the row's own object skin.
|
||||
|
||||
@@ -444,9 +444,11 @@ impl PluginGroup for GamePlugins {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod schedule_tests {
|
||||
use std::collections::HashMap;
|
||||
use std::any::TypeId;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use super::*;
|
||||
use bevy::ecs::component::ComponentId;
|
||||
use bevy::ecs::schedule::graph::Direction;
|
||||
use bevy::ecs::schedule::{LogLevel, NodeId, ScheduleBuildSettings, ScheduleLabel, SystemKey};
|
||||
|
||||
@@ -501,8 +503,13 @@ pub(crate) mod schedule_tests {
|
||||
pub(crate) struct Census {
|
||||
pub systems: HashMap<SystemKey, SystemInfo>,
|
||||
/// Pairs of systems with conflicting access and no path between them, each with what
|
||||
/// they fight over, by name.
|
||||
pub conflicts: Vec<(SystemKey, SystemKey, Vec<String>)>,
|
||||
/// they fight over.
|
||||
pub conflicts: Vec<(SystemKey, SystemKey, Vec<ComponentId>)>,
|
||||
/// Everything any pair fights over, by name (a placeholder in a build without type
|
||||
/// names — [`type_names_available`]).
|
||||
pub components: HashMap<ComponentId, String>,
|
||||
/// The explained classes, resolved against this world (decision 2287).
|
||||
pub classes: Classes,
|
||||
/// Every declared `a` runs before `b`, at the system level.
|
||||
pub dependencies: Vec<(SystemKey, SystemKey)>,
|
||||
/// The systems that must run on the main thread (the VM's, the audio layer's).
|
||||
@@ -516,6 +523,10 @@ pub(crate) mod schedule_tests {
|
||||
.map(|s| s.name.as_str())
|
||||
.unwrap_or("?")
|
||||
}
|
||||
|
||||
pub fn component(&self, id: ComponentId) -> &str {
|
||||
self.components.get(&id).map(String::as_str).unwrap_or("?")
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the census of one schedule. Initializing it runs every system's param setup, and
|
||||
@@ -622,28 +633,31 @@ pub(crate) mod schedule_tests {
|
||||
conditions: Vec::new(),
|
||||
});
|
||||
}
|
||||
let mut components = HashMap::new();
|
||||
let conflicts = schedule
|
||||
.graph()
|
||||
.conflicting_systems()
|
||||
.0
|
||||
.iter()
|
||||
.map(|(a, b, ids)| {
|
||||
let what = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
for id in ids.iter() {
|
||||
components.entry(*id).or_insert_with(|| {
|
||||
world
|
||||
.components()
|
||||
.get_name(*id)
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("{id:?}"))
|
||||
})
|
||||
.collect();
|
||||
(*a, *b, what)
|
||||
});
|
||||
}
|
||||
(*a, *b, ids.to_vec())
|
||||
})
|
||||
.collect();
|
||||
let classes = Classes::read(world);
|
||||
Census {
|
||||
systems,
|
||||
conflicts,
|
||||
components,
|
||||
classes,
|
||||
dependencies,
|
||||
non_send,
|
||||
}
|
||||
@@ -660,18 +674,134 @@ pub(crate) mod schedule_tests {
|
||||
c.systems.values().any(|s| s.name.contains("benilla_app::"))
|
||||
}
|
||||
|
||||
/// Is this conflict set nothing but the Lua VM?
|
||||
fn vm_only(what: &[String]) -> bool {
|
||||
what.iter()
|
||||
.all(|w| w.contains("benilla_ui::script::UiScript"))
|
||||
/// **The explained classes** (decision 2287) — what an undeclared order may be about
|
||||
/// without anyone declaring it, argued once here instead of by every session that adds a
|
||||
/// system. On 2287's eve three sessions raised the two constants this table replaced,
|
||||
/// each with a paragraph, each through a rebase conflict on the same lines, none declaring
|
||||
/// an order (2281, 2282, 2283) — because for most of what those counts held there was
|
||||
/// nothing to declare:
|
||||
///
|
||||
/// - **A non-`Send` owner** — the Lua VM, the audio layer's handles. Their systems run on
|
||||
/// the main thread one at a time under any executor, so no order among them is a race.
|
||||
/// *Which feed fires its Lua events first* is a real question — 2265 §A3(a)'s, answered
|
||||
/// once for the `UiFeed` set, not per pair. Derived, not listed: every registration that
|
||||
/// is not `Send + Sync`.
|
||||
/// - **A pure cache** — a read is a write because a miss records itself: the ask-once
|
||||
/// caches mark the key pending and send one query (`NameCache`, the GameObject
|
||||
/// templates, the page texts), the load-once caches build the entry (`WorldAssets`,
|
||||
/// `Creatures`' display models). Two misses for one key commute — one query, or one
|
||||
/// build, whichever runs first — and the answer lands in the net drain, in packet order,
|
||||
/// inside `WorldStage::Net`. *Pure* is the condition: a resource that is a cache **and**
|
||||
/// a window's state (`Items`, `MailOpen`, `GuildState`, `QuestLog`, `PetitionState`) is
|
||||
/// not here, because its other writers do not commute; 2265 §A4 is what lets those
|
||||
/// split (decision 2288).
|
||||
/// - **An append-only sink** — `ChatLog`, `MessageSounds`, `UiErrorKeys`, `UiErrorTexts`:
|
||||
/// writers commute, and a drain's order against a writer is one frame of latency, never
|
||||
/// a loss. (2283's ten pairs were all this shape: a new verb drain against its siblings
|
||||
/// over the chat and error sinks.)
|
||||
/// - **A random stream** — `SoundKits`, which every sound system holds to play a kit: a
|
||||
/// decode cache, a per-kit last-variation memory and one xorshift stream. Any
|
||||
/// interleaving of draws is a valid draw, and that is the reference's own contract for
|
||||
/// its single stream, consumed in whatever order its callers happen to run.
|
||||
///
|
||||
/// A pair is **explained** when everything it fights over is in one of the four, and
|
||||
/// **actionable** otherwise; only the actionable count is ratcheted. Adding a type here is
|
||||
/// the act raising the ceiling used to be — a claim, with its reason, made in review — and
|
||||
/// it is a claim about *every* writer of that resource, which is why the pure caches are
|
||||
/// pure. Resolved by `TypeId`, so the ratchet runs in the player build, which carries no
|
||||
/// names (1451).
|
||||
/// One row of the class table: the type's name for the message, and its `TypeId`.
|
||||
type ClassRow = (&'static str, fn() -> TypeId);
|
||||
|
||||
pub(crate) struct Classes {
|
||||
/// The VM's own id, for the executor census.
|
||||
pub vm: Option<ComponentId>,
|
||||
pub non_send: HashSet<ComponentId>,
|
||||
pub caches: HashSet<ComponentId>,
|
||||
pub sinks: HashSet<ComponentId>,
|
||||
pub streams: HashSet<ComponentId>,
|
||||
}
|
||||
|
||||
/// …or nothing but the VM and the audio layer (kira's handles, the kit catalog, the
|
||||
/// config) — the two non-`Send` owners 2265 §A3 names as the noise floor.
|
||||
fn vm_or_audio_only(what: &[String]) -> bool {
|
||||
what.iter().all(|w| {
|
||||
w.contains("benilla_ui::script::UiScript") || w.contains("benilla_app::sound::")
|
||||
})
|
||||
impl Classes {
|
||||
const CACHES: &[ClassRow] = &[
|
||||
("NameCache", TypeId::of::<crate::names::NameCache>),
|
||||
(
|
||||
"GameObjectTemplates",
|
||||
TypeId::of::<crate::go_templates::GameObjectTemplates>,
|
||||
),
|
||||
("PageTexts", TypeId::of::<crate::ui_item_text::PageTexts>),
|
||||
("WorldAssets", TypeId::of::<benilla_assets::WorldAssets>),
|
||||
("Creatures", TypeId::of::<crate::entities::Creatures>),
|
||||
];
|
||||
const SINKS: &[ClassRow] = &[
|
||||
("ChatLog", TypeId::of::<crate::ui_chat::ChatLog>),
|
||||
("MessageSounds", TypeId::of::<crate::sound::MessageSounds>),
|
||||
("UiErrorKeys", TypeId::of::<crate::ui_action::UiErrorKeys>),
|
||||
("UiErrorTexts", TypeId::of::<crate::ui_action::UiErrorTexts>),
|
||||
];
|
||||
const STREAMS: &[ClassRow] = &[("SoundKits", TypeId::of::<crate::sound::SoundKits>)];
|
||||
|
||||
/// Resolve the table against a world whose schedules have initialized (every param
|
||||
/// has registered its resource by then). A row that resolves to nothing is a stale
|
||||
/// row, and the test says which.
|
||||
fn read(world: &World) -> Self {
|
||||
let comps = world.components();
|
||||
let resolve = |rows: &[ClassRow]| -> HashSet<ComponentId> {
|
||||
rows.iter()
|
||||
.map(|(name, type_id)| {
|
||||
comps.get_resource_id(type_id()).unwrap_or_else(|| {
|
||||
panic!("`{name}` is in the class table but is not a resource of this world")
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Self {
|
||||
vm: comps.get_resource_id(TypeId::of::<benilla_ui::script::UiScript>()),
|
||||
non_send: comps
|
||||
.iter_registered()
|
||||
.filter(|info| !info.is_send_and_sync())
|
||||
.map(|info| info.id())
|
||||
.collect(),
|
||||
caches: resolve(Self::CACHES),
|
||||
sinks: resolve(Self::SINKS),
|
||||
streams: resolve(Self::STREAMS),
|
||||
}
|
||||
}
|
||||
|
||||
fn explains(&self, id: ComponentId) -> bool {
|
||||
self.non_send.contains(&id)
|
||||
|| self.caches.contains(&id)
|
||||
|| self.sinks.contains(&id)
|
||||
|| self.streams.contains(&id)
|
||||
}
|
||||
|
||||
/// Which class explains this pair — `None` if it is actionable.
|
||||
pub fn class_of(&self, what: &[ComponentId]) -> Option<&'static str> {
|
||||
if !what.iter().all(|id| self.explains(*id)) {
|
||||
return None;
|
||||
}
|
||||
Some(if self.vm_only(what) {
|
||||
"VM only"
|
||||
} else if self.non_send_only(what) {
|
||||
"non-Send owners"
|
||||
} else if what.iter().all(|id| self.caches.contains(id)) {
|
||||
"pure caches"
|
||||
} else if what.iter().all(|id| self.sinks.contains(id)) {
|
||||
"append-only sinks"
|
||||
} else if what.iter().all(|id| self.streams.contains(id)) {
|
||||
"random streams"
|
||||
} else {
|
||||
"mixed"
|
||||
})
|
||||
}
|
||||
|
||||
pub fn vm_only(&self, what: &[ComponentId]) -> bool {
|
||||
what.iter().all(|id| Some(*id) == self.vm)
|
||||
}
|
||||
|
||||
pub fn non_send_only(&self, what: &[ComponentId]) -> bool {
|
||||
what.iter().all(|id| self.non_send.contains(id))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -680,76 +810,42 @@ pub(crate) mod schedule_tests {
|
||||
assert!(app.world().contains_resource::<Schedules>());
|
||||
}
|
||||
|
||||
/// The standing count of ambiguous pairs in `Update` — two systems with conflicting access
|
||||
/// and no declared order between them, which the executor runs in whatever order the graph
|
||||
/// around them happens to produce (B354, 2220). Same shape as `world_api_wall.rs`: the
|
||||
/// count may not rise past the ceiling, and the ceiling follows the count down.
|
||||
///
|
||||
/// **Measured 2026-09-16 (decision 2279), 659 systems:** 16,437 pairs, of which 10,613 are
|
||||
/// on the Lua VM alone and 1,107 on the VM or the audio layer — the two non-`Send` owners
|
||||
/// that every feed and every sound call takes, so nearly every pair of them conflicts. The
|
||||
/// 4,717 that remain fight over ordinary resources and components (`Transform` 716,
|
||||
/// `WorldAssets` 362, `Items`, `NameCache`, `ChatLog`, `Player`…) and are the actionable
|
||||
/// number; `UPDATE_OTHER_CEILING` ratchets it on its own. `WOW_AMBIGUITY_DUMP=1` prints
|
||||
/// every non-VM, non-audio pair with what it fights over.
|
||||
///
|
||||
/// Raising a ceiling is a claim that a new undeclared order is acceptable; make it with the
|
||||
/// reason, or declare the order instead (`.after`, a set, a `chain`).
|
||||
/// **16,538 (decision 2283), 660 systems** — `ui_dialog_verbs::drain_meeting_stone_joins`, the
|
||||
/// meeting stone's use slot. +101, of which 91 are VM-only: it holds `NonSendMut<UiScript>` to
|
||||
/// raise its refusal line, and the paragraph above is the reason that number is what it is —
|
||||
/// every new feed or drain collides with every other VM holder by construction.
|
||||
/// `UPDATE_OTHER_CEILING` below carries the ten that are actionable.
|
||||
///
|
||||
/// **+4 (decision 2281)** — `ranged_flex`'s two systems, the `$BWP`/`$BWR` arm and the
|
||||
/// un-nock's reset. Both write `AnimationPlayer` on the ranged weapon prop, so each joins the
|
||||
/// accepted class its chain-mates already sit in: ambiguous against
|
||||
/// `portrait::booth::drive_booth_turn` and `quest_markers::pose_markers` for exactly the
|
||||
/// reason `driver::drive_animations` and `driver::grip::drive_hand_grip` are — four lanes
|
||||
/// driving players on populations that never intersect (a held weapon, a booth model, a quest
|
||||
/// marker), and the filter algebra cannot see the disjointness. Their *real* orders are
|
||||
/// declared at the registration instead: against each other by the chain, and before the
|
||||
/// per-sequence material samplers, which must answer for the clip armed this frame.
|
||||
///
|
||||
/// **+6 (decision 2282), all the same shape:** `{tick_anim_materials, matanim_probe} ×
|
||||
/// {attach_spell_fx, attach_missile_models, attach_item_glows}`, fighting over the two
|
||||
/// mat-anim resources (`MatAnimTable`, `UvAnimMaterials`) alone — the three fx attaches now
|
||||
/// register a per-instance UV clone where before they touched neither. Declared acceptable
|
||||
/// rather than ordered,
|
||||
/// because the order is immaterial *by construction* and an `.after` would assert a
|
||||
/// dependency that does not exist: `register_fx_uv` writes the new entry's rows itself, at
|
||||
/// attach, precisely so a tick that already ran this frame costs the instance nothing — and a
|
||||
/// tick that runs after re-derives the same numbers from the same clock (sampling is
|
||||
/// clock-indexed; the registration and the tick read one `AnimationPlayer`). The probe is a
|
||||
/// one-shot diagnostic behind an env var, off in every ordinary run. The cost of declaring it
|
||||
/// would be real: `tick_anim_materials` is engine-private, so the game would have to name a
|
||||
/// new engine `SystemSet` — a `world_api_wall` crossing bought for no behavioural difference.
|
||||
const UPDATE_CEILING: usize = 16_548;
|
||||
const UPDATE_SLACK: usize = 100;
|
||||
/// `PostUpdate`, 181 systems: `GlobalTransform` and the particle `EffectQuads` are most of it.
|
||||
const POST_UPDATE_CEILING: usize = 351;
|
||||
const POST_UPDATE_SLACK: usize = 20;
|
||||
/// The pairs in `Update` that are NOT explained by the VM or the audio layer.
|
||||
/// The **actionable** pairs in `Update` — two systems with conflicting access and no
|
||||
/// declared order, where [`Classes`] explains none of what they share, so the executor
|
||||
/// runs them in whatever order the graph around them happens to produce (B354, 2220). Same
|
||||
/// shape as `world_api_wall.rs`: the count may not rise past the ceiling, and the ceiling
|
||||
/// follows the count down. `WOW_AMBIGUITY_DUMP=1` prints every actionable pair with what
|
||||
/// it fights over.
|
||||
///
|
||||
/// **4,727 (decision 2283)** — the ten `drain_meeting_stone_joins` adds, every one of them
|
||||
/// against a sibling `.after(UiInput)` drain (`drain_queue_verbs`, `drain_latch_verbs`,
|
||||
/// `drain_battlefield`, `drain_talent_wipe`, `drain_party`, `drain_tabard`, the two chat-input
|
||||
/// drains, `idle_handler`, `movement_clears_afk`). What they share is the append-only
|
||||
/// chat/error sink and the outbound command channel — never a fact one reads and another
|
||||
/// writes — and each pair is two independent player verbs that a single click cannot both
|
||||
/// fire. Raised rather than ordered because a declared order here would assert a relationship
|
||||
/// between, say, leaving a meeting-stone queue and typing in chat that does not exist; the
|
||||
/// pair it *would* have been worth ordering, against the click that writes its message, is
|
||||
/// declared (`.after(TargetUpdate)`) and so is not in this list.
|
||||
/// **Measured 2026-09-17 (decision 2287), 662 systems:** 16,548 pairs in all, 12,849 of
|
||||
/// them explained — 10,703 on the VM alone, 431 on the other non-`Send` owners, 556 on
|
||||
/// the pure caches, 87 on the sinks, 41 on the stream, 1,031 on a mix of those — and
|
||||
/// 3,699 actionable. The largest part of those is `Transform` on disjoint lanes (716 pairs
|
||||
/// alone): populations that never intersect, which the filter algebra cannot see. A lane
|
||||
/// is not a class — the checker cannot tell a disjoint lane from two writers of the same
|
||||
/// entity — so they stay here, and each new one is a claim made at the registration with
|
||||
/// its reason:
|
||||
///
|
||||
/// **+4 (decision 2281)** — the same four pairs as above; all four are actionable-column, and
|
||||
/// the reason they are raised rather than ordered is the one stated there.
|
||||
/// - **2281:** the ranged prop's two `AnimationPlayer` writers against the booth's and the
|
||||
/// quest markers' (four lanes; the held weapon, a booth model, a marker).
|
||||
/// - **2282:** the three fx attaches against the mat-anim tick and its probe, over
|
||||
/// `MatAnimTable`/`UvAnimMaterials` — immaterial by construction, because attach writes
|
||||
/// the rows a tick would, off the same clock.
|
||||
///
|
||||
/// **+6 (decision 2282)** — all six of 2282's new pairs land here too (they fight over
|
||||
/// ordinary resources, not the VM), so both ceilings move by the same six; the reason is at
|
||||
/// [`UPDATE_CEILING`].
|
||||
const UPDATE_OTHER_CEILING: usize = 4_737;
|
||||
const UPDATE_OTHER_SLACK: usize = 50;
|
||||
/// **3,280 (decision 2288)** — the one query cache: a read that asks marks its miss through
|
||||
/// `&self`, so a feed that only resolves a name or a template holds the owner shared, and
|
||||
/// its pairs over `Items` (725 → 104, the largest non-class resource in the count) are gone;
|
||||
/// the pairs over the pure caches moved into the VM-only class, where the same systems still
|
||||
/// meet over the VM alone.
|
||||
///
|
||||
/// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with
|
||||
/// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is
|
||||
/// about a resource that commutes by construction, the claim belongs in [`Classes`].
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 3_280;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
eprintln!("{what}: {n} ambiguous pairs (ceiling {ceiling}, slack {slack})");
|
||||
@@ -758,7 +854,8 @@ pub(crate) mod schedule_tests {
|
||||
"{n} ambiguous pairs in {what}; the ceiling is {ceiling}. A new system runs in an \
|
||||
undeclared order against something it shares state with — declare the order \
|
||||
(`.after`, a set, a `chain`), or raise the ceiling here with the reason. \
|
||||
`WOW_AMBIGUITY_DUMP=1` on this test lists the pairs (decision 2279)."
|
||||
`WOW_AMBIGUITY_DUMP=1` on this test lists the pairs; a resource that commutes by \
|
||||
construction belongs in `Classes` instead (decisions 2279, 2287)."
|
||||
);
|
||||
assert!(
|
||||
n + slack >= ceiling,
|
||||
@@ -767,61 +864,49 @@ pub(crate) mod schedule_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The ratchet on the raw pair counts — name-free, so it runs in the player build too.
|
||||
/// The ratchet. Ids, not names, so it runs in the player build too; prints the class
|
||||
/// census beside the number it holds.
|
||||
#[test]
|
||||
fn the_schedules_have_no_more_undeclared_orders_than_the_ceilings_say() {
|
||||
let mut app = headless_client();
|
||||
let update = census(&mut app, Update);
|
||||
let post = census(&mut app, PostUpdate);
|
||||
ratchet(
|
||||
"Update",
|
||||
update.conflicts.len(),
|
||||
UPDATE_CEILING,
|
||||
UPDATE_SLACK,
|
||||
);
|
||||
ratchet(
|
||||
"PostUpdate",
|
||||
post.conflicts.len(),
|
||||
POST_UPDATE_CEILING,
|
||||
POST_UPDATE_SLACK,
|
||||
);
|
||||
}
|
||||
|
||||
/// The ratchet on the actionable class, and the dump. Needs type names
|
||||
/// ([`type_names_available`]).
|
||||
#[test]
|
||||
fn the_non_vm_ambiguities_do_not_grow() {
|
||||
let mut app = headless_client();
|
||||
let c = census(&mut app, Update);
|
||||
if !type_names_available(&c) {
|
||||
eprintln!(
|
||||
"skipped: this build carries no type names (bevy/debug rides with dev, 1451)"
|
||||
);
|
||||
return;
|
||||
let mut explained: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
let mut actionable = Vec::new();
|
||||
for (a, b, what) in &update.conflicts {
|
||||
match update.classes.class_of(what) {
|
||||
Some(class) => *explained.entry(class).or_default() += 1,
|
||||
None => actionable.push((*a, *b, what)),
|
||||
}
|
||||
}
|
||||
let vm = c.conflicts.iter().filter(|(_, _, w)| vm_only(w)).count();
|
||||
let audio = c
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|(_, _, w)| !vm_only(w) && vm_or_audio_only(w))
|
||||
.count();
|
||||
let other = c.conflicts.len() - vm - audio;
|
||||
eprintln!(
|
||||
"Update: {} systems, {} ambiguous pairs; {vm} VM-only; {audio} VM-or-audio-only; {other} other",
|
||||
c.systems.values().filter(|s| !is_sync_point(&s.name)).count(),
|
||||
c.conflicts.len(),
|
||||
"Update: {} systems, {} ambiguous pairs; {} explained ({}); {} actionable",
|
||||
update
|
||||
.systems
|
||||
.values()
|
||||
.filter(|s| !is_sync_point(&s.name))
|
||||
.count(),
|
||||
update.conflicts.len(),
|
||||
update.conflicts.len() - actionable.len(),
|
||||
explained
|
||||
.iter()
|
||||
.map(|(class, n)| format!("{n} {class}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
actionable.len(),
|
||||
);
|
||||
if std::env::var_os("WOW_AMBIGUITY_DUMP").is_some() {
|
||||
let mut rows: Vec<String> = c
|
||||
.conflicts
|
||||
let mut rows: Vec<String> = actionable
|
||||
.iter()
|
||||
.filter(|(_, _, w)| !vm_or_audio_only(w))
|
||||
.map(|(a, b, w)| {
|
||||
.map(|(a, b, what)| {
|
||||
format!(
|
||||
" {} <-> {}\n on {}",
|
||||
c.name(*a),
|
||||
c.name(*b),
|
||||
w.join(", ")
|
||||
update.name(*a),
|
||||
update.name(*b),
|
||||
what.iter()
|
||||
.map(|id| update.component(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -831,10 +916,16 @@ pub(crate) mod schedule_tests {
|
||||
}
|
||||
}
|
||||
ratchet(
|
||||
"Update (neither VM nor audio)",
|
||||
other,
|
||||
UPDATE_OTHER_CEILING,
|
||||
UPDATE_OTHER_SLACK,
|
||||
"Update (actionable)",
|
||||
actionable.len(),
|
||||
UPDATE_ACTIONABLE_CEILING,
|
||||
UPDATE_ACTIONABLE_SLACK,
|
||||
);
|
||||
ratchet(
|
||||
"PostUpdate",
|
||||
post.conflicts.len(),
|
||||
POST_UPDATE_CEILING,
|
||||
POST_UPDATE_SLACK,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -851,7 +942,7 @@ pub(crate) mod schedule_tests {
|
||||
/// is its floor. Returned as `(waves, critical_path)`.
|
||||
fn waves(
|
||||
c: &Census,
|
||||
keep_conflict: impl Fn(&[String]) -> bool,
|
||||
keep_conflict: impl Fn(&[ComponentId]) -> bool,
|
||||
non_send: &[SystemKey],
|
||||
) -> (usize, usize) {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -947,28 +1038,26 @@ pub(crate) mod schedule_tests {
|
||||
fn concurrency_census() {
|
||||
let mut app = headless_client();
|
||||
let c = census(&mut app, Update);
|
||||
if !type_names_available(&c) {
|
||||
eprintln!(
|
||||
"skipped: this build carries no type names (bevy/debug rides with dev, 1451)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let touches = |k: SystemKey, needle: &str| {
|
||||
let holds = |k: SystemKey, pick: &dyn Fn(ComponentId) -> bool| {
|
||||
c.conflicts
|
||||
.iter()
|
||||
.any(|(a, b, w)| (*a == k || *b == k) && w.iter().any(|x| x.contains(needle)))
|
||||
.any(|(a, b, w)| (*a == k || *b == k) && w.iter().any(|id| pick(*id)))
|
||||
};
|
||||
let vm: Vec<SystemKey> = c
|
||||
.non_send
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|k| touches(*k, "UiScript"))
|
||||
.filter(|k| holds(*k, &|id| Some(id) == c.classes.vm))
|
||||
.collect();
|
||||
let audio: Vec<SystemKey> = c
|
||||
.non_send
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|k| touches(*k, "benilla_app::sound::"))
|
||||
.filter(|k| {
|
||||
holds(*k, &|id| {
|
||||
c.classes.non_send.contains(&id) && Some(id) != c.classes.vm
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let other_non_send: Vec<SystemKey> = c
|
||||
.non_send
|
||||
@@ -1008,9 +1097,9 @@ pub(crate) mod schedule_tests {
|
||||
.copied()
|
||||
.filter(|k| !vm.contains(k) || audio.contains(k))
|
||||
.collect();
|
||||
let (w1, _) = waves(&c, |w| !vm_only(w), ¬_vm);
|
||||
let (w1, _) = waves(&c, |w| !c.classes.vm_only(w), ¬_vm);
|
||||
eprintln!(" VM Send-owned: {w1} waves");
|
||||
let (w2, _) = waves(&c, |w| !vm_or_audio_only(w), &other_non_send);
|
||||
let (w2, _) = waves(&c, |w| !c.classes.non_send_only(w), &other_non_send);
|
||||
eprintln!(" VM + audio Send-owned: {w2} waves");
|
||||
let (w3, _) = waves(&c, |_| false, &[]);
|
||||
eprintln!(" declared edges only: {w3} waves (every conflict ordered, everything Send)");
|
||||
|
||||
@@ -7,13 +7,12 @@
|
||||
//! from [`crate::net::apply`]'s `ObjectCreate`, the same discipline as the name cache), the answer
|
||||
//! resolved in [`GameObjectTemplates::insert`], and read by the interact routing ([`crate::target`]).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use benilla_formats::{LockCatalog, LockTypeCatalog};
|
||||
use benilla_protocol::guid;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::query_cache::QueryCache;
|
||||
|
||||
/// `Lock.dbc` as a resource (decision 0239) — `lockId → requirement slots`, read by the interact
|
||||
/// routing to decide use-vs-cast and, for a lockable object, which `LockType` the opener spell must
|
||||
@@ -120,31 +119,34 @@ pub(crate) struct TextPage {
|
||||
/// `entry → template`, ask-once per connection (mirrors [`crate::names::NameCache`]'s discipline).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct GameObjectTemplates {
|
||||
templates: HashMap<u32, GoTemplate>,
|
||||
pending: HashSet<u32>,
|
||||
templates: QueryCache<u32, GoTemplate>,
|
||||
}
|
||||
|
||||
impl crate::query_cache::AskOnce for GameObjectTemplates {
|
||||
fn clear_pending(&mut self) {
|
||||
self.templates.clear_pending();
|
||||
}
|
||||
}
|
||||
|
||||
impl GameObjectTemplates {
|
||||
/// Ask the server for a GameObject's template if not already known or in flight (once per entry).
|
||||
/// `guid` names the asking object; the server answers by entry, so all spawns of a template share
|
||||
/// the one query.
|
||||
pub(crate) fn request(&mut self, guid: u64, commands: &NetCommands) {
|
||||
pub(crate) fn request(&self, guid: u64, commands: &NetCommands) {
|
||||
let Some(entry) = guid::entry(guid) else {
|
||||
return;
|
||||
};
|
||||
if self.templates.contains_key(&entry) || !self.pending.insert(entry) {
|
||||
return;
|
||||
}
|
||||
debug!("go: asking template (entry {entry}, guid {guid:#x})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::GameObjectQuery { entry, guid });
|
||||
self.templates.get_or_ask(entry, || {
|
||||
debug!("go: asking template (entry {entry}, guid {guid:#x})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::GameObjectQuery { entry, guid });
|
||||
});
|
||||
}
|
||||
|
||||
/// Record a `SMSG_GAMEOBJECT_QUERY_RESPONSE`, resolving the lockId from the type-specific
|
||||
/// `data[]` slot. A miss (server didn't know the entry) arrives zeroed → `lock_id = 0` (no lock).
|
||||
pub(crate) fn insert(&mut self, entry: u32, type_id: u32, name: String, data: &[i32; 24]) {
|
||||
self.pending.remove(&entry);
|
||||
let lock_id = go_lock_slot(type_id)
|
||||
.and_then(|slot| data.get(slot))
|
||||
.map(|&v| v.max(0) as u32)
|
||||
@@ -189,7 +191,7 @@ impl GameObjectTemplates {
|
||||
};
|
||||
self.templates.insert(
|
||||
entry,
|
||||
GoTemplate {
|
||||
Some(GoTemplate {
|
||||
lock_id,
|
||||
name,
|
||||
highlight_column,
|
||||
@@ -198,13 +200,13 @@ impl GameObjectTemplates {
|
||||
mo_transport,
|
||||
text_page,
|
||||
quest_material,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// The cached template for a GameObject guid, or `None` if its query hasn't answered yet.
|
||||
pub(crate) fn get(&self, guid: u64) -> Option<&GoTemplate> {
|
||||
guid::entry(guid).and_then(|e| self.templates.get(&e))
|
||||
guid::entry(guid).and_then(|e| self.templates.get(e))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,14 +253,14 @@ mod tests {
|
||||
t.insert(101, 9, "Book".into(), &data);
|
||||
t.insert(102, 10, "Chest".into(), &data);
|
||||
t.insert(103, 15, "Boat".into(), &data);
|
||||
assert_eq!(t.templates[&100].quest_material, Some(2));
|
||||
assert_eq!(t.templates[&101].quest_material, Some(2));
|
||||
assert_eq!(t.templates.get(100).unwrap().quest_material, Some(2));
|
||||
assert_eq!(t.templates.get(101).unwrap().quest_material, Some(2));
|
||||
assert_eq!(
|
||||
t.templates[&102].quest_material,
|
||||
t.templates.get(102).unwrap().quest_material,
|
||||
Some(3),
|
||||
"a chest reads data[9]"
|
||||
);
|
||||
assert_eq!(t.templates[&103].quest_material, None);
|
||||
assert_eq!(t.templates.get(103).unwrap().quest_material, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -271,13 +273,18 @@ mod tests {
|
||||
data[1] = 30;
|
||||
data[2] = 1;
|
||||
t.insert(176231, 15, "Proudmore's Treasure".into(), &data);
|
||||
let mo = t.templates[&176231].mo_transport.expect("type 15 captures");
|
||||
let mo = t
|
||||
.templates
|
||||
.get(176231)
|
||||
.unwrap()
|
||||
.mo_transport
|
||||
.expect("type 15 captures");
|
||||
assert_eq!(mo.taxi_path_id, 292);
|
||||
assert_eq!(mo.move_speed, 30.0);
|
||||
assert_eq!(mo.accel_rate, 1.0);
|
||||
// A chest doesn't.
|
||||
t.insert(2, 3, "Chest".into(), &data);
|
||||
assert!(t.templates[&2].mo_transport.is_none());
|
||||
assert!(t.templates.get(2).unwrap().mo_transport.is_none());
|
||||
}
|
||||
|
||||
/// TEXT (9) captures the readable head — `data[0]` page id, `data[2]` material (decision
|
||||
@@ -290,12 +297,17 @@ mod tests {
|
||||
data[1] = 0; // language
|
||||
data[2] = 2; // pageMaterial (Stone)
|
||||
t.insert(2036, 9, "Book".into(), &data);
|
||||
let page = t.templates[&2036].text_page.expect("type 9 captures");
|
||||
let page = t
|
||||
.templates
|
||||
.get(2036)
|
||||
.unwrap()
|
||||
.text_page
|
||||
.expect("type 9 captures");
|
||||
assert_eq!(page.page_id, 1416);
|
||||
assert_eq!(page.material, 2);
|
||||
// A goober with the same bytes does not — its data[0] is a lockId.
|
||||
t.insert(2037, 10, "Lever".into(), &data);
|
||||
assert!(t.templates[&2037].text_page.is_none());
|
||||
assert!(t.templates.get(2037).unwrap().text_page.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -336,11 +348,11 @@ mod tests {
|
||||
t.insert(1630, 5, "Brill".into(), &brill);
|
||||
t.insert(175656, 5, "Doodad_WoodSignPointerNice10".into(), &pointer);
|
||||
|
||||
let brill = t.templates.get(&1630).expect("Brill cached");
|
||||
let brill = t.templates.get(1630).expect("Brill cached");
|
||||
assert!(brill.highlight_column, "Brill is hoverable");
|
||||
assert!(brill.floating_tooltip, "Brill's plate follows the cursor");
|
||||
|
||||
let pointer = t.templates.get(&175656).expect("the pointer sign cached");
|
||||
let pointer = t.templates.get(175656).expect("the pointer sign cached");
|
||||
assert!(
|
||||
pointer.highlight_column,
|
||||
"the pointer sign is hoverable too"
|
||||
@@ -363,7 +375,7 @@ mod tests {
|
||||
t.insert(9000 + type_id, type_id, format!("type {type_id}"), &data);
|
||||
assert!(
|
||||
!t.templates
|
||||
.get(&(9000 + type_id))
|
||||
.get(9000 + type_id)
|
||||
.expect("cached")
|
||||
.floating_tooltip,
|
||||
"type {type_id} must not take the cursor arm — it carries no semantic 0x13"
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
//! reports "not yet". Negative answers are cached — a bad entry never becomes a query loop.
|
||||
//! Templates survive disconnect: item definitions are stable across sessions.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::{ItemInfo, ObjectFields};
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore};
|
||||
use crate::query_cache::QueryCache;
|
||||
|
||||
/// The slice of an item template that equipment rendering + combat animation consume (decisions
|
||||
/// 0072/0073): the ItemDisplayInfo key, the two placement inputs, and the weapon class pair the
|
||||
@@ -291,28 +292,19 @@ impl RollCatalogs<'_> {
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Items {
|
||||
objects: HashMap<u64, ObjectFields>,
|
||||
/// `None` = the server flagged the entry unknown (the top-bit miss branch) — cached negative.
|
||||
templates: HashMap<u32, Option<ItemInfo>>,
|
||||
pending: HashSet<u32>,
|
||||
/// The template cache — ask-once through [`QueryCache`] (decision 2288); a `None` answer is
|
||||
/// the server's "unknown entry" (the top-bit miss branch), cached so it is never re-asked.
|
||||
templates: QueryCache<u32, ItemInfo>,
|
||||
/// Entries whose template landed since the last [`Self::take_fresh`] drain — the push half of
|
||||
/// the tooltip store (every landed template goes to the UI unprompted, so the first hover of
|
||||
/// an item whose name is already on screen never misses).
|
||||
fresh: Vec<u32>,
|
||||
/// Bumped by every landed answer ([`Self::insert_template`]), positive or negative. The
|
||||
/// **broadcast** twin of [`Self::fresh`]: `fresh` is a DRAIN (exactly one consumer can take
|
||||
/// it — the tooltip feed does), so a second consumer that caches a template-derived view
|
||||
/// needs its own signal. A consumer keeps the epoch it last resolved at and re-resolves when
|
||||
/// it advances — the modern stand-in for the ref's `DBCACHECALLBACK` redisplay (`0x6e29b0`),
|
||||
/// which is how the real client repaints a view that was drawn while the item cache was still
|
||||
/// answering. Any cache that gates a template read behind a change-flag needs this: the read
|
||||
/// itself is what ISSUES the ask-once query, so the first resolve of a cold entry ALWAYS
|
||||
/// misses (decision 0660).
|
||||
template_epoch: u64,
|
||||
/// [`Self::template_epoch`]'s twin for the INSTANCE side (decision 1439): bumped by every
|
||||
/// object create/merge/destroy and every enchant-deadline write — everything wire-driven
|
||||
/// that can change what a bag/equipment view reads. The pair exists because `is_changed` on
|
||||
/// this resource says nothing: the feeds' own lazy `template()` resolves take `&mut self`
|
||||
/// every frame, so the resource reads as changed even when nothing landed.
|
||||
/// that can change what a bag/equipment view reads. The pair was born because `is_changed`
|
||||
/// on this resource said nothing while the feeds' lazy `template()` resolves took `&mut
|
||||
/// self` every frame; 2288 made those reads `&self`, and the counters stay as the finer
|
||||
/// signal (a view keyed on one side does not rebuild for the other).
|
||||
object_epoch: u64,
|
||||
/// The **temporary-enchant deadlines**, keyed `(item guid, enchant slot)` — the reference's
|
||||
/// per-item `[obj + slot*4 + 0x324]` array (wow-re `tooltip-content-law.md` §E3), whose only
|
||||
@@ -514,19 +506,15 @@ impl Items {
|
||||
/// template-only) and returns `None` — call again after the answer lands. A cached negative
|
||||
/// (server doesn't know the entry) is also `None`, without a re-ask.
|
||||
pub(crate) fn template(
|
||||
&mut self,
|
||||
&self,
|
||||
entry: u32,
|
||||
guid: u64,
|
||||
commands: &NetCommands,
|
||||
) -> Option<&ItemInfo> {
|
||||
if !self.templates.contains_key(&entry) {
|
||||
if self.pending.insert(entry) {
|
||||
debug!("items: asking template (entry {entry})");
|
||||
let _ = commands.0.send(ClientCommand::ItemQuery { entry, guid });
|
||||
}
|
||||
return None;
|
||||
}
|
||||
self.templates.get(&entry).and_then(|t| t.as_ref())
|
||||
self.templates.get_or_ask(entry, || {
|
||||
debug!("items: asking template (entry {entry})");
|
||||
let _ = commands.0.send(ClientCommand::ItemQuery { entry, guid });
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the server has ANSWERED the `entry` query with "unknown item" — the cached
|
||||
@@ -535,13 +523,13 @@ impl Items {
|
||||
/// answer (the ref's `DBCACHECALLBACK` redisplay), negative → give up and show the ref's
|
||||
/// `"UNKNOWN"` fallback instead of waiting forever.
|
||||
pub(crate) fn template_answered_unknown(&self, entry: u32) -> bool {
|
||||
self.templates.get(&entry).is_some_and(|t| t.is_none())
|
||||
self.templates.answered_unknown(entry)
|
||||
}
|
||||
|
||||
/// The held/worn display head for `entry` — the [`HeldTemplate`] view of [`Self::template`]
|
||||
/// (same ask-once discipline, template-only ask). Equipment rendering + the swing selector
|
||||
/// consume this Copy slice instead of borrowing the full info.
|
||||
pub(crate) fn held(&mut self, entry: u32, commands: &NetCommands) -> Option<HeldTemplate> {
|
||||
pub(crate) fn held(&self, entry: u32, commands: &NetCommands) -> Option<HeldTemplate> {
|
||||
self.template(entry, 0, commands).map(|i| HeldTemplate {
|
||||
display_info_id: i.display_info_id,
|
||||
inventory_type: i.inventory_type,
|
||||
@@ -557,27 +545,29 @@ impl Items {
|
||||
/// rung runs inside a `&Items` borrow, and by the time a button is pressed the greying feed
|
||||
/// that shares its search has had the template for many frames.
|
||||
pub(crate) fn template_cached(&self, entry: u32) -> Option<&ItemInfo> {
|
||||
self.templates.get(&entry)?.as_ref()
|
||||
self.templates.get(entry)
|
||||
}
|
||||
|
||||
/// Record a template answer (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`); `None` = unknown entry.
|
||||
pub(crate) fn insert_template(&mut self, entry: u32, info: Option<ItemInfo>) {
|
||||
self.pending.remove(&entry);
|
||||
if info.is_some() {
|
||||
self.fresh.push(entry);
|
||||
}
|
||||
// A NEGATIVE answer moves the generation too: it flips the entry from "still asking" to
|
||||
// "answered unknown", which is a real display transition for anything that waits on the
|
||||
// ask (the cast-fail redisplay's `"UNKNOWN"` literal, [`Self::template_answered_unknown`]).
|
||||
self.templates.insert(entry, info);
|
||||
// A NEGATIVE answer bumps too: it flips the entry from "still asking" to "answered
|
||||
// unknown", which is a real display transition for anything that waits on the ask (the
|
||||
// cast-fail redisplay's `"UNKNOWN"` literal, [`Self::template_answered_unknown`]).
|
||||
self.template_epoch = self.template_epoch.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The landed-template broadcast counter — see the [`Self::template_epoch`] field. Advances on
|
||||
/// every answer; a consumer that caches a template-derived view compares it against the value
|
||||
/// it last resolved at and re-resolves when they differ.
|
||||
/// The landed-template broadcast counter — the cache's own generation, bumped by every
|
||||
/// landed answer, positive or negative. The **broadcast** twin of [`Self::fresh`]: `fresh`
|
||||
/// is a DRAIN (exactly one consumer can take it — the tooltip feed does), so a second
|
||||
/// consumer that caches a template-derived view needs its own signal; it keeps the epoch it
|
||||
/// last resolved at and re-resolves when it advances — the modern stand-in for the ref's
|
||||
/// `DBCACHECALLBACK` redisplay (`0x6e29b0`), which is how the real client repaints a view
|
||||
/// drawn while the item cache was still answering (decision 0660).
|
||||
pub(crate) fn template_epoch(&self) -> u64 {
|
||||
self.template_epoch
|
||||
self.templates.generation()
|
||||
}
|
||||
|
||||
/// Drain the entries whose template landed since the last drain (see the `fresh` field).
|
||||
@@ -609,17 +599,23 @@ impl Items {
|
||||
/// cost of a wrong latch (a feature dead all session, in silence) is nothing like the cost of
|
||||
/// a redundant re-ask.
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
self.pending.clear();
|
||||
self.templates.clear_pending();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
self.objects.clear();
|
||||
self.enchant_deadlines.clear();
|
||||
self.pending.clear();
|
||||
self.templates.clear_pending();
|
||||
self.object_epoch = self.object_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::query_cache::AskOnce for Items {
|
||||
fn clear_pending(&mut self) {
|
||||
Items::clear_pending(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ask-once trio every UI resolver needs to be tested: the template cache, the command
|
||||
/// channel, and — crucially — the channel's live **receiver**, without which every `ItemQuery`
|
||||
/// send fails and the "asked the server" half of the law goes unobservable. `items` starts empty,
|
||||
@@ -668,7 +664,7 @@ pub(crate) const EQUIPMENT_SLOT_OFFHAND: u8 = 16;
|
||||
/// slot or a template still in flight.
|
||||
fn equipped_class(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
slot: u8,
|
||||
) -> Option<u8> {
|
||||
@@ -689,7 +685,7 @@ fn equipped_class(
|
||||
/// was once missing from all three of its call sites.
|
||||
pub(crate) fn disarmed_equipment_slot(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> Option<u8> {
|
||||
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
|
||||
|
||||
@@ -100,6 +100,7 @@ mod portrait;
|
||||
mod preflight;
|
||||
#[cfg(feature = "dev")]
|
||||
mod probe_shield;
|
||||
mod query_cache;
|
||||
mod quest_markers;
|
||||
mod raid_marks;
|
||||
mod ranged_flex;
|
||||
|
||||
@@ -565,7 +565,7 @@ pub(super) fn emit_party_arrows(
|
||||
pub(super) fn drive_blip_tooltip(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
hover: Res<MinimapBlipHover>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<(String, Vec2)>>>,
|
||||
) {
|
||||
|
||||
@@ -414,7 +414,7 @@ pub(crate) fn drive_nameplates(
|
||||
// The UnitName* cvar mask (0992) — the kind gates below read it.
|
||||
Res<NameConfig>,
|
||||
),
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
// The guild-identity cache (1257) — the a5 line's text, and the lazy `CMSG_GUILD_QUERY` a
|
||||
// miss sends. `ResMut` because the read IS the ask ([`crate::ui_guild::unit_guild_name`]).
|
||||
mut guilds: ResMut<crate::ui_guild::GuildState>,
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
//! benilla's own addition, and it is what answered a wiped server's brand-new character with a
|
||||
//! deleted one's name (report B386, decision 2223).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::guid;
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore};
|
||||
use crate::query_cache::QueryCache;
|
||||
|
||||
/// The creature template's **`type_flags`** word, bit by bit — the dword the server ships in
|
||||
/// `SMSG_CREATURE_QUERY_RESPONSE` and the reference caches at `[CGUnit+0xb30] + 0x14` (its async
|
||||
@@ -76,7 +77,7 @@ pub(crate) mod type_flags {
|
||||
pub(crate) struct NameCache {
|
||||
/// Player names by guid. `Some(None)`-shaped answers are stored as `None`: the server was asked
|
||||
/// and didn't know (an empty wire name) — cached so we never re-ask a dead guid.
|
||||
players: HashMap<u64, Option<String>>,
|
||||
players: QueryCache<u64, String>,
|
||||
/// `(race, class, gender)` from the same `SMSG_NAME_QUERY_RESPONSE` that carried the name — the
|
||||
/// wire has always sent these three and we used to drop them. Two consumers now: the
|
||||
/// `$`-macro expander's non-player-subject path: the reference resolves a macro subject from
|
||||
@@ -92,19 +93,17 @@ pub(crate) struct NameCache {
|
||||
/// unknown. The subname is the overhead/tooltip title line ("Stable Master", …); the type is
|
||||
/// the `CreatureType.dbc` id the TAB-target critter filter reads; rank/civilian feed the
|
||||
/// unit tooltip's level-line word + CIVILIAN line (decision 0276).
|
||||
creatures: HashMap<u32, Option<CreatureRecord>>,
|
||||
creatures: QueryCache<u32, CreatureRecord>,
|
||||
/// Pet names by **pet number** — the third naming path (see [`Self::resolve`]'s pet branch).
|
||||
/// No negative entry exists: the server answers for a live pet or says nothing at all, so an
|
||||
/// unanswered ask stays unanswered, exactly as it does for the real client.
|
||||
pets: HashMap<u32, String>,
|
||||
pending_players: HashSet<u64>,
|
||||
pending_creatures: HashSet<u32>,
|
||||
pending_pets: HashSet<u32>,
|
||||
pets: QueryCache<u32, String>,
|
||||
/// Bumped by every **landed** answer (and the pet-rename eviction) — never by an ask. The
|
||||
/// gated feeds' watch counter (decision 1439): a feed that resolved a miss re-runs when the
|
||||
/// answer lands, and only then. `is_changed` cannot say this — the resolves themselves take
|
||||
/// `&mut self` every frame (the ask-once marker), so the resource reads as changed whenever
|
||||
/// anything is still pending.
|
||||
/// answer lands, and only then. Born because `is_changed` could not say this while the
|
||||
/// resolves took `&mut self` every frame; 2288 made them `&self` (the in-flight marks live
|
||||
/// behind [`QueryCache`]'s lock), and the counter stays as the one number the three caches
|
||||
/// share.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
@@ -177,16 +176,14 @@ impl NameCache {
|
||||
/// connection) and returns `None` — call again after the answer lands. A guid family that has no
|
||||
/// name on the 1.12 wire (GameObjects resolve via their own query, not modeled yet) is `None`
|
||||
/// without a query.
|
||||
pub(crate) fn resolve(&mut self, guid_val: u64, commands: &NetCommands) -> Option<&str> {
|
||||
pub(crate) fn resolve(&self, guid_val: u64, commands: &NetCommands) -> Option<&str> {
|
||||
if guid::is_player(guid_val) {
|
||||
if !self.players.contains_key(&guid_val) {
|
||||
if self.pending_players.insert(guid_val) {
|
||||
self.players
|
||||
.get_or_ask(guid_val, || {
|
||||
debug!("names: asking player name (guid {guid_val})");
|
||||
let _ = commands.0.send(ClientCommand::NameQuery { guid: guid_val });
|
||||
}
|
||||
return None;
|
||||
}
|
||||
self.players.get(&guid_val).and_then(|n| n.as_deref())
|
||||
})
|
||||
.map(String::as_str)
|
||||
} else if let Some(pet_number) = guid::pet_number(guid_val) {
|
||||
// A pet is a `TYPEID_UNIT` like any creature, but it carries a pet number where a
|
||||
// creature carries its template entry, so it has its own query. Asking the creature
|
||||
@@ -205,17 +202,15 @@ impl NameCache {
|
||||
/// answer**: vmangos returns early without a packet when the guid is not a live pet bearing that
|
||||
/// number (`PetHandler.cpp:190-192`), so an unanswered ask simply stays unresolved rather than
|
||||
/// caching a "server doesn't know" — and is re-asked after a reconnect like the others.
|
||||
fn resolve_pet(&mut self, pet_number: u32, guid: u64, commands: &NetCommands) -> Option<&str> {
|
||||
if !self.pets.contains_key(&pet_number) {
|
||||
if self.pending_pets.insert(pet_number) {
|
||||
fn resolve_pet(&self, pet_number: u32, guid: u64, commands: &NetCommands) -> Option<&str> {
|
||||
self.pets
|
||||
.get_or_ask(pet_number, || {
|
||||
debug!("names: asking pet name (pet {pet_number})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::PetNameQuery { pet_number, guid });
|
||||
}
|
||||
return None;
|
||||
}
|
||||
self.pets.get(&pet_number).map(String::as_str)
|
||||
})
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// The name for a creature template `entry`, if known — the entry-keyed twin of
|
||||
@@ -226,36 +221,32 @@ impl NameCache {
|
||||
/// regardless of which spawn asked (the same template-only convention as
|
||||
/// [`crate::items::Items::template`]'s `guid: 0`).
|
||||
pub(crate) fn resolve_creature(
|
||||
&mut self,
|
||||
&self,
|
||||
entry: u32,
|
||||
guid: u64,
|
||||
commands: &NetCommands,
|
||||
) -> Option<&str> {
|
||||
if !self.creatures.contains_key(&entry) {
|
||||
if self.pending_creatures.insert(entry) {
|
||||
self.creatures
|
||||
.get_or_ask(entry, || {
|
||||
debug!("names: asking creature name (entry {entry})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::CreatureQuery { entry, guid });
|
||||
}
|
||||
return None;
|
||||
}
|
||||
self.creatures
|
||||
.get(&entry)
|
||||
.and_then(|n| n.as_ref().map(|r| r.name.as_str()))
|
||||
})
|
||||
.map(|r| r.name.as_str())
|
||||
}
|
||||
|
||||
/// The cached name for `guid`, read-only — no query on a miss (the trace/diagnostic twin of
|
||||
/// [`Self::resolve`], for callers that must not mutate the ask-once state).
|
||||
pub(crate) fn peek(&self, guid_val: u64) -> Option<&str> {
|
||||
if guid::is_player(guid_val) {
|
||||
self.players.get(&guid_val).and_then(|n| n.as_deref())
|
||||
self.players.get(guid_val).map(String::as_str)
|
||||
} else if let Some(pet_number) = guid::pet_number(guid_val) {
|
||||
self.pets.get(&pet_number).map(String::as_str)
|
||||
self.pets.get(pet_number).map(String::as_str)
|
||||
} else if guid::is_creature_or_pet(guid_val) {
|
||||
self.creatures
|
||||
.get(&guid::entry(guid_val)?)
|
||||
.and_then(|n| n.as_ref().map(|r| r.name.as_str()))
|
||||
.get(guid::entry(guid_val)?)
|
||||
.map(|r| r.name.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -274,7 +265,6 @@ impl NameCache {
|
||||
/// replacing that name. Keeping the old one would leave a record whose name says one character
|
||||
/// and whose race says another — a contradiction manufactured by the cache itself.
|
||||
pub(crate) fn insert_player(&mut self, guid: u64, name: String, traits: Option<(u8, u8, u8)>) {
|
||||
self.pending_players.remove(&guid);
|
||||
match traits.filter(|_| !name.is_empty()) {
|
||||
Some(t) => self.player_traits.insert(guid, t),
|
||||
None => self.player_traits.remove(&guid),
|
||||
@@ -307,8 +297,6 @@ impl NameCache {
|
||||
self.players.clear();
|
||||
self.player_traits.clear();
|
||||
self.pets.clear();
|
||||
self.pending_players.clear();
|
||||
self.pending_pets.clear();
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
@@ -355,8 +343,7 @@ impl NameCache {
|
||||
|
||||
/// Record a pet-name answer (`SMSG_PET_NAME_QUERY_RESPONSE`), keyed by pet number.
|
||||
pub(crate) fn insert_pet(&mut self, pet_number: u32, name: String) {
|
||||
self.pending_pets.remove(&pet_number);
|
||||
self.pets.insert(pet_number, name);
|
||||
self.pets.insert(pet_number, Some(name));
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
@@ -370,14 +357,12 @@ impl NameCache {
|
||||
/// rename racing an in-flight query would otherwise have its re-ask deduped away and leave the
|
||||
/// stale name in place forever.
|
||||
pub(crate) fn forget_pet(&mut self, pet_number: u32) {
|
||||
self.pending_pets.remove(&pet_number);
|
||||
self.pets.remove(&pet_number);
|
||||
self.pets.evict(pet_number);
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Record a creature-name answer (`SMSG_CREATURE_QUERY_RESPONSE`); `None` = unknown entry.
|
||||
pub(crate) fn insert_creature(&mut self, entry: u32, record: Option<CreatureRecord>) {
|
||||
self.pending_creatures.remove(&entry);
|
||||
self.creatures.insert(entry, record);
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
@@ -385,10 +370,7 @@ impl NameCache {
|
||||
/// The cached subname (the overhead/tooltip title line) for a creature entry — read-only: the
|
||||
/// nameplate asks only after [`Self::resolve`] already returned the name (same answer packet).
|
||||
pub(crate) fn creature_subname(&self, entry: u32) -> Option<&str> {
|
||||
self.creatures
|
||||
.get(&entry)?
|
||||
.as_ref()
|
||||
.and_then(|r| r.subname.as_deref())
|
||||
self.creatures.get(entry)?.subname.as_deref()
|
||||
}
|
||||
|
||||
/// The whole cached record for a creature entry — the unit tooltip's read (subtitle, type,
|
||||
@@ -406,17 +388,14 @@ impl NameCache {
|
||||
/// fails CLOSED like `0x623b70`'s null leg, the wound gate (`DO_NOT_PLAY_WOUND_ANIM`, 2068)
|
||||
/// fails OPEN like `0x6125f0`'s.
|
||||
pub(crate) fn creature_record(&self, entry: u32) -> Option<&CreatureRecord> {
|
||||
self.creatures.get(&entry)?.as_ref()
|
||||
self.creatures.get(entry)
|
||||
}
|
||||
|
||||
/// The cached `CreatureType.dbc` id for a creature entry — read-only, same ask-once discipline
|
||||
/// as the subname (the TAB-target scan reads it; an unresolved entry is `None`, which the scan
|
||||
/// treats as targetable — the client's own out-of-range skip).
|
||||
pub(crate) fn creature_type(&self, entry: u32) -> Option<u32> {
|
||||
self.creatures
|
||||
.get(&entry)?
|
||||
.as_ref()
|
||||
.map(|r| r.creature_type)
|
||||
Some(self.creatures.get(entry)?.creature_type)
|
||||
}
|
||||
|
||||
/// Forget the in-flight asks (a disconnect may have dropped them on the writer floor). Resolved
|
||||
@@ -424,13 +403,18 @@ impl NameCache {
|
||||
/// names do not: a pet number names one live spawn rather than a template, and nothing that
|
||||
/// answered before the disconnect is still on the map after it.
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
self.pending_players.clear();
|
||||
self.pending_creatures.clear();
|
||||
self.pending_pets.clear();
|
||||
self.players.clear_pending();
|
||||
self.creatures.clear_pending();
|
||||
self.pets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::query_cache::AskOnce for NameCache {
|
||||
fn clear_pending(&mut self) {
|
||||
NameCache::clear_pending(self);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Persistence — the `.wdb` law, in benilla's own folder (decision 1689)
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -472,7 +456,7 @@ impl NameCache {
|
||||
pub(crate) fn to_tsv(&self, realm: &str) -> String {
|
||||
let mut out =
|
||||
format!("{CACHE_MAGIC}\t{CACHE_FORMAT}\t{CACHE_BUILD}\t{CACHE_LOCALE}\t{realm}\n");
|
||||
for (entry, rec) in &self.creatures {
|
||||
for (entry, rec) in self.creatures.iter() {
|
||||
match rec {
|
||||
Some(r) => out.push_str(&format!(
|
||||
"C\t{entry}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
|
||||
@@ -561,9 +545,8 @@ impl NameCache {
|
||||
/// that vmangos never sends it — so against our own server this is the mechanism being present
|
||||
/// and correct rather than a path we exercise.
|
||||
pub(crate) fn invalidate_player(&mut self, guid: u64) {
|
||||
self.players.remove(&guid);
|
||||
self.players.evict(guid);
|
||||
self.player_traits.remove(&guid);
|
||||
self.pending_players.remove(&guid);
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
@@ -664,7 +647,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_stable_list_names_the_pet_before_it_is_ever_summoned() {
|
||||
let (cmds, rx) = commands();
|
||||
let mut cache = NameCache::default();
|
||||
let cache = NameCache::default();
|
||||
// The guid the pet will have once summoned: its pet number rides the ENTRY slot.
|
||||
let rex = compose(guid::HIGH_PET, 7, 42);
|
||||
|
||||
@@ -716,7 +699,7 @@ mod tests {
|
||||
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header matches");
|
||||
|
||||
// A cached negative must survive as a negative — present-and-None, not absent.
|
||||
assert!(back.creatures.contains_key(&1234));
|
||||
assert!(back.creatures.answered(1234));
|
||||
let rec = back.creature_record(69).expect("creature survived");
|
||||
assert_eq!(rec.name, "Stable Master Kitrik");
|
||||
assert_eq!(rec.subname.as_deref(), Some("Stable Master"));
|
||||
@@ -808,7 +791,7 @@ mod tests {
|
||||
assert_eq!(cache.peek(0x11), None);
|
||||
assert_eq!(cache.player_traits(0x11), None);
|
||||
assert!(
|
||||
cache.creatures.contains_key(&69),
|
||||
cache.creatures.answered(69),
|
||||
"templates outlive the world session"
|
||||
);
|
||||
// And the ask-once markers went with them, so the next sighting asks rather than sitting
|
||||
|
||||
@@ -74,30 +74,14 @@ pub(crate) struct NetPlugin {
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct NetOffline;
|
||||
|
||||
/// Release the ask-once latches at world enter — the counterpart to the disconnect teardown in
|
||||
/// `apply::session`, which clears them when a socket DIES but never when one is born.
|
||||
///
|
||||
/// The asymmetry was load-bearing and wrong. `Items::template` (and `NameCache`'s resolvers) mark
|
||||
/// an id pending *before* sending the query, and a command sent while the io thread holds no
|
||||
/// writer evaporates with a warn — so an ask made before the first connect latched the id for the
|
||||
/// life of the process. On 2026-09-06 that emptied the mail send tab's stationery list for an
|
||||
/// entire session, in silence. The ask site that did it is fixed; this makes the class harmless,
|
||||
/// because a redundant re-ask costs one packet and a wrong latch costs a dead feature nobody can
|
||||
/// see is dead.
|
||||
fn release_ask_once_latches_on_enter(
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
mut items: ResMut<crate::items::Items>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
) {
|
||||
if entered.read().count() == 0 {
|
||||
return;
|
||||
}
|
||||
items.clear_pending();
|
||||
names.clear_pending();
|
||||
}
|
||||
|
||||
impl Plugin for NetPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// The one release-on-enter for the ask-once caches this plugin owns (decision 2288;
|
||||
// the story of the latch that emptied the mail send tab's stationery for a session is
|
||||
// on `query_cache::register`).
|
||||
crate::query_cache::register::<crate::names::NameCache>(app);
|
||||
crate::query_cache::register::<crate::go_templates::GameObjectTemplates>(app);
|
||||
crate::query_cache::register::<crate::items::Items>(app);
|
||||
let handles = io::spawn_net(io::NetConfig::from_env(), self.connect);
|
||||
if !self.connect {
|
||||
app.insert_resource(NetOffline);
|
||||
@@ -160,7 +144,6 @@ impl Plugin for NetPlugin {
|
||||
Update,
|
||||
(
|
||||
apply_net_updates,
|
||||
release_ask_once_latches_on_enter,
|
||||
tag_self_player,
|
||||
sample_splines,
|
||||
// Derive each creature's swim state from the water over its feet (the wire never
|
||||
|
||||
@@ -418,8 +418,8 @@ pub(crate) fn apply_net_updates(
|
||||
&mut stores,
|
||||
&mut pending,
|
||||
&mut speed_stage,
|
||||
&mut names,
|
||||
&mut go_templates,
|
||||
&names,
|
||||
&go_templates,
|
||||
&net_commands,
|
||||
)
|
||||
}
|
||||
@@ -977,7 +977,7 @@ pub(crate) fn apply_net_updates(
|
||||
&mut ui_error_keys,
|
||||
&name,
|
||||
&self_guid,
|
||||
&mut names,
|
||||
&names,
|
||||
&net_commands,
|
||||
),
|
||||
SessionEvent::GroupDestroyed => group::destroyed(&mut group, &mut ui_error_keys),
|
||||
@@ -996,7 +996,7 @@ pub(crate) fn apply_net_updates(
|
||||
members,
|
||||
leader,
|
||||
loot,
|
||||
&mut names,
|
||||
&names,
|
||||
&index,
|
||||
&net_commands,
|
||||
),
|
||||
@@ -1166,7 +1166,7 @@ pub(crate) fn apply_net_updates(
|
||||
}
|
||||
SessionEvent::PetitionSignResults(results) => crate::ui_petition::apply::sign_results(
|
||||
&mut petition,
|
||||
&mut names,
|
||||
&names,
|
||||
self_guid.0.unwrap_or(0),
|
||||
results,
|
||||
&net_commands,
|
||||
@@ -1336,7 +1336,7 @@ pub(crate) fn apply_net_updates(
|
||||
&mut combat_text_spawns,
|
||||
&mut unit_combat_feedback,
|
||||
&mut combat_text_events,
|
||||
&mut names,
|
||||
&names,
|
||||
&net_commands,
|
||||
)
|
||||
}
|
||||
@@ -1348,7 +1348,7 @@ pub(crate) fn apply_net_updates(
|
||||
&self_guid,
|
||||
&mut unit_combat_feedback,
|
||||
&mut combat_text_events,
|
||||
&mut names,
|
||||
&names,
|
||||
&net_commands,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ pub(super) fn periodic_aura_log(
|
||||
text: &mut MessageWriter<CombatTextSpawn>,
|
||||
feedback: &mut MessageWriter<UnitCombatFeedback>,
|
||||
center: &mut MessageWriter<CombatTextEvent>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
net: &NetCommands,
|
||||
) {
|
||||
if benilla_assets::trace::enabled() {
|
||||
@@ -433,7 +433,7 @@ pub(super) fn spell_heal_log(
|
||||
self_guid: &SelfGuid,
|
||||
feedback: &mut MessageWriter<UnitCombatFeedback>,
|
||||
center: &mut MessageWriter<CombatTextEvent>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
net: &NetCommands,
|
||||
) {
|
||||
if let Some(&unit) = index.0.get(&s.target) {
|
||||
|
||||
@@ -68,7 +68,7 @@ pub(super) fn leader_changed(
|
||||
errors: &mut UiErrorKeys,
|
||||
name: &str,
|
||||
self_guid: &SelfGuid,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
net_commands: &NetCommands,
|
||||
) {
|
||||
let own = self_guid
|
||||
@@ -97,7 +97,7 @@ pub(super) fn list(
|
||||
members: Vec<GroupMemberEntry>,
|
||||
leader: u64,
|
||||
loot: Option<GroupLootInfo>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
index: &GuidIndex,
|
||||
net_commands: &NetCommands,
|
||||
) {
|
||||
@@ -275,7 +275,7 @@ mod tests {
|
||||
vec![member(leader, "Frostshake"), member(far, "Thalyn")],
|
||||
leader,
|
||||
None,
|
||||
&mut names,
|
||||
&names,
|
||||
&GuidIndex::default(),
|
||||
&net,
|
||||
);
|
||||
@@ -304,7 +304,7 @@ mod tests {
|
||||
vec![member(leader, "Frostshake"), member(far, "Thalyn")],
|
||||
leader,
|
||||
None,
|
||||
&mut names,
|
||||
&names,
|
||||
&GuidIndex::default(),
|
||||
&net,
|
||||
);
|
||||
@@ -426,7 +426,7 @@ mod tests {
|
||||
UiErrorKeys::default(),
|
||||
QuestGiver::default(),
|
||||
);
|
||||
let mut names = NameCache::default();
|
||||
let names = NameCache::default();
|
||||
let (near, far) = (0x11u64, 0x22u64);
|
||||
|
||||
// `near` is streamed, `far` is not.
|
||||
@@ -445,7 +445,7 @@ mod tests {
|
||||
members,
|
||||
near,
|
||||
None,
|
||||
&mut names,
|
||||
&names,
|
||||
&index,
|
||||
&net,
|
||||
);
|
||||
|
||||
@@ -146,8 +146,7 @@ pub(super) fn send_mail_result(
|
||||
/// `SessionEvent::MailItemText` (`SMSG_ITEM_TEXT_QUERY_RESPONSE`) — land the letter body in the
|
||||
/// ask-once cache + clear its pending flag; the feed repaints (MAIL_INBOX_UPDATE) on the change.
|
||||
pub(super) fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) {
|
||||
mail.bodies.insert(text_id, text);
|
||||
mail.pending_bodies.remove(&text_id);
|
||||
mail.bodies.insert(text_id, Some(text));
|
||||
}
|
||||
|
||||
/// `SessionEvent::ReceivedMail` (`SMSG_RECEIVED_MAIL`) — mail just arrived. `seconds` is the wire's
|
||||
|
||||
@@ -82,8 +82,8 @@ pub(super) fn object_create(
|
||||
stores: &mut Query<&mut ObjectStore>,
|
||||
pending: &mut HashMap<u64, ObjectFields>,
|
||||
speed_stage: &mut SpeedStage,
|
||||
names: &mut NameCache,
|
||||
go_templates: &mut GameObjectTemplates,
|
||||
names: &NameCache,
|
||||
go_templates: &GameObjectTemplates,
|
||||
net_commands: &NetCommands,
|
||||
) {
|
||||
let net = NetEntity {
|
||||
|
||||
@@ -209,7 +209,7 @@ fn trim_spaces(arm: &[char]) -> &[char] {
|
||||
/// re-substitutes when that happens.
|
||||
pub(crate) fn player_identity(
|
||||
self_q: &Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<Subject> {
|
||||
let (store, guid) = self_q.iter().next()?;
|
||||
@@ -233,7 +233,7 @@ pub(crate) fn subject_for_guid(
|
||||
guid: u64,
|
||||
index: &GuidIndex,
|
||||
stores: &Query<&ObjectStore>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<Subject> {
|
||||
if guid == 0 {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! **`QueryCache<K, V>` — the one ask-once cache** (decision 2288; 2265 §A4).
|
||||
//!
|
||||
//! The reference has one `DBCache<T>` (wow-re `system/dbcache/dbcache.md`): a consumer's miss
|
||||
//! sends the query and queues a callback; a second lookup of a pending key appends a callback
|
||||
//! instead of re-sending; the response handler writes the record and fires the callbacks;
|
||||
//! eviction is explicit only. benilla had that machine hand-copied across eight modules under
|
||||
//! three names for the in-flight set (`pending`, `querying`, `queried`), the miss and the
|
||||
//! landing bodies pasted ("the exact twin of `NameCache`"), and the release that keeps an ask
|
||||
//! sent before the writer thread exists from latching its key for the process lifetime written
|
||||
//! for two of the eight.
|
||||
//!
|
||||
//! **The read that asks takes `&self`.** A miss marks itself in a lock-guarded set and sends
|
||||
//! its query; the answered map does not move. That is the reason for a type over the copies: a
|
||||
//! system that only *reads* a name or a template holds the owning resource shared, so two such
|
||||
//! systems no longer conflict over it — 2287's "pure cache" class measured 556 such undeclared
|
||||
//! orders — and Bevy's change detection on the owner means "an answer landed" again instead of
|
||||
//! "someone looked" (the two hand-rolled epochs in `items.rs` were written because it did not).
|
||||
//!
|
||||
//! A negative answer is cached as `None` (the server's high-bit miss), so a dead key is never
|
||||
//! re-asked; [`QueryCache::answered_unknown`] is for the consumer that waits (the cast-fail
|
||||
//! redisplay, decision 0552) to know when to stop.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::Hash;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use benilla_assets::LockRecover;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::EnteredWorldMessage;
|
||||
|
||||
pub(crate) struct QueryCache<K, V> {
|
||||
answered: HashMap<K, Option<V>>,
|
||||
/// The keys in flight. Behind a lock so the read that asks is `&self`; uncontended by
|
||||
/// construction (every holder runs on the main thread under the single-threaded executor,
|
||||
/// and a miss is the rare path).
|
||||
pending: Mutex<HashSet<K>>,
|
||||
/// Bumped by every landing, positive or negative — the broadcast a consumer that caches a
|
||||
/// view derived from an answer keys on (the reference's `DBCACHECALLBACK` redisplay, 0660).
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl<K: Copy + Eq + Hash, V> Default for QueryCache<K, V> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
answered: HashMap::new(),
|
||||
pending: Mutex::new(HashSet::new()),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Copy + Eq + Hash, V> QueryCache<K, V> {
|
||||
/// The answer for `key` if the server has given one. `None` for a miss — which runs `ask`
|
||||
/// once per key per connection — and for a cached negative alike.
|
||||
pub(crate) fn get_or_ask(&self, key: K, ask: impl FnOnce()) -> Option<&V> {
|
||||
match self.answered.get(&key) {
|
||||
Some(answer) => answer.as_ref(),
|
||||
None => {
|
||||
if self.pending.lock_recover().insert(key) {
|
||||
ask();
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The read-only twin: the answer if it is already here, and never an ask.
|
||||
pub(crate) fn get(&self, key: K) -> Option<&V> {
|
||||
self.answered.get(&key)?.as_ref()
|
||||
}
|
||||
|
||||
/// The answer for `key`, mutably — for a record the wire patches in place after it landed
|
||||
/// (a guild's rank rename, a petition's new title). Never an ask.
|
||||
pub(crate) fn get_mut(&mut self, key: K) -> Option<&mut V> {
|
||||
self.answered.get_mut(&key)?.as_mut()
|
||||
}
|
||||
|
||||
/// Has the server answered `key` at all — with a record or with "unknown"?
|
||||
pub(crate) fn answered(&self, key: K) -> bool {
|
||||
self.answered.contains_key(&key)
|
||||
}
|
||||
|
||||
/// Has the server answered `key` with "unknown"? Distinct from a still-pending ask, which
|
||||
/// reads `None` from [`Self::get_or_ask`] too.
|
||||
pub(crate) fn answered_unknown(&self, key: K) -> bool {
|
||||
self.answered.get(&key).is_some_and(Option::is_none)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Is an ask for `key` in flight?
|
||||
pub(crate) fn is_pending(&self, key: K) -> bool {
|
||||
self.pending.lock_recover().contains(&key)
|
||||
}
|
||||
|
||||
/// Land an answer; `None` is the server's negative.
|
||||
pub(crate) fn insert(&mut self, key: K, answer: Option<V>) {
|
||||
self.pending
|
||||
.get_mut()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.remove(&key);
|
||||
self.answered.insert(key, answer);
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The reference's explicit eviction (a high-bit key, `SMSG_INVALIDATE_PLAYER`): the next
|
||||
/// read anywhere re-asks. Returns whether there was anything to evict.
|
||||
pub(crate) fn evict(&mut self, key: K) -> bool {
|
||||
self.pending
|
||||
.get_mut()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.remove(&key);
|
||||
let was = self.answered.remove(&key).is_some();
|
||||
if was {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
was
|
||||
}
|
||||
|
||||
/// Forget the in-flight asks — a disconnect may have dropped them on the writer floor, and
|
||||
/// an ask sent before the writer existed never left at all. [`register`] runs this on every
|
||||
/// world entry for every cache that registers.
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
self.pending
|
||||
.get_mut()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.clear();
|
||||
}
|
||||
|
||||
/// Drop everything, answers included (a session-scoped cache on disconnect).
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.answered.clear();
|
||||
self.clear_pending();
|
||||
}
|
||||
|
||||
/// The landing counter — see the field.
|
||||
pub(crate) fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// Every answered key with its answer (a persisted cache's save walks this).
|
||||
pub(crate) fn iter(&self) -> impl Iterator<Item = (&K, &Option<V>)> {
|
||||
self.answered.iter()
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.answered.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.answered.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource that owns one or more [`QueryCache`]s: what to forget on world entry.
|
||||
pub(crate) trait AskOnce: Resource {
|
||||
fn clear_pending(&mut self);
|
||||
}
|
||||
|
||||
/// **The one release-on-enter.** Every cache owner registers here at build; the system runs on
|
||||
/// the world-enter message and clears the in-flight sets, so a key asked before the io writer
|
||||
/// existed — or across a disconnect — is asked again. Before 2288 this was written for two of
|
||||
/// the eight owners (`net::release_ask_once_latches_on_enter`, after the mail send tab's
|
||||
/// stationery list went missing for a whole session in silence), and the other six were the
|
||||
/// same dead-feature class, still armed.
|
||||
pub(crate) fn register<T: AskOnce>(app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
release_on_enter::<T>
|
||||
.in_set(benilla_world::schedule::WorldStage::Net)
|
||||
.after(crate::net::apply_net_updates),
|
||||
);
|
||||
}
|
||||
|
||||
fn release_on_enter<T: AskOnce>(
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
mut cache: ResMut<T>,
|
||||
) {
|
||||
if entered.read().count() == 0 {
|
||||
return;
|
||||
}
|
||||
cache.clear_pending();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_miss_asks_once_and_a_landing_answers_it() {
|
||||
let mut cache: QueryCache<u32, &str> = QueryCache::default();
|
||||
let mut asks = 0;
|
||||
assert!(cache.get_or_ask(7, || asks += 1).is_none());
|
||||
assert!(cache.get_or_ask(7, || asks += 1).is_none());
|
||||
assert_eq!(
|
||||
asks, 1,
|
||||
"the second lookup of a pending key does not re-send"
|
||||
);
|
||||
assert!(cache.is_pending(7));
|
||||
cache.insert(7, Some("seven"));
|
||||
assert_eq!(cache.get_or_ask(7, || asks += 1), Some(&"seven"));
|
||||
assert_eq!(asks, 1);
|
||||
assert!(!cache.is_pending(7));
|
||||
assert_eq!(cache.generation(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_is_cached_and_never_re_asked() {
|
||||
let mut cache: QueryCache<u32, &str> = QueryCache::default();
|
||||
let mut asks = 0;
|
||||
cache.get_or_ask(1, || asks += 1);
|
||||
cache.insert(1, None);
|
||||
assert!(cache.get_or_ask(1, || asks += 1).is_none());
|
||||
assert_eq!(asks, 1);
|
||||
assert!(cache.answered_unknown(1));
|
||||
assert!(!cache.answered_unknown(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_pending_lets_a_dropped_ask_go_again_and_keeps_the_answers() {
|
||||
let mut cache: QueryCache<u32, &str> = QueryCache::default();
|
||||
let mut asks = 0;
|
||||
cache.get_or_ask(1, || asks += 1);
|
||||
cache.insert(2, Some("two"));
|
||||
cache.clear_pending();
|
||||
cache.get_or_ask(1, || asks += 1);
|
||||
assert_eq!(asks, 2);
|
||||
assert_eq!(cache.get(2), Some(&"two"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_re_asks() {
|
||||
let mut cache: QueryCache<u32, &str> = QueryCache::default();
|
||||
cache.insert(3, Some("three"));
|
||||
assert!(cache.evict(3));
|
||||
assert!(!cache.evict(3));
|
||||
let mut asks = 0;
|
||||
assert!(cache.get_or_ask(3, || asks += 1).is_none());
|
||||
assert_eq!(asks, 1);
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,14 @@
|
||||
//! `0x5fcf90` = {107, 111, 112} exists and the `$BWR` handler never calls it, so those two match
|
||||
//! neither arm and get no prop animation at all.
|
||||
//!
|
||||
//! Three of that round's corrections are built in below, and one is not mine to build: the arms
|
||||
//! sit under a gate on `[CGUnit+0xac]` (the unit's in-flight cast/visual record list) whose
|
||||
//! **writer is still underived** — wow-re parked it. Nothing here models it; see the record.
|
||||
//! **Both arms sit under a gate, and it is the projectile queue** (decision 2288). `[CGUnit+0xac]`
|
||||
//! is not a spell-visual list, as this file first guessed from its readers — it is the queue of
|
||||
//! `CMissile` nodes waiting for the caster's release event, and `$BWR` reads it (`0x600182`) before
|
||||
//! draining it (`0x600294` → `0x60c940`, the launcher). So the prop's re-anim and the projectile's
|
||||
//! launch are two effects of one act: no missile queued, no flex. benilla already held that queue —
|
||||
//! [`crate::entities::PendingMissiles`], whose own doc named `+0xac` correctly long before this
|
||||
//! round — and the chain runs this arm ahead of the drain, which is the order the two addresses
|
||||
//! sit in.
|
||||
//!
|
||||
//! What this file does NOT own: the prop's rig and palette rows (the rider lane —
|
||||
//! [`benilla_world::rig_rider`], given a posed arm by 2281), the emitters' clock (`EmitClock::Host`
|
||||
@@ -99,10 +104,19 @@ pub(crate) fn flex_ranged_props(
|
||||
// Disjoint from `props_mut` by the filter: a unit is never its own ranged prop.
|
||||
wearers: Query<(&AnimationPlayer, &ModelAnimations), Without<RangedProp>>,
|
||||
mut props_mut: Query<(&mut AnimationPlayer, &ModelAnimations), With<RangedProp>>,
|
||||
// The `[+0xac]` gate — read here, drained by `entities::missile::spawn_missiles` on the same
|
||||
// key. The creature-anim chain runs `.before(EntityVisualsSet)`, so this read lands ahead of
|
||||
// that drain exactly as `0x600182` lands ahead of `0x600294`.
|
||||
pending: Res<crate::entities::PendingMissiles>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
let want = match &ev.ident {
|
||||
b"$BWP" => Some(BOW_PULL),
|
||||
// **`0x60018a je 0x600299`** — no projectile waiting, no prop block at all. The gate
|
||||
// is `$BWR`'s alone: `$BWP`'s handler (`0x624cc0`) reads nothing of the kind, and the
|
||||
// nock-latch clear above it (`0x60016c`) and the ammo detach below it (`0x600299`) are
|
||||
// both outside the skip, which is why `drive_nock_latch` stays ungated.
|
||||
b"$BWR" if !pending.releasing(ev.entity) => None,
|
||||
b"$BWR" => {
|
||||
if BOW_FAMILY.contains(&ev.anim_id) {
|
||||
Some(STAND)
|
||||
@@ -363,6 +377,7 @@ mod tests {
|
||||
) -> Option<(AnimationNodeIndex, f32)> {
|
||||
let mut app = App::new();
|
||||
app.add_message::<AnimSoundEvent>();
|
||||
app.init_resource::<crate::entities::PendingMissiles>();
|
||||
app.add_systems(Update, flex_ranged_props);
|
||||
let mut wearer_player = AnimationPlayer::default();
|
||||
let wearer_anims = match &body_clip {
|
||||
@@ -382,6 +397,9 @@ mod tests {
|
||||
RangedProp { owner: wearer },
|
||||
))
|
||||
.id();
|
||||
// A projectile queued on the wearer — the `[+0xac]` gate the `$BWR` arms sit under. Every
|
||||
// leg but the gate's own test wants it open; `queue_a_shot` is what opens it.
|
||||
crate::entities::PendingMissiles::queue_a_shot(&mut app, wearer);
|
||||
app.world_mut().write_message(AnimSoundEvent {
|
||||
entity: wearer,
|
||||
ident: *tag,
|
||||
@@ -432,6 +450,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The `[+0xac]` gate** (`0x600182`/`0x60018a`, decision 2288): a `$BWR` with no projectile
|
||||
/// waiting to be released arms nothing on the prop. The reference skips the whole block —
|
||||
/// prop re-anim and cast-point reposition together — because the flex and the launch are two
|
||||
/// effects of one act, and there is no act without a missile to throw.
|
||||
///
|
||||
/// Asserted as the difference the gate makes: the SAME key, the same rifle body clip, with and
|
||||
/// without a queued shot.
|
||||
#[test]
|
||||
fn a_bwr_with_no_projectile_queued_arms_nothing() {
|
||||
let mut app = App::new();
|
||||
app.add_message::<AnimSoundEvent>();
|
||||
app.init_resource::<crate::entities::PendingMissiles>();
|
||||
app.add_systems(Update, flex_ranged_props);
|
||||
let wearer = app.world_mut().spawn(anims(Vec::new())).id();
|
||||
let prop = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
AnimationPlayer::default(),
|
||||
gun(),
|
||||
RangedProp { owner: wearer },
|
||||
))
|
||||
.id();
|
||||
let fire = |app: &mut App| {
|
||||
app.world_mut().write_message(AnimSoundEvent {
|
||||
entity: wearer,
|
||||
ident: *b"$BWR",
|
||||
data: 0,
|
||||
anim_id: 49,
|
||||
pos: None,
|
||||
});
|
||||
app.update();
|
||||
app.world()
|
||||
.entity(prop)
|
||||
.get::<AnimationPlayer>()
|
||||
.unwrap()
|
||||
.playing_animations()
|
||||
.count()
|
||||
};
|
||||
assert_eq!(fire(&mut app), 0, "queue empty ⇒ the prop is not touched");
|
||||
crate::entities::PendingMissiles::queue_a_shot(&mut app, wearer);
|
||||
assert_eq!(
|
||||
fire(&mut app),
|
||||
1,
|
||||
"…and the same key arms 161 once a shot is queued"
|
||||
);
|
||||
}
|
||||
|
||||
/// `$BWP` arms BowPull — and only on a model that HAS it. The reference makes that a literal
|
||||
/// presence test it bails on (`0x624cee push 0xa0; call 0x711960`), and a firearm is exactly
|
||||
/// the model it bails for: 21 of the 22 shipped firearms author 161 and none authors 160.
|
||||
|
||||
@@ -56,6 +56,9 @@ pub(crate) fn kit_decodes() -> u32 {
|
||||
}
|
||||
pub(crate) use glue::GlueSound;
|
||||
pub(crate) use greeting::NpcGreetingRequest;
|
||||
/// Named by the schedule tests' class table (`game_plugins::schedule_tests::Classes`, 2287).
|
||||
#[cfg(test)]
|
||||
pub(crate) use kit::SoundKits;
|
||||
pub(crate) use message::MessageSounds;
|
||||
pub(crate) use mixer::Mixer;
|
||||
pub(crate) use ui::{AutoEquipSound, LootPickupSound};
|
||||
@@ -475,7 +478,7 @@ fn load_materials(mut commands: Commands, assets: Option<Res<benilla_assets::Wor
|
||||
/// object not streamed, and a template still in flight (asked once, answered next frame).
|
||||
pub(super) fn worn_chest_material(
|
||||
store: Option<&crate::net::ObjectStore>,
|
||||
items: &mut crate::items::Items,
|
||||
items: &crate::items::Items,
|
||||
net: &crate::net::NetCommands,
|
||||
) -> Option<u32> {
|
||||
/// Index 4 of the inv-slot array — `0x62fa50`/`0x62fb86` read the fifth 8-byte guid.
|
||||
|
||||
@@ -156,7 +156,7 @@ enum CursorGesture {
|
||||
fn play_item_gesture_sounds(
|
||||
script: Option<NonSend<UiScript>>,
|
||||
mut prev: Local<crate::ui_script::VmMemo<Option<CursorPayload>>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
displays: Option<Res<ItemDisplays>>,
|
||||
sounds: Option<Res<ItemSounds>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
|
||||
@@ -45,16 +45,16 @@ use crate::net::ObjectStore;
|
||||
/// spell). The `Option` members are absent without client data.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct GoLockInputs<'w> {
|
||||
// ResMut, not Res: the tooltip arm of this bundle drives the ask-once template
|
||||
// request on a miss (the click arm only reads).
|
||||
pub(crate) templates: ResMut<'w, crate::go_templates::GameObjectTemplates>,
|
||||
// Shared: the tooltip arm's ask-once template request on a miss marks itself through
|
||||
// `&self` (decision 2288), so neither arm needs the store exclusively.
|
||||
pub(crate) templates: Res<'w, crate::go_templates::GameObjectTemplates>,
|
||||
pub(crate) locks: Option<Res<'w, crate::go_templates::Locks>>,
|
||||
pub(crate) lock_types: Option<Res<'w, crate::go_templates::LockTypes>>,
|
||||
pub(crate) spells: Option<Res<'w, crate::ui_action::Spells>>,
|
||||
/// The skill-line catalog — the opener-value level term's spell→line hop
|
||||
/// ([`spell_skill_value`]). Absent without client data, which reads as skill 0 (fail-closed).
|
||||
pub(crate) skill_lines: Option<Res<'w, crate::ui_spellbook::SkillLines>>,
|
||||
pub(crate) items: ResMut<'w, crate::items::Items>,
|
||||
pub(crate) items: Res<'w, crate::items::Items>,
|
||||
}
|
||||
|
||||
/// The GameObject facts the Action gate and the requirement fallback read off the wire — gathered
|
||||
|
||||
@@ -34,11 +34,29 @@
|
||||
//! PREVENTED_BY_MECHANIC (decision 1948 — `0x6e2190`, and the one arm whose word is produced
|
||||
//! locally rather than read off the wire), `0x56` ONLY_SHAPESHIFT (decision 2280 — `0x6e1ff8`,
|
||||
//! the one arm that reads a **mask** and joins several names), and `0x78` TOTEMS / `0x5c`
|
||||
//! REAGENTS / `0x19`–`0x1b` EQUIPPED_ITEM_CLASS\* in the drain, which owns them because their
|
||||
//! fills need the item caches and the query-then-redisplay cache-miss behavior ("Requires Mining
|
||||
//! Pick", decisions 0545 + 0552). Still stripped rather than filled — each needs a DBC we do not
|
||||
//! load: `0x90` MIN_SKILL (`SkillLine.dbc`), `0x31` NEED_EXOTIC_AMMO and `0x84`
|
||||
//! PROSPECT_NEED_MORE. Stripping is a **deliberate divergence**, now byte-confirmed as one: on a
|
||||
//! REAGENTS / `0x19`–`0x1b` EQUIPPED_ITEM_CLASS\* / `0x31` NEED_EXOTIC_AMMO in the drain, which
|
||||
//! owns the totem/reagent pair because their fills need the item caches and the
|
||||
//! query-then-redisplay cache-miss behavior ("Requires Mining Pick", decisions 0545 + 0552), and
|
||||
//! the other two because they share `0x6e2380`'s subclass catalog.
|
||||
//!
|
||||
//! **That is every arm in the `0x6e1d8e` table, and the reasons that still read bare read bare
|
||||
//! for reasons that are not "a DBC we do not load"** — decision 2292, which found that claim
|
||||
//! false in all three places this paragraph used to make it — the same stale blocker that hid
|
||||
//! `0x56` behind a DBC we had been loading for a month:
|
||||
//!
|
||||
//! - `0x30` NEED_AMMO_POUCH — **the reference leaves its own `%s` literal.** There is no arm to
|
||||
//! write: `0x6e1e3d` only clears `[caster+0xd58]` bit `0x200`, and no word is ever supplied.
|
||||
//! - `0x31` NEED_EXOTIC_AMMO — modeled, and argless on this stack. `Spell::SendCastResult` fills
|
||||
//! `failureArg1` for four reasons and this is not one of them, so the arm declines.
|
||||
//! - `0x84` PROSPECT_NEED_MORE and `0x90` MIN_SKILL — **unreachable on 5875, not
|
||||
//! unimplemented.** Neither is server-sent, and their only raise sites image-wide (`0x49614e`
|
||||
//! and `0x496128`, both inside the item-target validator `0x495d60`) sit behind that
|
||||
//! function's third leg, `Effect[i] == 0x7f` SPELL_EFFECT_PROSPECTING — which no shipped spell
|
||||
//! carries, pinned on the real file by `real_prospecting_effect_is_absent_from_5875`.
|
||||
//! `SkillLine.dbc`, the DBC `0x90` was said to be waiting on, has been loaded since the
|
||||
//! spellbook grew tabs.
|
||||
//!
|
||||
//! Stripping is a **deliberate divergence**, now byte-confirmed as one: on a
|
||||
//! bad id or an absent word the reference jumps to the default arm with the pointer still on the
|
||||
//! *unfilled* template, so it displays a literal `Requires %s` (wow-re §WIRE-ARGS C3 — the fill
|
||||
//! path's own buffer swap sits after the printf and is skipped). We show the bare stem instead:
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(crate) struct CastLadder<'w, 's> {
|
||||
pub(crate) spells: Option<Res<'w, Spells>>,
|
||||
/// The item cache — the pre-send totem/reagent check reads it (decision 0552), and the item
|
||||
/// arms resolve templates through it.
|
||||
pub(crate) items: ResMut<'w, Items>,
|
||||
pub(crate) items: Res<'w, Items>,
|
||||
pub(crate) sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
|
||||
pub(crate) ecs: Commands<'w, 's>,
|
||||
pub(crate) pending: ResMut<'w, crate::ui_cast::PendingCast>,
|
||||
|
||||
@@ -416,7 +416,7 @@ pub(super) fn drain_action_uses(
|
||||
crate::ui_items::send_auto_equip(
|
||||
&mut script,
|
||||
&mut gate,
|
||||
&mut ladder.items,
|
||||
&ladder.items,
|
||||
&ladder.commands,
|
||||
bag_index,
|
||||
slot0,
|
||||
|
||||
@@ -114,7 +114,7 @@ pub(super) fn feed_actions(
|
||||
mut ui_error_texts: ResMut<UiErrorTexts>,
|
||||
spells: Option<Res<Spells>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
name_tables: FailNameTables,
|
||||
@@ -188,6 +188,40 @@ pub(super) fn feed_actions(
|
||||
.map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name)));
|
||||
}
|
||||
}
|
||||
// `0x31` NEED_EXOTIC_AMMO (`0x6e1e54`) — the same `0x6e2380` helper as the arm
|
||||
// above, one class over: `requirement_display_name(6 /* Projectile */, 1 << arg)`
|
||||
// reads the singular DisplayName at `row + 0x28 + locale*4`, so "Requires exotic
|
||||
// ammo: %s" names the ammo type. The shift is masked to five bits because the
|
||||
// reference's is — `0x6e1e5c: shl edx,cl` takes x86's own `cl & 31` — and a
|
||||
// subclass naming no row declines to the strip fallback exactly as
|
||||
// `0x6e1e6a: je 0x6e21d8` does.
|
||||
//
|
||||
// **The player's arm only.** The pet handler's index table (`0x6e93d0`) is gated
|
||||
// `cmp reason,0x8d; ja default` and carries no `0x31` entry, so a pet's exotic-ammo
|
||||
// refusal takes its generic arm `0x6e936a` and shows the bare template — unlike
|
||||
// `0x19`–`0x1b` just above, which the pet's own table *does* fill (`0x6e904d`).
|
||||
// Same shape as `0x78`/`0x5c` below, and the same trap decision 2033 named.
|
||||
//
|
||||
// **It declines on a vmangos server, every time**, and that is the server's shape
|
||||
// rather than a gap here: `Spell::SendCastResult` fills `failureArg1` for
|
||||
// NOT_READY, REQUIRES_SPELL_FOCUS, REQUIRES_AREA and the EQUIPPED_ITEM_CLASS
|
||||
// family and for nothing else, so `0x31` arrives with no word and the line reads
|
||||
// "Requires exotic ammo:". Modeled anyway because the arm is the mechanism and the
|
||||
// word is the server's to supply (decision 2292).
|
||||
if let (false, 0x31, Some(arg), Some(subs)) =
|
||||
(pet, reason, fail.arg, sub_classes.as_deref())
|
||||
{
|
||||
const ITEM_CLASS_PROJECTILE: u32 = 6;
|
||||
if let Some(name) = subs
|
||||
.0
|
||||
.requirement_display_name(ITEM_CLASS_PROJECTILE, 1u32 << (arg & 31))
|
||||
{
|
||||
let key = cast_fail::CAST_FAIL_KEYS[reason as usize];
|
||||
return get(key)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name)));
|
||||
}
|
||||
}
|
||||
// `0x78` TOTEMS / `0x5c` REAGENTS — **the player's arms only**. The pet handler's
|
||||
// index table (`0x6e93d0`) sends both to its generic arm, so a pet's refusal takes the
|
||||
// shared passthrough below and never runs the bag walk or the item query (decision
|
||||
@@ -456,7 +490,7 @@ pub(super) fn feed_actions(
|
||||
d,
|
||||
sp,
|
||||
store,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
)
|
||||
@@ -584,7 +618,7 @@ pub(super) fn feed_actions(
|
||||
d,
|
||||
sp,
|
||||
Some(store),
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
);
|
||||
@@ -634,7 +668,7 @@ fn spell_action_icon(
|
||||
d: &benilla_formats::SpellDisplay,
|
||||
spells: &super::Spells,
|
||||
store: Option<&crate::net::ObjectStore>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
|
||||
@@ -409,7 +409,7 @@ pub(super) fn feed_action_state(
|
||||
units: Query<(&ObjectStore, &Transform), Without<SelfPlayer>>,
|
||||
factions: Option<Res<crate::target::Factions>>,
|
||||
reputations: Res<crate::net::Reputations>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<StateMemory>>,
|
||||
) {
|
||||
@@ -533,7 +533,7 @@ pub(super) fn feed_action_state(
|
||||
carried: &carried,
|
||||
};
|
||||
let (u, oom) =
|
||||
usable::spell_usable(button.action, d, sp, &ctx, &mut items, &commands);
|
||||
usable::spell_usable(button.action, d, sp, &ctx, &items, &commands);
|
||||
st.usable = u;
|
||||
st.not_enough_mana = oom;
|
||||
} else {
|
||||
@@ -603,7 +603,7 @@ pub(super) fn feed_action_state(
|
||||
count > 0 || st.equipped,
|
||||
&ctx,
|
||||
spells.as_deref(),
|
||||
&mut items,
|
||||
&items,
|
||||
&commands,
|
||||
);
|
||||
st.usable = u;
|
||||
|
||||
@@ -158,7 +158,7 @@ pub(crate) fn equipped_item_reason(d: &SpellDisplay) -> u8 {
|
||||
pub(crate) fn equipped_item_fits(
|
||||
d: &SpellDisplay,
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> bool {
|
||||
// The reference's four short-circuits, all answering "fits" without looking at a single slot
|
||||
@@ -309,7 +309,7 @@ pub(crate) fn item_usable(
|
||||
held: bool,
|
||||
ctx: &UsableCtx,
|
||||
spells: Option<&Spells>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> (bool, bool) {
|
||||
if !held {
|
||||
@@ -342,7 +342,7 @@ pub(crate) fn spell_usable(
|
||||
d: &SpellDisplay,
|
||||
spells: &Spells,
|
||||
ctx: &UsableCtx,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> (bool, bool) {
|
||||
// Early-out (`0x6e3d99`): a tradeskill "spell" is always usable.
|
||||
@@ -559,7 +559,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let store = ObjectStore(ObjectFields::from_pairs(&pairs));
|
||||
equipped_item_fits(&needs_a_weapon, &store, &mut deps.items, &deps.commands)
|
||||
equipped_item_fits(&needs_a_weapon, &store, &deps.items, &deps.commands)
|
||||
};
|
||||
|
||||
// CONTROL — armed, the sword satisfies it.
|
||||
@@ -622,7 +622,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let store = ObjectStore(ObjectFields::from_pairs(&pairs));
|
||||
equipped_item_fits(d, &store, &mut deps.items, &deps.commands)
|
||||
equipped_item_fits(d, &store, &deps.items, &deps.commands)
|
||||
};
|
||||
|
||||
let sound = |field| (field, 0x2au64, 0u32, 0u32, 0u32);
|
||||
@@ -695,7 +695,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
&store,
|
||||
&mut deps.items,
|
||||
&deps.items,
|
||||
&deps.commands,
|
||||
)
|
||||
};
|
||||
@@ -731,7 +731,7 @@ mod tests {
|
||||
let cooldowns = Cooldowns::default();
|
||||
let reputations = Reputations(Vec::new());
|
||||
let spells = Spells::empty_for_tests();
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let carried = crate::ui_items::carried_counts(&store.0, &items);
|
||||
@@ -740,7 +740,7 @@ mod tests {
|
||||
d,
|
||||
&spells,
|
||||
&ctx(store, &cooldowns, &reputations, &carried),
|
||||
&mut items,
|
||||
&items,
|
||||
&commands,
|
||||
)
|
||||
}
|
||||
@@ -860,7 +860,7 @@ mod tests {
|
||||
let cooldowns = Cooldowns::default();
|
||||
let reputations = Reputations(Vec::new());
|
||||
let spells = Spells::empty_for_tests();
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let execute = SpellDisplay {
|
||||
@@ -882,7 +882,7 @@ mod tests {
|
||||
carried: &carried,
|
||||
};
|
||||
assert_eq!(
|
||||
spell_usable(5308, &execute, &spells, &ctx, &mut items, &commands),
|
||||
spell_usable(5308, &execute, &spells, &ctx, &items, &commands),
|
||||
(expect, false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const ITEM_CLASS_WEAPON: u32 = 2;
|
||||
/// item whose icon would otherwise be shown.
|
||||
fn main_hand_item(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<(u32, Option<String>)> {
|
||||
@@ -73,7 +73,7 @@ fn main_hand_item(
|
||||
pub(crate) fn melee_auto_attack_icon(
|
||||
store: &ObjectStore,
|
||||
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> String {
|
||||
@@ -111,7 +111,7 @@ pub(crate) fn melee_auto_attack_icon(
|
||||
/// return hands over to the normal SpellIconID path).
|
||||
pub(crate) fn ranged_weapon_icon(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
@@ -143,7 +143,7 @@ pub(super) fn auto_attack_icon(
|
||||
spell: &SpellDisplay,
|
||||
store: Option<&ObjectStore>,
|
||||
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
@@ -216,13 +216,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
melee_auto_attack_icon(
|
||||
&store,
|
||||
&forms,
|
||||
&mut deps.items,
|
||||
Some(&icons),
|
||||
&deps.commands,
|
||||
)
|
||||
melee_auto_attack_icon(&store, &forms, &deps.items, Some(&icons), &deps.commands)
|
||||
}
|
||||
|
||||
/// **The disarmed guard on the Attack button** (`0x4e68df`, decision 1863 closing 0231's
|
||||
|
||||
@@ -423,9 +423,9 @@ fn time_left_bucket(ms: u32) -> u32 {
|
||||
fn resolve_row(
|
||||
entry: &AuctionListEntry,
|
||||
self_guid: Option<u64>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
) -> AuctionRow {
|
||||
@@ -518,9 +518,9 @@ pub(crate) fn categories(
|
||||
fn rows_for(
|
||||
slot: &AuctionListSlot,
|
||||
self_guid: Option<u64>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
) -> Vec<AuctionRow> {
|
||||
@@ -571,9 +571,9 @@ type AuctionCatalogs<'w> = (
|
||||
fn feed_auction(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut auction: ResMut<AuctionOpen>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
time: Res<Time>,
|
||||
catalogs: AuctionCatalogs,
|
||||
@@ -651,9 +651,9 @@ fn feed_auction(
|
||||
let rows = rows_for(
|
||||
slot,
|
||||
self_guid,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
rolls,
|
||||
);
|
||||
@@ -737,9 +737,9 @@ fn drain_auction(
|
||||
mut auction: ResMut<AuctionOpen>,
|
||||
commands: Res<NetCommands>,
|
||||
time: Res<Time>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
self_q: Query<(&ObjectStore, &crate::net::Guid), With<SelfPlayer>>,
|
||||
// The click→auction-id map re-derives the same sorted rows the feed pushed, so it resolves
|
||||
// them the same way, roll included (1547).
|
||||
@@ -832,9 +832,9 @@ fn drain_auction(
|
||||
let rows = rows_for(
|
||||
&auction.lists[bid.list],
|
||||
self_guid,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
rolls,
|
||||
);
|
||||
@@ -850,9 +850,9 @@ fn drain_auction(
|
||||
let rows = rows_for(
|
||||
&auction.lists[OWNER],
|
||||
self_guid,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
rolls,
|
||||
);
|
||||
|
||||
@@ -221,7 +221,7 @@ fn feed_battlefield(
|
||||
queue: Res<BattlefieldQueue>,
|
||||
maps: Option<Res<MapCatalogRes>>,
|
||||
player: Res<Player>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
) {
|
||||
|
||||
@@ -111,7 +111,7 @@ fn feed_battlefield_positions(
|
||||
group: Res<GroupState>,
|
||||
guids: Res<GuidIndex>,
|
||||
unit_pos: Query<&GlobalTransform, With<NetEntity>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
// What the empty push last carried (`(has_list, icon_scale bits)`): with no list the engine
|
||||
// is told once per change, not per frame; with one, every frame — the positions move.
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(crate) fn score_columns(catalog: &WorldStateUiRes, map: u32) -> Vec<Battlefi
|
||||
/// Resolve the raw board through the name cache; `None` while any name is still in flight.
|
||||
fn resolve_board(
|
||||
log: &PvpLogData,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<Vec<BattlefieldScoreRow>> {
|
||||
let mut rows = Vec::with_capacity(log.rows.len());
|
||||
@@ -109,7 +109,7 @@ pub(crate) fn feed_battlefield_score(
|
||||
board: Res<BattlefieldScoreboard>,
|
||||
mut queue: ResMut<BattlefieldQueue>,
|
||||
catalog: Option<Res<WorldStateUiRes>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<BattlefieldScores>>>,
|
||||
) {
|
||||
@@ -120,7 +120,7 @@ pub(crate) fn feed_battlefield_score(
|
||||
script.set_battlefield_run_time_ms(queue.run_time_ms(now));
|
||||
|
||||
let fresh = board.log.as_ref().and_then(|log| {
|
||||
let rows = resolve_board(log, &mut names, &commands)?;
|
||||
let rows = resolve_board(log, &names, &commands)?;
|
||||
let columns = queue
|
||||
.active_map()
|
||||
.zip(catalog.as_deref())
|
||||
|
||||
@@ -169,7 +169,7 @@ impl BindGate<'_> {
|
||||
pub(crate) fn equip_binds(
|
||||
&self,
|
||||
script: &UiScript,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &crate::net::NetCommands,
|
||||
item_guid: u64,
|
||||
) -> bool {
|
||||
@@ -198,7 +198,7 @@ impl BindGate<'_> {
|
||||
/// the fire site is reached the item has already passed it.
|
||||
pub(crate) fn use_binds(
|
||||
&self,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &crate::net::NetCommands,
|
||||
item_guid: u64,
|
||||
) -> bool {
|
||||
|
||||
@@ -34,7 +34,8 @@ pub(crate) enum CastBarEdge {
|
||||
/// `SMSG_SPELL_DELAYED` — pushback: our cast took a hit and the server extended it by
|
||||
/// `delay_ms`. The bar slides its window out (spark jumps back), it does NOT cancel.
|
||||
Delayed { delay_ms: u32 },
|
||||
/// `MSG_CHANNEL_START` (self-only on the wire) — a channel opened; the bar counts *down*.
|
||||
/// `MSG_CHANNEL_START` (self-only on the wire) — a channel opened; the bar counts *down*,
|
||||
/// and is labelled by [`channel_start_args`]'s law rather than the spell's name.
|
||||
ChannelStart { spell_id: u32, duration_ms: u32 },
|
||||
/// `MSG_CHANNEL_UPDATE` (self-only): time left; `0` = the channel is over.
|
||||
ChannelUpdate { remaining_ms: u32 },
|
||||
@@ -575,12 +576,7 @@ fn local_self_cancel(
|
||||
/// **This is the CAST bar's law and it does not generalise to the channel bar** — which is why
|
||||
/// this is `SPELLCAST_START`'s label and not a shared helper. `0x6e7a2d` is the only
|
||||
/// `SpellRec+0x24 & 4` test in the whole image (censused twice), and the channel handler
|
||||
/// `0x6e7550` gates on entirely different bits: `AttributesEx3 & 0x2000` fires **no event at all**,
|
||||
/// and the name is `Name[locale]` only when `AttributesEx & 0x2000_0000` is set, otherwise the
|
||||
/// literal `GetText("CHANNELING")`. Neither is modelled yet — [`CastBarEdge::ChannelStart`] still
|
||||
/// passes the plain name — so a channel bar can read a spell name where the reference reads
|
||||
/// "Channeling". Named residual, not an oversight: it is a visible change to every channelled
|
||||
/// spell and wants the director's eye, not a silent fold-in.
|
||||
/// `0x6e7550` gates on entirely different bits. That law is [`channel_start_args`]'s, next door.
|
||||
fn cast_bar_label(spells: Option<&crate::ui_action::Spells>, id: u32) -> String {
|
||||
spells
|
||||
.and_then(|s| s.catalog.get(id))
|
||||
@@ -589,11 +585,83 @@ fn cast_bar_label(spells: Option<&crate::ui_action::Spells>, id: u32) -> String
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The CHANNEL bar's own law — `SpellChannelStart 0x6e7550`, whole. Returns the
|
||||
/// `SPELLCAST_CHANNEL_START` argument list, or `None` where the reference fires **no event at
|
||||
/// all** and the bar never appears.
|
||||
///
|
||||
/// The handler is 147 bytes and every one of its gates is here, in its order (decomp
|
||||
/// `FUN_006e7550`; disassembly quoted in wow-re `system/spell/scratch/wave-cast.md` §0x6e7550):
|
||||
///
|
||||
/// ```text
|
||||
/// 6e7571 test eax,eax / jle 6e75d8 ; (1) duration <= 0 -> NO EVENT
|
||||
/// 6e7579 test eax,eax / jl ; (2) id < 0 -> NO EVENT
|
||||
/// 6e757d cmp eax,[0xc0d78c] / jg ; (2) id past the table -> NO EVENT
|
||||
/// 6e758e test eax,eax / je ; (2) no SpellRec at that slot -> NO EVENT
|
||||
/// 6e7595 test ch,0x20 ; (3) AttributesEx3 & 0x2000 -> NO EVENT
|
||||
/// 6e759a test [rec+0x1c],0x20000000 ; (4) AttributesEx bit 29 ...
|
||||
/// 6e75a9 mov eax,[rec+0x1e0+locale*4] ; ... set -> Name[locale]
|
||||
/// 6e75bc ecx=0x870dd0 call 0x703bf0 ; ... clear -> GetText("CHANNELING")
|
||||
/// 6e75cb push 0x156 / call 0x703f50 ; fmt 0x8432cc "%d%s" — (duration, name)
|
||||
/// ```
|
||||
///
|
||||
/// **Bit 29 is the reverse default of the cast bar's naming rule.** A cast bar prints the spell's
|
||||
/// name unless a bit says not to; a channel bar prints the literal word "Channeling" unless a bit
|
||||
/// says to name the spell. Only **9** of the 323 channeled rows in the shipped 5875 `Spell.dbc`
|
||||
/// opt in (the four Fishing ranks, Cannibalize, Dream Vision, Using Control Console, and the two
|
||||
/// Blood Siphons that gate (3) hides anyway) — so on a real server this reads "Channeling" for
|
||||
/// Blizzard, Arcane Missiles, Mind Flay, Drain Life/Soul/Mana, Rain of Fire, Hurricane,
|
||||
/// Tranquility, Evocation and First Aid alike. Ours passed the plain `Spell.dbc` name to every
|
||||
/// one of them until decision 2284.
|
||||
///
|
||||
/// The word itself is read the way the reference reads it — `FrameScript_GetText` on the
|
||||
/// GlobalStrings key, i.e. the Lua global `CHANNELING`, off the player's own chain — not from a
|
||||
/// table of ours, so a localized or patched install gets its own word. A missing global resolves
|
||||
/// to `""` (`0x882748`, the shared empty string; `GetText` never returns NULL), and the event
|
||||
/// still fires: an untitled bar, not a hidden one.
|
||||
///
|
||||
/// **What this does NOT gate is [`ActiveChannel`]** (and so the action button's lit ring). The
|
||||
/// reference's channel mirror is `SPELLCAST+0x10` (`0xceac58`), written at the cast SEND
|
||||
/// (`0x6e4f88`) and never touched by this handler — so a Blood Siphon suppressed here still
|
||||
/// counts as the current channel there, and ours keeps arming it from the packet for the same
|
||||
/// reason.
|
||||
fn channel_start_args(
|
||||
script: &UiScript,
|
||||
spells: Option<&crate::ui_action::Spells>,
|
||||
id: u32,
|
||||
duration_ms: u32,
|
||||
) -> Option<Vec<ScriptValue>> {
|
||||
if duration_ms == 0 {
|
||||
return None;
|
||||
}
|
||||
// Gates (2) and (3): no record at that id, or the record forbids a channel bar outright. The
|
||||
// `filter` is the `je`/`jne` pair — both leave with the event unfired.
|
||||
let display = spells
|
||||
.and_then(|s| s.catalog.get(id))
|
||||
.filter(|d| !d.no_channel_bar())?;
|
||||
let name = if display.channel_bar_own_name() {
|
||||
display.name.clone()
|
||||
} else {
|
||||
script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>("CHANNELING")
|
||||
.unwrap_or_default()
|
||||
};
|
||||
Some(vec![
|
||||
ScriptValue::Int(i64::from(duration_ms)),
|
||||
ScriptValue::Str(name),
|
||||
])
|
||||
}
|
||||
|
||||
/// Drain the queue into FrameScript events — the reference `CastingBarFrame` contract
|
||||
/// (extracted 1.12 `CastingBarFrame.lua`): `SPELLCAST_START(name, ms)`,
|
||||
/// `SPELLCAST_CHANNEL_START(ms, name)`, `SPELLCAST_CHANNEL_UPDATE(remaining_ms)`, and the
|
||||
/// argless STOP / FAILED / INTERRUPTED / CHANNEL_STOP. A channel update of `0` fires
|
||||
/// CHANNEL_STOP — the server ends both the natural finish and the interrupt that way.
|
||||
/// `SPELLCAST_CHANNEL_START(ms, name)` — arg order reversed, and not a slip —
|
||||
/// `SPELLCAST_CHANNEL_UPDATE(remaining_ms)`, and the argless STOP / FAILED / INTERRUPTED /
|
||||
/// CHANNEL_STOP. A channel update of `0` fires CHANNEL_STOP — the server ends both the natural
|
||||
/// finish and the interrupt that way.
|
||||
///
|
||||
/// An edge can resolve to **no event**: [`channel_start_args`] returns `None` for the three
|
||||
/// conditions under which `0x6e7550` returns without firing, and the bar then never appears.
|
||||
///
|
||||
/// Also pushes the frame's stoppable mirror (running auto-repeat OR the [`Inflight`] slot — NOT
|
||||
/// a channel, which `SpellStopCasting()` answers nil for: wow-re `esc-stopcasting.md`) into the
|
||||
@@ -631,49 +699,72 @@ fn feed_cast_bar(
|
||||
script.set_casting(
|
||||
auto_repeat.0.is_some() || inflight(&pending, &queued_melee, started, now).is_some(),
|
||||
);
|
||||
// The two bars take their names from DIFFERENT laws — see [`cast_bar_label`]. The plain
|
||||
// `Spell.dbc` name is the channel bar's (incomplete) one; the cast bar's is attribute-gated.
|
||||
let channel_name = |id: u32| -> String {
|
||||
spells
|
||||
.as_ref()
|
||||
.and_then(|s| s.catalog.get(id))
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for edge in feed.0.drain(..) {
|
||||
let (event, args): (&str, Vec<ScriptValue>) = match edge {
|
||||
let Some((event, args)): Option<(&str, Vec<ScriptValue>)> = (match edge {
|
||||
CastBarEdge::Start {
|
||||
spell_id,
|
||||
cast_time_ms,
|
||||
} => (
|
||||
} => Some((
|
||||
"SPELLCAST_START",
|
||||
vec![
|
||||
ScriptValue::Str(cast_bar_label(spells.as_deref(), spell_id)),
|
||||
ScriptValue::Int(i64::from(cast_time_ms)),
|
||||
],
|
||||
),
|
||||
CastBarEdge::Stop => ("SPELLCAST_STOP", vec![]),
|
||||
CastBarEdge::Failed => ("SPELLCAST_FAILED", vec![]),
|
||||
CastBarEdge::Interrupted => ("SPELLCAST_INTERRUPTED", vec![]),
|
||||
CastBarEdge::Delayed { delay_ms } => (
|
||||
)),
|
||||
CastBarEdge::Stop => Some(("SPELLCAST_STOP", vec![])),
|
||||
CastBarEdge::Failed => Some(("SPELLCAST_FAILED", vec![])),
|
||||
CastBarEdge::Interrupted => Some(("SPELLCAST_INTERRUPTED", vec![])),
|
||||
CastBarEdge::Delayed { delay_ms } => Some((
|
||||
"SPELLCAST_DELAYED",
|
||||
vec![ScriptValue::Int(i64::from(delay_ms))],
|
||||
),
|
||||
)),
|
||||
CastBarEdge::ChannelStart {
|
||||
spell_id,
|
||||
duration_ms,
|
||||
} => (
|
||||
"SPELLCAST_CHANNEL_START",
|
||||
vec![
|
||||
ScriptValue::Int(i64::from(duration_ms)),
|
||||
ScriptValue::Str(channel_name(spell_id)),
|
||||
],
|
||||
),
|
||||
CastBarEdge::ChannelUpdate { remaining_ms: 0 } => ("SPELLCAST_CHANNEL_STOP", vec![]),
|
||||
CastBarEdge::ChannelUpdate { remaining_ms } => (
|
||||
"SPELLCAST_CHANNEL_UPDATE",
|
||||
vec![ScriptValue::Int(i64::from(remaining_ms))],
|
||||
),
|
||||
} => {
|
||||
let args = channel_start_args(&script, spells.as_deref(), spell_id, duration_ms);
|
||||
// The channel half of the cast trace. The other five edges are traced at RECV
|
||||
// (`net::apply::spells`); these two are traced HERE because the decision that
|
||||
// wants watching — which of `0x6e7550`'s legs the spell took — is made here,
|
||||
// and the drain runs the same frame the packet lands.
|
||||
if *crate::net::CAST_TRACE {
|
||||
match args.as_deref() {
|
||||
Some([_, ScriptValue::Str(label)]) => info!(
|
||||
"cast-trace: CHANNEL_START — spell {spell_id} {duration_ms}ms, \
|
||||
bar label {label:?}"
|
||||
),
|
||||
_ => info!(
|
||||
"cast-trace: CHANNEL_START — spell {spell_id} {duration_ms}ms, \
|
||||
NO BAR (0x6e7550 gate: zero duration, no Spell.dbc row, or \
|
||||
AttributesEx3 & 0x2000)"
|
||||
),
|
||||
}
|
||||
}
|
||||
args.map(|args| ("SPELLCAST_CHANNEL_START", args))
|
||||
}
|
||||
// `0x6e75f0`'s whole shape: nonzero re-times the window, zero ends it. It reads no
|
||||
// SpellRec, so neither leg has an attribute gate — a channel whose START was
|
||||
// suppressed still sends these, and the stock Lua's `IsShown()` guards absorb them.
|
||||
CastBarEdge::ChannelUpdate { remaining_ms } => {
|
||||
let over = remaining_ms == 0;
|
||||
if *crate::net::CAST_TRACE {
|
||||
if over {
|
||||
info!("cast-trace: CHANNEL_STOP — update 0, the channel is over");
|
||||
} else {
|
||||
info!("cast-trace: CHANNEL_UPDATE — {remaining_ms}ms left");
|
||||
}
|
||||
}
|
||||
Some(if over {
|
||||
("SPELLCAST_CHANNEL_STOP", vec![])
|
||||
} else {
|
||||
(
|
||||
"SPELLCAST_CHANNEL_UPDATE",
|
||||
vec![ScriptValue::Int(i64::from(remaining_ms))],
|
||||
)
|
||||
})
|
||||
}
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
script.fire_event(event, args);
|
||||
}
|
||||
@@ -742,6 +833,128 @@ mod tests {
|
||||
assert_eq!(cast_bar_label(None, 6478), "");
|
||||
}
|
||||
|
||||
/// The CHANNEL bar's law — [`channel_start_args`], the other half of `0x6e7550`, and the
|
||||
/// **opposite default** to the cast bar's above: a channel says "Channeling" unless
|
||||
/// `AttributesEx & 0x2000_0000` permits its own name. Shapes are the real shipped rows.
|
||||
#[test]
|
||||
fn the_channel_bar_says_channeling_unless_the_spell_may_name_itself() {
|
||||
use benilla_formats::{SpellCatalog, SpellDisplay};
|
||||
let row = |name: &str, attributes_ex: u32, attributes_ex3: u32| SpellDisplay {
|
||||
name: name.to_string(),
|
||||
attributes_ex,
|
||||
attributes_ex3,
|
||||
..Default::default()
|
||||
};
|
||||
let spells = crate::ui_action::Spells {
|
||||
catalog: SpellCatalog::from_displays(
|
||||
[
|
||||
// Blizzard rank 1 — the real 5875 columns (bit 29 clear).
|
||||
(10, row("Blizzard", 0x1000_008c, 0)),
|
||||
// Fishing — one of the nine that name themselves.
|
||||
(7620, row("Fishing", 0x2100_4004, 0x0800_0000)),
|
||||
// Blood Siphon — the whole-event suppressor, both shipped rows.
|
||||
(24322, row("Blood Siphon", 0x2001_808c, 0x2000)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
..crate::ui_action::Spells::empty_for_tests()
|
||||
};
|
||||
// `FrameScript_GetText` reads the Lua global, so the VM is the source of the word — the
|
||||
// chain-backed end-to-end is `ui_script::cast_tests`; here it is set by hand so the four
|
||||
// gates are exercised without client data.
|
||||
let script = UiScript::new().expect("VM");
|
||||
script.eval::<()>("CHANNELING = \"Channeling\"").unwrap();
|
||||
|
||||
let name_of = |id: u32, ms: u32| -> Option<String> {
|
||||
channel_start_args(&script, Some(&spells), id, ms).map(|args| match &args[1] {
|
||||
ScriptValue::Str(s) => s.clone(),
|
||||
other => panic!("arg 2 is the name, got {other:?}"),
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
name_of(10, 8_000).as_deref(),
|
||||
Some("Channeling"),
|
||||
"Blizzard has AttributesEx bit 29 clear, so the bar reads the generic word"
|
||||
);
|
||||
assert_eq!(
|
||||
name_of(7620, 20_000).as_deref(),
|
||||
Some("Fishing"),
|
||||
"bit 29 set takes Name[locale] instead"
|
||||
);
|
||||
assert_eq!(
|
||||
name_of(24322, 8_000),
|
||||
None,
|
||||
"AttributesEx3 & 0x2000 fires NO event — a suppressed bar, not a blank one"
|
||||
);
|
||||
assert_eq!(name_of(10, 0), None, "0x6e7574: duration <= 0 -> no event");
|
||||
assert_eq!(
|
||||
name_of(133, 8_000),
|
||||
None,
|
||||
"0x6e7590: no SpellRec -> no event"
|
||||
);
|
||||
assert_eq!(
|
||||
channel_start_args(&script, None, 10, 8_000),
|
||||
None,
|
||||
"no client data is the same no-record leg"
|
||||
);
|
||||
|
||||
// Arg ORDER is the reverse of SPELLCAST_START's, and the stock Lua reads it that way.
|
||||
assert_eq!(
|
||||
channel_start_args(&script, Some(&spells), 10, 8_000).unwrap()[0],
|
||||
ScriptValue::Int(8_000),
|
||||
"fmt 0x8432cc is \"%d%s\" — duration first"
|
||||
);
|
||||
}
|
||||
|
||||
/// The word itself comes off the PLAYER's chain, not a table of ours — `FrameScript_GetText`
|
||||
/// on the GlobalStrings key, so a localized install gets its own. Pinned against the real
|
||||
/// `Interface\FrameXML\GlobalStrings.lua` (l.478 in the shipped enUS file, whose trailing
|
||||
/// comment is literally "Channeling tag for the channeling bar"). Skips without client data.
|
||||
#[test]
|
||||
fn the_channeling_word_comes_off_the_players_own_chain() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
use benilla_formats::{SpellCatalog, SpellDisplay};
|
||||
let script = UiScript::new().expect("VM");
|
||||
crate::ui_script::test_ui::load_ui(&script, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
let spells = crate::ui_action::Spells {
|
||||
catalog: SpellCatalog::from_displays(
|
||||
[(
|
||||
10,
|
||||
SpellDisplay {
|
||||
name: "Blizzard".into(),
|
||||
attributes_ex: 0x1000_008c,
|
||||
..Default::default()
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
..crate::ui_action::Spells::empty_for_tests()
|
||||
};
|
||||
assert_eq!(
|
||||
channel_start_args(&script, Some(&spells), 10, 8_000).unwrap()[1],
|
||||
ScriptValue::Str("Channeling".into()),
|
||||
);
|
||||
}
|
||||
|
||||
/// The suppressed channel still arms [`ActiveChannel`], so its action button stays lit: the
|
||||
/// reference's channel mirror is `SPELLCAST+0x10` (`0xceac58`), written at the cast SEND
|
||||
/// (`0x6e4f88`) and never touched by `0x6e7550`. That is why all four of the handler's gates
|
||||
/// live in the DRAIN and none of them in `net::apply::spells::channel_start`, which arms this.
|
||||
#[test]
|
||||
fn suppressing_the_channel_bar_does_not_clear_the_channel_mirror() {
|
||||
let t0 = Instant::now();
|
||||
// What `MSG_CHANNEL_START` does for Blood Siphon 24322 — the suppressed row.
|
||||
let mut channel = ActiveChannel::default();
|
||||
channel.start(24322, 8_000, t0);
|
||||
assert_eq!(
|
||||
channel.current(t0 + Duration::from_secs(1)),
|
||||
Some(24322),
|
||||
"the wire edge arms the mirror regardless of what the bar does with it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_guard_is_open() {
|
||||
let g = PendingCast::default();
|
||||
|
||||
@@ -440,12 +440,7 @@ fn slot_entry(store: &ObjectStore, items: &Items, slot0: u8) -> Option<u32> {
|
||||
|
||||
/// The equipped item's weapon-skill line id: the vmangos `Item.cpp` subclass table for a class-2
|
||||
/// item, unarmed (162) for an empty hand or a non-weapon / unmapped subclass.
|
||||
fn weapon_skill_id(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
commands: &NetCommands,
|
||||
slot0: u8,
|
||||
) -> u32 {
|
||||
fn weapon_skill_id(store: &ObjectStore, items: &Items, commands: &NetCommands, slot0: u8) -> u32 {
|
||||
let Some(entry) = slot_entry(store, items, slot0) else {
|
||||
return SKILL_UNARMED;
|
||||
};
|
||||
@@ -589,7 +584,7 @@ pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
|
||||
|
||||
/// The player's snapshot: [`unit_combat_stats`] plus the half only *we* have — the two values that
|
||||
/// need the equipped items' templates, and the three `PLAYER_SKILL_INFO` pairs.
|
||||
fn combat_stats(store: &ObjectStore, items: &mut Items, commands: &NetCommands) -> UnitCombatStats {
|
||||
fn combat_stats(store: &ObjectStore, items: &Items, commands: &NetCommands) -> UnitCombatStats {
|
||||
// The offhand-speed gate is an offhand *weapon* (a shield doesn't swing); the wand check is
|
||||
// the ranged item's subclass. Both read the equipped item's template (ask-once in flight →
|
||||
// false this frame, refined when it lands).
|
||||
@@ -643,12 +638,12 @@ fn combat_stats(store: &ObjectStore, items: &mut Items, commands: &NetCommands)
|
||||
/// 64..=69). Everything past the guid — template, enchants, durability, the pending lock — is
|
||||
/// identical for both, which is the whole reason there is one function here and not two.
|
||||
fn slot_view(
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
commands: &NetCommands,
|
||||
pending: &PendingItemOps,
|
||||
names: &mut crate::names::NameCache,
|
||||
names: &crate::names::NameCache,
|
||||
// `ItemSubClass.dbc` — the count gate below reads its `DisplayFlags` bit 2. `None` (the DBC
|
||||
// failed to load) leaves every bag at 0, which is the plain-bag answer and the safe one.
|
||||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||||
@@ -810,12 +805,12 @@ fn ammo_count(store: &ObjectStore, items: &Items, ammo_id: u32) -> u32 {
|
||||
/// 0-based inv-slot array.
|
||||
fn inventory_slots(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
commands: &NetCommands,
|
||||
pending: &PendingItemOps,
|
||||
names: &mut crate::names::NameCache,
|
||||
names: &crate::names::NameCache,
|
||||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||||
classes: Option<&benilla_formats::ChrClasses>,
|
||||
) -> InventorySlots {
|
||||
@@ -905,12 +900,12 @@ fn inventory_slots(
|
||||
/// doll snapshot, rather than the `BankState` the bank window pushes.
|
||||
fn bank_bag_slots(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
commands: &NetCommands,
|
||||
pending: &PendingItemOps,
|
||||
names: &mut crate::names::NameCache,
|
||||
names: &crate::names::NameCache,
|
||||
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
||||
) -> BankBagSlots {
|
||||
let mut bags: BankBagSlots = Default::default();
|
||||
@@ -1051,7 +1046,7 @@ pub(crate) fn feed_char(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
// `SpellItemEnchantment`'s name column — the equipped tooltip's enchant lines (decision 0915).
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
@@ -1061,7 +1056,7 @@ pub(crate) fn feed_char(
|
||||
mut feed: ResMut<CharFeedState>,
|
||||
mut booth: ResMut<PaperDollBooth>,
|
||||
pending: Res<PendingItemOps>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
names: Res<crate::names::NameCache>,
|
||||
// `ItemSubClass.dbc` — `GetInventoryItemCount`'s bag gate (`slot_view`'s own note).
|
||||
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
) {
|
||||
@@ -1164,7 +1159,7 @@ pub(crate) fn feed_char(
|
||||
return;
|
||||
}
|
||||
|
||||
let stats = combat_stats(store, &mut items, &commands);
|
||||
let stats = combat_stats(store, &items, &commands);
|
||||
if memo.last_stats.as_ref() != Some(&stats) {
|
||||
gate.audit("feed_char", "the combat-stats snapshot");
|
||||
// PUSH before firing: event dispatch runs the Lua handlers synchronously, so the snapshot
|
||||
@@ -1178,7 +1173,7 @@ pub(crate) fn feed_char(
|
||||
|
||||
let inv = inventory_slots(
|
||||
store,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
crate::items::RollCatalogs {
|
||||
enchants: enchants.as_deref(),
|
||||
@@ -1186,13 +1181,13 @@ pub(crate) fn feed_char(
|
||||
},
|
||||
&commands,
|
||||
&pending,
|
||||
&mut names,
|
||||
&names,
|
||||
sub_classes.as_deref(),
|
||||
classes.as_deref().map(|t| &t.0),
|
||||
);
|
||||
let bank_bags = bank_bag_slots(
|
||||
store,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
crate::items::RollCatalogs {
|
||||
enchants: enchants.as_deref(),
|
||||
@@ -1200,7 +1195,7 @@ pub(crate) fn feed_char(
|
||||
},
|
||||
&commands,
|
||||
&pending,
|
||||
&mut names,
|
||||
&names,
|
||||
sub_classes.as_deref(),
|
||||
);
|
||||
// One transition for both bands: they are the same descriptor read at two offsets, and
|
||||
|
||||
@@ -1063,11 +1063,7 @@ pub(crate) fn power_word(script: &benilla_ui::script::UiScript, power: u32) -> O
|
||||
/// same ask-once name cache every other client-composed chat line waits on. `None` = not yet
|
||||
/// answered; the caller re-tries next frame, exactly as the reference's deferred-name queue
|
||||
/// (`DAT_00c4e208`, drained by the name-ready callback `0x6294b0`) replays its message.
|
||||
pub(crate) fn object_name(
|
||||
guid: u64,
|
||||
names: &mut NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
pub(crate) fn object_name(guid: u64, names: &NameCache, commands: &NetCommands) -> Option<String> {
|
||||
// Guid 0 = "the name is already in the fills" — no wire endpoint is ever guid 0, so the
|
||||
// sentinel costs nothing and is what lets `/chattest` drive the real drain with literal names.
|
||||
if guid == 0 {
|
||||
|
||||
@@ -770,7 +770,7 @@ pub(super) fn feed_chat(
|
||||
mut log: ResMut<ChatLog>,
|
||||
mut windows: ResMut<ChatWindows>,
|
||||
mut channels: ResMut<ChannelState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
mut speaker: SpeakerEffects,
|
||||
commands: Res<NetCommands>,
|
||||
// The text-emote sentence seam (decision 1274): the tables, plus the guid the composer's
|
||||
@@ -787,7 +787,7 @@ pub(super) fn feed_chat(
|
||||
// The combat log's item-name seam (1703): the four families whose sentence names an ITEM
|
||||
// (`TRADESKILL_LOG`, `FEEDPET_LOG`, `ITEMENCHANTMENT*`, `SPELLDURABILITYDAMAGE`) resolve it
|
||||
// here, through the same ask-once cache the reference's own deferred queue re-runs against.
|
||||
mut items: ResMut<crate::items::Items>,
|
||||
items: Res<crate::items::Items>,
|
||||
// The two 1.12 text filters (decision 2077), bundled for the same reason `SpeakerEffects` is:
|
||||
// this list is at the sixteen-parameter ceiling. This IS the reference's `0x49a870`, so both
|
||||
// arms belong here and nowhere else.
|
||||
@@ -862,7 +862,7 @@ pub(super) fn feed_chat(
|
||||
subject_guid,
|
||||
&guids,
|
||||
&stores,
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
);
|
||||
let (text, clean) = crate::npc_text::substitute_checked(
|
||||
@@ -1257,7 +1257,7 @@ pub(super) fn feed_chat(
|
||||
if guid == 0 {
|
||||
continue;
|
||||
}
|
||||
match super::combat::object_name(guid, &mut names, &commands) {
|
||||
match super::combat::object_name(guid, &names, &commands) {
|
||||
Some(name) if slot == 0 => line.fills.attacker = name,
|
||||
Some(name) => line.fills.victim = name,
|
||||
None => wait = true,
|
||||
@@ -1278,7 +1278,7 @@ pub(super) fn feed_chat(
|
||||
}
|
||||
}
|
||||
super::combat::Named::Unit(guid) => {
|
||||
match super::combat::object_name(guid, &mut names, &commands) {
|
||||
match super::combat::object_name(guid, &names, &commands) {
|
||||
Some(name) => line.fills.named = name,
|
||||
None => wait = true,
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::target::Selection;
|
||||
/// ref's silent no-op). The name is cache-resolved — a streamed player target is always cached.
|
||||
fn target_player_name(
|
||||
selection: &Selection,
|
||||
names: &mut crate::names::NameCache,
|
||||
names: &crate::names::NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
let guid = selection.guid?;
|
||||
@@ -641,7 +641,7 @@ pub(super) fn drain_chat_input(
|
||||
},
|
||||
ParsedChat::Invite { name } => {
|
||||
if let Some(name) =
|
||||
name.or_else(|| target_player_name(&selection, &mut names, &commands))
|
||||
name.or_else(|| target_player_name(&selection, &names, &commands))
|
||||
{
|
||||
let _ = commands.0.send(ClientCommand::GroupInvite { name });
|
||||
}
|
||||
@@ -654,14 +654,14 @@ pub(super) fn drain_chat_input(
|
||||
}
|
||||
ParsedChat::Uninvite { name } => {
|
||||
if let Some(name) =
|
||||
name.or_else(|| target_player_name(&selection, &mut names, &commands))
|
||||
name.or_else(|| target_player_name(&selection, &names, &commands))
|
||||
{
|
||||
let _ = commands.0.send(ClientCommand::GroupUninvite { name });
|
||||
}
|
||||
}
|
||||
ParsedChat::Promote { name } => {
|
||||
if let Some(name) =
|
||||
name.or_else(|| target_player_name(&selection, &mut names, &commands))
|
||||
name.or_else(|| target_player_name(&selection, &names, &commands))
|
||||
{
|
||||
// The 1.12 wire form is a guid (CMSG_GROUP_SET_LEADER) — resolve against the
|
||||
// roster; a miss answers with the server's own would-be error string
|
||||
@@ -692,7 +692,7 @@ pub(super) fn drain_chat_input(
|
||||
// player gate, arbiter echo) instead of a second one here.
|
||||
ParsedChat::Duel { name } => {
|
||||
if let Some(name) =
|
||||
name.or_else(|| target_player_name(&selection, &mut names, &commands))
|
||||
name.or_else(|| target_player_name(&selection, &names, &commands))
|
||||
{
|
||||
script.queue_duel_request(benilla_ui::script::DuelRequest::StartByName(name));
|
||||
}
|
||||
@@ -710,7 +710,7 @@ pub(super) fn drain_chat_input(
|
||||
// GetSlashCmdTarget(msg)` guard does.
|
||||
ParsedChat::Target { name } => {
|
||||
if let Some(name) =
|
||||
name.or_else(|| target_player_name(&selection, &mut names, &commands))
|
||||
name.or_else(|| target_player_name(&selection, &names, &commands))
|
||||
{
|
||||
chat_out
|
||||
.target
|
||||
|
||||
@@ -140,7 +140,7 @@ fn feed_craft(
|
||||
focus: Option<Res<SpellFocus>>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<CraftState>>>,
|
||||
) {
|
||||
|
||||
@@ -391,7 +391,7 @@ fn stone_line(
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct MeetingStoneInputs<'w, 's> {
|
||||
areas: Option<Res<'w, AreaTableRes>>,
|
||||
names: ResMut<'w, NameCache>,
|
||||
names: Res<'w, NameCache>,
|
||||
commands: Res<'w, NetCommands>,
|
||||
visuals: Option<Res<'w, SpellVisuals>>,
|
||||
fx: MessageWriter<'w, SpellKitFx>,
|
||||
|
||||
@@ -198,7 +198,7 @@ impl DressUpRoom {
|
||||
/// Resolve whatever is still waiting on a template answer. `Items::template` asks once and
|
||||
/// answers on a later frame; an id the server never answers for simply stays pending, showing
|
||||
/// the player's own gear in that slot rather than a hole.
|
||||
fn resolve_pending(&mut self, items: &mut Items, commands: &NetCommands) {
|
||||
fn resolve_pending(&mut self, items: &Items, commands: &NetCommands) {
|
||||
let mut pending = std::mem::take(&mut self.pending);
|
||||
pending.retain(|item| {
|
||||
let Some(t) = items.template(*item, 0, commands) else {
|
||||
@@ -237,7 +237,7 @@ fn feed_dressup(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut room: ResMut<DressUpRoom>,
|
||||
mut preview: ResMut<DressUpPreview>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
self_q: Query<(&ObjectStore, &NetEntity), With<SelfPlayer>>,
|
||||
// The guild identity cache (1257) — `ResMut` because it is lazy: a miss is what sends the
|
||||
@@ -268,14 +268,14 @@ fn feed_dressup(
|
||||
// law, 0208 §5 / 1751).
|
||||
preview.yaw = script.model_pane_facing("DressUpModel");
|
||||
|
||||
room.resolve_pending(&mut items, &commands);
|
||||
room.resolve_pending(&items, &commands);
|
||||
|
||||
// The guild join is the caller's, not [`player_look`]'s: that function resolves an *outfit* out
|
||||
// of the descriptor and the room's substitutions, and the crest is neither — it comes off a
|
||||
// separate lazy cache this system holds the handle to.
|
||||
let look = match (room.open, self_q.single().ok()) {
|
||||
(true, Some((store, net))) => {
|
||||
player_look(store, net, &room, &mut items, &commands).map(|l| DressUpLook {
|
||||
player_look(store, net, &room, &items, &commands).map(|l| DressUpLook {
|
||||
emblem: crate::ui_guild::unit_guild_emblem(&store.0, &mut guilds, &commands),
|
||||
..l
|
||||
})
|
||||
@@ -294,7 +294,7 @@ fn player_look(
|
||||
store: &ObjectStore,
|
||||
net: &NetEntity,
|
||||
room: &DressUpRoom,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> Option<DressUpLook> {
|
||||
let s = &store.0;
|
||||
@@ -417,9 +417,9 @@ mod tests {
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::Dress);
|
||||
room.apply(DressUpIntent::TryOn(2000));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look");
|
||||
assert_eq!(
|
||||
look.equipment[4].display_id, 7000,
|
||||
"the tried-on chest shows"
|
||||
@@ -446,9 +446,9 @@ mod tests {
|
||||
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::TryOn(2000));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
assert_eq!(
|
||||
player_look(&store, &net(), &room, &mut items, &cmds)
|
||||
player_look(&store, &net(), &room, &items, &cmds)
|
||||
.unwrap()
|
||||
.equipment[4]
|
||||
.display_id,
|
||||
@@ -457,7 +457,7 @@ mod tests {
|
||||
|
||||
room.apply(DressUpIntent::Dress);
|
||||
assert_eq!(
|
||||
player_look(&store, &net(), &room, &mut items, &cmds)
|
||||
player_look(&store, &net(), &room, &items, &cmds)
|
||||
.unwrap()
|
||||
.equipment[4]
|
||||
.display_id,
|
||||
@@ -485,9 +485,9 @@ mod tests {
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::Dress);
|
||||
room.apply(DressUpIntent::TryOn(2000));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
room.apply(DressUpIntent::Undress);
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).unwrap();
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).unwrap();
|
||||
assert_eq!(look.equipment[4].display_id, 0, "the tried-on chest is off");
|
||||
assert_eq!(look.equipment[0].display_id, 0, "the worn helm is off");
|
||||
assert_eq!(
|
||||
@@ -496,8 +496,8 @@ mod tests {
|
||||
);
|
||||
|
||||
room.apply(DressUpIntent::TryOn(2000));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).unwrap();
|
||||
room.resolve_pending(&items, &cmds);
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).unwrap();
|
||||
assert_eq!(
|
||||
look.equipment[4].display_id, 7000,
|
||||
"a try-on lands on the bare body"
|
||||
@@ -508,7 +508,7 @@ mod tests {
|
||||
);
|
||||
|
||||
room.apply(DressUpIntent::Dress);
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).unwrap();
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).unwrap();
|
||||
assert_eq!(
|
||||
look.equipment[4].display_id, 5000,
|
||||
"Dress puts the player's own chest back"
|
||||
@@ -528,10 +528,10 @@ mod tests {
|
||||
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::TryOn(2000));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
assert_eq!(room.pending, vec![2000], "still waiting on the answer");
|
||||
assert_eq!(
|
||||
player_look(&store, &net(), &room, &mut items, &cmds)
|
||||
player_look(&store, &net(), &room, &items, &cmds)
|
||||
.unwrap()
|
||||
.equipment[4]
|
||||
.display_id,
|
||||
@@ -546,10 +546,10 @@ mod tests {
|
||||
assert_eq!(asks, 1);
|
||||
|
||||
items.insert_template(2000, Some(worn("Shiny Chest", 7000, 5)));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
assert!(room.pending.is_empty());
|
||||
assert_eq!(
|
||||
player_look(&store, &net(), &room, &mut items, &cmds)
|
||||
player_look(&store, &net(), &room, &items, &cmds)
|
||||
.unwrap()
|
||||
.equipment[4]
|
||||
.display_id,
|
||||
@@ -567,7 +567,7 @@ mod tests {
|
||||
items.insert_template(3000, Some(worn("Healing Potion", 9000, 0)));
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::TryOn(3000));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
assert!(room.pending.is_empty(), "resolved, just not worn anywhere");
|
||||
assert!(room.worn.iter().all(Option::is_none));
|
||||
}
|
||||
@@ -593,9 +593,9 @@ mod tests {
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::Dress);
|
||||
room.apply(DressUpIntent::TryOn(item));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look");
|
||||
assert_eq!(
|
||||
look.equipment[17].display_id, display,
|
||||
"the {what} is in the ranged slot, which is what puts it in a hand"
|
||||
@@ -622,7 +622,7 @@ mod tests {
|
||||
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::Dress);
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look");
|
||||
assert_eq!(
|
||||
look.equipment[17].display_id, 0,
|
||||
"the worn bow stays stowed"
|
||||
@@ -644,11 +644,11 @@ mod tests {
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::Dress);
|
||||
room.apply(DressUpIntent::TryOn(2500));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
room.apply(DressUpIntent::TryOn(2700));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look");
|
||||
assert_eq!(look.equipment[15].display_id, 8700, "the axe is in hand");
|
||||
assert_eq!(look.equipment[17].display_id, 0, "…and the bow is gone");
|
||||
}
|
||||
@@ -668,13 +668,13 @@ mod tests {
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::Dress);
|
||||
room.apply(DressUpIntent::TryOn(1600));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
|
||||
let look = player_look(&player(&[(15, 1500)]), &net(), &room, &mut items, &cmds).unwrap();
|
||||
let look = player_look(&player(&[(15, 1500)]), &net(), &room, &items, &cmds).unwrap();
|
||||
assert_eq!(look.equipment[15].display_id, 5500, "the one-hander stays");
|
||||
assert_eq!(look.equipment[16].display_id, 5600, "…beside the shield");
|
||||
|
||||
let look = player_look(&player(&[(15, 1550)]), &net(), &room, &mut items, &cmds).unwrap();
|
||||
let look = player_look(&player(&[(15, 1550)]), &net(), &room, &items, &cmds).unwrap();
|
||||
assert_eq!(
|
||||
look.equipment[15].display_id, 0,
|
||||
"the two-hander is evicted"
|
||||
@@ -714,9 +714,9 @@ mod tests {
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::Dress);
|
||||
room.apply(DressUpIntent::TryOn(2000));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
room.resolve_pending(&items, &cmds);
|
||||
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look");
|
||||
assert_eq!(look.equipment[7].display_id, 7000, "the boots went on");
|
||||
assert_eq!(
|
||||
(look.equipment[15].display_id, look.equipment[16].display_id),
|
||||
@@ -747,7 +747,7 @@ mod tests {
|
||||
|
||||
let mut room = DressUpRoom::default();
|
||||
room.apply(DressUpIntent::Dress);
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look");
|
||||
assert_eq!(
|
||||
look.equipment[0].display_id, 0,
|
||||
"the player's own helm is hidden"
|
||||
@@ -761,8 +761,8 @@ mod tests {
|
||||
// A try-on is not their own gear, so nothing suppresses it — this is the branch the
|
||||
// reference reaches by never testing the flag on the TryOn path at all.
|
||||
room.apply(DressUpIntent::TryOn(2000));
|
||||
room.resolve_pending(&mut items, &cmds);
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
|
||||
room.resolve_pending(&items, &cmds);
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look");
|
||||
assert_eq!(
|
||||
look.equipment[0].display_id, 7000,
|
||||
"the tried-on helm previews regardless of the preference"
|
||||
@@ -770,7 +770,7 @@ mod tests {
|
||||
|
||||
// …and Reset drops the substitution, so the mannequin goes back to bare-headed.
|
||||
room.apply(DressUpIntent::Dress);
|
||||
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
|
||||
let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look");
|
||||
assert_eq!(
|
||||
look.equipment[0].display_id, 0,
|
||||
"Reset re-clones the hidden helm away"
|
||||
|
||||
@@ -255,7 +255,7 @@ pub(crate) mod apply {
|
||||
fn feed_duel(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut duel: ResMut<DuelState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
mut fed: Local<crate::ui_script::VmMemo<FedDuel>>,
|
||||
|
||||
@@ -313,7 +313,7 @@ fn feed_gossip(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
state: Res<GossipState>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<GossipMenu>>>,
|
||||
@@ -350,7 +350,7 @@ fn feed_gossip(
|
||||
let mut fresh = snapshot(&state);
|
||||
// Expand the greeting's chat-text macros ($N/$B/$G/$<n>w) client-side, as the real client does.
|
||||
if let Some(greeting) = fresh.as_mut().map(|m| &mut m.greeting) {
|
||||
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let player = crate::npc_text::player_identity(&self_q, &names, &commands);
|
||||
*greeting = crate::npc_text::substitute(
|
||||
greeting,
|
||||
&crate::npc_text::MacroContext {
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
//! (`0x4d1160`). See [`RosterUpdate`]: the two halves are one mechanism, and implementing either
|
||||
//! without the other breaks it.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use benilla_formats::GuildEmblem;
|
||||
use benilla_protocol::messages::{
|
||||
guild_event, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
|
||||
@@ -47,6 +45,7 @@ use benilla_ui::script::{LastOnline, UnitGuild};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::query_cache::QueryCache;
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
mod feed;
|
||||
@@ -158,14 +157,14 @@ pub(crate) struct GuildState {
|
||||
guild_id: u32,
|
||||
/// Our own `PLAYER_GUILDRANK` (field 192), **0-based**, `0` = guild master.
|
||||
rank_index: u32,
|
||||
/// Guild id → identity, from `SMSG_GUILD_QUERY_RESPONSE`. Holds negatives (module doc).
|
||||
identities: HashMap<u32, Identity>,
|
||||
/// Guild id → identity, from `SMSG_GUILD_QUERY_RESPONSE`, ask-once through [`QueryCache`]
|
||||
/// (decision 2288). Holds negatives (module doc).
|
||||
identities: QueryCache<u32, Identity>,
|
||||
/// Bumped by every landed identity ([`Self::apply_query_response`]) — never by an ask. The
|
||||
/// gated unit feeds' watch counter (decision 1439): `unit_guild`'s miss resolves later, and
|
||||
/// `is_changed` cannot flag the landing because the miss itself takes `&mut self` per frame.
|
||||
/// `is_changed` could not flag the landing while the miss itself took `&mut self` per frame
|
||||
/// (2288 made it `&self`; the counter stays as the finer signal).
|
||||
identity_generation: u64,
|
||||
/// Guild ids with a `CMSG_GUILD_QUERY` in flight — the ask-once gate.
|
||||
queried: HashSet<u32>,
|
||||
/// The message of the day. Kept beside the roster rather than inside it because `GE_MOTD`
|
||||
/// updates it on its own, and at login vmangos sends that event *before* any roster exists
|
||||
/// (`CharacterHandler.cpp:558`).
|
||||
@@ -209,6 +208,12 @@ pub(crate) struct GuildState {
|
||||
pending_info: Vec<GuildInfo>,
|
||||
}
|
||||
|
||||
impl crate::query_cache::AskOnce for GuildState {
|
||||
fn clear_pending(&mut self) {
|
||||
self.identities.clear_pending();
|
||||
}
|
||||
}
|
||||
|
||||
impl GuildState {
|
||||
/// Are we in a guild? Our own `PLAYER_GUILDID`, nothing else — answerable before any packet.
|
||||
pub(crate) fn in_guild(&self) -> bool {
|
||||
@@ -269,25 +274,24 @@ impl GuildState {
|
||||
/// Ask for a guild's identity if we do not already hold one — the lazy cache fill (module
|
||||
/// doc). Nothing else in this client ever sends `CMSG_GUILD_QUERY`: no world-entry priming, no
|
||||
/// sweep. A negative (`""` name) counts as held, so it is never re-asked.
|
||||
fn request_identity(&mut self, guild_id: u32, commands: &NetCommands) {
|
||||
if guild_id != 0
|
||||
&& !self.identities.contains_key(&guild_id)
|
||||
&& self.queried.insert(guild_id)
|
||||
{
|
||||
let _ = commands.0.send(ClientCommand::GuildQuery { guild_id });
|
||||
fn request_identity(&self, guild_id: u32, commands: &NetCommands) {
|
||||
if guild_id != 0 {
|
||||
self.identities.get_or_ask(guild_id, || {
|
||||
let _ = commands.0.send(ClientCommand::GuildQuery { guild_id });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A guild's identity, if we hold a real one. `None` covers both "not asked yet / in flight"
|
||||
/// and "no such guild".
|
||||
fn identity(&self, guild_id: u32) -> Option<&Identity> {
|
||||
self.identities.get(&guild_id).filter(|i| i.exists())
|
||||
self.identities.get(guild_id).filter(|i| i.exists())
|
||||
}
|
||||
|
||||
/// [`Self::request_identity`] then [`Self::identity`] — the read that also asks, shaped like
|
||||
/// [`crate::names::NameCache::resolve`]: it answers `None` for *this* call and the answer
|
||||
/// arrives later, which is the reference's behaviour and not a gap to paper over.
|
||||
fn resolve_identity(&mut self, guild_id: u32, commands: &NetCommands) -> Option<&Identity> {
|
||||
fn resolve_identity(&self, guild_id: u32, commands: &NetCommands) -> Option<&Identity> {
|
||||
self.request_identity(guild_id, commands);
|
||||
self.identity(guild_id)
|
||||
}
|
||||
@@ -302,7 +306,7 @@ impl GuildState {
|
||||
/// while it has not arrived or the player has no guild. A miss sends the query, the lazy-cache
|
||||
/// idiom every other read of this cache uses.
|
||||
pub(crate) fn own_emblem_record(
|
||||
&mut self,
|
||||
&self,
|
||||
guild_id: u32,
|
||||
commands: &NetCommands,
|
||||
) -> Option<[i32; 5]> {
|
||||
@@ -320,8 +324,7 @@ impl GuildState {
|
||||
/// dropped so the next query anywhere re-fetches it — the tabards of every member in sight
|
||||
/// re-dress off the arrival, as the reference's guild-appearance refresh does.
|
||||
pub(crate) fn evict_own_identity(&mut self) {
|
||||
if self.identities.remove(&self.guild_id).is_some() {
|
||||
self.queried.remove(&self.guild_id);
|
||||
if self.identities.evict(self.guild_id) {
|
||||
self.identity_generation = self.identity_generation.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
}
|
||||
@@ -329,11 +332,10 @@ impl GuildState {
|
||||
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE` — fill (or negatively fill) the identity cache.
|
||||
fn apply_query_response(&mut self, response: GuildQueryResponse) {
|
||||
self.queried.remove(&response.guild_id);
|
||||
let ours = response.guild_id == self.guild_id;
|
||||
self.identities.insert(
|
||||
response.guild_id,
|
||||
Identity {
|
||||
Some(Identity {
|
||||
name: response.name,
|
||||
rank_names: response.rank_names,
|
||||
emblem: GuildEmblem {
|
||||
@@ -343,7 +345,7 @@ impl GuildState {
|
||||
border_color: response.border_color,
|
||||
background_color: response.background_color,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
self.identity_generation = self.identity_generation.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
@@ -385,8 +387,7 @@ impl GuildState {
|
||||
// this packet.
|
||||
let rank = notice.params.first().and_then(|p| p.parse::<usize>().ok());
|
||||
let name = notice.params.get(1).cloned().unwrap_or_default();
|
||||
if let (Some(rank), Some(identity)) =
|
||||
(rank, self.identities.get_mut(&self.guild_id))
|
||||
if let (Some(rank), Some(identity)) = (rank, self.identities.get_mut(self.guild_id))
|
||||
{
|
||||
if let Some(slot) = identity.rank_names.get_mut(rank) {
|
||||
*slot = name;
|
||||
@@ -781,6 +782,7 @@ pub(crate) struct UiGuildPlugin;
|
||||
|
||||
impl Plugin for UiGuildPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
crate::query_cache::register::<GuildState>(app);
|
||||
app.init_resource::<GuildState>()
|
||||
.init_resource::<GuildMemberNotify>()
|
||||
.add_systems(
|
||||
@@ -993,8 +995,8 @@ mod tests {
|
||||
name: String::new(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(guild.identities.contains_key(&7), "cached as a negative");
|
||||
assert!(!guild.queried.contains(&7), "and no longer in flight");
|
||||
assert!(guild.identities.answered(7), "cached as a negative");
|
||||
assert!(!guild.identities.is_pending(7), "and no longer in flight");
|
||||
assert!(
|
||||
guild.resolve_identity(7, &commands).is_none(),
|
||||
"an empty name is not a guild"
|
||||
@@ -1091,7 +1093,9 @@ mod tests {
|
||||
#[test]
|
||||
fn leaving_the_guild_drops_the_roster_but_not_the_identities() {
|
||||
let mut guild = GuildState::default();
|
||||
guild.identities.insert(7, identity("Legacy", &["GM"]));
|
||||
guild
|
||||
.identities
|
||||
.insert(7, Some(identity("Legacy", &["GM"])));
|
||||
assert!(guild.mirror_self(7, 0), "joined");
|
||||
guild.apply_roster(GuildRoster {
|
||||
motd: "Raid at eight".into(),
|
||||
@@ -1107,7 +1111,7 @@ mod tests {
|
||||
assert!(guild.members.is_empty());
|
||||
assert!(guild.motd.is_empty());
|
||||
assert_eq!(guild.selection, 0);
|
||||
assert!(guild.identities.contains_key(&7), "identities survive");
|
||||
assert!(guild.identities.answered(7), "identities survive");
|
||||
|
||||
assert!(!guild.mirror_self(0, 0), "no edge when nothing moved");
|
||||
}
|
||||
@@ -1182,13 +1186,13 @@ mod tests {
|
||||
guild.mirror_self(7, 0);
|
||||
guild
|
||||
.identities
|
||||
.insert(7, identity("Legacy", &["GM", "Off"]));
|
||||
.insert(7, Some(identity("Legacy", &["GM", "Off"])));
|
||||
guild.apply_event(&event(
|
||||
guild_event::UPDATE_RANK_NAME,
|
||||
&["1", "Officer"],
|
||||
None,
|
||||
));
|
||||
assert_eq!(guild.identities[&7].rank_name(1), "Officer");
|
||||
assert_eq!(guild.identities.get(7).unwrap().rank_name(1), "Officer");
|
||||
assert!(guild.dirty);
|
||||
}
|
||||
|
||||
@@ -1256,7 +1260,7 @@ mod tests {
|
||||
let mut guild = GuildState::default();
|
||||
guild
|
||||
.identities
|
||||
.insert(7, identity("Legacy", &["GM", "Off"]));
|
||||
.insert(7, Some(identity("Legacy", &["GM", "Off"])));
|
||||
|
||||
// A creature, and a guildless player: no PLAYER_GUILDID at all.
|
||||
let guildless = ObjectFields::from_pairs(&[]);
|
||||
@@ -1297,9 +1301,9 @@ mod tests {
|
||||
let mut guild = GuildState::default();
|
||||
guild
|
||||
.identities
|
||||
.insert(7, identity("Legacy", &["GM", "Off"]));
|
||||
.insert(7, Some(identity("Legacy", &["GM", "Off"])));
|
||||
// The negative cache: a query that came back empty is "no such guild", not a blank name.
|
||||
guild.identities.insert(8, identity("", &[]));
|
||||
guild.identities.insert(8, Some(identity("", &[])));
|
||||
|
||||
let guildless = ObjectFields::from_pairs(&[]);
|
||||
assert_eq!(unit_guild_name(&guildless, &mut guild, &commands), None);
|
||||
|
||||
@@ -99,7 +99,7 @@ impl Plugin for InspectUiPlugin {
|
||||
/// data the reference's own inspect tooltip has.
|
||||
fn inspect_slot_view(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
commands: &NetCommands,
|
||||
@@ -160,7 +160,7 @@ fn feed_inspect(
|
||||
mut target: ResMut<InspectTarget>,
|
||||
mut feed: ResMut<InspectFeedState>,
|
||||
mut booth: ResMut<InspectBooth>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
// `SpellItemEnchantment`'s name column — the inspected item's enchant line (decision 0915) —
|
||||
// and `ItemRandomProperties`, the roll behind its "of the Monkey" name (1547).
|
||||
@@ -233,7 +233,7 @@ fn feed_inspect(
|
||||
for slot in 1..=19u8 {
|
||||
slots[usize::from(slot)] = inspect_slot_view(
|
||||
store,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
crate::items::RollCatalogs {
|
||||
enchants: catalogs.0.as_deref(),
|
||||
|
||||
@@ -28,8 +28,6 @@
|
||||
//! bar) never fires — no translation mechanic on a private server's readables, and the `language`
|
||||
//! column (`data[1]`/`LanguageID`) is carried by neither source here.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{ItemTextState, UiScript};
|
||||
@@ -38,6 +36,7 @@ use crate::go_templates::GameObjectTemplates;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::query_cache::QueryCache;
|
||||
use crate::ui_mail::MailOpen;
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
@@ -56,27 +55,30 @@ pub(crate) struct PageText {
|
||||
/// per-page fetch is still here for the server that answers one at a time.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PageTexts {
|
||||
pages: HashMap<u32, PageText>,
|
||||
pending: HashSet<u32>,
|
||||
pages: QueryCache<u32, PageText>,
|
||||
}
|
||||
|
||||
impl crate::query_cache::AskOnce for PageTexts {
|
||||
fn clear_pending(&mut self) {
|
||||
self.pages.clear_pending();
|
||||
}
|
||||
}
|
||||
|
||||
impl PageTexts {
|
||||
/// Record a page (and clear its in-flight flag).
|
||||
pub(crate) fn insert(&mut self, page_id: u32, text: String, next: u32) {
|
||||
self.pending.remove(&page_id);
|
||||
self.pages.insert(page_id, PageText { text, next });
|
||||
self.pages.insert(page_id, Some(PageText { text, next }));
|
||||
}
|
||||
|
||||
/// The cached page, asking for it once if this is the first asker. `guid` names the object
|
||||
/// doing the reading (the reference writes it after the page id; vmangos discards it).
|
||||
fn get_or_ask(&mut self, page_id: u32, guid: u64, commands: &NetCommands) -> Option<&PageText> {
|
||||
if !self.pages.contains_key(&page_id) && self.pending.insert(page_id) {
|
||||
fn get_or_ask(&self, page_id: u32, guid: u64, commands: &NetCommands) -> Option<&PageText> {
|
||||
self.pages.get_or_ask(page_id, || {
|
||||
debug!("page text: asking page {page_id} (object {guid:#x})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::PageTextQuery { page_id, guid });
|
||||
}
|
||||
self.pages.get(&page_id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +182,7 @@ pub(crate) struct UiItemTextPlugin;
|
||||
|
||||
impl Plugin for UiItemTextPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
crate::query_cache::register::<PageTexts>(app);
|
||||
app.init_resource::<ItemTextOpen>()
|
||||
.init_resource::<PageTexts>()
|
||||
.add_systems(
|
||||
@@ -214,8 +217,8 @@ struct Readable {
|
||||
/// is FrameXML's "Parchment". A GameObject whose template is not cached yet is asked for.
|
||||
pub(crate) fn object_material(
|
||||
guid: u64,
|
||||
items: &mut Items,
|
||||
go_templates: &mut GameObjectTemplates,
|
||||
items: &Items,
|
||||
go_templates: &GameObjectTemplates,
|
||||
materials: Option<&PageMaterials>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
@@ -239,7 +242,7 @@ pub(crate) fn object_material(
|
||||
/// single resolve serves both sources.
|
||||
fn readable(
|
||||
guid: u64,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
go_templates: &GameObjectTemplates,
|
||||
materials: Option<&PageMaterials>,
|
||||
commands: &NetCommands,
|
||||
@@ -270,10 +273,10 @@ fn readable(
|
||||
fn feed_item_text(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<ItemTextOpen>,
|
||||
mut mail: ResMut<MailOpen>,
|
||||
mut pages: ResMut<PageTexts>,
|
||||
mut items: ResMut<Items>,
|
||||
mut names: ResMut<NameCache>,
|
||||
mail: Res<MailOpen>,
|
||||
pages: Res<PageTexts>,
|
||||
items: Res<Items>,
|
||||
names: Res<NameCache>,
|
||||
go_templates: Res<GameObjectTemplates>,
|
||||
materials: Option<Res<PageMaterials>>,
|
||||
commands: Res<NetCommands>,
|
||||
@@ -298,7 +301,7 @@ fn feed_item_text(
|
||||
}
|
||||
let Some(readable) = readable(
|
||||
sess.object_guid,
|
||||
&mut items,
|
||||
&items,
|
||||
&go_templates,
|
||||
materials.as_deref(),
|
||||
&commands,
|
||||
@@ -345,13 +348,15 @@ fn feed_item_text(
|
||||
},
|
||||
};
|
||||
// The body: the shared ask-once item-text cache (fetch if this is the first asker).
|
||||
let body = mail.bodies.get(text_id).cloned();
|
||||
if body.is_none() && mail.pending_bodies.insert(*text_id) {
|
||||
let _ = commands.0.send(ClientCommand::ItemTextQuery {
|
||||
text_id: *text_id,
|
||||
mail_id: 0,
|
||||
});
|
||||
}
|
||||
let body = mail
|
||||
.bodies
|
||||
.get_or_ask(*text_id, || {
|
||||
let _ = commands.0.send(ClientCommand::ItemTextQuery {
|
||||
text_id: *text_id,
|
||||
mail_id: 0,
|
||||
});
|
||||
})
|
||||
.cloned();
|
||||
let Some(text) = body else {
|
||||
return; // body query in flight
|
||||
};
|
||||
@@ -377,7 +382,7 @@ fn feed_item_text(
|
||||
|
||||
// Page/book text is server-authored, so it runs the `$`-macro expander — the reference does it
|
||||
// from two sites in `ItemTextFrame.cpp` (decision 0754), subject = the local player.
|
||||
let subject = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let subject = crate::npc_text::player_identity(&self_q, &names, &commands);
|
||||
let text = crate::npc_text::substitute(
|
||||
&text,
|
||||
&crate::npc_text::MacroContext {
|
||||
@@ -422,7 +427,7 @@ fn drain_item_text(
|
||||
let ReadSource::Pages { visited } = &mut sess.source else {
|
||||
continue; // a letter has no pages; its buttons never show
|
||||
};
|
||||
if turn_page(visited, delta, |id| pages.pages.get(&id).map(|p| p.next)) {
|
||||
if turn_page(visited, delta, |id| pages.pages.get(id).map(|p| p.next)) {
|
||||
// Repaint on the next feed, without re-firing BEGIN (within this VM; a fresh VM
|
||||
// re-begins regardless, which is the reload repaint).
|
||||
sess.told.get(&script).ready = false;
|
||||
@@ -538,7 +543,7 @@ mod quest_material_tests {
|
||||
/// (FrameXML's Parchment); an uncached GameObject is asked for and answers nothing meanwhile.
|
||||
#[test]
|
||||
fn a_quest_sourced_from_an_object_paints_the_objects_material() {
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let mut gos = GameObjectTemplates::default();
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
@@ -553,44 +558,24 @@ mod quest_material_tests {
|
||||
gos.insert(100, 2, "Wanted Poster".into(), &data);
|
||||
gos.insert(102, 10, "Chest".into(), &data);
|
||||
assert_eq!(
|
||||
object_material(
|
||||
go_guid(100),
|
||||
&mut items,
|
||||
&mut gos,
|
||||
Some(&catalog),
|
||||
&commands
|
||||
)
|
||||
.as_deref(),
|
||||
object_material(go_guid(100), &items, &gos, Some(&catalog), &commands).as_deref(),
|
||||
Some("Stone")
|
||||
);
|
||||
assert_eq!(
|
||||
object_material(
|
||||
go_guid(102),
|
||||
&mut items,
|
||||
&mut gos,
|
||||
Some(&catalog),
|
||||
&commands
|
||||
)
|
||||
.as_deref(),
|
||||
object_material(go_guid(102), &items, &gos, Some(&catalog), &commands).as_deref(),
|
||||
Some("Marble"),
|
||||
"a chest reads data[9]"
|
||||
);
|
||||
// A creature quest-giver: neither template, nothing asked.
|
||||
let creature = (u64::from(benilla_protocol::guid::HIGH_UNIT) << 48) | (6 << 24) | 1;
|
||||
assert_eq!(
|
||||
object_material(creature, &mut items, &mut gos, Some(&catalog), &commands),
|
||||
object_material(creature, &items, &gos, Some(&catalog), &commands),
|
||||
None
|
||||
);
|
||||
assert!(rx.try_recv().is_err(), "a creature source asks for nothing");
|
||||
// An uncached object: nothing yet, and the template is asked for.
|
||||
assert_eq!(
|
||||
object_material(
|
||||
go_guid(777),
|
||||
&mut items,
|
||||
&mut gos,
|
||||
Some(&catalog),
|
||||
&commands
|
||||
),
|
||||
object_material(go_guid(777), &items, &gos, Some(&catalog), &commands),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -33,7 +33,7 @@ use super::{slot_guid, slot_guid_count, wire_pos, INVTYPE_AMMO};
|
||||
pub(crate) fn send_auto_equip(
|
||||
script: &mut UiScript,
|
||||
gate: &mut crate::ui_bind_confirm::BindGate,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
bag_index: u8,
|
||||
slot: u8,
|
||||
@@ -89,7 +89,7 @@ pub(crate) fn send_auto_equip(
|
||||
/// visibly dimmed), pre-existing and out of this slice's scope to fix.
|
||||
pub(super) fn drain_container_autoequips(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut gate: crate::ui_bind_confirm::BindGate,
|
||||
@@ -113,7 +113,7 @@ pub(super) fn drain_container_autoequips(
|
||||
send_auto_equip(
|
||||
&mut script,
|
||||
&mut gate,
|
||||
&mut items,
|
||||
&items,
|
||||
&commands,
|
||||
bag_index,
|
||||
wire_slot,
|
||||
@@ -417,7 +417,7 @@ pub(super) fn drain_container_uses(
|
||||
if send_auto_equip(
|
||||
&mut script,
|
||||
&mut gate,
|
||||
&mut ladder.items,
|
||||
&ladder.items,
|
||||
&ladder.commands,
|
||||
bag_index,
|
||||
wire_slot,
|
||||
@@ -662,7 +662,7 @@ pub(super) fn drain_container_moves(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
commands: Res<NetCommands>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
mut gate: crate::ui_bind_confirm::BindGate,
|
||||
) {
|
||||
@@ -699,7 +699,7 @@ pub(super) fn drain_container_moves(
|
||||
send_container_move(
|
||||
&mut script,
|
||||
&mut gate,
|
||||
&mut items,
|
||||
&items,
|
||||
&commands,
|
||||
store,
|
||||
&mut pending,
|
||||
@@ -727,7 +727,7 @@ fn is_equip_position(bag_index: u8, slot: u8) -> bool {
|
||||
pub(crate) fn send_container_move(
|
||||
script: &mut UiScript,
|
||||
gate: &mut crate::ui_bind_confirm::BindGate,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
store: Option<&ObjectStore>,
|
||||
pending: &mut PendingItemOps,
|
||||
@@ -1040,7 +1040,7 @@ pub(super) fn drain_bind_confirm_answers(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
mut gate: crate::ui_bind_confirm::BindGate,
|
||||
) {
|
||||
@@ -1072,7 +1072,7 @@ pub(super) fn drain_bind_confirm_answers(
|
||||
send_container_move(
|
||||
&mut script,
|
||||
&mut gate,
|
||||
&mut items,
|
||||
&items,
|
||||
&commands,
|
||||
store,
|
||||
&mut pending,
|
||||
@@ -1094,7 +1094,7 @@ pub(super) fn drain_bind_confirm_answers(
|
||||
send_auto_equip(
|
||||
&mut script,
|
||||
&mut gate,
|
||||
&mut items,
|
||||
&items,
|
||||
&commands,
|
||||
bag_index,
|
||||
slot,
|
||||
|
||||
@@ -272,7 +272,7 @@ fn template_view(
|
||||
pub(super) fn feed_item_sets(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
sets: Option<Res<super::ItemSets>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||||
@@ -606,13 +606,13 @@ pub(super) fn feed_player_req(
|
||||
/// exists before its query answers.
|
||||
fn resolve_slot(
|
||||
guid: u64,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
commands: &NetCommands,
|
||||
cooldowns: &crate::cooldowns::Cooldowns,
|
||||
spells: Option<&benilla_formats::SpellCatalog>,
|
||||
names: &mut crate::names::NameCache,
|
||||
names: &crate::names::NameCache,
|
||||
// The petition record cache, for a charter slot's tooltip lines — a LAZY fill, so it is taken
|
||||
// mutably for the same reason `names` is: the read is what issues the query.
|
||||
petitions: &mut crate::ui_petition::PetitionState,
|
||||
@@ -782,7 +782,7 @@ fn resolve_slot(
|
||||
fn bag_family_name(
|
||||
player: Option<&ObjectStore>,
|
||||
bag_slot: u8,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
families: Option<&benilla_formats::ItemBagFamilyCatalog>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
@@ -841,7 +841,7 @@ pub(crate) fn feed_containers(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
// `ChrClasses.dbc` field 16, for the fit rule's relic half — see `find_equip_slot` (1803).
|
||||
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
// The two item-DBC catalogs, as one param (the 16-SystemParam ceiling): `SpellItemEnchantment`'s
|
||||
// name column — the tooltip's enchant lines (decision 0915) — and `ItemRandomProperties`, the
|
||||
@@ -866,7 +866,7 @@ pub(crate) fn feed_containers(
|
||||
bag_families: Option<Res<crate::ui_items::ItemBagFamilies>>,
|
||||
pending: Res<PendingItemOps>,
|
||||
mut lock_cleared: ResMut<LockTransitions>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
names: Res<crate::names::NameCache>,
|
||||
// Paired into one param (the 16-SystemParam ceiling this signature already sits at): the UI
|
||||
// clock, and the petition record cache a charter slot's tooltip lines read — `ResMut` because
|
||||
// that cache is LAZY and the hover is what issues its query.
|
||||
@@ -1000,7 +1000,7 @@ pub(crate) fn feed_containers(
|
||||
bag_family_name(
|
||||
player,
|
||||
e.bag_slot,
|
||||
&mut items,
|
||||
&items,
|
||||
bag_families.as_deref().map(|c| &c.0),
|
||||
&commands,
|
||||
)
|
||||
@@ -1064,13 +1064,13 @@ pub(crate) fn feed_containers(
|
||||
let guid = store.0.player_pack_slot(i).unwrap_or(0);
|
||||
if let Some(mut slot) = resolve_slot(
|
||||
guid,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
rolls,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
@@ -1119,13 +1119,13 @@ pub(crate) fn feed_containers(
|
||||
for (j, &guid) in slot_guids.iter().enumerate() {
|
||||
if let Some(mut slot) = resolve_slot(
|
||||
guid,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
rolls,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
@@ -1154,13 +1154,13 @@ pub(crate) fn feed_containers(
|
||||
let guid = store.0.player_bank_slot(i).unwrap_or(0);
|
||||
if let Some(mut slot) = resolve_slot(
|
||||
guid,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
rolls,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
@@ -1205,13 +1205,13 @@ pub(crate) fn feed_containers(
|
||||
for (j, &guid) in slot_guids.iter().enumerate() {
|
||||
if let Some(mut slot) = resolve_slot(
|
||||
guid,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
rolls,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
@@ -1244,13 +1244,13 @@ pub(crate) fn feed_containers(
|
||||
let guid = store.0.player_keyring_slot(i).unwrap_or(0);
|
||||
if let Some(mut slot) = resolve_slot(
|
||||
guid,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
rolls,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
@@ -1272,7 +1272,7 @@ pub(crate) fn feed_containers(
|
||||
// `HasKey()` — the gate that decides whether the keyring exists in the UI at all. Pushed
|
||||
// beside the containers because it is the same knowledge (item templates) read over the
|
||||
// same slot arrays, and it must be fresh on exactly the frames a BAG_UPDATE fires.
|
||||
let key = has_key(&store.0, &mut items, &commands);
|
||||
let key = has_key(&store.0, &items, &commands);
|
||||
if key != memory.had_key {
|
||||
gate.audit("feed_containers", "the HasKey() flip");
|
||||
debug!(
|
||||
|
||||
@@ -575,7 +575,7 @@ pub(crate) fn collect_inventory(
|
||||
///
|
||||
/// A slot whose item template is still in flight can't be judged and reads as "not a key"; the
|
||||
/// answer lands within a frame or two and the feed re-pushes.
|
||||
pub(crate) fn has_key(store: &ObjectFields, items: &mut Items, commands: &NetCommands) -> bool {
|
||||
pub(crate) fn has_key(store: &ObjectFields, items: &Items, commands: &NetCommands) -> bool {
|
||||
// Every guid mode 0x4f reaches, in the walker's own order — a container is recursed into as it
|
||||
// is passed (the depth-first rule), which is why each bag's contents follow its own slot.
|
||||
// Collected first, then judged: the template lookup needs `items` mutably (the ask-once query).
|
||||
@@ -843,7 +843,7 @@ pub(crate) fn send_item_use(
|
||||
};
|
||||
// The disarm ladder, asked of the caster's own inventory (decision 1903).
|
||||
let disarmed_hand = ctx.rel.self_store.and_then(|store| {
|
||||
crate::items::disarmed_equipment_slot(store, &mut ladder.items, &ladder.commands)
|
||||
crate::items::disarmed_equipment_slot(store, &ladder.items, &ladder.commands)
|
||||
});
|
||||
match item_use_route(it, aura_cancels, disarmed_hand) {
|
||||
ItemUseRoute::CantUseDisarmed => {
|
||||
@@ -894,7 +894,7 @@ pub(crate) fn send_item_use(
|
||||
if !suppress
|
||||
&& it
|
||||
.guid
|
||||
.is_some_and(|g| gate.use_binds(&mut ladder.items, &ladder.commands, g)) =>
|
||||
.is_some_and(|g| gate.use_binds(&ladder.items, &ladder.commands, g)) =>
|
||||
{
|
||||
gate.defer_use(script, it);
|
||||
false
|
||||
@@ -1766,13 +1766,13 @@ mod find_item_tests {
|
||||
item(&mut items, 0xF2, BREAD, None);
|
||||
|
||||
// Nothing at all.
|
||||
assert!(!has_key(&player(&[]), &mut items, &commands));
|
||||
assert!(!has_key(&player(&[]), &items, &commands));
|
||||
// A non-key in the backpack is not a key.
|
||||
assert!(!has_key(&player(&[(23, 0xF2)]), &mut items, &commands));
|
||||
assert!(!has_key(&player(&[(23, 0xF2)]), &items, &commands));
|
||||
// The director's own case: the key sitting in keyring slot 1.
|
||||
assert!(has_key(&player(&[(81, 0xF1)]), &mut items, &commands));
|
||||
assert!(has_key(&player(&[(81, 0xF1)]), &items, &commands));
|
||||
// And in the backpack, before it has been filed.
|
||||
assert!(has_key(&player(&[(23, 0xF1)]), &mut items, &commands));
|
||||
assert!(has_key(&player(&[(23, 0xF1)]), &items, &commands));
|
||||
|
||||
// The BANK — reachable only because HasKey passes 0x4f rather than the walker's default
|
||||
// 0x47. `find_item` must NOT see the same copy (its mode omits 0x08).
|
||||
@@ -1780,7 +1780,7 @@ mod find_item_tests {
|
||||
banked.insert(39u16, 0xF1u64);
|
||||
let store = bank_player(&banked);
|
||||
assert!(
|
||||
has_key(&store, &mut items, &commands),
|
||||
has_key(&store, &items, &commands),
|
||||
"a key in the bank still gives you a keyring"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -685,7 +685,7 @@ fn coin_icon(copper: u32) -> &'static str {
|
||||
/// rather than at each call site.
|
||||
fn resolve_item(
|
||||
item: &LootItem,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
rolls: RollCatalogs,
|
||||
@@ -754,7 +754,7 @@ fn templates_outstanding(items: &Items, snap: &LootSnapshot) -> bool {
|
||||
/// shift up).
|
||||
fn snapshot(
|
||||
loot: &LootState,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
rolls: RollCatalogs,
|
||||
@@ -886,7 +886,7 @@ fn receive_line(r: &PendingReceive, name: &str, quality: u32) -> String {
|
||||
/// an entry the server never answers for).
|
||||
fn drain_receives(
|
||||
loot: &mut LootState,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
chat: &mut crate::ui_chat::ChatLog,
|
||||
@@ -946,7 +946,7 @@ fn drain_receives(
|
||||
fn feed_loot(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut loot: ResMut<LootState>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut chat: ResMut<crate::ui_chat::ChatLog>,
|
||||
@@ -973,7 +973,7 @@ fn feed_loot(
|
||||
};
|
||||
drain_receives(
|
||||
&mut loot,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
&mut chat,
|
||||
@@ -985,7 +985,7 @@ fn feed_loot(
|
||||
group: &group,
|
||||
names: &names,
|
||||
};
|
||||
let fresh = snapshot(&loot, &mut items, icons.as_deref(), &commands, rolls, who);
|
||||
let fresh = snapshot(&loot, &items, icons.as_deref(), &commands, rolls, who);
|
||||
if fresh == *last {
|
||||
return;
|
||||
}
|
||||
@@ -1040,7 +1040,7 @@ fn feed_loot(
|
||||
// raises the confirm, and every later one in the same sweep is left
|
||||
// in the window untouched — not taken, not asked about. Otherwise a
|
||||
// three-blue corpse would stack three dialogs over one pending slot.
|
||||
if bind_confirm_required(&mut items, &commands, item_id) {
|
||||
if bind_confirm_required(&items, &commands, item_id) {
|
||||
if bind_confirm_fired {
|
||||
continue;
|
||||
}
|
||||
@@ -1105,7 +1105,7 @@ fn feed_loot(
|
||||
/// has no name on it either, so it is not a row anyone has clicked. Asking (rather than peeking)
|
||||
/// costs nothing — the entry is already in flight from the snapshot — and keeps the answer right
|
||||
/// for the next click if one somehow arrives first.
|
||||
fn bind_confirm_required(items: &mut Items, commands: &NetCommands, item_id: u32) -> bool {
|
||||
fn bind_confirm_required(items: &Items, commands: &NetCommands, item_id: u32) -> bool {
|
||||
items
|
||||
.template(item_id, 0, commands)
|
||||
.is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP && t.quality >= BIND_CONFIRM_MIN_QUALITY)
|
||||
@@ -1174,7 +1174,7 @@ fn drain_loot(
|
||||
// The bind-on-pickup deferral reads the row's template (`bonding`, `quality`). Already cached
|
||||
// by then in every reachable case — the snapshot asks for it to put a NAME on the row, and a
|
||||
// row with no name is a row nobody has clicked.
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
// The controller's move-start report (decision 2097) and the selection teardown the close
|
||||
// asks for — ahead of the VM check below, because neither depends on Lua.
|
||||
mut move_start: ResMut<LootMoveStart>,
|
||||
@@ -1218,7 +1218,7 @@ fn drain_loot(
|
||||
// confirm at all, which is why picking up a quest trinket never asks. The event
|
||||
// carries the row out, the row is stashed, and NOTHING is sent; not even the
|
||||
// pickup sound, which the reference plays only on the arm that actually sends.
|
||||
if bind_confirm_required(&mut items, &commands, item_id) {
|
||||
if bind_confirm_required(&items, &commands, item_id) {
|
||||
debug!("ui_loot: row {index} (wire {wire_slot}) binds on pickup — confirming");
|
||||
loot.pending_bind_confirm = Some(index);
|
||||
script.fire_event(
|
||||
@@ -2374,7 +2374,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn coin_row_uses_real_words_and_the_ladders_icon() {
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let (grp, nm) = nobody();
|
||||
@@ -2383,7 +2383,7 @@ mod tests {
|
||||
loot.open(0x42, loot_type::CORPSE, 4, vec![]);
|
||||
let snap = snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
@@ -2402,7 +2402,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn snapshot_prepends_coin_and_resolves_items() {
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let (grp, nm) = nobody();
|
||||
@@ -2410,7 +2410,7 @@ mod tests {
|
||||
// Closed → no snapshot.
|
||||
assert!(snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
@@ -2423,7 +2423,7 @@ mod tests {
|
||||
loot.open(0x42, loot_type::CORPSE, 12_345, vec![item(0, 117, 3)]);
|
||||
let snap = snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
@@ -2449,7 +2449,7 @@ mod tests {
|
||||
loot.clear_money();
|
||||
let snap = snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
@@ -2467,7 +2467,7 @@ mod tests {
|
||||
loot.remove_slot(0);
|
||||
let snap = snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
|
||||
@@ -434,8 +434,8 @@ fn format_line_detailed(
|
||||
fn render(
|
||||
line: &RollLine,
|
||||
self_guid: Option<u64>,
|
||||
items: &mut Items,
|
||||
names: &mut NameCache,
|
||||
items: &Items,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
detailed: bool,
|
||||
@@ -487,8 +487,8 @@ fn render(
|
||||
fn drain_lines(
|
||||
rolls: &mut LootRolls,
|
||||
self_guid: Option<u64>,
|
||||
items: &mut Items,
|
||||
names: &mut NameCache,
|
||||
items: &Items,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
chat: &mut ChatLog,
|
||||
catalogs: crate::items::RollCatalogs,
|
||||
@@ -523,7 +523,7 @@ fn drain_lines(
|
||||
/// cache (`None`/`false` while in flight; the frame shows its placeholder and fills in later).
|
||||
fn snapshot(
|
||||
rolls: &LootRolls,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
catalogs: crate::items::RollCatalogs,
|
||||
@@ -577,8 +577,8 @@ fn snapshot(
|
||||
fn feed_loot_rolls(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut rolls: ResMut<LootRolls>,
|
||||
mut items: ResMut<Items>,
|
||||
mut names: ResMut<NameCache>,
|
||||
items: Res<Items>,
|
||||
names: Res<NameCache>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
commands: Res<NetCommands>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
@@ -605,8 +605,8 @@ fn feed_loot_rolls(
|
||||
drain_lines(
|
||||
&mut rolls,
|
||||
self_guid.0,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&items,
|
||||
&names,
|
||||
&commands,
|
||||
&mut chat,
|
||||
catalogs,
|
||||
@@ -617,7 +617,7 @@ fn feed_loot_rolls(
|
||||
// item out of the model in its OnShow, and the roll it is about was added to `active` in the
|
||||
// same `start()` call that queued `opened`, so pushing after would hand every fresh roll an
|
||||
// empty lookup. Same order as ui_loot's window feed, for the same reason.
|
||||
let fresh = snapshot(&rolls, &mut items, icons.as_deref(), &commands, catalogs);
|
||||
let fresh = snapshot(&rolls, &items, icons.as_deref(), &commands, catalogs);
|
||||
if fresh != *last {
|
||||
script.set_loot_rolls(fresh.clone());
|
||||
*last = fresh;
|
||||
|
||||
@@ -45,8 +45,6 @@
|
||||
//! [`benilla_ui::script::UiScript::set_has_new_mail`] current, and fires `UPDATE_PENDING_MAIL` at
|
||||
//! the three sites the reference fires it (see [`MailPending::notify`]).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use benilla_protocol::messages::{mail_error, mail_message_type, MailListEntry};
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -56,6 +54,7 @@ use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::query_cache::QueryCache;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
|
||||
|
||||
@@ -112,10 +111,8 @@ pub(crate) struct MailOpen {
|
||||
pub(crate) mailbox: Option<u64>,
|
||||
/// The inbox rows (wire order = 1-based display order).
|
||||
pub(crate) mails: Vec<MailListEntry>,
|
||||
/// Ask-once letter-body cache: `item_text_id` → body text.
|
||||
pub(crate) bodies: HashMap<u32, String>,
|
||||
/// `item_text_id`s with a `CMSG_ITEM_TEXT_QUERY` in flight (ask-once dedup).
|
||||
pub(crate) pending_bodies: HashSet<u32>,
|
||||
/// Ask-once letter-body cache: `item_text_id` → body text, through [`QueryCache`] (2288).
|
||||
pub(crate) bodies: QueryCache<u32, String>,
|
||||
/// Fire `MAIL_SHOW` next feed — set on every mailbox click (the reference opens the window per
|
||||
/// use; wow-re §5). Consumed by [`feed_mail`].
|
||||
show_requested: bool,
|
||||
@@ -133,6 +130,12 @@ pub(crate) struct MailOpen {
|
||||
pub(crate) errors: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl crate::query_cache::AskOnce for MailOpen {
|
||||
fn clear_pending(&mut self) {
|
||||
self.bodies.clear_pending();
|
||||
}
|
||||
}
|
||||
|
||||
impl MailOpen {
|
||||
/// A mailbox right-click (decision 0544): open (or re-show) the window's session and request its
|
||||
/// `MAIL_SHOW`. A *different* mailbox resets the session (rows + throttle); the same one keeps
|
||||
@@ -172,7 +175,6 @@ impl MailOpen {
|
||||
self.mailbox = None;
|
||||
self.mails.clear();
|
||||
self.bodies.clear();
|
||||
self.pending_bodies.clear();
|
||||
self.show_requested = false;
|
||||
self.last_list_query = None;
|
||||
self.send_acks.clear();
|
||||
@@ -203,6 +205,7 @@ pub(crate) struct UiMailPlugin;
|
||||
|
||||
impl Plugin for UiMailPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
crate::query_cache::register::<MailOpen>(app);
|
||||
app.init_resource::<MailOpen>()
|
||||
.init_resource::<MailPending>()
|
||||
.add_systems(
|
||||
@@ -285,11 +288,11 @@ use invoice::auction_mail;
|
||||
/// placeholder and fills in when the answer lands (the merchant/loot pattern).
|
||||
fn resolve_row(
|
||||
entry: &MailListEntry,
|
||||
bodies: &HashMap<u32, String>,
|
||||
items: &mut Items,
|
||||
bodies: &QueryCache<u32, String>,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
stationery: Option<&Stationery>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
@@ -346,7 +349,7 @@ fn resolve_row(
|
||||
// `$`-substituted text below: the invoice is machine-written colon fields, and running a macro
|
||||
// expander over them would be expanding the auction house's own bookkeeping.
|
||||
let raw_body = (entry.item_text_id != 0)
|
||||
.then(|| bodies.get(&entry.item_text_id))
|
||||
.then(|| bodies.get(entry.item_text_id))
|
||||
.flatten();
|
||||
|
||||
// ── The auction house's mail (`ui_mail::invoice`, wow-re §11.1a) ─────────────────────────
|
||||
@@ -445,7 +448,7 @@ fn resolve_row(
|
||||
fn stationeries(
|
||||
catalog: &Stationery,
|
||||
self_q: &Query<(&ObjectStore, &crate::net::Guid), With<SelfPlayer>>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> Vec<StationeryView> {
|
||||
@@ -483,10 +486,10 @@ fn stationeries(
|
||||
|
||||
fn snapshot(
|
||||
mail: &MailOpen,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
stationery: Option<&Stationery>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
@@ -532,10 +535,10 @@ struct MailFeedExtras<'w, 's> {
|
||||
fn feed_mail(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut mail: ResMut<MailOpen>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
stationery: Option<Res<Stationery>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut pending: ResMut<MailPending>,
|
||||
time: Res<Time>,
|
||||
@@ -602,7 +605,7 @@ fn feed_mail(
|
||||
// The macro subject, resolved before the row walk borrows the name cache again. `None` until
|
||||
// the player is streamed and named — the feed diffs on the substituted text, so a letter opened
|
||||
// that early re-substitutes as soon as it lands.
|
||||
let subject = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let subject = crate::npc_text::player_identity(&self_q, &names, &commands);
|
||||
let macros = crate::npc_text::MacroContext {
|
||||
subject: subject.as_ref(),
|
||||
states: &states,
|
||||
@@ -612,10 +615,10 @@ fn feed_mail(
|
||||
let get_text = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
snapshot(
|
||||
&mail,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
stationery.as_deref(),
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
rolls,
|
||||
¯os,
|
||||
@@ -646,7 +649,7 @@ fn feed_mail(
|
||||
let usable = stationery
|
||||
.as_deref()
|
||||
.filter(|_| !self_q.is_empty())
|
||||
.map(|catalog| stationeries(catalog, &self_q, &mut items, icons.as_deref(), &commands))
|
||||
.map(|catalog| stationeries(catalog, &self_q, &items, icons.as_deref(), &commands))
|
||||
.unwrap_or_default();
|
||||
let memo = last_stationeries.get(&script);
|
||||
if *memo != usable {
|
||||
@@ -770,13 +773,12 @@ fn drain_mail(
|
||||
.0
|
||||
.send(ClientCommand::MailMarkAsRead { mailbox, mail_id });
|
||||
}
|
||||
if text_id != 0
|
||||
&& !mail.bodies.contains_key(&text_id)
|
||||
&& mail.pending_bodies.insert(text_id)
|
||||
{
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::ItemTextQuery { text_id, mail_id });
|
||||
if text_id != 0 {
|
||||
mail.bodies.get_or_ask(text_id, || {
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::ItemTextQuery { text_id, mail_id });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,7 +967,7 @@ mod tests {
|
||||
let mut m = MailOpen::default();
|
||||
m.click(0x1);
|
||||
m.mails.push(entry(1, Some(0xA), 0, 30.0));
|
||||
m.bodies.insert(7, "hi".into());
|
||||
m.bodies.insert(7, Some("hi".into()));
|
||||
m.clear();
|
||||
assert!(m.mailbox.is_none());
|
||||
assert!(m.mails.is_empty());
|
||||
@@ -981,21 +983,21 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_row_derives_the_reply_and_delete_law() {
|
||||
let states = crate::world_state::WorldStates::default();
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let mut names = NameCache::default();
|
||||
let bodies = HashMap::new();
|
||||
let names = NameCache::default();
|
||||
let bodies = QueryCache::default();
|
||||
|
||||
// A plain letter from a player (no attachments) → replyable AND deletable.
|
||||
let from_player = entry(1, Some(0xA), 0, 30.0);
|
||||
let row = resolve_row(
|
||||
&from_player,
|
||||
&bodies,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
None,
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
crate::items::RollCatalogs::NONE,
|
||||
&no_macros(&states),
|
||||
@@ -1012,10 +1014,10 @@ mod tests {
|
||||
let row = resolve_row(
|
||||
&with_money,
|
||||
&bodies,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
None,
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
crate::items::RollCatalogs::NONE,
|
||||
&no_macros(&states),
|
||||
@@ -1029,10 +1031,10 @@ mod tests {
|
||||
let row = resolve_row(
|
||||
&returned,
|
||||
&bodies,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
None,
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
crate::items::RollCatalogs::NONE,
|
||||
&no_macros(&states),
|
||||
@@ -1046,10 +1048,10 @@ mod tests {
|
||||
let row = resolve_row(
|
||||
&system,
|
||||
&bodies,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
None,
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
crate::items::RollCatalogs::NONE,
|
||||
&no_macros(&states),
|
||||
@@ -1061,12 +1063,12 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_row_reads_the_item_and_body_caches() {
|
||||
let states = crate::world_state::WorldStates::default();
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let mut names = NameCache::default();
|
||||
let mut bodies = HashMap::new();
|
||||
bodies.insert(99, "the letter body".into());
|
||||
let names = NameCache::default();
|
||||
let mut bodies = QueryCache::default();
|
||||
bodies.insert(99, Some("the letter body".into()));
|
||||
|
||||
let mut e = entry(1, Some(0xA), 0, 30.0);
|
||||
e.item_text_id = 99;
|
||||
@@ -1083,10 +1085,10 @@ mod tests {
|
||||
let row = resolve_row(
|
||||
&e,
|
||||
&bodies,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
None,
|
||||
&mut names,
|
||||
&names,
|
||||
&commands,
|
||||
crate::items::RollCatalogs::NONE,
|
||||
&no_macros(&states),
|
||||
|
||||
@@ -209,7 +209,7 @@ fn sell_error_key(reason: u8) -> Option<&'static str> {
|
||||
/// placeholder and fills in when the answer lands, exactly like a bag slot).
|
||||
fn resolve_item(
|
||||
item: &VendorItem,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> MerchantItem {
|
||||
@@ -277,7 +277,7 @@ fn buyback_order(store: &benilla_protocol::ObjectFields) -> Vec<u8> {
|
||||
fn resolve_buyback(
|
||||
idx: u8,
|
||||
store: &benilla_protocol::ObjectFields,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> MerchantItem {
|
||||
@@ -328,7 +328,7 @@ fn resolve_buyback(
|
||||
/// No reputation model yet → discount 0.
|
||||
fn item_repair_cost(
|
||||
guid: u64,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
tables: &RepairTables,
|
||||
commands: &NetCommands,
|
||||
) -> u32 {
|
||||
@@ -356,12 +356,12 @@ fn item_repair_cost(
|
||||
/// buyback).
|
||||
fn repair_all_cost(
|
||||
store: &benilla_protocol::ObjectFields,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
tables: &RepairTables,
|
||||
commands: &NetCommands,
|
||||
) -> u32 {
|
||||
let mut total: u64 = 0;
|
||||
let mut add = |guid: u64, items: &mut Items| {
|
||||
let mut add = |guid: u64, items: &Items| {
|
||||
if guid != 0 {
|
||||
total += u64::from(item_repair_cost(guid, items, tables, commands));
|
||||
}
|
||||
@@ -395,7 +395,7 @@ fn repair_all_cost(
|
||||
/// when no vendor is open.
|
||||
fn snapshot(
|
||||
open: &MerchantOpen,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
player: Option<&benilla_protocol::ObjectFields>,
|
||||
@@ -435,13 +435,13 @@ fn snapshot(
|
||||
fn feed_merchant(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
open: Res<MerchantOpen>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
commands: Res<NetCommands>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
units: Query<(&crate::net::Guid, &ObjectStore), Without<SelfPlayer>>,
|
||||
tables: Option<Res<RepairTables>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
mut errors: ResMut<MerchantErrors>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<MerchantState>>>,
|
||||
mut last_money: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
@@ -495,7 +495,7 @@ fn feed_merchant(
|
||||
let player = self_q.iter().next().map(|s| &s.0);
|
||||
let fresh = snapshot(
|
||||
&open,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
player,
|
||||
@@ -799,14 +799,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_maps_unlimited_stock_to_minus_one() {
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
// Unlimited stock → numAvailable -1; a finite count passes through.
|
||||
let unlimited = resolve_item(&row(159, 1, STOCK_UNLIMITED), &mut items, None, &commands);
|
||||
let unlimited = resolve_item(&row(159, 1, STOCK_UNLIMITED), &items, None, &commands);
|
||||
assert_eq!(unlimited.num_available, -1);
|
||||
assert_eq!(unlimited.item_id, 159);
|
||||
let finite = resolve_item(&row(4540, 2, 5), &mut items, None, &commands);
|
||||
let finite = resolve_item(&row(4540, 2, 5), &items, None, &commands);
|
||||
assert_eq!(finite.num_available, 5);
|
||||
// No template answer yet → name + tooltip stats in flight (nil), the rest present.
|
||||
assert!(finite.name.is_none());
|
||||
@@ -876,7 +876,7 @@ mod tests {
|
||||
bag_family: 0,
|
||||
}),
|
||||
);
|
||||
let resolved = resolve_item(&row(2129, 1, 3), &mut items, None, &commands);
|
||||
let resolved = resolve_item(&row(2129, 1, 3), &items, None, &commands);
|
||||
let stats = resolved.stats.expect("template answered → stats present");
|
||||
assert_eq!(
|
||||
(
|
||||
|
||||
@@ -871,7 +871,7 @@ pub(super) fn drain_party(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut group: ResMut<GroupState>,
|
||||
selection: Res<Selection>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
self_q: Query<&Guid, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
|
||||
@@ -125,7 +125,7 @@ impl Plugin for UiPetStatsPlugin {
|
||||
/// (decision 1676).
|
||||
fn family_for(
|
||||
pet: Option<&ObjectStore>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
tables: Option<&PetFamilyTables>,
|
||||
) -> (Option<String>, Option<String>, Vec<String>) {
|
||||
@@ -242,7 +242,7 @@ fn feed_pet_stats(
|
||||
self_store: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
|
||||
tables: Option<Res<PetStatTables>>,
|
||||
family_tables: Option<Res<PetFamilyTables>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<(bool, PetStats)>>>,
|
||||
) {
|
||||
@@ -251,7 +251,7 @@ fn feed_pet_stats(
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let store = pet.store(bar.spells.pet_guid);
|
||||
let family = family_for(store, &mut names, &commands, family_tables.as_deref());
|
||||
let family = family_for(store, &names, &commands, family_tables.as_deref());
|
||||
let fresh = stats_for(store, self_store.iter().next(), tables.as_deref(), family);
|
||||
// Push only on change. Happiness moves on its own clock and the frame repaints off UNIT_*
|
||||
// events, so a per-frame push would be pure churn — but the DIFF is what makes this cheap,
|
||||
@@ -592,8 +592,8 @@ mod tests {
|
||||
let (cmds, rx) = commands();
|
||||
|
||||
// 1. No pet at all.
|
||||
let mut names = NameCache::default();
|
||||
assert_eq!(family_for(None, &mut names, &cmds, Some(&t)), no_family());
|
||||
let names = NameCache::default();
|
||||
assert_eq!(family_for(None, &names, &cmds, Some(&t)), no_family());
|
||||
assert!(rx.try_recv().is_err(), "nothing to ask about");
|
||||
|
||||
// 2. A pet whose creature query has NOT answered yet — nil, and the ask goes out (once).
|
||||
@@ -602,7 +602,7 @@ mod tests {
|
||||
(PETNUMBER, 7),
|
||||
]));
|
||||
assert_eq!(
|
||||
family_for(Some(&pet), &mut names, &cmds, Some(&t)),
|
||||
family_for(Some(&pet), &names, &cmds, Some(&t)),
|
||||
no_family(),
|
||||
"un-queried is nil, not a guess"
|
||||
);
|
||||
@@ -617,17 +617,14 @@ mod tests {
|
||||
|
||||
// 3. The answer lands with family 0 — a template with no family. Still nil, and this is
|
||||
// the common case for every non-tameable creature.
|
||||
let mut names = cache_with(IMP_ENTRY, 0);
|
||||
assert_eq!(
|
||||
family_for(Some(&pet), &mut names, &cmds, Some(&t)),
|
||||
no_family()
|
||||
);
|
||||
let names = cache_with(IMP_ENTRY, 0);
|
||||
assert_eq!(family_for(Some(&pet), &names, &cmds, Some(&t)), no_family());
|
||||
|
||||
// 4. The answer lands with the Imp's real family (23, from the live `creature_template`).
|
||||
// A warlock minion: a word, and an EMPTY diet — mask 0 in the shipped DBC.
|
||||
let mut names = cache_with(IMP_ENTRY, 23);
|
||||
let names = cache_with(IMP_ENTRY, 23);
|
||||
assert_eq!(
|
||||
family_for(Some(&pet), &mut names, &cmds, Some(&t)),
|
||||
family_for(Some(&pet), &names, &cmds, Some(&t)),
|
||||
(
|
||||
Some("Imp".into()),
|
||||
// The family row's own icon column (decision 1676) — and the shipped value for a
|
||||
@@ -644,8 +641,8 @@ mod tests {
|
||||
);
|
||||
|
||||
// 5. A hunter's boar (entry 113 → family 5): a word AND the six-diet list, in bit order.
|
||||
let mut names = cache_with(BOAR_ENTRY, 5);
|
||||
let (name, icon, diet) = family_for(Some(&boar()), &mut names, &cmds, Some(&t));
|
||||
let names = cache_with(BOAR_ENTRY, 5);
|
||||
let (name, icon, diet) = family_for(Some(&boar()), &names, &cmds, Some(&t));
|
||||
assert_eq!(name.as_deref(), Some("Boar"));
|
||||
assert_eq!(
|
||||
icon.as_deref(),
|
||||
@@ -654,11 +651,8 @@ mod tests {
|
||||
assert_eq!(diet, ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]);
|
||||
|
||||
// 6. No DBC tables at all: nil, degraded to exactly the blank level line 1057 shipped.
|
||||
let mut names = cache_with(BOAR_ENTRY, 5);
|
||||
assert_eq!(
|
||||
family_for(Some(&boar()), &mut names, &cmds, None),
|
||||
no_family()
|
||||
);
|
||||
let names = cache_with(BOAR_ENTRY, 5);
|
||||
assert_eq!(family_for(Some(&boar()), &names, &cmds, None), no_family());
|
||||
}
|
||||
|
||||
/// **The family WORD survives the hunter gate; the DIET does not** — the carved split
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(super) fn feed_petition(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
registrar: Res<GuildRegistrarState>,
|
||||
mut petition: ResMut<PetitionState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
commands: Res<NetCommands>,
|
||||
@@ -110,7 +110,7 @@ pub(super) fn feed_petition(
|
||||
.unwrap_or_default();
|
||||
|
||||
let record = open.as_ref().and_then(|o| {
|
||||
let r = petition.records.get(&o.petition_id)?;
|
||||
let r = petition.records.get(o.petition_id)?;
|
||||
Some(PetitionRecordView {
|
||||
petition_type: if r.is_charter {
|
||||
PETITION_TYPE_CHARTER
|
||||
@@ -222,7 +222,7 @@ pub(super) fn drain_petition(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut registrar: ResMut<GuildRegistrarState>,
|
||||
mut petition: ResMut<PetitionState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
items: Res<Items>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
|
||||
@@ -87,8 +87,6 @@
|
||||
//! 6. **Using a charter item opens it** — see [`crate::ui_items::ItemUseRoute::ShowPetition`],
|
||||
//! where the evidence and its limits are written down.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
petition_result, PetitionQueryResponse, PetitionRename, PetitionShowList,
|
||||
PetitionShowSignatures, PetitionSignResults,
|
||||
@@ -97,6 +95,7 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::query_cache::QueryCache;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_session::NpcSession;
|
||||
|
||||
@@ -194,12 +193,10 @@ impl NpcSession for GuildRegistrarState {
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PetitionState {
|
||||
open: Option<OpenCharter>,
|
||||
/// The lazy record cache, keyed by petition id — the guild-identity cache's twin. Session
|
||||
/// state: a petition id means nothing across a reconnect.
|
||||
records: HashMap<u32, Record>,
|
||||
/// Petition ids with a `CMSG_PETITION_QUERY` in flight, so the ask happens once per id per
|
||||
/// connection ([`crate::names::NameCache`]'s rule).
|
||||
querying: HashSet<u32>,
|
||||
/// The lazy record cache, keyed by petition id — the guild-identity cache's twin, ask-once
|
||||
/// through [`QueryCache`] (decision 2288). Session state: a petition id means nothing across
|
||||
/// a reconnect.
|
||||
records: QueryCache<u32, Record>,
|
||||
/// `[0xbdce1c]` — set while a sign we sent is outstanding, cleared when its result lands.
|
||||
///
|
||||
/// It exists for one reader, and that reader is easy to miss: the decline leg of a close is
|
||||
@@ -262,6 +259,12 @@ struct Record {
|
||||
is_charter: bool,
|
||||
}
|
||||
|
||||
impl crate::query_cache::AskOnce for PetitionState {
|
||||
fn clear_pending(&mut self) {
|
||||
self.records.clear_pending();
|
||||
}
|
||||
}
|
||||
|
||||
impl PetitionState {
|
||||
/// `SMSG_PETITION_SHOW_SIGNATURES` — open the window on this charter, and fire the record
|
||||
/// query if we have never seen this petition.
|
||||
@@ -282,13 +285,12 @@ impl PetitionState {
|
||||
/// The lazy record fill: ask once per petition id, and answer nothing for the call that missed.
|
||||
/// The arrival simply lands in the cache; the feed's next rebuild picks it up (see
|
||||
/// [`feed`]'s note on why there is no flag).
|
||||
fn request_record(&mut self, petition_id: u32, item: u64, commands: &NetCommands) {
|
||||
if self.records.contains_key(&petition_id) || !self.querying.insert(petition_id) {
|
||||
return;
|
||||
}
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::PetitionQuery { petition_id, item });
|
||||
fn request_record(&self, petition_id: u32, item: u64, commands: &NetCommands) {
|
||||
self.records.get_or_ask(petition_id, || {
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::PetitionQuery { petition_id, item });
|
||||
});
|
||||
}
|
||||
|
||||
/// One step per landed or patched record — see [`Self::records_epoch`]'s own field doc.
|
||||
@@ -299,16 +301,15 @@ impl PetitionState {
|
||||
/// `SMSG_PETITION_QUERY_RESPONSE` — fill the cache.
|
||||
fn apply_record(&mut self, response: PetitionQueryResponse) {
|
||||
self.records_epoch = self.records_epoch.wrapping_add(1);
|
||||
self.querying.remove(&response.petition_id);
|
||||
self.records.insert(
|
||||
response.petition_id,
|
||||
Record {
|
||||
Some(Record {
|
||||
owner: response.owner,
|
||||
name: response.name,
|
||||
body_text: response.body_text,
|
||||
required: response.min_signatures as i32,
|
||||
is_charter: response.flags & 1 != 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -326,14 +327,14 @@ impl PetitionState {
|
||||
&mut self,
|
||||
petition_id: u32,
|
||||
item: u64,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<benilla_ui::script::PetitionSlotView> {
|
||||
if petition_id == 0 {
|
||||
return None;
|
||||
}
|
||||
self.request_record(petition_id, item, commands);
|
||||
let rec = self.records.get(&petition_id)?;
|
||||
let rec = self.records.get(petition_id)?;
|
||||
let (is_charter, title, owner_guid) = (rec.is_charter, rec.name.clone(), rec.owner);
|
||||
Some(benilla_ui::script::PetitionSlotView {
|
||||
is_charter,
|
||||
@@ -360,7 +361,7 @@ impl PetitionState {
|
||||
&mut self,
|
||||
item: u64,
|
||||
signer: u64,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
let open = self.open.as_mut().filter(|o| o.item == item)?;
|
||||
@@ -388,7 +389,7 @@ impl PetitionState {
|
||||
let Some(open) = self.open.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if self.signing || !self.records.contains_key(&open.petition_id) || open.owner == me {
|
||||
if self.signing || !self.records.answered(open.petition_id) || open.owner == me {
|
||||
return;
|
||||
}
|
||||
let _ = commands
|
||||
@@ -413,6 +414,7 @@ pub(crate) struct UiPetitionPlugin;
|
||||
|
||||
impl Plugin for UiPetitionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
crate::query_cache::register::<PetitionState>(app);
|
||||
app.init_resource::<GuildRegistrarState>()
|
||||
.init_resource::<PetitionState>()
|
||||
.add_systems(
|
||||
@@ -489,7 +491,7 @@ pub(crate) mod apply {
|
||||
/// success additionally **closes the window** (`0x5ef037` fires `PETITION_CLOSED`).
|
||||
pub(crate) fn sign_results(
|
||||
petition: &mut PetitionState,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
self_guid: u64,
|
||||
results: PetitionSignResults,
|
||||
commands: &NetCommands,
|
||||
@@ -557,7 +559,12 @@ pub(crate) mod apply {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
petition.records.entry(id).or_default().name = rename.name;
|
||||
if !petition.records.answered(id) {
|
||||
petition.records.insert(id, Some(Record::default()));
|
||||
}
|
||||
if let Some(record) = petition.records.get_mut(id) {
|
||||
record.name = rename.name;
|
||||
}
|
||||
// A patched title is a changed record: the tooltip's line 3 reads it too.
|
||||
petition.records_epoch = petition.records_epoch.wrapping_add(1);
|
||||
}
|
||||
@@ -595,7 +602,7 @@ mod tests {
|
||||
/// return, spelled once here rather than at three assertion sites.
|
||||
fn open_title(p: &PetitionState) -> Option<&str> {
|
||||
let open = p.open.as_ref()?;
|
||||
p.records.get(&open.petition_id).map(|r| r.name.as_str())
|
||||
p.records.get(open.petition_id).map(|r| r.name.as_str())
|
||||
}
|
||||
|
||||
fn signatures(item: u64, owner: u64, id: u32, signers: &[u64]) -> PetitionShowSignatures {
|
||||
|
||||
@@ -360,7 +360,7 @@ type Pool = Vec<(u32, u32)>;
|
||||
/// in flight — the row shows a placeholder and fills in, exactly like a bag slot / vendor row).
|
||||
fn resolve_item(
|
||||
it: &QuestRewardItem,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> QuestItemView {
|
||||
@@ -389,7 +389,7 @@ fn resolve_item(
|
||||
|
||||
fn resolve_items(
|
||||
src: &[QuestRewardItem],
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> Vec<QuestItemView> {
|
||||
@@ -425,7 +425,7 @@ pub(crate) fn reward_spell_view(
|
||||
|
||||
fn snapshot(
|
||||
giver: &QuestGiver,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
@@ -501,14 +501,14 @@ fn panel_event(panel: QuestPanel) -> &'static str {
|
||||
fn feed_quest(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut giver: ResMut<QuestGiver>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut go_templates: ResMut<crate::go_templates::GameObjectTemplates>,
|
||||
go_templates: Res<crate::go_templates::GameObjectTemplates>,
|
||||
materials: Option<Res<crate::ui_item_text::PageMaterials>>,
|
||||
mut sink: MessageSink,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<QuestState>>>,
|
||||
@@ -539,10 +539,10 @@ fn feed_quest(
|
||||
})
|
||||
.collect();
|
||||
show_messages(&mut script, &mut sink, "ui_quest", lines);
|
||||
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let player = crate::npc_text::player_identity(&self_q, &names, &commands);
|
||||
let fresh = snapshot(
|
||||
&giver,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
&crate::npc_text::MacroContext {
|
||||
@@ -559,8 +559,8 @@ fn feed_quest(
|
||||
st.background_material = giver.npc.and_then(|source| {
|
||||
crate::ui_item_text::object_material(
|
||||
source,
|
||||
&mut items,
|
||||
&mut go_templates,
|
||||
&items,
|
||||
&go_templates,
|
||||
materials.as_deref(),
|
||||
&commands,
|
||||
)
|
||||
@@ -877,7 +877,7 @@ mod tests {
|
||||
}),
|
||||
);
|
||||
assert!(giver.is_open());
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let player = crate::npc_text::Subject {
|
||||
@@ -888,7 +888,7 @@ mod tests {
|
||||
};
|
||||
let snap = snapshot(
|
||||
&giver,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
&commands,
|
||||
&crate::npc_text::MacroContext {
|
||||
@@ -928,12 +928,12 @@ mod tests {
|
||||
is_complete: true,
|
||||
}),
|
||||
);
|
||||
let mut items = Items::default();
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let snap = snapshot(
|
||||
&giver,
|
||||
&mut items,
|
||||
&items,
|
||||
None,
|
||||
&commands,
|
||||
&crate::npc_text::MacroContext {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
//! rather than left dead, so the retired law can't be reached for by accident.
|
||||
|
||||
use crate::ui_items::{count_of, InventoryScope};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -54,6 +54,7 @@ use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::query_cache::QueryCache;
|
||||
use crate::ui_action::Spells;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_unit::UnitFeed;
|
||||
@@ -73,16 +74,15 @@ struct Row {
|
||||
|
||||
/// The quest-log state cache + per-frame bookkeeping.
|
||||
///
|
||||
/// - `templates`/`pending` — the `SMSG_QUEST_QUERY_RESPONSE` cache, ask-once by quest id (the exact
|
||||
/// twin of [`Items`]'s item-template cache).
|
||||
/// - `templates` — the `SMSG_QUEST_QUERY_RESPONSE` cache, ask-once by quest id through
|
||||
/// [`QueryCache`] (the exact twin of [`Items`]'s item-template cache).
|
||||
/// - `entry_slots` — this frame's pushed entry order → descriptor slot, so
|
||||
/// [`drain_quest_log_abandons`] can turn a confirmed abandon's 1-based Lua index back into the
|
||||
/// `CMSG_QUESTLOG_REMOVE_QUEST` slot it came from (slots aren't contiguous — an abandoned/turned-in
|
||||
/// quest leaves a gap).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct QuestLog {
|
||||
templates: HashMap<u32, QuestTemplate>,
|
||||
pending: HashSet<u32>,
|
||||
templates: QueryCache<u32, QuestTemplate>,
|
||||
entry_slots: Vec<Option<u8>>,
|
||||
/// Collapsed section headers, keyed by header TITLE (two zones sharing a name share a header
|
||||
/// row, so the fold state naturally shares too). Owned here — the engine only reports the
|
||||
@@ -93,31 +93,28 @@ pub(crate) struct QuestLog {
|
||||
header_keys: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl crate::query_cache::AskOnce for QuestLog {
|
||||
fn clear_pending(&mut self) {
|
||||
self.templates.clear_pending();
|
||||
}
|
||||
}
|
||||
|
||||
impl QuestLog {
|
||||
/// The template for `quest_id`, if known — ask-once: a miss sends `CMSG_QUEST_QUERY` (deduped
|
||||
/// while in flight) and returns `None`; a cached negative (the server doesn't know the id) is
|
||||
/// also `None`, without a re-ask. The exact twin of [`Items::template`].
|
||||
pub(crate) fn template(
|
||||
&mut self,
|
||||
quest_id: u32,
|
||||
commands: &NetCommands,
|
||||
) -> Option<&QuestTemplate> {
|
||||
if !self.templates.contains_key(&quest_id) {
|
||||
if self.pending.insert(quest_id) {
|
||||
debug!("ui_quest_log: asking quest template (quest {quest_id})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::QuestQuery { quest: quest_id });
|
||||
}
|
||||
return None;
|
||||
}
|
||||
self.templates.get(&quest_id)
|
||||
pub(crate) fn template(&self, quest_id: u32, commands: &NetCommands) -> Option<&QuestTemplate> {
|
||||
self.templates.get_or_ask(quest_id, || {
|
||||
debug!("ui_quest_log: asking quest template (quest {quest_id})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::QuestQuery { quest: quest_id });
|
||||
})
|
||||
}
|
||||
|
||||
/// Record a template answer (`SMSG_QUEST_QUERY_RESPONSE`).
|
||||
pub(crate) fn insert_template(&mut self, template: QuestTemplate) {
|
||||
self.pending.remove(&template.quest_id);
|
||||
self.templates.insert(template.quest_id, template);
|
||||
self.templates.insert(template.quest_id, Some(template));
|
||||
}
|
||||
|
||||
/// Disconnect: drop everything, including the templates. Unlike item templates (which persist —
|
||||
@@ -126,7 +123,6 @@ impl QuestLog {
|
||||
/// to re-ask that there's no reason to risk it.
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
self.templates.clear();
|
||||
self.pending.clear();
|
||||
self.entry_slots.clear();
|
||||
self.collapsed.clear();
|
||||
self.header_keys.clear();
|
||||
@@ -137,6 +133,7 @@ pub(crate) struct UiQuestLogPlugin;
|
||||
|
||||
impl Plugin for UiQuestLogPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
crate::query_cache::register::<QuestLog>(app);
|
||||
app.init_resource::<QuestLog>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
@@ -293,7 +290,7 @@ fn money_split(money: i32) -> (u32, u32) {
|
||||
fn resolve_template_item(
|
||||
entry: u32,
|
||||
count: u32,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> QuestItemView {
|
||||
@@ -340,8 +337,8 @@ fn build_objectives(
|
||||
template: &QuestTemplate,
|
||||
log_slot: &QuestLogSlot,
|
||||
store: &ObjectFields,
|
||||
items: &mut Items,
|
||||
names: &mut NameCache,
|
||||
items: &Items,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
// The VM's own `GlobalStrings.lua`, for the leaderboard's three format keys.
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
@@ -391,7 +388,7 @@ fn build_objectives(
|
||||
/// not — see [`build_objectives`]), so this no longer needs the descriptor slot at all.
|
||||
fn build_detail(
|
||||
template: &QuestTemplate,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
@@ -545,8 +542,8 @@ fn feed_quest_log(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
mut quest_log: ResMut<QuestLog>,
|
||||
mut names: ResMut<NameCache>,
|
||||
mut items: ResMut<Items>,
|
||||
names: Res<NameCache>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
commands: Res<NetCommands>,
|
||||
header_names: Option<Res<QuestHeaderNamesRes>>,
|
||||
@@ -663,7 +660,7 @@ fn feed_quest_log(
|
||||
let keyed: Vec<GroupRow> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let t = quest_log.templates.get(&r.quest_id);
|
||||
let t = quest_log.templates.get(r.quest_id);
|
||||
let zos = t.map(|t| t.zone_or_sort).unwrap_or(0);
|
||||
GroupRow {
|
||||
zone_or_sort: zos,
|
||||
@@ -688,7 +685,7 @@ fn feed_quest_log(
|
||||
|
||||
// Above the row loop because every row's detail substitutes through it now (the chat macros
|
||||
// in a quest's description/objectives paragraph), not just the selection's.
|
||||
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
||||
let player = crate::npc_text::player_identity(&self_q, &names, &commands);
|
||||
let macros = crate::npc_text::MacroContext {
|
||||
subject: player.as_ref(),
|
||||
states: &states,
|
||||
@@ -739,21 +736,13 @@ fn feed_quest_log(
|
||||
// button we wrongly enabled would push, and the party would get a detail panel
|
||||
// for a quest the server then refuses (decision 1733).
|
||||
t.flags & quest_flags::SHARABLE != 0,
|
||||
build_objectives(
|
||||
t,
|
||||
&r.log_slot,
|
||||
&store.0,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&commands,
|
||||
&get,
|
||||
),
|
||||
build_objectives(t, &r.log_slot, &store.0, &items, &names, &commands, &get),
|
||||
// Every row, not just the selection (decision 2247) — the detail bindings
|
||||
// resolve the live selection against these at call time, the way the
|
||||
// reference peeks its quest cache inside the call.
|
||||
Some(build_detail(
|
||||
t,
|
||||
&mut items,
|
||||
&items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
¯os,
|
||||
@@ -1425,8 +1414,8 @@ mod tests {
|
||||
timer: 0,
|
||||
};
|
||||
let store = ObjectFields::default(); // empty bags — the item objective reads 0/5
|
||||
let mut items = Items::default();
|
||||
let mut names = NameCache::default();
|
||||
let items = Items::default();
|
||||
let names = NameCache::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
|
||||
@@ -1434,8 +1423,8 @@ mod tests {
|
||||
&template,
|
||||
&log_slot,
|
||||
&store,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&items,
|
||||
&names,
|
||||
&commands,
|
||||
&probe_strings,
|
||||
);
|
||||
@@ -1475,8 +1464,8 @@ mod tests {
|
||||
timer: 0,
|
||||
};
|
||||
let store = ObjectFields::default();
|
||||
let mut items = Items::default();
|
||||
let mut names = NameCache::default();
|
||||
let items = Items::default();
|
||||
let names = NameCache::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
|
||||
@@ -1484,8 +1473,8 @@ mod tests {
|
||||
&template,
|
||||
&log_slot,
|
||||
&store,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&items,
|
||||
&names,
|
||||
&commands,
|
||||
&probe_strings,
|
||||
);
|
||||
@@ -1517,8 +1506,8 @@ mod tests {
|
||||
timer: 0,
|
||||
};
|
||||
let store = ObjectFields::default();
|
||||
let mut items = Items::default();
|
||||
let mut names = NameCache::default();
|
||||
let items = Items::default();
|
||||
let names = NameCache::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
|
||||
@@ -1526,8 +1515,8 @@ mod tests {
|
||||
&template,
|
||||
&log_slot,
|
||||
&store,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&items,
|
||||
&names,
|
||||
&commands,
|
||||
&probe_strings,
|
||||
);
|
||||
@@ -1556,8 +1545,8 @@ mod tests {
|
||||
timer: 0,
|
||||
};
|
||||
let store = ObjectFields::default();
|
||||
let mut items = Items::default();
|
||||
let mut names = NameCache::default();
|
||||
let items = Items::default();
|
||||
let names = NameCache::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
|
||||
@@ -1565,8 +1554,8 @@ mod tests {
|
||||
&template,
|
||||
&log_slot,
|
||||
&store,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&items,
|
||||
&names,
|
||||
&commands,
|
||||
&probe_strings,
|
||||
);
|
||||
@@ -1591,8 +1580,8 @@ mod tests {
|
||||
timer: 0,
|
||||
};
|
||||
let store = ObjectFields::default();
|
||||
let mut items = Items::default();
|
||||
let mut names = NameCache::default();
|
||||
let items = Items::default();
|
||||
let names = NameCache::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
|
||||
@@ -1600,8 +1589,8 @@ mod tests {
|
||||
&template,
|
||||
&log_slot,
|
||||
&store,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&items,
|
||||
&names,
|
||||
&commands,
|
||||
&probe_strings,
|
||||
);
|
||||
|
||||
@@ -156,7 +156,7 @@ impl Plugin for QuestSharePlugin {
|
||||
fn feed_quest_share(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut share: ResMut<QuestShare>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: MessageSink,
|
||||
) {
|
||||
|
||||
@@ -181,17 +181,32 @@ fn failed_cast_turns_red_holds_then_fades() {
|
||||
fn channel_counts_down_not_up() {
|
||||
let mut s = harness();
|
||||
// SPELLCAST_CHANNEL_START(ms, name) — args reversed vs START, per the reference contract.
|
||||
// The name is whatever `ui_cast::channel_start_args` composed; for all but nine of the 323
|
||||
// channeled rows that is the literal word, which is what the feed hands Starshards too.
|
||||
s.fire_event(
|
||||
"SPELLCAST_CHANNEL_START",
|
||||
vec![
|
||||
ScriptValue::Int(6000),
|
||||
ScriptValue::Str("Starshards".into()),
|
||||
ScriptValue::Str("Channeling".into()),
|
||||
],
|
||||
);
|
||||
assert!(s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return CastingBarText:GetText()").unwrap(),
|
||||
"Starshards"
|
||||
"Channeling"
|
||||
);
|
||||
|
||||
// **In 1.12 the channel bar is ORANGE** — the same `SetStatusBarColor(1.0, 0.7, 0.0)` the cast
|
||||
// bar takes (stock `CastingBarFrame.lua` l.76 vs l.21, byte-identical to the copy in the
|
||||
// player's `patch.MPQ`); green is the COMPLETION flash and nothing else. A channel is told
|
||||
// apart by draining instead of filling, and by its label. **Classic Era's channel bar IS
|
||||
// green** (`CastingBarType.Channel`'s `classicFillColor = CASTBAR_CLASSIC_GREEN`) — a real
|
||||
// behaviour of a different client, which is why this is a gate and not a comment: if we ever
|
||||
// take Era's colour it is a deliberate deviation that has to come here first (decision 2284).
|
||||
let (r, g, b) = bar_color(&s);
|
||||
assert!(
|
||||
(r - 1.0).abs() < 1e-6 && (g - 0.7).abs() < 1e-6 && b.abs() < 1e-6,
|
||||
"a channel opens orange, exactly like a cast (got {r} {g} {b})"
|
||||
);
|
||||
|
||||
let full = bar_value(&s);
|
||||
|
||||
@@ -237,7 +237,7 @@ fn feed_shapeshift_bar(
|
||||
units: Query<&ObjectStore, Without<SelfPlayer>>,
|
||||
factions: Option<Res<crate::target::Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<StanceMemory>>,
|
||||
@@ -303,7 +303,7 @@ fn feed_shapeshift_bar(
|
||||
cooldowns: &cooldowns,
|
||||
carried: &carried,
|
||||
};
|
||||
usable::spell_usable(id, d, &spells, &ctx, &mut items, &commands).0
|
||||
usable::spell_usable(id, d, &spells, &ctx, &items, &commands).0
|
||||
});
|
||||
let texture = form_texture(d, active);
|
||||
let cooldown = cooldowns
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(super) struct FedSocial {
|
||||
pub(super) fn feed_social(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut social: ResMut<SocialState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: MessageSink,
|
||||
@@ -80,7 +80,7 @@ pub(super) fn feed_social(
|
||||
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
// Each owed line carries a catalog key, so the surface AND the sound come from its row —
|
||||
// `ERR_FRIEND_ONLINE_SS`'s is `FRIENDJOINGAME`, which a straight chat push could not play.
|
||||
let owed: Vec<Shown> = drain_result_lines(&mut social, &mut names, &commands)
|
||||
let owed: Vec<Shown> = drain_result_lines(&mut social, &names, &commands)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
crate::ui_action::ui_error_text(e, &get).map(|text| Shown::keyed(e.key, text))
|
||||
@@ -88,7 +88,7 @@ pub(super) fn feed_social(
|
||||
.collect();
|
||||
// The row's away tag, through `CHAT_FLAG_AFK`/`_DND` — the chat frame's own pair.
|
||||
let away = |status: u8| status_flag_key(status).and_then(&get).unwrap_or_default();
|
||||
let (friends, display_order) = friend_rows(&social, &mut names, &commands, areas, &away);
|
||||
let (friends, display_order) = friend_rows(&social, &names, &commands, areas, &away);
|
||||
(owed, friends, display_order)
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(super) fn feed_social(
|
||||
// update that removes their zone.
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_social", owed);
|
||||
|
||||
let (ignores, ignore_order) = ignore_rows(&social, &mut names, &commands);
|
||||
let (ignores, ignore_order) = ignore_rows(&social, &names, &commands);
|
||||
let who = who_rows(&social, areas);
|
||||
|
||||
let selected_friend = index_of(&display_order, social.selected_friend);
|
||||
@@ -170,7 +170,7 @@ fn answer_goes_to_the_frame(to_ui: bool, shown: usize) -> bool {
|
||||
/// it" must not look the same.
|
||||
fn drain_result_lines(
|
||||
social: &mut SocialState,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Vec<UiError> {
|
||||
let mut still_pending = Vec::new();
|
||||
@@ -251,7 +251,7 @@ fn who_lines(rows: &[WhoInfo], total: u32, get: &dyn Fn(&str) -> Option<String>)
|
||||
/// drain can map a row index back to a player.
|
||||
fn friend_rows(
|
||||
social: &SocialState,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
areas: Option<&AreaTableCatalog>,
|
||||
away: &dyn Fn(u8) -> String,
|
||||
@@ -303,7 +303,7 @@ fn friend_rows(
|
||||
/// The ignore rows: names only, same ordering rule.
|
||||
fn ignore_rows(
|
||||
social: &SocialState,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> (Vec<String>, Vec<u64>) {
|
||||
let mut rows: Vec<(u64, String)> = social
|
||||
|
||||
@@ -152,7 +152,7 @@ fn feed_spellbook(
|
||||
skill_lines: Option<Res<SkillLines>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
commands: Res<NetCommands>,
|
||||
cooldowns: Res<crate::cooldowns::Cooldowns>,
|
||||
@@ -237,13 +237,13 @@ fn feed_spellbook(
|
||||
// not spell 6603's `Temp` placeholder (decision 0230) — resolved here where the self player +
|
||||
// item stores are in hand, once for the whole page (it's the same for any auto-attack spell).
|
||||
let attack_icon = store
|
||||
.map(|s| melee_auto_attack_icon(s, &spells.forms, &mut items, icons.as_deref(), &commands));
|
||||
.map(|s| melee_auto_attack_icon(s, &spells.forms, &items, icons.as_deref(), &commands));
|
||||
// The ranged auto-repeat shots (Auto Shot, wand Shoot) borrow the equipped ranged weapon's
|
||||
// icon the same way (decision 0231's ranged case; `None` — unarmed/thrown — keeps the
|
||||
// spell's own icon, never Spell-Reset). Character-level like the melee icon: one resolve
|
||||
// serves the page.
|
||||
let ranged_icon =
|
||||
store.and_then(|s| ranged_weapon_icon(s, &mut items, icons.as_deref(), &commands));
|
||||
store.and_then(|s| ranged_weapon_icon(s, &items, icons.as_deref(), &commands));
|
||||
let (mut fresh, tab_lines) = build_book(
|
||||
&actions.spells,
|
||||
&spells.catalog,
|
||||
|
||||
@@ -143,7 +143,7 @@ impl NpcSession for StableOpen {
|
||||
/// gating the whole row: a pet whose query is in flight still shows its name and level.
|
||||
fn resolve_pet(
|
||||
wire: &StabledPet,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
families: Option<&PetFamilyTables>,
|
||||
stats: Option<&PetStatTables>,
|
||||
commands: &NetCommands,
|
||||
@@ -195,7 +195,7 @@ fn resolve_pet(
|
||||
/// Build the Lua-facing snapshot from [`StableOpen`] — `None` when no stable is open.
|
||||
fn snapshot(
|
||||
open: &StableOpen,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
families: Option<&PetFamilyTables>,
|
||||
stats: Option<&PetStatTables>,
|
||||
next_slot_cost: u32,
|
||||
@@ -236,7 +236,7 @@ fn feed_stable(
|
||||
stats: Option<Res<PetStatTables>>,
|
||||
prices: Option<Res<StableSlotPrices>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
mut errors: ResMut<StableErrors>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<StableState>>>,
|
||||
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
@@ -271,7 +271,7 @@ fn feed_stable(
|
||||
let has_live_pet = bar.spells.pet_guid != 0;
|
||||
let fresh = snapshot(
|
||||
&open,
|
||||
&mut names,
|
||||
&names,
|
||||
families.as_deref(),
|
||||
stats.as_deref(),
|
||||
next_slot_cost,
|
||||
|
||||
@@ -130,7 +130,7 @@ impl SummonState {
|
||||
fn feed_summon(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut summon: ResMut<SummonState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
// Real, not virtual: the deadline was stamped on this clock by the net drain, and the server's
|
||||
|
||||
@@ -154,7 +154,7 @@ fn feed_tabard(
|
||||
mut open: ResMut<TabardOpen>,
|
||||
mut design: ResMut<TabardDesign>,
|
||||
mut booth: ResMut<PaperDollBooth>,
|
||||
mut guilds: ResMut<GuildState>,
|
||||
guilds: Res<GuildState>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last_host: Local<crate::ui_script::VmMemo<Option<TabardHost>>>,
|
||||
@@ -222,7 +222,7 @@ fn feed_tabard(
|
||||
fn drain_tabard(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<TabardOpen>,
|
||||
mut guilds: ResMut<GuildState>,
|
||||
guilds: Res<GuildState>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
|
||||
@@ -135,7 +135,7 @@ fn feed_taxi(
|
||||
mut state: ResMut<TaxiState>,
|
||||
catalogs: Option<Res<TaxiCatalogs>>,
|
||||
player: Res<Player>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut cache: ResMut<TaxiRouteCache>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TaxiUiState>>>,
|
||||
|
||||
@@ -706,7 +706,7 @@ fn drive_mouseover_tooltip(
|
||||
// The stored GAMEOBJECT_STATE the lock lines' Action gate reads (decision 0752).
|
||||
anims: Query<&crate::go_anim::GoAnim>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
// The standing pair as one param — see [`crate::target::ReactionInputs`]. Bundled here and
|
||||
// not elsewhere because this system sits on Bevy's tuple limit.
|
||||
@@ -715,7 +715,7 @@ fn drive_mouseover_tooltip(
|
||||
// hover and the click can never disagree about whether a lock is satisfiable — the same reason
|
||||
// `usable` and the click share one resolver (0752). Carries the go-template, Lock.dbc and
|
||||
// item caches this system used to take as three separate params.
|
||||
mut go_inputs: crate::target::lock::GoLockInputs,
|
||||
go_inputs: crate::target::lock::GoLockInputs,
|
||||
// The known-spell set the resolver's SKILL arm scans.
|
||||
player_actions: Res<crate::ui_action::PlayerActions>,
|
||||
// The cursor seat crosses the VM seam (0582/0584): the anchor below is UI units, not px.
|
||||
|
||||
@@ -597,7 +597,7 @@ fn streamed_player<'a>(
|
||||
fn answer_trade_request(
|
||||
mut trade: ResMut<TradeSession>,
|
||||
commands: Res<NetCommands>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
|
||||
social: Res<crate::ui_social::SocialState>,
|
||||
cinematic: Res<crate::cinematic::Cinematic>,
|
||||
@@ -719,7 +719,7 @@ fn answer_trade_request(
|
||||
/// is decision 0592 P3.
|
||||
fn resolve_slot(
|
||||
item: &TradeItem,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> TradeSlotItem {
|
||||
@@ -755,7 +755,7 @@ fn own_item_at(
|
||||
bag: i64,
|
||||
slot: u32,
|
||||
store: Option<&ObjectStore>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> Option<TradeItem> {
|
||||
let (guid, count) = crate::ui_items::slot_guid_count(store, bag, slot, items);
|
||||
@@ -787,7 +787,7 @@ fn own_item_at(
|
||||
/// Resolve one side's wire offer into the Lua-facing side state.
|
||||
fn resolve_side(
|
||||
offer: &TradeOffer,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> TradeSideState {
|
||||
@@ -804,9 +804,9 @@ fn resolve_side(
|
||||
/// Build the Lua-facing snapshot from [`TradeSession`] — `None` when no trade window is open.
|
||||
fn snapshot(
|
||||
trade: &TradeSession,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
names: &mut NameCache,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<TradeState> {
|
||||
if !trade.open {
|
||||
@@ -829,9 +829,9 @@ fn snapshot(
|
||||
fn feed_trade(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut trade: ResMut<TradeSession>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
|
||||
// The status arms' second guard (`0x468460(guid, TYPEMASK_PLAYER)`) — see
|
||||
@@ -853,7 +853,7 @@ fn feed_trade(
|
||||
let last_player_gold = last_player_gold.get(&script);
|
||||
let last_their_gold = last_their_gold.get(&script);
|
||||
|
||||
let fresh = snapshot(&trade, &mut items, icons.as_deref(), &mut names, &commands);
|
||||
let fresh = snapshot(&trade, &items, icons.as_deref(), &names, &commands);
|
||||
let opened = !*last_open && trade.is_open();
|
||||
let closed = *last_open && !trade.is_open();
|
||||
let changed = fresh != *last;
|
||||
@@ -983,7 +983,7 @@ fn drain_trade(
|
||||
commands: Res<NetCommands>,
|
||||
selection: Res<Selection>,
|
||||
group: Res<GroupState>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
@@ -1040,7 +1040,7 @@ fn drain_trade(
|
||||
if let Some(trade_slot) = id.checked_sub(1).and_then(|n| u8::try_from(n).ok()) {
|
||||
// Optimistic own display: vmangos echoes the placement only to the partner, so resolve the
|
||||
// bag item and fill our own column client-side (decision 0592 P2).
|
||||
if let Some(item) = own_item_at(bag, slot, store, &mut items, &commands) {
|
||||
if let Some(item) = own_item_at(bag, slot, store, &items, &commands) {
|
||||
trade.place_own_item(id, item);
|
||||
}
|
||||
info!(target: "trade", "set item: slot {id} <- bag {bag}/{slot} (wire {wire_bag}/{wire_slot}); sending CMSG_SET_TRADE_ITEM");
|
||||
|
||||
@@ -227,7 +227,7 @@ pub(crate) fn difficulty(rank: u32, low: u32, high: u32) -> TradeSkillDifficulty
|
||||
fn recipe_icon(
|
||||
d: &benilla_formats::SpellDisplay,
|
||||
icons: Option<&ItemDisplays>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
let item = d.effect_item_type[0];
|
||||
@@ -249,7 +249,7 @@ fn resolve_recipe(
|
||||
icons: Option<&ItemDisplays>,
|
||||
subclasses: Option<&crate::ui_items::ItemSubClasses>,
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
cooldowns: &crate::cooldowns::Cooldowns,
|
||||
now: Instant,
|
||||
@@ -375,7 +375,7 @@ fn feed_trade_skill(
|
||||
subclasses: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
repeat: Res<TradeSkillRepeat>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
cooldowns: Res<crate::cooldowns::Cooldowns>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TradeSkillState>>>,
|
||||
@@ -416,7 +416,7 @@ fn feed_trade_skill(
|
||||
icons.as_deref(),
|
||||
subclasses.as_deref(),
|
||||
store,
|
||||
&mut items,
|
||||
&items,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
now,
|
||||
@@ -583,7 +583,7 @@ mod tests {
|
||||
let icons = landed_item(&mut deps);
|
||||
let d = recipe(SPELL_EFFECT_CREATE_ITEM, 777);
|
||||
assert_eq!(
|
||||
recipe_icon(&d, Some(&icons), &mut deps.items, &deps.commands),
|
||||
recipe_icon(&d, Some(&icons), &deps.items, &deps.commands),
|
||||
Some("ITEM".into()),
|
||||
);
|
||||
}
|
||||
@@ -598,7 +598,7 @@ mod tests {
|
||||
let icons = landed_item(&mut deps);
|
||||
let d = recipe(SPELL_EFFECT_ENCHANT_ITEM, 777);
|
||||
assert_eq!(
|
||||
recipe_icon(&d, Some(&icons), &mut deps.items, &deps.commands),
|
||||
recipe_icon(&d, Some(&icons), &deps.items, &deps.commands),
|
||||
Some("ITEM".into()),
|
||||
);
|
||||
}
|
||||
@@ -614,24 +614,24 @@ mod tests {
|
||||
// EffectItemType[0] == 0: 0x55ba30 short-circuits on a zero id before hashing.
|
||||
let none = recipe(SPELL_EFFECT_ENCHANT_ITEM, 0);
|
||||
assert_eq!(
|
||||
recipe_icon(&none, Some(&icons), &mut deps.items, &deps.commands),
|
||||
recipe_icon(&none, Some(&icons), &deps.items, &deps.commands),
|
||||
None,
|
||||
);
|
||||
|
||||
// A template that never lands (the async row) — nil, and the ask goes out exactly once.
|
||||
let missing = recipe(SPELL_EFFECT_CREATE_ITEM, 999);
|
||||
assert_eq!(
|
||||
recipe_icon(&missing, Some(&icons), &mut deps.items, &deps.commands),
|
||||
recipe_icon(&missing, Some(&icons), &deps.items, &deps.commands),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
recipe_icon(&missing, Some(&icons), &mut deps.items, &deps.commands),
|
||||
recipe_icon(&missing, Some(&icons), &deps.items, &deps.commands),
|
||||
None,
|
||||
);
|
||||
assert_eq!(deps.queried_entries(), vec![999], "ask-once, not ask-often");
|
||||
|
||||
// The template landed but ItemDisplayInfo is unresolved — still nil, still not "SPELL".
|
||||
let d = recipe(SPELL_EFFECT_CREATE_ITEM, 777);
|
||||
assert_eq!(recipe_icon(&d, None, &mut deps.items, &deps.commands), None);
|
||||
assert_eq!(recipe_icon(&d, None, &deps.items, &deps.commands), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) fn service_icon(
|
||||
trainer_type: u32,
|
||||
spells: &SpellCatalog,
|
||||
icons: Option<&ItemDisplays>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
let wire = spells.get(wire_spell)?;
|
||||
|
||||
@@ -193,7 +193,7 @@ fn resolve_service(
|
||||
skill_lines: Option<&SkillLineCatalog>,
|
||||
known: &BTreeSet<u32>,
|
||||
icons: Option<&ItemDisplays>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
// The VM's own `GlobalStrings.lua`, for [`service_group`]'s three header labels.
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
@@ -310,7 +310,7 @@ fn snapshot(
|
||||
skill_lines: Option<&SkillLineCatalog>,
|
||||
known: &BTreeSet<u32>,
|
||||
icons: Option<&ItemDisplays>,
|
||||
items: &mut Items,
|
||||
items: &Items,
|
||||
commands: &NetCommands,
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<TrainerState> {
|
||||
@@ -355,10 +355,10 @@ fn feed_trainer(
|
||||
// A tradeskill trainer's rows front the CREATED ITEM's icon, so the feed needs the ask-once
|
||||
// template cache + `ItemDisplayInfo.dbc` — the tradeskill window's own pair ([`service_icon`]).
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
mut items: ResMut<Items>,
|
||||
items: Res<Items>,
|
||||
mut errors: ResMut<TrainerErrors>,
|
||||
commands: Res<NetCommands>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TrainerState>>>,
|
||||
mut last_trainer: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
@@ -400,7 +400,7 @@ fn feed_trainer(
|
||||
Some(&skill_lines.catalog),
|
||||
&actions.spells,
|
||||
icons.as_deref(),
|
||||
&mut items,
|
||||
&items,
|
||||
&commands,
|
||||
&|key: &str| {
|
||||
script
|
||||
|
||||
@@ -43,7 +43,7 @@ fn resolve_service(
|
||||
skill_lines: Option<&SkillLineCatalog>,
|
||||
known: &BTreeSet<u32>,
|
||||
) -> TrainerService {
|
||||
let mut deps = Deps::new();
|
||||
let deps = Deps::new();
|
||||
super::resolve_service(
|
||||
wire,
|
||||
trainer_type,
|
||||
@@ -51,7 +51,7 @@ fn resolve_service(
|
||||
skill_lines,
|
||||
known,
|
||||
None,
|
||||
&mut deps.items,
|
||||
&deps.items,
|
||||
&deps.commands,
|
||||
&probe_strings,
|
||||
)
|
||||
@@ -73,14 +73,14 @@ fn wire(spell: u32, state: u8, cost: u32, req_level: u8, req_skill: u32) -> Trai
|
||||
|
||||
/// [`snapshot`] with the icon trio defaulted.
|
||||
fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option<TrainerState> {
|
||||
let mut deps = Deps::new();
|
||||
let deps = Deps::new();
|
||||
snapshot(
|
||||
open,
|
||||
spells,
|
||||
None,
|
||||
&BTreeSet::new(),
|
||||
None,
|
||||
&mut deps.items,
|
||||
&deps.items,
|
||||
&deps.commands,
|
||||
&probe_strings,
|
||||
)
|
||||
@@ -134,7 +134,7 @@ fn icon_of(
|
||||
trainer_type,
|
||||
spells,
|
||||
icons.as_ref(),
|
||||
&mut deps.items,
|
||||
&deps.items,
|
||||
&deps.commands,
|
||||
)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ fn trainer_icon_scans_every_effect_slot_for_either_learn_effect() {
|
||||
#[test]
|
||||
fn trainer_icon_is_nil_until_the_product_template_lands_and_asks_once() {
|
||||
let spells = icon_catalog();
|
||||
let mut deps = Deps::new();
|
||||
let deps = Deps::new();
|
||||
let icons = ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::new()));
|
||||
|
||||
let first = service_icon(
|
||||
@@ -230,7 +230,7 @@ fn trainer_icon_is_nil_until_the_product_template_lands_and_asks_once() {
|
||||
TRAINER_TYPE_TRADESKILL,
|
||||
&spells,
|
||||
Some(&icons),
|
||||
&mut deps.items,
|
||||
&deps.items,
|
||||
&deps.commands,
|
||||
);
|
||||
assert_eq!(first, None, "no template yet → nil, not the wrapper's icon");
|
||||
@@ -246,7 +246,7 @@ fn trainer_icon_is_nil_until_the_product_template_lands_and_asks_once() {
|
||||
TRAINER_TYPE_TRADESKILL,
|
||||
&spells,
|
||||
Some(&icons),
|
||||
&mut deps.items,
|
||||
&deps.items,
|
||||
&deps.commands,
|
||||
);
|
||||
assert!(
|
||||
@@ -276,14 +276,14 @@ fn trainer_icon_on_real_data_reaches_for_the_crafted_item() {
|
||||
|
||||
// A tradeskill trainer: gate 3 fires for item 2847 and the icon waits on the template —
|
||||
// crucially it is NOT the crown.
|
||||
let mut deps = Deps::new();
|
||||
let deps = Deps::new();
|
||||
let icons = ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::new()));
|
||||
let icon = service_icon(
|
||||
2756,
|
||||
TRAINER_TYPE_TRADESKILL,
|
||||
&spells,
|
||||
Some(&icons),
|
||||
&mut deps.items,
|
||||
&deps.items,
|
||||
&deps.commands,
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -297,8 +297,8 @@ fn trainer_icon_on_real_data_reaches_for_the_crafted_item() {
|
||||
);
|
||||
|
||||
// A class trainer with the same wire spell: the wrapper's own icon, not the recipe's.
|
||||
let mut deps = Deps::new();
|
||||
let class_icon = service_icon(2756, 0, &spells, None, &mut deps.items, &deps.commands);
|
||||
let deps = Deps::new();
|
||||
let class_icon = service_icon(2756, 0, &spells, None, &deps.items, &deps.commands);
|
||||
assert_eq!(class_icon, wrapper_icon);
|
||||
assert_ne!(
|
||||
class_icon, recipe_icon,
|
||||
@@ -596,9 +596,9 @@ fn tooltip_hops_to_the_taught_spell_where_the_icon_pins_the_wire() {
|
||||
);
|
||||
// The icon law, same row, same catalog: the WIRE spell's own art. Pinned together so the
|
||||
// disagreement is visible in one place and can't be "fixed" into agreement.
|
||||
let mut deps = Deps::new();
|
||||
let deps = Deps::new();
|
||||
assert_eq!(
|
||||
super::service_icon(100, 0, &spells, None, &mut deps.items, &deps.commands),
|
||||
super::service_icon(100, 0, &spells, None, &deps.items, &deps.commands),
|
||||
Some("WRAPPER".into()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1594,7 +1594,7 @@ fn feed_units(
|
||||
index: Option<Res<crate::net::GuidIndex>>,
|
||||
mut stores: UnitStores,
|
||||
mut feed: ResMut<UnitFeedState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
|
||||
@@ -451,7 +451,7 @@ fn drive_vplates(
|
||||
// the pointer is driving the camera.
|
||||
rig: Res<crate::player::CameraControl>,
|
||||
world: PlateWorld,
|
||||
mut names: ResMut<NameCache>,
|
||||
names: Res<NameCache>,
|
||||
net_commands: Res<NetCommands>,
|
||||
// The widget layer this drives (decision 2148). `None` in a VM-less run (a capture with the
|
||||
// interface off, a bare test app) — the gate below then costs one early return.
|
||||
|
||||
@@ -385,6 +385,28 @@ mod tests {
|
||||
Some("Fist Weapon")
|
||||
);
|
||||
|
||||
// Class 6 PROJECTILE — the lookup the cast-fail `0x31` NEED_EXOTIC_AMMO arm makes
|
||||
// (`0x6e1e5e: mov ecx,0x6`, mask `1 << arg`), so "Requires exotic ammo: %s" names the
|
||||
// ammo type. Only two of the five shipped rows are live in 1.12; the other three carry
|
||||
// an `(OBSOLETE)` suffix in the data itself, and one is pinned below so a schema slip
|
||||
// that silently shifted the class would be visible rather than merely returning `None`.
|
||||
assert_eq!(
|
||||
cat.requirement_display_name(6, 1 << 2).as_deref(),
|
||||
Some("Arrow")
|
||||
);
|
||||
assert_eq!(
|
||||
cat.requirement_display_name(6, 1 << 3).as_deref(),
|
||||
Some("Bullet")
|
||||
);
|
||||
assert_eq!(
|
||||
cat.requirement_display_name(6, 1 << 4).as_deref(),
|
||||
Some("Thrown(OBSOLETE)")
|
||||
);
|
||||
// Past the shipped rows — the arm's own decline (`0x6e1e6a: je 0x6e21d8`, the shared
|
||||
// default), which is where an absent or wild wire word lands.
|
||||
assert_eq!(cat.requirement_display_name(6, 1 << 5), None);
|
||||
assert_eq!(cat.requirement_display_name(6, 1 << 31), None);
|
||||
|
||||
// Nothing to name.
|
||||
assert_eq!(cat.requirement_name(2, 0), None);
|
||||
assert_eq!(cat.requirement_name(99, 1), None);
|
||||
|
||||
@@ -203,7 +203,8 @@ pub use spells::{
|
||||
ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE,
|
||||
SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM,
|
||||
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL,
|
||||
SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
|
||||
SPELL_EFFECT_PROSPECTING, SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING,
|
||||
SPELL_EFFECT_TRADE_SKILL,
|
||||
};
|
||||
mod skill_lines;
|
||||
pub use skill_lines::{
|
||||
|
||||
@@ -1038,6 +1038,47 @@ fn real_main_hand_autopick_family() {
|
||||
assert_eq!(wf_totem.targets, 0, "a totem summon binds nothing");
|
||||
}
|
||||
|
||||
/// **The prospecting leg of `0x495d60` is dead on 5875 data** — the fact two cast-failure
|
||||
/// argument arms rest on not being written (decision 2292). That validator's effect loop has a
|
||||
/// third leg past the two enchant ones: `0x495df1 jne 0x495f36` falls into
|
||||
/// `0x495f39 cmp DWORD PTR [eax],0x7f`, and inside it sit the only image-wide raises of
|
||||
/// `SPELL_FAILED_PROSPECT_NEED_MORE` (`0x49614e`) and `SPELL_FAILED_MIN_SKILL` (`0x496128`).
|
||||
/// vmangos sends neither reason from anywhere, so if no shipped spell carries
|
||||
/// [`SPELL_EFFECT_PROSPECTING`] there is no route to either message at all — and none does.
|
||||
///
|
||||
/// This lives in the formats crate rather than beside the arms because it is a claim about the
|
||||
/// **data**: if a data set ever ships a prospecting spell, this fails and names the two arms that
|
||||
/// just came alive. Skips without client data.
|
||||
#[test]
|
||||
fn real_prospecting_effect_is_absent_from_5875() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let raw = chain.read_file(SPELL).expect("Spell.dbc");
|
||||
let set = parse(&raw, spell_schema(), "Spell.dbc").expect("parse Spell.dbc");
|
||||
|
||||
let mut slots = 0usize;
|
||||
for r in set.records() {
|
||||
for i in 0..3 {
|
||||
slots += 1;
|
||||
assert_ne!(
|
||||
u32_at(r, COL_EFFECT_1 + i).unwrap_or(0),
|
||||
crate::SPELL_EFFECT_PROSPECTING,
|
||||
"spell {} carries SPELL_EFFECT_PROSPECTING in effect slot {i} — `0x495d60`'s \
|
||||
third leg is reachable now, so cast-fail reasons 0x84 PROSPECT_NEED_MORE and \
|
||||
0x90 MIN_SKILL can raise and `ui_action::cast_fail` owes them argument arms \
|
||||
(decision 2292)",
|
||||
u32_at(r, 0).unwrap_or(0)
|
||||
);
|
||||
}
|
||||
}
|
||||
// The scan covering nothing would pass just as quietly, so pin the shape of the file too.
|
||||
assert_eq!(
|
||||
slots,
|
||||
22357 * 3,
|
||||
"every effect slot of every 5875 row was read"
|
||||
);
|
||||
}
|
||||
|
||||
/// The item-target family and its gate columns (decision 0923), against the real 5875 file. The
|
||||
/// reference's `TargetingWantsItem 0x6e6330` is `flag_word & 0x4010`, and on shipped data those
|
||||
/// two bits are **never** mixed with a unit bit — the whole family is `Targets` exactly `0x10`
|
||||
@@ -1437,3 +1478,75 @@ fn real_is_harmful_pins() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The channel bar's naming law on the real build-5875 `Spell.dbc`** (decision 2284) — the two
|
||||
/// bits `SpellChannelStart 0x6e7550` tests, and the population that makes the default the
|
||||
/// interesting half.
|
||||
///
|
||||
/// The cast bar names its spell unless a bit forbids it; the channel bar says "Channeling" unless
|
||||
/// a bit permits it. Exactly **9** of the 323 channeled rows permit it and **2** suppress the bar
|
||||
/// outright, so "Channeling" is what a player sees for essentially every channel in the game.
|
||||
/// A column slip, or a bit read off the wrong `AttributesEx*`, fails here. Skips without client
|
||||
/// data.
|
||||
#[test]
|
||||
fn real_channel_bar_name_law() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
|
||||
|
||||
// The generic leg — every rank of Blizzard, plus the other channels a player meets daily.
|
||||
// These are the rows that read the literal word, and Blizzard is the one that prompted 2284.
|
||||
for id in [
|
||||
10u32, 6141, 8427, 10185, 10186, 10187, // Blizzard, all six ranks
|
||||
5143, // Arcane Missiles
|
||||
15407, // Mind Flay
|
||||
689, // Drain Life
|
||||
5740, // Rain of Fire
|
||||
] {
|
||||
let d = cat.get(id).unwrap_or_else(|| panic!("spell {id}"));
|
||||
assert!(
|
||||
!d.channel_bar_own_name(),
|
||||
"{id} {:?} reads \"Channeling\", not its own name (AttributesEx {:#010x})",
|
||||
d.name,
|
||||
d.attributes_ex
|
||||
);
|
||||
assert!(!d.no_channel_bar(), "{id} {:?} still shows a bar", d.name);
|
||||
}
|
||||
|
||||
// The named leg — the sharpest control wow-re names: Fishing and Mind Flay take opposite
|
||||
// legs of the same `0x6e75a1`, and vanilla really does print one name and one generic word.
|
||||
for (id, name) in [
|
||||
(7620u32, "Fishing"),
|
||||
(18248, "Fishing"),
|
||||
(20578, "Cannibalize"),
|
||||
] {
|
||||
let d = cat.get(id).unwrap_or_else(|| panic!("spell {id}"));
|
||||
assert!(d.channel_bar_own_name(), "{id} names itself on the bar");
|
||||
assert_eq!(d.name, name);
|
||||
}
|
||||
|
||||
// The suppressor — a total one, and the only two rows that carry it.
|
||||
for id in [24322u32, 24323] {
|
||||
assert!(
|
||||
cat.get(id).expect("Blood Siphon").no_channel_bar(),
|
||||
"{id} Blood Siphon shows no channel bar at all"
|
||||
);
|
||||
}
|
||||
|
||||
// The population, which is the law's real shape: the opt-ins are a rounding error.
|
||||
let channeled: Vec<_> = cat.iter().filter(|(_, d)| d.tooltip_channeled()).collect();
|
||||
assert_eq!(channeled.len(), 323, "channeled rows in the shipped file");
|
||||
assert_eq!(
|
||||
channeled
|
||||
.iter()
|
||||
.filter(|(_, d)| d.channel_bar_own_name())
|
||||
.count(),
|
||||
9,
|
||||
"only nine channeled rows print their own name"
|
||||
);
|
||||
assert_eq!(
|
||||
channeled.iter().filter(|(_, d)| d.no_channel_bar()).count(),
|
||||
2,
|
||||
"only the two Blood Siphons suppress the bar"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -567,6 +567,37 @@ impl SpellDisplay {
|
||||
self.attributes_ex3 & ATTR_EX3_NO_CASTING_BAR_TEXT != 0
|
||||
}
|
||||
|
||||
/// `AttributesEx3 & 0x2000` — **this spell shows no channel bar at all**
|
||||
/// ([`ATTR_EX3_NO_CHANNEL_BAR`]). The channel handler `0x6e7550` tests it before it composes
|
||||
/// anything (`0x6e7595 test ch,0x20` → `jne` the return), so `SPELLCAST_CHANNEL_START` never
|
||||
/// fires and the frame is never shown.
|
||||
///
|
||||
/// A **different** suppression from [`Self::no_casting_bar_text`]'s and a total one: that bit
|
||||
/// blanks the cast bar's *label* and still draws the bar; this one removes the event. The two
|
||||
/// live one nibble apart in the same column and are easy to conflate — the channel path never
|
||||
/// reads bit 2 at all (`0x6e7a2d` is the only bit-2 test on `SpellRec+0x24` image-wide, wow-re
|
||||
/// `wave-cast.md`'s twice-run census).
|
||||
///
|
||||
/// Both shipped rows are 24322/24323 "Blood Siphon", the Hakkar encounter's drain.
|
||||
pub fn no_channel_bar(&self) -> bool {
|
||||
self.attributes_ex3 & ATTR_EX3_NO_CHANNEL_BAR != 0
|
||||
}
|
||||
|
||||
/// `AttributesEx & 0x2000_0000` — **the channel bar prints this spell's own name**
|
||||
/// ([`ATTR_EX_CHANNEL_BAR_OWN_NAME`]); cleared, it prints the GlobalStrings word `CHANNELING`.
|
||||
/// `0x6e759a test DWORD PTR [SpellRec+0x1c],0x20000000` — set takes `Name[locale]`
|
||||
/// (`0x6e75a9`), clear takes `FrameScript_GetText(0x870dd0 = "CHANNELING")` (`0x6e75bc`).
|
||||
///
|
||||
/// This is the whole of the channel bar's naming law, and it is the reverse default of the
|
||||
/// cast bar's: a cast bar names its spell unless told not to, a channel bar says "Channeling"
|
||||
/// unless told to name it. Nine of the 323 channeled rows in the shipped 5875 file opt in —
|
||||
/// the four Fishing ranks, Cannibalize, Dream Vision, Using Control Console and the two
|
||||
/// (suppressed) Blood Siphons. Blizzard, Arcane Missiles, Mind Flay, Drain Life/Soul/Mana,
|
||||
/// Rain of Fire, Hurricane, Tranquility, Evocation and First Aid all read the generic word.
|
||||
pub fn channel_bar_own_name(&self) -> bool {
|
||||
self.attributes_ex & ATTR_EX_CHANNEL_BAR_OWN_NAME != 0
|
||||
}
|
||||
|
||||
/// The ranged-shot cooldown pad's gate (`0x6e2b60` at `0x6e2c2c`–`0x6e2c47`, byte-verified —
|
||||
/// wow-re `ranged-cooldown-sweep.md`): on the caster's own SPELL_GO self-insert, a
|
||||
/// `Attributes & 0x2` spell WITHOUT `AttributesEx2 & 0x20000`
|
||||
|
||||
@@ -402,6 +402,20 @@ const ATTR_EX3_NORMAL_RANGED_ATTACK: u32 = 0x8000;
|
||||
/// to themselves about what this attribute does to the spell, and it reached a player's screen
|
||||
/// because we printed the name unconditionally.
|
||||
const ATTR_EX3_NO_CASTING_BAR_TEXT: u32 = 0x4;
|
||||
/// `AttributesEx3` bit 13 (`0x2000`) — **this spell shows no CHANNEL bar at all**. The channel
|
||||
/// handler's own suppressor, and a *total* one: `0x6e7595 test ch,0x20` on `[SpellRec+0x24]`
|
||||
/// (`ch` = bits 8-15, so bit 13 — not [`ATTR_EX3_NO_CASTING_BAR_TEXT`]'s bit 2) jumps straight to
|
||||
/// the return, so `SPELLCAST_CHANNEL_START` never fires. Exactly two rows in the shipped 5875 file
|
||||
/// carry it, both 24322/24323 "Blood Siphon".
|
||||
const ATTR_EX3_NO_CHANNEL_BAR: u32 = 0x2000;
|
||||
/// `AttributesEx` bit 29 (`0x2000_0000`) — **the channel bar prints this spell's own name**
|
||||
/// instead of the generic word. `0x6e759a test DWORD PTR [SpellRec+0x1c],0x20000000`: set ⇒
|
||||
/// `Name[locale]`, clear ⇒ the GlobalStrings value of `CHANNELING`. Nine of the 323 channeled rows
|
||||
/// in the shipped file set it — the four Fishing ranks (7620/7731/7732/18248), 20578 Cannibalize,
|
||||
/// 11403 Dream Vision, 24937 Using Control Console and the two Blood Siphons (which the suppressor
|
||||
/// above hides anyway). Every other channel — Blizzard, Arcane Missiles, Mind Flay, Drain Life,
|
||||
/// Rain of Fire, Hurricane, Tranquility, Evocation, First Aid — reads "Channeling".
|
||||
const ATTR_EX_CHANNEL_BAR_OWN_NAME: u32 = 0x2000_0000;
|
||||
/// `Attributes` bit `0x2` — the "uses the ranged slot" attribute (mangos `SPELL_ATTR_RANGED`).
|
||||
/// One half of the client's ranged-stance gate (module docs).
|
||||
const ATTR_RANGED: u32 = 0x2;
|
||||
@@ -546,17 +560,39 @@ pub const SPELL_EFFECT_CREATE_ITEM: u32 = 24;
|
||||
/// (`0x4b2623: cmp [esi+0xf4], 0x5f` — `0x5f == 95`), and the cursor's skin leg requires that latch
|
||||
/// (decision 0752).
|
||||
pub const SPELL_EFFECT_SKINNING: u32 = 95;
|
||||
/// `SpellEffects` values `53`/`54` — `SPELL_EFFECT_ENCHANT_ITEM` (permanent) /
|
||||
/// `SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY` (vmangos `SharedDefines.h`): the item-targeted craft
|
||||
/// casts the CraftFrame sends with `TARGET_FLAG_ITEM` (0437 phase 3).
|
||||
/// `SpellEffects` value `39` — `SPELL_EFFECT_LANGUAGE`: the effect that makes a spell *be* a
|
||||
/// language. Its `EffectMiscValue_1` is the `Languages.dbc` id, which is how a language reaches a
|
||||
/// skill line at all — see [`SpellCatalog::language_spell`].
|
||||
pub const SPELL_EFFECT_LANGUAGE: u32 = 39;
|
||||
|
||||
/// `SpellEffects` values `53`/`54` — `SPELL_EFFECT_ENCHANT_ITEM` (permanent) /
|
||||
/// `SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY` (vmangos `SharedDefines.h`): the item-targeted craft
|
||||
/// casts the CraftFrame sends with `TARGET_FLAG_ITEM` (0437 phase 3).
|
||||
pub const SPELL_EFFECT_ENCHANT_ITEM: u32 = 53;
|
||||
pub const SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY: u32 = 54;
|
||||
|
||||
/// `SpellEffects` value `127` — `SPELL_EFFECT_PROSPECTING`, and **no 5875 spell carries it**
|
||||
/// (pinned by `real_prospecting_effect_is_absent_from_5875`). It is named here because the
|
||||
/// absence is load-bearing rather than incidental: the item-target validator `0x495d60` has a
|
||||
/// third leg past its two enchant ones, gated on exactly this value (`0x495f39: cmp [eax],0x7f`),
|
||||
/// and that leg holds the only raise sites image-wide for the cast-failure reasons `0x84`
|
||||
/// PROSPECT_NEED_MORE (`0x49614e`) and `0x90` MIN_SKILL (`0x496128`). The server sends neither
|
||||
/// reason either, so on this build both messages are unreachable — which is why
|
||||
/// `ui_action::cast_fail` models neither arm (decision 2292).
|
||||
///
|
||||
/// **The number is the binary's own, not a later build's enum ported backwards.** `TryCast
|
||||
/// 0x6e4b60` carries a matched pair of `Effect[0]` gates two instructions apart:
|
||||
/// `0x6e4ca2 cmp eax,0x63` (99) raises `SPELL_FAILED_DISENCHANT_WHILE_LOOTING` (`0x6e4cbb`), and
|
||||
/// `0x6e4cd4 cmp [edx+0xf4],0x7f` raises `SPELL_FAILED_PROSPECT_WHILE_LOOTING` (`0x6e4cee`). That
|
||||
/// pairing names 99 ↔ disenchant and 127 ↔ prospect in 5875's own hand — and makes `0x83` dead
|
||||
/// on the same grounds as `0x84`/`0x90`, while its sibling `0x82` stays live (effect 99 ships).
|
||||
///
|
||||
/// The **code** is live and only the **data** is absent: 127 is handled in three reachable places
|
||||
/// (`0x495f39`, `0x6e4cd4`, and the effect classifier `0x6e3b80`'s 128-entry case table at
|
||||
/// `0x6e3bdc`, which maps it to the default arm). Nothing here is dead-stripped — 5875 ships
|
||||
/// prospecting with no prospecting spell to reach it.
|
||||
pub const SPELL_EFFECT_PROSPECTING: u32 = 127;
|
||||
|
||||
/// `Spell.dbc` × `SpellIcon.dbc`, joined: spell id → name + icon path, plus the **learn-spell map**
|
||||
/// (a `SPELL_EFFECT_LEARN_SPELL` spell → the spell it teaches). Vanilla trainers offer a *learn*
|
||||
/// spell — a thin wrapper whose only effect is "teach spell X" — not the ability itself; the ability
|
||||
|
||||
Reference in New Issue
Block a user