#[cfg(test)]
#[path = "../tests/tf2/item_schema.rs"]
mod tests;
use crate::tf2::PlayerClass;
use crate::tf2::weapons::{ItemDefinitionIndex, ItemGenerationError};
use crate::{Game, NotThreadSafe, Server};
use sdk_raw::tf2::item_generation::ItemGeneration;
use sdk_raw::tf2::item_schema;
use std::ffi::{CStr, c_char, c_int};
use std::marker::PhantomData;
use std::ops::RangeInclusive;
use std::ptr::NonNull;
use std::sync::OnceLock;
const CHECKED: [(u16, VisionFilter); 3] = [
(738, VisionFilter::PYRO),
(30143, VisionFilter::ROME),
(49, VisionFilter::empty()),
];
const CHECKED_DETAILS: [Details; 4] = [
Details {
index: 18,
class: Some(PlayerClass::Soldier),
position: LoadoutPosition::Primary,
quality: ItemQuality::Normal,
levels: Some((1, 1)),
holiday: None,
},
Details {
index: 49,
class: Some(PlayerClass::Heavy),
position: LoadoutPosition::Misc,
quality: ItemQuality::Unique,
levels: None,
holiday: None,
},
Details {
index: 115,
class: None,
position: LoadoutPosition::Misc,
quality: ItemQuality::Unique,
levels: Some((10, 10)),
holiday: Some(c"halloween_or_fullmoon"),
},
Details {
index: 738,
class: None,
position: LoadoutPosition::Misc,
quality: ItemQuality::Unique,
levels: Some((20, 20)),
holiday: None,
},
];
static DETAILS: OnceLock<bool> = OnceLock::new();
struct Details {
index: u16,
class: Option<PlayerClass>,
position: LoadoutPosition,
quality: ItemQuality,
levels: Option<(u8, u8)>,
holiday: Option<&'static CStr>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RawDetails {
classes: u32,
default_position: c_int,
max_level: u8,
min_level: u8,
positions: [c_int; 11],
quality: u8,
}
#[doc(alias("loadout_positions_t", "LOADOUT_POSITION"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum LoadoutPosition {
#[doc(alias("LOADOUT_POSITION_PRIMARY"))]
Primary,
#[doc(alias("LOADOUT_POSITION_SECONDARY"))]
Secondary,
#[doc(alias("LOADOUT_POSITION_MELEE"))]
Melee,
#[doc(alias("LOADOUT_POSITION_UTILITY"))]
Utility,
#[doc(alias("LOADOUT_POSITION_BUILDING"))]
Building,
#[doc(alias("LOADOUT_POSITION_PDA"))]
Pda,
#[doc(alias("LOADOUT_POSITION_PDA2"))]
Pda2,
#[doc(alias("LOADOUT_POSITION_HEAD"))]
Head,
#[doc(alias("LOADOUT_POSITION_MISC"))]
Misc,
#[doc(alias("LOADOUT_POSITION_ACTION"))]
Action,
#[doc(alias("LOADOUT_POSITION_MISC2"))]
Misc2,
#[doc(alias("LOADOUT_POSITION_TAUNT"))]
Taunt,
#[doc(alias("LOADOUT_POSITION_TAUNT2"))]
Taunt2,
#[doc(alias("LOADOUT_POSITION_TAUNT3"))]
Taunt3,
#[doc(alias("LOADOUT_POSITION_TAUNT4"))]
Taunt4,
#[doc(alias("LOADOUT_POSITION_TAUNT5"))]
Taunt5,
#[doc(alias("LOADOUT_POSITION_TAUNT6"))]
Taunt6,
#[doc(alias("LOADOUT_POSITION_TAUNT7"))]
Taunt7,
#[doc(alias("LOADOUT_POSITION_TAUNT8"))]
Taunt8,
}
impl LoadoutPosition {
pub const ALL: [Self; 19] = [
Self::Primary,
Self::Secondary,
Self::Melee,
Self::Utility,
Self::Building,
Self::Pda,
Self::Pda2,
Self::Head,
Self::Misc,
Self::Action,
Self::Misc2,
Self::Taunt,
Self::Taunt2,
Self::Taunt3,
Self::Taunt4,
Self::Taunt5,
Self::Taunt6,
Self::Taunt7,
Self::Taunt8,
];
pub const fn from_raw(raw: c_int) -> Option<Self> {
if raw >= 0 && raw < Self::ALL.len() as c_int {
Some(Self::ALL[raw as usize])
} else {
None
}
}
pub const fn to_raw(self) -> c_int {
self as c_int
}
}
const CHECKED_STRINGS: [(u16, &CStr, &CStr); 3] = [
(18, c"TF_WEAPON_ROCKETLAUNCHER", c"tf_weapon_rocketlauncher"),
(49, c"Football Helmet", c"tf_wearable"),
(738, c"Pet Balloonicorn", c"tf_wearable"),
];
#[doc(alias("pszWpnEntTranslationList"))]
const CLASS_ITEM_CLASSES: [(&CStr, [Option<&CStr>; 9]); 8] = [
(
c"tf_weapon_shotgun",
[
None,
None,
Some(c"tf_weapon_shotgun_soldier"),
None,
None,
Some(c"tf_weapon_shotgun_hwg"),
Some(c"tf_weapon_shotgun_pyro"),
None,
Some(c"tf_weapon_shotgun_primary"),
],
),
(
c"tf_weapon_pistol",
[
Some(c"tf_weapon_pistol_scout"),
None,
None,
None,
None,
None,
None,
None,
Some(c"tf_weapon_pistol"),
],
),
(
c"tf_weapon_shovel",
[
None,
None,
Some(c"tf_weapon_shovel"),
Some(c"tf_weapon_bottle"),
None,
None,
None,
None,
None,
],
),
(
c"tf_weapon_bottle",
[
None,
None,
Some(c"tf_weapon_shovel"),
Some(c"tf_weapon_bottle"),
None,
None,
None,
None,
None,
],
),
(
c"saxxy",
[
Some(c"tf_weapon_bat"),
Some(c"tf_weapon_club"),
Some(c"tf_weapon_shovel"),
Some(c"tf_weapon_bottle"),
Some(c"tf_weapon_bonesaw"),
Some(c"tf_weapon_fireaxe"),
Some(c"tf_weapon_fireaxe"),
Some(c"tf_weapon_knife"),
Some(c"tf_weapon_wrench"),
],
),
(
c"tf_weapon_throwable",
[
Some(c"tf_weapon_throwable_secondary"),
Some(c"tf_weapon_throwable_secondary"),
Some(c"tf_weapon_throwable_secondary"),
Some(c"tf_weapon_throwable_secondary"),
Some(c"tf_weapon_throwable_primary"),
Some(c"tf_weapon_throwable_secondary"),
Some(c"tf_weapon_throwable_secondary"),
Some(c"tf_weapon_throwable_secondary"),
Some(c"tf_weapon_throwable_secondary"),
],
),
(
c"tf_weapon_parachute",
[
None,
None,
Some(c"tf_weapon_parachute_secondary"),
Some(c"tf_weapon_parachute_primary"),
None,
None,
None,
None,
None,
],
),
(
c"tf_weapon_revolver",
[
None,
None,
None,
None,
None,
None,
None,
Some(c"tf_weapon_revolver"),
Some(c"tf_weapon_revolver_secondary"),
],
),
];
#[doc(alias("TranslateWeaponEntForClass"))]
fn class_item_class(item_class: &CStr, class: PlayerClass) -> Option<&CStr> {
let translated = CLASS_ITEM_CLASSES.iter().find(|(generic, _)| {
generic
.to_bytes()
.eq_ignore_ascii_case(item_class.to_bytes())
});
match translated {
Some((_, classes)) => classes[class.to_raw() as usize - 1],
None => Some(item_class),
}
}
static STRINGS: OnceLock<bool> = OnceLock::new();
static LAYOUT: OnceLock<bool> = OnceLock::new();
#[doc(alias("CEconItemDefinition"))]
#[derive(Debug, Clone, Copy)]
pub struct ItemDefinition<'s> {
raw: NonNull<sys::CEconItemDefinition>,
_scope: PhantomData<&'s ()>,
_not_thread_safe: NotThreadSafe,
}
#[doc(alias("m_iEntityLevel"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ItemLevel(u8);
impl ItemLevel {
pub const DEFAULT: Self = Self(1);
pub const MAX: Self = Self(i8::MAX as u8);
pub const fn new(level: u8) -> Option<Self> {
if level <= Self::MAX.0 {
Some(Self(level))
} else {
None
}
}
pub const fn get(self) -> u8 {
self.0
}
}
impl Default for ItemLevel {
fn default() -> Self {
Self::DEFAULT
}
}
#[doc(alias("EEconItemQuality", "entityquality_t", "m_iEntityQuality"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ItemQuality {
#[doc(alias("AE_NORMAL"))]
Normal,
#[doc(alias("AE_RARITY1"))]
Genuine,
#[doc(alias("AE_VINTAGE"))]
Vintage,
#[doc(alias("AE_UNUSUAL"))]
Unusual,
#[doc(alias("AE_UNIQUE"))]
Unique,
#[doc(alias("AE_COMMUNITY"))]
Community,
#[doc(alias("AE_DEVELOPER"))]
Valve,
#[doc(alias("AE_SELFMADE"))]
SelfMade,
#[doc(alias("AE_STRANGE"))]
Strange,
#[doc(alias("AE_HAUNTED"))]
Haunted,
#[doc(alias("AE_COLLECTORS"))]
Collectors,
#[doc(alias("AE_PAINTKITWEAPON"))]
DecoratedWeapon,
}
impl ItemQuality {
pub const ALL: [Self; 12] = [
Self::Normal,
Self::Genuine,
Self::Vintage,
Self::Unusual,
Self::Unique,
Self::Community,
Self::Valve,
Self::SelfMade,
Self::Strange,
Self::Haunted,
Self::Collectors,
Self::DecoratedWeapon,
];
pub const fn from_raw(raw: sys::entityquality_t) -> Option<Self> {
Some(match raw {
sys::EEconItemQuality_AE_NORMAL => Self::Normal,
sys::EEconItemQuality_AE_RARITY1 => Self::Genuine,
sys::EEconItemQuality_AE_VINTAGE => Self::Vintage,
sys::EEconItemQuality_AE_UNUSUAL => Self::Unusual,
sys::EEconItemQuality_AE_UNIQUE => Self::Unique,
sys::EEconItemQuality_AE_COMMUNITY => Self::Community,
sys::EEconItemQuality_AE_DEVELOPER => Self::Valve,
sys::EEconItemQuality_AE_SELFMADE => Self::SelfMade,
sys::EEconItemQuality_AE_STRANGE => Self::Strange,
sys::EEconItemQuality_AE_HAUNTED => Self::Haunted,
sys::EEconItemQuality_AE_COLLECTORS => Self::Collectors,
sys::EEconItemQuality_AE_PAINTKITWEAPON => Self::DecoratedWeapon,
_ => return None,
})
}
pub const fn to_raw(self) -> sys::entityquality_t {
match self {
Self::Normal => sys::EEconItemQuality_AE_NORMAL,
Self::Genuine => sys::EEconItemQuality_AE_RARITY1,
Self::Vintage => sys::EEconItemQuality_AE_VINTAGE,
Self::Unusual => sys::EEconItemQuality_AE_UNUSUAL,
Self::Unique => sys::EEconItemQuality_AE_UNIQUE,
Self::Community => sys::EEconItemQuality_AE_COMMUNITY,
Self::Valve => sys::EEconItemQuality_AE_DEVELOPER,
Self::SelfMade => sys::EEconItemQuality_AE_SELFMADE,
Self::Strange => sys::EEconItemQuality_AE_STRANGE,
Self::Haunted => sys::EEconItemQuality_AE_HAUNTED,
Self::Collectors => sys::EEconItemQuality_AE_COLLECTORS,
Self::DecoratedWeapon => sys::EEconItemQuality_AE_PAINTKITWEAPON,
}
}
}
impl<'s> ItemDefinition<'s> {
#[doc(alias("m_nDefIndex", "GetDefinitionIndex"))]
pub fn index(self) -> u16 {
unsafe { (&raw const (*self.raw.as_ptr()).m_nDefIndex).read() }
}
#[doc(alias("m_nVisionFilterFlags", "GetVisionFilterFlags", "vision_filter_flags"))]
pub fn vision_filter(self) -> VisionFilter {
VisionFilter::from_bits_retain(unsafe {
(&raw const (*self.raw.as_ptr()).m_nVisionFilterFlags).read()
})
}
#[doc(alias("m_pszItemClassname", "GetItemClass"))]
pub fn item_class(self) -> Result<Option<&'s CStr>, ItemSchemaError> {
strings_checked()?;
Ok(unsafe { self.read_string(|raw| &raw const (*raw).m_pszItemClassname) })
}
#[doc(alias("TranslateWeaponEntForClass"))]
pub fn item_class_for(self, class: PlayerClass) -> Result<Option<&'s CStr>, ItemSchemaError> {
Ok(self
.item_class()?
.and_then(|item_class| class_item_class(item_class, class)))
}
#[doc(alias("m_pszDefinitionName", "GetDefinitionName"))]
pub fn name(self) -> Result<Option<&'s CStr>, ItemSchemaError> {
strings_checked()?;
Ok(unsafe { self.read_string(|raw| &raw const (*raw).m_pszDefinitionName) })
}
fn new(raw: NonNull<sys::CEconItemDefinition>) -> Self {
Self {
raw,
_scope: PhantomData,
_not_thread_safe: PhantomData,
}
}
unsafe fn read_string(
self,
field: impl FnOnce(*const sys::CEconItemDefinition) -> *const *const c_char,
) -> Option<&'s CStr> {
unsafe {
let string = field(self.raw.as_ptr()).read();
(!string.is_null()).then(|| CStr::from_ptr(string))
}
}
#[doc(alias("m_iDefaultLoadoutSlot", "GetDefaultLoadoutSlot", "item_slot"))]
pub fn default_loadout_position(self) -> Result<Option<LoadoutPosition>, ItemSchemaError> {
Ok(LoadoutPosition::from_raw(
self.checked_details()?.default_position,
))
}
#[doc(alias("m_pszHolidayRestriction", "GetHolidayRestriction"))]
pub fn holiday_restriction(self) -> Result<Option<&'s CStr>, ItemSchemaError> {
self.checked_details()?;
Ok(unsafe { self.read_string(|raw| &raw const (*raw).m_pszHolidayRestriction) })
}
#[doc(alias("m_vbClassUsability", "CanBeUsedByClass", "used_by_classes"))]
pub fn is_used_by(self, class: PlayerClass) -> Result<bool, ItemSchemaError> {
Ok(self.checked_details()?.classes & 1 << class.to_raw() != 0)
}
#[doc(alias("m_unMinItemLevel", "m_unMaxItemLevel", "GetMinLevel", "GetMaxLevel"))]
pub fn levels(self) -> Result<RangeInclusive<u8>, ItemSchemaError> {
let details = self.checked_details()?;
Ok(details.min_level..=details.max_level)
}
#[doc(alias("m_iLoadoutSlots", "GetLoadoutSlot"))]
pub fn loadout_position(
self,
class: PlayerClass,
) -> Result<Option<LoadoutPosition>, ItemSchemaError> {
let positions = self.checked_details()?.positions;
Ok(positions
.get(class.to_raw() as usize)
.and_then(|&position| LoadoutPosition::from_raw(position)))
}
#[doc(alias("m_nItemQuality", "GetQuality", "item_quality"))]
pub fn quality(self) -> Result<Option<ItemQuality>, ItemSchemaError> {
Ok(ItemQuality::from_raw(c_int::from(
self.checked_details()?.quality,
)))
}
fn checked_details(self) -> Result<RawDetails, ItemSchemaError> {
match DETAILS.get() {
Some(true) => Ok(unsafe { self.read_details() }),
_ => Err(ItemSchemaError::UnsupportedLayout),
}
}
unsafe fn read_details(self) -> RawDetails {
let base = self.raw.as_ptr();
let tf = base.cast::<sys::CTFItemDefinition>();
unsafe {
RawDetails {
classes: (&raw const (*tf).m_vbClassUsability).read(),
default_position: (&raw const (*tf).m_iDefaultLoadoutSlot).read(),
max_level: (&raw const (*base).m_unMaxItemLevel).read(),
min_level: (&raw const (*base).m_unMinItemLevel).read(),
positions: (&raw const (*tf).m_iLoadoutSlots).read(),
quality: (&raw const (*base).m_nItemQuality).read(),
}
}
}
}
#[doc(alias("CEconItemSchema"))]
#[derive(Debug, Clone, Copy)]
pub struct ItemSchema<'s> {
generation: ItemGeneration,
_scope: PhantomData<&'s ()>,
_not_thread_safe: NotThreadSafe,
}
impl<'s> ItemSchema<'s> {
pub fn new(server: Server<'s>) -> Result<Self, ItemSchemaError> {
if server.game() != Game::TeamFortress2 {
return Err(ItemSchemaError::NotTf2);
}
let generation = unsafe { ItemGeneration::cached(server.game_server_factory().as_raw()) }?;
let schema = Self {
generation,
_scope: PhantomData,
_not_thread_safe: PhantomData,
};
let checked = match LAYOUT.get() {
Some(&checked) => checked,
None => {
let checked = schema.check_layout()?;
LAYOUT.set(checked).ok();
checked
}
};
if !checked {
return Err(ItemSchemaError::UnsupportedLayout);
}
if STRINGS.get().is_none()
&& let Ok(Some(checked)) = schema.check_strings()
{
STRINGS.set(checked).ok();
}
if DETAILS.get().is_none()
&& let Ok(Some(checked)) = schema.check_details()
{
DETAILS.set(checked).ok();
}
Ok(schema)
}
fn check_layout(self) -> Result<bool, ItemSchemaError> {
for (index, flag) in CHECKED {
let Some(definition) = self.find(index)? else {
return Err(ItemSchemaError::Generation(
ItemGenerationError::UnknownDefinition,
));
};
let vision_filter = definition.vision_filter();
let flagged = if flag.is_empty() {
vision_filter.is_empty()
} else {
vision_filter.contains(flag)
};
if definition.index() != index || !flagged {
return Ok(false);
}
}
Ok(true)
}
#[doc(alias("GetItemDefinition"))]
pub fn definition(
self,
index: ItemDefinitionIndex,
) -> Result<Option<ItemDefinition<'s>>, ItemSchemaError> {
let definition = self.find(index.get())?;
match definition {
Some(definition) if definition.index() != index.get() => {
Err(ItemSchemaError::UnsupportedLayout)
}
definition => Ok(definition),
}
}
fn find(self, index: u16) -> Result<Option<ItemDefinition<'s>>, ItemSchemaError> {
match unsafe { self.generation.definition(index) } {
Ok(raw) => Ok(Some(ItemDefinition::new(raw))),
Err(ItemGenerationError::UnknownDefinition) => Ok(None),
Err(error) => Err(error.into()),
}
}
fn check_strings(self) -> Result<Option<bool>, ItemSchemaError> {
for (index, name, class) in CHECKED_STRINGS {
let Some(definition) = self.find(index)? else {
return Ok(None);
};
let strings = unsafe {
(
definition.read_string(|raw| &raw const (*raw).m_pszDefinitionName),
definition.read_string(|raw| &raw const (*raw).m_pszItemClassname),
)
};
if strings != (Some(name), Some(class)) {
return Ok(Some(false));
}
}
Ok(Some(true))
}
fn check_details(self) -> Result<Option<bool>, ItemSchemaError> {
for details in CHECKED_DETAILS {
let Some(definition) = self.find(details.index)? else {
return Ok(None);
};
let raw = unsafe { definition.read_details() };
let levels = details
.levels
.is_none_or(|levels| levels == (raw.min_level, raw.max_level));
let mut classes = 0;
let mut positions = [-1; 11];
for class in PlayerClass::ALL {
if details.class.is_none_or(|only| only == class) {
classes |= 1 << class.to_raw();
positions[class.to_raw() as usize] = details.position.to_raw();
}
}
let checked = levels
&& raw.classes == classes
&& raw.positions == positions
&& raw.default_position == details.position.to_raw()
&& raw.quality == details.quality.to_raw() as u8;
if !checked
|| unsafe {
definition.read_string(|raw| &raw const (*raw).m_pszHolidayRestriction)
} != details.holiday
{
return Ok(Some(false));
}
}
Ok(Some(true))
}
#[doc(alias("GetItemDefinitionByName"))]
pub fn definition_by_name(
self,
name: &CStr,
) -> Result<Option<ItemDefinition<'s>>, ItemSchemaError> {
for definition in self.definitions()? {
let named = definition
.name()?
.is_some_and(|own| own.to_bytes().eq_ignore_ascii_case(name.to_bytes()));
if named {
return Ok(Some(definition));
}
}
Ok(None)
}
#[doc(alias("m_mapItemsSorted", "GetSortedItemDefinitionMap"))]
pub fn definitions(self) -> Result<Vec<ItemDefinition<'s>>, ItemSchemaError> {
let (schema, default) = unsafe {
(
self.generation.schema()?,
self.generation.default_definition()?,
)
};
let sorted = unsafe { item_schema::sorted_definitions(schema, default) }
.ok_or(ItemSchemaError::UnsupportedLayout)?;
let samples = [sorted.first(), sorted.get(sorted.len() / 2), sorted.last()];
for &(index, raw) in samples.into_iter().flatten() {
if self.find(index)?.map(|definition| definition.raw) != Some(raw) {
return Err(ItemSchemaError::UnsupportedLayout);
}
}
Ok(sorted
.into_iter()
.map(|(_, raw)| ItemDefinition::new(raw))
.collect())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, thiserror::Error)]
pub enum ItemSchemaError {
#[error(transparent)]
Generation(#[from] ItemGenerationError),
#[error("the item schema is TF2's")]
NotTf2,
#[error("the item schema's definitions are not laid out as the bindings expect")]
UnsupportedLayout,
}
bitflags::bitflags! {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct VisionFilter: c_int {
#[doc(alias("TF_VISION_FILTER_PYRO"))]
const PYRO = 1 << 0;
#[doc(alias("TF_VISION_FILTER_HALLOWEEN"))]
const HALLOWEEN = 1 << 1;
#[doc(alias("TF_VISION_FILTER_ROME"))]
const ROME = 1 << 2;
}
}
fn strings_checked() -> Result<(), ItemSchemaError> {
match STRINGS.get() {
Some(true) => Ok(()),
_ => Err(ItemSchemaError::UnsupportedLayout),
}
}