#[cfg(test)]
#[path = "../tests/tf2/voice.rs"]
mod tests;
use crate::entities::Entity;
use crate::tf2::PlayerClass;
use crate::tf2::script_binding::{self as binding, BindingError};
use crate::{Game, Server};
use sdk_raw::entities::BASE_ENTITY_FIELD_OFFSET_LIMIT;
use sdk_raw::players::LIFE_ALIVE;
use sdk_raw::tf2::voice::MAX_SCENE_FILENAME;
use sdk_raw::vcall;
use std::ffi::{CStr, CString};
use std::fmt::Display;
use std::ptr::null_mut;
use std::time::Duration;
const NO_POST_SPEAK_DELAY: f32 = 0.0;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ScenePath(CString);
impl ScenePath {
#[doc(alias("MAX_SCENE_FILENAME"))]
pub const MAX_LEN: usize = MAX_SCENE_FILENAME - 1;
pub fn new(path: &CStr) -> Result<Self, ScenePathError> {
Self::checked(path.to_owned())
}
fn checked(path: CString) -> Result<Self, ScenePathError> {
let bytes = path.to_bytes();
if bytes.len() > Self::MAX_LEN {
return Err(ScenePathError::TooLong { len: bytes.len() });
}
if !bytes.iter().all(u8::is_ascii_graphic) {
return Err(ScenePathError::InvalidCharacter);
}
match bytes.split_at_checked(6) {
Some((directory, [b'/' | b'\\', ..])) if directory.eq_ignore_ascii_case(b"scenes") => {}
_ => return Err(ScenePathError::NotInScenes),
}
match bytes.split_last_chunk::<4>() {
Some((_, extension)) if extension.eq_ignore_ascii_case(b".vcd") => Ok(Self(path)),
_ => Err(ScenePathError::NotVcd),
}
}
pub fn voice_line(class: PlayerClass, line: impl Display) -> Result<Self, ScenePathError> {
let line = line.to_string();
if line.is_empty()
|| !line
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
{
return Err(ScenePathError::InvalidLineName);
}
let path = format!("scenes/Player/{}/low/{line}.vcd", class.scene_directory());
Self::checked(CString::new(path).map_err(|_| ScenePathError::InvalidLineName)?)
}
pub fn as_c_str(&self) -> &CStr {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ScenePathError {
#[error("the scene path has a character other than printable ASCII without spaces")]
InvalidCharacter,
#[error("a voice line's name must be ASCII letters, digits, `_` or `-`, and not empty")]
InvalidLineName,
#[error("the scene path does not start with `scenes/` or `scenes\\`")]
NotInScenes,
#[error("the scene path does not end with `.vcd`")]
NotVcd,
#[error(
"the scene path is {len} bytes long, but the game keeps at most {max}",
max = ScenePath::MAX_LEN
)]
TooLong {
len: usize,
},
}
#[derive(Debug, Clone, Copy)]
pub struct Speaker<'s> {
player: Entity<'s>,
life_state_offset: usize,
}
impl<'s> Speaker<'s> {
pub fn new(server: Server<'s>, player: Entity<'s>) -> Result<Self, VoiceError> {
if server.game() != Game::TeamFortress2 || !player.has_data_map_class(c"CTFPlayer") {
return Err(VoiceError::NotTfPlayer);
}
let life_state_offset = life_state_offset(player).ok_or(VoiceError::UnsupportedLayout)?;
Ok(Self {
player,
life_state_offset,
})
}
fn check_ready(self) -> Result<(), VoiceError> {
if self.player.is_marked_for_deletion() {
Err(VoiceError::MarkedForDeletion)
} else if !self.is_alive() {
Err(VoiceError::NotAlive)
} else {
Ok(())
}
}
#[doc(alias("IsAlive", "m_lifeState"))]
pub fn is_alive(self) -> bool {
let state = unsafe {
self.player
.as_ptr()
.byte_add(self.life_state_offset)
.cast::<u8>()
.read()
};
state == LIFE_ALIVE
}
#[doc(alias("PlayScene", "InstancedScriptedScene"))]
pub fn play_scene(self, scene: &ScenePath) -> Result<Duration, VoiceError> {
self.check_ready()?;
let player = self.player.as_ptr().cast::<sys::CTFPlayer>();
let length = unsafe {
vcall!(player as sys::CTFPlayer__bindgen_vtable => CTFPlayer_PlayScene(
scene.as_c_str().as_ptr(),
NO_POST_SPEAK_DELAY,
null_mut(),
null_mut(),
))
};
scene_length(length)
}
#[doc(alias("ScriptPlayScene"))]
pub fn play_scene_scripted(self, scene: &ScenePath) -> Result<Duration, VoiceError> {
self.check_ready()?;
let result = unsafe {
binding::call(
self.player,
c"CBaseFlex",
c"PlayScene",
&mut [
binding::string(scene.as_c_str()),
binding::float(NO_POST_SPEAK_DELAY),
],
binding::FLOAT,
)?
};
scene_length(unsafe { result.__bindgen_anon_1.m_float })
}
pub const fn player(self) -> Entity<'s> {
self.player
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum VoiceError {
#[error("the player is marked for deletion")]
MarkedForDeletion,
#[error("the scene is not in scenes.image")]
MissingScene,
#[error("the player is not alive")]
NotAlive,
#[error("voice lines require a TF2 player")]
NotTfPlayer,
#[error("the native PlayScene method rejected its arguments")]
Rejected,
#[error("the player's datamap does not describe its life state")]
UnsupportedLayout,
#[error("the game does not expose the expected native PlayScene method")]
UnsupportedMethod,
}
impl From<BindingError> for VoiceError {
fn from(error: BindingError) -> Self {
match error {
BindingError::Unavailable | BindingError::SignatureMismatch => Self::UnsupportedMethod,
BindingError::Rejected => Self::Rejected,
}
}
}
fn life_state_offset(entity: Entity<'_>) -> Option<usize> {
let map = entity
.data_maps()
.find(|map| map.class_name() == Some(c"CBaseEntity"))?;
let field = map.fields().iter().find(|field| {
field.fieldType == sys::_fieldtypes_FIELD_CHARACTER && field.name() == Some(c"m_lifeState")
})?;
if field.fieldSize != 1 || field.fieldSizeInBytes != 1 {
return None;
}
field
.offset()
.filter(|&offset| offset < BASE_ENTITY_FIELD_OFFSET_LIMIT)
}
fn scene_length(seconds: f32) -> Result<Duration, VoiceError> {
Duration::try_from_secs_f32(seconds)
.ok()
.filter(|length| !length.is_zero())
.ok_or(VoiceError::MissingScene)
}