#[cfg(test)]
#[path = "tests/respawn_hooks.rs"]
mod tests;
use crate::MetamodApi;
use crate::hook::{
Handler, HookAction, HookCall, HookError, HookId, HookTarget, HookTiming, VirtualFunction,
};
use source_sdk_2013::entities::Entity;
use source_sdk_2013::raw::tf2::respawn::{FORCE_RESPAWN_SLOT, ForceRespawnFn as ForceRespawn};
use source_sdk_2013::raw::util::vtable::vtable_pointer;
use source_sdk_2013::{Game, Server, ServerBinding, sys};
use std::cell::Cell;
use std::ffi::c_void;
use std::ptr::NonNull;
pub type RespawnFn = for<'s> fn(Server<'s>, Entity<'s>) -> RespawnAction;
const FORCE_RESPAWN: VirtualFunction<ForceRespawn> = VirtualFunction::new(FORCE_RESPAWN_SLOT);
static ROUTES: [RespawnRoute; 8] = [const { RespawnRoute::new() }; 8];
#[must_use]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum RespawnAction {
#[default]
Allow,
Refuse,
}
#[derive(Debug, thiserror::Error)]
pub enum RespawnHookError {
#[error("respawn hooks require a TF2 server and a CTFPlayer entity")]
NotTfPlayer,
#[error(transparent)]
Hook(#[from] HookError),
}
struct RespawnRoute {
state: Cell<Option<RoutedRespawn>>,
}
impl RespawnRoute {
const fn new() -> Self {
Self {
state: Cell::new(None),
}
}
}
impl Handler<ForceRespawn> for RespawnRoute {
fn call(&self, call: &HookCall<'_, ForceRespawn>) -> HookAction<()> {
if call.superseded() == Some(true) {
return HookAction::Ignore;
}
let Some(route) = self.state.get() else {
return HookAction::Ignore;
};
let Some(player) = NonNull::new(call.this()) else {
return HookAction::Ignore;
};
let scope = ();
let server = unsafe { route.binding.server(&scope) };
let player = unsafe { Entity::from_live(server, player) };
match (route.callback)(server, player) {
RespawnAction::Allow => HookAction::Ignore,
RespawnAction::Refuse => HookAction::Supersede(()),
}
}
}
unsafe impl Sync for RespawnRoute {}
#[derive(Clone, Copy)]
struct RoutedRespawn {
binding: ServerBinding,
callback: RespawnFn,
hook: HookId,
vtable: usize,
}
impl MetamodApi<'_> {
pub fn hook_player_respawn(
self,
player: Entity<'_>,
binding: ServerBinding,
callback: RespawnFn,
) -> Result<HookId, RespawnHookError> {
if binding.game() != Game::TeamFortress2
|| !player
.server_class()
.is_some_and(|class| class.name() == c"CTFPlayer")
{
return Err(RespawnHookError::NotTfPlayer);
}
unsafe { self.install_respawn(NonNull::new(player.as_ptr()).unwrap(), binding, callback) }
}
unsafe fn install_respawn(
self,
object: NonNull<sys::CBaseEntity>,
binding: ServerBinding,
callback: RespawnFn,
) -> Result<HookId, RespawnHookError> {
let vtable = unsafe { vtable_pointer::<c_void>(object.as_ptr()) }.addr();
if ROUTES.iter().any(|route| {
route
.state
.get()
.is_some_and(|state| state.vtable == vtable && self.has_hook(state.hook))
}) {
return Err(HookError::AlreadyInstalled.into());
}
let route = ROUTES
.iter()
.find(|route| {
route
.state
.get()
.is_none_or(|state| !self.has_hook(state.hook))
})
.ok_or(HookError::TooManyFunctions)?;
let hook = unsafe {
self.add_hook(
FORCE_RESPAWN,
HookTarget::class_of(object),
HookTiming::Pre,
route,
)
}?;
route.state.set(Some(RoutedRespawn {
binding,
callback,
hook,
vtable,
}));
Ok(hook)
}
}