pub mod catalog;
mod definition;
mod layout;
#[cfg(test)]
#[path = "../../tests/tf2/attributes.rs"]
mod tests;
use crate::NotThreadSafe;
use crate::entities::Entity;
use crate::tf2::attributes::definition::RawDef;
use crate::tf2::attributes::layout::ItemLayout;
use crate::tf2::script_binding::{self as binding, BindingError};
use crate::tf2::weapons::ItemDefinitionIndex;
use crate::{Game, Server};
use sdk_raw::tf2::attributes::DEFAULT_CUSTOM_ATTRIBUTE_DURATION;
use std::ffi::CStr;
use std::marker::PhantomData;
pub use definition::{
Amount, AnyAttributeDef, AttributeDef, AttributeIndex, AttributeValue, Combine,
DescriptionFormat, Flag, Multiplier, Seconds,
};
pub(crate) use layout::item_definition;
#[doc(alias("MAX_ATTRIBUTES_PER_ITEM"))]
pub const MAX_RUNTIME_ATTRIBUTES: usize = sdk_raw::tf2::attributes::MAX_ATTRIBUTES_PER_ITEM;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AttributeError {
#[error("the attribute value or duration is invalid")]
InvalidValue,
#[error("the entity is marked for deletion")]
MarkedForDeletion,
#[error("the value is outside the attribute's supported range")]
OutOfDomain,
#[error("the game rejected the attribute change")]
Rejected,
#[error("the item already has the most runtime attributes the game networks")]
RuntimeListFull,
#[error("the running item schema maps the attribute's name to index {found}, not {expected}")]
SchemaMismatch {
expected: AttributeIndex,
found: AttributeIndex,
},
#[error("the server game dll interface is unavailable")]
Unavailable,
#[error("the runtime attribute list changed unexpectedly")]
UnexpectedChange,
#[error("the game did not apply the attribute; the running item schema may lack it")]
UnknownAttribute,
#[error("the item's runtime attribute list is not linked to its attribute container")]
Unlinked,
#[error("the entity is not a TF2 player or economy item, as required")]
UnsupportedEntity,
#[error("attributes require Team Fortress 2")]
UnsupportedGame,
#[error("the item's attribute storage does not match the sdk layout")]
UnsupportedLayout,
#[error("the game does not expose the expected native attribute method")]
UnsupportedMethod,
}
impl From<BindingError> for AttributeError {
fn from(error: BindingError) -> Self {
match error {
BindingError::Unavailable | BindingError::SignatureMismatch => Self::UnsupportedMethod,
BindingError::Rejected => Self::Rejected,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AttributeSet {
entries: Vec<(RawDef, f32)>,
}
impl AttributeSet {
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn apply<'s>(
&self,
token: SchemaToken<'s>,
item: ItemAttributes<'s>,
) -> Result<(), AttributeError> {
item.check_live()?;
let runtime = item.layout.snapshot()?;
let added = self
.entries
.iter()
.filter(|(def, _)| !runtime.iter().any(|entry| entry.index == def.index))
.count();
if added != 0 && runtime.len() + added > MAX_RUNTIME_ATTRIBUTES {
return Err(AttributeError::RuntimeListFull);
}
for &(def, stored) in &self.entries {
item.set_raw(token, def, stored)?;
}
Ok(())
}
pub fn insert<V: AttributeValue>(
&mut self,
def: &AttributeDef<V>,
value: V,
) -> Result<(), AttributeError> {
let stored = def.stored(value)?;
self.insert_raw(def.raw(), stored);
Ok(())
}
pub fn insert_any(&mut self, def: AnyAttributeDef, value: f32) -> Result<(), AttributeError> {
let stored = def.stored(value)?;
self.insert_raw(def.raw(), stored);
Ok(())
}
fn insert_raw(&mut self, def: RawDef, stored: f32) {
match self
.entries
.iter_mut()
.find(|(existing, _)| existing.index == def.index)
{
Some(entry) => *entry = (def, stored),
None => self.entries.push((def, stored)),
}
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn remove<V: AttributeValue>(&mut self, def: &AttributeDef<V>) -> bool {
let len = self.entries.len();
self.entries
.retain(|(existing, _)| existing.index != def.index());
self.entries.len() != len
}
pub fn with<V: AttributeValue>(
mut self,
def: &AttributeDef<V>,
value: V,
) -> Result<Self, AttributeError> {
self.insert(def, value)?;
Ok(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Change {
Appended(RuntimeAttribute),
Changed {
position: usize,
before: RuntimeAttribute,
},
None,
Other,
}
impl Change {
fn between(before: &[RuntimeAttribute], after: &[RuntimeAttribute]) -> Self {
if before == after {
return Self::None;
}
if after.len() == before.len() + 1 && after.starts_with(before) {
let added = after[before.len()];
return if before.iter().any(|entry| entry.index == added.index) {
Self::Other
} else {
Self::Appended(added)
};
}
if after.len() == before.len() {
let mut changed = before
.iter()
.zip(after)
.enumerate()
.filter(|(_, (old, new))| old != new);
if let (Some((position, (old, new))), None) = (changed.next(), changed.next())
&& old.index == new.index
&& old.refundable_currency == new.refundable_currency
{
return Self::Changed {
position,
before: *old,
};
}
}
Self::Other
}
}
#[doc(alias("CEconEntity", "CAttributeList"))]
#[derive(Debug, Clone, Copy)]
pub struct ItemAttributes<'s> {
layout: ItemLayout<'s>,
}
impl<'s> ItemAttributes<'s> {
pub fn new(server: Server<'s>, entity: Entity<'s>) -> Result<Self, AttributeError> {
if server.game() != Game::TeamFortress2 {
return Err(AttributeError::UnsupportedGame);
}
if !entity
.data_maps()
.any(|map| map.class_name() == Some(c"CEconEntity"))
{
return Err(AttributeError::UnsupportedEntity);
}
let dll = server
.server_game_dll()
.map_err(|_| AttributeError::Unavailable)?;
Ok(Self {
layout: ItemLayout::validate(dll, entity)?,
})
}
unsafe fn add(self, name: &CStr, value: f32) -> Result<(), AttributeError> {
unsafe {
self.call(
c"AddAttribute",
&mut [
binding::string(name),
binding::float(value),
binding::float(DEFAULT_CUSTOM_ATTRIBUTE_DURATION),
],
binding::VOID,
)
}?;
Ok(())
}
unsafe fn call(
self,
method: &CStr,
arguments: &mut [sys::ScriptVariant_t],
result: sys::ScriptDataType_t,
) -> Result<sys::ScriptVariant_t, AttributeError> {
Ok(unsafe { binding::call(self.entity(), c"CEconEntity", method, arguments, result) }?)
}
fn check_live(self) -> Result<(), AttributeError> {
if self.entity().is_marked_for_deletion() {
Err(AttributeError::MarkedForDeletion)
} else {
Ok(())
}
}
#[doc(alias("m_iItemDefinitionIndex"))]
pub fn definition(self) -> Result<Option<ItemDefinitionIndex>, AttributeError> {
self.check_live()?;
Ok(ItemDefinitionIndex::new(self.layout.definition_index()))
}
pub const fn entity(self) -> Entity<'s> {
self.layout.entity()
}
#[doc(alias("GetAttribute"))]
pub fn get<V: AttributeValue>(
self,
token: SchemaToken<'s>,
def: &AttributeDef<V>,
) -> Result<Option<V>, AttributeError> {
let _ = token;
self.check_live()?;
let result = unsafe {
self.call(
c"GetAttribute",
&mut [binding::string(def.name()), binding::float(f32::NAN)],
binding::FLOAT,
)
}?;
let value = unsafe { result.__bindgen_anon_1.m_float };
if value.is_nan() {
return Ok(None);
}
V::from_stored(value)
.map(Some)
.ok_or(AttributeError::InvalidValue)
}
#[doc(alias("GetAttribute"))]
pub fn get_by_name(
self,
token: SchemaToken<'s>,
name: &CStr,
) -> Result<Option<f32>, AttributeError> {
let _ = token;
self.check_live()?;
let result = unsafe {
self.call(
c"GetAttribute",
&mut [binding::string(name), binding::float(f32::NAN)],
binding::FLOAT,
)
}?;
let value = unsafe { result.__bindgen_anon_1.m_float };
Ok((!value.is_nan()).then_some(value))
}
#[doc(alias("ReapplyProvision"))]
pub fn reapply_provision(self, token: SchemaToken<'s>) -> Result<(), AttributeError> {
let _ = token;
self.check_live()?;
unsafe { self.call(c"ReapplyProvision", &mut [], binding::VOID) }?;
Ok(())
}
#[doc(alias("OnAttributeValuesChanged"))]
pub fn refresh(self) -> Result<(), AttributeError> {
self.check_live()?;
self.layout.notify()
}
#[doc(alias("RemoveAttribute"))]
pub fn remove<V: AttributeValue>(self, def: &AttributeDef<V>) -> Result<bool, AttributeError> {
self.check_live()?;
let def = def.raw();
let before = self.layout.snapshot()?;
if !before.iter().any(|entry| entry.index == def.index) {
return Ok(false);
}
self.remove_native(def.name)?;
let after = self.layout.snapshot()?;
match removed_entry(&before, &after)? {
None => Err(AttributeError::UnknownAttribute),
Some(entry) if entry.index == def.index => Ok(true),
Some(entry) => Err(AttributeError::SchemaMismatch {
expected: def.index,
found: entry.index,
}),
}
}
#[doc(alias("RemoveAttribute"))]
pub fn remove_by_name(self, name: &CStr) -> Result<Option<RuntimeAttribute>, AttributeError> {
self.check_live()?;
let before = self.layout.snapshot()?;
self.remove_native(name)?;
let after = self.layout.snapshot()?;
removed_entry(&before, &after)
}
fn remove_native(self, name: &CStr) -> Result<(), AttributeError> {
unsafe {
self.call(
c"RemoveAttribute",
&mut [binding::string(name)],
binding::VOID,
)
}?;
Ok(())
}
#[doc(alias("m_AttributeList"))]
pub fn runtime(self) -> Result<Vec<RuntimeAttribute>, AttributeError> {
self.check_live()?;
self.layout.snapshot()
}
pub fn runtime_value<V: AttributeValue>(
self,
def: &AttributeDef<V>,
) -> Result<Option<V>, AttributeError> {
self.runtime()?
.into_iter()
.find(|entry| entry.index == def.index())
.map(|entry| V::from_stored(entry.value()).ok_or(AttributeError::InvalidValue))
.transpose()
}
#[doc(alias("AddAttribute"))]
pub fn set<V: AttributeValue>(
self,
token: SchemaToken<'s>,
def: &AttributeDef<V>,
value: V,
) -> Result<(), AttributeError> {
let stored = def.stored(value)?;
self.set_raw(token, def.raw(), stored)
}
#[doc(alias("AddAttribute"))]
pub fn set_any(
self,
token: SchemaToken<'s>,
def: AnyAttributeDef,
value: f32,
) -> Result<(), AttributeError> {
let stored = def.stored(value)?;
self.set_raw(token, def.raw(), stored)
}
#[doc(alias("AddAttribute"))]
pub unsafe fn set_by_name_unchecked(
self,
name: &CStr,
value: f32,
) -> Result<Option<RuntimeAttribute>, AttributeError> {
self.check_live()?;
if !value.is_finite() {
return Err(AttributeError::InvalidValue);
}
let before = self.layout.snapshot()?;
unsafe { self.add(name, value) }?;
let after = self.layout.snapshot()?;
match Change::between(&before, &after) {
Change::Appended(_) if after.len() > MAX_RUNTIME_ATTRIBUTES => {
self.undo_append(name, &before)?;
Err(AttributeError::RuntimeListFull)
}
Change::Appended(entry) => Ok(Some(entry)),
Change::Changed { position, .. } => Ok(Some(after[position])),
Change::None => Ok(None),
Change::Other => Err(AttributeError::UnexpectedChange),
}
}
fn set_raw(
self,
token: SchemaToken<'s>,
def: RawDef,
stored: f32,
) -> Result<(), AttributeError> {
let _ = token;
self.check_live()?;
let bits = stored.to_bits();
let before = self.layout.snapshot()?;
match before.iter().find(|entry| entry.index == def.index) {
Some(entry) if entry.bits == bits => {
let probe = if def.min.to_bits() == bits {
def.max
} else {
def.min
};
self.set_raw(token, def, probe)?;
return self.set_raw(token, def, stored);
}
None if before.len() >= MAX_RUNTIME_ATTRIBUTES => {
return Err(AttributeError::RuntimeListFull);
}
_ => {}
}
unsafe { self.add(def.name, stored) }?;
let after = self.layout.snapshot()?;
let check = |entry: RuntimeAttribute| {
if entry.index != def.index {
Err(AttributeError::SchemaMismatch {
expected: def.index,
found: entry.index,
})
} else if entry.bits != bits {
Err(AttributeError::Rejected)
} else {
Ok(())
}
};
match Change::between(&before, &after) {
Change::Appended(entry) => {
let outcome = match check(entry) {
Ok(()) if after.len() > MAX_RUNTIME_ATTRIBUTES => {
Err(AttributeError::RuntimeListFull)
}
outcome => outcome,
};
if outcome.is_err() {
self.undo_append(def.name, &before)?;
}
outcome
}
Change::Changed {
position,
before: previous,
} => {
let outcome = check(after[position]);
if outcome.is_err() {
unsafe { self.layout.restore_bits(position, previous.bits) }?;
self.layout.notify()?;
}
outcome
}
Change::None => Err(AttributeError::UnknownAttribute),
Change::Other => Err(AttributeError::UnexpectedChange),
}
}
fn undo_append(self, name: &CStr, before: &[RuntimeAttribute]) -> Result<(), AttributeError> {
let _ = self.remove_native(name);
let after = self.layout.snapshot()?;
if after == before {
return Ok(());
}
if after.len() == before.len() + 1 && after.starts_with(before) {
unsafe { self.layout.truncate(before.len()) }?;
return self.layout.notify();
}
Err(AttributeError::UnexpectedChange)
}
}
#[doc(alias("CTFPlayer"))]
#[derive(Debug, Clone, Copy)]
pub struct PlayerAttributes<'s> {
player: Entity<'s>,
}
impl<'s> PlayerAttributes<'s> {
pub fn new(server: Server<'s>, player: Entity<'s>) -> Result<Self, AttributeError> {
if server.game() != Game::TeamFortress2 {
return Err(AttributeError::UnsupportedGame);
}
if !player
.data_maps()
.any(|map| map.class_name() == Some(c"CTFPlayer"))
{
return Err(AttributeError::UnsupportedEntity);
}
Ok(Self { player })
}
unsafe fn call(
self,
method: &CStr,
arguments: &mut [sys::ScriptVariant_t],
result: sys::ScriptDataType_t,
) -> Result<sys::ScriptVariant_t, AttributeError> {
if self.player.is_marked_for_deletion() {
return Err(AttributeError::MarkedForDeletion);
}
Ok(unsafe { binding::call(self.player, c"CTFPlayer", method, arguments, result) }?)
}
#[doc(alias("GetCustomAttribute"))]
pub fn get(self, token: SchemaToken<'s>, name: &CStr) -> Result<Option<f32>, AttributeError> {
let _ = token;
unsafe { self.read(name) }
}
pub const fn player(self) -> Entity<'s> {
self.player
}
unsafe fn read(self, name: &CStr) -> Result<Option<f32>, AttributeError> {
let result = unsafe {
self.call(
c"GetCustomAttribute",
&mut [binding::string(name), binding::float(f32::NAN)],
binding::FLOAT,
)
}?;
let value = unsafe { result.__bindgen_anon_1.m_float };
Ok((!value.is_nan()).then_some(value))
}
#[doc(alias("RemoveCustomAttribute"))]
pub fn remove(self, token: SchemaToken<'s>, name: &CStr) -> Result<(), AttributeError> {
let _ = token;
unsafe {
self.call(
c"RemoveCustomAttribute",
&mut [binding::string(name)],
binding::VOID,
)
}?;
Ok(())
}
#[doc(alias("AddCustomAttribute"))]
pub fn set<V: AttributeValue>(
self,
token: SchemaToken<'s>,
def: &AttributeDef<V>,
value: V,
duration: Option<f32>,
) -> Result<bool, AttributeError> {
let _ = token;
let stored = def.stored(value)?;
unsafe { self.set_for_unchecked(def.name(), stored, duration) }
}
#[doc(alias("AddCustomAttribute"))]
pub fn set_any(
self,
token: SchemaToken<'s>,
def: AnyAttributeDef,
value: f32,
duration: Option<f32>,
) -> Result<bool, AttributeError> {
let _ = token;
let stored = def.stored(value)?;
unsafe { self.set_for_unchecked(def.name(), stored, duration) }
}
#[doc(alias("AddCustomAttribute"))]
pub unsafe fn set_for_unchecked(
self,
name: &CStr,
value: f32,
duration: Option<f32>,
) -> Result<bool, AttributeError> {
if !value.is_finite()
|| duration.is_some_and(|seconds| !seconds.is_finite() || seconds <= 0.0)
{
return Err(AttributeError::InvalidValue);
}
unsafe {
self.call(
c"AddCustomAttribute",
&mut [
binding::string(name),
binding::float(value),
binding::float(duration.unwrap_or(DEFAULT_CUSTOM_ATTRIBUTE_DURATION)),
],
binding::VOID,
)
}?;
Ok(unsafe { self.read(name) }?.is_some_and(|stored| stored == value))
}
#[doc(alias("AddCustomAttribute"))]
pub unsafe fn set_unchecked(self, name: &CStr, value: f32) -> Result<bool, AttributeError> {
unsafe { self.set_for_unchecked(name, value, None) }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RuntimeAttribute {
pub index: AttributeIndex,
pub bits: u32,
pub refundable_currency: i32,
}
impl RuntimeAttribute {
pub const fn value(self) -> f32 {
f32::from_bits(self.bits)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SchemaToken<'s> {
_scope: PhantomData<&'s ()>,
_not_thread_safe: NotThreadSafe,
}
fn removed_entry(
before: &[RuntimeAttribute],
after: &[RuntimeAttribute],
) -> Result<Option<RuntimeAttribute>, AttributeError> {
if after == before {
return Ok(None);
}
let position = before
.iter()
.zip(after)
.position(|(old, new)| old != new)
.unwrap_or(after.len());
if after.len() + 1 != before.len() || before[position + 1..] != after[position..] {
return Err(AttributeError::UnexpectedChange);
}
Ok(Some(before[position]))
}
pub const unsafe fn trust_shipped_schema<'s>(server: Server<'s>) -> SchemaToken<'s> {
let _ = server;
SchemaToken {
_scope: PhantomData,
_not_thread_safe: PhantomData,
}
}