items are objects in the one index, and three more packets are answered

- an item is an object in the one index, and its enchant and lifetime countdowns are its own (2334, 2340)
- the server's stand state applies, a bag equipped into a bag slot fires BAG_OPEN, and the inspect echo is dropped on purpose (2339)
- the schedule census counts declared orders only, and the architecture walls gate leaks rather than the API (2337, 2338)
- one queue for every event into the UI is not built: the reference orders its events per mechanism, not by the wire (2341)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 12:16:11 +07:00
co-authored by Claude Opus 5.5
parent 7e49e17688
commit 9c49f10882
70 changed files with 2822 additions and 1016 deletions
+20 -8
View File
@@ -42,7 +42,9 @@ pub(super) fn seed_ui_fixture(
mut quest: ResMut<crate::ui_quest::QuestGiver>,
mut quest_log: ResMut<crate::ui_quest_log::QuestLog>,
mut loot: ResMut<crate::ui_loot::LootState>,
mut items: ResMut<crate::items::Items>,
// The item store and the object index as one param (the 16-SystemParam ceiling): the item
// objects are entities in the index (2334), and the fixture spawns them like the wire does.
store: (ResMut<crate::items::Items>, ResMut<crate::net::GuidIndex>),
mut names: ResMut<crate::names::NameCache>,
icons: Option<Res<crate::entities::ItemDisplays>>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
@@ -57,6 +59,7 @@ pub(super) fn seed_ui_fixture(
ResMut<crate::loading_screen::LoadingScreen>,
),
) {
let (mut items, mut index) = store;
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
let Some(scenario) = ctx.scenario else {
return;
@@ -326,7 +329,7 @@ pub(super) fn seed_ui_fixture(
(G_STONE, 94_004, 1, "Hearthstone", DISP_STONE, 1),
(G_BANKBAG, 94_005, 1, "Small Brown Pouch", DISP_STONE, 1),
] {
items.insert_object(guid, obj(entry, stack));
crate::items::spawn_item(&mut commands, &mut index, guid, obj(entry, stack), false);
let mut t = template(name, quality);
t.display_info_id = disp;
if guid == G_BANKBAG {
@@ -338,7 +341,9 @@ pub(super) fn seed_ui_fixture(
// The held bank bag is a real CONTAINER object (its contents stream on the bag item —
// decision 0604): 6 slots, one occupied, so the POPOUT window (container 5) is in the
// shot too — its snug-fit stitch, lit bag button, and own-icon portrait.
items.insert_object(
crate::items::spawn_item(
&mut commands,
&mut index,
G_BANKBAG,
ObjectFields::from_pairs(&[
(3, 94_005),
@@ -346,6 +351,7 @@ pub(super) fn seed_ui_fixture(
(48, 6), // CONTAINER_NUM_SLOTS
(50, G_JERKY as u32), // CONTAINER_SLOT_1
]),
true,
);
// The self player: 4 occupied vault slots, the bank bag, TWO bought bag slots
// (`PLAYER_BYTES_2` byte 2 — bag button 1 owned, 2 bought-but-empty, 3–6 the red
@@ -911,10 +917,10 @@ pub(super) fn seed_ui_fixture(
let obj = |entry: u32, stack: u32| {
ObjectFields::from_pairs(&[(3, entry), (14, stack)]) // OBJECT_ENTRY, STACK_COUNT
};
items.insert_object(G_CHEST, obj(93_010, 1));
items.insert_object(G_SWORD, obj(93_011, 1));
items.insert_object(G_BOW, obj(93_013, 1));
items.insert_object(G_ARROWS, obj(93_012, 200));
crate::items::spawn_item(&mut commands, &mut index, G_CHEST, obj(93_010, 1), false);
crate::items::spawn_item(&mut commands, &mut index, G_SWORD, obj(93_011, 1), false);
crate::items::spawn_item(&mut commands, &mut index, G_BOW, obj(93_013, 1), false);
crate::items::spawn_item(&mut commands, &mut index, G_ARROWS, obj(93_012, 200), false);
let mut chest = template("Tarnished Chainmail", 1);
chest.display_info_id = DISP_SHIELD;
chest.inventory_type = 5;
@@ -1171,7 +1177,13 @@ pub(super) fn seed_ui_fixture(
crate::net::Guid(PLAYER_GUID),
));
// The equipped main-hand weapon — the auto-attack borrows its icon (decision 0230).
items.insert_object(G_SWORD, ObjectFields::from_pairs(&[(3, 93_011), (14, 1)]));
crate::items::spawn_item(
&mut commands,
&mut index,
G_SWORD,
ObjectFields::from_pairs(&[(3, 93_011), (14, 1)]),
false,
);
let mut sword = template("Militia Shortsword", 2);
sword.display_info_id = DISP_SWORD;
items.insert_template(93_011, Some(sword));
@@ -117,7 +117,9 @@ use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::items::Items;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::net::{
ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, Objects, SelfPlayer,
};
use crate::player::Player;
use crate::target::cursor_mode::npc_flags;
use crate::ui_gossip::GossipState;
@@ -458,6 +460,7 @@ fn charter_probe(
mut probe: ResMut<CharterProbe>,
gossip: Res<GossipState>,
registrar: Res<GuildRegistrarState>,
objects: Objects,
items: Res<Items>,
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
@@ -797,7 +800,12 @@ fn charter_probe(
};
return;
}
let found = find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default());
let found = find_item(
&store.0,
&objects,
CHARTER_ITEM_ENTRY,
ItemSearch::default(),
);
// The template has to have landed too, and it is folded into the SAME `Option` as the
// item rather than short-circuiting on its own: the click dispatcher's charter arm is
// a **template flag** test (`ITEM_FLAG_CHARTER`), so a click made before the answer
@@ -1168,9 +1176,12 @@ fn charter_probe(
// destroys whatever whole stack sits at the addressed position, so a stale pair
// would destroy the wrong item; nothing is expected to move a charter, and that is
// exactly the kind of expectation a destructive send must not rest on.
let Some((bag_index, slot, _)) =
find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default())
else {
let Some((bag_index, slot, _)) = find_item(
&store.0,
&objects,
CHARTER_ITEM_ENTRY,
ItemSearch::default(),
) else {
probe.pass(
9,
"cleanup",
@@ -1197,8 +1208,12 @@ fn charter_probe(
};
return;
}
let still_there =
find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default());
let still_there = find_item(
&store.0,
&objects,
CHARTER_ITEM_ENTRY,
ItemSearch::default(),
);
if still_there.is_none() {
probe.pass(
9,
+5 -3
View File
@@ -52,7 +52,7 @@ use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::items::Items;
use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::pending_item_ops::PendingItemOps;
use crate::ui_loot::{LootConfig, LootLatch, LootState};
@@ -184,6 +184,7 @@ fn window_open(script: &UiScript, loot: &LootState) -> bool {
fn find_in_backpack(
store: &ObjectStore,
entry: u32,
objects: &Objects,
items: &Items,
net: &NetCommands,
) -> Option<(u64, u32)> {
@@ -195,7 +196,7 @@ fn find_in_backpack(
.filter(|g| *g != 0)
.map(|g| (i, g))
})
.find(|(_, guid)| items.object(*guid).and_then(|f| f.object_entry()) == Some(entry))?;
.find(|(_, guid)| objects.object(*guid).and_then(|f| f.object_entry()) == Some(entry))?;
// `template` asks the server once on a miss and answers `None` until the reply lands, which is
// exactly the "not stocked yet" state this returns.
items
@@ -209,6 +210,7 @@ fn clam_probe(
mut probe: ResMut<ClamProbe>,
script: Option<NonSendMut<UiScript>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
objects: Objects,
items: Res<Items>,
mut cfg: ResMut<LootConfig>,
loot: Res<LootState>,
@@ -240,7 +242,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, &items, &net) {
if let Some((guid, slot)) = find_in_backpack(store, entry, &objects, &items, &net) {
info!(
"PROBE_CLAM: item {entry} is guid {guid:#x} in backpack slot {slot} — \
sampling {CONTROL_FRAMES} control frames with it UNCLICKED"
@@ -355,7 +355,7 @@ fn service_probe(
// the guid the wire addresses.
mut merchant: ResMut<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
items: Res<crate::items::Items>,
objects: crate::net::Objects,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
@@ -759,7 +759,7 @@ fn service_probe(
let Some(item_guid) = self_store
.single()
.ok()
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &items))
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &objects))
else {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {} slot {} is on the \
@@ -18,7 +18,7 @@ use benilla_protocol::EntityKind;
use crate::aura_visual::AuraProc;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::items::{ItemObject, Items};
use crate::net::{FieldChanged, NetCommands, NetEntity, ObjectStore};
use benilla_assets::{LockRecover, WorldAssets};
@@ -336,7 +336,9 @@ pub(super) struct WeaponVisualSrc<'w, 's> {
displays: Option<Res<'w, ItemDisplays>>,
items: Option<Res<'w, Items>>,
net: Option<Res<'w, NetCommands>>,
units: Query<'w, 's, (Option<&'static NetEntity>, &'static ObjectStore)>,
// `Without<ItemObject>`: an item is an object in the index too (2334), and its block's
// dwords overlap the unit's indices — a unit read of an item store answers item fields.
units: Query<'w, 's, (Option<&'static NetEntity>, &'static ObjectStore), Without<ItemObject>>,
}
impl WeaponVisualSrc<'_, '_> {
@@ -779,7 +781,7 @@ pub(super) fn route_cast_visuals(
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut weapon_src: WeaponVisualSrc,
units: Query<(Entity, &ObjectStore)>,
units: Query<(Entity, &ObjectStore), Without<ItemObject>>,
holds: Query<&CastHold>,
mut channel_cache: Local<EntityHashMap<u32>>,
) {
@@ -1337,8 +1339,8 @@ pub(crate) fn arm_aura_state_fx(
// sight, because a standing aura is a STATE the streamed-in unit wears, not an edge. The
// unfiltered twin runs exactly once per DBC-resource arrival: a unit that streamed in before
// `SpellVisuals`/`Spells` landed carries standing auras no edge will re-announce.
units: Query<(Entity, &ObjectStore)>,
arrived: Query<Entity, Added<ObjectStore>>,
units: Query<(Entity, &ObjectStore), Without<ItemObject>>,
arrived: Query<Entity, (Added<ObjectStore>, Without<ItemObject>)>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
@@ -1481,7 +1483,7 @@ pub(super) fn arm_loot_fx(
// `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0 (decision 1723) — different fields at different indices,
// and a corpse descriptor has no UNIT block to ask at all; the edge's `kind` keeps them apart.
units: Query<(Entity, &ObjectStore, &crate::net::NetEntity)>,
arrived: Query<Entity, Added<ObjectStore>>,
arrived: Query<Entity, (Added<ObjectStore>, Without<ItemObject>)>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
mut fx: MessageWriter<SpellKitFx>,
+35 -19
View File
@@ -14,7 +14,7 @@ use bevy::prelude::*;
use benilla_assets::coords::wow_to_bevy;
use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, SelfGuid, SelfPlayer};
use crate::ui_action::Spells;
use crate::ui_script::{UiFeed, UiInput};
@@ -191,7 +191,9 @@ fn feed_death(
mut feed: ResMut<DeathFeedState>,
names: Res<crate::names::NameCache>,
net: Res<NetCommands>,
index: Res<GuidIndex>,
// The object lookup (2334) — the spirit healer's entity, and the bag walk the self-res
// item leg takes.
objects: Objects,
transforms: Query<&Transform>,
map: Option<Res<benilla_world::world_map::CurrentMap>>,
status: Res<crate::net::NetStatus>,
@@ -233,10 +235,9 @@ fn feed_death(
.reclaim_at
.map_or(0.0, |at| (at - now).max(0.0) as f32);
let spirit_healer_in_range = death_net.spirit_healer.is_some_and(|npc| {
index
.0
.get(&npc)
.and_then(|&e| transforms.get(e).ok())
objects
.entity(npc)
.and_then(|e| transforms.get(e).ok())
.is_some_and(|t| {
t.translation.distance_squared(self_t.translation) <= SPIRIT_HEALER_RANGE_SQ
})
@@ -261,7 +262,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, &items, spells.as_deref(), &net)
self_res_label: resolve_self_res(&store.0, &objects, &items, spells.as_deref(), &net)
.map(|r| r.label().to_owned()),
});
@@ -472,6 +473,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,
objects: &Objects,
items: &crate::items::Items,
spells: Option<&Spells>,
commands: &NetCommands,
@@ -485,12 +487,15 @@ fn resolve_self_res(
.map_or_else(|| "UNKNOWN".to_owned(), |d| d.name.clone());
return Some(SelfRes::Spell { spell, label });
}
// Collected first, then judged: the template lookup needs `items` mutably (it is ask-once,
// so a miss fires the query) while the walk holds it immutably — `has_key`'s own idiom.
let slots =
crate::ui_items::collect_inventory(store, items, crate::ui_items::InventoryScope::DEFAULT);
// Collected first, then judged — `has_key`'s own idiom: the walk reads the object index,
// the judging reads the ask-once template cache beside it.
let slots = crate::ui_items::collect_inventory(
store,
objects,
crate::ui_items::InventoryScope::DEFAULT,
);
slots.into_iter().find_map(|(bag_index, slot, guid)| {
let entry = items.object(guid)?.object_entry()?;
let entry = objects.object(guid)?.object_entry()?;
let t = items.template(entry, guid, commands)?;
let self_res = t.spells.iter().any(|sp| {
sp.trigger == 0
@@ -630,6 +635,7 @@ fn drain_death(
match store.as_ref().and_then(|store| {
resolve_self_res(
store,
&ladder.objects,
&ladder.items,
ladder.spells.as_deref(),
&ladder.commands,
@@ -841,6 +847,9 @@ mod self_res_tests {
fn the_dead_gate_precedes_the_field() {
let (net, _rx) = commands();
let items = Items::default();
// Nothing streamed: the spell leg answers before the inventory walk is reached (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let spells = catalog([(REINCARNATION, named("Reincarnation", [94, 0, 0]))]);
let alive = ObjectFields::from_pairs(&[
@@ -849,7 +858,7 @@ mod self_res_tests {
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&alive, &items, Some(&spells), &net),
resolve_self_res(&alive, &objects, &items, Some(&spells), &net),
None,
"alive with a self-res owed: nil"
);
@@ -862,7 +871,7 @@ mod self_res_tests {
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&ghost, &items, Some(&spells), &net),
resolve_self_res(&ghost, &objects, &items, Some(&spells), &net),
None,
"a released ghost still holds the field, and still answers nil"
);
@@ -873,7 +882,7 @@ mod self_res_tests {
(F_SELF_RES, REINCARNATION),
]);
assert_eq!(
resolve_self_res(&dead, &items, Some(&spells), &net),
resolve_self_res(&dead, &objects, &items, Some(&spells), &net),
Some(SelfRes::Spell {
spell: REINCARNATION,
label: "Reincarnation".into(),
@@ -887,11 +896,13 @@ mod self_res_tests {
fn an_unresolvable_spell_id_reads_unknown_not_nil() {
let (net, _rx) = commands();
let items = Items::default();
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
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, &items, spells.as_ref(), &net),
resolve_self_res(&dead, &objects, &items, spells.as_ref(), &net),
Some(SelfRes::Spell {
spell: 999_999,
label: "UNKNOWN".into(),
@@ -910,6 +921,7 @@ mod self_res_tests {
fn a_zero_field_falls_through_to_a_carried_item() {
let (net, _rx) = commands();
let mut items = Items::default();
let mut objs = crate::ui_items::TestObjects::new();
let item = |name: &str, spells: Vec<ItemSpellEntry>| ItemInfo {
spells,
@@ -932,8 +944,9 @@ mod self_res_tests {
Some(item("Ankh of Reincarnation", vec![block(3026, 0)])),
);
for (guid, entry) in [(0xA0_u64, 10_u32), (0xA1, 11), (0xA2, 12)] {
items.insert_object(guid, ObjectFields::from_pairs(&[(3, entry)]));
objs.spawn(guid, ObjectFields::from_pairs(&[(3, entry)]));
}
let objects = objs.get();
let spells = catalog([
(
3026,
@@ -951,7 +964,7 @@ mod self_res_tests {
}
let dead = ObjectFields::from_pairs(&pairs);
match resolve_self_res(&dead, &items, Some(&spells), &net) {
match resolve_self_res(&dead, &objects, &items, Some(&spells), &net) {
Some(SelfRes::Item { entry, label, .. }) => {
assert_eq!(
entry, 12,
@@ -964,6 +977,9 @@ 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, &items, Some(&spells), &net), None);
assert_eq!(
resolve_self_res(&bare, &objects, &items, Some(&spells), &net),
None
);
}
}
@@ -203,11 +203,14 @@ pub(in crate::entities) fn resolve_equipment(
// The enchant column rides its own resource (decision 0915) — shared with the tooltip lane.
glows: Option<ResMut<ItemGlows>>,
enchants: Option<Res<crate::items::Enchants>>,
// The [`Items`] epochs as of the last run — the skip gate's global half. Deliberately the
// explicit counters and never resource change ticks: `templates` is `ResMut` in this very
// system (ask-once misses write it), so a tick gate would read its own writes and never
// close.
mut last_epochs: Local<Option<(u64, u64, u64)>>,
// The [`Items`] template epoch as of the last run — the skip gate's global half. Deliberately
// the explicit counter and never the resource's change tick: `templates` is `ResMut` in this
// very system (ask-once misses write it), so a tick gate would read its own writes and never
// close. The item *objects* are entities since 2334, watched through `item_changes`.
mut last_epochs: Local<Option<(u64, u64)>>,
// The object lookup the equipped guids resolve through and the item entities' change watch,
// as one param (the 16-SystemParam ceiling).
item_objects: (crate::net::Objects, crate::items::ItemChanges),
// The guild identity cache (decision 1257) — `ResMut` because it is LAZY: the miss below is
// what sends the `CMSG_GUILD_QUERY` whose answer paints the tabard. `Option` for the same
// reason `creatures` is: a harness without the UI plugins still resolves equipment.
@@ -222,11 +225,14 @@ pub(in crate::entities) fn resolve_equipment(
// a `CMSG_GUILD_QUERY` answered three frames after a player spawned changes what that player's
// tabard paints, and nothing about their descriptor or the item cache moves to say so.
let epochs = (
templates.object_epoch(),
templates.template_epoch(),
guilds.as_ref().map_or(0, |g| g.identity_generation()),
);
// The watch is drained whether or not the gate is read (`moved` reads the removals).
let (objects, mut item_changes) = item_objects;
let items_moved = item_changes.moved();
let caches_moved = last_epochs.replace(epochs) != Some(epochs)
|| items_moved
|| creatures.as_ref().is_some_and(|c| c.is_changed())
|| enchants.as_ref().is_some_and(|e| e.is_changed())
|| tabard_design.as_ref().is_some_and(|d| d.is_changed());
@@ -607,7 +613,7 @@ pub(in crate::entities) fn resolve_equipment(
for bag in 19u8..23 {
let entry = s
.player_inv_slot(bag)
.and_then(|g| templates.object(g))
.and_then(|g| objects.object(g))
.and_then(|o| o.object_entry());
let Some(t) = entry.and_then(|e| templates.held(e, &net)) else {
continue;
@@ -1039,6 +1045,8 @@ mod tests {
items.insert_template(300, Some(worn(700, 5))); // INVTYPE_CHEST
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
app.insert_resource(items);
// The one object index `Objects` reads (2334); empty, like the map these tests never seeded.
app.init_resource::<crate::net::GuidIndex>();
app.insert_resource(NetCommands(tx));
app.insert_resource(ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(std::collections::HashMap::new()),
@@ -1119,6 +1127,8 @@ mod tests {
items.insert_template(101, Some(worn(901, 1))); // the swap target
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
app.insert_resource(items);
// The one object index `Objects` reads (2334); empty, like the map these tests never seeded.
app.init_resource::<crate::net::GuidIndex>();
app.insert_resource(NetCommands(tx));
app.insert_resource(ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(std::collections::HashMap::new()),
@@ -1216,6 +1226,8 @@ mod tests {
);
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
app.insert_resource(items);
// The one object index `Objects` reads (2334); empty, like the map these tests never seeded.
app.init_resource::<crate::net::GuidIndex>();
app.insert_resource(NetCommands(tx));
app.insert_resource(ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(
@@ -1333,6 +1345,8 @@ mod tests {
);
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
app.insert_resource(items);
// The one object index `Objects` reads (2334); empty, like the map these tests never seeded.
app.init_resource::<crate::net::GuidIndex>();
app.insert_resource(NetCommands(tx));
app.insert_resource(ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(std::collections::HashMap::new()),
+56 -11
View File
@@ -544,14 +544,37 @@ pub(crate) mod schedule_tests {
}
}
/// Which graph a census reads (decision 2337).
///
/// Bevy's build inserts an `ApplyDeferred` barrier between a system with commands and its
/// dependents, and it **shares one barrier per "distance"** (the number of barriers between
/// a node and the schedule's start — `auto_insert_apply_deferred.rs`, `get_sync_point`).
/// Two systems that share a barrier are ordered *through* it, so a pair the graph never
/// declared reads as ordered — and one edge added anywhere upstream re-homes whole groups
/// onto a different barrier and moves pairs among systems the edit never touched (2333's
/// five-out-six-in). Measured on this tree the day it was named: 2,888 actionable pairs
/// with the barriers, 5,063 without — the barriers were hiding 43% of the undeclared
/// orders behind their accidental placement.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncPoints {
/// The schedule as it actually runs, barriers included — what an executor census
/// wants, since a barrier really is a wave boundary.
Built,
/// The **declared** graph only: no auto-inserted barriers, so "no path between two
/// systems" means exactly "nobody declared an order", and the count is a function of
/// the edges alone. What a ratchet on undeclared orders must read.
Declared,
}
/// Take the census of one schedule. Initializing it runs every system's param setup, and
/// at least one of those inserts `Schedules` itself, so this goes through bevy's own
/// take-out-initialize-put-back rather than a `resource_scope` on that resource.
pub(crate) fn census(app: &mut App, label: impl ScheduleLabel) -> Census {
pub(crate) fn census(app: &mut App, label: impl ScheduleLabel, sync: SyncPoints) -> Census {
let label = label.intern();
app.world_mut().schedule_scope(label, |world, schedule| {
schedule.set_build_settings(ScheduleBuildSettings {
ambiguity_detection: LogLevel::Warn,
auto_insert_apply_deferred: sync == SyncPoints::Built,
..default()
});
// The systems' access is filled by `initialize`, which the build below would run
@@ -869,7 +892,9 @@ pub(crate) mod schedule_tests {
}
/// `PostUpdate`, 181 systems: `GlobalTransform` and the particle `EffectQuads` are most of it.
const POST_UPDATE_CEILING: usize = 351;
/// **371 (decision 2337)** — the declared-graph count, re-measured beside `Update`'s when
/// the census stopped reading bevy's barriers as orders (see the ledger below).
const POST_UPDATE_CEILING: usize = 371;
const POST_UPDATE_SLACK: usize = 20;
/// 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
@@ -974,10 +999,21 @@ pub(crate) mod schedule_tests {
/// a new undeclared order; 2333 hands it to this ratchet's owner (2287) as a finding. The
/// number is the dump's, not a sum.
///
/// **5,063 (decision 2337)** — not a raise: a re-measurement. Until here the census read
/// the *built* schedule, barriers included, and bevy shares one `ApplyDeferred` barrier per
/// distance from the schedule's start — so 2,175 pairs (43%) were "ordered" only through a
/// barrier the build happened to place between them, and moved whenever an edge anywhere
/// upstream re-homed a group onto another barrier: that is 2333's five-out-six-in, and it is
/// the one build step that can make a pair *enter* when an edge is added. The ratchet reads
/// the declared graph now ([`SyncPoints::Declared`]); this is the same tree's count with the
/// barriers gone, checked by removing one declared edge and watching exactly its five pairs
/// return and nothing else move. The dump prints each pair in canonical order since the same
/// record, so two dumps diff by text. The number is the dump's, not a sum.
///
/// 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 = 2_888;
const UPDATE_ACTIONABLE_CEILING: usize = 5_063;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
@@ -1002,8 +1038,10 @@ pub(crate) mod schedule_tests {
#[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);
// The declared graph, not the built one: a barrier bevy placed is not an order anyone
// declared, and it moves under unrelated edits (2337; see [`SyncPoints`]).
let update = census(&mut app, Update, SyncPoints::Declared);
let post = census(&mut app, PostUpdate, SyncPoints::Declared);
let mut explained: BTreeMap<&str, usize> = BTreeMap::new();
let mut actionable = Vec::new();
for (a, b, what) in &update.conflicts {
@@ -1032,10 +1070,17 @@ pub(crate) mod schedule_tests {
let mut rows: Vec<String> = actionable
.iter()
.map(|(a, b, what)| {
// Canonical order within the pair: bevy reports (a, b) in whichever order
// its walk met them, and a diff of two dumps must not see that as movement.
let (x, y) = if update.name(*a) <= update.name(*b) {
(*a, *b)
} else {
(*b, *a)
};
format!(
" {} <-> {}\n on {}",
update.name(*a),
update.name(*b),
update.name(x),
update.name(y),
what.iter()
.map(|id| update.component(*id))
.collect::<Vec<_>>()
@@ -1175,7 +1220,7 @@ pub(crate) mod schedule_tests {
#[ignore = "instrument: run by hand (2265 §A3's executor census) — cargo test -p benilla-app --lib concurrency_census -- --ignored --nocapture"]
fn concurrency_census() {
let mut app = headless_client();
let c = census(&mut app, Update);
let c = census(&mut app, Update, SyncPoints::Built);
let holds = |k: SystemKey, pick: &dyn Fn(ComponentId) -> bool| {
c.conflicts
.iter()
@@ -1266,7 +1311,7 @@ pub(crate) mod schedule_tests {
#[test]
fn every_vm_holder_in_update_declares_its_side_of_the_tick() {
let mut app = headless_client();
let c = census(&mut app, Update);
let c = census(&mut app, Update, SyncPoints::Built);
if !type_names_available(&c) {
eprintln!(
"skipped: this build carries no type names (bevy/debug rides with dev, 1451)"
@@ -1536,14 +1581,14 @@ pub(crate) mod schedule_tests {
}
}
let mut app = headless_client();
let update = census(&mut app, Update);
let update = census(&mut app, Update, SyncPoints::Built);
if !type_names_available(&update) {
eprintln!(
"skipped: this build carries no type names (bevy/debug rides with dev, 1451)"
);
return;
}
let post = census(&mut app, PostUpdate);
let post = census(&mut app, PostUpdate, SyncPoints::Built);
let by_name: HashMap<&str, &SystemInfo> = update
.systems
.values()
+471 -332
View File
@@ -1,14 +1,17 @@
//! The item layer — decision 0068's T2 (containers) groundwork.
//!
//! The wire splits item knowledge in two, so this module holds two stores:
//! The wire splits item knowledge in two:
//!
//! - **Objects** — the item/container *instances* the server streamed at us (`ItemCreate`: our own
//! inventory at login, loot, trades; they are private, so only ours ever arrive). Keyed by guid,
//! holding the merged descriptor fields — entry, stack count, a bag's slot guids. Which *slot*
//! holds a guid lives one level up, in the player descriptor's `INV_SLOT`/`PACK_SLOT` arrays and
//! a bag's `CONTAINER_FIELD_SLOT` array; this store resolves those guids to actual items. Fed by
//! the net bridge (create seed → `Values` merges → destroy), cleared whole on disconnect (the
//! server re-streams inventory at login; stale bags are worse than empty ones).
//! inventory at login, loot, trades; they are private, so only ours ever arrive). **An item is
//! an object** (decision 2334): it is an entity in the one guid index with the same
//! [`ObjectStore`] every unit has — `Guid` + `ObjectStore` + [`ItemObject`] — created, merged
//! and destroyed by the object layer's handlers like any other kind, its field edges on the
//! same watch (`FieldChanged`, kind `Item`/`Container`), gone with the session's sweep. Which
//! *slot* holds a guid lives one level up, in the player descriptor's `INV_SLOT`/`PACK_SLOT`
//! arrays and a bag's `CONTAINER_FIELD_SLOT` array; [`crate::net::Objects`] resolves those guids
//! to the item's fields. Its countdowns (temporary enchants, its own lifetime) are its own
//! [`Countdowns`] component — the reference's per-object deadline cells (decision 2340).
//!
//! - **Templates** — the static item *definitions* (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`: name,
//! quality, class, display id), keyed by entry and shared by every copy. The exact twin of
@@ -17,15 +20,228 @@
//! 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;
use std::time::{Duration, Instant};
use bevy::prelude::*;
use benilla_protocol::{ItemInfo, ObjectFields};
use crate::net::{ClientCommand, NetCommands, ObjectStore};
use bevy::ecs::system::SystemParam;
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, Objects};
use crate::query_cache::QueryCache;
/// **An item or container entity's kind** — the reference's `TYPEMASK_ITEM` / `TYPEMASK_CONTAINER`
/// on the one object index (decision 2334). An item is `Guid` + [`ObjectStore`] + this; it has no
/// `NetEntity` and no `Transform` because it has no model and no pose. Every system that iterates
/// stores as *units* filters this out (`Without<ItemObject>`): an item block's dwords overlap the
/// unit block's indices, so an unfiltered unit read of an item's store answers with item fields.
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ItemObject {
/// `TYPEMASK_CONTAINER` — the create said so; a bag's slot array lives past the item block.
pub(crate) container: bool,
}
/// Spawn an item object into the index — the item half of the object layer's create, and what a
/// fixture seeds with. The seed is never merged (the create-time notify-suppress, 2297); a
/// re-create of a live guid takes the values path in the handler, not this.
pub(crate) fn spawn_item(
commands: &mut Commands,
index: &mut GuidIndex,
guid: u64,
fields: ObjectFields,
container: bool,
) -> Entity {
let e = commands
.spawn((
Guid(guid),
ObjectStore(fields),
ItemObject { container },
Countdowns::default(),
))
.id();
index.0.insert(guid, e);
e
}
/// The item's enchantment slots — `ITEM_FIELD_ENCHANTMENT`'s 21 dwords, three per slot, and the
/// reference's seven enchant deadline cells `[obj + 0x324 + slot*4]` (wow-re
/// `tooltip-content-law.md` §E3), which end where the next member begins at `+0x340`.
pub(crate) const ENCHANT_SLOTS: usize = 7;
/// **An item's countdowns** — the reference's per-object deadline cells on `CGItem_C` (decision
/// 2340): its own lifetime at `+0x320` (fed only by `SMSG_ITEM_TIME_UPDATE`, decision 1933) and
/// one temporary-enchant deadline per enchant slot at `+0x324` (fed only by
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE`, decision 0920; the item's `ITEM_FIELD_ENCHANTMENT` duration
/// field is never read for it). Every item object carries one from its spawn, so the cells die
/// with the object as the reference's do, and a write through `Mut` is the landing the inventory
/// feeds' [`ItemChanges`] sees.
///
/// Absolute deadlines, recomputed on read and never ticked — `0x5d9c60` / `0x5d9d00` subtract
/// `now` from the cell on every call.
#[derive(Component, Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct Countdowns {
lifetime: Option<Instant>,
enchants: [Option<Instant>; ENCHANT_SLOTS],
}
/// The two setters' shared store rule (`0x5d9c00`, `0x5d9cc0`): a **signed** `<= 0` clears the
/// cell — `jle` on the wire value, so `0` and anything with the top bit set both store absence
/// rather than a 68-year deadline — and anything else parks `now + seconds`, the wire's seconds
/// times the one `imul 0x3e8`.
fn deadline(seconds: u32) -> Option<Instant> {
((seconds as i32) > 0).then(|| Instant::now() + Duration::from_secs(u64::from(seconds)))
}
/// A cell's time left: `None` when unset, `Some(0)` once elapsed (`0x5d9d00`'s `max(0, …)`).
fn left(cell: Option<Instant>) -> Option<Duration> {
cell.map(|at| at.saturating_duration_since(Instant::now()))
}
/// The tooltip's read: an elapsed cell is no timer at all (the `!= 0` gate prints the plain
/// line).
fn remaining_ms(cell: Option<Instant>) -> Option<u64> {
left(cell)
.filter(|l| !l.is_zero())
.map(|l| l.as_millis() as u64)
}
impl Countdowns {
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE`'s setter `0x5d9cc0`. The reference indexes the cell array
/// with the wire's slot unchecked — a slot past the seventh overruns into the next member;
/// this refuses it instead (`false`), as the spell-modifier tables refuse theirs.
pub(crate) fn set_enchant(&mut self, slot: u32, seconds: u32) -> bool {
let Some(cell) = self.enchants.get_mut(slot as usize) else {
return false;
};
*cell = deadline(seconds);
true
}
/// `SMSG_ITEM_TIME_UPDATE`'s setter `0x5d9c00`.
pub(crate) fn set_lifetime(&mut self, seconds: u32) {
self.lifetime = deadline(seconds);
}
fn enchant(&self, slot: u32) -> Option<Instant> {
self.enchants.get(slot as usize).copied().flatten()
}
/// Milliseconds left on the temporary enchant in `slot`, or `None` when that slot carries no
/// timer — including an expired one (`0x5d9d00` returns 0 past the deadline, and the tooltip's
/// `!= 0` gate then prints the plain name).
pub(crate) fn enchant_remaining_ms(&self, slot: u32) -> Option<u64> {
remaining_ms(self.enchant(slot))
}
/// [`Self::enchant_remaining_ms`] at **display granularity**: floored to the whole second.
///
/// The snapshot feeds read this one. The tooltip's bucket ladder is ceil at day/hour/min and
/// truncate at seconds, so every value inside one second renders the same line — but a raw
/// per-ms read makes the *snapshot* differ every frame, which held the 1439 gates open and
/// fired `UNIT_INVENTORY_CHANGED` at frame rate for as long as a poison ticked (director
/// report, 2026-08-19: the char window's cost, and its refusal to settle after closing).
/// Floored, the snapshot moves once a second — exactly as often as its rendering can.
/// The live per-ms reader stays for `GetWeaponEnchantInfo` ([`Self::enchant_deadline_ms`]),
/// whose per-frame push is the reference's own recompute-per-call (`0x5d9d00`).
pub(crate) fn enchant_remaining_display_ms(&self, slot: u32) -> Option<u64> {
self.enchant_remaining_ms(slot).map(|ms| ms - ms % 1000)
}
/// The same deadline read **without** the tooltip's expired-is-absent collapse: `Some(0)` for a
/// timer that has run out, `None` only when the slot never had one.
///
/// `GetWeaponEnchantInfo` needs the two apart where the tooltip does not. Its expiration return
/// is `max(0, deadline − now)` from the client-local deadline array (`0x5d9d00`, subtract on
/// read — VERIFIED wow-re `system/ui/scratch/weapon-enchant-info.md`), so an enchant whose
/// timer has elapsed answers the NUMBER 0, and `BuffFrame_Enchant_OnUpdate` then draws "0 s"
/// and pulses the icon. Collapsing that to nil would silently hide a expiring enchant's last
/// state.
pub(crate) fn enchant_deadline_ms(&self, slot: u32) -> Option<u64> {
left(self.enchant(slot)).map(|l| l.as_millis() as u64)
}
/// Milliseconds left on the item's own lifetime, or `None` when it carries no timer —
/// including an elapsed one.
pub(crate) fn lifetime_remaining_ms(&self) -> Option<u64> {
remaining_ms(self.lifetime)
}
/// [`Self::lifetime_remaining_ms`] at **display granularity** — floored to the whole second,
/// for the same reason [`Self::enchant_remaining_display_ms`] is.
pub(crate) fn lifetime_remaining_display_ms(&self) -> Option<u64> {
self.lifetime_remaining_ms().map(|ms| ms - ms % 1000)
}
/// This item's share of [`ItemChanges::countdown_steps`]: each live cell contributes
/// `floor(seconds left) + 1`, an unset or elapsed one 0 — so the final `Some(0) → None`
/// collapse (the tooltip reverting to the plain enchant name, the lifetime line vanishing) is
/// its own step, one frame after the deadline elapses.
fn steps(&self, now: Instant) -> u64 {
std::iter::once(self.lifetime)
.chain(self.enchants)
.flatten()
.map(|at| {
let left = at.saturating_duration_since(now);
if left.is_zero() {
0
} else {
left.as_secs() + 1
}
})
.sum()
}
}
/// An item entity whose fields or countdown cells were written since the reader last ran.
type ItemMoved = (
With<ItemObject>,
Or<(Changed<ObjectStore>, Changed<Countdowns>)>,
);
/// **Did any item object move this frame** — the gate input the inventory feeds watch in place of
/// the item map's old epoch (decision 2334): a create, a values delta, a countdown landing or a
/// destroy on any item entity. `Changed` covers the first three (a spawn is a change, and so is a
/// write to either component), the removal reader the fourth — a bag's slot going empty is the
/// player's own field, but the *item* vanishing is only this.
#[derive(SystemParam)]
pub(crate) struct ItemChanges<'w, 's> {
changed: Query<'w, 's, (), ItemMoved>,
removed: RemovedComponents<'w, 's, ItemObject>,
countdowns: Query<'w, 's, &'static Countdowns>,
}
impl ItemChanges<'_, '_> {
/// Drains the removal reader, so call it once per run.
pub(crate) fn moved(&mut self) -> bool {
let removed = self.removed.read().count() > 0;
!self.changed.is_empty() || removed
}
/// The value a gated feed watches instead of holding its gate open per-frame while a
/// countdown runs: it moves exactly when some **displayable** countdown can — the two
/// second-floored reads a bag/equipment snapshot renders. The sum of every item's
/// [`Countdowns`] steps; between landings (which [`Self::moved`] reports) each term only
/// falls, so a displayable change can never be masked by another. No live cell — the
/// overwhelmingly common case — sums to 0.
pub(crate) fn countdown_steps(&self) -> u64 {
let now = Instant::now();
self.countdowns.iter().map(|c| c.steps(now)).sum()
}
}
/// **The player's inventory, as one read** — what a bag, paper-doll or spellbook feed resolves
/// its slots from (decision 2334): the self descriptor's slot arrays and their change tick, the
/// object lookup those guids resolve through, and the item entities' own change watch.
#[derive(SystemParam)]
pub(crate) struct Inventory<'w, 's> {
pub(crate) self_store: Query<'w, 's, &'static ObjectStore, With<crate::net::SelfPlayer>>,
pub(crate) self_changed:
Query<'w, 's, (), (With<crate::net::SelfPlayer>, Changed<ObjectStore>)>,
pub(crate) objects: Objects<'w, 's>,
pub(crate) changes: ItemChanges<'w, 's>,
}
/// 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
/// swing/ready selectors key on. A Copy **view** of the cached [`ItemInfo`] ([`Items::held`]).
@@ -291,7 +507,6 @@ impl RollCatalogs<'_> {
/// Filled by the net bridge; read by the container APIs (`GetContainerItemInfo` and kin).
#[derive(Resource, Default)]
pub(crate) struct Items {
objects: HashMap<u64, ObjectFields>,
/// 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>,
@@ -299,206 +514,38 @@ pub(crate) struct Items {
/// 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>,
/// [`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 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
/// feed is `SMSG_ITEM_ENCHANT_TIME_UPDATE`. NOT derived from `ITEM_FIELD_ENCHANTMENT`'s
/// duration field, which the reference's tooltip never reads. Session state, so it clears with
/// the objects (decision 0920).
enchant_deadlines: HashMap<(u64, u32), std::time::Instant>,
/// The **item-lifetime deadlines**, keyed by item guid — a duration-limited instance's
/// remaining life (a conjured stone, a holiday gift, a timed quest item). Its only feed is
/// `SMSG_ITEM_TIME_UPDATE`; the instance's own `ITEM_FIELD_DURATION` carries the same number
/// and is sent to the owner, but vmangos's writer says in as many words that the field is not
/// what the client displays from (`Item::SendTimeUpdate`, `Objects/Item.cpp:1094`). Same shape
/// as [`Self::enchant_deadlines`] one field up, for the same reason (decision 1933).
duration_deadlines: HashMap<u64, std::time::Instant>,
/// **`PlayerPendingItemExpiration`** — the temporary-enchant updates that named an item not
/// yet held, kept for the item's arrival (decision 2340). The reference's `0x1EB` arm, on an
/// item-lookup miss, links a `{item guid, slot, seconds}` record onto the active player's list
/// (`0x5ebd40`, the list at `CGPlayer_C + 0x1cc8`); `0x5ebde0` walks it when an item of ours is
/// set up (`0x5d8440`), applies each match through the setter — `seconds` counted from then,
/// not from the packet — and unlinks it. Records for an item that never arrives live as long
/// as the player object: here, the session. The item-lifetime arm `0x1EA` has no such list.
pending_enchant_times: Vec<(u64, u32, u32)>,
}
impl Items {
/// Seed an item object from its create block. A re-create for a guid we already hold overlays
/// the fresh snapshot onto the existing store (same rule as the scene's `ObjectStore`).
pub(crate) fn insert_object(&mut self, guid: u64, fields: ObjectFields) {
match self.objects.entry(guid) {
std::collections::hash_map::Entry::Occupied(mut e) => e.get_mut().merge(fields),
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(fields);
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` named an item we do not hold: keep it for the item's
/// arrival ([`Self::pending_enchant_times`]'s `0x5ebd40`). The caller has checked the active
/// player resolves — the reference queues onto a player object, and drops the update when
/// none resolves.
pub(crate) fn queue_enchant_time(&mut self, guid: u64, slot: u32, seconds: u32) {
debug!("enchant timer: item {guid:#x} not held — queued for its arrival");
self.pending_enchant_times.push((guid, slot, seconds));
}
/// The item `guid` arrived: its queued enchant updates, in arrival order, unlinked — what
/// `0x5ebde0` replays through the setter.
pub(crate) fn take_enchant_times(&mut self, guid: u64) -> Vec<(u32, u32)> {
let mut taken = Vec::new();
self.pending_enchant_times.retain(|&(g, slot, seconds)| {
let hit = g == guid;
if hit {
taken.push((slot, seconds));
}
}
self.object_epoch = self.object_epoch.wrapping_add(1);
}
/// Merge a `Values` delta into a tracked item; `false` when the guid isn't ours to track (a
/// delta for an item we never saw created — dropped, same as the scene path's unknown guids).
pub(crate) fn merge_object(&mut self, guid: u64, fields: ObjectFields) -> bool {
if let Some(store) = self.objects.get_mut(&guid) {
store.merge(fields);
self.object_epoch = self.object_epoch.wrapping_add(1);
true
} else {
false
}
}
/// The item ceased to exist (`SMSG_DESTROY_OBJECT` — consumed, sold, destroyed).
pub(crate) fn remove_object(&mut self, guid: u64) {
if self.objects.remove(&guid).is_some() {
self.object_epoch = self.object_epoch.wrapping_add(1);
}
self.enchant_deadlines.retain(|&(g, _), _| g != guid);
self.duration_deadlines.remove(&guid);
}
/// The instance-side broadcast counter — see the [`Self::object_epoch`] field.
pub(crate) fn object_epoch(&self) -> u64 {
self.object_epoch
}
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` landed: park the absolute deadline for that item's enchant
/// slot. `seconds == 0` is the reference's own "no timer" store (`0x5d9cc0`'s `<= 0` arm), so
/// it REMOVES the entry rather than parking an already-expired one.
pub(crate) fn set_enchant_deadline(&mut self, guid: u64, slot: u32, seconds: u32) {
match seconds {
0 => {
if self.enchant_deadlines.remove(&(guid, slot)).is_some() {
self.object_epoch = self.object_epoch.wrapping_add(1);
}
}
n => {
let at = std::time::Instant::now() + std::time::Duration::from_secs(u64::from(n));
self.enchant_deadlines.insert((guid, slot), at);
self.object_epoch = self.object_epoch.wrapping_add(1);
debug!("enchant timer: item {guid:#x} slot {slot} → {n}s");
}
}
}
/// Milliseconds left on an item's temporary enchant, or `None` when that slot carries no timer
/// — including an expired one (`0x5d9d00` returns 0 past the deadline, and the tooltip's
/// `!= 0` gate then prints the plain name).
pub(crate) fn enchant_remaining_ms(&self, guid: u64, slot: u32) -> Option<u64> {
let at = self.enchant_deadlines.get(&(guid, slot))?;
let left = at.saturating_duration_since(std::time::Instant::now());
(!left.is_zero()).then_some(left.as_millis() as u64)
}
/// [`Self::enchant_remaining_ms`] at **display granularity**: floored to the whole second.
///
/// The snapshot feeds read this one. The tooltip's bucket ladder is ceil at day/hour/min and
/// truncate at seconds, so every value inside one second renders the same line — but a raw
/// per-ms read makes the *snapshot* differ every frame, which held the 1439 gates open and
/// fired `UNIT_INVENTORY_CHANGED` at frame rate for as long as a poison ticked (director
/// report, 2026-08-19: the char window's cost, and its refusal to settle after closing).
/// Floored, the snapshot moves once a second — exactly as often as its rendering can.
/// The live per-ms reader stays for `GetWeaponEnchantInfo` ([`Self::enchant_deadline_ms`]),
/// whose per-frame push is the reference's own recompute-per-call (`0x5d9d00`).
pub(crate) fn enchant_remaining_display_ms(&self, guid: u64, slot: u32) -> Option<u64> {
self.enchant_remaining_ms(guid, slot)
.map(|ms| ms - ms % 1000)
}
/// The counter a gated feed watches instead of holding its gate open per-frame: moves exactly
/// when some displayable countdown can — [`Self::enchant_remaining_display_ms`] or
/// [`Self::duration_remaining_display_ms`], the two per-second timers a bag/equipment
/// snapshot renders. Each live deadline contributes `floor(seconds left) + 1` — the `+1`
/// makes the final `Some(0) → None` collapse (the tooltip reverting to the plain enchant
/// name, the duration line vanishing) its own step, one frame after the deadline elapses.
/// Landings and removals move [`Self::object_epoch`] on their own. Empty — the overwhelmingly
/// common case — costs two empty iterations.
pub(crate) fn countdown_display_epoch(&self) -> u64 {
let now = std::time::Instant::now();
let step = |at: &std::time::Instant| {
let left = at.saturating_duration_since(now);
if left.is_zero() {
0
} else {
left.as_secs() + 1
}
};
self.enchant_deadlines
.values()
.chain(self.duration_deadlines.values())
.map(step)
.sum()
}
/// The same deadline read **without** the tooltip's expired-is-absent collapse: `Some(0)` for a
/// timer that has run out, `None` only when the slot never had one.
///
/// `GetWeaponEnchantInfo` needs the two apart where the tooltip does not. Its expiration return
/// is `max(0, deadline − now)` from the client-local deadline array (`0x5d9d00`, subtract on
/// read — VERIFIED wow-re `system/ui/scratch/weapon-enchant-info.md`), so an enchant whose
/// timer has elapsed answers the NUMBER 0, and `BuffFrame_Enchant_OnUpdate` then draws "0 s"
/// and pulses the icon. Collapsing that to nil would silently hide a expiring enchant's last
/// state. The wire carries **seconds** and this returns **milliseconds**, which is the
/// conversion `set_enchant_deadline` already performs.
pub(crate) fn enchant_deadline_ms(&self, guid: u64, slot: u32) -> Option<u64> {
let at = self.enchant_deadlines.get(&(guid, slot))?;
Some(
at.saturating_duration_since(std::time::Instant::now())
.as_millis() as u64,
)
}
/// `SMSG_ITEM_TIME_UPDATE` landed: park the absolute deadline for that item instance's
/// remaining lifetime. Same store shape as the enchant path one field up
/// ([`Self::set_enchant_deadline`]) — byte-confirmed as such, not assumed (decision 1933's
/// fold-back).
///
/// Three rules, each the reference's:
///
/// - **A non-POSITIVE duration clears.** `0x5d9c0c` tests the wire value **signed** (`jle`),
/// so `0` and anything with the top bit set both zero the cell rather than parking a
/// 68-year deadline. An expired countdown is an absent line, not a line reading `0`.
/// - **An item we do not hold is DROPPED — no queue, no retry.** `0x1EA`'s arm resolves the
/// guid and returns on a miss. This is the load-bearing asymmetry with its sibling: the
/// `0x1EB` enchant arm *does* enqueue an unresolved update and replays it when the item
/// appears (`0x5ebd40` → `0x5ebde0`); this one does not. Following the sibling would also
/// grow this map without bound for items that never arrive.
/// - The cell dies with the object, so a relog or a zone destroy loses it; the server re-sends
/// for everything carried on world enter (`Player::SendItemDurations`).
pub(crate) fn set_item_duration(&mut self, guid: u64, seconds: u32) {
if !self.objects.contains_key(&guid) {
debug!("item duration: {guid:#x} names an item we do not hold — dropped");
return;
}
if seconds == 0 || seconds as i32 <= 0 {
if self.duration_deadlines.remove(&guid).is_some() {
self.object_epoch = self.object_epoch.wrapping_add(1);
}
return;
}
let at = std::time::Instant::now() + std::time::Duration::from_secs(u64::from(seconds));
self.duration_deadlines.insert(guid, at);
self.object_epoch = self.object_epoch.wrapping_add(1);
debug!("item duration: item {guid:#x} → {seconds}s");
}
/// Milliseconds left on an item instance's lifetime, or `None` when it carries no timer —
/// including an elapsed one. Recomputed on read from the parked deadline, never ticked (the
/// enchant path's `0x5d9d00` shape).
pub(crate) fn duration_remaining_ms(&self, guid: u64) -> Option<u64> {
let at = self.duration_deadlines.get(&guid)?;
let left = at.saturating_duration_since(std::time::Instant::now());
(!left.is_zero()).then_some(left.as_millis() as u64)
}
/// [`Self::duration_remaining_ms`] at **display granularity** — floored to the whole second,
/// for the same reason [`Self::enchant_remaining_display_ms`] is: a raw per-ms read makes
/// every snapshot differ every frame and fires the inventory feeds at frame rate.
pub(crate) fn duration_remaining_display_ms(&self, guid: u64) -> Option<u64> {
self.duration_remaining_ms(guid).map(|ms| ms - ms % 1000)
}
/// A tracked item object's merged descriptor fields.
pub(crate) fn object(&self, guid: u64) -> Option<&ObjectFields> {
self.objects.get(&guid)
!hit
});
taken
}
/// The template for `entry`, if known. On a miss, asks the server (once per entry per
@@ -602,11 +649,12 @@ impl Items {
self.templates.clear_pending();
}
/// The objects themselves — and their countdowns — are the index's, swept with every other
/// entity (decisions 2334, 2340); the pending enchant times die with the player they were
/// queued on.
pub(crate) fn clear_session(&mut self) {
self.objects.clear();
self.enchant_deadlines.clear();
self.pending_enchant_times.clear();
self.templates.clear_pending();
self.object_epoch = self.object_epoch.wrapping_add(1);
}
}
@@ -629,6 +677,9 @@ impl crate::query_cache::AskOnce for Items {
pub(crate) struct TestDeps {
pub(crate) items: Items,
pub(crate) commands: NetCommands,
/// The object index the item objects live in (decision 2334) — seed it with
/// [`Self::spawn_item`], read it through [`Self::with_objects`].
pub(crate) world: World,
rx: crossbeam_channel::Receiver<ClientCommand>,
}
@@ -636,13 +687,33 @@ pub(crate) struct TestDeps {
impl TestDeps {
pub(crate) fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.init_resource::<GuidIndex>();
TestDeps {
items: Items::default(),
commands: NetCommands(tx),
world,
rx,
}
}
/// An item object in the index — what the wire's `ItemCreate` spawns.
pub(crate) fn spawn_item(&mut self, guid: u64, fields: ObjectFields) -> Entity {
test_spawn_item(&mut self.world, guid, fields, false)
}
/// Run `f` with the lookup the helpers under test take, beside the template cache and the
/// command channel — the three borrows split so a test can hold all of them at once.
pub(crate) fn with_objects<R>(
&mut self,
f: impl FnOnce(&Objects, &Items, &NetCommands) -> R,
) -> R {
let (world, items, commands) = (&mut self.world, &self.items, &self.commands);
let mut state = bevy::ecs::system::SystemState::<Objects>::new(world);
let objects = state.get(world);
f(&objects, items, commands)
}
/// The entries the resolver asked the server for — an ask-once gate firing is observable here
/// even when the icon it feeds is still `None`.
pub(crate) fn queried_entries(&self) -> Vec<u32> {
@@ -656,6 +727,22 @@ impl TestDeps {
}
}
/// Spawn an item object straight into a test world's index — [`spawn_item`] without the command
/// queue, for a test that holds the `World`.
#[cfg(test)]
pub(crate) fn test_spawn_item(
world: &mut World,
guid: u64,
fields: ObjectFields,
container: bool,
) -> Entity {
let e = world
.spawn((Guid(guid), ObjectStore(fields), ItemObject { container }))
.id();
world.resource_mut::<GuidIndex>().0.insert(guid, e);
e
}
/// Equipment slots 15/16 — `EQUIPMENT_SLOT_MAINHAND` / `_OFFHAND` (vmangos `EquipmentSlots`).
pub(crate) const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
pub(crate) const EQUIPMENT_SLOT_OFFHAND: u8 = 16;
@@ -664,12 +751,13 @@ pub(crate) const EQUIPMENT_SLOT_OFFHAND: u8 = 16;
/// slot or a template still in flight.
fn equipped_class(
store: &ObjectStore,
objects: &Objects,
items: &Items,
commands: &NetCommands,
slot: u8,
) -> Option<u8> {
let guid = store.0.player_inv_slot(slot).filter(|&g| g != 0)?;
let entry = items.object(guid).and_then(|o| o.object_entry())?;
let entry = objects.object(guid).and_then(|o| o.object_entry())?;
Some(items.template(entry, guid, commands)?.class as u8)
}
@@ -685,25 +773,30 @@ fn equipped_class(
/// was once missing from all three of its call sites.
pub(crate) fn disarmed_equipment_slot(
store: &ObjectStore,
objects: &Objects,
items: &Items,
commands: &NetCommands,
) -> Option<u8> {
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
return None;
}
let main = equipped_class(store, items, commands, EQUIPMENT_SLOT_MAINHAND);
let off = equipped_class(store, items, commands, EQUIPMENT_SLOT_OFFHAND);
let main = equipped_class(store, objects, items, commands, EQUIPMENT_SLOT_MAINHAND);
let off = equipped_class(store, objects, items, commands, EQUIPMENT_SLOT_OFFHAND);
crate::creature_anim::disarmed_hand(main, off).map(|hand| EQUIPMENT_SLOT_MAINHAND + hand as u8)
}
/// [`disarmed_equipment_slot`]'s read-only twin — same ladder, no ask (decision 1925).
pub(crate) fn disarmed_equipment_slot_cached(store: &ObjectStore, items: &Items) -> Option<u8> {
pub(crate) fn disarmed_equipment_slot_cached(
store: &ObjectStore,
objects: &Objects,
items: &Items,
) -> Option<u8> {
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
return None;
}
let class_of = |slot: u8| -> Option<u8> {
let guid = store.0.player_inv_slot(slot).filter(|&g| g != 0)?;
let entry = items.object(guid).and_then(|o| o.object_entry())?;
let entry = objects.object(guid).and_then(|o| o.object_entry())?;
Some(items.template_cached(entry)?.class as u8)
};
crate::creature_anim::disarmed_hand(
@@ -813,133 +906,103 @@ mod tests {
);
}
/// The gated feeds' instance-side counter (1439): every object mutation moves it, a
/// template landing moves ONLY the template epoch, and a lazy `template()` miss — the
/// per-frame read that poisons `is_changed` — moves neither.
/// The gated feeds' template-side counter (1439): a template landing moves it, and a lazy
/// `template()` miss — the per-frame read that poisons `is_changed` — moves nothing. The
/// instance side is the item entities' own change ticks since 2334
/// ([`an_item_is_an_object_in_the_index_and_its_changes_are_watched`]).
#[test]
fn the_object_epoch_counts_instances_and_asks_count_nothing() {
fn the_template_epoch_counts_landings_and_asks_count_nothing() {
let (cmds, _rx) = commands();
let mut items = Items::default();
let e0 = items.object_epoch();
let t0 = items.template_epoch();
assert!(items.template(117, 0x42, &cmds).is_none());
assert_eq!(items.object_epoch(), e0, "an ask-once miss is not a change");
let t0 = items.template_epoch();
assert_eq!(
items.template_epoch(),
t0,
"an ask-once miss is not a change"
);
items.insert_template(117, Some(info("Tough Jerky")));
assert_eq!(
items.object_epoch(),
e0,
"a landed template is the OTHER epoch's edge"
);
assert_ne!(items.template_epoch(), t0);
items.insert_object(0x42, ObjectFields::from_pairs(&[(3, 117)]));
let created = items.object_epoch();
assert_ne!(e0, created, "a create moves it");
items.merge_object(0x42, ObjectFields::from_pairs(&[(14, 5)]));
let merged = items.object_epoch();
assert_ne!(created, merged, "a delta moves it");
items.merge_object(0x99, ObjectFields::from_pairs(&[(14, 5)]));
assert_eq!(
items.object_epoch(),
merged,
"an untracked guid's delta is dropped, not counted"
);
items.remove_object(0x42);
assert_ne!(items.object_epoch(), merged, "a destroy moves it");
let removed = items.object_epoch();
items.remove_object(0x42);
assert_eq!(
items.object_epoch(),
removed,
"removing the already-removed is silent"
);
assert_ne!(items.template_epoch(), t0, "a landing is");
}
/// The display epoch/quantize pair: a parked deadline contributes `floor(secs)+1` (bounded
/// here, not exact — the test can't pin the sub-second phase), the display read is floored
/// to the whole second, and clearing the deadline zeroes the epoch (the `Some(0) → None`
/// collapse is the term's own last step).
/// The display step/quantize pair on one item's cells (decision 2340): a parked deadline
/// contributes `floor(secs)+1` (bounded here, not exact — the test can't pin the sub-second
/// phase), the display read is floored to the whole second, and clearing the cell takes its
/// term away (the `Some(0) → None` collapse is the term's own last step).
#[test]
fn the_countdown_display_epoch_steps_by_displayable_seconds() {
let mut items = Items::default();
assert_eq!(items.countdown_display_epoch(), 0, "no deadlines, no epoch");
fn countdown_steps_move_by_displayable_seconds() {
let now = Instant::now();
let mut c = Countdowns::default();
assert_eq!(c.steps(now), 0, "no deadlines, no steps");
items.set_enchant_deadline(0x42, 1, 90);
let epoch = items.countdown_display_epoch();
assert!(c.set_enchant(1, 90));
let steps = c.steps(Instant::now());
assert!(
(90..=91).contains(&epoch),
"a 90 s deadline contributes floor(secs)+1, got {epoch}"
(90..=91).contains(&steps),
"a 90 s deadline contributes floor(secs)+1, got {steps}"
);
let shown = items.enchant_remaining_display_ms(0x42, 1).unwrap();
let shown = c.enchant_remaining_display_ms(1).unwrap();
assert_eq!(shown % 1000, 0, "the display read is second-floored");
assert!(shown <= 90_000);
items.set_enchant_deadline(0x42, 1, 0);
assert_eq!(
items.countdown_display_epoch(),
0,
"cleared deadline, term gone"
// The lifetime cell joins the same step sum.
c.set_lifetime(1800);
let shown = c.lifetime_remaining_display_ms().expect("parked");
assert_eq!(shown % 1000, 0);
assert!(shown <= 1_800_000);
let steps = c.steps(Instant::now());
assert!(
(1890..=1892).contains(&steps),
"both cells step, got {steps}"
);
assert_eq!(items.enchant_remaining_display_ms(0x42, 1), None);
assert!(c.set_enchant(1, 0));
c.set_lifetime(0);
assert_eq!(c.steps(Instant::now()), 0, "cleared cells, terms gone");
assert_eq!(c.enchant_remaining_display_ms(1), None);
assert_eq!(c.lifetime_remaining_ms(), None);
}
/// The item-lifetime store (decision 1933) — the enchant timer's shape, one key narrower:
/// `seconds == 0` stores ABSENCE, the read is second-floored, the deadline joins the shared
/// display epoch, and destroying the item takes its timer with it.
/// The two setters' store rules, each the reference's (`0x5d9c00` / `0x5d9cc0`):
///
/// - **A non-POSITIVE value clears, in both cells.** The test is SIGNED (`jle`), so `0` and
/// a wire value with the top bit set both store absence instead of a 68-year deadline.
/// Before decision 2340 only the lifetime path knew this; the enchant path parked the far
/// future.
/// - **A slot past the seventh is refused**, where the reference would index past the cell
/// array into the next member.
/// - The deadline read answers `Some(_)` for a live cell and `None` for a slot that never had
/// one, where the tooltip read collapses both an unset and an elapsed cell to `None`.
#[test]
fn an_item_duration_parks_a_deadline_and_dies_with_the_item() {
let mut items = Items::default();
fn the_setters_clear_on_signed_non_positive_and_refuse_a_slot_past_the_array() {
let mut c = Countdowns::default();
c.set_lifetime(600);
assert!(c.lifetime_remaining_ms().is_some());
c.set_lifetime(0x8000_0000);
assert_eq!(
items.duration_remaining_ms(0x42),
None,
"no timer by default"
);
items.insert_object(0x42, ObjectFields::default());
items.set_item_duration(0x42, 1800);
let shown = items.duration_remaining_display_ms(0x42).expect("parked");
assert_eq!(shown % 1000, 0, "the display read is second-floored");
assert!(shown <= 1_800_000);
let epoch = items.countdown_display_epoch();
assert!(
(1800..=1801).contains(&epoch),
"the lifetime deadline joins the same per-second epoch as the enchant one, got {epoch}"
);
// `0` is the wire's "no timer", never a zero-second timer — the same store the enchant
// path takes for the same reason (an expired countdown is an absent line).
items.set_item_duration(0x42, 0);
assert_eq!(items.duration_remaining_ms(0x42), None);
assert_eq!(items.countdown_display_epoch(), 0);
// A destroyed item takes its timer with it: the guid can be reissued by the server, and a
// stale deadline would print a countdown on whatever lands there next.
items.insert_object(0x99, ObjectFields::default());
items.set_item_duration(0x99, 600);
items.remove_object(0x99);
assert_eq!(items.duration_remaining_ms(0x99), None);
// **An item we do not hold is dropped outright** — no queue, no retry, and nothing
// parked. `0x1EA`'s arm resolves the guid and returns on a miss; its enchant sibling
// `0x1EB` is the one that enqueues and replays, and copying that here would grow the map
// without bound for guids that never arrive.
items.set_item_duration(0xdead, 600);
assert_eq!(items.duration_remaining_ms(0xdead), None);
assert_eq!(items.countdown_display_epoch(), 0);
// **A non-POSITIVE duration clears** — the reference's test is SIGNED (`0x5d9c0c jle`),
// so a wire value with the top bit set zeroes the cell instead of parking a 68-year
// deadline.
items.insert_object(0x7, ObjectFields::default());
items.set_item_duration(0x7, 600);
assert!(items.duration_remaining_ms(0x7).is_some());
items.set_item_duration(0x7, 0x8000_0000);
assert_eq!(
items.duration_remaining_ms(0x7),
c.lifetime_remaining_ms(),
None,
"a negative-as-signed duration clears, it does not park a far future"
);
assert!(c.set_enchant(1, 600));
assert!(c.enchant_remaining_ms(1).is_some());
assert!(c.set_enchant(1, 0xffff_fff0));
assert_eq!(
c.enchant_remaining_ms(1),
None,
"the enchant setter's test is the same signed `jle`"
);
assert!(c.set_enchant(6, 30), "the seventh slot is the last cell");
let before = c.clone();
assert!(!c.set_enchant(7, 30), "the eighth is past the array");
assert!(!c.set_enchant(u32::MAX, 30));
assert_eq!(c, before, "a refused slot writes nothing");
assert_eq!(c.enchant_deadline_ms(0), None, "never set");
assert!(c.enchant_deadline_ms(6).is_some_and(|ms| ms <= 30_000));
}
/// The push half of the tooltip store: a landed template is marked fresh exactly once (the
@@ -1011,28 +1074,104 @@ mod tests {
);
}
/// **An item is an object** (decision 2334): spawned into the one index with its store,
/// resolved through [`Objects`] like a unit, and its create, its delta, a countdown landing
/// and its despawn each move [`ItemChanges`] exactly once — the gate the inventory feeds watch.
#[test]
fn objects_track_create_merge_destroy_and_session_clear() {
let mut items = Items::default();
items.insert_object(0x42, ObjectFields::default());
assert!(items.object(0x42).is_some());
assert!(items.merge_object(0x42, ObjectFields::default()));
fn an_item_is_an_object_in_the_index_and_its_changes_are_watched() {
use bevy::ecs::system::RunSystemOnce;
const GUID: u64 = 0x4000_0000_0000_0042;
let mut world = World::new();
world.init_resource::<GuidIndex>();
world
.run_system_once(|mut commands: Commands, mut index: ResMut<GuidIndex>| {
spawn_item(
&mut commands,
&mut index,
GUID,
ObjectFields::from_pairs(&[(3, 117), (14, 5)]),
false,
);
})
.unwrap();
// A registered system keeps its change ticks between runs (a one-shot would read every
// store as new every time).
let read = world.register_system(
|objects: Objects, mut changes: ItemChanges| -> (Option<u32>, Option<u32>, bool) {
let o = objects.object(GUID);
(
o.and_then(|o| o.object_entry()),
o.and_then(|o| o.item_stack_count()),
changes.moved(),
)
},
);
assert_eq!(
world.run_system(read).unwrap(),
(Some(117), Some(5), true),
"the spawn is a change"
);
assert_eq!(
world.run_system(read).unwrap(),
(Some(117), Some(5), false),
"nothing moved since"
);
// A values delta lands in the store and moves the watch once.
let e = world.resource::<GuidIndex>().0[&GUID];
world
.get_mut::<ObjectStore>(e)
.unwrap()
.0
.merge(ObjectFields::from_pairs(&[(14, 4)]));
assert_eq!(world.run_system(read).unwrap(), (Some(117), Some(4), true));
assert!(!world.run_system(read).unwrap().2);
// A countdown landing is the item's own change too (decision 2340), and its cell joins
// the step sum the feeds watch between landings.
world
.get_mut::<Countdowns>(e)
.expect("every item carries its cells")
.set_enchant(1, 90);
assert!(
!items.merge_object(0x99, ObjectFields::default()),
"a delta for an unseen guid is not tracked"
world.run_system(read).unwrap().2,
"a landing moves the watch"
);
let steps = world
.run_system_once(|c: ItemChanges| c.countdown_steps())
.unwrap();
assert!((90..=91).contains(&steps), "got {steps}");
// The destroy: the index drops the guid, the entity goes, the watch moves once more.
world.resource_mut::<GuidIndex>().0.remove(&GUID);
world.despawn(e);
assert_eq!(world.run_system(read).unwrap(), (None, None, true));
assert!(!world.run_system(read).unwrap().2);
}
/// **`PlayerPendingItemExpiration`** (decision 2340): an enchant time for an item not yet held
/// waits for it, is handed over in arrival order once, and a disconnect drops what never
/// arrived — while the templates the session learned survive it.
#[test]
fn a_queued_enchant_time_waits_for_its_item_and_the_session_keeps_templates() {
let mut items = Items::default();
items.queue_enchant_time(0x42, 1, 30);
items.queue_enchant_time(0x43, 1, 60);
items.queue_enchant_time(0x42, 1, 45);
assert_eq!(
items.take_enchant_times(0x42),
vec![(1, 30), (1, 45)],
"arrival order — the later record is applied last and wins"
);
assert!(
items.take_enchant_times(0x42).is_empty(),
"unlinked once replayed"
);
items.remove_object(0x42);
assert!(items.object(0x42).is_none());
// A disconnect clears instances + in-flight asks, keeps templates.
// A disconnect drops the records for items that never arrived and the in-flight asks,
// and keeps the templates.
let (cmds, rx) = commands();
items.insert_object(0x43, ObjectFields::default());
items.insert_template(117, Some(info("Tough Jerky")));
assert!(items.template(118, 0, &cmds).is_none()); // leaves 118 in flight
items.clear_session();
assert!(items.object(0x43).is_none());
assert!(items.take_enchant_times(0x43).is_empty());
assert!(items.template(117, 0, &cmds).is_some(), "templates survive");
// 118's ask was dropped with the writer — it must re-ask now.
let _ = rx.try_recv();
+41 -3
View File
@@ -21,6 +21,7 @@ use benilla_protocol::{
messages::WhoRequest, EntityKind, JumpInfo, MoveMode, MoveSpeeds, ObjectFields, SessionEvent,
SpeedKind, TransportPose,
};
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crossbeam_channel::{Receiver, Sender};
@@ -548,12 +549,49 @@ pub(crate) struct LoginSubmit(pub(crate) Sender<io::LoginRequest>);
#[derive(Resource)]
pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc<std::sync::atomic::AtomicU64>);
/// guid → spawned ECS entity, for O(1) lookup on move/remove. Maintained solely by
/// [`apply_net_updates`]; read-only to everyone else (the merchant range-close resolves its vendor
/// through it).
/// guid → spawned ECS entity, for O(1) lookup on move/remove — every kind, items and containers
/// included since decision 2334 (the reference's one `ClntObjMgr` index). Maintained solely by
/// the object layer's handlers (`net::objects`); read-only to everyone else, through [`Objects`]
/// or directly.
#[derive(Resource, Default)]
pub(crate) struct GuidIndex(pub(crate) HashMap<u64, Entity>);
/// **The object manager's guid lookup** — `ClntObjMgrObjectPtr 0x468460`'s shape: a guid to its
/// descriptor store, whatever the kind. The one read-only parameter a helper that resolves guids
/// takes (the inventory walkers resolve item guids off the player's slot arrays through it,
/// decision 2334), and an item's countdown cells beside its fields (2340); the handlers write
/// both through their own mutable queries.
#[derive(SystemParam)]
pub(crate) struct Objects<'w, 's> {
index: Res<'w, GuidIndex>,
stores: Query<'w, 's, &'static ObjectStore>,
countdowns: Query<'w, 's, &'static crate::items::Countdowns>,
}
impl Objects<'_, '_> {
/// The entity behind a guid, if streamed.
pub(crate) fn entity(&self, guid: u64) -> Option<Entity> {
self.index.0.get(&guid).copied()
}
/// A streamed object's merged descriptor fields.
pub(crate) fn object(&self, guid: u64) -> Option<&ObjectFields> {
self.index
.0
.get(&guid)
.and_then(|&e| self.stores.get(e).ok())
.map(|s| &s.0)
}
/// An item object's countdown cells — `None` for a guid that is not a held item.
pub(crate) fn countdowns(&self, guid: u64) -> Option<&crate::items::Countdowns> {
self.index
.0
.get(&guid)
.and_then(|&e| self.countdowns.get(e).ok())
}
}
/// Our own player's guid, once the IO thread reports we're in the world. Used to tag
/// [`SelfPlayer`], and read by the combat-text emitters' source-ownership classifier
/// (`crate::combat_text::melee_impact_text` — the "mine" test against Summoned/CreatedBy).
+190 -19
View File
@@ -98,6 +98,7 @@ pub(crate) struct Scene<'w, 's> {
real: Res<'w, Time<Real>>,
transforms: Query<'w, 's, &'static mut Transform>,
stores: Query<'w, 's, &'static mut ObjectStore>,
countdowns: Query<'w, 's, &'static mut crate::items::Countdowns>,
remote: Query<'w, 's, &'static mut RemoteMotion>,
modes: Query<'w, 's, &'static mut UnitMoveModes>,
riders: Query<'w, 's, &'static mut crate::transport::TransportRider>,
@@ -160,27 +161,70 @@ fn on_object_create(In(ev): In<SessionEvent>, mut sc: Scene) {
/// (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our `CMSG_ITEM_QUERY_SINGLE` — the ask-once
/// cache, decisions 0068/0072; a server miss records `None` so the entry is never re-asked), the
/// item-lifetime countdown's only feed (decision 1933) and the temporary-enchant countdown's
/// (decision 0920) — both parked on the item store, which every tooltip surface reads back.
/// (decision 0920) — both written into the item's own [`crate::items::Countdowns`] (decision
/// 2340), which every tooltip surface reads back.
fn on_item(In(ev): In<SessionEvent>, mut sc: Scene) {
match ev {
SessionEvent::ItemCreate {
guid,
container,
fields,
} => item_create(guid, container, fields, &mut sc.items),
} => item_create(
guid,
container,
fields,
&mut sc.commands,
&mut sc.index,
&mut sc.stores,
&mut sc.field_changes,
&mut sc.items,
),
SessionEvent::ItemTemplate { entry, info } => {
let info = info.map(|b| *b);
debug!("net: item template {entry} → {info:?}");
sc.items.insert_template(entry, info);
}
// `0x5e4f30`'s two arms share the item lookup (`0x468460`, `TYPEMASK_ITEM`) — here the
// index plus the item's own cells — and part company on a miss.
SessionEvent::ItemTime { item_guid, seconds } => {
sc.items.set_item_duration(item_guid, seconds)
// **An item we do not hold drops the update — no queue, no retry** (`0x1EA`'s arm
// returns on the miss). The login re-send (`Player::SendItemDurations`) goes out the
// moment the player is added to the map, so an item whose create has not landed yet
// shows no lifetime until the server's next update, as in the reference.
match sc.index.0.get(&item_guid).copied() {
Some(e) if sc.countdowns.contains(e) => {
if let Ok(mut c) = sc.countdowns.get_mut(e) {
c.set_lifetime(seconds);
}
debug!("item duration: item {item_guid:#x} → {seconds}s");
}
_ => debug!("item duration: {item_guid:#x} names an item we do not hold — dropped"),
}
}
SessionEvent::ItemEnchantTime {
item_guid,
slot,
seconds,
} => sc.items.set_enchant_deadline(item_guid, slot, seconds),
} => match sc.index.0.get(&item_guid).copied() {
Some(e) if sc.countdowns.contains(e) => {
if let Ok(mut c) = sc.countdowns.get_mut(e) {
if c.set_enchant(slot, seconds) {
debug!("enchant timer: item {item_guid:#x} slot {slot} → {seconds}s");
} else {
debug!("enchant timer: item {item_guid:#x} slot {slot} is past the seventh — refused");
}
}
}
// `0x1EB`'s miss arm: the record goes onto a resolving player's pending list
// (`0x5ebd40`) — the packet's own player guid first, the active player second — and
// only the active player's list is ever replayed (`0x5d8440` → `0x5ebde0`). vmangos
// names the owner, which is us (`SendItemEnchantTimeUpdate(GetObjectGuid(), ..)`,
// `Player.cpp:11807`/`:12001`), so both lookups land on the active player.
_ if sc.self_guid.0.is_some_and(|g| sc.index.0.contains_key(&g)) => {
sc.items.queue_enchant_time(item_guid, slot, seconds)
}
_ => debug!("enchant timer: {item_guid:#x} unheld and no player to queue on — dropped"),
},
_ => {}
}
}
@@ -276,7 +320,6 @@ fn on_object_values(In(ev): In<SessionEvent>, mut sc: Scene) {
&sc.index,
&mut sc.stores,
&mut sc.field_changes,
&mut sc.items,
);
}
}
@@ -288,7 +331,7 @@ fn on_object_destroyed(In(ev): In<SessionEvent>, mut sc: Scene) {
crate::death::net::forget_corpse(guid, &mut sc.death_net);
let store = sc.index.0.get(&guid).and_then(|e| sc.stores.get(*e).ok());
crate::ui_party::net::member_deactivated(guid, &mut sc.group, store, &sc.net);
object_destroyed(guid, &mut sc.commands, &mut sc.index, &mut sc.items);
object_destroyed(guid, &mut sc.commands, &mut sc.index);
}
}
@@ -642,11 +685,38 @@ fn object_create(
}
}
/// An item or container entered our view (`SMSG_UPDATE_OBJECT` descriptor-only create) — no scene
/// entity; the item store owns it.
fn item_create(guid: u64, container: bool, fields: ObjectFields, items: &mut Items) {
/// An item or container entered our view (`SMSG_UPDATE_OBJECT` descriptor-only create): an
/// object like any other (decision 2334) — an entity in the index carrying its store, with no
/// pose and no model. A re-create of a live guid overlays the snapshot through the values path,
/// which notifies the field watchers like any delta (the scene create's own rule).
fn item_create(
guid: u64,
container: bool,
fields: ObjectFields,
commands: &mut Commands,
index: &mut GuidIndex,
stores: &mut Query<&mut ObjectStore>,
edges: &mut MessageWriter<FieldChanged>,
items: &mut Items,
) {
debug!("net: item create {guid:#x} (container: {container})");
items.insert_object(guid, fields);
if let Some(&e) = index.0.get(&guid) {
merge_fields(commands, stores, edges, e, guid, fields);
} else {
let e = crate::items::spawn_item(commands, index, guid, fields, container);
// The item arrived: replay the enchant times queued for it while it was not held
// (`0x5ebde0`, decision 2340), each through the setter as of now.
let queued = items.take_enchant_times(guid);
if !queued.is_empty() {
let mut countdowns = crate::items::Countdowns::default();
for (slot, seconds) in queued {
if !countdowns.set_enchant(slot, seconds) {
debug!("enchant timer: queued slot {slot} for {guid:#x} is past the seventh — refused");
}
}
commands.entity(e).insert(countdowns);
}
}
}
/// An existing object moved to a new authoritative pose (an `SMSG_UPDATE_OBJECT` movement block) —
@@ -789,9 +859,9 @@ fn unit_move(
}
}
/// A descriptor delta (`SMSG_UPDATE_OBJECT` values block): merge into the object's store — a scene
/// object's in place, an item's into the item store. An unknown guid —
/// a `Values` with no create seen — is dropped, as before.
/// A descriptor delta (`SMSG_UPDATE_OBJECT` values block): merge into the object's store in place
/// — a unit's, a GameObject's, an item's (decision 2334), one path. An unknown guid — a `Values`
/// with no create seen — is dropped, as before.
fn object_values(
guid: u64,
fields: ObjectFields,
@@ -799,17 +869,14 @@ fn object_values(
index: &GuidIndex,
stores: &mut Query<&mut ObjectStore>,
edges: &mut MessageWriter<FieldChanged>,
items: &mut Items,
) {
if let Some(&e) = index.0.get(&guid) {
merge_fields(commands, stores, edges, e, guid, fields);
} else if guid::is_item(guid) {
items.merge_object(guid, fields);
}
}
/// The object ceased to exist (`SMSG_DESTROY_OBJECT` — corpse decay ahead of respawn, a despawn).
fn object_destroyed(guid: u64, commands: &mut Commands, index: &mut GuidIndex, items: &mut Items) {
fn object_destroyed(guid: u64, commands: &mut Commands, index: &mut GuidIndex) {
// **The object goes away by the same fade its stream-out takes** — `DespawnFade`, not a raw
// despawn (decision 2198). The reference's object-manager destroy hands the object's *model*
// to the `SWModelFadeout` scheduler on the way out: the base OnDeactivate `0x6145e0` (vtable
@@ -836,6 +903,13 @@ fn object_destroyed(guid: u64, commands: &mut Commands, index: &mut GuidIndex, i
// fade then follows the animation, where the deferred destroy runs
// ([`crate::go_anim::release_despawn_pin`]).
if let Some(e) = index.0.remove(&guid) {
// An item has no model to hand the fadeout, so it goes now (decision 2334) — what the
// scheduler does with a modelless entity anyway, one frame later — and its countdown
// cells go with it (2340).
if guid::is_item(guid) {
commands.entity(e).despawn();
return;
}
commands.queue(move |world: &mut bevy::ecs::world::World| {
if world
.get::<crate::go_anim::DespawnAnimAnnounced>(e)
@@ -849,8 +923,6 @@ fn object_destroyed(guid: u64, commands: &mut Commands, index: &mut GuidIndex, i
}
});
}
// An item destroy (consumed, sold) never had a scene entity — clear the item store.
items.remove_object(guid);
}
/// Stream-out (out-of-range, the update-object `OutOfRange` block): the unit still exists, we just
@@ -1269,6 +1341,105 @@ fn speed_changed(
mod tests {
use super::*;
/// **An item's countdowns are its own cells** (decision 2340), end to end through the real
/// registration on the built client: `0x1EB` for an item not yet held waits on the active
/// player and is replayed into the item's cells when its create lands, `0x1EA` for one not
/// held is dropped for good, a refused slot writes nothing, and the destroy takes the cells
/// with the object. Without an active player to queue on, the enchant update is dropped too.
#[test]
fn item_countdowns_live_on_the_item_and_a_queued_enchant_waits_for_it() {
use crate::items::Countdowns;
const ME: u64 = 0x0000_0000_0000_0007;
const SWORD: u64 = 0x4000_0000_0000_0042;
const STONE: u64 = 0x4000_0000_0000_0043;
let mut app = crate::game_plugins::schedule_tests::headless_client();
let world = app.world_mut();
let create = |guid| SessionEvent::ItemCreate {
guid,
container: false,
fields: ObjectFields::from_pairs(&[(3, 117)]),
};
let cells = |world: &World, guid: u64| -> Option<Countdowns> {
let e = *world.resource::<GuidIndex>().0.get(&guid)?;
world.get::<Countdowns>(e).cloned()
};
// No active player: the enchant update has nowhere to wait.
super::super::handlers::dispatch(
world,
vec![
SessionEvent::ItemEnchantTime {
item_guid: STONE,
slot: 1,
seconds: 60,
},
create(STONE),
],
);
assert_eq!(
cells(world, STONE),
Some(Countdowns::default()),
"dropped, not queued"
);
// With the active player resolving, it waits for the item — and the lifetime does not.
let me = world.spawn_empty().id();
world.resource_mut::<GuidIndex>().0.insert(ME, me);
world.resource_mut::<SelfGuid>().0 = Some(ME);
super::super::handlers::dispatch(
world,
vec![
SessionEvent::ItemEnchantTime {
item_guid: SWORD,
slot: 1,
seconds: 90,
},
SessionEvent::ItemTime {
item_guid: SWORD,
seconds: 600,
},
],
);
assert_eq!(cells(world, SWORD), None, "nothing held yet");
super::super::handlers::dispatch(world, vec![create(SWORD)]);
let c = cells(world, SWORD).expect("the create spawned the item with its cells");
assert!(
c.enchant_remaining_ms(1)
.is_some_and(|ms| (89_000..=90_000).contains(&ms)),
"the queued enchant replayed as of the create"
);
assert_eq!(
c.lifetime_remaining_ms(),
None,
"`0x1EA` for an unheld item has no queue"
);
// Held now: both arms write the cells directly; a slot past the array writes nothing.
super::super::handlers::dispatch(
world,
vec![
SessionEvent::ItemTime {
item_guid: SWORD,
seconds: 600,
},
SessionEvent::ItemEnchantTime {
item_guid: SWORD,
slot: 9,
seconds: 30,
},
],
);
let c = cells(world, SWORD).unwrap();
assert!(c.lifetime_remaining_ms().is_some());
assert!(c.enchant_remaining_ms(1).is_some());
// The destroy takes the object and its cells with it.
let e = world.resource::<GuidIndex>().0[&SWORD];
super::super::handlers::dispatch(world, vec![SessionEvent::ObjectDestroyed(SWORD)]);
assert!(!world.resource::<GuidIndex>().0.contains_key(&SWORD));
assert!(world.get_entity(e).is_err(), "the cells died with the item");
}
/// The observer movement-mode family's own harness (decision 1780): one indexed unit, and a
/// `World` small enough that the only thing that can move it is the code under test.
mod spline_modes {
+13
View File
@@ -41,6 +41,7 @@ use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress};
use benilla_world::schedule::WorldStage;
mod arc;
mod net;
// Writing the frame onto the body we drive — pose, MovementState, the counter-twist gap.
mod body_pose;
pub(crate) mod camera;
@@ -230,6 +231,15 @@ pub(crate) struct StandStateRequest {
pub(crate) state: u8,
}
/// The server's own stand state for OUR body (`SMSG_STANDSTATE_UPDATE` — the eat/drink sit, the
/// stand on damage; decision 2339). Applied by [`posture`] through the same local setter the
/// volunteered change uses, with no refusal gate and no packet back — the reference's `0x603e50`
/// → `0x6127b0`. Written by [`net`], read in `control`.
#[derive(bevy::ecs::message::Message, Clone, Copy, Debug)]
pub(crate) struct ServerStandState {
pub(crate) state: u8,
}
/// **The body in our hands** — normally our own streamed avatar, and a possessed creature while we
/// hold its reins (decision 1277). The controller reads its server pose to take control, then
/// drives its transform (feet position + facing) and feeds its movement to the animation selector
@@ -301,6 +311,7 @@ pub(crate) struct PlayerPlugin;
impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
net::register(app);
follow::plugin(app);
camera_saved::plugin(app);
camera_view::plugin(app);
@@ -379,6 +390,8 @@ impl Plugin for PlayerPlugin {
// The posture setter's queue (the `/sit` family — decision 0881; `control` is the sole
// executor, like the sheath queue).
.add_message::<StandStateRequest>()
// The server's own stand state for our body (decision 2339), written by [`net`].
.add_message::<ServerStandState>()
// Land-here ([`land`]): the ask, and the re-attach when the server's teleport lands.
// Before `control` so the frame that applies the teleport is the frame that takes
// third-person control back — `control` reads `detached` after this has cleared it.
@@ -83,6 +83,9 @@ pub(super) fn control(
// The loot window's move-start close (decision 2097): the reference's movement-START
// guard `0x60e990`, reported here beside the cast bar's edge and consumed by `ui_loot`.
ResMut<crate::ui_loot::LootMoveStart>,
// The server's own stand state for our body (decision 2339): `SMSG_STANDSTATE_UPDATE`,
// applied through the posture setter's local half — ungated, nothing sent back.
MessageReader<super::ServerStandState>,
),
// Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`).
speed_capsule: (
@@ -699,6 +702,7 @@ pub(super) fn control(
&net.0,
&mut net.3,
&mut net.10,
&mut net.15,
moving,
turned,
);
+21
View File
@@ -0,0 +1,21 @@
//! The player's packet handlers (the net handler table, decision 2327) — today one: the server's
//! own stand state for our body (`SMSG_STANDSTATE_UPDATE`, decision 2339), handed to
//! [`super::posture`] as a [`ServerStandState`] message because the apply wants the frame's body
//! query and sheath queue, which live in `control`.
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::ServerStandState;
use crate::net::NetHandlerApp;
/// Register the handler — called from [`super::PlayerPlugin`].
pub(super) fn register(app: &mut App) {
app.net_handler(SessionEventKind::StandStateUpdate, on_stand_state_update);
}
fn on_stand_state_update(In(ev): In<SessionEvent>, mut out: MessageWriter<ServerStandState>) {
if let SessionEvent::StandStateUpdate { state } = ev {
out.write(ServerStandState { state });
}
}
+85 -18
View File
@@ -24,15 +24,17 @@ pub(super) fn update(
net: &NetCommands,
sheath: &mut MessageWriter<crate::creature_anim::SheathRequest>,
asks: &mut MessageReader<StandStateRequest>,
server: &mut MessageReader<super::ServerStandState>,
moving: bool,
turned: bool,
) -> u8 {
// Stand state (decision 0080c) — a real field, not a local bool: X volunteers
// `CMSG_STANDSTATECHANGE` (sit 1 ↔ stand 0) and movement input stands us up; the
// server's echo into `UNIT_FIELD_BYTES_1` drives the pose — ours *and* every
// observer's. `stand_pending` is the local commit (the client's `SetStandState`
// applies immediately and sends, one setter — `0x6127b0`), overlaid on the echoed
// byte until it lands so the pose never waits on the round-trip.
// observer's. `stand_pending` is the local commit — the client's `SetStandState 0x5ed430`
// sends, then applies through the setter's local half `0x6127b0` ([`apply_locally`]), the
// same half the server's own `SMSG_STANDSTATE_UPDATE` reaches (2339) — overlaid on the
// echoed byte until it lands so the pose never waits on the round-trip.
let (stand_byte, reads_dead) = body
.single()
.ok()
@@ -52,6 +54,15 @@ pub(super) fn update(
if player.stand_pending == Some(stand_byte) {
player.stand_pending = None; // the echo landed
}
// The server's own stand state first (decision 2339): `SMSG_STANDSTATE_UPDATE` reaches the
// setter's local half directly — no refusal gate, no `CMSG_STANDSTATECHANGE` back — so the
// volunteered logic below sees the state the server just put us in. The last packet wins,
// as it does in the reference's handler order.
if let Some(s) = server.read().last().map(|m| m.state) {
let stand_now = player.stand_pending.unwrap_or(stand_byte);
move_trace::posture("server", s, stand_now, player.move_flags());
apply_locally(player, s, stand_now, body, sheath);
}
let stand_state = player.stand_pending.unwrap_or(stand_byte);
// The queued asks first (the `/sit` family — decision 0881), then the X key, which is the
// reference's own precedence: a queued `SetStandState` ran during the frame's message pass,
@@ -112,24 +123,12 @@ pub(super) fn update(
}
if let Some(s) = request_stand.filter(|&s| s != stand_state) {
move_trace::posture("commit", s, stand_state, player.move_flags());
player.stand_pending = Some(s);
// The reference's order: `0x5ed430` sends at `0x5ed501`, then calls the local half at
// `0x5ed53f` — the packet is the volunteer path's, never the setter's (2339).
let _ = net.0.send(ClientCommand::StandStateChange {
state: u32::from(s),
});
// The sit-stow rider (the client's SetStandState → SetSheatheState(0, SNAP) —
// wow-re `sheath-policy.md` §4): entering any stand-state ∉ {0 STAND, 2 SIT_CHAIR}
// force-stows drawn weapons, through the anim layer's one setter.
if s != 0 && s != 2 {
if let Ok((e, _, _, _, drv, _, _, _, _, _, _)) = body.single() {
if drv.and_then(|d| d.sheath_state()).unwrap_or(0) != 0 {
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 0,
ceremony: false,
});
}
}
}
apply_locally(player, s, stand_state, body, sheath);
}
let stand_now = player.stand_pending.unwrap_or(stand_byte);
// Sheath toggle (Z) — vanilla's draw/stow, through the anim layer's ONE setter
@@ -187,3 +186,71 @@ pub(super) fn update(
stand_now
}
/// The stand-state setter's **local half** — the reference's `0x6127b0`, reached by the
/// volunteered change (`0x5ed430`, after it has sent `CMSG_STANDSTATECHANGE`) and by the server's
/// own `SMSG_STANDSTATE_UPDATE` (`0x603e50`) alike; it sends no `CMSG_STANDSTATECHANGE` itself —
/// its four callers image-wide hold none of the five send sites (wow-re `object-layer.md` "Three
/// server handlers at the bytes"; decision 2339).
///
/// On a CHANGE it writes the predicted state (`[player+0x1d68]`, our `stand_pending`), which the
/// pose reads until the `UNIT_FIELD_BYTES_1` echo lands ([`predict`]). Whether or not the state
/// changed, a drawn weapon meeting a state outside {0 STAND, 2 SIT_CHAIR} is stowed through the
/// anim layer's one setter — `0x6127cc`'s gate is the sheath state, not the same-state compare
/// (wow-re `sheath-policy.md` §4).
///
/// What the reference's local half also does on a change, and this does not yet: release an open
/// loot window and stop a running attack when sitting down (`0x5f0790` → `0x48f200`, `0x5ecac0`),
/// re-run the movement input when standing up, and on EVERY call re-acquire the follow camera
/// behind the body's facing (`0x48ec90`, the stand-state-keyed pivot presets). 2339 names them.
fn apply_locally(
player: &mut Player,
s: u8,
stand_now: u8,
body: &BodyQuery,
sheath: &mut MessageWriter<crate::creature_anim::SheathRequest>,
) {
predict(&mut player.stand_pending, s, stand_now);
if s != 0 && s != 2 {
if let Ok((e, _, _, _, drv, _, _, _, _, _, _)) = body.single() {
if drv.and_then(|d| d.sheath_state()).unwrap_or(0) != 0 {
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 0,
ceremony: false,
});
}
}
}
}
/// `0x5f0790`'s first line: the predicted stand state is written only when it changes
/// (`0x5f0799 cmp [esi+0x1d68], eax; je ret`). Returns whether it did.
fn predict(pending: &mut Option<u8>, s: u8, stand_now: u8) -> bool {
if s == stand_now {
return false;
}
*pending = Some(s);
true
}
#[cfg(test)]
mod setter_tests {
use super::predict;
#[test]
fn the_local_half_writes_the_prediction_only_on_a_change() {
// Seated by the server (a drink) while the echo still says standing: predicted.
let mut pending = None;
assert!(predict(&mut pending, 1, 0));
assert_eq!(pending, Some(1));
// vmangos's same-state re-send (its camera re-acquire): nothing written.
let mut pending = None;
assert!(!predict(&mut pending, 0, 0));
assert_eq!(pending, None);
// A stand from the server while we predicted a sit: the newer state wins.
let mut pending = Some(1);
assert!(predict(&mut pending, 0, 1));
assert_eq!(pending, Some(0));
}
}
+5 -3
View File
@@ -836,9 +836,11 @@ pub(crate) struct Player {
/// right-drag — into a mid-cast "you moved". Decision 0907; the reconcile lives in
/// [`super::movement_net::stream_self_movement`].
pub(super) last_pos: [f32; 3],
/// The stand state we last volunteered (`CMSG_STANDSTATECHANGE`) whose echo into our
/// `UNIT_FIELD_BYTES_1` hasn't landed yet — the local commit (the client's `SetStandState`
/// `0x6127b0` applies immediately *and* sends; decision 0080c). `None` = at the echoed value.
/// The stand state we last committed locally — volunteered (`CMSG_STANDSTATECHANGE`) or the
/// server's own (`SMSG_STANDSTATE_UPDATE`, 2339) — whose echo into our `UNIT_FIELD_BYTES_1`
/// hasn't landed yet: the reference's predicted cache `[player+0x1d68]`, written by the
/// setter's local half `0x6127b0`, which sends nothing itself (the volunteer path `0x5ed430`
/// sends first, then calls it; decisions 0080c, 2339). `None` = at the echoed value.
pub(super) stand_pending: Option<u8>,
/// **Settling after a teleport/summon/login**: the streamed world (terrain *and* its WMO
/// buildings + colliders) arrives over several frames, so the collision under the destination
+2 -1
View File
@@ -465,6 +465,7 @@ fn combat_sounds(
units: Query<CombatUnit>,
tables: MeleeTables,
attach: crate::entities::AttachPoints,
objects: crate::net::Objects,
mut items: Option<ResMut<crate::items::Items>>,
net_commands: Res<crate::net::NetCommands>,
kits: Option<ResMut<SoundKits>>,
@@ -724,7 +725,7 @@ fn combat_sounds(
items
.as_mut()
.and_then(|it| {
super::worn_chest_material(vstore, it, &net_commands)
super::worn_chest_material(vstore, &objects, it, &net_commands)
})
.map_or(impact_slot::FLESH, |m| mats.armor_impact_slot(m) as usize)
}
+10 -6
View File
@@ -115,11 +115,12 @@ fn footstep_sounds(
parents: Query<&ChildOf>,
root_state: Query<(Option<&ObjectStore>, Option<&MovementState>)>,
footsteps: Option<Res<Footsteps>>,
// The foley half: the material table, the creature catalog that answers a unit's material,
// and the item store that answers a player's (a chest template ask rides the same
// once-per-entry discipline every other consumer uses).
materials: Option<Res<super::Materials>>,
creatures: Option<Res<Creatures>>,
// The foley half: the material table and the creature catalog that answers a unit's
// material, as ONE param (the 16-SystemParam ceiling this signature sits at), plus the item
// store that answers a player's (a chest template ask rides the same once-per-entry
// discipline every other consumer uses) and the object index its guid resolves through.
foley: (Option<Res<super::Materials>>, Option<Res<Creatures>>),
objects: crate::net::Objects,
mut items: Option<ResMut<Items>>,
net_commands: Res<NetCommands>,
voices: Option<Res<CreatureVoices>>,
@@ -133,6 +134,7 @@ fn footstep_sounds(
if events.is_empty() {
return;
}
let (materials, creatures) = foley;
let (Some(footsteps), Some(voices), Some(mut kits), Some(assets)) =
(footsteps, voices, kits, assets)
else {
@@ -168,7 +170,9 @@ fn footstep_sounds(
// The player override (`0x62fa30`) reads the chest through the *private*
// inv-slot array, so this resolves for you and no one else — the reference's
// own reach, not a restriction added here.
EntityKind::Player => super::worn_chest_material(store, it, &net_commands),
EntityKind::Player => {
super::worn_chest_material(store, &objects, it, &net_commands)
}
_ => net
.display_id
.and_then(|d| creatures.as_deref()?.foley_material(d)),
+2 -1
View File
@@ -479,13 +479,14 @@ 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>,
objects: &crate::net::Objects,
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.
const EQUIPMENT_SLOT_CHEST: u8 = 4;
let guid = store?.0.player_inv_slot(EQUIPMENT_SLOT_CHEST)?;
let entry = items.object(guid)?.object_entry()?;
let entry = objects.object(guid)?.object_entry()?;
Some(items.held(entry, net)?.material)
}
+15 -9
View File
@@ -36,7 +36,7 @@ use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use crate::net::{ClientCommand, NetCommands, Objects, SelfPlayer};
use super::{cast_target, validator, AutoRepeatActive};
use crate::ui_action::{reagent_totem_refusal, CastErrors, Spells};
@@ -95,9 +95,11 @@ pub(crate) struct CastLadder<'w, 's> {
pub(crate) self_player:
Query<'w, 's, (Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
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.
/// The item cache — the item arms resolve templates through it.
pub(crate) items: Res<'w, Items>,
/// The one object index (decision 2334) — the pre-send totem/reagent check walks the bags
/// through it, and the item arms resolve an instance guid to its fields here.
pub(crate) objects: Objects<'w, 's>,
pub(crate) sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
pub(crate) ecs: Commands<'w, 's>,
pub(crate) pending: ResMut<'w, crate::spell::PendingCast>,
@@ -240,6 +242,7 @@ impl CastLadder<'_, '_> {
&self.commands,
&self.self_player,
self.spells.as_deref(),
&self.objects,
&self.items,
&mut self.sheath,
&mut self.ecs,
@@ -281,6 +284,7 @@ fn send_spell_cast(
commands: &NetCommands,
self_player: &Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
spells: Option<&Spells>,
objects: &Objects,
items: &Items,
sheath: &mut MessageWriter<crate::creature_anim::SheathRequest>,
ecs: &mut Commands,
@@ -354,7 +358,7 @@ fn send_spell_cast(
// it the real message can't appear. Position pinned by the 0948 §5: TryCast runs it BEFORE
// the validator (`0x6e4ded` precedes the `0x6e4f3b` call), so an on-cooldown press with
// missing reagents shows the reagent error, never "not ready".
if reagent_totem_refusal(spell_id, def, ctx.rel.self_store, items, cast_errors) {
if reagent_totem_refusal(spell_id, def, ctx.rel.self_store, objects, cast_errors) {
return;
}
// **TryCast rung 7** (`0x6e4e03`, decision 1925) — the equipped-item requirement, which the
@@ -367,7 +371,7 @@ fn send_spell_cast(
// player who is both stunned and missing the required weapon is told about **the weapon**.
if let Some(d) = def {
if let Some(store) = ctx.rel.self_store {
if !super::usable::equipped_item_fits_cached(d, store, items) {
if !super::usable::equipped_item_fits_cached(d, store, objects, items) {
let reason = super::usable::equipped_item_reason(d);
debug!("ui_action: cast {spell_id} refused locally — equipped item ({reason:#x})");
cast_errors.push_local(spell_id, reason);
@@ -392,12 +396,12 @@ fn send_spell_cast(
selection: ctx.selection_guid,
caster: ctx.self_guid,
// The ref resolves its candidate guid through `0x468460(typemask 1)` (`6e53bc`) before
// handing it to the binder: a guid naming no live object binds nothing. Ours is the item
// cache — an equipped item whose object has not streamed yet is exactly that miss, and
// falls to the cursor rather than shipping a guid the server would reject.
// handing it to the binder: a guid naming no live object binds nothing. Ours is the one
// object index — an equipped item whose object has not streamed yet is exactly that miss,
// and falls to the cursor rather than shipping a guid the server would reject.
main_hand_item: ctx
.main_hand_item
.filter(|guid| items.object(*guid).is_some()),
.filter(|guid| objects.object(*guid).is_some()),
};
let target = match explicit_object {
Some(_) => None,
@@ -863,6 +867,8 @@ mod tests {
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<Items>();
// The one object index the ladder's item arms resolve an instance guid through (2334).
world.init_resource::<crate::net::GuidIndex>();
world.init_resource::<crate::spell::PendingCast>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::Cooldowns>();
+11 -4
View File
@@ -400,7 +400,7 @@ fn on_item_cooldown(In(ev): In<SessionEvent>, mut l: Lifecycle, sc: Scene) {
spell_id,
} = ev
{
item_cooldown(item_guid, spell_id, &sc.items, &mut l.cooldowns);
item_cooldown(item_guid, spell_id, &l.index, &sc.stores, &mut l.cooldowns);
}
}
@@ -1095,7 +1095,8 @@ fn spell_go(
stores.get(*e).ok()?.0.unit_ranged_attack_time()
})
.unwrap_or(0);
match item_caster.and_then(|g| items.object(g)?.object_entry()) {
// The item's entry off its own store — an item is an object in the index (2334).
match item_caster.and_then(|g| stores.get(*index.0.get(&g)?).ok()?.0.object_entry()) {
Some(entry) => {
let use_spell = items
.template(entry, 0, net_commands)
@@ -1432,11 +1433,17 @@ fn spell_cooldowns(
fn item_cooldown(
item_guid: u64,
spell_id: u32,
items: &crate::items::Items,
index: &GuidIndex,
stores: &Query<&mut ObjectStore>,
cooldowns: &mut Cooldowns,
) {
debug!("net: item cooldown — item {item_guid:#x} spell {spell_id}");
if let Some(entry) = items.object(item_guid).and_then(|o| o.object_entry()) {
let entry = index
.0
.get(&item_guid)
.and_then(|&e| stores.get(e).ok())
.and_then(|s| s.0.object_entry());
if let Some(entry) = entry {
cooldowns.apply_wire_item_cooldown(entry, spell_id, Instant::now());
}
}
@@ -336,6 +336,7 @@ mod tests {
world.init_resource::<crate::target::HoveredObject>();
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.insert_resource(PickOcclusion {
distance: 10.0,
point,
@@ -179,7 +179,7 @@ pub(crate) fn commit_item_cast_on_pick(
match self_q
.iter()
.next()
.and_then(|store| crate::ui_items::slot_guid(&store.0, bag, slot0, &ladder.items))
.and_then(|store| crate::ui_items::slot_guid(&store.0, bag, slot0, &ladder.objects))
{
Some(guid) => asks.push((guid, false)),
None => {
@@ -203,7 +203,7 @@ pub(crate) fn commit_item_cast_on_pick(
// the bag needed it for the icon) binds ungated and lets the server judge, the same
// permissive shape the rest of the click law uses.
let entry = ladder
.items
.objects
.object(item_guid)
.and_then(|o| o.object_entry());
let clicked = match entry {
@@ -227,7 +227,7 @@ pub(crate) fn commit_item_cast_on_pick(
continue;
};
let cat = enchants.as_deref();
let fields = ladder.items.object(item_guid);
let fields = ladder.objects.object(item_guid);
let item = ClickedItem {
class,
subclass,
@@ -174,6 +174,7 @@ mod tests {
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.init_resource::<crate::spell::PendingCast>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::Cooldowns>();
+42 -28
View File
@@ -27,7 +27,7 @@ use benilla_formats::{
};
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, Reputations};
use crate::net::{NetCommands, ObjectStore, Objects, Reputations};
use crate::spell::Cooldowns;
use crate::spell::{SpellModifiers, OP_COST};
use crate::target::{can_attack, ring_reaction, Factions};
@@ -99,6 +99,7 @@ fn hand_mask(d: &SpellDisplay) -> u32 {
pub(crate) fn equipped_item_fits_cached(
d: &SpellDisplay,
store: &ObjectStore,
objects: &Objects,
items: &Items,
) -> bool {
if d.equipped_item_class < 0
@@ -108,7 +109,7 @@ pub(crate) fn equipped_item_fits_cached(
return true;
}
let mut mask = hand_mask(d);
if let Some(hidden) = crate::items::disarmed_equipment_slot_cached(store, items) {
if let Some(hidden) = crate::items::disarmed_equipment_slot_cached(store, objects, items) {
mask &= !(1u32 << hidden);
}
equipped_slots_match(
@@ -117,7 +118,7 @@ pub(crate) fn equipped_item_fits_cached(
d.equipped_item_class as u32,
d.equipped_item_subclass_mask,
|guid| {
let obj = items.object(guid)?;
let obj = objects.object(guid)?;
let t = items.template_cached(obj.object_entry()?)?;
Some(WornItem {
class: t.class,
@@ -161,6 +162,7 @@ pub(crate) fn equipped_item_reason(d: &SpellDisplay) -> u8 {
pub(crate) fn equipped_item_fits(
d: &SpellDisplay,
store: &ObjectStore,
objects: &Objects,
items: &Items,
commands: &NetCommands,
) -> bool {
@@ -194,11 +196,11 @@ pub(crate) fn equipped_item_fits(
// probes and a mask edit, NOT by `GetWeapon` returning NULL — same ladder, same outcome, one
// hand only). A disarmed warrior's Heroic Strike greys out and its tooltip requirement line
// turns red; a disarmed dual-wielder's off-hand weapon still satisfies a hand-agnostic one.
if let Some(hidden) = crate::items::disarmed_equipment_slot(store, items, commands) {
if let Some(hidden) = crate::items::disarmed_equipment_slot(store, objects, items, commands) {
mask &= !(1u32 << hidden);
}
equipped_slots_match(store, mask, class, d.equipped_item_subclass_mask, |guid| {
let (entry, durability, max_durability) = items.object(guid).and_then(|o| {
let (entry, durability, max_durability) = objects.object(guid).and_then(|o| {
Some((
o.object_entry()?,
o.item_durability(),
@@ -312,6 +314,7 @@ pub(crate) fn item_usable(
held: bool,
ctx: &UsableCtx,
spells: Option<&Spells>,
objects: &Objects,
items: &Items,
commands: &NetCommands,
) -> (bool, bool) {
@@ -336,7 +339,7 @@ pub(crate) fn item_usable(
let (Some(d), Some(spells)) = (d, spells) else {
return (false, false);
};
spell_usable(spell_id, d, spells, ctx, items, commands)
spell_usable(spell_id, d, spells, ctx, objects, items, commands)
}
/// The walk. Returns `(usable, not_enough_mana)` — the `IsUsableAction` pair.
@@ -345,6 +348,7 @@ pub(crate) fn spell_usable(
d: &SpellDisplay,
spells: &Spells,
ctx: &UsableCtx,
objects: &Objects,
items: &Items,
commands: &NetCommands,
) -> (bool, bool) {
@@ -369,7 +373,7 @@ pub(crate) fn spell_usable(
}
}
// Leg 4 (`0x6e40e0`): some worn item must match the class + subclass mask.
if !equipped_item_fits(d, ctx.store, items, commands) {
if !equipped_item_fits(d, ctx.store, objects, items, commands) {
return (false, false);
}
// Leg 5 (`0x6e3e7a`–`0x6e3eb2`): the combo-point gate, §5-VERIFIED end to end (0879). A
@@ -559,8 +563,7 @@ mod tests {
for (i, (field, guid)) in hands.iter().enumerate() {
pairs.push((*field, *guid as u32));
let entry = 500 + i as u32;
deps.items
.insert_object(*guid, ObjectFields::from_pairs(&[(3, entry)]));
deps.spawn_item(*guid, ObjectFields::from_pairs(&[(3, entry)]));
deps.items.insert_template(
entry,
Some(benilla_protocol::messages::ItemInfo {
@@ -571,7 +574,9 @@ mod tests {
);
}
let store = ObjectStore(ObjectFields::from_pairs(&pairs));
equipped_item_fits(&needs_a_weapon, &store, &deps.items, &deps.commands)
deps.with_objects(|objects, items, commands| {
equipped_item_fits(&needs_a_weapon, &store, objects, items, commands)
})
};
// CONTROL — armed, the sword satisfies it.
@@ -615,7 +620,7 @@ mod tests {
for (i, (field, guid, flags, max_dur, dur)) in hands.iter().enumerate() {
pairs.push((*field, *guid as u32));
let entry = 500 + i as u32;
deps.items.insert_object(
deps.spawn_item(
*guid,
ObjectFields::from_pairs(&[
(3, entry),
@@ -634,7 +639,9 @@ mod tests {
);
}
let store = ObjectStore(ObjectFields::from_pairs(&pairs));
equipped_item_fits(d, &store, &deps.items, &deps.commands)
deps.with_objects(|objects, items, commands| {
equipped_item_fits(d, &store, objects, items, commands)
})
};
let sound = |field| (field, 0x2au64, 0u32, 0u32, 0u32);
@@ -687,8 +694,7 @@ mod tests {
let guid = 0x2a + i as u64;
pairs.push((*field, guid as u32));
let entry = 500 + i as u32;
deps.items
.insert_object(guid, ObjectFields::from_pairs(&[(3, entry)]));
deps.spawn_item(guid, ObjectFields::from_pairs(&[(3, entry)]));
deps.items.insert_template(
entry,
Some(benilla_protocol::messages::ItemInfo {
@@ -699,17 +705,20 @@ mod tests {
);
}
let store = ObjectStore(ObjectFields::from_pairs(&pairs));
equipped_item_fits(
&SpellDisplay {
equipped_item_class: 2,
equipped_item_subclass_mask: 1 << 7, // Sword1H
attributes_ex3: ex3,
..Default::default()
},
&store,
&deps.items,
&deps.commands,
)
deps.with_objects(|objects, items, commands| {
equipped_item_fits(
&SpellDisplay {
equipped_item_class: 2,
equipped_item_subclass_mask: 1 << 7, // Sword1H
attributes_ex3: ex3,
..Default::default()
},
&store,
objects,
items,
commands,
)
})
};
// Dual-wielding, armed: both abilities are usable.
@@ -748,13 +757,16 @@ mod tests {
let items = Items::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let carried = crate::ui_items::carried_counts(&store.0, &items);
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let carried = crate::ui_items::carried_counts(&store.0, &objects);
let spell_mods = SpellModifiers::default();
spell_usable(
1,
d,
&spells,
&ctx(store, &cooldowns, &reputations, &carried, &spell_mods),
&objects,
&items,
&commands,
)
@@ -933,7 +945,9 @@ mod tests {
let healthy = ObjectStore(ObjectFields::from_pairs(&[(22, 100), (125, 0)]));
let low = ObjectStore(ObjectFields::from_pairs(&[(22, 10), (125, 0x2)]));
let carried = crate::ui_items::carried_counts(&me.0, &items);
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let carried = crate::ui_items::carried_counts(&me.0, &objects);
for (target, expect) in [(&healthy, false), (&low, true)] {
let ctx = UsableCtx {
store: &me,
@@ -945,7 +959,7 @@ mod tests {
spell_mods: &SpellModifiers::default(),
};
assert_eq!(
spell_usable(5308, &execute, &spells, &ctx, &items, &commands),
spell_usable(5308, &execute, &spells, &ctx, &objects, &items, &commands),
(expect, false)
);
}
+4 -3
View File
@@ -910,7 +910,7 @@ pub(super) fn act_on_right_click(
.and_then(|item| {
let slot0 = u8::try_from(item.slot.saturating_sub(1)).unwrap_or(0);
self_store.and_then(|s| {
crate::ui_items::slot_guid(&s.0, item.bag, slot0, &go_inputs.items)
crate::ui_items::slot_guid(&s.0, item.bag, slot0, &go_inputs.objects)
})
});
let Some(arm) = service_arm(npc_flags, service.quest.status(guid)) else {
@@ -1067,7 +1067,7 @@ pub(crate) fn resolve_go_action(
inputs.spells.as_deref(),
inputs.skill_lines.as_ref().map(|s| &s.catalog),
me_store,
&inputs.items,
&inputs.objects,
facts,
&mut matched,
);
@@ -1118,7 +1118,7 @@ pub(crate) fn resolve_go_action(
};
let Some((bag_index, slot, key_guid)) = crate::ui_items::find_item(
&store.0,
&inputs.items,
&inputs.objects,
key_entry,
crate::ui_items::ItemSearch::default(),
) else {
@@ -2359,6 +2359,7 @@ mod tests {
world.init_resource::<crate::creature_anim::GestureQueue>();
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.init_resource::<crate::ui_action::PlayerActions>();
world.init_resource::<crate::ui_action::LearnedAbilities>();
world.init_resource::<crate::ui_quest::QuestGiver>();
+1 -1
View File
@@ -870,7 +870,7 @@ pub(super) fn classify_cursor(
go_inputs.spells.as_deref(),
go_inputs.skill_lines.as_ref().map(|s| &s.catalog),
Some(self_store),
&go_inputs.items,
&go_inputs.objects,
facts,
&mut None,
)
+43 -21
View File
@@ -44,7 +44,7 @@ use crate::net::ObjectStore;
/// item-template cache (key-item names for the "Requires \<key\>" toast, and the key's own ON_USE
/// spell). The `Option` members are absent without client data.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct GoLockInputs<'w> {
pub(crate) struct GoLockInputs<'w, 's> {
// 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>,
@@ -55,6 +55,8 @@ pub(crate) struct GoLockInputs<'w> {
/// ([`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: Res<'w, crate::items::Items>,
/// The one object index (2334) — the key-item scan walks the player's bags through it.
pub(crate) objects: crate::net::Objects<'w, 's>,
}
/// The GameObject facts the Action gate and the requirement fallback read off the wire — gathered
@@ -154,7 +156,7 @@ pub(crate) fn resolve_lock(
spells: Option<&crate::ui_action::Spells>,
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
me: Option<&ObjectStore>,
items: &crate::items::Items,
objects: &crate::net::Objects,
go: GoFacts,
matched_spell: &mut Option<u32>,
) -> LockOutcome {
@@ -189,7 +191,7 @@ pub(crate) fn resolve_lock(
if !slot.available(go.state, go.flag_locked) {
continue;
}
if me.is_some_and(|s| holds_item(&s.0, items, slot.index)) {
if me.is_some_and(|s| holds_item(&s.0, objects, slot.index)) {
return LockOutcome::OpenByKey(slot.index);
}
}
@@ -306,11 +308,16 @@ pub(crate) fn required_skill(slot: &LockSlot, go_level: u32) -> i32 {
/// `CMSG_USE_ITEM` (decision 0769).
fn holds_item(
store: &benilla_protocol::messages::ObjectFields,
items: &crate::items::Items,
objects: &crate::net::Objects,
entry: u32,
) -> bool {
crate::ui_items::find_item(store, items, entry, crate::ui_items::ItemSearch::default())
.is_some()
crate::ui_items::find_item(
store,
objects,
entry,
crate::ui_items::ItemSearch::default(),
)
.is_some()
}
#[cfg(test)]
@@ -445,7 +452,10 @@ mod tests {
// Spell → line: Mining is SkillLine 186.
let lines = SkillLineCatalog::from_spell_lines([(2575, 186)]);
let known = BTreeSet::from([2575]);
let items = crate::items::Items::default();
// Nothing streamed: no case here holds a key, and the key branch is only reached
// with a store, which these pass as `None` (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
// A 250-skill vein, available (Action 0, READY, not flagged).
let mut vein = [LockSlot::default(); 8];
vein[0] = skill_slot(3, 250, 0);
@@ -471,7 +481,7 @@ mod tests {
Some(&spells),
lines,
store,
&items,
&objects,
facts,
&mut None,
)
@@ -509,7 +519,10 @@ mod tests {
skill_slot(10, 0, 0), // Quick Open — Action 0, gated out on a flagged-locked door
LockSlot::default(),
];
let items = crate::items::Items::default();
// Nothing streamed: no case here holds a key, and the key branch is only reached
// with a store, which these pass as `None` (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let mut matched = None;
let out = resolve_lock(
&slots,
@@ -517,7 +530,7 @@ mod tests {
None,
None,
None,
&items,
&objects,
GoFacts {
state: GO_STATE_READY,
flag_locked: true,
@@ -588,7 +601,10 @@ mod tests {
),
..crate::ui_action::Spells::empty_for_tests()
};
let items = crate::items::Items::default();
// Nothing streamed: no case here holds a key, and the key branch is only reached
// with a store, which these pass as `None` (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let locked_shut = GoFacts {
state: GO_STATE_READY,
flag_locked: true,
@@ -604,7 +620,7 @@ mod tests {
Some(&spells),
None,
None,
&items,
&objects,
locked_shut,
&mut matched,
),
@@ -624,7 +640,7 @@ mod tests {
Some(&spells),
None,
None,
&items,
&objects,
GoFacts {
flag_locked: false,
..locked_shut
@@ -647,7 +663,7 @@ mod tests {
Some(&spells),
None,
None,
&items,
&objects,
locked_shut,
&mut matched,
),
@@ -676,7 +692,7 @@ mod tests {
Some(&spells),
None,
None,
&items,
&objects,
locked_shut,
&mut None,
),
@@ -708,7 +724,7 @@ mod tests {
Some(&with_mining),
None,
None,
&items,
&objects,
GoFacts {
state: GO_STATE_READY,
flag_locked: false,
@@ -758,7 +774,10 @@ mod tests {
),
..crate::ui_action::Spells::empty_for_tests()
};
let items = crate::items::Items::default();
// Nothing streamed: no case here holds a key, and the key branch is only reached
// with a store, which these pass as `None` (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let unlocked = GoFacts {
state: GO_STATE_READY,
flag_locked: false,
@@ -773,7 +792,7 @@ mod tests {
Some(&spells),
None,
None,
&items,
&objects,
unlocked,
&mut matched,
),
@@ -791,7 +810,7 @@ mod tests {
Some(&spells),
None,
None,
&items,
&objects,
unlocked,
&mut matched,
),
@@ -803,7 +822,10 @@ mod tests {
#[test]
fn an_empty_row_is_unlocked() {
let slots = [LockSlot::default(); 8];
let items = crate::items::Items::default();
// Nothing streamed: no case here holds a key, and the key branch is only reached
// with a store, which these pass as `None` (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let mut matched = None;
assert_eq!(
resolve_lock(
@@ -812,7 +834,7 @@ mod tests {
None,
None,
None,
&items,
&objects,
GoFacts {
state: GO_STATE_ACTIVE,
flag_locked: false,
+2 -1
View File
@@ -363,7 +363,7 @@ pub(super) fn drain_action_uses(
continue;
};
let route = item_action_route(&template, |s| {
crate::ui_items::find_item(&store.0, &ladder.items, b.action, s)
crate::ui_items::find_item(&store.0, &ladder.objects, b.action, s)
});
let ((bag_index, slot0, guid), equip) = match route {
ItemRoute::Use(pos) => (pos, false),
@@ -389,6 +389,7 @@ pub(super) fn drain_action_uses(
crate::ui_items::send_auto_equip(
&mut script,
&mut gate,
&ladder.objects,
&ladder.items,
&ladder.commands,
bag_index,
@@ -127,7 +127,7 @@ pub(crate) fn drop_item_on_unit(
};
let slot0 = u8::try_from(held.slot.saturating_sub(1)).unwrap_or(0);
let Some(item_guid) =
crate::ui_items::slot_guid(&self_store.0, held.bag, slot0, &ladder.items)
crate::ui_items::slot_guid(&self_store.0, held.bag, slot0, &ladder.objects)
else {
continue; // the slot emptied under us — silent, as every refusal here is
};
+25 -19
View File
@@ -591,16 +591,16 @@ pub(crate) fn reagent_totem_refusal(
spell_id: u32,
def: Option<&benilla_formats::SpellDisplay>,
self_store: Option<&ObjectStore>,
items: &crate::items::Items,
objects: &crate::net::Objects,
errors: &mut CastErrors,
) -> bool {
let (Some(d), Some(store)) = (def, self_store) else {
return false;
};
// Totems before reagents — the ref's in-function loop order.
let reason = if first_missing_totem(d, store, items).is_some() {
let reason = if first_missing_totem(d, store, objects).is_some() {
0x78
} else if first_short_reagent(d, store, items).is_some() {
} else if first_short_reagent(d, store, objects).is_some() {
0x5c
} else {
return false;
@@ -615,13 +615,13 @@ pub(crate) fn reagent_totem_refusal(
pub(super) fn first_missing_totem(
d: &benilla_formats::SpellDisplay,
store: &ObjectStore,
items: &crate::items::Items,
objects: &crate::net::Objects,
) -> Option<u32> {
d.totems
.iter()
.copied()
.filter(|&t| t != 0)
.find(|&t| count_of(&store.0, items, t, InventoryScope::CARRIED) == 0)
.find(|&t| count_of(&store.0, objects, t, InventoryScope::CARRIED) == 0)
}
/// The first reagent slot whose owned count falls short — the `0x6e4000` reagent loop's failing
@@ -629,13 +629,13 @@ pub(super) fn first_missing_totem(
pub(super) fn first_short_reagent(
d: &benilla_formats::SpellDisplay,
store: &ObjectStore,
items: &crate::items::Items,
objects: &crate::net::Objects,
) -> Option<u32> {
d.reagents
.iter()
.copied()
.filter(|&(id, _)| id != 0)
.find(|&(id, n)| count_of(&store.0, items, id, InventoryScope::CARRIED) < n)
.find(|&(id, n)| count_of(&store.0, objects, id, InventoryScope::CARRIED) < n)
.map(|(id, _)| id)
}
@@ -1010,6 +1010,12 @@ mod totem_reagent_tests {
ObjectStore(ObjectFields::default())
}
/// The object index the count walks read — nothing streamed, which is the empty-bags pole
/// every case here is graded at (2334).
fn objects() -> crate::ui_items::TestObjects {
crate::ui_items::TestObjects::new()
}
fn spell(totems: [u32; 2], reagents: [(u32, u32); 8]) -> SpellDisplay {
SpellDisplay {
totems,
@@ -1024,7 +1030,7 @@ mod totem_reagent_tests {
/// `IsActivePlayer` gate) — the cast then goes out for the server to judge.
#[test]
fn missing_materials_refuse_with_the_refs_reasons() {
let items = crate::items::Items::default();
let mut objs = objects();
let st = store();
let mining = spell([2901, 0], [(0, 0); 8]);
let mut errors = CastErrors::default();
@@ -1032,7 +1038,7 @@ mod totem_reagent_tests {
2575,
Some(&mining),
Some(&st),
&items,
&objs.get(),
&mut errors
));
assert_eq!(errors.0.as_slice(), &[CastFail::local(2575, 0x78)]);
@@ -1045,7 +1051,7 @@ mod totem_reagent_tests {
130,
Some(&slow_fall),
Some(&st),
&items,
&objs.get(),
&mut errors
));
assert_eq!(errors.0.as_slice(), &[CastFail::local(130, 0x5c)]);
@@ -1056,7 +1062,7 @@ mod totem_reagent_tests {
1,
Some(&both),
Some(&st),
&items,
&objs.get(),
&mut errors
));
assert_eq!(errors.0.as_slice(), &[CastFail::local(1, 0x78)]);
@@ -1067,21 +1073,21 @@ mod totem_reagent_tests {
133,
Some(&plain),
Some(&st),
&items,
&objs.get(),
&mut errors
));
assert!(!reagent_totem_refusal(
2575,
None,
Some(&st),
&items,
&objs.get(),
&mut errors
));
assert!(!reagent_totem_refusal(
2575,
Some(&mining),
None,
&items,
&objs.get(),
&mut errors
));
assert!(errors.0.is_empty());
@@ -1091,15 +1097,15 @@ mod totem_reagent_tests {
/// reagent (against empty bags, the first nonzero of each).
#[test]
fn first_failing_slot_is_named() {
let items = crate::items::Items::default();
let mut objs = objects();
let st = store();
let mut reagents = [(0, 0); 8];
reagents[1] = (17056, 1);
let d = spell([0, 7005], reagents);
assert_eq!(first_missing_totem(&d, &st, &items), Some(7005));
assert_eq!(first_short_reagent(&d, &st, &items), Some(17056));
assert_eq!(first_missing_totem(&d, &st, &objs.get()), Some(7005));
assert_eq!(first_short_reagent(&d, &st, &objs.get()), Some(17056));
let none = spell([0, 0], [(0, 0); 8]);
assert_eq!(first_missing_totem(&none, &st, &items), None);
assert_eq!(first_short_reagent(&none, &st, &items), None);
assert_eq!(first_missing_totem(&none, &st, &objs.get()), None);
assert_eq!(first_short_reagent(&none, &st, &objs.get()), None);
}
}
+16 -7
View File
@@ -110,10 +110,15 @@ pub(super) fn feed_actions(
mut cast_errors: ResMut<CastErrors>,
mut mount_errors: ResMut<MountErrors>,
mut pet_tame_failures: ResMut<PetTameFailures>,
mut ui_error_keys: ResMut<UiErrorKeys>,
mut ui_error_texts: ResMut<UiErrorTexts>,
// The two client-local refusal queues as one param (the 16-SystemParam ceiling this
// signature already sits at): the by-KEY route (`DisplayError`) and the already-resolved
// TEXT route, both drained onto the same red line below.
ui_errors: (ResMut<UiErrorKeys>, ResMut<UiErrorTexts>),
spells: Option<Res<Spells>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// The inventory read (2334): every count, reagent and totem question below walks the bags
// through it, and the weapon-icon arms resolve the equipped instance here.
objects: crate::net::Objects,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
@@ -127,6 +132,7 @@ pub(super) fn feed_actions(
let Some(mut script) = script else {
return;
};
let (mut ui_error_keys, mut ui_error_texts) = ui_errors;
let memory = memory.get(&script);
// Rejected casts surface as the client's red error line (UI_ERROR_MESSAGE → the errors
@@ -230,12 +236,12 @@ pub(super) fn feed_actions(
let d = d?;
let failing = if reason == 0x78 {
self_store
.and_then(|s| first_missing_totem(d, s, &items))
.and_then(|s| first_missing_totem(d, s, &objects))
// No store to test against (a race): name the first tool at all.
.or_else(|| d.totems.iter().copied().find(|&t| t != 0))
} else {
self_store
.and_then(|s| first_short_reagent(d, s, &items))
.and_then(|s| first_short_reagent(d, s, &objects))
.or_else(|| d.reagents.iter().map(|&(id, _)| id).find(|&id| id != 0))
}?;
let cached = items
@@ -490,6 +496,7 @@ pub(super) fn feed_actions(
d,
sp,
store,
&objects,
&items,
icons.as_deref(),
&commands,
@@ -512,7 +519,7 @@ pub(super) fn feed_actions(
.and_then(|t| icons.as_ref()?.catalog.get(t.display_info_id)?.icon.clone())
.unwrap_or_else(|| MISSING_ITEM_ICON.to_string());
let count = store
.map(|s| count_of(&s.0, &items, button.action, InventoryScope::CARRIED))
.map(|s| count_of(&s.0, &objects, button.action, InventoryScope::CARRIED))
.unwrap_or(0);
// The Count fontstring's gate — `IsConsumableAction 0x4e5250`: ammo/thrown by
// InventoryType, or an ON_USE block with NEGATIVE charges
@@ -593,7 +600,7 @@ pub(super) fn feed_actions(
for (&action, slot) in memory.pushed.iter_mut() {
let changed = match slot.kind {
ACTION_KIND_ITEM => {
let fresh = count_of(&store.0, &items, slot.action, InventoryScope::CARRIED);
let fresh = count_of(&store.0, &objects, slot.action, InventoryScope::CARRIED);
let changed = fresh != slot.count;
if changed {
slot.count = fresh;
@@ -618,6 +625,7 @@ pub(super) fn feed_actions(
d,
sp,
Some(store),
&objects,
&items,
icons.as_deref(),
&commands,
@@ -668,11 +676,12 @@ fn spell_action_icon(
d: &benilla_formats::SpellDisplay,
spells: &super::Spells,
store: Option<&crate::net::ObjectStore>,
objects: &crate::net::Objects,
items: &Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
auto_attack_icon(d, store, &spells.forms, items, icons, commands)
auto_attack_icon(d, store, &spells.forms, objects, items, icons, commands)
.or_else(|| {
store
.filter(|s| super::toggle::active_action_toggle(spell_id, d, s))
@@ -58,6 +58,7 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver<ClientCommand
app.insert_resource(actions)
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<CastErrors>()
.init_resource::<MountErrors>()
.init_resource::<PetTameFailures>()
@@ -310,6 +311,7 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() {
actions.dirty = true;
app.insert_resource(actions)
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<CastErrors>()
.init_resource::<MountErrors>()
.init_resource::<PetTameFailures>()
@@ -523,6 +525,7 @@ fn a_pets_refused_cast_writes_no_combat_log_line() {
..Spells::empty_for_tests()
})
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<MountErrors>()
.init_resource::<PetTameFailures>()
.init_resource::<UiErrorKeys>()
+28 -14
View File
@@ -34,7 +34,7 @@ use benilla_ui::script::{ActionState, UiScript};
use crate::creature_anim::{Casting, Engaged};
use crate::items::Items;
use crate::net::{GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::spell::Cooldowns;
use crate::target::Selection;
@@ -124,7 +124,10 @@ pub(super) fn feed_action_state(
),
self_q: Query<(&ObjectStore, &Transform, Has<Engaged>, Option<&Casting>), With<SelfPlayer>>,
selection: Res<Selection>,
index: Res<GuidIndex>,
// The object lookup (2334) — the guid index this system already resolved its selection
// through, plus the item-store read the ITEM arms need. One param, not two: this signature
// sits at Bevy's 16-SystemParam ceiling.
objects: crate::net::Objects,
units: Query<(&ObjectStore, &Transform), Without<SelfPlayer>>,
factions: Option<Res<crate::target::Factions>>,
reputations: Res<crate::net::Reputations>,
@@ -159,7 +162,7 @@ pub(super) fn feed_action_state(
// reads this table. It used to be one whole walk per question — per reagent per spell slot,
// per item slot — for the same bags each time (1697 item 13).
let carried = me
.map(|(s, _, _, _)| carried_counts(&s.0, &items))
.map(|(s, _, _, _)| carried_counts(&s.0, &objects))
.unwrap_or_default();
let engaged = me.is_some_and(|(_, _, e, _)| e);
let form_byte = me
@@ -172,8 +175,8 @@ pub(super) fn feed_action_state(
// The current target's reach + squared distance (the client tests dx²+dy²+dz² — 0x6e47b0).
let target = selection
.guid
.and_then(|g| index.0.get(&g))
.and_then(|&e| units.get(e).ok());
.and_then(|g| objects.entity(g))
.and_then(|e| units.get(e).ok());
let target_reach = target.map(|(s, _)| s.0.unit_combat_reach());
let dist_sq = match (self_pos, target) {
(Some(a), Some((_, t))) => Some(a.distance_squared(t.translation)),
@@ -252,8 +255,15 @@ pub(super) fn feed_action_state(
carried: &carried,
spell_mods,
};
let (u, oom) =
usable::spell_usable(button.action, d, sp, &ctx, &items, &commands);
let (u, oom) = usable::spell_usable(
button.action,
d,
sp,
&ctx,
&objects,
&items,
&commands,
);
st.usable = u;
st.not_enough_mana = oom;
} else {
@@ -298,7 +308,7 @@ pub(super) fn feed_action_state(
st.equipped = me.is_some_and(|(s, _, _, _)| {
(0..19).any(|i| {
s.0.player_inv_slot(i)
.and_then(|g| items.object(g))
.and_then(|g| objects.object(g))
.and_then(|o| o.object_entry())
== Some(button.action)
})
@@ -324,6 +334,7 @@ pub(super) fn feed_action_state(
count > 0 || st.equipped,
&ctx,
spells.as_deref(),
&objects,
&items,
&commands,
);
@@ -511,7 +522,7 @@ mod tests {
.init_resource::<crate::spell::ActiveChannel>()
.init_resource::<crate::spell::SpellTargeting>()
.init_resource::<Selection>()
.init_resource::<GuidIndex>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::net::Reputations>()
.init_resource::<Items>()
.insert_resource(NetCommands(tx));
@@ -575,10 +586,6 @@ mod tests {
},
);
let mut items = Items::default();
items.insert_object(
0xF0,
ObjectFields::from_pairs(&[(3, FOOD_ITEM), (STACK, 10)]),
);
items.insert_template(
FOOD_ITEM,
Some(benilla_protocol::messages::ItemInfo {
@@ -616,10 +623,17 @@ mod tests {
.init_resource::<crate::spell::ActiveChannel>()
.init_resource::<crate::spell::SpellTargeting>()
.init_resource::<Selection>()
.init_resource::<GuidIndex>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::net::Reputations>()
.insert_resource(items)
.insert_resource(NetCommands(tx));
// The ten muffins, as the one index's own item entity (2334).
crate::items::test_spawn_item(
app.world_mut(),
0xF0,
ObjectFields::from_pairs(&[(3, FOOD_ITEM), (STACK, 10)]),
false,
);
// The player: alive, with the ten muffins in backpack slot 1.
let flags = (1u32 << 3)
| if in_combat {
@@ -13,7 +13,7 @@ use benilla_formats::SpellDisplay;
use crate::creature_anim::UNIT_FLAG_DISARMED;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore};
use crate::net::{NetCommands, ObjectStore, Objects};
/// Equipment slot 15 = `EQUIPMENT_SLOT_MAINHAND` (vmangos `EquipmentSlots`).
const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
@@ -43,12 +43,13 @@ const ITEM_CLASS_WEAPON: u32 = 2;
/// item whose icon would otherwise be shown.
fn main_hand_item(
store: &ObjectStore,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<(u32, Option<String>)> {
let guid = store.0.player_inv_slot(EQUIPMENT_SLOT_MAINHAND)?;
let entry = items.object(guid)?.object_entry()?;
let entry = objects.object(guid)?.object_entry()?;
let template = items.template(entry, guid, commands)?;
let (class, display) = (template.class, template.display_info_id);
let icon = icons
@@ -73,6 +74,7 @@ fn main_hand_item(
pub(crate) fn melee_auto_attack_icon(
store: &ObjectStore,
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
@@ -86,7 +88,7 @@ pub(crate) fn melee_auto_attack_icon(
return icon;
}
}
let main = main_hand_item(store, items, icons, commands);
let main = main_hand_item(store, objects, items, icons, commands);
// Precedence step 2 — the **disarmed guard** (`0x4e68df`: `test dword ptr [ecx+0xa0],
// 0x200000`, then `GetWeapon(0, 1)` and a `== 2` on the returned class byte): while the
// character is disarmed, a weapon in the main hand shows `Spell-Reset` exactly as an empty
@@ -111,12 +113,13 @@ pub(crate) fn melee_auto_attack_icon(
/// return hands over to the normal SpellIconID path).
pub(crate) fn ranged_weapon_icon(
store: &ObjectStore,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
let guid = store.0.player_inv_slot(EQUIPMENT_SLOT_RANGED)?;
let entry = items.object(guid)?.object_entry()?;
let entry = objects.object(guid)?.object_entry()?;
let template = items.template(entry, guid, commands)?;
if template.subclass == ITEM_SUBCLASS_THROWN {
return None;
@@ -143,16 +146,19 @@ pub(super) fn auto_attack_icon(
spell: &SpellDisplay,
store: Option<&ObjectStore>,
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
let store = store?;
if spell.is_melee_auto_attack() {
return Some(melee_auto_attack_icon(store, forms, items, icons, commands));
return Some(melee_auto_attack_icon(
store, forms, objects, items, icons, commands,
));
}
if spell.ranged_icon_substitution() {
return ranged_weapon_icon(store, items, icons, commands);
return ranged_weapon_icon(store, objects, items, icons, commands);
}
None
}
@@ -187,8 +193,7 @@ mod tests {
let mut pairs = vec![(UNIT_FLAGS, flags), (UNIT_BYTES_1, u32::from(form) << 16)];
if let Some(class) = hand {
pairs.push((INV_SLOT_MAINHAND, 0x2a));
deps.items
.insert_object(0x2a, ObjectFields::from_pairs(&[(3, 500)]));
deps.spawn_item(0x2a, ObjectFields::from_pairs(&[(3, 500)]));
deps.items.insert_template(
500,
Some(ItemInfo {
@@ -216,7 +221,9 @@ mod tests {
..Default::default()
},
)]);
melee_auto_attack_icon(&store, &forms, &deps.items, Some(&icons), &deps.commands)
deps.with_objects(|objects, items, commands| {
melee_auto_attack_icon(&store, &forms, objects, items, Some(&icons), commands)
})
}
/// **The disarmed guard on the Attack button** (`0x4e68df`, decision 1863 closing 0231's
+3 -2
View File
@@ -54,7 +54,7 @@ use benilla_ui::script::{
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError};
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
@@ -775,6 +775,7 @@ fn drain_auction(
mut auction: ResMut<AuctionOpen>,
commands: Res<NetCommands>,
time: Res<Time>,
objects: Objects,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
names: Res<NameCache>,
@@ -910,7 +911,7 @@ fn drain_auction(
.auction_sell_item()
.and_then(|(bag, slot)| {
self_q.iter().next().and_then(|(store, _)| {
crate::ui_items::slot_guid(&store.0, bag, (slot.max(1) - 1) as u8, &items)
crate::ui_items::slot_guid(&store.0, bag, (slot.max(1) - 1) as u8, &objects)
})
})
.unwrap_or(0);
+7 -4
View File
@@ -59,6 +59,7 @@ use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript};
use crate::items::{Enchants, Items};
use crate::net::Objects;
/// `item_template.bonding` — the values the three arms compare against (vmangos
/// `ItemPrototype.h`'s `ItemBondingType`; the client reads the same field at `+0x194`).
@@ -169,17 +170,18 @@ impl BindGate<'_> {
pub(crate) fn equip_binds(
&self,
script: &UiScript,
objects: &Objects,
items: &Items,
commands: &crate::net::NetCommands,
item_guid: u64,
) -> bool {
let Some(fields) = items.object(item_guid) else {
let Some(fields) = objects.object(item_guid) else {
return false;
};
if crate::items::already_bound(fields, self.enchants.as_deref()) {
return false;
}
let Some(entry) = items.object(item_guid).and_then(|o| o.object_entry()) else {
let Some(entry) = objects.object(item_guid).and_then(|o| o.object_entry()) else {
return false;
};
let Some(t) = items.template(entry, item_guid, commands) else {
@@ -198,17 +200,18 @@ impl BindGate<'_> {
/// the fire site is reached the item has already passed it.
pub(crate) fn use_binds(
&self,
objects: &Objects,
items: &Items,
commands: &crate::net::NetCommands,
item_guid: u64,
) -> bool {
let Some(fields) = items.object(item_guid) else {
let Some(fields) = objects.object(item_guid) else {
return false;
};
if crate::items::already_bound(fields, self.enchants.as_deref()) {
return false;
}
let Some(entry) = items.object(item_guid).and_then(|o| o.object_entry()) else {
let Some(entry) = objects.object(item_guid).and_then(|o| o.object_entry()) else {
return false;
};
items
+95 -50
View File
@@ -51,7 +51,7 @@ use benilla_ui::strings::Arg;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::pending_item_ops::PendingItemOps;
use crate::portrait::PaperDollBooth;
use crate::ui_items::{find_equip_slot, item_link};
@@ -99,12 +99,14 @@ struct CharFeedMemo {
/// (decision 2140).
last_bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT],
/// The gate's counter memories (1439) — the stores whose lazy resolves poison `is_changed`
/// for this feed (the equipment templates' ask-once, the enchant-name creator lookups).
items_objects: gate::Watch,
/// for this feed (the equipment templates' ask-once, the enchant-name creator lookups). The
/// item OBJECTS are not among them since 2334: an instance is an entity, so its edges arrive
/// on the ordinary `Changed`/removal watch ([`crate::items::ItemChanges`]).
items_templates: gate::Watch,
names_generation: gate::Watch,
/// `Items::countdown_display_epoch` — one step per displayable countdown change (the slot
/// views read second-floored countdowns), including the last elapse's collapsing push.
/// `ItemChanges::countdown_steps` — one step per displayable countdown change (the slot
/// views read second-floored countdowns), including the last elapse's collapsing push; a
/// landing is the item's own change tick (decision 2340).
enchant_deadlines: gate::Watch,
/// `PendingItemOps::epoch` — one step per change to the in-flight lock set. Watched BESIDE
/// `!is_empty()` and not instead of it: `is_empty()` holds the gate open *while* an op is in
@@ -433,15 +435,21 @@ fn drain_skill_abandons(script: Option<NonSendMut<UiScript>>, commands: Res<NetC
/// Resolve one equipped slot's item template entry (`None` = empty slot, or the item object /
/// template hasn't streamed yet).
fn slot_entry(store: &ObjectStore, items: &Items, slot0: u8) -> Option<u32> {
fn slot_entry(store: &ObjectStore, objects: &Objects, slot0: u8) -> Option<u32> {
let guid = store.0.player_inv_slot(slot0)?;
items.object(guid)?.object_entry()
objects.object(guid)?.object_entry()
}
/// 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: &Items, commands: &NetCommands, slot0: u8) -> u32 {
let Some(entry) = slot_entry(store, items, slot0) else {
fn weapon_skill_id(
store: &ObjectStore,
objects: &Objects,
items: &Items,
commands: &NetCommands,
slot0: u8,
) -> u32 {
let Some(entry) = slot_entry(store, objects, slot0) else {
return SKILL_UNARMED;
};
let Some(t) = items.template(entry, 0, commands) else {
@@ -584,20 +592,25 @@ 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: &Items, commands: &NetCommands) -> UnitCombatStats {
fn combat_stats(
store: &ObjectStore,
objects: &Objects,
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).
let has_offhand = slot_entry(store, items, SLOT_OFF_HAND)
let has_offhand = slot_entry(store, objects, SLOT_OFF_HAND)
.and_then(|e| items.template(e, 0, commands))
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON);
let has_wand = slot_entry(store, items, SLOT_RANGED)
let has_wand = slot_entry(store, objects, SLOT_RANGED)
.and_then(|e| items.template(e, 0, commands))
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON && t.subclass == SUBCLASS_WAND);
let main_skill = weapon_skill_id(store, items, commands, SLOT_MAIN_HAND);
let offhand_skill = weapon_skill_id(store, items, commands, SLOT_OFF_HAND);
let ranged_skill_id = slot_entry(store, items, SLOT_RANGED)
let main_skill = weapon_skill_id(store, objects, items, commands, SLOT_MAIN_HAND);
let offhand_skill = weapon_skill_id(store, objects, items, commands, SLOT_OFF_HAND);
let ranged_skill_id = slot_entry(store, objects, SLOT_RANGED)
.and_then(|e| items.template(e, 0, commands))
.filter(|t| t.class == ITEM_CLASS_WEAPON)
.and_then(|t| weapon_subclass_skill(t.subclass));
@@ -638,6 +651,7 @@ fn combat_stats(store: &ObjectStore, items: &Items, commands: &NetCommands) -> U
/// 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(
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
@@ -653,13 +667,12 @@ fn slot_view(
guid: u64,
live_id: u32,
) -> Option<InvSlotView> {
// The temp-enchant countdowns, read before the object borrow (both live on `Items`) — the bag
// feed's twin.
let enchant_ms: [Option<u64>; 7] =
std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32));
// The item's own expiry countdown — the bag feed's twin (decision 1933).
let duration_ms = items.duration_remaining_display_ms(guid);
let obj = items.object(guid)?;
// The item's own countdown cells — the bag feed's twin (decisions 1933, 2340).
let countdowns = objects.countdowns(guid);
let enchant_ms: [Option<u64>; crate::items::ENCHANT_SLOTS] =
std::array::from_fn(|s| countdowns.and_then(|c| c.enchant_remaining_display_ms(s as u32)));
let duration_ms = countdowns.and_then(|c| c.lifetime_remaining_display_ms());
let obj = objects.object(guid)?;
let entry = obj.object_entry()?;
let count = obj.item_stack_count().unwrap_or(1).max(1);
// `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER` — the fork `GetInventoryItemCount` takes first
@@ -739,7 +752,7 @@ fn slot_view(
if counts_contents {
content_guids
.iter()
.filter_map(|&g| items.object(g))
.filter_map(|&g| objects.object(g))
.map(|o| o.item_stack_count().unwrap_or(1).max(1))
.sum()
} else {
@@ -772,10 +785,10 @@ fn slot_view(
/// Total carried ammo matching `ammo_id`: the backpack's 16 slots + the four equipped bags'
/// contents (`GetInventoryItemCount("player", ammo)` counts what you can actually shoot — bank
/// and buyback never join).
fn ammo_count(store: &ObjectStore, items: &Items, ammo_id: u32) -> u32 {
fn ammo_count(store: &ObjectStore, objects: &Objects, ammo_id: u32) -> u32 {
let mut n = 0;
let mut add = |guid: Option<u64>| {
if let Some(o) = guid.and_then(|g| items.object(g)) {
if let Some(o) = guid.and_then(|g| objects.object(g)) {
if o.object_entry() == Some(ammo_id) {
n += o.item_stack_count().unwrap_or(1);
}
@@ -785,10 +798,10 @@ fn ammo_count(store: &ObjectStore, items: &Items, ammo_id: u32) -> u32 {
add(store.0.player_pack_slot(i));
}
for b in 19..23u8 {
if let Some(bag) = store.0.player_inv_slot(b).and_then(|g| items.object(g)) {
if let Some(bag) = store.0.player_inv_slot(b).and_then(|g| objects.object(g)) {
let slots = bag.container_num_slots().unwrap_or(0).min(36) as u8;
for s in 0..slots {
if let Some(o) = bag.container_slot(s).and_then(|g| items.object(g)) {
if let Some(o) = bag.container_slot(s).and_then(|g| objects.object(g)) {
if o.object_entry() == Some(ammo_id) {
n += o.item_stack_count().unwrap_or(1);
}
@@ -805,6 +818,7 @@ fn ammo_count(store: &ObjectStore, items: &Items, ammo_id: u32) -> u32 {
/// 0-based inv-slot array.
fn inventory_slots(
store: &ObjectStore,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
@@ -844,7 +858,7 @@ fn inventory_slots(
already_bound: false,
item_id: ammo_id,
icon,
count: ammo_count(store, items, ammo_id),
count: ammo_count(store, objects, ammo_id),
// Ammo is not a container — slot 0 is `GetInventoryItemCount`'s own `PLAYER_AMMO_ID`
// leg, which never reaches the TYPEMASK fork.
contents_count: None,
@@ -875,6 +889,7 @@ fn inventory_slots(
continue;
};
inv[live as usize] = slot_view(
objects,
items,
icons,
rolls,
@@ -900,6 +915,7 @@ fn inventory_slots(
/// doll snapshot, rather than the `BankState` the bank window pushes.
fn bank_bag_slots(
store: &ObjectStore,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
@@ -914,6 +930,7 @@ fn bank_bag_slots(
continue;
};
*bag = slot_view(
objects,
items,
icons,
rolls,
@@ -1044,8 +1061,9 @@ pub(crate) fn feed_char(
// (1803). Absent client data reads every class as an ordinary ranged wielder.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
// The inventory read (2334): the self store, its change tick, the object lookup and the item
// entities' change watch.
mut inv: crate::items::Inventory,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
// `SpellItemEnchantment`'s name column — the equipped tooltip's enchant lines (decision 0915).
@@ -1078,7 +1096,7 @@ pub(crate) fn feed_char(
// snapshots below re-push and their events re-fire for it.
let (memo, vm_reset) = feed.vm.get_reset(&script);
let Some(store) = self_q.iter().next() else {
let Some(store) = inv.self_store.iter().next() else {
// The self store's absence is NO DATA, never "the player has nothing equipped". The one
// window this branch runs with a previously-fed VM is the logout despawn frames —
// `SMSG_LOGOUT_COMPLETE` despawns the entity a full Update before `OnExit(InWorld)` runs
@@ -1099,16 +1117,18 @@ pub(crate) fn feed_char(
// `UNIT_INVENTORY_CHANGED` on every tick. The reference recomputes it per call for exactly
// the same reason (`0x5d9d00`). Hoisted above the gate (it used to sit last): the push is
// order-free of the two snapshots, and a handler they fire reads the fresher value this way.
// Bound before the snapshot build below shadows `inv` with the pushed slot array.
let objects = &inv.objects;
script.set_weapon_enchants(
weapon_enchant(store, &items, EQUIPMENT_SLOT_MAINHAND),
weapon_enchant(store, &items, EQUIPMENT_SLOT_OFFHAND),
weapon_enchant(store, objects, EQUIPMENT_SLOT_MAINHAND),
weapon_enchant(store, objects, EQUIPMENT_SLOT_OFFHAND),
);
// The gate (1439), around the two snapshot builds only: the self descriptor (equipment
// slots, every stat field), the item stores behind the equipped templates and enchant
// names, the two catalogs, and the pending-op locks (held open while in flight; the
// resolving frame's own field update moves the object epoch).
let objects_moved = memo.items_objects.moved(items.object_epoch());
let objects_moved = inv.changes.moved();
let templates_moved = memo.items_templates.moved(items.template_epoch());
let names_moved = memo.names_generation.moved(names.generation());
// The DISPLAY epoch, not a per-frame hold-open: the snapshot reads second-floored countdowns
@@ -1116,10 +1136,8 @@ pub(crate) fn feed_char(
// second per ticking enchant, plus one final step at the elapse. Holding the gate open on
// `live_enchant_deadlines() > 0` (the first 1439 shape) rebuilt both snapshots and fired
// `UNIT_INVENTORY_CHANGED` at frame rate for the whole life of a poison.
let deadlines_moved = memo
.enchant_deadlines
.moved(items.countdown_display_epoch());
let self_changed = !changed_self.is_empty();
let deadlines_moved = memo.enchant_deadlines.moved(inv.changes.countdown_steps());
let self_changed = !inv.self_changed.is_empty();
// `is_added` for the icon catalog: the feeds read only its load-once icon column;
// its model-cache half churns every frame (the containers gate's own note).
let icons_added = icons.as_ref().is_some_and(|r| r.is_added());
@@ -1159,7 +1177,7 @@ pub(crate) fn feed_char(
return;
}
let stats = combat_stats(store, &items, &commands);
let stats = combat_stats(store, objects, &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
@@ -1173,6 +1191,7 @@ pub(crate) fn feed_char(
let inv = inventory_slots(
store,
objects,
&items,
icons.as_deref(),
crate::items::RollCatalogs {
@@ -1187,6 +1206,7 @@ pub(crate) fn feed_char(
);
let bank_bags = bank_bag_slots(
store,
objects,
&items,
icons.as_deref(),
crate::items::RollCatalogs {
@@ -1301,14 +1321,15 @@ const TEMP_ENCHANTMENT_SLOT: u8 = 1;
/// else, and `item_enchant` already answers `None` for a zero id.
fn weapon_enchant(
store: &ObjectStore,
items: &Items,
objects: &Objects,
slot0: u8,
) -> Option<benilla_ui::script::WeaponEnchant> {
let guid = store.0.player_inv_slot(slot0)?;
// Before the object borrow — the deadlines and the objects both live on `Items`. The
// *deadline* read, not the tooltip's: an elapsed timer must answer 0, not "no timer".
let remaining_ms = items.enchant_deadline_ms(guid, u32::from(TEMP_ENCHANTMENT_SLOT));
let obj = items.object(guid)?;
// The *deadline* read, not the tooltip's: an elapsed timer must answer 0, not "no timer".
let remaining_ms = objects
.countdowns(guid)
.and_then(|c| c.enchant_deadline_ms(u32::from(TEMP_ENCHANTMENT_SLOT)));
let obj = objects.object(guid)?;
obj.item_enchant(TEMP_ENCHANTMENT_SLOT)?;
Some(benilla_ui::script::WeaponEnchant {
remaining_ms,
@@ -1383,6 +1404,20 @@ mod tests {
const BAG_ENTRY: u32 = 4500;
const BAG: u64 = 0x4000_0000_0000_0abc;
/// Re-stream one live item object's fields — the wire's values delta, which is what moves
/// the item entity's own change tick (2334); the create-time spawn is
/// [`crate::items::test_spawn_item`].
fn restream(app: &mut App, guid: u64, fields: ObjectFields) {
let entity = app
.world()
.resource::<crate::net::GuidIndex>()
.0
.get(&guid)
.copied()
.expect("the item was streamed");
*app.world_mut().get_mut::<ObjectStore>(entity).unwrap() = ObjectStore(fields);
}
/// A world with the player carrying `bag` (or nothing) in the FIRST bank bag slot, and a
/// recorder frame registered for the two events the reference's bank bag buttons repaint on.
fn world_with_bank_bag(bag: Option<u64>) -> App {
@@ -1392,6 +1427,7 @@ mod tests {
.init_resource::<PaperDollBooth>()
.init_resource::<PendingItemOps>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::names::NameCache>()
.insert_resource(NetCommands(tx));
let fields = match bag {
@@ -1403,9 +1439,14 @@ mod tests {
};
app.world_mut().spawn((SelfPlayer, ObjectStore(fields)));
if let Some(g) = bag {
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_object(g, ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]));
items.insert_template(
// The bag is an object in the one index (2334), its template still on `Items`.
crate::items::test_spawn_item(
app.world_mut(),
g,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]),
true,
);
app.world_mut().resource_mut::<Items>().insert_template(
BAG_ENTRY,
Some(crate::items::test_template("Traveler's Backpack")),
);
@@ -1467,8 +1508,10 @@ mod tests {
app.world_mut().run_system_once(feed_char).unwrap();
assert!(seen(&mut app).contains(&"PLAYERBANKSLOTS_CHANGED nil".to_string()));
// The SAME bag guid, restacked. Only the item object moved.
app.world_mut().resource_mut::<Items>().insert_object(
// The SAME bag guid, restacked. Only the item object moved — a values update on the
// live item entity, which is what the wire's own delta is (2334).
restream(
&mut app,
BAG,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY), (F_ITEM_STACK_COUNT, 3)]),
);
@@ -1504,9 +1547,11 @@ mod tests {
]));
};
set_slots(&mut app, BAG, BAG2);
app.world_mut().resource_mut::<Items>().insert_object(
crate::items::test_spawn_item(
app.world_mut(),
BAG2,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]),
true,
);
app.world_mut().run_system_once(feed_char).unwrap();
let _ = seen(&mut app);
+4 -3
View File
@@ -28,7 +28,7 @@ use benilla_ui::script::{CraftReagent, CraftRecipe, CraftState, CraftTooltip, Ui
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::spell::{cast_target, CastCommit, CastLadder};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_items::{count_of, item_icon, InventoryScope};
@@ -138,6 +138,7 @@ fn feed_craft(
focus: Option<Res<SpellFocus>>,
icons: Option<Res<ItemDisplays>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
objects: Objects,
items: Res<Items>,
commands: Res<NetCommands>,
mut last: Local<crate::ui_script::VmMemo<Option<CraftState>>>,
@@ -192,7 +193,7 @@ fn feed_craft(
let mut reagents = Vec::new();
let mut num_available = u32::MAX;
for &(entry, need) in d.reagents.iter().filter(|&&(e, n)| e != 0 && n != 0) {
let have = count_of(&store.0, &items, entry, InventoryScope::CARRIED);
let have = count_of(&store.0, &objects, entry, InventoryScope::CARRIED);
// A reagent is an ITEM row, so it terminates in the one genuinely shared chain
// (wow-re §5): ItemTemplate → ItemDisplayInfo → icon. Unlike the *recipe* icon
// above, there is nothing per-binding about this one.
@@ -230,7 +231,7 @@ fn feed_craft(
}
}
for &t in d.totems.iter().filter(|&&t| t != 0) {
let have = count_of(&store.0, &items, t, InventoryScope::CARRIED) > 0;
let have = count_of(&store.0, &objects, t, InventoryScope::CARRIED) > 0;
if let Some(info) = items.template(t, 0, &commands) {
tools.push((info.name.clone(), have));
}
+38 -8
View File
@@ -35,7 +35,7 @@ use benilla_ui::script::{ItemTextState, UiScript};
use crate::go_templates::GameObjectTemplates;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::net::{ClientCommand, NetCommands, Objects};
use crate::query_cache::QueryCache;
use crate::ui_mail::MailOpen;
use crate::ui_script::{UiFeed, UiInput};
@@ -245,6 +245,7 @@ struct Readable {
/// is FrameXML's "Parchment". A GameObject whose template is not cached yet is asked for.
pub(crate) fn object_material(
guid: u64,
objects: &Objects,
items: &Items,
go_templates: &GameObjectTemplates,
materials: Option<&PageMaterials>,
@@ -260,7 +261,7 @@ pub(crate) fn object_material(
}
};
}
let entry = items.object(guid)?.object_entry()?;
let entry = objects.object(guid)?.object_entry()?;
let t = items.template(entry, guid, commands)?;
name(t.page_material)
}
@@ -270,6 +271,7 @@ pub(crate) fn object_material(
/// single resolve serves both sources.
fn readable(
guid: u64,
objects: &Objects,
items: &Items,
go_templates: &GameObjectTemplates,
materials: Option<&PageMaterials>,
@@ -283,7 +285,7 @@ fn readable(
page_head: go.text_page.map_or(0, |p| p.page_id),
});
}
let entry = items.object(guid)?.object_entry()?;
let entry = objects.object(guid)?.object_entry()?;
let t = items.template(entry, guid, commands)?;
Some(Readable {
title: t.name.clone(),
@@ -303,6 +305,7 @@ fn feed_item_text(
mut open: ResMut<ItemTextOpen>,
mail: Res<MailOpen>,
pages: Res<PageTexts>,
objects: Objects,
items: Res<Items>,
names: Res<NameCache>,
go_templates: Res<GameObjectTemplates>,
@@ -329,6 +332,7 @@ fn feed_item_text(
}
let Some(readable) = readable(
sess.object_guid,
&objects,
&items,
&go_templates,
materials.as_deref(),
@@ -365,7 +369,7 @@ fn feed_item_text(
ReadSource::Letter { text_id } => {
// The creator line: `ITEM_FIELD_CREATOR` → name cache (ask-once). `None` guid =
// authorless.
let creator_guid = items
let creator_guid = objects
.object(sess.object_guid)
.and_then(|o| o.item_creator());
let creator = match creator_guid {
@@ -572,6 +576,9 @@ mod quest_material_tests {
#[test]
fn a_quest_sourced_from_an_object_paints_the_objects_material() {
let items = Items::default();
// No item source in this case — every one of these is a GameObject or a creature (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let mut gos = GameObjectTemplates::default();
let (tx, rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
@@ -586,24 +593,47 @@ 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), &items, &gos, Some(&catalog), &commands).as_deref(),
object_material(
go_guid(100),
&objects,
&items,
&gos,
Some(&catalog),
&commands
)
.as_deref(),
Some("Stone")
);
assert_eq!(
object_material(go_guid(102), &items, &gos, Some(&catalog), &commands).as_deref(),
object_material(
go_guid(102),
&objects,
&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, &items, &gos, Some(&catalog), &commands),
object_material(creature, &objects, &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), &items, &gos, Some(&catalog), &commands),
object_material(
go_guid(777),
&objects,
&items,
&gos,
Some(&catalog),
&commands
),
None
);
assert!(
+49 -27
View File
@@ -9,7 +9,7 @@ use benilla_protocol::messages::BAG_PLAYER_INVENTORY;
use benilla_ui::script::{UiScript, EQUIPMENT_BAG};
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::pending_item_ops::PendingItemOps;
use super::{slot_guid, slot_guid_count, wire_pos, INVTYPE_AMMO};
@@ -33,6 +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,
objects: &Objects,
items: &Items,
commands: &NetCommands,
bag_index: u8,
@@ -42,7 +43,7 @@ pub(crate) fn send_auto_equip(
) -> bool {
if !suppress {
if let Some(guid) = guid {
if gate.equip_binds(script, items, commands, guid) {
if gate.equip_binds(script, objects, items, commands, guid) {
gate.defer_equip(
script,
crate::ui_bind_confirm::PendingEquip::AutoEquip {
@@ -56,7 +57,7 @@ pub(crate) fn send_auto_equip(
}
}
let ammo_entry = guid.and_then(|guid| {
let entry = items.object(guid)?.object_entry()?;
let entry = objects.object(guid)?.object_entry()?;
let t = items.template(entry, guid, commands)?;
(t.inventory_type == INVTYPE_AMMO).then_some(entry)
});
@@ -90,6 +91,7 @@ pub(crate) fn send_auto_equip(
pub(super) fn drain_container_autoequips(
script: Option<NonSendMut<UiScript>>,
items: Res<Items>,
objects: Objects,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
mut gate: crate::ui_bind_confirm::BindGate,
@@ -109,10 +111,11 @@ pub(super) fn drain_container_autoequips(
let guid = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0, &items));
.and_then(|store| slot_guid(&store.0, bag, slot0, &objects));
send_auto_equip(
&mut script,
&mut gate,
&objects,
&items,
&commands,
bag_index,
@@ -216,9 +219,9 @@ pub(super) fn drain_inventory_uses(
let (guid, start_quest, spell_index, use_spell, entry, is_charter) = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot, &ladder.items))
.and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot, &ladder.objects))
.and_then(|guid| {
let entry = ladder.items.object(guid)?.object_entry()?;
let entry = ladder.objects.object(guid)?.object_entry()?;
let t = ladder.items.template(entry, guid, &ladder.commands)?;
// The wire's spell byte is a template BLOCK ordinal (decision 0666) — the
// template is already in hand here for `start_quest`, so name the real one.
@@ -286,7 +289,7 @@ pub(super) fn drain_container_uses(
let item_guid = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0, &ladder.items));
.and_then(|store| slot_guid(&store.0, bag, slot0, &ladder.objects));
match item_guid {
Some(guid) => {
debug!("ui_items: repair lua bag {bag} slot {slot} (item {guid:#x})");
@@ -320,7 +323,7 @@ pub(super) fn drain_container_uses(
let item_guid = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &ladder.items));
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &ladder.objects));
match item_guid {
Some(guid) => {
debug!("ui_items: sell lua bag {bag} slot {slot} (item {guid:#x})");
@@ -370,9 +373,9 @@ pub(super) fn drain_container_uses(
let clicked = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &ladder.items))
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &ladder.objects))
.and_then(|guid| {
let obj = ladder.items.object(guid)?;
let obj = ladder.objects.object(guid)?;
let inst_flags = obj.item_flags().unwrap_or(0);
let item_text_id = obj.item_text_id().unwrap_or(0);
let entry = obj.object_entry()?;
@@ -417,6 +420,7 @@ pub(super) fn drain_container_uses(
if send_auto_equip(
&mut script,
&mut gate,
&ladder.objects,
&ladder.items,
&ladder.commands,
bag_index,
@@ -569,7 +573,7 @@ pub(super) fn drain_container_uses(
let (guid, count) = self_q
.iter()
.next()
.map(|store| slot_guid_count(Some(store), bag, slot, &ladder.items))
.map(|store| slot_guid_count(Some(store), bag, slot, &ladder.objects))
.unwrap_or((0, 0));
pending_items.add([(bag, slot, guid, count)]);
script.fire_event("ITEM_LOCK_CHANGED", Vec::new());
@@ -662,6 +666,7 @@ pub(super) fn drain_container_moves(
script: Option<NonSendMut<UiScript>>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
objects: Objects,
items: Res<Items>,
mut pending: ResMut<PendingItemOps>,
mut gate: crate::ui_bind_confirm::BindGate,
@@ -699,6 +704,7 @@ pub(super) fn drain_container_moves(
send_container_move(
&mut script,
&mut gate,
&objects,
&items,
&commands,
store,
@@ -727,6 +733,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,
objects: &Objects,
items: &Items,
commands: &NetCommands,
store: Option<&ObjectStore>,
@@ -758,9 +765,9 @@ pub(crate) fn send_container_move(
(mv.dst_bag, mv.dst_slot)
};
let slot0 = u8::try_from(item_slot.saturating_sub(1)).unwrap_or(0);
let guid = store.and_then(|s| slot_guid(&s.0, item_bag, slot0, items));
let guid = store.and_then(|s| slot_guid(&s.0, item_bag, slot0, objects));
if let Some(guid) = guid {
if gate.equip_binds(script, items, commands, guid) {
if gate.equip_binds(script, objects, items, commands, guid) {
gate.defer_equip(
script,
crate::ui_bind_confirm::PendingEquip::Swap {
@@ -819,8 +826,8 @@ pub(crate) fn send_container_move(
// The pending lock: both ends, baselined on their CURRENT (guid, count) — the resolving
// clear then watches for either to move (an empty destination baselines (0, 0) and watches
// for an item to land there).
let (src_guid, src_count) = slot_guid_count(store, mv.src_bag, mv.src_slot, items);
let (dst_guid, dst_count) = slot_guid_count(store, mv.dst_bag, mv.dst_slot, items);
let (src_guid, src_count) = slot_guid_count(store, mv.src_bag, mv.src_slot, objects);
let (dst_guid, dst_count) = slot_guid_count(store, mv.dst_bag, mv.dst_slot, objects);
pending.add([
(mv.src_bag, mv.src_slot, src_guid, src_count),
(mv.dst_bag, mv.dst_slot, dst_guid, dst_count),
@@ -845,7 +852,7 @@ pub(super) fn drain_container_destroys(
script: Option<NonSendMut<UiScript>>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
items: Res<Items>,
objects: Objects,
mut pending: ResMut<PendingItemOps>,
) {
let Some(mut script) = script else {
@@ -864,7 +871,7 @@ pub(super) fn drain_container_destroys(
slot: wire_slot,
count,
});
let (guid, stack) = slot_guid_count(store, bag, slot, &items);
let (guid, stack) = slot_guid_count(store, bag, slot, &objects);
pending.add([(bag, slot, guid, stack)]);
script.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
@@ -913,6 +920,7 @@ mod tests {
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
// The player, holding the clam in backpack slot 1.
@@ -923,13 +931,15 @@ mod tests {
(F_PACK_SLOT_1 + 1, (CLAM >> 32) as u32),
])),
));
// The item object and its landed template — LOOTABLE, so the dispatcher's open arm claims
// the click (`ItemInfo::opens_loot`).
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_object(
// The item object (an entity in the one index, 2334) and its landed template —
// LOOTABLE, so the dispatcher's open arm claims the click (`ItemInfo::opens_loot`).
crate::items::test_spawn_item(
app.world_mut(),
CLAM,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, CLAM_ENTRY)]),
false,
);
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_template(
CLAM_ENTRY,
Some(ItemInfo {
@@ -1041,6 +1051,7 @@ pub(super) fn drain_bind_confirm_answers(
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut pending: ResMut<PendingItemOps>,
objects: Objects,
items: Res<Items>,
commands: Res<NetCommands>,
mut gate: crate::ui_bind_confirm::BindGate,
@@ -1073,6 +1084,7 @@ pub(super) fn drain_bind_confirm_answers(
send_container_move(
&mut script,
&mut gate,
&objects,
&items,
&commands,
store,
@@ -1095,6 +1107,7 @@ pub(super) fn drain_bind_confirm_answers(
send_auto_equip(
&mut script,
&mut gate,
&objects,
&items,
&commands,
bag_index,
@@ -1235,6 +1248,7 @@ mod bind_confirm_tests {
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
app.world_mut().spawn((
@@ -1244,14 +1258,16 @@ mod bind_confirm_tests {
(F_PACK_SLOT_1 + 1, (AXE_GUID >> 32) as u32),
])),
));
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_object(
crate::items::test_spawn_item(
app.world_mut(),
AXE_GUID,
ObjectFields::from_pairs(&[
(F_OBJECT_ENTRY, FLURRY_AXE),
(F_ITEM_FLAGS, u32::from(already_bound)),
]),
false,
);
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_template(
FLURRY_AXE,
Some(ItemInfo {
@@ -1501,6 +1517,7 @@ mod bind_confirm_tests {
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
app.world_mut().spawn((
SelfPlayer,
@@ -1509,11 +1526,13 @@ mod bind_confirm_tests {
(F_PACK_SLOT_1 + 1, (AXE_GUID >> 32) as u32),
])),
));
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_object(
crate::items::test_spawn_item(
app.world_mut(),
AXE_GUID,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, FLURRY_AXE)]),
false,
);
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_template(
FLURRY_AXE,
Some(ItemInfo {
@@ -1662,6 +1681,7 @@ mod bind_confirm_tests {
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
app.world_mut().spawn((
SelfPlayer,
@@ -1670,11 +1690,13 @@ mod bind_confirm_tests {
(F_PACK_SLOT_1 + 1, (AXE_GUID >> 32) as u32),
])),
));
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_object(
crate::items::test_spawn_item(
app.world_mut(),
AXE_GUID,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, FLURRY_AXE)]),
false,
);
let mut items = app.world_mut().resource_mut::<Items>();
let mut template = crate::items::test_template("A Bind-On-Use Thing");
template.bonding = 3;
template.quality = 3;
+202 -47
View File
@@ -11,7 +11,7 @@ use benilla_ui::script::{ContainerSlot, ContainerState, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
use crate::ui_script::gate;
@@ -31,9 +31,10 @@ pub(crate) struct FeedMemory {
/// every frame. It is the gate the entire keyring UI hangs off, and "the keyring never
/// appeared" is otherwise indistinguishable from "the button is mis-anchored".
had_key: bool,
/// The gate's counter memories (1439) — the four stores whose lazy `&mut` resolves poison
/// `is_changed` for this feed, watched by their explicit counters instead.
items_objects: gate::Watch,
/// The gate's counter memories (1439) — the stores whose lazy `&mut` resolves poison
/// `is_changed` for this feed, watched by their explicit counters instead. The item OBJECTS
/// left this list with 2334: an instance is an entity, so its edges come off the ordinary
/// `Changed`/removal watch ([`crate::items::ItemChanges`]).
items_templates: gate::Watch,
cooldown_epoch: gate::Watch,
names_generation: gate::Watch,
@@ -41,8 +42,9 @@ pub(crate) struct FeedMemory {
/// what a charter slot's tooltip lines are built from. Lazy, so its arrival moves nothing else
/// here (the same reason `names_generation` exists one line up).
petition_records: gate::Watch,
/// `Items::countdown_display_epoch` — one step per displayable countdown change (the slot
/// views read second-floored countdowns), including the last elapse's collapsing push.
/// `ItemChanges::countdown_steps` — one step per displayable countdown change (the slot
/// views read second-floored countdowns), including the last elapse's collapsing push; a
/// landing is the item's own change tick (decision 2340).
enchant_deadlines: gate::Watch,
/// The item guid **of the bag itself** in each of the ten bag slots — container ids 1..=10
/// (the four equipped, then the six bank bags), indexed `id − 1`. Diffed frame to frame for
@@ -606,6 +608,7 @@ pub(super) fn feed_player_req(
/// exists before its query answers.
fn resolve_slot(
guid: u64,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
@@ -626,15 +629,15 @@ fn resolve_slot(
if guid == 0 {
return None;
}
// The temporary-enchant countdowns, read off the deadline store BEFORE the object borrow below
// (both live on `Items`): one `Option<ms>` per enchant slot.
let enchant_ms: [Option<u64>; 7] =
std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32));
// The item's own expiry countdown, read off the same deadline store and floored the same way
// — `SMSG_ITEM_TIME_UPDATE`'s only surface (decision 1933).
let duration_ms = items.duration_remaining_display_ms(guid);
// The item's own countdown cells (decision 2340): one `Option<ms>` per temporary-enchant
// slot, and its lifetime — `SMSG_ITEM_TIME_UPDATE`'s only surface (decision 1933) — both
// second-floored for the snapshot.
let countdowns = objects.countdowns(guid);
let enchant_ms: [Option<u64>; crate::items::ENCHANT_SLOTS] =
std::array::from_fn(|s| countdowns.and_then(|c| c.enchant_remaining_display_ms(s as u32)));
let duration_ms = countdowns.and_then(|c| c.lifetime_remaining_display_ms());
let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) =
match items.object(guid) {
match objects.object(guid) {
Some(fields) => (
fields.object_entry().unwrap_or(0),
fields.item_stack_count().unwrap_or(1),
@@ -716,7 +719,7 @@ fn resolve_slot(
// rule.
petition: (t.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0)
.then(|| {
let id = items
let id = objects
.object(guid)
.and_then(|f| f.item_enchant(0))
.unwrap_or(0);
@@ -782,6 +785,7 @@ fn resolve_slot(
fn bag_family_name(
player: Option<&ObjectStore>,
bag_slot: u8,
objects: &Objects,
items: &Items,
families: Option<&benilla_formats::ItemBagFamilyCatalog>,
commands: &NetCommands,
@@ -799,7 +803,7 @@ fn bag_family_name(
_ => None,
}
.filter(|&g| g != 0)?;
let entry = items.object(guid)?.object_entry().filter(|&e| e != 0)?;
let entry = objects.object(guid)?.object_entry().filter(|&e| e != 0)?;
let family = items.template(entry, guid, commands)?.bag_family;
families.name(family).map(str::to_string)
}
@@ -820,7 +824,7 @@ fn bag_family_name(
/// feeds because neither of them owns the clear any more.
pub(crate) fn resolve_item_locks(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
items: Res<Items>,
objects: Objects,
mut pending: ResMut<PendingItemOps>,
mut transitions: ResMut<LockTransitions>,
) {
@@ -833,7 +837,7 @@ pub(crate) fn resolve_item_locks(
}
transitions
.0
.extend(pending.resolve(|bag, slot1| slot_guid_count(player, bag, slot1, &items)));
.extend(pending.resolve(|bag, slot1| slot_guid_count(player, bag, slot1, &objects)));
}
#[allow(clippy::type_complexity)] // the param list IS the input set
@@ -850,12 +854,10 @@ pub(crate) fn feed_containers(
Option<Res<crate::items::Enchants>>,
Option<Res<crate::items::RandomProperties>>,
),
// The two self-descriptor legs in one param (the 16-SystemParam ceiling): the store the
// slot arrays read, and its change tick — the gate's cheapest input (1439).
self_q: (
Query<&ObjectStore, With<SelfPlayer>>,
Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
),
// The inventory read (2334): the self store the slot arrays come off and its change tick —
// the gate's cheapest input (1439) — the object lookup the guids resolve through, and the
// item entities' own change watch.
mut inv: crate::items::Inventory,
commands: Res<NetCommands>,
cooldowns: Res<crate::spell::Cooldowns>,
spells: Option<Res<crate::ui_action::Spells>>,
@@ -865,7 +867,9 @@ pub(crate) fn feed_containers(
// Reason 16's `%s` source (decision 0916); absent = every 16 keeps the generic line.
bag_families: Option<Res<crate::ui_items::ItemBagFamilies>>,
pending: Res<PendingItemOps>,
mut lock_cleared: ResMut<LockTransitions>,
// The two handler-fed queues this feed fires for (the 16-SystemParam ceiling): the lock
// transitions (`ITEM_LOCK_CHANGED`) and the server-opened containers (`BAG_OPEN`, 2339).
queues: (ResMut<LockTransitions>, ResMut<super::net::BagOpens>),
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
@@ -881,6 +885,7 @@ pub(crate) fn feed_containers(
};
let (clock, mut petitions) = clock_and_petitions;
let (mut equip_errors, mut sink) = equip;
let (mut lock_cleared, mut opens) = queues;
let (memory, vm_reset) = memory.get_reset(&script);
// The gate (1439): the snapshot below is a function of the self descriptor's slot arrays,
// the item stores (both epochs — `is_changed` on `Items`/`NameCache`/`Cooldowns` is
@@ -890,7 +895,7 @@ pub(crate) fn feed_containers(
// is deliberately NOT an input: the pushed cooldown triple carries the ABSOLUTE start
// (frame-stable, the memory struct's own doc), and natural expiry moves the store's
// `feed_epoch` through the prune.
let objects_moved = memory.items_objects.moved(items.object_epoch());
let objects_moved = inv.changes.moved();
let templates_moved = memory.items_templates.moved(items.template_epoch());
let cooldowns_moved = memory.cooldown_epoch.moved(cooldowns.feed_epoch());
let names_moved = memory.names_generation.moved(names.generation());
@@ -900,10 +905,10 @@ pub(crate) fn feed_containers(
// that rebuilt every bag snapshot for the whole life of a ticking enchant.
let deadlines_moved = memory
.enchant_deadlines
.moved(items.countdown_display_epoch());
.moved(inv.changes.countdown_steps());
// Bound as `let`s (not a bare OR-chain) so the gate trace below can name each input.
let sweep = cooldowns.sweep_pending(clock.anchor);
let self_changed = !self_q.1.is_empty();
let self_changed = !inv.self_changed.is_empty();
// `is_added`, NOT `is_changed`: the feeds read only the load-once icon CATALOG off this
// resource (its struct doc says so verbatim); the world's held-model cache half is
// get-or-insert every frame, so `is_changed` reads true forever — 1439's gate-trace found
@@ -915,7 +920,7 @@ pub(crate) fn feed_containers(
let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed());
let families_changed = bag_families.as_ref().is_some_and(|r| r.is_changed());
let errors_held = !equip_errors.0.is_empty();
let locks_held = !lock_cleared.0.is_empty() || !pending.is_empty();
let locks_held = !lock_cleared.0.is_empty() || !pending.is_empty() || !opens.0.is_empty();
gate::trace(
"feed_containers",
&[
@@ -986,7 +991,7 @@ pub(crate) fn feed_containers(
// `ui_action::cast_fail` already runs. No hex debug line on the player's screen: a code we
// failed to map can't reach here (the table is total), and a key we typo'd is caught by
// `equip_error`'s resolution test against the real `GlobalStrings.lua`, not at runtime.
let player = self_q.0.iter().next();
let player = inv.self_store.iter().next();
let mut refusals = Vec::new();
for e in equip_errors.0.drain(..) {
// Reason 16's substitution — the ONE reason whose text is chosen by the app rather than
@@ -1000,6 +1005,7 @@ pub(crate) fn feed_containers(
bag_family_name(
player,
e.bag_slot,
&inv.objects,
&items,
bag_families.as_deref().map(|c| &c.0),
&commands,
@@ -1045,6 +1051,9 @@ pub(crate) fn feed_containers(
// sees the unlocked slot, not stale data — and after `feed_char`'s own push, for the same
// reason on the doll and bank-bag bands (1771).
let transitioned: Vec<(i64, u32)> = std::mem::take(&mut lock_cleared.0);
// The containers the server opened this frame (`SMSG_OPEN_CONTAINER`, decision 2339) — the
// packet handler queued them; they fire as `BAG_OPEN(id)` after the push below, like the locks.
let opened: Vec<i64> = std::mem::take(&mut opens.0);
let mut fresh: HashMap<i64, ContainerState> = HashMap::new();
// The player's own relic flag, resolved once. `find_equip_slot` reads it for INVSLOT 17 and
@@ -1064,6 +1073,7 @@ pub(crate) fn feed_containers(
let guid = store.0.player_pack_slot(i).unwrap_or(0);
if let Some(mut slot) = resolve_slot(
guid,
&inv.objects,
&items,
icons.as_deref(),
rolls,
@@ -1098,7 +1108,7 @@ pub(crate) fn feed_containers(
if bag_guid == 0 {
continue; // no bag equipped → absent → GetContainerNumSlots = 0
}
let (entry, num_slots, slot_guids) = match items.object(bag_guid) {
let (entry, num_slots, slot_guids) = match inv.objects.object(bag_guid) {
Some(f) => {
let n = f.container_num_slots().unwrap_or(0);
let guids: Vec<u64> = (0..n.min(36) as u8)
@@ -1119,6 +1129,7 @@ pub(crate) fn feed_containers(
for (j, &guid) in slot_guids.iter().enumerate() {
if let Some(mut slot) = resolve_slot(
guid,
&inv.objects,
&items,
icons.as_deref(),
rolls,
@@ -1154,6 +1165,7 @@ pub(crate) fn feed_containers(
let guid = store.0.player_bank_slot(i).unwrap_or(0);
if let Some(mut slot) = resolve_slot(
guid,
&inv.objects,
&items,
icons.as_deref(),
rolls,
@@ -1184,7 +1196,7 @@ pub(crate) fn feed_containers(
if bag_guid == 0 {
continue; // no bag in the slot → absent → GetContainerNumSlots = 0
}
let (entry, num_slots, slot_guids) = match items.object(bag_guid) {
let (entry, num_slots, slot_guids) = match inv.objects.object(bag_guid) {
Some(f) => {
let n = f.container_num_slots().unwrap_or(0);
let guids: Vec<u64> = (0..n.min(36) as u8)
@@ -1205,6 +1217,7 @@ pub(crate) fn feed_containers(
for (j, &guid) in slot_guids.iter().enumerate() {
if let Some(mut slot) = resolve_slot(
guid,
&inv.objects,
&items,
icons.as_deref(),
rolls,
@@ -1244,6 +1257,7 @@ pub(crate) fn feed_containers(
let guid = store.0.player_keyring_slot(i).unwrap_or(0);
if let Some(mut slot) = resolve_slot(
guid,
&inv.objects,
&items,
icons.as_deref(),
rolls,
@@ -1272,7 +1286,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, &items, &commands);
let key = has_key(&store.0, &inv.objects, &items, &commands);
if key != memory.had_key {
gate.audit("feed_containers", "the HasKey() flip");
debug!(
@@ -1308,8 +1322,12 @@ pub(crate) fn feed_containers(
vault: std::array::from_fn(|i| player.map_or(0, |s| vault_slot_guid(s, i as u8))),
},
transitioned,
opened,
) {
gate.audit("feed_containers", "a bag diff or a lock event");
gate.audit(
"feed_containers",
"a bag diff, a lock event or a server-opened bag",
);
}
}
@@ -1342,6 +1360,7 @@ pub(crate) fn apply_container_source(
// is the logout despawn window — see this function's own note).
guids: SlotGuids,
transitioned: Vec<(i64, u32)>,
opened: Vec<i64>,
) -> bool {
// Diff whole bags; push + fire BAG_UPDATE per transition, one BAG_UPDATE_DELAYED per batch. A
// pending-lock transition always flips a slot's `.locked` (part of `ContainerSlot`'s equality),
@@ -1364,6 +1383,14 @@ pub(crate) fn apply_container_source(
for _ in transitioned {
script.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
// `SMSG_OPEN_CONTAINER` → `BAG_OPEN(containerId)` (decision 2339). The reference's arm
// `0x5e3b35` fires it from the packet handler; ours reaches the VM here — after this frame's
// `BAG_UPDATE`s, so the frame the stock `ContainerFrame_OnEvent` shows paints the bag as it now
// is. The id is the reference's own: 0 backpack, 1..4 equipped, 5..10 bank.
pushed |= !opened.is_empty();
for bag in opened {
script.fire_event("BAG_OPEN", vec![ScriptValue::Int(bag)]);
}
// Whether anything went into the VM — the caller's gate audit reads it (1439).
pushed
}
@@ -1591,6 +1618,46 @@ mod tests {
/// · the same item's own fields change (a restack) → `0x4c728d`, **`arg1 = "player"`**;
/// · two IDENTICAL items exchanged between two slots → the pushed views are equal, so the
/// container diff sees nothing at all, and the reference fires the argless one **twice**.
/// `SMSG_OPEN_CONTAINER` → `BAG_OPEN(containerId)` (decision 2339): the ids the handler
/// resolved reach the VM in order, after the frame's container push, and the stock
/// `ContainerFrame_OnEvent` sees the id as `arg1` — the reference's own event shape.
#[test]
fn a_server_opened_bag_fires_bag_open_with_its_container_id() {
let mut s = UiScript::new().unwrap();
s.run(
"SEEN = {} \
local f = CreateFrame('Frame') \
f:RegisterEvent('BAG_OPEN') \
f:RegisterEvent('BAG_UPDATE') \
f:SetScript('OnEvent', function() \
table.insert(SEEN, event .. ' ' .. tostring(arg1)) end)",
)
.unwrap();
let mut memory = FeedMemory::default();
// A bag equipped into the second bag slot, and nothing else changed this frame.
assert!(apply_container_source(
&mut s,
&mut memory,
None,
NO_BAGS,
Vec::new(),
vec![2]
));
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
assert_eq!(seen, vec!["BAG_OPEN 2"]);
// Nothing queued: nothing fired, and the push reports nothing went in.
s.run("SEEN = {}").unwrap();
assert!(!apply_container_source(
&mut s,
&mut memory,
None,
NO_BAGS,
Vec::new(),
Vec::new()
));
assert!(s.eval::<Vec<String>>("return SEEN").unwrap().is_empty());
}
#[test]
fn playerbankslots_changed_names_its_producer_by_the_slot_guid() {
let mut s = UiScript::new().unwrap();
@@ -1630,16 +1697,37 @@ mod tests {
// An item ARRIVES in vault slot 1 — the descriptor path, no arguments.
guids.vault[0] = 0xF00D;
apply_container_source(&mut s, &mut memory, Some(vault(1, 5)), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(vault(1, 5)),
guids,
Vec::new(),
Vec::new(),
);
assert_eq!(seen(&mut s), vec!["PLAYERBANKSLOTS_CHANGED nil"]);
// The SAME item restacks — its own `ITEM_FIELD_STACK_COUNT` moved, which is the item
// watcher's path, and the reference pushes the unit token there.
apply_container_source(&mut s, &mut memory, Some(vault(1, 9)), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(vault(1, 9)),
guids,
Vec::new(),
Vec::new(),
);
assert_eq!(seen(&mut s), vec!["PLAYERBANKSLOTS_CHANGED player"]);
// Nothing moved at all.
apply_container_source(&mut s, &mut memory, Some(vault(1, 9)), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(vault(1, 9)),
guids,
Vec::new(),
Vec::new(),
);
assert!(seen(&mut s).is_empty(), "an unchanged vault says nothing");
// Two IDENTICAL stacks exchanged between slots 1 and 2. Both slot views are equal before
@@ -1673,10 +1761,24 @@ mod tests {
)])
};
guids.vault[1] = 0xBEEF;
apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(two()),
guids,
Vec::new(),
Vec::new(),
);
assert_eq!(seen(&mut s), vec!["PLAYERBANKSLOTS_CHANGED nil"]);
guids.vault.swap(0, 1);
let pushed = apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new());
let pushed = apply_container_source(
&mut s,
&mut memory,
Some(two()),
guids,
Vec::new(),
Vec::new(),
);
assert_eq!(
seen(&mut s),
vec![
@@ -1735,12 +1837,26 @@ mod tests {
// Equip a bag into the first equipped bag slot: BAG_UPDATE, and no BAG_CLOSED — the slot
// was empty, which is the `0x4f9247 je` arm.
guids.bags[0] = 0xAAAA;
apply_container_source(&mut s, &mut memory, Some(pouch(6)), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(pouch(6)),
guids,
Vec::new(),
Vec::new(),
);
assert_eq!(seen(&mut s), vec!["BAG_UPDATE 1"]);
// Its contents change: the bag guid stands, so neither the close nor its suppression
// applies and the ordinary announce runs.
apply_container_source(&mut s, &mut memory, Some(pouch(6)), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(pouch(6)),
guids,
Vec::new(),
Vec::new(),
);
assert!(
seen(&mut s).is_empty(),
"an unchanged container says nothing"
@@ -1749,12 +1865,26 @@ mod tests {
// SWAP for a different bag: closed FIRST, then updated. This is the arm a "the bag went
// away" reading gets wrong — the slot is still occupied.
guids.bags[0] = 0xBBBB;
apply_container_source(&mut s, &mut memory, Some(pouch(10)), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(pouch(10)),
guids,
Vec::new(),
Vec::new(),
);
assert_eq!(seen(&mut s), vec!["BAG_CLOSED 1", "BAG_UPDATE 1"]);
// UNEQUIP: closed, and **no** BAG_UPDATE.
guids.bags[0] = 0;
apply_container_source(&mut s, &mut memory, Some(HashMap::new()), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(HashMap::new()),
guids,
Vec::new(),
Vec::new(),
);
assert_eq!(seen(&mut s), vec!["BAG_CLOSED 1"]);
// Two IDENTICAL empty bags exchanged between bank bag slots 5 and 6. Every container
@@ -1784,10 +1914,24 @@ mod tests {
let mut guids = NO_BAGS;
guids.bags[4] = 0x1111;
guids.bags[5] = 0x2222;
apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(two()),
guids,
Vec::new(),
Vec::new(),
);
let _ = seen(&mut s);
guids.bags.swap(4, 5);
let pushed = apply_container_source(&mut s, &mut memory, Some(two()), guids, Vec::new());
let pushed = apply_container_source(
&mut s,
&mut memory,
Some(two()),
guids,
Vec::new(),
Vec::new(),
);
assert_eq!(seen(&mut s), vec!["BAG_CLOSED 5", "BAG_CLOSED 6"]);
assert!(
pushed,
@@ -1830,13 +1974,14 @@ mod tests {
Some(HashMap::from([(0, bag0)])),
NO_BAGS,
Vec::new(),
Vec::new(),
);
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 16);
assert_eq!(s.eval::<i64>("return BAG_EVENTS").unwrap(), 1);
// The logout despawn frame: no store. The VM keeps its last-pushed bags and no event
// fires — an addon reading its bags out of the PLAYER_LOGOUT edge sees them intact.
apply_container_source(&mut s, &mut memory, None, NO_BAGS, Vec::new());
apply_container_source(&mut s, &mut memory, None, NO_BAGS, Vec::new(), Vec::new());
assert_eq!(
s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(),
16,
@@ -1855,6 +2000,7 @@ mod tests {
Some(HashMap::new()),
NO_BAGS,
Vec::new(),
Vec::new(),
);
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 0);
assert_eq!(s.eval::<i64>("return BAG_EVENTS").unwrap(), 2);
@@ -1912,6 +2058,7 @@ mod tests {
Some(bag(arrows(200))),
NO_BAGS,
Vec::new(),
Vec::new(),
);
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
@@ -1927,6 +2074,7 @@ mod tests {
Some(bag(arrows(199))),
NO_BAGS,
Vec::new(),
Vec::new(),
);
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
@@ -1942,7 +2090,14 @@ mod tests {
s.run("ORDER = {}").unwrap();
let mut other = arrows(20);
other.item_id = 3033; // Razor Arrow
apply_container_source(&mut s, &mut memory, Some(bag(other)), NO_BAGS, Vec::new());
apply_container_source(
&mut s,
&mut memory,
Some(bag(other)),
NO_BAGS,
Vec::new(),
Vec::new(),
);
assert_eq!(
s.eval::<String>("return table.concat(ORDER, ' ')").unwrap(),
"BAG_UPDATE:0,nil",
+188 -107
View File
@@ -4,8 +4,9 @@
//! Each frame, the player's own descriptor names the bag layout (the PRIVATE `PACK_SLOT` array =
//! the backpack; `INV_SLOT` 19–22 = the equipped bags, each a container object with its own
//! `CONTAINER_FIELD_SLOT` array; `KEYRING_SLOT` 81.. = the keyring, decision 0765 — a container
//! with no container *object*, exactly like the bank), the item store ([`crate::items::Items`]) resolves slot guids to
//! instances (entry, stack count), the template cache resolves entries to name/quality (ask-once
//! with no container *object*, exactly like the bank), the object index ([`crate::net::Objects`])
//! resolves slot guids to instances (entry, stack count), the template cache resolves entries to
//! name/quality (ask-once
//! `ITEM_QUERY_SINGLE` — a slot whose answer is in flight shows as an unresolved occupied slot and
//! fills in when it lands), and `ItemDisplayInfo.dbc` turns the template's display id into the
//! icon. The assembled per-bag [`ContainerState`](benilla_ui::script::ContainerState)s are diffed
@@ -42,7 +43,7 @@ use benilla_ui::script::EQUIPMENT_BAG;
use bevy::prelude::*;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore};
use crate::net::{NetCommands, ObjectStore, Objects};
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
@@ -191,14 +192,19 @@ pub(crate) struct EquipErrors(pub Vec<EquipError>);
/// object's own slot array, or — [`EQUIPMENT_BAG`], decision 0208 phase 1b — the player
/// descriptor's own `INV_SLOT` array directly (`slot0` is already the wire `EQUIPMENT_SLOT_*`
/// id, [`wire_pos`]'s own convention). The same resolution the feed does.
pub(crate) fn slot_guid(store: &ObjectFields, bag: i64, slot0: u8, items: &Items) -> Option<u64> {
pub(crate) fn slot_guid(
store: &ObjectFields,
bag: i64,
slot0: u8,
objects: &Objects,
) -> Option<u64> {
match bag {
0 => store.player_pack_slot(slot0).filter(|g| *g != 0),
1..=4 => {
let bag_guid = store
.player_inv_slot(BAG_SLOT_FIRST + bag as u8 - 1)
.filter(|g| *g != 0)?;
items
objects
.object(bag_guid)?
.container_slot(slot0)
.filter(|g| *g != 0)
@@ -209,7 +215,7 @@ pub(crate) fn slot_guid(store: &ObjectFields, bag: i64, slot0: u8, items: &Items
let bag_guid = store
.player_bank_bag_slot((bag - BANK_BAG_ID_FIRST) as u8)
.filter(|g| *g != 0)?;
items
objects
.object(bag_guid)?
.container_slot(slot0)
.filter(|g| *g != 0)
@@ -236,15 +242,15 @@ pub(crate) fn slot_guid_count(
store: Option<&ObjectStore>,
bag: i64,
slot1: u32,
items: &Items,
objects: &Objects,
) -> (u64, u32) {
let Some(store) = store else {
return (0, 0);
};
let slot0 = slot1.saturating_sub(1) as u8;
match slot_guid(&store.0, bag, slot0, items) {
match slot_guid(&store.0, bag, slot0, objects) {
Some(guid) => {
let count = items
let count = objects
.object(guid)
.and_then(|f| f.item_stack_count())
.unwrap_or(1);
@@ -355,7 +361,7 @@ impl InventoryScope {
/// come before the backpack.
fn walk_inventory<T>(
store: &ObjectFields,
items: &Items,
objects: &Objects,
scope: InventoryScope,
mut visit: impl FnMut(u8, u8, u64) -> Option<T>,
) -> Option<T> {
@@ -363,7 +369,7 @@ fn walk_inventory<T>(
// (true for equipped bags and bank bags alike — `BANK_BAG_SLOT_FIRST`'s note).
let contents =
|bag_slot: u8, bag_guid: u64, visit: &mut dyn FnMut(u8, u8, u64) -> Option<T>| {
let bag_fields = items.object(bag_guid)?;
let bag_fields = objects.object(bag_guid)?;
let num_slots = bag_fields.container_num_slots().unwrap_or(0).min(36) as u8;
(0..num_slots).find_map(|j| {
let guid = bag_fields.container_slot(j).unwrap_or(0);
@@ -458,13 +464,13 @@ fn walk_inventory<T>(
/// and the feed reruns.
pub(crate) fn count_of(
store: &ObjectFields,
items: &Items,
objects: &Objects,
entry: u32,
scope: InventoryScope,
) -> u32 {
let mut total = 0u32;
walk_inventory::<()>(store, items, scope, |_, _, guid| {
if let Some(fields) = items.object(guid) {
walk_inventory::<()>(store, objects, scope, |_, _, guid| {
if let Some(fields) = objects.object(guid) {
if fields.object_entry() == Some(entry) {
total += fields.item_stack_count().unwrap_or(1);
}
@@ -480,11 +486,11 @@ pub(crate) fn count_of(
/// the per-entry question becomes a lookup. Same scope, same per-copy stack sum.
pub(crate) fn carried_counts(
store: &ObjectFields,
items: &Items,
objects: &Objects,
) -> std::collections::HashMap<u32, u32> {
let mut counts = std::collections::HashMap::new();
walk_inventory::<()>(store, items, InventoryScope::CARRIED, |_, _, guid| {
if let Some(fields) = items.object(guid) {
walk_inventory::<()>(store, objects, InventoryScope::CARRIED, |_, _, guid| {
if let Some(fields) = objects.object(guid) {
if let Some(entry) = fields.object_entry() {
*counts.entry(entry).or_insert(0) += fields.item_stack_count().unwrap_or(1);
}
@@ -515,7 +521,7 @@ pub(crate) struct ItemSearch {
/// order and why it is load-bearing (decision 0666; bank and buyback are not in this scope).
pub(crate) fn find_item(
store: &ObjectFields,
items: &Items,
objects: &Objects,
entry: u32,
search: ItemSearch,
) -> Option<(u8, u8, u64)> {
@@ -524,8 +530,8 @@ pub(crate) fn find_item(
} else {
InventoryScope::DEFAULT
};
walk_inventory(store, items, scope, |bag, slot, guid| {
let f = items.object(guid)?;
walk_inventory(store, objects, scope, |bag, slot, guid| {
let f = objects.object(guid)?;
if f.object_entry() != Some(entry) {
return None;
}
@@ -550,12 +556,12 @@ pub(crate) fn find_item(
/// its load-bearing order (decisions 0666/1158) and just defers the judging by one step.
pub(crate) fn collect_inventory(
store: &ObjectFields,
items: &Items,
objects: &Objects,
scope: InventoryScope,
) -> Vec<(u8, u8, u64)> {
let mut out = Vec::new();
// `None` throughout — the walk is never stopped, so every slot in scope lands in `out`.
walk_inventory(store, items, scope, |bag, slot, guid| {
walk_inventory(store, objects, scope, |bag, slot, guid| {
out.push((bag, slot, guid));
None::<()>
});
@@ -576,12 +582,17 @@ 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: &Items, commands: &NetCommands) -> bool {
pub(crate) fn has_key(
store: &ObjectFields,
objects: &Objects,
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).
fn contents(bag_guid: u64, items: &Items, out: &mut Vec<u64>) {
let Some(f) = items.object(bag_guid) else {
fn contents(bag_guid: u64, objects: &Objects, out: &mut Vec<u64>) {
let Some(f) = objects.object(bag_guid) else {
return;
};
let n = f.container_num_slots().unwrap_or(0).min(36) as u8;
@@ -594,7 +605,7 @@ pub(crate) fn has_key(store: &ObjectFields, items: &Items, commands: &NetCommand
for bag in 0..BAGS {
let bag_guid = store.player_inv_slot(BAG_SLOT_FIRST + bag).unwrap_or(0);
guids.push(bag_guid);
contents(bag_guid, items, &mut guids);
contents(bag_guid, objects, &mut guids);
}
for i in 0..PACK_SLOTS {
guids.push(store.player_pack_slot(i).unwrap_or(0));
@@ -605,7 +616,7 @@ pub(crate) fn has_key(store: &ObjectFields, items: &Items, commands: &NetCommand
for bag in 0..BANK_BAGS {
let bag_guid = store.player_bank_bag_slot(bag).unwrap_or(0);
guids.push(bag_guid);
contents(bag_guid, items, &mut guids);
contents(bag_guid, objects, &mut guids);
}
for i in 0..KEYRING_SLOTS {
guids.push(store.player_keyring_slot(i).unwrap_or(0));
@@ -614,7 +625,7 @@ pub(crate) fn has_key(store: &ObjectFields, items: &Items, commands: &NetCommand
if guid == 0 {
return false;
}
let Some(entry) = items.object(guid).and_then(|f| f.object_entry()) else {
let Some(entry) = objects.object(guid).and_then(|f| f.object_entry()) else {
return false;
};
items
@@ -622,6 +633,44 @@ pub(crate) fn has_key(store: &ObjectFields, items: &Items, commands: &NetCommand
.is_some_and(|t| t.bag_family == BAG_FAMILY_KEYS)
})
}
/// **The object index a test resolves item guids through** (decision 2334) — the half of the
/// seam [`crate::items::TestDeps`] does not cover, for a test that holds no `TestDeps`: a world to
/// seed with item entities, and the [`Objects`] lookup over it. The two live together because the
/// lookup borrows the world it reads, so a test cannot hold one without the other.
///
/// Seed first, then read: `objects.get()` borrows the world for as long as its answer lives, so
/// the idiom at a call site is `&objs.get()` inline.
#[cfg(test)]
pub(crate) struct TestObjects {
world: World,
state: bevy::ecs::system::SystemState<Objects<'static, 'static>>,
}
#[cfg(test)]
impl TestObjects {
pub(crate) fn new() -> Self {
let mut world = World::new();
world.init_resource::<crate::net::GuidIndex>();
let state = bevy::ecs::system::SystemState::new(&mut world);
Self { world, state }
}
/// One item instance in the index — the wire's `ItemCreate`.
pub(crate) fn spawn(&mut self, guid: u64, fields: ObjectFields) {
crate::items::test_spawn_item(&mut self.world, guid, fields, false);
}
/// The same for a CONTAINER instance (`TYPEMASK_CONTAINER`).
pub(crate) fn spawn_container(&mut self, guid: u64, fields: ObjectFields) {
crate::items::test_spawn_item(&mut self.world, guid, fields, true);
}
/// The lookup itself.
pub(crate) fn get(&mut self) -> Objects<'_, '_> {
self.state.get(&self.world)
}
}
/// The wire's "player array" bag index — `INVENTORY_SLOT_BAG_0`. With it, [`ItemUse::slot`] IS the
/// equipment index (0–18 worn, 19–22 the equipped bags).
pub(crate) const PLAYER_ARRAY: u8 = 255;
@@ -844,7 +893,12 @@ 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, &ladder.items, &ladder.commands)
crate::items::disarmed_equipment_slot(
store,
&ladder.objects,
&ladder.items,
&ladder.commands,
)
});
match item_use_route(it, aura_cancels, disarmed_hand) {
ItemUseRoute::CantUseDisarmed => {
@@ -893,9 +947,9 @@ pub(crate) fn send_item_use(
// narrower than the reference, and wow-re said so in as many words.
ItemUseRoute::Nothing | ItemUseRoute::Cast(_)
if !suppress
&& it
.guid
.is_some_and(|g| gate.use_binds(&ladder.items, &ladder.commands, g)) =>
&& it.guid.is_some_and(|g| {
gate.use_binds(&ladder.objects, &ladder.items, &ladder.commands, g)
}) =>
{
gate.defer_use(script, it);
false
@@ -1154,6 +1208,7 @@ impl Plugin for UiItemsPlugin {
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<LockTransitions>()
.init_resource::<net::BagOpens>()
// AFTER the chain opens — a bare Startup slot raced AssetSet::Open and, when it
// won, silently skipped every item DBC (no ItemSets/ItemSubClasses resource for the
// whole session: set tooltips lost their SET block, the crafting book its headers).
@@ -1579,8 +1634,7 @@ mod tests {
/// at all (an equipped trinket's action button was inert) and put the backpack ahead of the bags.
#[cfg(test)]
mod find_item_tests {
use super::{find_item, ItemSearch};
use crate::items::Items;
use super::{find_item, ItemSearch, TestObjects};
use benilla_protocol::ObjectFields;
// Descriptor indices, raw (the module's own consts are private; the codebase's test idiom).
@@ -1615,23 +1669,23 @@ mod find_item_tests {
ObjectFields::from_pairs(&pairs)
}
/// A plain item instance of `entry` (optionally with live charges).
fn item(store: &mut Items, guid: u64, entry: u32, charges: Option<i32>) {
/// A plain item instance of `entry` (optionally with live charges), in the one index (2334).
fn item(objects: &mut TestObjects, guid: u64, entry: u32, charges: Option<i32>) {
let mut pairs = vec![(ENTRY, entry)];
if let Some(c) = charges {
pairs.push((CHARGES, c as u32));
}
store.insert_object(guid, ObjectFields::from_pairs(&pairs));
objects.spawn(guid, ObjectFields::from_pairs(&pairs));
}
/// A container instance holding `contents` at its own inner slots.
fn bag(store: &mut Items, guid: u64, entry: u32, contents: &[(u8, u64)]) {
fn bag(objects: &mut TestObjects, guid: u64, entry: u32, contents: &[(u8, u64)]) {
let mut pairs = vec![(ENTRY, entry), (NUM_SLOTS, 16)];
for &(i, item_guid) in contents {
pairs.push((SLOT_1 + 2 * u16::from(i), item_guid as u32));
pairs.push((SLOT_1 + 2 * u16::from(i) + 1, (item_guid >> 32) as u32));
}
store.insert_object(guid, ObjectFields::from_pairs(&pairs));
objects.spawn_container(guid, ObjectFields::from_pairs(&pairs));
}
const ALL: ItemSearch = ItemSearch {
@@ -1651,12 +1705,12 @@ mod find_item_tests {
/// identical copy sitting in the backpack, and the wire pair is the doll's `(255, 13)`.
#[test]
fn equipment_is_searched_before_everything_else() {
let mut items = Items::default();
item(&mut items, 0xE1, TRINKET, None);
item(&mut items, 0xB1, TRINKET, None);
let mut objs = TestObjects::new();
item(&mut objs, 0xE1, TRINKET, None);
item(&mut objs, 0xB1, TRINKET, None);
let store = player(&[(13, 0xE1), (23, 0xB1)]);
assert_eq!(
find_item(&store, &items, TRINKET, ALL),
find_item(&store, &objs.get(), TRINKET, ALL),
Some((255, 13, 0xE1))
);
}
@@ -1665,12 +1719,12 @@ mod find_item_tests {
/// This is the stage that decides USE-in-place vs EQUIP.
#[test]
fn the_equipment_only_stage_ignores_the_bags() {
let mut items = Items::default();
item(&mut items, 0xB1, TRINKET, None);
let mut objs = TestObjects::new();
item(&mut objs, 0xB1, TRINKET, None);
let store = player(&[(23, 0xB1)]);
assert_eq!(find_item(&store, &items, TRINKET, WORN), None);
assert_eq!(find_item(&store, &objs.get(), TRINKET, WORN), None);
assert_eq!(
find_item(&store, &items, TRINKET, ALL),
find_item(&store, &objs.get(), TRINKET, ALL),
Some((255, 23, 0xB1))
);
}
@@ -1679,13 +1733,13 @@ mod find_item_tests {
/// container) — the leg the old backpack-first walk had backwards.
#[test]
fn bag_contents_precede_the_backpack() {
let mut items = Items::default();
item(&mut items, 0xC1, TRINKET, None);
item(&mut items, 0xB1, TRINKET, None);
bag(&mut items, 0xBA, BAG, &[(2, 0xC1)]);
let mut objs = TestObjects::new();
item(&mut objs, 0xC1, TRINKET, None);
item(&mut objs, 0xB1, TRINKET, None);
bag(&mut objs, 0xBA, BAG, &[(2, 0xC1)]);
let store = player(&[(19, 0xBA), (23, 0xB1)]);
assert_eq!(
find_item(&store, &items, TRINKET, ALL),
find_item(&store, &objs.get(), TRINKET, ALL),
Some((19, 2, 0xC1)),
"bag 1's inner slot 2, addressed by the bag's own player-array index"
);
@@ -1695,27 +1749,30 @@ mod find_item_tests {
/// bar is a real, placeable action (`InventoryType` 18 passes PlaceAction's filter).
#[test]
fn an_equipped_bag_is_found_as_itself() {
let mut items = Items::default();
bag(&mut items, 0xBA, BAG, &[]);
let mut objs = TestObjects::new();
bag(&mut objs, 0xBA, BAG, &[]);
let store = player(&[(19, 0xBA)]);
assert_eq!(find_item(&store, &items, BAG, ALL), Some((255, 19, 0xBA)));
assert_eq!(
find_item(&store, &objs.get(), BAG, ALL),
Some((255, 19, 0xBA))
);
}
/// The mode-`0x20` charge filter skips a SPENT copy and returns one with uses left — so a
/// click on a charged item reaches a copy that still works.
#[test]
fn the_charge_filter_skips_a_spent_copy() {
let mut items = Items::default();
item(&mut items, 0xB1, TRINKET, Some(0));
item(&mut items, 0xB2, TRINKET, Some(3));
let mut objs = TestObjects::new();
item(&mut objs, 0xB1, TRINKET, Some(0));
item(&mut objs, 0xB2, TRINKET, Some(3));
let store = player(&[(23, 0xB1), (24, 0xB2)]);
assert_eq!(
find_item(&store, &items, TRINKET, ALL),
find_item(&store, &objs.get(), TRINKET, ALL),
Some((255, 23, 0xB1)),
"without the filter the first copy wins, spent or not"
);
assert_eq!(
find_item(&store, &items, TRINKET, CHARGED),
find_item(&store, &objs.get(), TRINKET, CHARGED),
Some((255, 24, 0xB2)),
"with it, the spent copy is skipped"
);
@@ -1727,16 +1784,19 @@ mod find_item_tests {
#[test]
fn a_key_in_the_keyring_is_found_last() {
const KEY: u32 = 7_146; // The Scarlet Key
let mut items = Items::default();
item(&mut items, 0xE1, KEY, None);
let mut objs = TestObjects::new();
item(&mut objs, 0xE1, KEY, None);
let store = player(&[(81, 0xE1)]);
assert_eq!(find_item(&store, &items, KEY, ALL), Some((255, 81, 0xE1)));
assert_eq!(
find_item(&store, &objs.get(), KEY, ALL),
Some((255, 81, 0xE1))
);
// ...and a copy anywhere earlier still wins: the keyring really is last, not first.
item(&mut items, 0xE2, KEY, None);
item(&mut objs, 0xE2, KEY, None);
let store = player(&[(81, 0xE1), (23, 0xE2)]);
assert_eq!(
find_item(&store, &items, KEY, ALL),
find_item(&store, &objs.get(), KEY, ALL),
Some((255, 23, 0xE2)),
"the backpack copy precedes the keyring one"
);
@@ -1758,22 +1818,38 @@ mod find_item_tests {
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut objs = TestObjects::new();
let mut items = Items::default();
let mut key_tpl = test_template("The Scarlet Key");
key_tpl.bag_family = BAG_FAMILY_KEYS;
items.insert_template(KEY, Some(key_tpl));
items.insert_template(BREAD, Some(test_template("Tough Hunk of Bread")));
item(&mut items, 0xF1, KEY, None);
item(&mut items, 0xF2, BREAD, None);
item(&mut objs, 0xF1, KEY, None);
item(&mut objs, 0xF2, BREAD, None);
// Nothing at all.
assert!(!has_key(&player(&[]), &items, &commands));
assert!(!has_key(&player(&[]), &objs.get(), &items, &commands));
// A non-key in the backpack is not a key.
assert!(!has_key(&player(&[(23, 0xF2)]), &items, &commands));
assert!(!has_key(
&player(&[(23, 0xF2)]),
&objs.get(),
&items,
&commands
));
// The director's own case: the key sitting in keyring slot 1.
assert!(has_key(&player(&[(81, 0xF1)]), &items, &commands));
assert!(has_key(
&player(&[(81, 0xF1)]),
&objs.get(),
&items,
&commands
));
// And in the backpack, before it has been filed.
assert!(has_key(&player(&[(23, 0xF1)]), &items, &commands));
assert!(has_key(
&player(&[(23, 0xF1)]),
&objs.get(),
&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).
@@ -1781,11 +1857,11 @@ mod find_item_tests {
banked.insert(39u16, 0xF1u64);
let store = bank_player(&banked);
assert!(
has_key(&store, &items, &commands),
has_key(&store, &objs.get(), &items, &commands),
"a key in the bank still gives you a keyring"
);
assert_eq!(
find_item(&store, &items, KEY, ALL),
find_item(&store, &objs.get(), KEY, ALL),
None,
"...while the ordinary item search never reaches the bank"
);
@@ -1811,8 +1887,7 @@ mod find_item_tests {
/// `0x47`) and does not. Every case here is a slot band that separates the two scopes.
#[cfg(test)]
mod count_of_tests {
use super::{count_of, InventoryScope};
use crate::items::Items;
use super::{count_of, InventoryScope, TestObjects};
use benilla_protocol::ObjectFields;
// Descriptor indices, raw (the module's own consts are private; the codebase's test idiom).
@@ -1847,22 +1922,22 @@ mod count_of_tests {
ObjectFields::from_pairs(&pairs)
}
/// An item instance of `entry` holding `stack` copies.
fn stack(items: &mut Items, guid: u64, entry: u32, stack: u32) {
items.insert_object(
/// An item instance of `entry` holding `stack` copies, in the one index (2334).
fn stack(objects: &mut TestObjects, guid: u64, entry: u32, stack: u32) {
objects.spawn(
guid,
ObjectFields::from_pairs(&[(ENTRY, entry), (STACK, stack)]),
);
}
/// A container instance holding `contents` at its own inner slots.
fn bag(items: &mut Items, guid: u64, contents: &[(u8, u64)]) {
fn bag(objects: &mut TestObjects, guid: u64, contents: &[(u8, u64)]) {
let mut pairs = vec![(ENTRY, 4_500), (NUM_SLOTS, 16)];
for &(slot, held) in contents {
pairs.push((SLOT_1 + 2 * u16::from(slot), held as u32));
pairs.push((SLOT_1 + 2 * u16::from(slot) + 1, (held >> 32) as u32));
}
items.insert_object(guid, ObjectFields::from_pairs(&pairs));
objects.spawn_container(guid, ObjectFields::from_pairs(&pairs));
}
/// The headline: bank the quest's items and a quest objective still counts them. This is the
@@ -1872,16 +1947,16 @@ mod count_of_tests {
#[test]
fn a_quest_objective_counts_banked_copies_and_nothing_else_does() {
let store = player(&[(23, 0xA1), (39, 0xB1)]); // one stack in the backpack, one in the bank
let mut items = Items::default();
stack(&mut items, 0xA1, AMMO, 3);
stack(&mut items, 0xB1, AMMO, 5);
let mut objs = TestObjects::new();
stack(&mut objs, 0xA1, AMMO, 3);
stack(&mut objs, 0xB1, AMMO, 5);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
count_of(&store, &objs.get(), AMMO, InventoryScope::QUEST_ITEMS),
8,
"mask 0x4F sees the bank"
);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::CARRIED),
count_of(&store, &objs.get(), AMMO, InventoryScope::CARRIED),
3,
"an action-bar/reagent count must NOT see the bank"
);
@@ -1893,15 +1968,18 @@ mod count_of_tests {
#[test]
fn a_quest_objective_counts_the_contents_of_bank_bags() {
let store = player(&[(63, 0xBB)]); // a bag in bank-bag slot 1
let mut items = Items::default();
bag(&mut items, 0xBB, &[(0, 0xC1), (4, 0xC2)]);
stack(&mut items, 0xC1, AMMO, 2);
stack(&mut items, 0xC2, AMMO, 6);
let mut objs = TestObjects::new();
bag(&mut objs, 0xBB, &[(0, 0xC1), (4, 0xC2)]);
stack(&mut objs, 0xC1, AMMO, 2);
stack(&mut objs, 0xC2, AMMO, 6);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
count_of(&store, &objs.get(), AMMO, InventoryScope::QUEST_ITEMS),
8
);
assert_eq!(count_of(&store, &items, AMMO, InventoryScope::CARRIED), 0);
assert_eq!(
count_of(&store, &objs.get(), AMMO, InventoryScope::CARRIED),
0
);
}
/// The other two bands `0x47` carries that benilla's own [`InventoryScope::CARRIED`] does not:
@@ -1909,15 +1987,15 @@ mod count_of_tests {
#[test]
fn the_quest_scope_also_reaches_worn_gear_and_the_keyring() {
let store = player(&[(5, 0xE1), (81, 0xF1)]); // a worn copy and one in the keyring
let mut items = Items::default();
stack(&mut items, 0xE1, AMMO, 1);
stack(&mut items, 0xF1, AMMO, 1);
let mut objs = TestObjects::new();
stack(&mut objs, 0xE1, AMMO, 1);
stack(&mut objs, 0xF1, AMMO, 1);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
count_of(&store, &objs.get(), AMMO, InventoryScope::QUEST_ITEMS),
2
);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::CARRIED),
count_of(&store, &objs.get(), AMMO, InventoryScope::CARRIED),
0,
"benilla's pre-1158 count reached neither band — the named narrowing"
);
@@ -1928,15 +2006,18 @@ mod count_of_tests {
#[test]
fn carried_bags_are_unchanged_in_both_scopes() {
let store = player(&[(19, 0xBA), (25, 0xA2)]);
let mut items = Items::default();
bag(&mut items, 0xBA, &[(2, 0xC1)]);
stack(&mut items, 0xC1, AMMO, 4);
stack(&mut items, 0xA2, AMMO, 1);
let mut objs = TestObjects::new();
bag(&mut objs, 0xBA, &[(2, 0xC1)]);
stack(&mut objs, 0xC1, AMMO, 4);
stack(&mut objs, 0xA2, AMMO, 1);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
count_of(&store, &objs.get(), AMMO, InventoryScope::QUEST_ITEMS),
5
);
assert_eq!(
count_of(&store, &objs.get(), AMMO, InventoryScope::CARRIED),
5
);
assert_eq!(count_of(&store, &items, AMMO, InventoryScope::CARRIED), 5);
}
/// Buyback (slots 69–80) has no mask bit at all and is unreachable at every scope — an item
@@ -1946,12 +2027,12 @@ mod count_of_tests {
// The buyback band has no accessor in the walk by construction; assert the neighbouring
// bands still resolve so this is a real coverage statement, not a vacuous one.
let store = player(&[(38, 0xA1), (68, 0xBB)]);
let mut items = Items::default();
stack(&mut items, 0xA1, AMMO, 1);
bag(&mut items, 0xBB, &[(0, 0xC1)]);
stack(&mut items, 0xC1, AMMO, 1);
let mut objs = TestObjects::new();
stack(&mut objs, 0xA1, AMMO, 1);
bag(&mut objs, 0xBB, &[(0, 0xC1)]);
stack(&mut objs, 0xC1, AMMO, 1);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
count_of(&store, &objs.get(), AMMO, InventoryScope::QUEST_ITEMS),
2,
"last backpack slot + last bank bag, with buyback between them untouched"
);
+104 -8
View File
@@ -1,18 +1,68 @@
//! The inventory refusal's packet handler (in the net handler table since 2319, moved out of the
//! drain's loot arm file) — `SMSG_INVENTORY_CHANGE_FAILURE` onto the equip-error line, the pending
//! item locks, and the loot latch's sixth clear.
//! The item feed's packet handlers (in the net handler table since 2319; the refusal moved out of
//! the drain's loot arm file) — `SMSG_INVENTORY_CHANGE_FAILURE` onto the equip-error line, the
//! pending item locks, and the loot latch's sixth clear; and `SMSG_OPEN_CONTAINER` onto the
//! `BAG_OPEN` queue (decision 2339).
use benilla_protocol::{SessionEvent, SessionEventKind};
use benilla_protocol::{ObjectFields, SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::{EquipError, EquipErrors};
use crate::net::NetHandlerApp;
use super::{EquipError, EquipErrors, BAG_SLOT_FIRST, BANK_BAG_ID_FIRST};
use crate::net::{NetHandlerApp, ObjectStore, SelfGuid, SelfPlayer};
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
use crate::ui_loot::LootLatch;
/// Register the handler — called from [`super::UiItemsPlugin`].
/// Register the handlers — called from [`super::UiItemsPlugin`].
pub(super) fn register(app: &mut App) {
app.net_handler(SessionEventKind::InventoryFailure, on_inventory_failure);
app.net_handler(SessionEventKind::InventoryFailure, on_inventory_failure)
.net_handler(SessionEventKind::OpenContainer, on_open_container);
}
/// The containers the server told us to open (`SMSG_OPEN_CONTAINER`), by container id, waiting
/// for the feed ([`super::feed::feed_containers`]) to fire `BAG_OPEN(id)` through the VM — this
/// handler has no `UiScript`, the [`LockTransitions`] shape (decision 2339).
#[derive(Resource, Default)]
pub(crate) struct BagOpens(pub(crate) Vec<i64>);
fn on_open_container(
In(ev): In<SessionEvent>,
self_guid: Res<SelfGuid>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut opens: ResMut<BagOpens>,
) {
if let SessionEvent::OpenContainer { item } = ev {
let store = self_q.single().ok().map(|s| &s.0);
match container_of(self_guid.0, store, item) {
Some(id) => opens.0.push(id),
None => debug!("open container {item:#x}: not one of our bags — no BAG_OPEN"),
}
}
}
/// The reference's `BAG_OPEN` raiser `0x4f9410`, transcribed (wow-re
/// `object-layer/scratch/open-container-inspect-standstate-handlers.md`): our own guid names the
/// backpack, container **0**; otherwise the guid is looked up in the ten bag slots — the four
/// equipped bags (`PLAYER_FIELD_INV_SLOT_HEAD` 19..22 → containers **1..4**) and the six bank
/// bags (→ **5..10**), the same numbering `BAG_CLOSED` uses — and a guid in none of them fires
/// nothing. vmangos sends the packet from `HandleAutoEquipItemOpcode` when the destination is a
/// bag slot, i.e. when a bag gets equipped, which is why the stock `ContainerFrame_OnEvent`
/// answers `BAG_OPEN` with `Show()` on the frame carrying that id.
pub(super) fn container_of(
self_guid: Option<u64>,
store: Option<&ObjectFields>,
item: u64,
) -> Option<i64> {
if self_guid.is_some_and(|me| me == item) {
return Some(0);
}
let store = store?;
(0..4u8)
.find(|i| store.player_inv_slot(BAG_SLOT_FIRST + i) == Some(item))
.map(|i| i64::from(i) + 1)
.or_else(|| {
(0..6u8)
.find(|j| store.player_bank_bag_slot(*j) == Some(item))
.map(|j| BANK_BAG_ID_FIRST + i64::from(j))
})
}
fn on_inventory_failure(
@@ -116,3 +166,49 @@ mod tests {
assert_eq!(latch.0, None);
}
}
#[cfg(test)]
mod open_container_tests {
use super::*;
use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD;
/// `PLAYER_FIELD_BANK_BAG_SLOT_1` (index 612; private to the fields module, so spelled here).
const BANK_BAG_SLOT_1: u16 = 612;
/// A self descriptor holding the given guids at the given first-dword indices.
fn store(guids: &[(u16, u64)]) -> ObjectFields {
let pairs: Vec<(u16, u32)> = guids
.iter()
.flat_map(|(i, g)| [(*i, *g as u32), (*i + 1, (*g >> 32) as u32)])
.collect();
ObjectFields::from_pairs(&pairs)
}
#[test]
fn the_bag_open_raiser_resolves_the_reference_container_ids() {
let me = 0x0000_0000_0000_0042;
let bag_in_slot_20 = 0x4000_0000_0000_0b02; // the second equipped bag → container 2
let bank_bag_3 = 0x4000_0000_0000_0b44; // the fourth bank bag → container 5 + 3
let s = store(&[
(FIELD_PLAYER_INV_SLOT_HEAD + 2 * 20, bag_in_slot_20),
(BANK_BAG_SLOT_1 + 2 * 3, bank_bag_3),
]);
assert_eq!(
container_of(Some(me), Some(&s), me),
Some(0),
"our own guid is the backpack"
);
assert_eq!(container_of(Some(me), Some(&s), bag_in_slot_20), Some(2));
assert_eq!(container_of(Some(me), Some(&s), bank_bag_3), Some(8));
assert_eq!(
container_of(Some(me), Some(&s), 0xdead),
None,
"a stranger's guid fires nothing"
);
assert_eq!(
container_of(None, None, me),
None,
"no self, no store: nothing to match"
);
}
}
+18 -5
View File
@@ -53,7 +53,9 @@ use benilla_ui::script::{MailInboxRow, MailState, ScriptValue, StationeryView, U
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{
ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, Objects, SelfPlayer,
};
use crate::query_cache::QueryCache;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
@@ -450,6 +452,7 @@ fn resolve_row(
fn stationeries(
catalog: &Stationery,
self_q: &Query<(&ObjectStore, &crate::net::Guid), With<SelfPlayer>>,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
@@ -463,7 +466,7 @@ fn stationeries(
let carried = store.is_some_and(|s| {
crate::ui_items::count_of(
&s.0,
items,
objects,
row.item,
crate::ui_items::InventoryScope::CARRIED,
) > 0
@@ -537,6 +540,7 @@ struct MailFeedExtras<'w, 's> {
fn feed_mail(
script: Option<NonSendMut<UiScript>>,
mut mail: ResMut<MailOpen>,
objects: Objects,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
stationery: Option<Res<Stationery>>,
@@ -651,7 +655,16 @@ fn feed_mail(
let usable = stationery
.as_deref()
.filter(|_| !self_q.is_empty())
.map(|catalog| stationeries(catalog, &self_q, &items, icons.as_deref(), &commands))
.map(|catalog| {
stationeries(
catalog,
&self_q,
&objects,
&items,
icons.as_deref(),
&commands,
)
})
.unwrap_or_default();
let memo = last_stationeries.get(&script);
if *memo != usable {
@@ -723,7 +736,7 @@ fn drain_mail(
mut mail: ResMut<MailOpen>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
items: Res<Items>,
objects: Objects,
time: Res<Time>,
mut pending: ResMut<MailPending>,
) {
@@ -826,7 +839,7 @@ fn drain_mail(
if let Some(req) = script.take_mail_send() {
let item_guid = req.item.and_then(|(bag, slot)| {
self_q.iter().next().and_then(|s| {
crate::ui_items::slot_guid(&s.0, bag, (slot.max(1) - 1) as u8, &items)
crate::ui_items::slot_guid(&s.0, bag, (slot.max(1) - 1) as u8, &objects)
})
});
if req.item.is_some() && item_guid.is_none() {
+15 -9
View File
@@ -28,7 +28,7 @@ use benilla_ui::script::{ItemStatsHead, MerchantItem, MerchantState, ScriptValue
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::ui_items::{item_link, slot_guid, wire_pos};
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
@@ -278,12 +278,13 @@ fn buyback_order(store: &benilla_protocol::ObjectFields) -> Vec<u8> {
fn resolve_buyback(
idx: u8,
store: &benilla_protocol::ObjectFields,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> MerchantItem {
let guid = store.player_buyback_slot(idx).unwrap_or(0);
let obj = items.object(guid);
let obj = objects.object(guid);
let entry = obj.and_then(|o| o.object_entry()).unwrap_or(0);
let quantity = obj.and_then(|o| o.item_stack_count()).unwrap_or(1).max(1);
let template = items.template(entry, guid, commands);
@@ -329,11 +330,12 @@ fn resolve_buyback(
/// No reputation model yet → discount 0.
fn item_repair_cost(
guid: u64,
objects: &Objects,
items: &Items,
tables: &RepairTables,
commands: &NetCommands,
) -> u32 {
let Some(obj) = items.object(guid) else {
let Some(obj) = objects.object(guid) else {
return 0;
};
let cur = obj.item_durability().unwrap_or(0);
@@ -357,6 +359,7 @@ fn item_repair_cost(
/// buyback).
fn repair_all_cost(
store: &benilla_protocol::ObjectFields,
objects: &Objects,
items: &Items,
tables: &RepairTables,
commands: &NetCommands,
@@ -364,7 +367,7 @@ fn repair_all_cost(
let mut total: u64 = 0;
let mut add = |guid: u64, items: &Items| {
if guid != 0 {
total += u64::from(item_repair_cost(guid, items, tables, commands));
total += u64::from(item_repair_cost(guid, objects, items, tables, commands));
}
};
for i in 0..19u8 {
@@ -378,7 +381,7 @@ fn repair_all_cost(
if bag_guid == 0 {
continue;
}
let slots: Vec<u64> = items
let slots: Vec<u64> = objects
.object(bag_guid)
.map(|b| {
let n = b.container_num_slots().unwrap_or(0).min(36) as u8;
@@ -396,6 +399,7 @@ fn repair_all_cost(
/// when no vendor is open.
fn snapshot(
open: &MerchantOpen,
objects: &Objects,
items: &Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
@@ -408,13 +412,13 @@ fn snapshot(
.map(|store| {
buyback_order(store)
.into_iter()
.map(|idx| resolve_buyback(idx, store, items, icons, commands))
.map(|idx| resolve_buyback(idx, store, objects, items, icons, commands))
.collect()
})
.unwrap_or_default();
let can_repair = vendor_npc_flags & NPC_FLAG_REPAIR != 0;
let repair_cost = match (can_repair, player, tables) {
(true, Some(store), Some(t)) => repair_all_cost(store, items, t, commands),
(true, Some(store), Some(t)) => repair_all_cost(store, objects, items, t, commands),
_ => 0,
};
Some(MerchantState {
@@ -436,6 +440,7 @@ fn snapshot(
fn feed_merchant(
script: Option<NonSendMut<UiScript>>,
open: Res<MerchantOpen>,
objects: Objects,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
@@ -496,6 +501,7 @@ fn feed_merchant(
let player = self_q.iter().next().map(|s| &s.0);
let fresh = snapshot(
&open,
&objects,
&items,
icons.as_deref(),
&commands,
@@ -565,7 +571,7 @@ fn drain_merchant(
mut open: ResMut<MerchantOpen>,
commands: Res<NetCommands>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
items: Res<Items>,
objects: Objects,
) {
let Some(mut script) = script else {
return;
@@ -627,7 +633,7 @@ fn drain_merchant(
for (bag, slot) in script.take_merchant_cursor_sells() {
let Some(vendor) = open.vendor else { continue };
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
match self_store.and_then(|s| slot_guid(&s.0, bag, slot0, &items)) {
match self_store.and_then(|s| slot_guid(&s.0, bag, slot0, &objects)) {
Some(item_guid) => {
debug!("ui_merchant: cursor sell bag {bag} slot {slot} (item {item_guid:#x})");
let _ = commands.0.send(ClientCommand::SellItem {
+3 -4
View File
@@ -34,9 +34,8 @@ use benilla_ui::script::{
};
use bevy::prelude::*;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, SelfGuid, SelfPlayer};
use crate::ui_items::{find_item, ItemSearch};
use crate::ui_session::{npc_switched, NpcSession};
@@ -223,7 +222,7 @@ pub(super) fn drain_petition(
mut registrar: ResMut<GuildRegistrarState>,
mut petition: ResMut<PetitionState>,
names: Res<NameCache>,
items: Res<Items>,
objects: Objects,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
self_guid: Res<SelfGuid>,
selection: Res<crate::target::Selection>,
@@ -256,7 +255,7 @@ pub(super) fn drain_petition(
let charter = self_q.iter().next().and_then(|store| {
find_item(
&store.0,
&items,
&objects,
benilla_protocol::messages::CHARTER_ITEM_ENTRY,
ItemSearch::default(),
)
+2
View File
@@ -504,6 +504,7 @@ fn panel_event(panel: QuestPanel) -> &'static str {
fn feed_quest(
script: Option<NonSendMut<UiScript>>,
mut giver: ResMut<QuestGiver>,
objects: crate::net::Objects,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
@@ -562,6 +563,7 @@ fn feed_quest(
st.background_material = giver.npc.and_then(|source| {
crate::ui_item_text::object_material(
source,
&objects,
&items,
&go_templates,
materials.as_deref(),
+34 -2
View File
@@ -376,6 +376,7 @@ fn build_objectives(
template: &QuestTemplate,
log_slot: &QuestLogSlot,
store: &ObjectFields,
objects: &crate::net::Objects,
items: &Items,
names: &NameCache,
commands: &NetCommands,
@@ -408,7 +409,7 @@ fn build_objectives(
let item_name = items
.template(obj.item_id, 0, commands)
.map(|t| t.name.clone());
let bag_count = count_of(store, items, obj.item_id, InventoryScope::QUEST_ITEMS);
let bag_count = count_of(store, objects, obj.item_id, InventoryScope::QUEST_ITEMS);
if let Some(line) = item_line(
obj,
template.src_item_id,
@@ -582,6 +583,8 @@ fn feed_quest_log(
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
mut quest_log: ResMut<QuestLog>,
names: Res<NameCache>,
// The bag walk behind an item objective's carried count (2334).
objects: crate::net::Objects,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
@@ -775,7 +778,16 @@ 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, &items, &names, &commands, &get),
build_objectives(
t,
&r.log_slot,
&store.0,
&objects,
&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.
@@ -1454,6 +1466,9 @@ mod tests {
};
let store = ObjectFields::default(); // empty bags — the item objective reads 0/5
let items = Items::default();
// Empty bags — every item objective here reads 0 owned (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
@@ -1462,6 +1477,7 @@ mod tests {
&template,
&log_slot,
&store,
&objects,
&items,
&names,
&commands,
@@ -1504,6 +1520,9 @@ mod tests {
};
let store = ObjectFields::default();
let items = Items::default();
// Empty bags — every item objective here reads 0 owned (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
@@ -1512,6 +1531,7 @@ mod tests {
&template,
&log_slot,
&store,
&objects,
&items,
&names,
&commands,
@@ -1546,6 +1566,9 @@ mod tests {
};
let store = ObjectFields::default();
let items = Items::default();
// Empty bags — every item objective here reads 0 owned (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
@@ -1554,6 +1577,7 @@ mod tests {
&template,
&log_slot,
&store,
&objects,
&items,
&names,
&commands,
@@ -1585,6 +1609,9 @@ mod tests {
};
let store = ObjectFields::default();
let items = Items::default();
// Empty bags — every item objective here reads 0 owned (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
@@ -1593,6 +1620,7 @@ mod tests {
&template,
&log_slot,
&store,
&objects,
&items,
&names,
&commands,
@@ -1620,6 +1648,9 @@ mod tests {
};
let store = ObjectFields::default();
let items = Items::default();
// Empty bags — every item objective here reads 0 owned (2334).
let mut objs = crate::ui_items::TestObjects::new();
let objects = objs.get();
let names = NameCache::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
@@ -1628,6 +1659,7 @@ mod tests {
&template,
&log_slot,
&store,
&objects,
&items,
&names,
&commands,
@@ -1412,6 +1412,7 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
None,
Default::default(),
Vec::new(),
Vec::new(),
);
s.fire_event("PLAYER_LEAVING_WORLD", Vec::new());
s.fire_event("PLAYER_LOGOUT", Vec::new());
@@ -550,6 +550,7 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
quiver(200),
Default::default(),
Vec::new(),
Vec::new(),
);
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
@@ -565,6 +566,7 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
quiver(199),
Default::default(),
Vec::new(),
Vec::new(),
);
s.tick(0.1);
@@ -2341,7 +2341,6 @@ mod tests {
"UIParent.lua — the enchant-replacement confirm inside a trade; benilla's trade \
feed does not derive it",
),
("BAG_OPEN", "ContainerFrame.lua"),
(
"CLOSE_WORLD_MAP",
"WorldMapFrame.lua — the engine-side close the reference fires when the map is \
+8 -6
View File
@@ -64,7 +64,7 @@ use bevy::prelude::*;
use benilla_ui::script::{ShapeshiftFormView, UiScript};
use crate::items::Items;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, Reputations, SelfPlayer};
use crate::spell::Cooldowns;
use crate::spell::{cast_target, usable, CastCommit, CastLadder};
use crate::target::Selection;
@@ -233,7 +233,9 @@ fn feed_shapeshift_bar(
cooldowns: Res<Cooldowns>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
selection: Res<Selection>,
index: Res<GuidIndex>,
// The object lookup (2334) — the guid index the selection resolves through, plus the bag
// walk behind each form's reagent leg. One param, not two.
objects: Objects,
units: Query<&ObjectStore, Without<SelfPlayer>>,
factions: Option<Res<crate::target::Factions>>,
reputations: Res<Reputations>,
@@ -260,8 +262,8 @@ fn feed_shapeshift_bar(
// action feed's own ctx.
let target_store = selection
.guid
.and_then(|g| index.0.get(&g))
.and_then(|&e| units.get(e).ok());
.and_then(|g| objects.entity(g))
.and_then(|e| units.get(e).ok());
// Admission + order (module docs).
let mut rows: Vec<(u32, &benilla_formats::SpellDisplay)> = actions
@@ -286,7 +288,7 @@ fn feed_shapeshift_bar(
// The bags walked once for every form's reagent leg (see `feed_action_state`).
let carried = store
.map(|s| crate::ui_items::carried_counts(&s.0, &items))
.map(|s| crate::ui_items::carried_counts(&s.0, &objects))
.unwrap_or_default();
let fresh: Vec<ShapeshiftFormView> = rows
.into_iter()
@@ -305,7 +307,7 @@ fn feed_shapeshift_bar(
carried: &carried,
spell_mods: &spell_mods,
};
usable::spell_usable(id, d, &spells, &ctx, &items, &commands).0
usable::spell_usable(id, d, &spells, &ctx, &objects, &items, &commands).0
});
let texture = form_texture(d, active);
let cooldown = cooldowns
+25 -13
View File
@@ -40,7 +40,7 @@ use benilla_ui::script::{ScriptValue, SpellBookState, SpellSlotView, SpellTabVie
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::net::NetCommands;
use crate::spell::{cast_target, CastCommit, CastLadder};
use crate::ui_action::{melee_auto_attack_icon, ranged_weapon_icon, PlayerActions, Spells};
use crate::ui_script::{gate, UiInput};
@@ -135,8 +135,8 @@ struct FeedMemory {
pushed: SpellBookState,
/// The gate's counter memories (1439) — the stores whose lazy resolves poison `is_changed`
/// (the weapon-icon template asks, the cooldown store's per-frame prune), plus the self
/// store's PRESENCE: the book's weapon icons flip on a despawn no `Changed` filter sees.
items_objects: gate::Watch,
/// store's PRESENCE: the book's weapon icons flip on a despawn no `Changed` filter sees. The
/// item OBJECTS are an entity watch since 2334 ([`crate::items::ItemChanges`]), not a counter.
items_templates: gate::Watch,
cooldown_epoch: gate::Watch,
self_present: gate::Watch,
@@ -147,8 +147,10 @@ fn feed_spellbook(
actions: Res<PlayerActions>,
spells: Option<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
// The inventory read (2334): the self store (race/class, the form byte, the two weapon
// slots), its change tick, the object lookup the weapon icons resolve through, and the
// item entities' change watch.
mut inv: crate::items::Inventory,
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
@@ -166,14 +168,16 @@ fn feed_spellbook(
// see the memory struct), the item stores behind the weapon icons, and the cooldown
// store. `UiClock` is deliberately NOT an input: `ui_triple` is frame-stable per arm and
// natural expiry rides the store's `feed_epoch`.
let objects_moved = memory.items_objects.moved(items.object_epoch());
let objects_moved = inv.changes.moved();
let templates_moved = memory.items_templates.moved(items.template_epoch());
let cooldowns_moved = memory.cooldown_epoch.moved(cooldowns.feed_epoch());
let presence_moved = memory.self_present.moved(u64::from(!self_q.is_empty()));
let presence_moved = memory
.self_present
.moved(u64::from(!inv.self_store.is_empty()));
// The frame a timer crosses zero, BEFORE the per-frame prune has moved the epoch
// (`sweep_pending`'s own doc) — the triple flips to None right then.
let sweep = cooldowns.sweep_pending(clock.anchor);
let self_changed = !changed_self.is_empty();
let self_changed = !inv.self_changed.is_empty();
let actions_changed = actions.is_changed();
let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed());
let lines_changed = skill_lines.as_ref().is_some_and(|r| r.is_changed());
@@ -226,21 +230,29 @@ fn feed_spellbook(
};
// The player's race/class drive the General collapse (module doc). Absent (no self player
// yet), 0/0 skips the collapse — each line keeps its own tab until the descriptor arrives.
let store = self_q.single().ok();
let store = inv.self_store.single().ok();
let (race, class) = store
.map(|s| (s.0.unit_race().unwrap_or(0), s.0.unit_class().unwrap_or(0)))
.unwrap_or((0, 0));
// The melee auto-attack's icon is the equipped main-hand weapon (or Spell-Reset when unarmed),
// 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, &items, icons.as_deref(), &commands));
let attack_icon = store.map(|s| {
melee_auto_attack_icon(
s,
&spells.forms,
&inv.objects,
&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, &items, icons.as_deref(), &commands));
let ranged_icon = store
.and_then(|s| ranged_weapon_icon(s, &inv.objects, &items, icons.as_deref(), &commands));
let (mut fresh, tab_lines) = build_book(
&actions.spells,
&spells.catalog,
+18 -13
View File
@@ -20,7 +20,7 @@ use benilla_ui::strings::Arg;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::target::{
go_is_nearest, ring_reaction, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC,
};
@@ -45,7 +45,7 @@ impl Plugin for UiTooltipPlugin {
/// law: the worn set (law §3.6's equipped-item test), the bags (§3.8's reagent possession, and
/// the item-name cache the reagent names come from), the current form, and the bind point `$z`
/// substitutes against.
struct ViewCtx<'a> {
struct ViewCtx<'a, 'w, 's> {
home_area: Option<&'a str>,
form: u8,
store: Option<&'a ObjectStore>,
@@ -56,6 +56,9 @@ struct ViewCtx<'a> {
/// arm resolves `[caster+0xc48]` itself, so this cell moves with the mob you are swinging at
/// and falls back to doubling the caster's own reach when nothing is engaged.
attack_target_reach: Option<f32>,
/// The one object index (2334) — what the possession cells resolve an instance guid
/// through: the worn-item line's search and each reagent's carried count.
objects: &'a Objects<'w, 's>,
items: &'a mut Items,
commands: &'a NetCommands,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
@@ -302,9 +305,9 @@ fn spell_tooltip_view(
// avoidance/crit percentages to print, and — except for ATTACK, which bypasses the gate — the
// spell must be passive. The percentages are already percents on the wire.
let chance = chance_line(d, vctx.store);
let item_met = vctx
.store
.is_none_or(|s| crate::spell::usable::equipped_item_fits(d, s, vctx.items, vctx.commands));
let item_met = vctx.store.is_none_or(|s| {
crate::spell::usable::equipped_item_fits(d, s, vctx.objects, vctx.items, vctx.commands)
});
// Reagents (law §3.8): the named slots, `count > 1` suffixed, a slot the player is short of
// wrapped in the builder's inline red. A reagent whose item template hasn't streamed yet is
// simply absent from this snapshot — `feed_spell_tooltips` re-pushes when it lands, which is
@@ -325,7 +328,7 @@ fn spell_tooltip_view(
name
};
let short = vctx.store.is_some_and(|s| {
count_of(&s.0, vctx.items, entry, InventoryScope::CARRIED) < count
count_of(&s.0, vctx.objects, entry, InventoryScope::CARRIED) < count
});
parts.push(if short {
format!("|cffff2020{text}|r")
@@ -435,7 +438,9 @@ fn feed_spell_tooltips(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// Who the player is auto-attacking, if anyone — the melee range cell's second reach.
engaged_q: Query<&crate::creature_anim::Engaged, With<SelfPlayer>>,
guids: Res<crate::net::GuidIndex>,
// The object lookup (2334) — the guid index the engaged-target reach resolves through,
// plus the bag walk each watched reagent's carried count takes.
objects: Objects,
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
mut items: ResMut<Items>,
@@ -564,9 +569,8 @@ fn feed_spell_tooltips(
let attack_target_reach = engaged_q
.single()
.ok()
.and_then(|e| guids.0.get(&e.0))
.and_then(|&e| stores.get(e).ok())
.map(|s| s.0.unit_combat_reach());
.and_then(|e| objects.object(e.0))
.map(|f| f.unit_combat_reach());
let reaches = (
self_store.map(|s| s.0.unit_combat_reach().to_bits()),
attack_target_reach.map(f32::to_bits),
@@ -589,7 +593,7 @@ fn feed_spell_tooltips(
.map(|entry| {
let named = items.template(entry, 0, &commands).is_some();
let owned = self_store.map_or(0, |s| {
count_of(&s.0, &items, entry, InventoryScope::CARRIED)
count_of(&s.0, &objects, entry, InventoryScope::CARRIED)
});
(entry, (owned, named))
})
@@ -611,6 +615,7 @@ fn feed_spell_tooltips(
store: self_store,
combat_reach: self_store.map_or(1.5, |s| s.0.unit_combat_reach()),
attack_target_reach,
objects: &objects,
items: &mut items,
commands: &commands,
sub_classes: sub_classes.as_deref().map(|c| &c.0),
@@ -629,7 +634,7 @@ fn feed_spell_tooltips(
{
let named = vctx.items.template(entry, 0, vctx.commands).is_some();
let owned = vctx.store.map_or(0, |s| {
count_of(&s.0, vctx.items, entry, InventoryScope::CARRIED)
count_of(&s.0, vctx.objects, entry, InventoryScope::CARRIED)
});
slot.insert((owned, named));
}
@@ -989,7 +994,7 @@ fn drive_mouseover_tooltip(
go_inputs.spells.as_deref(),
go_inputs.skill_lines.as_ref().map(|s| &s.catalog),
self_store,
&go_inputs.items,
&go_inputs.objects,
facts,
&mut matched,
)
+145 -56
View File
@@ -51,33 +51,36 @@ impl TestCtx {
}
}
fn ctx<'a>(
fn ctx<'a, 'w, 's>(
&'a mut self,
objects: &'a Objects<'w, 's>,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
) -> ViewCtx<'a> {
self.ctx_for(form, sub_classes, None)
) -> ViewCtx<'a, 'w, 's> {
self.ctx_for(objects, form, sub_classes, None)
}
/// The same context with an auto-attack target engaged — the melee range arm's second
/// reach, which `0x6e3480` resolves out of `[caster+0xc48]` rather than taking as an
/// argument.
fn ctx_engaged<'a>(
fn ctx_engaged<'a, 'w, 's>(
&'a mut self,
objects: &'a Objects<'w, 's>,
store: Option<&'a ObjectStore>,
target_reach: f32,
) -> ViewCtx<'a> {
let mut ctx = self.ctx_for(0, None, store);
) -> ViewCtx<'a, 'w, 's> {
let mut ctx = self.ctx_for(objects, 0, None, store);
ctx.attack_target_reach = Some(target_reach);
ctx
}
fn ctx_for<'a>(
fn ctx_for<'a, 'w, 's>(
&'a mut self,
objects: &'a Objects<'w, 's>,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
store: Option<&'a ObjectStore>,
) -> ViewCtx<'a> {
) -> ViewCtx<'a, 'w, 's> {
ViewCtx {
home_area: None,
form,
@@ -86,6 +89,7 @@ impl TestCtx {
// The tests drive the melee arm through `combat_reach` alone; the engaged-target
// reach has its own case in `range_cell_on_real_data`.
attack_target_reach: None,
objects,
items: &mut self.items,
commands: &self.commands,
sub_classes,
@@ -96,6 +100,14 @@ impl TestCtx {
}
}
/// The object index the possession cells resolve through — nothing streamed, which is what
/// every case here is graded against: the worn-item search finds no instance and each reagent's
/// carried count reads 0 (decision 2334). Held beside the [`TestCtx`] because the lookup borrows
/// the world it reads.
fn no_objects() -> crate::ui_items::TestObjects {
crate::ui_items::TestObjects::new()
}
/// A player descriptor with nothing worn and nothing in the bags — the "owns none of it"
/// pole of both possession tests.
fn empty_player() -> ObjectStore {
@@ -120,7 +132,9 @@ fn fireball_view_on_real_data() {
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
let v = spell_tooltip_view(133, &spells, &mut t.ctx(0, None)).expect("Fireball view");
let mut objs = no_objects();
let objects = objs.get();
let v = spell_tooltip_view(133, &spells, &mut t.ctx(&objects, 0, None)).expect("Fireball view");
assert_eq!(v.name, "Fireball");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost.as_deref(), Some("30 Mana"));
@@ -150,7 +164,7 @@ fn fireball_view_on_real_data() {
// Charge rank 1 (100) — the director's reference shot, end to end: the dual-bound range
// row (SpellRange 95 = {8, 25}), the CATEGORY-column cooldown (recoveryTime 0 /
// categoryRecoveryTime 15000), and the Stances-mask form line (0x10000 → form 17).
let v = spell_tooltip_view(100, &spells, &mut t.ctx(0, None)).expect("Charge view");
let v = spell_tooltip_view(100, &spells, &mut t.ctx(&objects, 0, None)).expect("Charge view");
assert_eq!(v.name, "Charge");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost, None, "Charge costs nothing (it generates rage)");
@@ -163,7 +177,7 @@ fn fireball_view_on_real_data() {
v.description,
"Charge an enemy, generate 9 rage, and stun it for 1 sec. Cannot be used in combat."
);
let v = spell_tooltip_view(100, &spells, &mut t.ctx(17, None)).expect("Charge view");
let v = spell_tooltip_view(100, &spells, &mut t.ctx(&objects, 17, None)).expect("Charge view");
assert!(v.form_met, "form 17 = Battle Stance satisfies the mask");
// 1483 — a PERMISSIVE Stances mask prints no line at all. 5875 overloads the column: with
@@ -183,7 +197,7 @@ fn fireball_view_on_real_data() {
d.form_mask_is_permissive(),
"{name} carries AttributesEx2 b19"
);
let v = spell_tooltip_view(id, &spells, &mut t.ctx(0, None)).expect(name);
let v = spell_tooltip_view(id, &spells, &mut t.ctx(&objects, 0, None)).expect(name);
assert_eq!(v.requires_form, None, "{name} demands no form");
}
}
@@ -207,6 +221,8 @@ fn cost_and_cast_cells_on_real_data() {
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
// A level-60 warrior-shaped store: max health 4000, base mana 1000 (field indices are
// the protocol crate's: health 22, maxhealth 28, level 34, base mana 162).
let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
@@ -219,15 +235,23 @@ fn cost_and_cast_cells_on_real_data() {
// Bloodrage (2687): pct-ONLY health cost — 20% of MAX health resolves to a flat number
// through the health fallback; never a percentage line. Instant on a non-mana type reads
// bare "Instant" whatever it costs.
let v = spell_tooltip_view(2687, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Bloodrage view");
let v = spell_tooltip_view(
2687,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Bloodrage view");
assert_eq!(v.cost.as_deref(), Some("800 Health"), "20% of 4000");
assert_eq!(v.cast_time.as_deref(), Some("Instant"));
// Life Tap (1454): the 5875 file carries NO cost columns for any rank — the cell is
// empty, exactly the reference render (the printed health cost is 2.x's change).
let v = spell_tooltip_view(1454, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Life Tap view");
let v = spell_tooltip_view(
1454,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Life Tap view");
assert_eq!(v.cost, None, "1.12 Life Tap has no cost cell");
assert_eq!(
v.cast_time.as_deref(),
@@ -237,23 +261,32 @@ fn cost_and_cast_cells_on_real_data() {
// Health Funnel (755): the `_PER_TIME` composite in the health lane, and the channeled
// cast cell.
let v = spell_tooltip_view(755, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Health Funnel view");
let v = spell_tooltip_view(
755,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Health Funnel view");
assert_eq!(v.cost.as_deref(), Some("11 Health, plus 5 per sec"));
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
// Judgement (20271): pct-of-base-mana resolves to its flat number — the line B152
// reported as "% of base mana" never exists on the reference. DBC-only (no store)
// degrades to the flat cost: none here.
let v = spell_tooltip_view(20271, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Judgement view");
let v = spell_tooltip_view(
20271,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Judgement view");
assert_eq!(v.cost.as_deref(), Some("60 Mana"), "6% of base mana 1000");
let v = spell_tooltip_view(20271, &spells, &mut t.ctx(0, None)).expect("Judgement view");
let v =
spell_tooltip_view(20271, &spells, &mut t.ctx(&objects, 0, None)).expect("Judgement view");
assert_eq!(v.cost, None, "a DBC-only view cannot resolve a pct cost");
// Heroic Strike (78): the cost cell keeps its rage (wire 150 ÷ 10) and "Next melee"
// moves to the CAST cell where the ref's ladder puts it.
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(0, None, Some(&store)))
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.expect("Heroic Strike view");
assert_eq!(v.cost.as_deref(), Some("15 Rage"));
assert_eq!(v.cast_time.as_deref(), Some("Next melee"));
@@ -261,20 +294,32 @@ fn cost_and_cast_cells_on_real_data() {
// Throw (2764) and Auto Shot (75): the ranged bit reads "Attack speed" — and for Throw
// the bit is ALONE (`Attributes & 0x2`, not the auto-repeat or-pair). Melee Attack
// (6603) is the §3.4 skip: Effect[0] == ATTACK omits the whole line.
let v = spell_tooltip_view(2764, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Throw view");
let v = spell_tooltip_view(
2764,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Throw view");
assert_eq!(v.cast_time.as_deref(), Some("Attack speed"));
let v = spell_tooltip_view(75, &spells, &mut t.ctx_for(0, None, Some(&store)))
let v = spell_tooltip_view(75, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.expect("Auto Shot view");
assert_eq!(v.cast_time.as_deref(), Some("Attack speed"));
let v = spell_tooltip_view(6603, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Attack view");
let v = spell_tooltip_view(
6603,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Attack view");
assert_eq!(v.cast_time, None, "ATTACK Effect[0] omits the line");
// Mind Flay (15407): a channeled MANA spell — the cost cell and the channeled cell
// together.
let v = spell_tooltip_view(15407, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Mind Flay view");
let v = spell_tooltip_view(
15407,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Mind Flay view");
assert_eq!(v.cost.as_deref(), Some("45 Mana"));
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
}
@@ -297,6 +342,8 @@ fn range_cell_on_real_data() {
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
// A default-reach player: `UNIT_FIELD_COMBATREACH` (130) unset reads the descriptor's 1.5.
let store = empty_player();
@@ -306,8 +353,12 @@ fn range_cell_on_real_data() {
let d = spells.catalog.get(1752).expect("Sinister Strike 1752");
assert_eq!(d.range_index, 2, "the melee row");
assert!(spells.ranges.get(2).expect("row 2").is_melee());
let v = spell_tooltip_view(1752, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Sinister Strike view");
let v = spell_tooltip_view(
1752,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("5 yd range"));
// …and it MOVES with the caster's reach, which is the whole reason the cell needs a store:
@@ -316,35 +367,47 @@ fn range_cell_on_real_data() {
130u16,
4.0f32.to_bits(),
)]));
let v = spell_tooltip_view(1752, &spells, &mut t.ctx_for(0, None, Some(&big)))
let v = spell_tooltip_view(1752, &spells, &mut t.ctx_for(&objects, 0, None, Some(&big)))
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("9 yd range"));
// …and the second reach is the AUTO-ATTACK target's, not the caster's doubled: swinging at
// a 4.0-reach mob with a default 1.5 body reads 1.5 + 4.0 + 1.3333334 = 6.833 -> 7.
let v = spell_tooltip_view(1752, &spells, &mut t.ctx_engaged(Some(&store), 4.0))
.expect("Sinister Strike view");
let v = spell_tooltip_view(
1752,
&spells,
&mut t.ctx_engaged(&objects, Some(&store), 4.0),
)
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("7 yd range"));
// An authored single-number row: Fireball's 0–35.
let v = spell_tooltip_view(133, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Fireball view");
let v = spell_tooltip_view(
133,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Fireball view");
assert_eq!(v.range.as_deref(), Some("35 yd range"));
// An authored PAIR (the `"%d-%d"` nested fill): Charge's 8–25, unpadded — the tooltip's own
// `GetMinMaxRange` call passes `target = NULL`, so the reach never joins these two.
let v =
spell_tooltip_view(100, &spells, &mut t.ctx_for(0, None, Some(&big))).expect("Charge view");
let v = spell_tooltip_view(100, &spells, &mut t.ctx_for(&objects, 0, None, Some(&big)))
.expect("Charge view");
assert_eq!(v.range.as_deref(), Some("8-25 yd range"));
// The two absences. Heroic Strike (78) carries the on-next-swing pair, which jumps the cell
// before the resolver runs — it does NOT read "Melee Range", and it does not read 5 yd
// either. Bloodrage (2687) sits on the self row, whose resolved max is 0.
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(0, None, Some(&store)))
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.expect("Heroic Strike view");
assert_eq!(v.range, None, "Attributes & 0x404 → no range cell");
let v = spell_tooltip_view(2687, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Bloodrage view");
let v = spell_tooltip_view(
2687,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Bloodrage view");
assert_eq!(v.range, None, "the self row resolves max 0");
}
@@ -364,14 +427,20 @@ fn the_pinned_c6_lines_on_real_data() {
};
let subs = benilla_formats::load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc");
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
let store = empty_player();
// 1 · The wand Shoot (5019, class 2 / submask bit 19) — "Requires Wands", red with no
// wand worn. The same row feeds the cast-fail line's SINGULAR "Wand" (see `cast_fail`).
assert_eq!(subs.name(2, 19), Some("Wands"), "the verbose plural");
assert_eq!(subs.display_name(2, 19), Some("Wand"), "the singular");
let v = spell_tooltip_view(5019, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
.expect("Shoot view");
let v = spell_tooltip_view(
5019,
&spells,
&mut t.ctx_for(&objects, 0, Some(&subs), Some(&store)),
)
.expect("Shoot view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Wands"));
assert!(!v.item_met, "nothing worn satisfies class 2 / bit 19 → red");
// A multi-bit mask is named by ItemSubClassMask.dbc, not skipped (law §3-EQUIPITEM — we
@@ -379,8 +448,12 @@ fn the_pinned_c6_lines_on_real_data() {
// melee subclasses, which that table names in one word.
let parry = spells.catalog.get(3127).expect("Parry 3127");
assert!(parry.equipped_item_subclass_mask.count_ones() > 1);
let v = spell_tooltip_view(3127, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
.expect("Parry view");
let v = spell_tooltip_view(
3127,
&spells,
&mut t.ctx_for(&objects, 0, Some(&subs), Some(&store)),
)
.expect("Parry view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Melee Weapon"));
// 2 · Attack (6603) — `Effect[0] == 78` omits the cast|cooldown line WHOLE, even though
@@ -388,7 +461,7 @@ fn the_pinned_c6_lines_on_real_data() {
let d = spells.catalog.get(6603).expect("Attack 6603");
assert_eq!(d.effects[0], 78, "SPELL_EFFECT_ATTACK");
assert!(!d.passive, "6603 carries Attributes 0x10, not 0x40");
let v = spell_tooltip_view(6603, &spells, &mut t.ctx(0, None)).expect("Attack view");
let v = spell_tooltip_view(6603, &spells, &mut t.ctx(&objects, 0, None)).expect("Attack view");
assert_eq!(v.cast_time, None, "the law's Effect[0] gate");
// …and the chance line the same Effect[0] selects (law line 10 / §3-CHANCE). ATTACK
// BYPASSES the passive gate, which is the whole reason a non-passive Attack shows a crit
@@ -399,35 +472,51 @@ fn the_pinned_c6_lines_on_real_data() {
(1109u16, 2.62f32.to_bits()), // PLAYER_CRIT_PERCENTAGE
(1107u16, 5.5f32.to_bits()), // PLAYER_DODGE_PERCENTAGE
]));
let v = spell_tooltip_view(6603, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Attack view");
let v = spell_tooltip_view(
6603,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&rated)),
)
.expect("Attack view");
assert_eq!(v.chance.as_deref(), Some("2.62% chance to crit"));
// Dodge (81) is passive and reads its own field.
let dodge = spells.catalog.get(81).expect("Dodge 81");
assert_eq!(dodge.effects[0], 20, "SPELL_EFFECT_DODGE");
assert!(dodge.passive, "81 carries Attributes 0x40");
let v =
spell_tooltip_view(81, &spells, &mut t.ctx_for(0, None, Some(&rated))).expect("Dodge view");
let v = spell_tooltip_view(81, &spells, &mut t.ctx_for(&objects, 0, None, Some(&rated)))
.expect("Dodge view");
assert_eq!(v.chance.as_deref(), Some("5.50% chance to dodge"));
// A spell naming none of the four effects has no line at all.
let v = spell_tooltip_view(133, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Fireball view");
let v = spell_tooltip_view(
133,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&rated)),
)
.expect("Fireball view");
assert_eq!(v.chance, None);
// 3 · Slow Fall (130) — "Reagents: Light Feather", inline-red while unowned (no store =
// owns nothing). The name rides the ask-once item cache, seeded here as the server would.
let d = spells.catalog.get(130).expect("Slow Fall 130");
assert_eq!(d.reagents[0], (17056, 1), "Light Feather ×1");
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Slow Fall view");
let v = spell_tooltip_view(
130,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Slow Fall view");
assert_eq!(
v.reagents, None,
"the template hasn't landed: the line waits rather than printing an id"
);
t.items
.insert_template(17056, Some(crate::items::test_template("Light Feather")));
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Slow Fall view");
let v = spell_tooltip_view(
130,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Slow Fall view");
assert_eq!(
v.reagents.as_deref(),
Some("Reagents: |cffff2020Light Feather|r"),
+9 -4
View File
@@ -55,7 +55,7 @@ use benilla_ui::script::{ScriptValue, TradeSideState, TradeSlotItem, TradeState,
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::target::Selection;
use crate::ui_party::GroupState;
use crate::ui_script::{UiFeed, UiInput};
@@ -766,14 +766,15 @@ fn own_item_at(
bag: i64,
slot: u32,
store: Option<&ObjectStore>,
objects: &Objects,
items: &Items,
commands: &NetCommands,
) -> Option<TradeItem> {
let (guid, count) = crate::ui_items::slot_guid_count(store, bag, slot, items);
let (guid, count) = crate::ui_items::slot_guid_count(store, bag, slot, objects);
if guid == 0 {
return None;
}
let entry = items.object(guid).and_then(|f| f.object_entry())?;
let entry = objects.object(guid).and_then(|f| f.object_entry())?;
let display_id = items
.template(entry, guid, commands)
.map(|t| t.display_info_id)
@@ -994,6 +995,7 @@ fn drain_trade(
commands: Res<NetCommands>,
selection: Res<Selection>,
group: Res<GroupState>,
objects: Objects,
items: Res<Items>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
) {
@@ -1051,7 +1053,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, &items, &commands) {
if let Some(item) = own_item_at(bag, slot, store, &objects, &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");
@@ -1346,6 +1348,7 @@ mod tests {
app.init_resource::<TradeSession>()
.init_resource::<GroupState>()
.init_resource::<Items>()
.init_resource::<GuidIndex>()
.insert_resource(NetCommands(tx))
.insert_resource(Selection {
target: None,
@@ -1844,6 +1847,7 @@ mod tests {
app.init_resource::<TradeSession>()
.init_resource::<GroupState>()
.init_resource::<Items>()
.init_resource::<GuidIndex>()
.insert_resource(NetCommands(tx))
.insert_resource(Selection::default());
let mut vm = UiScript::new().unwrap();
@@ -1891,6 +1895,7 @@ mod tests {
app.init_resource::<TradeSession>()
.init_resource::<GroupState>()
.init_resource::<Items>()
.init_resource::<GuidIndex>()
.insert_resource(NetCommands(tx))
.insert_resource(Selection::default());
app.insert_non_send_resource(UiScript::new().unwrap());
+6 -3
View File
@@ -38,7 +38,7 @@ use benilla_ui::script::{
use crate::creature_anim::{CastEvent, CastEventKind};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::spell::{cast_target, CastCommit, CastLadder};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_items::{count_of, item_icon, InventoryScope};
@@ -250,6 +250,7 @@ fn resolve_recipe(
icons: Option<&ItemDisplays>,
subclasses: Option<&crate::ui_items::ItemSubClasses>,
store: &ObjectStore,
objects: &Objects,
items: &Items,
commands: &NetCommands,
cooldowns: &crate::spell::Cooldowns,
@@ -262,7 +263,7 @@ fn resolve_recipe(
let mut reagents = Vec::new();
let mut num_available = u32::MAX;
for &(entry, need) in d.reagents.iter().filter(|&&(e, n)| e != 0 && n != 0) {
let have = count_of(&store.0, items, entry, InventoryScope::CARRIED);
let have = count_of(&store.0, objects, entry, InventoryScope::CARRIED);
let (name, icon) = match items.template(entry, 0, commands) {
Some(t) => (Some(t.name.clone()), item_icon(icons, t.display_info_id)),
None => (None, None),
@@ -337,7 +338,7 @@ fn resolve_recipe(
}
}
for &t in d.totems.iter().filter(|&&t| t != 0) {
let have = count_of(&store.0, items, t, InventoryScope::CARRIED) > 0;
let have = count_of(&store.0, objects, t, InventoryScope::CARRIED) > 0;
if let Some(info) = items.template(t, 0, commands) {
tools.push((info.name.clone(), have));
}
@@ -376,6 +377,7 @@ fn feed_trade_skill(
subclasses: Option<Res<crate::ui_items::ItemSubClasses>>,
repeat: Res<TradeSkillRepeat>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
objects: Objects,
items: Res<Items>,
commands: Res<NetCommands>,
cooldowns: Res<crate::spell::Cooldowns>,
@@ -417,6 +419,7 @@ fn feed_trade_skill(
icons.as_deref(),
subclasses.as_deref(),
store,
&objects,
&items,
&commands,
&cooldowns,
+69 -35
View File
@@ -18,39 +18,62 @@
//! Same shape as `world_api_wall.rs`: `CEILING` is the standing count, `SLACK` keeps a single
//! closure from failing the gate while making it impossible to bank a whole stage of work without
//! writing the new number down. `WOW_UISCRIPT_DUMP=1` prints the files.
//!
//! **Four families, one gated** (decision 2338). The `ui_*` window feeds are 1177's feed layer
//! in a hundred modules (2265 §A3 makes them one); `capture/` probes read the stock binding on
//! purpose; test modules test. None of those is what 1177 meant, so they are reported, and only
//! the remaining gameplay files — the ones that can reach zero — are ratcheted.
use std::path::{Path, PathBuf};
/// The standing count. **2026-09-16, 164** — measured the day the instrument was built, against
/// 1177's 141 five weeks earlier. It moved twice on that one day, for two different files, and
/// both reasons are kept because each is a different kind of legitimate:
/// Where a file that names the VM stands under 1177 (decision 2338).
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum Family {
/// Gameplay code that knows the FrameXML VM exists — the camera, targeting, the minimap,
/// the sound layer, bindings, the glue screens. **1177's number**: the one this wall
/// gates, and the one that can reach zero.
Game,
/// `ui_*` — a window's feed. 1177 makes the feeds THE legitimate caller; that they are a
/// hundred modules and not one is 2265 §A3's refactor, not a leak. Reported, never gated.
Feed,
/// `capture/` — a live probe that asks the STOCK binding on purpose (2283, 2290): fed a
/// model value it would prove nothing. Reported.
Probe,
/// A test module beside the code it tests. Reported.
Test,
}
fn family(rel: &str) -> Family {
let file = rel.rsplit('/').next().unwrap_or(rel);
if rel.starts_with("capture/") {
Family::Probe
} else if file == "tests.rs" || file.ends_with("_tests.rs") || rel.contains("/tests/") {
Family::Test
} else if rel.starts_with("ui_") {
Family::Feed
} else {
Family::Game
}
}
/// The standing count of **`Game`** files — gameplay code outside the feeds that names the VM.
///
/// - **165 (decision 2279)** — `game_plugins.rs`, the structural test for the VM's one-shot
/// consumers, which has to name the identifier it scans for. A test *about* the wall, not a file
/// that learned the VM exists.
/// - **166 (decision 2283)** — `capture/probe_stone.rs`, the meeting-stone live probe, joining the
/// fifteen sibling probes that read the VM *on purpose*. A probe's whole value is that it asks
/// the **stock** binding (`IsInMeetingStoneQueue()`, `MiniMapMeetingStoneFrame:IsShown()`)
/// rather than our own mirror of it, so feeding it a model value would make it prove nothing.
////// - **167 (decision 2290)** — `capture/probe_bg.rs`, the inside-a-battleground live probe, the
/// same class again and for the same reason, sharpened: it takes the port through the stock
/// `AcceptBattlefieldPort(slot, 1)` and reads the battleground back through
/// `GetNumWorldStateUI()`, `GetBattlefieldStatus()` and a Lua event tap. A probe that asked our
/// own model instead could not have found what this one did — that the battleground's
/// `CHAT_MSG_BG_SYSTEM_NEUTRAL` lines reach the interface, which no app-side reading shows.
///
/// The ratchet is for *production* feed code. Both numbers still went up, and both are written
/// down — which is the whole point of it, and why two sessions raising it the same day collided
/// here instead of quietly passing each other.
const CEILING: usize = 167;
/// **2026-09-16, 164** was the whole outside-`ui_script/` count the day the instrument was
/// built, against 1177's 141 five weeks earlier; it moved to 167 the same day (2279's
/// structural test, 2283's and 2290's probes — each legitimate, each written down here, and
/// two sessions colliding on this line instead of quietly passing each other). **Decision 2338
/// split the count**: of those 167, 96 were the window feeds, 18 the probes, 8 tests — none of
/// them what 1177 meant — and **45** were gameplay files that know the VM. The wall gates the
/// 45 and reports the rest, so the number here is one that can actually reach 1177's zero.
const CEILING: usize = 45;
/// How far under [`CEILING`] the count may sit before the test asks for the ceiling to follow it.
const SLACK: usize = 8;
const SLACK: usize = 4;
#[test]
fn the_vm_is_named_by_no_more_files_than_the_ceiling_says() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files: Vec<String> = rs_files(&src)
let mut files: Vec<(Family, String)> = rs_files(&src)
.into_iter()
.filter_map(|p| {
let rel = p
@@ -62,28 +85,39 @@ fn the_vm_is_named_by_no_more_files_than_the_ceiling_says() {
return None;
}
let text = std::fs::read_to_string(&p).ok()?;
names_ident(&text, "UiScript").then_some(rel)
names_ident(&text, "UiScript").then(|| (family(&rel), rel))
})
.collect();
files.sort();
let n = files.len();
eprintln!("files outside ui_script/ naming UiScript: {n} (ceiling {CEILING}, slack {SLACK})");
let count = |f: Family| files.iter().filter(|(fam, _)| *fam == f).count();
let n = count(Family::Game);
eprintln!(
"files outside ui_script/ naming UiScript: {} — {n} game (ceiling {CEILING}, slack \
{SLACK}), {} feeds, {} probes, {} tests",
files.len(),
count(Family::Feed),
count(Family::Probe),
count(Family::Test),
);
if std::env::var("WOW_UISCRIPT_DUMP").is_ok() {
eprintln!("{}", files.join("\n"));
for (fam, rel) in &files {
eprintln!(" {fam:?}\t{rel}");
}
}
assert!(
n <= CEILING,
"{n} files outside ui_script/ name UiScript; the ceiling is {CEILING}.\n\
A new file learned that the FrameXML VM exists. Decision 1177's line is that only the \
feed layer may know; either feed a model value instead, or — if this file genuinely is \
feed code — raise CEILING here with the reason, the way world_api_wall.rs requires.\n\
WOW_UISCRIPT_DUMP=1 lists the files."
"{n} gameplay files outside ui_script/ and the feeds name UiScript; the ceiling is \
{CEILING}.\nA new file learned that the FrameXML VM exists. Decision 1177's line is that \
only the feed layer may know: feed a model value instead, or — if this file genuinely is \
feed code — it belongs under a `ui_*` module, and a probe under `capture/`. Raise CEILING \
here only with the reason, the way world_api_wall.rs requires.\nWOW_UISCRIPT_DUMP=1 lists \
the files by family."
);
assert!(
n + SLACK >= CEILING,
"only {n} files name UiScript and the ceiling still says {CEILING}. Lower CEILING to {n} \
in this file so the next coupling has to earn its place — the ratchet only holds if the \
number follows the work down."
"only {n} gameplay files name UiScript and the ceiling still says {CEILING}. Lower CEILING \
to {n} in this file so the next coupling has to earn its place — the ratchet only holds \
if the number follows the work down."
);
}
+396 -37
View File
@@ -21,6 +21,16 @@
//! not an API consumer), doc comments (a `[`crate::x::Y`]` link is prose), and the composition
//! root's plugin registrations are counted like anything else — 1164's plugin-group collapse has
//! to actually happen for them to stop counting.
//!
//! **Since decision 2338 the number that gates is the LEAKS, not the surface.** For five weeks
//! the one number went up twenty-seven times, every raise a paragraph saying "a PUBLISH", and
//! never once down: the falsifier 1163 set at forty had fired, and the ratchet had become a
//! diary. So the sort is data now. [`PUBLISHED`] is the designed API, each row with the record
//! that published it; [`SORTED_LEAKS`] is what 1164 filed to close and how; anything else that
//! crosses is an unsorted leak. The surface and the published count are reported; only the leak
//! count is ratcheted, and it ratchets toward zero. Adding a `PUBLISHED` row is the act that
//! raising the ceiling used to be — a claim made in review, with its record — and a row nothing
//! names any more fails the test until it is deleted, so the tables cannot rot.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
@@ -110,7 +120,13 @@ const INSTRUMENT_ROOTS: &[&str] = &["art_scope", "debug_panel", "perf", "pipe_wa
/// the engine's `LogPlugin` because that is where the layer has to be installed. An API shaped
/// by what a crash report wanted to attach is 1163's failure exactly, so it is counted here, not
/// in the doorway.
const INSTRUMENT_CONSUMERS: &[&str] = &["crash", "debug_panel", "perf", "pipe_warm"];
///
/// `capture` (decision 2338) is the fifth, and the oldest ruling of the five: 1164 §Measured
/// already counted `capture/*` among the instruments outside the doorway (its "21 named only by
/// instruments"), but this list never said so, and a probe's reach — `static_gx::StaticGx`,
/// `liquid::FoamPatch`, `ride_frame::ride_matrix`, the water-effect fixtures — was gated as if
/// the game had asked for it. A probe that reads the engine's own state is what a probe is for.
const INSTRUMENT_CONSUMERS: &[&str] = &["capture", "crash", "debug_panel", "perf", "pipe_warm"];
/// Is this file one of the app-side instruments?
fn is_instrument_consumer(rel: &str) -> bool {
@@ -118,8 +134,223 @@ fn is_instrument_consumer(rel: &str) -> bool {
INSTRUMENT_CONSUMERS.contains(&root)
}
/// The gate. 1163 sets it at forty; this is the current standing count, ratcheted down by each
/// commit that lands a DOWN or a CLOSE from 1164's sort.
/// **The designed API** — every engine item game code may name, and the record that published
/// it (decision 2338). `1164` is the sort's own PUBLISH bucket and its facades; a four-digit
/// number is the record whose paragraph in the raise log below said "a PUBLISH"; `wall` is a
/// raise-log paragraph that published the item without naming a record (grep the name below);
/// `1167` published the facades the crate move landed; `2338` is the boot seam — the process-level
/// entries a binary needs to run the engine at all (`worldview::run`, `tuned_default_plugins`,
/// `BuildId`, the background-window and thread-QoS helpers), the same class as the three plugins
/// the log already published. **Adding a row is a claim made in review, with its record.** A row
/// no game file names any more fails the test until it is deleted.
const PUBLISHED: &[(&str, &str)] = &[
("bgwin::BgWinPlugin", "wall"),
("bgwin::background_run", "2338"),
("bgwin::no_pixel_run", "2338"),
("billboard::BillboardCard", "1164"),
("boot::tuned_default_plugins", "2338"),
("build_id::BuildId", "2338"),
("build_id::banner", "1179"),
("collision::ColliderEpoch", "1384"),
("collision::MoverTraceExclusions", "1767"),
("collision::WorldCollision", "1164"),
("decal::WorldDecal", "1164"),
("dev_state::STILL_INPUTS_CHANGED", "1979"),
("doodad_anim::AnimMatPart", "2295"),
("doodad_anim::DoodadAnimHost", "1365"),
("doodad_anim::MatAnim", "1164"),
("doodad_anim::TintLoop", "2295"),
("doodad_anim::UvLoops", "2282"),
("doodad_anim::register_entity_uv", "2295"),
("doodad_anim::register_fx_uv", "2282"),
("doodad_anim::register_tint", "2295"),
("doodad_anim::spawn_anim_host", "1164"),
("ffx_glow::FfxBackdrop", "2234"),
("ffx_glow::FfxGlow", "1164"),
("ffx_glow::GlueFfx", "1731"),
("final_pass::FinalPassTarget", "2206"),
("instance_tint::InstanceTintMirrors", "1731"),
("instance_tint::InstanceTints", "1164"),
("interact::PickMesh", "1164"),
("interact::WorldObject", "1164"),
("interact::WorldPick", "1164"),
("interact::cast_pick_ray", "1164"),
("interior::NodeAmbient", "2079"),
("lighting::LightBlob", "1164"),
("lighting::WorldTime", "1167"),
("lighting::WowLighting", "1164"),
("mac_quit::MacQuitPlugin", "1528"),
("mat_anim_table::MatAnimMirrors", "2023"),
("mat_anim_table::MatAnimTable", "1381"),
("mat_anim_table::affine_row", "2019"),
("mesh_tag::HIGHLIGHT_BIT", "1164"),
("model_fade::ModelFade", "1164"),
("model_fade::ParentModel", "1164"),
("model_fade::RenderFade", "1164"),
("model_fade::UnitRenderAlpha", "wall"),
("model_forms::ModelForms", "1167"),
("model_render::BatchVariants", "1164"),
("model_render::EntityUvLane", "2295"),
("model_render::M2BatchMaterials", "1164"),
("model_render::ModelKind", "1164"),
("model_render::ModelPart", "1164"),
("model_render::lazy::realize", "1940"),
("modkeys::SyntheticHold", "wall"),
("particles::ViewThrottled", "1559"),
("particles::render::EFFECT_DRAW_STATS", "1955"),
("particles::spawn_emitter", "1164"),
("ride_frame::RideFrame", "1661"),
("rig_anim::AnimParked", "wall"),
("rig_anim::GlobalSeqDrive", "wall"),
("rig_anim::PosePost", "wall"),
("rig_anim::RigAnchor", "wall"),
("rig_anim::RigFrame", "wall"),
("rig_anim::RigPose", "wall"),
("rig_palette::RigPalettes", "1164"),
("rig_palette::RigSkin", "1164"),
("rig_rider::RigRider", "1609"),
("schedule::WorldLive", "1160"),
("schedule::WorldStage", "1164"),
("sky_order::Rung", "wall"),
("terrain_stream::SPAWN_XY", "wall"),
("terrain_stream::ViewFocus", "1160"),
("terrain_stream::WorldLoadProgress", "wall"),
("thread_qos::QosClass", "2338"),
("thread_qos::ThreadQosPlugin", "wall"),
("thread_qos::promote_current_thread", "2338"),
("view::MsaaFormats", "1632"),
("view::MsaaSetting", "1629"),
("view::ViewDistance", "1164"),
("view::Viewer", "wall"),
("view::WorldCamera", "1164"),
("vis_chain::VisChainOnly", "1441"),
("weather::WeatherMessage", "1164"),
("weather::WeatherState", "2181"),
("wmo_portal::room_pvs_visible", "1475"),
("world_census::CensusReport", "1164"),
("world_census::WorldCensus", "1164"),
("world_map::CurrentMap", "1164"),
("world_map::MapChange", "1164"),
("world_plugins::WorldPlugins", "1164"),
("world_point::Subject", "1164"),
("world_point::WorldPoint", "1164"),
("world_unit::ViewerUnit", "wall"),
("world_unit::WorldUnit", "wall"),
("worldview::run", "2338"),
];
/// **The sorted leaks** — items 1164 filed to close, still crossing today, with the bucket it
/// filed them under (decision 2338 moved the sort here from prose). `CLOSE/absorb` means a facade
/// takes the call, `CLOSE/invert` the engine publishes the fact instead of the game reaching in,
/// `CLOSE/move-engine` mis-filed engine code moving engine-side (the twelve `terrain_stream::*`
/// items are `entities/wmo_props.rs`, 1164's fourth candidate, invisible until 2338's scanner
/// fix), `CLOSE/move-game` gameplay moving out of the engine. A leak that is in neither table is
/// unsorted: it crossed after 1164 and no record has said what happens to it. A row no game
/// file names any more fails the test until it is deleted — closing a leak is deleting its row.
const SORTED_LEAKS: &[(&str, &str)] = &[
("billboard::BillboardJointRig", "CLOSE/absorb"),
("billboard::BillboardPlace", "CLOSE/absorb"),
("billboard::billboard_basis", "CLOSE/move-engine"),
("billboard::billboard_joint_palette", "CLOSE/absorb"),
("clutter::ClutterConfig", "CLOSE/absorb"),
("collision::PickOccluder", "CLOSE/absorb"),
("decal::DecalFrame", "CLOSE/absorb"),
("doodad_anim::DoodadAnimTier", "CLOSE/absorb"),
("doodad_anim::TintAnimMaterials", "CLOSE/move-engine"),
("doodad_anim::UvAnimMaterials", "CLOSE/move-engine"),
("doodad_anim::classify", "CLOSE/absorb"),
("doodad_anim::sample_mat_anim", "CLOSE/invert"),
("doodad_anim::wants_rig", "CLOSE/move-engine"),
("entity_shade::GroundShade", "CLOSE/absorb"),
("ffx_glow::FfxDeathFade", "CLOSE/absorb"),
("instance_tint::IDENTITY", "CLOSE/absorb"),
("instance_tint::pack", "CLOSE/absorb"),
("interact::PickBox", "CLOSE/absorb"),
("interact::WorldClick", "CLOSE/move-game"),
("interact::WorldRightClick", "CLOSE/move-game"),
("interact::WorldRightPress", "CLOSE/move-game"),
("interact::cast_pick_ray_inflated", "CLOSE/absorb"),
("interior::BodyBakeCenter", "CLOSE/absorb"),
("interior::ContainmentAttach", "CLOSE/absorb"),
("interior::InteriorLit", "CLOSE/invert"),
("interior::InteriorReauthor", "CLOSE/invert"),
("interior::classify_entity_interior", "CLOSE/absorb"),
("interior::part_interior_lit", "CLOSE/absorb"),
("lighting::GameClock", "CLOSE/absorb"),
("lighting::PropProbeSlot", "CLOSE/move-engine"),
("lighting::PropProbes", "CLOSE/move-engine"),
("liquid::WaterChunkInfo", "CLOSE/absorb"),
("map_proj::WorldProj", "CLOSE/move-game"),
("map_proj::ZoneRect", "CLOSE/move-game"),
("mesh_tag::describe", "CLOSE/absorb"),
("mesh_tag::with_alpha", "CLOSE/invert"),
("model_fade::DespawnFade", "CLOSE/move-game"),
("model_fade::FadeMaterials", "CLOSE/absorb"),
("model_fade::FadeSet", "CLOSE/absorb"),
("model_fade::JoinedFade", "CLOSE/absorb"),
("model_fade::MAX_MODEL_CHAIN", "CLOSE/invert"),
("model_fade::PartFade", "CLOSE/absorb"),
("model_fade::PendingAppearFade", "CLOSE/invert"),
("model_fade::UnitAppearFade", "CLOSE/move-game"),
("model_fade::apply_render_fade", "CLOSE/absorb"),
("model_fade::fade_alpha", "CLOSE/absorb"),
("model_fade::join_unit_appear_fade", "CLOSE/absorb"),
("model_render::FarSideOfWater", "CLOSE/invert"),
("model_render::FarSideTwins", "CLOSE/invert"),
("model_render::ModelVisSet", "CLOSE/absorb"),
("model_render::ShadeSel", "CLOSE/absorb"),
("model_render::far_resolved", "CLOSE/invert"),
("model_render::replace_fog_policy", "CLOSE/absorb"),
("modkeys::DEV_CHORD", "CLOSE/move-engine"),
("modkeys::dev_chord", "CLOSE/move-engine"),
("particles::EmitClock", "CLOSE/absorb"),
("particles::EmitterFade", "CLOSE/invert"),
("particles::EmitterFrames", "CLOSE/absorb"),
("particles::OwnerLoss", "CLOSE/absorb"),
("particles::ParticleEmitter", "CLOSE/invert"),
("particles::buffer::EffectLightOverride", "CLOSE/invert"),
("particles::buffer::EffectQuads", "CLOSE/invert"),
("particles::buffer::EffectVertex", "CLOSE/absorb"),
("particles::buffer::WorldEffectDraw", "CLOSE/absorb"),
("particles::buffer::begin_effect_frame", "CLOSE/absorb"),
("ribbons::RibbonSeq", "CLOSE/absorb"),
("ribbons::RibbonTrail", "CLOSE/invert"),
("ribbons::spawn_ribbon", "CLOSE/absorb"),
("rig_palette::RigPaletteMirrors", "CLOSE/absorb"),
("rig_palette::RigPart", "CLOSE/absorb"),
("rig_palette::RigStarved", "CLOSE/invert"),
("rig_palette::rig_cost_enabled", "CLOSE/absorb"),
("terrain_stream::AreaAuthoritySet", "CLOSE/absorb"),
("terrain_stream::CurrentArea", "CLOSE/absorb"),
("terrain_stream::PendingCollider", "CLOSE/move-engine"),
("terrain_stream::PropLobeLight", "CLOSE/move-engine"),
("terrain_stream::ShadeResolve", "CLOSE/move-engine"),
("terrain_stream::SpawnedModel", "CLOSE/move-engine"),
("terrain_stream::TerrainStreamer", "CLOSE/absorb"),
("terrain_stream::build_collider_task", "CLOSE/move-engine"),
("terrain_stream::doodad_ground_shade", "CLOSE/move-engine"),
("terrain_stream::fold_interior_probe", "CLOSE/move-engine"),
("terrain_stream::hex_word", "CLOSE/move-engine"),
("terrain_stream::m2_anim_bound", "CLOSE/move-engine"),
("terrain_stream::m2_fade", "CLOSE/move-engine"),
(
"terrain_stream::placement_collider_data",
"CLOSE/move-engine",
),
("terrain_stream::point_light", "CLOSE/absorb"),
("terrain_stream::spawn_model_entities", "CLOSE/move-engine"),
("view::CAM_FOVY", "CLOSE/absorb"),
("view::FARCLIP_RANGE", "CLOSE/absorb"),
("view::NEARCLIP_DEFAULT", "CLOSE/absorb"),
("wmo_portal::UnitWmoRoom", "CLOSE/absorb"),
("wmo_portal::WmoPortalInstance", "CLOSE/absorb"),
("wmo_portal::WmoPvsSet", "CLOSE/absorb"),
];
/// The gate — on the **leaks**. 1163 set the surface at forty; 1164 sorted it; 2338 made the
/// sort data and pointed the ratchet at what is left to close. The raise log below is the
/// history of the surface count this constant gated until then, and the source of every
/// `PUBLISHED` row that names a record or `wall`.
///
/// **Lower it when you close something.** The lower bound below exists so that a session which
/// closes twenty leaks cannot leave the ceiling standing at the old number, which would silently
@@ -496,61 +727,127 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// area id is what told this instrument that the port lands at Silverwing Hold and that the
/// release lands at the graveyard. The alternative was to re-derive the leaf in the probe from
/// tiles the engine already resolved, which is the copy-the-rule drift this wall exists to stop.
const CEILING: usize = 190;
///
/// **190 → the leaks (decision 2338).** The surface stood at 190 published-and-leaked items
/// together; the tables above split it, `capture` joined the instruments as 1164 always counted
/// it, and the scanner learned to read a `use` group that spans lines (nine imports, twenty-one
/// items counted nowhere until then — see [`logical_lines`]). What this constant gates from here
/// is the leak count alone, measured on the tree that lands.
const LEAK_CEILING: usize = 107;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
/// impossible to bank a whole stage of work without writing the new number down.
const SLACK: usize = 10;
/// How far under [`LEAK_CEILING`] the real count may sit before this test asks for the ceiling
/// to be lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while
/// making it impossible to bank a whole stage of work without writing the new number down.
const LEAK_SLACK: usize = 10;
#[test]
fn the_world_api_doorway_stays_shut() {
let all = measure();
// Partition: an item only an instrument names is the instruments' surcharge, not the designed
// doorway (see `INSTRUMENT_CONSUMERS`). One a game module *also* names is the doorway's.
// Partition one: an item only an instrument names is the instruments' surcharge, not the
// designed doorway (see `INSTRUMENT_CONSUMERS`). One a game module *also* names is the doorway's.
let (probes, surface): (BTreeMap<_, _>, BTreeMap<_, _>) = all
.into_iter()
.partition(|(_, files)| files.iter().all(|f| is_instrument_consumer(f)));
let n = surface.len();
// Always say the number: `cargo test -p benilla-app --test world_api_wall -- --nocapture` is
// the one-command answer to "where is the wall now", which is asked on every unit of 1164's
// sort and used to need a deliberately-failing ceiling to get.
// Partition two (decision 2338): the doorway is the published API plus the leaks, and only
// the leaks gate.
let published_by: BTreeMap<&str, &str> = PUBLISHED.iter().copied().collect();
let sorted_by: BTreeMap<&str, &str> = SORTED_LEAKS.iter().copied().collect();
let both: Vec<&str> = published_by
.keys()
.filter(|k| sorted_by.contains_key(*k))
.copied()
.collect();
assert!(
both.is_empty(),
"an item cannot be both published and a sorted leak: {both:?}"
);
let (published, leaks): (BTreeMap<_, _>, BTreeMap<_, _>) = surface
.into_iter()
.partition(|(k, _)| published_by.contains_key(k.as_str()));
let n = leaks.len();
let sorted = leaks
.keys()
.filter(|k| sorted_by.contains_key(k.as_str()))
.count();
// Always say the numbers: `cargo test -p benilla-app --test world_api_wall -- --nocapture`
// is the one-command answer to "where is the wall now".
eprintln!(
"world API surface: {n} items (ceiling {CEILING}, slack {SLACK}) \
+ {} named only by the instruments",
"world API surface: {} items — {n} leaks (ceiling {LEAK_CEILING}, slack {LEAK_SLACK}; \
{sorted} sorted by 1164, {} unsorted), {} published; + {} named only by the instruments",
published.len() + n,
n - sorted,
published.len(),
probes.len()
);
// `WOW_API_DUMP=1` prints the surface itself, most-named first. The count alone answers "is
// the wall holding"; it cannot answer "which item did that unit actually retire", and a unit
// that reads as net-zero is exactly when you need the list (a second scanner disagreeing by
// one is how this got added).
// The tables are the sort. A row no game file names any more is a row to delete — a stale
// published row would let the item cross again for free, and a stale leak row is a closure
// nobody wrote down.
let stale: Vec<&str> = PUBLISHED
.iter()
.map(|(k, _)| *k)
.filter(|k| !published.contains_key(*k) && !probes.contains_key(*k))
.chain(
SORTED_LEAKS
.iter()
.map(|(k, _)| *k)
.filter(|k| !leaks.contains_key(*k) && !probes.contains_key(*k)),
)
.collect();
assert!(
stale.is_empty(),
"rows in PUBLISHED / SORTED_LEAKS that no game file names any more — delete them (a \
closed leak is a deleted row; a published item nobody uses is not API):\n {}",
stale.join("\n ")
);
// `WOW_API_DUMP=1` prints the surface itself, most-named first, each item with what the
// tables say about it. The count alone answers "is the wall holding"; it cannot answer
// "which item did that unit actually retire", and a unit that reads as net-zero is exactly
// when you need the list.
if std::env::var("WOW_API_DUMP").is_ok() {
let mut rows: Vec<_> = surface.iter().map(|(k, v)| (v.len(), k)).collect();
rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
eprintln!("{}", render(&rows));
let mut probe_rows: Vec<_> = probes.iter().map(|(k, v)| (v.len(), k)).collect();
probe_rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
let tagged = |m: &BTreeMap<String, BTreeSet<String>>, tag: &dyn Fn(&str) -> String| {
let mut rows: Vec<_> = m
.iter()
.map(|(k, v)| (v.len(), format!("{k} [{}]", tag(k))))
.collect();
rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
rows.iter()
.map(|(n, k)| format!(" {n:3} file(s) {k}"))
.collect::<Vec<_>>()
.join("\n")
};
eprintln!(
"--- named only by the instruments ---\n{}",
render(&probe_rows)
"--- leaks ---\n{}\n--- published ---\n{}\n--- named only by the instruments ---\n{}",
tagged(&leaks, &|k| sorted_by
.get(k)
.map_or_else(|| "unsorted".to_string(), |b| b.to_string())),
tagged(&published, &|k| published_by[k].to_string()),
tagged(&probes, &|k| published_by.get(k).map_or_else(
|| "instrument".to_string(),
|b| format!("published {b}")
)),
);
}
if n > CEILING {
let fresh: Vec<_> = surface.iter().map(|(k, v)| (v.len(), k)).collect();
if n > LEAK_CEILING {
let fresh: Vec<_> = leaks
.iter()
.filter(|(k, _)| !sorted_by.contains_key(k.as_str()))
.map(|(k, v)| (v.len(), k))
.collect();
panic!(
"world API surface is {n} items, ceiling is {CEILING}.\n\
Something new crossed the line. Either close it, or — if it is genuine engine API — \
raise the ceiling in this file WITH the justification, the way decision 1164 requires \
for every item in the PUBLISH bucket.\n\n{}",
"world API leaks: {n}, ceiling {LEAK_CEILING}.\n\
Something new crossed the line and no record publishes it. Either close it, or — if \
it is genuine engine API — add it to PUBLISHED with the record that says so (decision \
2338); never a bare number. The unsorted leaks on this tree:\n\n{}",
render(&fresh)
);
}
assert!(
n + SLACK >= CEILING,
"world API surface is down to {n} items and the ceiling still says {CEILING}. \
Lower CEILING to {n} in this file so the next leak has to earn its place — the ratchet \
n + LEAK_SLACK >= LEAK_CEILING,
"world API leaks are down to {n} and the ceiling still says {LEAK_CEILING}. Lower \
LEAK_CEILING to {n} in this file so the next leak has to earn its place — the ratchet \
only holds if the number follows the work down."
);
}
@@ -758,7 +1055,7 @@ fn paths_in(text: &str, split: bool, want_engine: bool) -> Vec<(String, usize)>
// `#[cfg(test)] pub(crate) use …;`) it ends at that statement's semicolon.
let mut test_at: Option<i32> = None;
let mut test_open = false;
for (n, line) in text.lines().enumerate() {
for (n, line) in logical_lines(text).iter().map(|(n, l)| (*n, l.as_str())) {
let t = line.trim_start();
if t.starts_with("#[cfg(test)]") {
test_at = Some(depth);
@@ -851,6 +1148,46 @@ fn paths_in(text: &str, split: bool, want_engine: bool) -> Vec<(String, usize)>
found
}
/// The file's lines, with a `use` statement whose brace group spans several physical lines
/// joined into one. The scan is line-based, and [`expand`] given a `{` with nothing after it
/// yielded nothing — so every member of such a group was invisible to both walls: nine imports
/// in game files, twenty-one items counted nowhere, among them a 1164 CLOSE row and the twelve
/// `terrain_stream::*` names `entities/wmo_props.rs` reaches for (decision 2338). Each joined
/// line keeps its first physical line's number; a `//` comment inside the group is cut at the
/// comment (a `use` statement has no string literal a `//` could sit in).
fn logical_lines(text: &str) -> Vec<(usize, String)> {
fn open(s: &str) -> i32 {
s.matches('{').count() as i32 - s.matches('}').count() as i32
}
fn strip_comment(s: &str) -> &str {
s.find("//").map_or(s, |i| &s[..i])
}
let mut out = Vec::new();
let mut lines = text.lines().enumerate();
while let Some((n, line)) = lines.next() {
let t = line.trim_start();
let is_use = t.starts_with("use ")
|| (t.starts_with("pub") && t.contains(" use ") && !t.contains('='));
if is_use && open(strip_comment(line)) > 0 {
let mut joined = strip_comment(line).trim_end().to_string();
let mut depth = open(&joined);
for (_, next) in lines.by_ref() {
let next = strip_comment(next);
joined.push(' ');
joined.push_str(next.trim());
depth += open(next);
if depth <= 0 {
break;
}
}
out.push((n, joined));
} else {
out.push((n, line.to_string()));
}
}
out
}
fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
@@ -984,3 +1321,25 @@ fn render(items: &[(usize, &String)]) -> String {
.collect::<Vec<_>>()
.join("\n")
}
/// A `use` group that spans lines counts every member — the blind spot 2338 closed. The
/// comment inside the group is cut, and the closing line's `};` ends the statement.
#[test]
fn a_use_group_spanning_lines_counts_every_member() {
let text =
"use benilla_world::interact::{\n PickParts,\n ray_mesh_bounds, // a lead\n \
ray_posed_mesh,\n};\nfn f() {}\n";
let mut found: Vec<String> = paths_in(text, true, true)
.into_iter()
.map(|(p, _)| p)
.collect();
found.sort();
assert_eq!(
found,
[
"interact::PickParts",
"interact::ray_mesh_bounds",
"interact::ray_posed_mesh"
]
);
}
+8
View File
@@ -643,6 +643,14 @@ pub enum SessionEvent {
/// hatch on this packet; a totem's death and a DynamicObject's expiry send it too, and those
/// carry nothing armable, so the consumer falls back to the ordinary instant destroy.
GameObjectDespawnAnim { guid: u64 },
/// The server opened a container for us (`SMSG_OPEN_CONTAINER`, one `u64` item guid) — a bag
/// was equipped into a bag slot. The reference fires `BAG_OPEN(containerId)`: 0 for our own
/// guid (the backpack), 1..10 for the slot in its bag cache, nothing for a miss (2339).
OpenContainer { item: u64 },
/// The server changed OUR stand state (`SMSG_STANDSTATE_UPDATE`, one `u8`): the eat/drink sit,
/// the stand on damage. Applied to the local player ungated, through the same setter the
/// volunteered change uses — the reference's `0x603e50` → `0x6127b0` (2339).
StandStateUpdate { state: u8 },
/// The fishing channel ended with nothing hooked (`SMSG_FISH_NOT_HOOKED`, empty body):
/// the red `ERR_FISH_NOT_HOOKED` toast (decision 1086).
FishNotHooked,
@@ -951,6 +951,15 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::GameObjectDespawnAnim { guid } => {
vec![SessionEvent::GameObjectDespawnAnim { guid }]
}
ServerPacket::OpenContainer { item } => vec![SessionEvent::OpenContainer { item }],
ServerPacket::StandStateUpdate { state } => {
vec![SessionEvent::StandStateUpdate { state }]
}
// Deliberately nothing: the reference's `0x5e7d70` reads the echoed guid into a stack local
// and discards it — no global, no field, no event — and the inspect window paints off the
// target's `PLAYER_VISIBLE_ITEM_*` fields without waiting (decision 0631; byte-confirmed by
// wow-re's round for 2339). Parsed so the wire stays in step and no tally calls it dropped.
ServerPacket::Inspect { .. } => Vec::new(),
ServerPacket::FishNotHooked => vec![SessionEvent::FishNotHooked],
ServerPacket::FishEscaped => vec![SessionEvent::FishEscaped],
// The keepalive echo: the io layer matches the sequence against its ping clock to compute
@@ -829,6 +829,21 @@ pub(super) fn read_item_time(r: &mut &[u8]) -> io::Result<(u64, u32)> {
///
/// `seconds == 0` means expired, and the reference stores that as **no timer** (`0x5d9cc0` writes
/// a `0` deadline whenever `seconds <= 0`), not as "0 seconds left".
/// Read `SMSG_OPEN_CONTAINER` → the item guid. VERIFIED vmangos `Player::SendOpenContainer`
/// (`Objects/Player.cpp:11707`) → `WorldPackets::Item::OpenContainer`, whose whole body is the item
/// guid; the one sender is `HandleAutoEquipItemOpcode` (`ItemHandler.cpp:227`) when the destination
/// is a bag slot. The reference fires `BAG_OPEN(containerId)` from it and nothing else (2339).
pub(super) fn read_open_container(r: &mut &[u8]) -> io::Result<u64> {
read_u64_le(r)
}
/// Read `SMSG_INSPECT` → the echoed target guid. VERIFIED vmangos `HandleInspectOpcode`
/// (`MiscHandler.cpp:957`) → `WorldPackets::Misc::InspectResponse`, one `u64`. The reference's
/// handler `0x5e7d70` reads it and does nothing with it (2339; decision 0631's call).
pub(super) fn read_inspect(r: &mut &[u8]) -> io::Result<u64> {
read_u64_le(r)
}
pub(super) fn read_item_enchant_time(r: &mut &[u8]) -> io::Result<(u64, u32, u32)> {
let item_guid = read_u64_le(r)?;
let slot = read_u32_le(r)?;
@@ -846,6 +861,35 @@ mod tests {
// Byte-exact encode goldens — the item-move CMSG bodies (VERIFIED field order + widths against
// vmangos `Server/Packets/Item.cpp` `ReadFromWorldPacket`s; every field is a `uint8`).
#[test]
fn open_container_decodes() {
// The whole body is the bag's guid (VERIFIED vmangos `Player::SendOpenContainer` →
// `WorldPackets::Item::OpenContainer::AppendBodyTo` writes only `itemGuid`); decision 2339.
let body = 0x4000_0000_0012_3456u64.to_le_bytes();
match crate::messages::parse_server(crate::messages::opcode::SMSG_OPEN_CONTAINER, &body)
.unwrap()
{
crate::messages::ServerPacket::OpenContainer { item } => {
assert_eq!(item, 0x4000_0000_0012_3456);
}
other => panic!("expected OpenContainer, got {}", other.name()),
}
}
#[test]
fn inspect_decodes_and_is_ignored() {
// One u64, the echoed target (VERIFIED vmangos `Misc::InspectResponse::AppendBodyTo`), and
// `decode()` turns it into nothing — the reference's handler discards it too (2339, 0631).
let body = 0x0000_0000_0000_0007u64.to_le_bytes();
let packet =
crate::messages::parse_server(crate::messages::opcode::SMSG_INSPECT, &body).unwrap();
assert!(matches!(
packet,
crate::messages::ServerPacket::Inspect { guid: 7 }
));
assert!(crate::events::decode(packet).is_empty());
}
#[test]
fn auto_equip_item_body() {
// 266: srcbag, srcslot (Item.cpp:17-21).
@@ -134,6 +134,14 @@ pub const CMSG_SWAP_INV_ITEM: u16 = 0x010D; // 269
pub const CMSG_SPLIT_ITEM: u16 = 0x010E; // 270
pub const CMSG_DESTROYITEM: u16 = 0x0111; // 273 — the popup-confirmed world-drop delete (0216 §3)
pub const SMSG_INVENTORY_CHANGE_FAILURE: u16 = 0x0112; // 274
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 275. Body: one raw `u64` item guid
/// (`WorldPackets::Item::OpenContainer::AppendBodyTo`). The one sender is `Player::SendOpenContainer`
/// from `HandleAutoEquipItemOpcode` (`ItemHandler.cpp:227`) when the destination is a bag slot — a
/// bag got equipped. The reference's handler is the object-layer dispatcher `0x5e38c0`'s arm
/// `0x5e3b35` → `0x4f9410(guid)`, the `BAG_OPEN` raiser: our own guid → container 0 (the backpack),
/// else the 10-entry bag cache → 1..10, no match → no fire (wow-re
/// `object-layer/scratch/open-container-inspect-standstate-handlers.md`; decision 2339).
pub const SMSG_OPEN_CONTAINER: u16 = 0x0113; // 275
// The player-to-player trade family (VERIFIED vmangos `Opcodes_1_12_1.h:278-289`,
// `Handlers/TradeHandler.cpp`, `Server/Packets/Trade.{h,cpp}`; bodies/parses in [`trade`],
@@ -690,6 +698,14 @@ pub const MSG_RANDOM_ROLL: u16 = 0x01FB; // 507
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 257. Body in [`super::stand_state_change`].
pub const CMSG_STANDSTATECHANGE: u16 = 0x0101; // 257
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 669. Body: one `u8` stand state
/// (`WorldPackets::Misc::StandStateUpdate`), sent to the player by `Unit::SetStandState` on every
/// server-side change (`Objects/Unit.cpp:9541` — the eat/drink sit, the stand on damage) and, with the
/// CURRENT state, as vmangos's camera re-acquire for a Mac client on a transport
/// (`MiscHandler.cpp:388`). The reference's `0x603e50` reads the one byte, no guid, and applies it to
/// the LOCAL player through the stand-state setter `0x6127b0`, ungated (wow-re `object-layer.md`
/// "Three server handlers at the bytes"; decision 2339).
pub const SMSG_STANDSTATE_UPDATE: u16 = 0x029D; // 669
pub const CMSG_CAST_SPELL: u16 = 0x012E; // 302
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 310. Body in [`super::spells::cancel_aura`] — a lone `u32`
/// spell id (`Server/Packets/Spell.h:55-62`): the server cancels **by spell, never by slot**, and
@@ -714,6 +730,14 @@ pub const CMSG_CANCEL_CHANNELLING: u16 = 0x013B; // 315
/// streamed PUBLIC `PLAYER_VISIBLE_ITEM_*` fields without waiting on it, and no FrameXML handler
/// registers an inspect event. Decision 0631.
pub const CMSG_INSPECT: u16 = 0x0114; // 276
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 277. Body: one raw `u64`, the echoed target guid
/// (`WorldPackets::Misc::InspectResponse`), sent once `HandleInspectOpcode`'s distance and hostility
/// gates pass. The reference's handler `0x5e7d70` (29 bytes) reads the guid into a stack local and
/// **discards it** — no global, no field, no Lua event, nothing sent (wow-re
/// `object-layer/scratch/open-container-inspect-standstate-handlers.md`, byte-read 2026-09-23).
/// Parsed so the wire stays in step, and dropped in `decode()` on purpose: 0631's call, confirmed
/// at the bytes by decision 2339.
pub const SMSG_INSPECT: u16 = 0x0115; // 277
/// 726 (VERIFIED vmangos `Opcodes_1_12_1.h`) — the inspect window's **Honor tab**, and an `MSG_`:
/// the one opcode number carries both directions. Our request is a raw 8-byte guid
/// ([`super::inspect_honor_stats`]); the server's reply, on the same number, is the 50-byte
@@ -460,6 +460,24 @@ pub enum ServerPacket {
guid: u64,
anim_id: u32,
},
/// `SMSG_OPEN_CONTAINER` — the server opened a container for us: one raw `u64` item guid
/// (VERIFIED vmangos `Player::SendOpenContainer`), sent when a bag is equipped into a bag slot.
/// The reference fires `BAG_OPEN(containerId)` and nothing else (decision 2339).
OpenContainer {
item: u64,
},
/// `SMSG_INSPECT` — the inspect request's echo: one raw `u64`, the target's guid (VERIFIED
/// vmangos `Misc::InspectResponse`). Parsed and **deliberately dropped**: the reference's
/// handler discards it too, and the window paints off the visible-item fields (0631, 2339).
Inspect {
guid: u64,
},
/// `SMSG_STANDSTATE_UPDATE` — the server changed OUR stand state: one `u8` (VERIFIED vmangos
/// `Unit::SetStandState`). Applied to the local player, ungated, through the same setter the
/// volunteered change goes through (decision 2339).
StandStateUpdate {
state: u8,
},
/// `SMSG_GAMEOBJECT_DESPAWN_ANIM` — an object plays its one-shot **Despawn** animation and
/// then goes away. Payload VERIFIED vmangos `WorldObject::SendObjectDeSpawnAnim`: a bare
/// `u64` guid. The client arms substate 12 (AnimationData **157 Despawn**) and PINS the
@@ -1709,6 +1727,9 @@ impl ServerPacket {
ServerPacket::PageTextQueryResponse { .. } => "SMSG_PAGE_TEXT_QUERY_RESPONSE".into(),
ServerPacket::GameObjectCustomAnim { .. } => "SMSG_GAMEOBJECT_CUSTOM_ANIM".into(),
ServerPacket::GameObjectDespawnAnim { .. } => "SMSG_GAMEOBJECT_DESPAWN_ANIM".into(),
ServerPacket::OpenContainer { .. } => "SMSG_OPEN_CONTAINER".into(),
ServerPacket::Inspect { .. } => "SMSG_INSPECT".into(),
ServerPacket::StandStateUpdate { .. } => "SMSG_STANDSTATE_UPDATE".into(),
ServerPacket::FishNotHooked => "SMSG_FISH_NOT_HOOKED".into(),
ServerPacket::FishEscaped => "SMSG_FISH_ESCAPED".into(),
ServerPacket::PlaySound { .. } => "SMSG_PLAY_SOUND".into(),
+11 -1
View File
@@ -14,7 +14,7 @@ use super::{
action_bar, area_trigger, attack, auction, bank, battlefield, binder, broadcast, channel, chat,
combat_log, death, duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot,
mail, meeting_stone, mirror_timer, monster_move, movement, opcode, page_text, pet, petition,
progression, pvp, quest, social, spellbook, spells, stable, summon, tabard, taxi, trade,
pose, progression, pvp, quest, social, spellbook, spells, stable, summon, tabard, taxi, trade,
trainer, tutorial, update_object, vendor, world_state, AttackSwingError, Character,
CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind, SplineMode,
};
@@ -1322,6 +1322,16 @@ fn parse_server_body(
let guid = gameobject::read_gameobject_despawn_anim(&mut r)?;
ServerPacket::GameObjectDespawnAnim { guid }
}
// Decision 2339's three — the ones vmangos sends that the reference answers.
opcode::SMSG_OPEN_CONTAINER => ServerPacket::OpenContainer {
item: items::read_open_container(&mut r)?,
},
opcode::SMSG_INSPECT => ServerPacket::Inspect {
guid: items::read_inspect(&mut r)?,
},
opcode::SMSG_STANDSTATE_UPDATE => ServerPacket::StandStateUpdate {
state: pose::read_stand_state_update(&mut r)?,
},
// Both fishing verdicts are empty-bodied (the opcode IS the message).
opcode::SMSG_FISH_NOT_HOOKED => ServerPacket::FishNotHooked,
opcode::SMSG_FISH_ESCAPED => ServerPacket::FishEscaped,
@@ -10,6 +10,14 @@
//!
//! `CMSG_MOUNTSPECIAL_ANIM`, the third member of that family, has an empty body and so needs no
//! builder here (see `world::writer::pose`).
//!
//! The stand state also has an **inbound** twin, [`read_stand_state_update`] (decision 2339): the
//! server's own `SMSG_STANDSTATE_UPDATE`, one byte, which the reference applies to the local player
//! through the same setter the volunteered change goes through.
use std::io;
use crate::wire::read_u8;
/// Body of `CMSG_SETSHEATHED` (vmangos `SetSheathed::ReadFromWorldPacket`: `recv_data >> sheathed`):
/// one `u32` sheath state (0 unarmed/stowed, 1 melee drawn, 2 ranged drawn). Purely
@@ -29,3 +37,29 @@ pub fn set_sheathed(state: u32) -> Vec<u8> {
pub fn stand_state_change(state: u32) -> Vec<u8> {
state.to_le_bytes().to_vec()
}
/// Read `SMSG_STANDSTATE_UPDATE` → the stand state. VERIFIED vmangos `Unit::SetStandState`
/// (`Objects/Unit.cpp:9541`) → `WorldPackets::Misc::StandStateUpdate::AppendBodyTo`, one `u8`
/// (`UnitStandStateType`: 0 STAND · 1 SIT · 3 SLEEP · 8 KNEEL …). No guid: the reference's
/// `0x603e50` applies it to the local player whatever unit the server meant (2339).
pub(super) fn read_stand_state_update(r: &mut &[u8]) -> io::Result<u8> {
read_u8(r)
}
#[cfg(test)]
mod inbound_tests {
use crate::messages::{opcode, parse_server, ServerPacket};
#[test]
fn stand_state_update_decodes() {
// One byte (VERIFIED vmangos `Misc::StandStateUpdate::AppendBodyTo`); a drink's sit is 1.
match parse_server(opcode::SMSG_STANDSTATE_UPDATE, &[1]).unwrap() {
ServerPacket::StandStateUpdate { state } => assert_eq!(state, 1),
other => panic!("expected StandStateUpdate, got {}", other.name()),
}
assert!(
parse_server(opcode::SMSG_STANDSTATE_UPDATE, &[]).is_err(),
"a short body is an error"
);
}
}
@@ -78,7 +78,7 @@ impl ObjectFields {
///
/// The triple's MIDDLE field (the duration) is deliberately not exposed: the reference's
/// tooltip never reads it. A temporary enchant's countdown comes from
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` instead (`items::Items::enchant_remaining_ms`).
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` instead (`items::Countdowns::enchant_remaining_ms`).
pub fn item_enchant_charges(&self, slot: u8) -> u32 {
(slot < 7)
.then(|| self.get_u32(FIELD_ITEM_ENCHANTMENT + 3 * u16::from(slot) + 2))
+1 -1
View File
@@ -108,7 +108,7 @@ pub struct ContainerSlot {
/// **The instance's remaining LIFETIME in milliseconds** — a duration-limited item counting
/// down to its own destruction (a conjured stone, a holiday gift, a timed quest item). Its
/// only feed is `SMSG_ITEM_TIME_UPDATE`, parked as an absolute deadline and recomputed on
/// read ([`crate::items::Items::duration_remaining_display_ms`] in benilla-app), floored to
/// read ([`crate::items::Countdowns::lifetime_remaining_display_ms`] in benilla-app), floored to
/// the whole second so the snapshot moves once a second rather than every frame.
/// `None` = no timer, which is nearly every item. Decision 1933.
pub duration_ms: Option<u64>,
@@ -22,7 +22,7 @@
//! | `*Charges` | a number (the enchantment triple's third dword) | the number **0**, never nil (`mov DWORD PTR [ebp-0x4],0x0; fild`, `0x4c985a`) |
//!
//! Milliseconds, not seconds: `ref-BuffFrame.lua:189`/`:212` divide by 1000 before displaying, and
//! `0x5d9d00` is the same live remaining-time reader `benilla::items::Items::enchant_remaining_ms`
//! `0x5d9d00` is the same live remaining-time reader `benilla::items::Countdowns::enchant_remaining_ms`
//! mirrors (decision 0920). It is *remaining* time, recomputed per call against a deadline — never
//! an absolute stamp.
//!
@@ -56,8 +56,8 @@
//! `SMSG_ITEM_ENCHANT_TIME_UPDATE` as `now_ms + seconds*1000`, and read back as
//! `max(0, deadline − now)` (`0x5d9d00`: `0x5d9d15` clock, `0x5d9d2a sub`). **The wire carries
//! seconds; this API returns milliseconds.** So a host stores a deadline and subtracts on read — it
//! never ticks a countdown. `benilla::items::Items` does exactly that, and
//! `Items::enchant_deadline_ms` is the read that keeps an *elapsed* timer as the number `0` rather
//! never ticks a countdown. `benilla::items::Countdowns` does exactly that, and
//! `Countdowns::enchant_deadline_ms` is the read that keeps an *elapsed* timer as the number `0` rather
//! than collapsing it to "no timer", which is the distinction `BuffFrame_Enchant_OnUpdate` draws
//! "0 s" from.
//!