From 9c49f10882206a7a32abc096bab98f73e49ddc4e Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2026 12:16:11 +0700 Subject: [PATCH] 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) --- crates/benilla-app/src/capture/fixtures.rs | 28 +- .../benilla-app/src/capture/probe_charter.rs | 29 +- crates/benilla-app/src/capture/probe_clam.rs | 8 +- .../benilla-app/src/capture/probe_service.rs | 4 +- .../src/creature_anim/spell_visual.rs | 14 +- crates/benilla-app/src/death/mod.rs | 54 +- .../src/entities/equipment/resolve.rs | 28 +- crates/benilla-app/src/game_plugins.rs | 67 +- crates/benilla-app/src/items.rs | 803 ++++++++++-------- crates/benilla-app/src/net.rs | 44 +- crates/benilla-app/src/net/objects.rs | 209 ++++- crates/benilla-app/src/player.rs | 13 + crates/benilla-app/src/player/controller.rs | 4 + crates/benilla-app/src/player/net.rs | 21 + crates/benilla-app/src/player/posture.rs | 103 ++- crates/benilla-app/src/player/state.rs | 8 +- crates/benilla-app/src/sound/combat.rs | 3 +- crates/benilla-app/src/sound/footsteps.rs | 16 +- crates/benilla-app/src/sound/mod.rs | 3 +- crates/benilla-app/src/spell/cast_send.rs | 24 +- crates/benilla-app/src/spell/net.rs | 15 +- .../benilla-app/src/spell/targeting/cursor.rs | 1 + .../benilla-app/src/spell/targeting/item.rs | 6 +- .../benilla-app/src/spell/targeting/world.rs | 1 + crates/benilla-app/src/spell/usable.rs | 70 +- crates/benilla-app/src/target/click.rs | 7 +- crates/benilla-app/src/target/cursor_mode.rs | 2 +- crates/benilla-app/src/target/lock.rs | 64 +- crates/benilla-app/src/ui_action/drain.rs | 3 +- crates/benilla-app/src/ui_action/drop_item.rs | 2 +- crates/benilla-app/src/ui_action/errors.rs | 44 +- crates/benilla-app/src/ui_action/feed.rs | 23 +- .../benilla-app/src/ui_action/feed_tests.rs | 3 + crates/benilla-app/src/ui_action/state.rs | 42 +- .../benilla-app/src/ui_action/weapon_icon.rs | 25 +- crates/benilla-app/src/ui_auction/mod.rs | 5 +- crates/benilla-app/src/ui_bind_confirm.rs | 11 +- crates/benilla-app/src/ui_char.rs | 145 ++-- crates/benilla-app/src/ui_craft.rs | 7 +- crates/benilla-app/src/ui_item_text.rs | 46 +- crates/benilla-app/src/ui_items/drain.rs | 76 +- crates/benilla-app/src/ui_items/feed.rs | 249 +++++- crates/benilla-app/src/ui_items/mod.rs | 295 ++++--- crates/benilla-app/src/ui_items/net.rs | 112 ++- crates/benilla-app/src/ui_mail/mod.rs | 23 +- crates/benilla-app/src/ui_merchant/mod.rs | 24 +- crates/benilla-app/src/ui_petition/feed.rs | 7 +- crates/benilla-app/src/ui_quest/mod.rs | 2 + crates/benilla-app/src/ui_quest_log.rs | 36 +- .../src/ui_script/bagnon_render_tests.rs | 1 + .../benilla-app/src/ui_script/quiver_tests.rs | 2 + .../benilla-app/src/ui_script/reference_ui.rs | 1 - crates/benilla-app/src/ui_shapeshift.rs | 14 +- crates/benilla-app/src/ui_spellbook.rs | 38 +- crates/benilla-app/src/ui_tooltip/mod.rs | 31 +- crates/benilla-app/src/ui_tooltip/tests.rs | 201 +++-- crates/benilla-app/src/ui_trade/mod.rs | 13 +- crates/benilla-app/src/ui_tradeskill.rs | 9 +- crates/benilla-app/tests/ui_script_wall.rs | 104 ++- crates/benilla-app/tests/world_api_wall.rs | 433 +++++++++- crates/benilla-protocol/src/events.rs | 8 + crates/benilla-protocol/src/events/decode.rs | 9 + crates/benilla-protocol/src/messages/items.rs | 44 + .../benilla-protocol/src/messages/opcode.rs | 24 + .../benilla-protocol/src/messages/packet.rs | 21 + crates/benilla-protocol/src/messages/parse.rs | 12 +- crates/benilla-protocol/src/messages/pose.rs | 34 + .../messages/update_object/fields/player.rs | 2 +- crates/benilla-ui/src/script/container.rs | 2 +- .../benilla-ui/src/script/weapon_enchant.rs | 6 +- 70 files changed, 2822 insertions(+), 1016 deletions(-) create mode 100644 crates/benilla-app/src/player/net.rs diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index ab191c6c..5442e0d0 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -42,7 +42,9 @@ pub(super) fn seed_ui_fixture( mut quest: ResMut, mut quest_log: ResMut, mut loot: ResMut, - mut items: ResMut, + // 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, ResMut), mut names: ResMut, icons: Option>, mut script: Option>, @@ -57,6 +59,7 @@ pub(super) fn seed_ui_fixture( ResMut, ), ) { + 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)); diff --git a/crates/benilla-app/src/capture/probe_charter.rs b/crates/benilla-app/src/capture/probe_charter.rs index 5fce6ffa..422fb62b 100644 --- a/crates/benilla-app/src/capture/probe_charter.rs +++ b/crates/benilla-app/src/capture/probe_charter.rs @@ -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, gossip: Res, registrar: Res, + objects: Objects, items: Res, script: Option>, self_q: Query<&ObjectStore, With>, @@ -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, diff --git a/crates/benilla-app/src/capture/probe_clam.rs b/crates/benilla-app/src/capture/probe_clam.rs index fc31c1ff..c6965385 100644 --- a/crates/benilla-app/src/capture/probe_clam.rs +++ b/crates/benilla-app/src/capture/probe_clam.rs @@ -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, script: Option>, self_store: Query<&ObjectStore, With>, + objects: Objects, items: Res, mut cfg: ResMut, loot: Res, @@ -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" diff --git a/crates/benilla-app/src/capture/probe_service.rs b/crates/benilla-app/src/capture/probe_service.rs index eb32d358..3db859da 100644 --- a/crates/benilla-app/src/capture/probe_service.rs +++ b/crates/benilla-app/src/capture/probe_service.rs @@ -355,7 +355,7 @@ fn service_probe( // the guid the wire addresses. mut merchant: ResMut, script: Option>, - items: Res, + objects: crate::net::Objects, self_store: Query<&ObjectStore, With>, self_player: Query<(), With>, player: Res, @@ -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 \ diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index 26197d15..ac2e4933 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -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>, items: Option>, net: Option>, - units: Query<'w, 's, (Option<&'static NetEntity>, &'static ObjectStore)>, + // `Without`: 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>, } impl WeaponVisualSrc<'_, '_> { @@ -779,7 +781,7 @@ pub(super) fn route_cast_visuals( visuals: Option>, spells: Option>, mut weapon_src: WeaponVisualSrc, - units: Query<(Entity, &ObjectStore)>, + units: Query<(Entity, &ObjectStore), Without>, holds: Query<&CastHold>, mut channel_cache: Local>, ) { @@ -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>, + units: Query<(Entity, &ObjectStore), Without>, + arrived: Query, Without)>, mut edges: MessageReader, visuals: Option>, spells: Option>, @@ -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>, + arrived: Query, Without)>, mut edges: MessageReader, visuals: Option>, mut fx: MessageWriter, diff --git a/crates/benilla-app/src/death/mod.rs b/crates/benilla-app/src/death/mod.rs index dd22cbf3..557aa6bb 100644 --- a/crates/benilla-app/src/death/mod.rs +++ b/crates/benilla-app/src/death/mod.rs @@ -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, names: Res, net: Res, - index: Res, + // 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>, status: Res, @@ -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| 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 + ); } } diff --git a/crates/benilla-app/src/entities/equipment/resolve.rs b/crates/benilla-app/src/entities/equipment/resolve.rs index 236d00ae..92f032ba 100644 --- a/crates/benilla-app/src/entities/equipment/resolve.rs +++ b/crates/benilla-app/src/entities/equipment/resolve.rs @@ -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>, enchants: Option>, - // 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>, + // 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>, + // 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::(); app.insert_resource(items); + // The one object index `Objects` reads (2334); empty, like the map these tests never seeded. + app.init_resource::(); 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::(); app.insert_resource(items); + // The one object index `Objects` reads (2334); empty, like the map these tests never seeded. + app.init_resource::(); 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::(); app.insert_resource(items); + // The one object index `Objects` reads (2334); empty, like the map these tests never seeded. + app.init_resource::(); 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::(); app.insert_resource(items); + // The one object index `Objects` reads (2334); empty, like the map these tests never seeded. + app.init_resource::(); app.insert_resource(NetCommands(tx)); app.insert_resource(ItemDisplays::icons_for_tests( benilla_formats::ItemDisplayCatalog::from_displays(std::collections::HashMap::new()), diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 0e513a91..e03a584e 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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 = 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::>() @@ -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() diff --git a/crates/benilla-app/src/items.rs b/crates/benilla-app/src/items.rs index 985f4601..0ddb6039 100644 --- a/crates/benilla-app/src/items.rs +++ b/crates/benilla-app/src/items.rs @@ -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`): 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, + enchants: [Option; 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 { + ((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) -> Option { + 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) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + Or<(Changed, Changed)>, +); + +/// **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>, + pub(crate) self_changed: + Query<'w, 's, (), (With, Changed)>, + 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, /// 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, @@ -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, - /// [`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, + /// **`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 { - 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 { - 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 { - 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 { - 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 { - 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, } @@ -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::(); 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( + &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::::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 { @@ -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::().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 { 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 { 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 { +pub(crate) fn disarmed_equipment_slot_cached( + store: &ObjectStore, + objects: &Objects, + items: &Items, +) -> Option { if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 { return None; } let class_of = |slot: u8| -> Option { 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::(); + world + .run_system_once(|mut commands: Commands, mut index: ResMut| { + 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, Option, 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::().0[&GUID]; + world + .get_mut::(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::(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::().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(); diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index baa62eda..b43caeab 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -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); #[derive(Resource)] pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc); -/// 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); +/// **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 { + 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). diff --git a/crates/benilla-app/src/net/objects.rs b/crates/benilla-app/src/net/objects.rs index 7a88c9ba..e07493ad 100644 --- a/crates/benilla-app/src/net/objects.rs +++ b/crates/benilla-app/src/net/objects.rs @@ -98,6 +98,7 @@ pub(crate) struct Scene<'w, 's> { real: Res<'w, Time>, 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, 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, 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, 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, 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, + 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, - 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::(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 { + let e = *world.resource::().0.get(&guid)?; + world.get::(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::().0.insert(ME, me); + world.resource_mut::().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::().0[&SWORD]; + super::super::handlers::dispatch(world, vec![SessionEvent::ObjectDestroyed(SWORD)]); + assert!(!world.resource::().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 { diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 77160706..2ace39a6 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -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::() + // The server's own stand state for our body (decision 2339), written by [`net`]. + .add_message::() // 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. diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 5bb855b3..c739be57 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -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, + // 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, ), // 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, ); diff --git a/crates/benilla-app/src/player/net.rs b/crates/benilla-app/src/player/net.rs new file mode 100644 index 00000000..605c0351 --- /dev/null +++ b/crates/benilla-app/src/player/net.rs @@ -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, mut out: MessageWriter) { + if let SessionEvent::StandStateUpdate { state } = ev { + out.write(ServerStandState { state }); + } +} diff --git a/crates/benilla-app/src/player/posture.rs b/crates/benilla-app/src/player/posture.rs index d874f478..37efead7 100644 --- a/crates/benilla-app/src/player/posture.rs +++ b/crates/benilla-app/src/player/posture.rs @@ -24,15 +24,17 @@ pub(super) fn update( net: &NetCommands, sheath: &mut MessageWriter, asks: &mut MessageReader, + server: &mut MessageReader, 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, +) { + 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, 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)); + } +} diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 685377ce..fce1ad5b 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -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, /// **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 diff --git a/crates/benilla-app/src/sound/combat.rs b/crates/benilla-app/src/sound/combat.rs index ca505c25..0b8f539a 100644 --- a/crates/benilla-app/src/sound/combat.rs +++ b/crates/benilla-app/src/sound/combat.rs @@ -465,6 +465,7 @@ fn combat_sounds( units: Query, tables: MeleeTables, attach: crate::entities::AttachPoints, + objects: crate::net::Objects, mut items: Option>, net_commands: Res, kits: Option>, @@ -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) } diff --git a/crates/benilla-app/src/sound/footsteps.rs b/crates/benilla-app/src/sound/footsteps.rs index a759013a..269109ae 100644 --- a/crates/benilla-app/src/sound/footsteps.rs +++ b/crates/benilla-app/src/sound/footsteps.rs @@ -115,11 +115,12 @@ fn footstep_sounds( parents: Query<&ChildOf>, root_state: Query<(Option<&ObjectStore>, Option<&MovementState>)>, footsteps: Option>, - // 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>, - creatures: Option>, + // 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>, Option>), + objects: crate::net::Objects, mut items: Option>, net_commands: Res, voices: Option>, @@ -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)), diff --git a/crates/benilla-app/src/sound/mod.rs b/crates/benilla-app/src/sound/mod.rs index f36145f4..2715427f 100644 --- a/crates/benilla-app/src/sound/mod.rs +++ b/crates/benilla-app/src/sound/mod.rs @@ -479,13 +479,14 @@ fn load_materials(mut commands: Commands, assets: Option, + objects: &crate::net::Objects, items: &crate::items::Items, net: &crate::net::NetCommands, ) -> Option { /// 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) } diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index 3123ec50..3e29aa5b 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -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), With>, pub(crate) spells: Option>, - /// 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), With>, spells: Option<&Spells>, + objects: &Objects, items: &Items, sheath: &mut MessageWriter, 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::(); + // The one object index the ladder's item arms resolve an instance guid through (2334). + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index 3f0be0fb..ce2bac03 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -400,7 +400,7 @@ fn on_item_cooldown(In(ev): In, 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()); } } diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index 193e7357..d583984d 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -336,6 +336,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.insert_resource(PickOcclusion { distance: 10.0, point, diff --git a/crates/benilla-app/src/spell/targeting/item.rs b/crates/benilla-app/src/spell/targeting/item.rs index c2fc417e..78463e8a 100644 --- a/crates/benilla-app/src/spell/targeting/item.rs +++ b/crates/benilla-app/src/spell/targeting/item.rs @@ -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, diff --git a/crates/benilla-app/src/spell/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs index 0189c28f..d730f932 100644 --- a/crates/benilla-app/src/spell/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -174,6 +174,7 @@ mod tests { let mut world = World::new(); world.insert_resource(NetCommands(tx)); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); diff --git a/crates/benilla-app/src/spell/usable.rs b/crates/benilla-app/src/spell/usable.rs index d4c1634e..bc531142 100644 --- a/crates/benilla-app/src/spell/usable.rs +++ b/crates/benilla-app/src/spell/usable.rs @@ -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) ); } diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index e84dc482..054d4f35 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -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::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index d0901757..5032e603 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -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, ) diff --git a/crates/benilla-app/src/target/lock.rs b/crates/benilla-app/src/target/lock.rs index 403eabda..932c3d9e 100644 --- a/crates/benilla-app/src/target/lock.rs +++ b/crates/benilla-app/src/target/lock.rs @@ -44,7 +44,7 @@ use crate::net::ObjectStore; /// item-template cache (key-item names for the "Requires \" 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>, 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, ) -> 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, diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index 8f2a921f..2a77e058 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -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, diff --git a/crates/benilla-app/src/ui_action/drop_item.rs b/crates/benilla-app/src/ui_action/drop_item.rs index cb028a21..f51a079b 100644 --- a/crates/benilla-app/src/ui_action/drop_item.rs +++ b/crates/benilla-app/src/ui_action/drop_item.rs @@ -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 }; diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index f04cd745..4286ea8d 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -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 { 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 { 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); } } diff --git a/crates/benilla-app/src/ui_action/feed.rs b/crates/benilla-app/src/ui_action/feed.rs index 55113e79..26322b9c 100644 --- a/crates/benilla-app/src/ui_action/feed.rs +++ b/crates/benilla-app/src/ui_action/feed.rs @@ -110,10 +110,15 @@ pub(super) fn feed_actions( mut cast_errors: ResMut, mut mount_errors: ResMut, mut pet_tame_failures: ResMut, - mut ui_error_keys: ResMut, - mut ui_error_texts: ResMut, + // 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, ResMut), spells: Option>, self_q: Query<&ObjectStore, With>, + // 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, icons: Option>, sub_classes: Option>, @@ -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 { - 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)) diff --git a/crates/benilla-app/src/ui_action/feed_tests.rs b/crates/benilla-app/src/ui_action/feed_tests.rs index 3d953600..1dfc4be6 100644 --- a/crates/benilla-app/src/ui_action/feed_tests.rs +++ b/crates/benilla-app/src/ui_action/feed_tests.rs @@ -58,6 +58,7 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -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::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -523,6 +525,7 @@ fn a_pets_refused_cast_writes_no_combat_log_line() { ..Spells::empty_for_tests() }) .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 53881841..8da96801 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -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, Option<&Casting>), With>, selection: Res, - index: Res, + // 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>, factions: Option>, reputations: Res, @@ -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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .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 { diff --git a/crates/benilla-app/src/ui_action/weapon_icon.rs b/crates/benilla-app/src/ui_action/weapon_icon.rs index 9e841e72..f5624c23 100644 --- a/crates/benilla-app/src/ui_action/weapon_icon.rs +++ b/crates/benilla-app/src/ui_action/weapon_icon.rs @@ -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)> { 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, + 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 { 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, + objects: &Objects, items: &Items, icons: Option<&ItemDisplays>, commands: &NetCommands, ) -> Option { 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 diff --git a/crates/benilla-app/src/ui_auction/mod.rs b/crates/benilla-app/src/ui_auction/mod.rs index 3f73dd53..b1bdb55b 100644 --- a/crates/benilla-app/src/ui_auction/mod.rs +++ b/crates/benilla-app/src/ui_auction/mod.rs @@ -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, commands: Res, time: Res