#[cfg(test)]
#[path = "tests/inputs.rs"]
mod tests;
use crate::entities::{Entity, EntityHandle};
use crate::math::{Color32, Vector};
use sdk_raw::entities::datamap::FTYPEDESC_INPUT;
use sdk_raw::inputs::Variant;
use std::ffi::{CStr, c_int};
use std::fmt::{self, Display, Formatter};
const CODE_OR_SPAWN_INPUTS: [&[u8]; 5] = [
b"RunScriptCode",
b"RunScriptFile",
b"CallScriptFunction",
b"ForceSpawn",
b"ForceSpawnAtEntityOrigin",
];
const KILL_INPUTS: [&[u8]; 2] = [b"Kill", b"KillHierarchy"];
const NPC_MAKER_SPAWN_INPUTS: [&[u8]; 4] = [
b"Spawn",
b"SpawnNPCInRadius",
b"SpawnNPCInLine",
b"SpawnMultiple",
];
const PICKER_NAME: &[u8] = b"!picker";
const UNCHECKED_LOOKUP_INPUTS: [&[u8]; 1] = [b"SpeakResponseConcept"];
#[derive(Debug, Clone, Copy)]
pub(crate) struct CheckedInput<'s> {
name: &'s CStr,
declared: InputType,
frees_entities: bool,
kills: bool,
looks_up_unchecked: bool,
target_is_protected: bool,
}
impl<'s> CheckedInput<'s> {
pub(crate) const fn name(self) -> &'s CStr {
self.name
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum InputError {
#[error("the input name is empty or not ASCII")]
InvalidName,
#[error("the entity is marked for deletion")]
MarkedForDeletion,
#[error("the value contains a non-finite component")]
NonFinite,
#[error("the entity has no such input")]
UnknownInput,
#[error("the input takes {expected}, which the value cannot be converted to")]
TypeMismatch {
expected: InputType,
},
#[error("the input can free entities immediately, so it is only sent unchecked")]
FreesEntities,
#[error("the input would remove the world, a player, or a soundscape")]
ProtectedEntity,
#[error("the value \"!picker\" can make the game dereference a missing player")]
PickerName,
#[error("the input uses a lookup of its value unchecked, so it is only sent unchecked")]
UncheckedLookup,
#[error("the string could not be added to the game's string pool")]
NotPooled,
#[error("the entity rejected the input")]
Rejected,
}
#[doc(alias("fieldtype_t"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InputType {
#[doc(alias("FIELD_VOID"))]
Void,
#[doc(alias("FIELD_BOOLEAN"))]
Bool,
#[doc(alias("FIELD_INTEGER"))]
Int,
#[doc(alias("FIELD_FLOAT"))]
Float,
#[doc(alias("FIELD_STRING"))]
String,
#[doc(alias("FIELD_VECTOR"))]
Vector,
#[doc(alias("FIELD_COLOR32"))]
Color,
#[doc(alias("FIELD_EHANDLE"))]
Entity,
#[doc(alias("FIELD_INPUT"))]
Any,
Other(i64),
}
impl InputType {
fn from_raw(raw: sys::fieldtype_t) -> Self {
match raw {
sys::_fieldtypes_FIELD_VOID => Self::Void,
sys::_fieldtypes_FIELD_BOOLEAN => Self::Bool,
sys::_fieldtypes_FIELD_INTEGER => Self::Int,
sys::_fieldtypes_FIELD_FLOAT => Self::Float,
sys::_fieldtypes_FIELD_STRING => Self::String,
sys::_fieldtypes_FIELD_VECTOR => Self::Vector,
sys::_fieldtypes_FIELD_COLOR32 => Self::Color,
sys::_fieldtypes_FIELD_EHANDLE => Self::Entity,
sys::_fieldtypes_FIELD_INPUT => Self::Any,
other => Self::Other(i64::from(other)),
}
}
fn accepts(self, value: InputValue<'_>) -> bool {
use InputValue as V;
matches!(
(self, value),
(Self::Void | Self::Any, _)
| (Self::String, V::Void | V::String(_) | V::Entity(_))
| (
Self::Bool,
V::Bool(_) | V::Int(_) | V::Float(_) | V::String(_)
)
| (
Self::Int | Self::Float,
V::Int(_) | V::Float(_) | V::String(_)
)
| (Self::Vector, V::Vector(_) | V::String(_))
| (Self::Color, V::Color(_) | V::String(_))
| (Self::Entity, V::Entity(_) | V::String(_))
)
}
const fn keeps_strings(self) -> bool {
matches!(self, Self::String | Self::Any)
}
}
impl Display for InputType {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::Void => f.write_str("no value"),
Self::Bool => f.write_str("a boolean"),
Self::Int => f.write_str("an integer"),
Self::Float => f.write_str("a float"),
Self::String => f.write_str("a string"),
Self::Vector => f.write_str("a vector"),
Self::Color => f.write_str("a color"),
Self::Entity => f.write_str("an entity"),
Self::Any => f.write_str("any value"),
Self::Other(raw) => write!(f, "field type {raw}"),
}
}
}
#[doc(alias("variant_t"))]
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum InputValue<'a> {
#[default]
Void,
Bool(bool),
Int(c_int),
Float(f32),
String(&'a CStr),
Vector(Vector),
Color(Color32),
Entity(Option<Entity<'a>>),
}
impl InputValue<'_> {
fn is_finite(self) -> bool {
match self {
Self::Float(value) => value.is_finite(),
Self::Vector(value) => value.is_finite(),
_ => true,
}
}
fn to_variant(self, string: sys::string_t) -> sys::variant_t {
match self {
Self::Void => Variant::Void,
Self::Bool(value) => Variant::Bool(value),
Self::Int(value) => Variant::Int(value),
Self::Float(value) => Variant::Float(value),
Self::String(_) => Variant::String(string),
Self::Vector(value) => Variant::Vector([value.x, value.y, value.z]),
Self::Color(value) => Variant::Color(value.into()),
Self::Entity(entity) => Variant::Entity(
entity
.map_or(EntityHandle::INVALID, Entity::handle)
.to_raw(),
),
}
.to_raw()
}
}
pub(crate) fn check_guards(
target: Entity<'_>,
input: CheckedInput<'_>,
value: InputValue<'_>,
) -> Result<(), InputError> {
if input.frees_entities {
return Err(InputError::FreesEntities);
}
if input.kills && (input.target_is_protected || target.index() == Some(0)) {
return Err(InputError::ProtectedEntity);
}
if input.looks_up_unchecked {
return Err(InputError::UncheckedLookup);
}
if let InputValue::String(string) = value
&& string.to_bytes().eq_ignore_ascii_case(PICKER_NAME)
{
return Err(InputError::PickerName);
}
Ok(())
}
pub(crate) fn check_input<'s>(
target: Entity<'s>,
name: &CStr,
value: InputValue<'_>,
) -> Result<CheckedInput<'s>, InputError> {
let name = name.to_bytes();
if name.is_empty() || !name.is_ascii() {
return Err(InputError::InvalidName);
}
if target.is_marked_for_deletion() {
return Err(InputError::MarkedForDeletion);
}
if !value.is_finite() {
return Err(InputError::NonFinite);
}
let mut found = None;
let mut is_protected = false;
let mut is_npc_maker = false;
for map in target.data_maps() {
let class = map.class_name().map_or(&[][..], CStr::to_bytes);
is_protected |= class == b"CBasePlayer" || class == b"CEnvSoundscape";
is_npc_maker |= class == b"CBaseNPCMaker";
if found.is_some() {
continue;
}
found = map.fields().iter().find_map(|field| {
let external = field.external_name()?;
(field.flags & FTYPEDESC_INPUT != 0 && external.to_bytes().eq_ignore_ascii_case(name))
.then_some((external, field.fieldType))
});
}
let (declared_name, declared) = found.ok_or(InputError::UnknownInput)?;
let declared = InputType::from_raw(declared);
if !declared.accepts(value) {
return Err(InputError::TypeMismatch { expected: declared });
}
let is_any = |names: &[&[u8]]| names.iter().any(|known| known.eq_ignore_ascii_case(name));
Ok(CheckedInput {
name: declared_name,
declared,
frees_entities: is_any(&CODE_OR_SPAWN_INPUTS)
|| (is_npc_maker && is_any(&NPC_MAKER_SPAWN_INPUTS)),
kills: is_any(&KILL_INPUTS),
looks_up_unchecked: is_any(&UNCHECKED_LOOKUP_INPUTS),
target_is_protected: is_protected,
})
}
pub(crate) fn to_variant(
input: CheckedInput<'_>,
value: InputValue<'_>,
pool: impl FnOnce(&CStr) -> Option<sys::string_t>,
) -> Result<sys::variant_t, InputError> {
let null = sys::string_t {
pszValue: std::ptr::null(),
};
let (value, string) = match value {
InputValue::String(string) if string.is_empty() => (value, null),
InputValue::String(string) if input.declared.keeps_strings() => {
(value, pool(string).ok_or(InputError::NotPooled)?)
}
InputValue::String(string) => (
value,
sys::string_t {
pszValue: string.as_ptr(),
},
),
InputValue::Entity(None) if input.declared == InputType::String => (InputValue::Void, null),
_ => (value, null),
};
Ok(value.to_variant(string))
}