#[cfg(test)]
#[path = "tests/inputs.rs"]
mod tests;
use crate::datatables::ServerClass;
use crate::entities::{Entity, EntityHandle};
use crate::math::{Color32, Vector};
use sdk_raw::entities::datamap::FTYPEDESC_INPUT;
use sdk_raw::inputs::{MAX_CONTROL_POINTS, MAX_PREVIOUS_POINTS, NUM_ROBOT_TYPES, Variant};
use sdk_raw::players::{FIRST_GAME_TEAM, MAX_TEAMS, TEAM_UNASSIGNED, TF_TEAM_COUNT};
use sdk_raw::tier0::MAX_PATH;
use sdk_raw::util::cstr::copy_cstr;
use std::ffi::{CStr, c_int};
use std::fmt::{self, Display, Formatter};
const ADD_OUTPUT_INPUT: &[u8] = b"AddOutput";
const BUILDING_HEALTH_LIMIT: f32 = 2_147_483_648.0;
const CAPTURE_AREA_TEAM_KEYS: [&[u8]; 4] = [
b"team_numcap_",
b"team_cancap_",
b"team_spawn_",
b"team_startcap_",
];
const CODE_OR_SPAWN_INPUTS: [&[u8]; 5] = [
b"RunScriptCode",
b"RunScriptFile",
b"CallScriptFunction",
b"ForceSpawn",
b"ForceSpawnAtEntityOrigin",
];
const CONTROL_POINT_TEAM_KEYS: [&[u8]; 6] = [
b"team_capsound_",
b"team_model_",
b"team_timedpoints_",
b"team_bodygroup_",
b"team_icon_",
b"team_overlay_",
];
const KILL_INPUTS: [&[u8]; 2] = [b"Kill", b"KillHierarchy"];
const MASTER_TEAM_KEY: &[u8] = b"team_base_icon_";
const MAX_HEALTH_KEY: &[u8] = b"max_health";
const NPC_MAKER_SPAWN_INPUTS: [&[u8]; 4] = [
b"Spawn",
b"SpawnNPCInRadius",
b"SpawnNPCInLine",
b"SpawnMultiple",
];
const PICKER_NAME: &[u8] = b"!picker";
const POINT_DEFAULT_OWNER_KEY: &[u8] = b"point_default_owner";
const POINT_INDEX_KEY: &[u8] = b"point_index";
const PREVIOUS_POINT_KEY: &[u8] = b"team_previouspoint_";
const ROBOT_TYPE_KEY: &[u8] = b"type";
const SET_HEALTH_INPUT: &[u8] = b"SetHealth";
const SET_OWNER_INPUT: &[u8] = b"SetOwner";
const SET_TEAM_INPUT: &[u8] = b"SetTeam";
const SET_WINNER_AND_FORCE_CAPS_INPUT: &[u8] = b"SetWinnerAndForceCaps";
const TEAM_NUMBER_KEY: &[u8] = b"teamnumber";
const TEAM_TABLE: &CStr = c"DT_Team";
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,
adds_output: bool,
sets_max_health: bool,
sets_point_owner: bool,
sets_team: 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 input would set a maximum health the game cannot handle")]
InvalidMaxHealth,
#[error("the input would set a key value the game cannot handle")]
InvalidKeyValue,
#[error("the input would put the entity in a team the game cannot handle")]
InvalidTeam,
#[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()
}
}
#[derive(Debug, Clone, Copy, Default)]
struct TargetClasses {
building: bool,
capture_area: bool,
control_point: bool,
control_point_master: bool,
player: bool,
robot_spawn: bool,
team: bool,
}
impl TargetClasses {
fn of(entity: Entity<'_>) -> Self {
let mut target = Self {
team: entity
.server_class()
.and_then(ServerClass::table)
.is_some_and(|table| table.derives_from_named(TEAM_TABLE)),
..Self::default()
};
for map in entity.data_maps() {
let class = match map.class_name().map(CStr::to_bytes) {
Some(b"CBaseObject") => &mut target.building,
Some(b"CBasePlayer") => &mut target.player,
Some(b"CTeamControlPoint") => &mut target.control_point,
Some(b"CTeamControlPointMaster") => &mut target.control_point_master,
Some(b"CTFRobotDestruction_RobotSpawn") => &mut target.robot_spawn,
Some(b"CTriggerAreaCapture") => &mut target.capture_area,
_ => continue,
};
*class = true;
}
target
}
}
fn atoi(string: &[u8]) -> Option<c_int> {
scan_int(string).map_or(Some(0), |(value, _)| value)
}
fn check_added_key_value(target: Entity<'_>, value: InputValue<'_>) -> Result<(), InputError> {
let name;
let received = match value {
InputValue::String(string) => string.to_bytes(),
InputValue::Entity(Some(entity)) => {
name = entity
.name_field()
.and_then(|field| unsafe { copy_cstr(field.read().pszValue) })
.unwrap_or_default();
name.to_bytes()
}
_ => return Ok(()),
};
let copied = &received[..received.len().min(MAX_PATH - 1)];
let Some(space) = copied.iter().position(|&byte| byte == b' ') else {
return Ok(());
};
check_key(
TargetClasses::of(target),
&copied[..space],
&copied[space + 1..],
)
}
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 input.sets_max_health
&& !converted_int(value).is_some_and(|max| is_valid_max_health(max, true))
{
return Err(InputError::InvalidMaxHealth);
}
if input.adds_output {
check_added_key_value(target, value)?;
}
if input.sets_point_owner && !converted_int(value).is_some_and(is_valid_point_owner) {
return Err(InputError::InvalidTeam);
}
if input.sets_team {
check_team_change(target, value)?;
}
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_building = false;
let mut is_control_point = false;
let mut is_control_point_master = false;
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_building |= class == b"CBaseObject";
is_control_point |= class == b"CTeamControlPoint";
is_control_point_master |= class == b"CTeamControlPointMaster";
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),
adds_output: is_any(&[ADD_OUTPUT_INPUT]),
sets_max_health: is_building && is_any(&[SET_HEALTH_INPUT]),
sets_point_owner: declared == InputType::Int
&& ((is_control_point && is_any(&[SET_OWNER_INPUT]))
|| (is_control_point_master && is_any(&[SET_WINNER_AND_FORCE_CAPS_INPUT]))),
sets_team: declared == InputType::Int && is_any(&[SET_TEAM_INPUT]),
target_is_protected: is_protected,
})
}
fn check_key(target: TargetClasses, key: &[u8], value: &[u8]) -> Result<(), InputError> {
let below =
|number: Option<c_int>, limit: c_int| number.is_some_and(|n| (0..limit).contains(&n));
let checked = |valid: bool| valid.then_some(()).ok_or(InputError::InvalidKeyValue);
let team_suffix =
|prefixes: &[&[u8]]| prefixes.iter().find_map(|prefix| key.strip_prefix(*prefix));
if target.control_point {
if let Some(rest) = key.strip_prefix(PREVIOUS_POINT_KEY) {
return checked(is_valid_previous_point(rest));
}
if let Some(rest) = team_suffix(&CONTROL_POINT_TEAM_KEYS) {
return checked(below(atoi(rest), TF_TEAM_COUNT));
}
}
if target.capture_area
&& let Some(rest) = team_suffix(&CAPTURE_AREA_TEAM_KEYS)
{
return checked(below(atoi(rest), TF_TEAM_COUNT));
}
if target.control_point_master
&& let Some(rest) = key.strip_prefix(MASTER_TEAM_KEY)
{
return checked(below(atoi(rest), MAX_TEAMS));
}
let key = key.split(|&byte| byte == b'#').next().unwrap_or_default();
let is = |name: &[u8]| key.eq_ignore_ascii_case(name);
let value = atoi(value);
if is(MAX_HEALTH_KEY) {
return value
.is_some_and(|max| is_valid_max_health(max, target.building))
.then_some(())
.ok_or(InputError::InvalidMaxHealth);
}
if is(TEAM_NUMBER_KEY) {
return checked(!target.player && !target.team && below(value, TF_TEAM_COUNT));
}
if target.control_point && is(POINT_INDEX_KEY) {
return checked(below(value, MAX_CONTROL_POINTS));
}
if target.control_point && is(POINT_DEFAULT_OWNER_KEY) {
return checked(value.is_some_and(is_valid_point_owner));
}
if target.robot_spawn && is(ROBOT_TYPE_KEY) {
return checked(below(value, NUM_ROBOT_TYPES));
}
Ok(())
}
pub(crate) fn check_key_value(
target: Entity<'_>,
key: &CStr,
value: &CStr,
) -> Result<(), InputError> {
check_key(TargetClasses::of(target), key.to_bytes(), value.to_bytes())
}
fn check_team_change(target: Entity<'_>, value: InputValue<'_>) -> Result<(), InputError> {
let target = TargetClasses::of(target);
let valid = target.player
|| (!target.team
&& converted_int(value).is_some_and(|team| (0..TF_TEAM_COUNT).contains(&team)));
valid.then_some(()).ok_or(InputError::InvalidTeam)
}
fn converted_int(value: InputValue<'_>) -> Option<c_int> {
match value {
InputValue::Int(value) => Some(value),
InputValue::Float(value) => (-2_147_483_648.0..2_147_483_648.0)
.contains(&value)
.then_some(value as c_int),
InputValue::String(string) => atoi(string.to_bytes()),
_ => None,
}
}
fn is_valid_max_health(max: c_int, building: bool) -> bool {
max > 0 && (!building || (max as f32) < BUILDING_HEALTH_LIMIT)
}
fn is_valid_point_owner(team: c_int) -> bool {
team == TEAM_UNASSIGNED || (FIRST_GAME_TEAM..TF_TEAM_COUNT).contains(&team)
}
fn is_valid_previous_point(rest: &[u8]) -> bool {
let Some((Some(team), rest)) = scan_int(rest) else {
return false;
};
let index = match rest.strip_prefix(b"_").and_then(scan_int) {
Some((index, _)) => index,
None => Some(0),
};
(0..TF_TEAM_COUNT).contains(&team)
&& index.is_some_and(|index| (0..MAX_PREVIOUS_POINTS).contains(&index))
}
fn scan_int(string: &[u8]) -> Option<(Option<c_int>, &[u8])> {
let start = string
.iter()
.position(|&byte| !byte.is_ascii_whitespace() && byte != 0x0B)
.unwrap_or(string.len());
let (negative, digits) = match &string[start..] {
[b'-', digits @ ..] => (true, digits),
[b'+', digits @ ..] => (false, digits),
digits => (false, digits),
};
let count = digits
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
if count == 0 {
return None;
}
let value = digits[..count].iter().try_fold(0, |value: c_int, &digit| {
let digit = c_int::from(digit - b'0');
value.checked_mul(10).and_then(|value| {
if negative {
value.checked_sub(digit)
} else {
value.checked_add(digit)
}
})
});
Some((value, &digits[count..]))
}
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))
}