Files
benilla-octo/crates/benilla-app/src/items.rs
T
Sam SungandClaude Opus 5.5 4f8350c9ed String literals and test names in benilla-app carry no record numbers (#368)
The strings pass for `benilla-app`, the last crate: string literals and test names lose the private record numbers and the workspace's words that a comment pass cannot touch.

Fixes #92. Fixes #36.

- **Messages.** About 230 assertion, panic, `expect` and log messages, printed tool labels, and comments inside embedded Lua fixtures drop their record numbers; the eight that named "the director" keep their fact in plain words. Stale claims in a few messages are corrected on the way (a cast-bar step, a font line, the every-shot recompute).
- **Names.** Seven test and helper names that carried a record number or "director" now say what they check. `git grep` found no other use of any of them.
- **Player files.** The key-bindings file's header and `config.toml`'s header each lose a record citation. Neither header is compared when the file is read back: the bindings reader skips every `#` line, and `config.toml`'s header is a TOML comment.
- **Fixtures.** Test fixtures use neutral character, account and frame names.
- **#36.** The quest-material test labels GameObject type 10 GOOBER (in 1.12, 3 is CHEST, 9 TEXT, 10 GOOBER); the code under test was already right, and the comments were fixed with the comment pass.
- **Verification.** With comments stripped and every literal's contents masked, each file is identical to main and keeps its literals' line counts, apart from the renamed functions and one rustfmt reflow. `cargo fmt --check` is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:06:28 +07:00

1046 lines
40 KiB
Rust

//! The item layer: item objects in the guid index and the item template cache.
//!
//! An item is `Guid` + [`ObjectStore`] + [`ItemObject`] in the guid index; item fields are private,
//! so only our own arrive. Templates are keyed by entry, asked once, and survive disconnect.
use std::time::{Duration, Instant};
use bevy::prelude::*;
use benilla_protocol::{ItemInfo, ObjectFields};
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 (`TYPEMASK_ITEM` / `TYPEMASK_CONTAINER`), with no model or
/// pose. Unit readers filter it out (`Without<ItemObject>`): an item block's dwords overlap the
/// unit block's indices.
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ItemObject {
/// `TYPEMASK_CONTAINER`: a bag's slot array lives past the item block.
pub(crate) container: bool,
}
/// Spawn an item object into the index. The seed is never merged, so the create raises no field
/// notify; a re-create of a live guid takes the handler's values path instead.
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
}
/// `ITEM_FIELD_ENCHANTMENT`'s 21 dwords, three per slot; the reference's seven deadline cells at
/// `obj + 0x324 + slot*4` (`0x5d9d00`) end where the next member begins, at `+0x340`.
pub(crate) const ENCHANT_SLOTS: usize = 7;
/// An item's countdowns, the reference's deadline cells on `CGItem_C`: its lifetime at `+0x320`
/// (fed only by `SMSG_ITEM_TIME_UPDATE`) and one temporary-enchant deadline per slot at `+0x324`
/// (fed only by `SMSG_ITEM_ENCHANT_TIME_UPDATE`; the enchantment field's duration is never read).
/// Absolute deadlines, recomputed on every read and never ticked (`0x5d9c60`, `0x5d9d00`).
#[derive(Component, Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct Countdowns {
lifetime: Option<Instant>,
enchants: [Option<Instant>; ENCHANT_SLOTS],
}
/// The setters' store rule (`0x5d9c00`, `0x5d9cc0`): a signed `<= 0` (`jle`) clears the cell, so a
/// top-bit value is absence, not a 68-year deadline; anything else parks `now + seconds`.
fn deadline(seconds: u32) -> Option<Instant> {
((seconds as i32) > 0).then(|| Instant::now() + Duration::from_secs(u64::from(seconds)))
}
/// A cell's time left: `None` when unset, `Some(0)` once elapsed (`0x5d9d00`'s `max(0, …)`).
fn left(cell: Option<Instant>) -> Option<Duration> {
cell.map(|at| at.saturating_duration_since(Instant::now()))
}
/// The tooltip's read: an elapsed cell is no timer (its `!= 0` gate prints the plain line).
fn remaining_ms(cell: Option<Instant>) -> Option<u64> {
left(cell)
.filter(|l| !l.is_zero())
.map(|l| l.as_millis() as u64)
}
impl Countdowns {
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE`'s setter `0x5d9cc0`. A slot past the seventh is refused
/// (`false`), where the reference indexes unchecked and overruns into the next member.
pub(crate) fn set_enchant(&mut self, slot: u32, seconds: u32) -> bool {
let Some(cell) = self.enchants.get_mut(slot as usize) else {
return false;
};
*cell = deadline(seconds);
true
}
/// `SMSG_ITEM_TIME_UPDATE`'s setter `0x5d9c00`.
pub(crate) fn set_lifetime(&mut self, seconds: u32) {
self.lifetime = deadline(seconds);
}
fn enchant(&self, slot: u32) -> Option<Instant> {
self.enchants.get(slot as usize).copied().flatten()
}
/// Milliseconds left on the temporary enchant in `slot`; `None` also once expired.
pub(crate) fn enchant_remaining_ms(&self, slot: u32) -> Option<u64> {
remaining_ms(self.enchant(slot))
}
/// [`Self::enchant_remaining_ms`] floored to the second, for the snapshot feeds: a per-ms read
/// would fire `UNIT_INVENTORY_CHANGED` every frame while a timer runs.
pub(crate) fn enchant_remaining_display_ms(&self, slot: u32) -> Option<u64> {
self.enchant_remaining_ms(slot).map(|ms| ms - ms % 1000)
}
/// The deadline read without the expired-is-absent collapse: `Some(0)` once run out, `None`
/// only when never set. `GetWeaponEnchantInfo` returns `max(0, deadline - now)` (`0x5d9d00`),
/// so an elapsed enchant answers 0 and `BuffFrame_Enchant_OnUpdate` draws "0 s".
pub(crate) fn enchant_deadline_ms(&self, slot: u32) -> Option<u64> {
left(self.enchant(slot)).map(|l| l.as_millis() as u64)
}
/// Milliseconds left on the item's own lifetime; `None` also once elapsed.
pub(crate) fn lifetime_remaining_ms(&self) -> Option<u64> {
remaining_ms(self.lifetime)
}
/// [`Self::lifetime_remaining_ms`] floored to the whole second, as the enchant read is.
pub(crate) fn lifetime_remaining_display_ms(&self) -> Option<u64> {
self.lifetime_remaining_ms().map(|ms| ms - ms % 1000)
}
/// This item's share of [`ItemChanges::countdown_steps`]: `floor(seconds left) + 1` per live
/// cell, 0 when unset or elapsed, so the final `Some(0)` to `None` collapse is its own step.
fn steps(&self, now: Instant) -> u64 {
std::iter::once(self.lifetime)
.chain(self.enchants)
.flatten()
.map(|at| {
let left = at.saturating_duration_since(now);
if left.is_zero() {
0
} else {
left.as_secs() + 1
}
})
.sum()
}
}
/// An item entity whose fields or countdown cells were written since the reader last ran.
type ItemMoved = (
With<ItemObject>,
Or<(Changed<ObjectStore>, Changed<Countdowns>)>,
);
/// Whether any item object was created, written or destroyed this frame; the inventory feeds' gate.
#[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 sum of every item's [`Countdowns`] steps: it moves exactly when a second-floored read
/// can, and each term only falls between landings, so no change masks another.
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: the self descriptor, the object lookup and the item watch.
#[derive(SystemParam)]
pub(crate) struct Inventory<'w, 's> {
pub(crate) self_store: Query<'w, 's, &'static ObjectStore, With<crate::net::SelfPlayer>>,
pub(crate) self_changed:
Query<'w, 's, (), (With<crate::net::SelfPlayer>, Changed<ObjectStore>)>,
pub(crate) objects: Objects<'w, 's>,
pub(crate) changes: ItemChanges<'w, 's>,
}
/// The Copy slice of an [`ItemInfo`] that equipment rendering and combat animation read.
#[derive(Clone, Copy)]
pub(crate) struct HeldTemplate {
pub(crate) display_info_id: u32,
pub(crate) inventory_type: u32,
pub(crate) sheath: u32,
pub(crate) class: u32,
pub(crate) subclass: u32,
/// `Material.dbc` id (1 metal, 2 wood, 5 chain, 6 plate, 7 cloth, 8 leather, 0 undefined), the
/// only input to the draw/stow sound: every `SheatheSoundLookups` row of a material agrees.
pub(crate) material: u32,
}
/// `SpellItemEnchantment.dbc`, for the weapon glow ([`crate::entities::item_glow`]) and the
/// tooltip enchant line ([`crate::ui_items`]). Optional: absent, neither draws.
#[derive(Resource)]
pub(crate) struct Enchants(pub(crate) benilla_formats::EnchantCatalog);
/// One enchant slot: `(slot index, id, charges, remaining ms)`. The id is signed: the sign picks
/// the line's colour only, and `abs(id)` names the DBC row (`0x52c9f9`).
pub(crate) type EnchantSlot = (u8, i32, u32, Option<u64>);
/// The tooltip lines an item's enchant slots contribute; every tooltip surface feeds through here.
/// The reference's per-slot gate (`0x52c9f9` to `0x52ca23`): `id != 0` and `abs(id)` names a
/// `SpellItemEnchantment` row, else nothing. Our own items carry all seven slots; another
/// player's descriptor carries only the permanent and temporary ones (vmangos).
pub(crate) fn enchant_lines(
slots: impl IntoIterator<Item = EnchantSlot>,
enchants: Option<&Enchants>,
) -> Vec<benilla_ui::script::EnchantView> {
let lines = enchant_lines_quiet(slots, enchants);
// Logged once per session: a missing enchant looks exactly like an unenchanted item.
if !lines.is_empty() {
static FIRST: std::sync::Once = std::sync::Once::new();
// The whole view: the id, charges and countdown each ride a different wire lane.
FIRST.call_once(|| info!("item enchant: {lines:?} (the first resolved this session)"));
}
lines
}
/// `0x5da2c0`: already bound when `ITEM_FIELD_FLAGS & 1` is set or a live enchant slot names a
/// binding `SpellItemEnchantment` row (`0x5da300` to `0x5da320`). The enchant cursor's bind
/// question (gate `0x495d60`) and the tooltip's Soulbound line both read it. It reads the raw
/// descriptor, not [`enchant_lines`]: Firestone and Orb of Fire both bind and print no line.
pub(crate) fn already_bound(fields: &ObjectFields, cat: Option<&Enchants>) -> bool {
fields.item_flags().is_some_and(|f| f & 0x1 != 0)
|| (0..7).any(|slot| live_enchant(fields, slot, cat).is_some_and(|id| binds(id, cat)))
}
/// One enchant slot as the bind checks read it (`0x495eec`): the raw id must be positive (`jl` at
/// `0x495ef4`, `0x5da306`) and name a row, where the tooltip line uses `abs(id)`.
pub(crate) fn live_enchant(fields: &ObjectFields, slot: u8, cat: Option<&Enchants>) -> Option<u32> {
let id = u32::try_from(fields.item_enchant(slot)?).ok()?;
cat.is_some_and(|c| c.0.has_row(id)).then_some(id)
}
/// `SpellItemEnchantment.Flags & 1`: this enchant soulbinds the item it lands on.
pub(crate) fn binds(id: u32, cat: Option<&Enchants>) -> bool {
cat.is_some_and(|c| c.0.binds_the_item(id))
}
/// [`enchant_lines`] without the log line, for the load-time resolve of the whole
/// `ItemRandomProperties` table ([`random_property_views`]).
fn enchant_lines_quiet(
slots: impl IntoIterator<Item = EnchantSlot>,
enchants: Option<&Enchants>,
) -> Vec<benilla_ui::script::EnchantView> {
let Some(enchants) = enchants else {
return Vec::new();
};
let lines: Vec<benilla_ui::script::EnchantView> = slots
.into_iter()
.filter(|&(_, id, _, _)| id != 0)
// `SpellItemEnchantment.Flags & 0x2`: no line; both reference printers return before
// reading the name (`0x6290e4`, `0x62923e`). Twelve rows: the totem weapon imbues,
// Firestone, Orb of Fire.
.filter(|&(_, id, _, _)| !enchants.0.tooltip_hides_name(id.unsigned_abs()))
.filter_map(|(slot, id, charges, remaining_ms)| {
let name = enchants.0.name(id.unsigned_abs())?.to_string();
Some(benilla_ui::script::EnchantView {
slot,
name,
negative: id < 0,
charges,
remaining_ms,
})
})
.collect();
lines
}
/// `ItemRandomProperties.dbc`, the random-suffix roll: a name suffix and tooltip enchant slots
/// 2..6 (`0x52b7e0`). Optional: absent, names stay unsuffixed.
#[derive(Resource)]
pub(crate) struct RandomProperties(pub(crate) benilla_formats::RandomPropertyCatalog);
/// The item's display name, the reference's one formatter `0x5d8b00(entry, randomPropertyId)`:
/// `ITEM_SUFFIX_TEMPLATE` (`"%s %s"`) with the roll's suffix, or the plain name when the id names
/// no suffixed row. Every surface that shows an item name goes through here.
pub(crate) fn item_display_name(
base: &str,
random_property_id: i32,
props: Option<&RandomProperties>,
) -> String {
match props.and_then(|p| p.0.get(random_property_id)) {
Some(row) => format!("{base} {}", row.suffix),
None => base.to_string(),
}
}
/// The tooltip lines of a roll, for a source with no item object: the reference (`0x52b7bf` to
/// `0x52b7fb`) copies the row's five enchant ids into slots 2..6 and prints them as its own.
pub(crate) fn random_property_lines(
row: &benilla_formats::RandomProperty,
enchants: Option<&Enchants>,
) -> Vec<benilla_ui::script::EnchantView> {
let slots = row.enchants.iter().enumerate().map(|(i, &id)| {
(
benilla_formats::RANDOM_PROPERTY_FIRST_SLOT + i as u8,
id as i32,
0,
None,
)
});
enchant_lines_quiet(slots, enchants)
}
/// Every `ItemRandomProperties` row as its suffix and named enchant lines, pushed to the engine
/// whole: a chat-link tooltip has no hover re-enter, so a per-id ask would miss the first click.
pub(crate) fn random_property_views(
props: &RandomProperties,
enchants: Option<&Enchants>,
) -> std::collections::HashMap<u32, benilla_ui::script::RandomPropertyView> {
props
.0
.iter()
.map(|(id, row)| {
(
id,
benilla_ui::script::RandomPropertyView {
suffix: row.suffix.clone(),
enchants: random_property_lines(row, enchants),
},
)
})
.collect()
}
/// The two catalogs a random-suffix roll resolves through: the roll table and the enchant names.
#[derive(Clone, Copy)]
pub(crate) struct RollCatalogs<'a> {
pub(crate) props: Option<&'a RandomProperties>,
pub(crate) enchants: Option<&'a Enchants>,
}
impl RollCatalogs<'_> {
/// No DBC catalogs: the plain name and no suffix lines.
#[cfg(test)]
pub(crate) const NONE: RollCatalogs<'static> = RollCatalogs {
props: None,
enchants: None,
};
/// [`item_display_name`] with this roll's suffix.
pub(crate) fn name(&self, base: &str, random_property_id: u32) -> String {
item_display_name(base, random_property_id as i32, self.props)
}
/// [`random_property_lines`] for one id; the live surfaces read the engine's pushed table.
#[cfg(test)]
pub(crate) fn lines(&self, random_property_id: u32) -> Vec<benilla_ui::script::EnchantView> {
match self.props.and_then(|p| p.0.get(random_property_id as i32)) {
Some(row) => random_property_lines(row, self.enchants),
None => Vec::new(),
}
}
}
/// Item templates by entry, asked once, and the enchant times queued for items not yet held.
#[derive(Resource, Default)]
pub(crate) struct Items {
/// A `None` answer is the server's unknown entry, cached so it is never re-asked.
templates: QueryCache<u32, ItemInfo>,
/// Landed since the last [`Self::take_fresh`]; pushed to the UI unprompted.
fresh: Vec<u32>,
/// The reference's `PlayerPendingItemExpiration` list (`0x5ebd40`, at `CGPlayer_C + 0x1cc8`):
/// `(guid, slot, seconds)` enchant updates for an item not yet held. `0x5ebde0` applies them
/// when the item is set up (`0x5d8440`), counting seconds from then; they live as long as the
/// player, here the session. `SMSG_ITEM_TIME_UPDATE` has no such list.
pending_enchant_times: Vec<(u64, u32, u32)>,
}
impl Items {
/// Queue an `SMSG_ITEM_ENCHANT_TIME_UPDATE` for an item not held. The caller has checked the
/// active player resolves: the reference drops the update when none does.
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 (`0x5ebde0`).
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));
}
!hit
});
taken
}
/// The template for `entry` if known; a miss asks once per entry per connection (`guid` rides
/// along, `0` for template-only). A cached negative is also `None`, without a re-ask.
pub(crate) fn template(
&self,
entry: u32,
guid: u64,
commands: &NetCommands,
) -> Option<&ItemInfo> {
self.templates.get_or_ask(entry, || {
debug!("items: asking template (entry {entry})");
let _ = commands.0.send(ClientCommand::ItemQuery { entry, guid });
})
}
/// Whether the server answered `entry` as unknown, not still pending; the cast-fail redisplay
/// then shows the reference's `"UNKNOWN"` instead of waiting.
pub(crate) fn template_answered_unknown(&self, entry: u32) -> bool {
self.templates.answered_unknown(entry)
}
/// The [`HeldTemplate`] view of [`Self::template`], asked template-only.
pub(crate) fn held(&self, entry: u32, commands: &NetCommands) -> Option<HeldTemplate> {
self.template(entry, 0, commands).map(|i| HeldTemplate {
display_info_id: i.display_info_id,
inventory_type: i.inventory_type,
sheath: i.sheath,
class: i.class,
subclass: i.subclass,
material: i.material,
})
}
/// [`Self::template`] without the ask: the cached record only.
pub(crate) fn template_cached(&self, entry: u32) -> Option<&ItemInfo> {
self.templates.get(entry)
}
/// Record a template answer (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`); `None` = unknown entry.
pub(crate) fn insert_template(&mut self, entry: u32, info: Option<ItemInfo>) {
if info.is_some() {
self.fresh.push(entry);
}
// A negative answer moves the generation too: "still asking" to "answered unknown" is a
// display transition for the cast-fail redisplay.
self.templates.insert(entry, info);
}
/// Bumped by every landed answer, for consumers besides the drain; the stand-in for the
/// reference's `DBCACHECALLBACK` redisplay (`0x6e29b0`).
pub(crate) fn template_epoch(&self) -> u64 {
self.templates.generation()
}
/// Every entry with a cached template.
pub(crate) fn cached_template_ids(&self) -> Vec<u32> {
self.templates
.iter()
.filter_map(|(&id, t)| t.is_some().then_some(id))
.collect()
}
/// Drain the entries whose template landed since the last drain.
pub(crate) fn take_fresh(&mut self) -> Vec<u32> {
std::mem::take(&mut self.fresh)
}
/// World enter: release the ask-once latch and keep what the cache learned. An ask is marked
/// pending before the send, and a send made while no writer is held is dropped silently.
pub(crate) fn clear_pending(&mut self) {
self.templates.clear_pending();
}
/// Disconnect: drop the in-flight asks and the queued enchant times, keep the templates. The
/// item objects and their countdowns go with the index's sweep.
pub(crate) fn clear_session(&mut self) {
self.pending_enchant_times.clear();
self.templates.clear_pending();
}
}
impl crate::query_cache::AskOnce for Items {
fn clear_pending(&mut self) {
Items::clear_pending(self);
}
}
/// The ask-once test fixture; it holds the live receiver, without which every send fails.
#[cfg(test)]
pub(crate) struct TestDeps {
pub(crate) items: Items,
pub(crate) commands: NetCommands,
/// The object index: seed with [`Self::spawn_item`], read through [`Self::with_objects`].
pub(crate) world: World,
rx: crossbeam_channel::Receiver<ClientCommand>,
}
#[cfg(test)]
impl TestDeps {
pub(crate) fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.init_resource::<GuidIndex>();
TestDeps {
items: Items::default(),
commands: NetCommands(tx),
world,
rx,
}
}
/// An item object in the index, as the wire's `ItemCreate` spawns it.
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 object lookup, the template cache and the command channel borrowed apart.
pub(crate) fn with_objects<R>(
&mut self,
f: impl FnOnce(&Objects, &Items, &NetCommands) -> R,
) -> R {
let (world, items, commands) = (&mut self.world, &self.items, &self.commands);
let mut state = bevy::ecs::system::SystemState::<Objects>::new(world);
let objects = state.get(world);
f(&objects, items, commands)
}
/// The entries asked of the server, observable while the icon they feed is still `None`.
pub(crate) fn queried_entries(&self) -> Vec<u32> {
self.rx
.try_iter()
.filter_map(|c| match c {
ClientCommand::ItemQuery { entry, .. } => Some(entry),
_ => None,
})
.collect()
}
}
/// [`spawn_item`] straight into a test world, without the command queue.
#[cfg(test)]
pub(crate) fn test_spawn_item(
world: &mut World,
guid: u64,
fields: ObjectFields,
container: bool,
) -> Entity {
let e = world
.spawn((Guid(guid), ObjectStore(fields), ItemObject { container }))
.id();
world.resource_mut::<GuidIndex>().0.insert(guid, e);
e
}
/// `EQUIPMENT_SLOT_MAINHAND` / `_OFFHAND` (vmangos `EquipmentSlots`).
pub(crate) const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
pub(crate) const EQUIPMENT_SLOT_OFFHAND: u8 = 16;
/// One equipment slot's item class; `None` while empty or its template is in flight.
fn equipped_class(
store: &ObjectStore,
objects: &Objects,
items: &Items,
commands: &NetCommands,
slot: u8,
) -> Option<u8> {
let guid = store.0.player_inv_slot(slot).filter(|&g| g != 0)?;
let entry = objects.object(guid).and_then(|o| o.object_entry())?;
Some(items.template(entry, guid, commands)?.class as u8)
}
/// The equipment slot this character's disarm hides ([`crate::creature_anim::disarmed_hand`] over
/// the raw inventory), for the action bar's equipped-item requirement (`0x5f0c50`) and the
/// item-use refusal (`CGItem::Use` rung 15). `None` while the flag is down or neither hand holds
/// a weapon.
pub(crate) fn disarmed_equipment_slot(
store: &ObjectStore,
objects: &Objects,
items: &Items,
commands: &NetCommands,
) -> Option<u8> {
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
return None;
}
let main = equipped_class(store, 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`] without the ask.
pub(crate) fn disarmed_equipment_slot_cached(
store: &ObjectStore,
objects: &Objects,
items: &Items,
) -> Option<u8> {
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
return None;
}
let class_of = |slot: u8| -> Option<u8> {
let guid = store.0.player_inv_slot(slot).filter(|&g| g != 0)?;
let entry = objects.object(guid).and_then(|o| o.object_entry())?;
Some(items.template_cached(entry)?.class as u8)
};
crate::creature_anim::disarmed_hand(
class_of(EQUIPMENT_SLOT_MAINHAND),
class_of(EQUIPMENT_SLOT_OFFHAND),
)
.map(|hand| EQUIPMENT_SLOT_MAINHAND + hand as u8)
}
/// A minimal valid item template named `name`; the sentinels that matter are `allowable_*` = -1
/// and `stackable` = 1.
#[cfg(test)]
pub(crate) fn test_template(name: &str) -> ItemInfo {
ItemInfo {
class: 0,
subclass: 0,
name: name.into(),
display_info_id: 1,
quality: 1,
flags: 0,
buy_price: 0,
sell_price: 0,
inventory_type: 0,
allowable_class: -1,
allowable_race: -1,
item_level: 0,
required_level: 0,
required_skill: 0,
required_skill_rank: 0,
required_spell: 0,
required_honor_rank: 0,
required_city_rank: 0,
required_rep_faction: 0,
required_rep_rank: 0,
max_count: 0,
stackable: 1,
container_slots: 0,
stats: Vec::new(),
damages: Vec::new(),
dmg_min: 0.0,
dmg_max: 0.0,
dmg_type: 0,
armor: 0,
resistances: [0; 6],
delay_ms: 0,
ammo_type: 0,
ranged_mod_range: 0.0,
spells: Vec::new(),
spell_charges_0: 0,
use_spell: None,
bonding: 0,
description: String::new(),
page_text: 0,
language_id: 0,
page_material: 0,
start_quest: 0,
lock_id: 0,
material: 0,
sheath: 0,
random_property: 0,
block: 0,
item_set: 0,
max_durability: 0,
area: 0,
map: 0,
bag_family: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::test_template as info;
use benilla_protocol::messages::{
ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED, ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER,
};
use crossbeam_channel::TryRecvError;
use std::collections::HashMap;
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
(NetCommands(tx), rx)
}
#[test]
fn template_miss_queries_once_then_serves_the_answer() {
let (cmds, rx) = commands();
let mut items = Items::default();
assert!(items.template(117, 0x42, &cmds).is_none());
// Second copy of the same item: no second query.
assert!(items.template(117, 0x43, &cmds).is_none());
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::ItemQuery {
entry: 117,
guid: 0x42
})
));
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
items.insert_template(117, Some(info("Tough Jerky")));
assert_eq!(
items.template(117, 0x43, &cmds).map(|i| i.name.as_str()),
Some("Tough Jerky")
);
}
#[test]
fn the_template_epoch_counts_landings_and_asks_count_nothing() {
let (cmds, _rx) = commands();
let mut items = Items::default();
let t0 = items.template_epoch();
assert!(items.template(117, 0x42, &cmds).is_none());
assert_eq!(
items.template_epoch(),
t0,
"an ask-once miss is not a change"
);
items.insert_template(117, Some(info("Tough Jerky")));
assert_ne!(items.template_epoch(), t0, "a landing is");
}
/// The step count is bounded, not exact: the test cannot pin the sub-second phase.
#[test]
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");
assert!(c.set_enchant(1, 90));
let steps = c.steps(Instant::now());
assert!(
(90..=91).contains(&steps),
"a 90 s deadline contributes floor(secs)+1, got {steps}"
);
let shown = c.enchant_remaining_display_ms(1).unwrap();
assert_eq!(shown % 1000, 0, "the display read is second-floored");
assert!(shown <= 90_000);
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!(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);
}
#[test]
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!(
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));
}
#[test]
fn landed_templates_drain_as_fresh_once() {
let mut items = Items::default();
items.insert_template(117, Some(info("Tough Jerky")));
items.insert_template(9999, None);
assert_eq!(items.take_fresh(), vec![117]);
assert!(items.take_fresh().is_empty(), "a drain empties the queue");
}
/// The tooltip's open line waits for an unlocked instance; the click is the bare template bit,
/// so a locked junkbox still sends and the server refuses.
#[test]
fn open_line_carries_the_lock_gate_the_open_send_does_not() {
let plain = |flags: u32, lock_id: u32| {
let mut t = info("Small Barnacled Clam");
t.flags = flags;
t.lock_id = lock_id;
t
};
// The clam: lootable, no lock; line and send agree.
assert!(plain(ITEM_FLAG_LOOTABLE, 0).shows_open_line(0));
assert!(plain(ITEM_FLAG_LOOTABLE, 0).opens_loot());
// An ordinary item does neither, however its instance is flagged.
assert!(!plain(0, 0).shows_open_line(ITEM_DYNFLAG_UNLOCKED | ITEM_DYNFLAG_WRAPPED));
assert!(!plain(0, 0).opens_loot());
// A junkbox, lootable but locked: no line until unlocked, but the click goes out.
assert!(!plain(ITEM_FLAG_LOOTABLE, 7).shows_open_line(0));
assert!(plain(ITEM_FLAG_LOOTABLE, 7).shows_open_line(ITEM_DYNFLAG_UNLOCKED));
assert!(
plain(ITEM_FLAG_LOOTABLE, 7).opens_loot(),
"the send ignores LockID entirely — the server owns the refusal"
);
// Gift wrap unwraps only while the instance is still wrapped, its own dispatcher arm.
assert!(!plain(ITEM_FLAG_WRAPPER, 0).unwraps_gift(0));
assert!(plain(ITEM_FLAG_WRAPPER, 0).unwraps_gift(ITEM_DYNFLAG_WRAPPED));
assert!(
!plain(ITEM_FLAG_WRAPPER, 0).opens_loot(),
"WRAPPER is not LOOTABLE"
);
assert!(plain(ITEM_FLAG_WRAPPER, 0).shows_open_line(ITEM_DYNFLAG_WRAPPED));
// A wrapped gift of a locked box: the gift arm ignores the lock gate on both sides.
let gift_box = plain(ITEM_FLAG_WRAPPER | ITEM_FLAG_LOOTABLE, 7);
assert!(gift_box.unwraps_gift(ITEM_DYNFLAG_WRAPPED));
assert!(gift_box.shows_open_line(ITEM_DYNFLAG_WRAPPED));
}
#[test]
fn negative_template_answer_is_cached() {
let (cmds, rx) = commands();
let mut items = Items::default();
assert!(items.template(9999, 0, &cmds).is_none());
let _ = rx.try_recv();
items.insert_template(9999, None); // server: unknown entry
assert!(items.template(9999, 0, &cmds).is_none());
assert!(
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
"no re-ask"
);
}
#[test]
fn an_item_is_an_object_in_the_index_and_its_changes_are_watched() {
use bevy::ecs::system::RunSystemOnce;
const GUID: u64 = 0x4000_0000_0000_0042;
let mut world = World::new();
world.init_resource::<GuidIndex>();
world
.run_system_once(|mut commands: Commands, mut index: ResMut<GuidIndex>| {
spawn_item(
&mut commands,
&mut index,
GUID,
ObjectFields::from_pairs(&[(3, 117), (14, 5)]),
false,
);
})
.unwrap();
// A registered system keeps its change ticks between runs (a one-shot would read every
// store as new every time).
let read = world.register_system(
|objects: Objects, mut changes: ItemChanges| -> (Option<u32>, Option<u32>, bool) {
let o = objects.object(GUID);
(
o.and_then(|o| o.object_entry()),
o.and_then(|o| o.item_stack_count()),
changes.moved(),
)
},
);
assert_eq!(
world.run_system(read).unwrap(),
(Some(117), Some(5), true),
"the spawn is a change"
);
assert_eq!(
world.run_system(read).unwrap(),
(Some(117), Some(5), false),
"nothing moved since"
);
// A values delta lands in the store and moves the watch once.
let e = world.resource::<GuidIndex>().0[&GUID];
world
.get_mut::<ObjectStore>(e)
.unwrap()
.0
.merge(ObjectFields::from_pairs(&[(14, 4)]));
assert_eq!(world.run_system(read).unwrap(), (Some(117), Some(4), true));
assert!(!world.run_system(read).unwrap().2);
// A countdown landing is the item's own change, and its cell joins the step sum.
world
.get_mut::<Countdowns>(e)
.expect("every item carries its cells")
.set_enchant(1, 90);
assert!(
world.run_system(read).unwrap().2,
"a landing moves the watch"
);
let steps = world
.run_system_once(|c: ItemChanges| c.countdown_steps())
.unwrap();
assert!((90..=91).contains(&steps), "got {steps}");
// The destroy: the index drops the guid, the entity goes, the watch moves once more.
world.resource_mut::<GuidIndex>().0.remove(&GUID);
world.despawn(e);
assert_eq!(world.run_system(read).unwrap(), (None, None, true));
assert!(!world.run_system(read).unwrap().2);
}
#[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"
);
// A disconnect drops unarrived records and in-flight asks, and keeps the templates.
let (cmds, rx) = commands();
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.take_enchant_times(0x43).is_empty());
assert!(items.template(117, 0, &cmds).is_some(), "templates survive");
// 118's ask was dropped with the writer, so it re-asks now.
let _ = rx.try_recv();
assert!(items.template(118, 0, &cmds).is_none());
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::ItemQuery { entry: 118, .. })
));
}
/// `abs(id)` names the row and the sign only travels (`0x52c9f9`).
#[test]
fn enchant_lines_join_named_ids_in_slot_order() {
let cat = Enchants(benilla_formats::EnchantCatalog::from_rows(
Default::default(),
[
(2564, "Agility +15".to_string()),
(1900, "Crusader".to_string()),
]
.into_iter()
.collect(),
Default::default(),
));
let named = |v: Vec<benilla_ui::script::EnchantView>| -> Vec<String> {
v.into_iter().map(|e| e.name).collect()
};
assert_eq!(
named(enchant_lines(
[(0, 2564, 0, None), (1, 0, 0, None), (4, 1900, 0, None)],
Some(&cat)
)),
vec!["Agility +15".to_string(), "Crusader".to_string()]
);
// A negative id resolves off `abs(id)` and carries its sign onward.
let neg = enchant_lines([(0, -2564, 0, None)], Some(&cat));
assert_eq!(neg.len(), 1);
assert_eq!(neg[0].name, "Agility +15");
assert!(neg[0].negative, "the sign travels to the colour rule");
// Charges and a countdown ride the slot through untouched (the engine formats them).
let temp = enchant_lines([(1, 1900, 5, Some(90_000))], Some(&cat));
assert_eq!((temp[0].charges, temp[0].remaining_ms), (5, Some(90_000)));
// An id the table does not name contributes nothing.
assert!(enchant_lines([(0, 999_999, 0, None)], Some(&cat)).is_empty());
// No DBC, no lines.
assert!(enchant_lines([(0, 2564, 0, None)], None).is_empty());
}
/// `SpellItemEnchantment` field 23 for three synthetic rows: one that binds, one that binds
/// AND hides its tooltip line (the Firestone shape), one that does neither.
fn catalog() -> Enchants {
Enchants(benilla_formats::EnchantCatalog::from_rows(
HashMap::new(),
HashMap::new(),
HashMap::from([(11, 0x1), (12, 0x1 | 0x2), (13, 0x0)]),
))
}
/// One item object with the given `ITEM_FIELD_FLAGS` and enchant slot 0.
fn item(flags: u32, slot0: u32) -> ObjectFields {
ObjectFields::from_pairs(&[(21, flags), (22, slot0)])
}
/// `0x5da2c0`: the Firestone shape binds and prints no line, so a predicate read off
/// [`enchant_lines`] would call it unbound.
#[test]
fn the_bind_predicate_reads_the_descriptor_not_the_rendered_lines() {
let cat = catalog();
let cat = Some(&cat);
assert!(
already_bound(&item(0x1, 0), cat),
"ITEM_FIELD_FLAGS & 1 — already soulbound"
);
assert!(
!already_bound(&item(0x0, 0), cat),
"no flag, no enchant — the plain BoE"
);
assert!(
!already_bound(&item(0x8, 0), cat),
"a WRAPPED gift is not a bound item — the bit is 0x1, not any bit"
);
assert!(
already_bound(&item(0x0, 11), cat),
"a live enchant slot naming a binding row"
);
assert!(
!already_bound(&item(0x0, 13), cat),
"a non-binding enchant leaves the item unbound"
);
assert!(
!already_bound(&item(0x0, 99), cat),
"an id that names no row binds nothing (the reference's `testl` after the table load)"
);
assert!(
!already_bound(&item(0x0, 11), None),
"no catalog loaded — the enchant half cannot answer, and does not guess"
);
// The case that pins the design: bound, and no line.
assert!(
already_bound(&item(0x0, 12), cat),
"the Firestone shape — binds AND hides its line"
);
assert!(
enchant_lines_quiet([(0u8, 12i32, 0u32, None)], cat).is_empty(),
"…and the rendered lines are empty for it, which is why they are not the source"
);
}
}