use super::*;
use crate::test_support::entities::{MockEntity, set_datamap, state_maps};
use crate::test_support::leak;
use crate::test_support::server::{mock_server, null_server};
use crate::test_support::tf2::script_binding::{
SCRIPT_DESCRIPTION_SLOT, class_description, member_binding, script_description,
set_script_description,
};
use sdk_raw::test_support::{mock_vtable, unexpected_call};
use sdk_raw::tf2::script_binding::{STRING, boolean};
use std::cell::{Cell, RefCell};
use std::ffi::{CString, c_char, c_void};
use std::ptr::{NonNull, null_mut};
type Method = (
&'static CStr,
sys::ScriptDataType_t,
&'static [sys::ScriptDataType_t],
);
const ANIMATING_METHODS: &[Method] = &[
(c"FindBodygroupByName", INT, &[STRING]),
(c"GetAttachmentBone", INT, &[INT]),
(c"GetBodygroup", INT, &[INT]),
(c"GetBodygroupName", STRING, &[INT]),
(c"GetBodygroupPartName", STRING, &[INT, INT]),
(c"GetCycle", FLOAT, &[]),
(c"GetModelScale", FLOAT, &[]),
(c"GetPlaybackRate", FLOAT, &[]),
(c"GetSequence", INT, &[]),
(c"GetSequenceActivityName", STRING, &[INT]),
(c"GetSequenceDuration", FLOAT, &[INT]),
(c"GetSequenceName", STRING, &[INT]),
(c"GetSkin", INT, &[]),
(c"IsSequenceFinished", BOOL, &[]),
(c"LookupActivity", INT, &[STRING]),
(c"LookupBone", INT, &[STRING]),
(c"LookupPoseParameter", INT, &[STRING]),
(c"LookupSequence", INT, &[STRING]),
(c"ResetSequence", VOID, &[INT]),
(c"SetBodygroup", VOID, &[INT, INT]),
(c"SetCycle", VOID, &[FLOAT]),
(c"SetModelScale", VOID, &[FLOAT, FLOAT]),
(c"SetModelSimple", VOID, &[STRING]),
(c"SetPlaybackRate", VOID, &[FLOAT]),
(c"SetPoseParameter", FLOAT, &[INT, FLOAT]),
(c"SetSequence", VOID, &[INT]),
(c"SetSkin", VOID, &[INT]),
(c"StopAnimation", VOID, &[]),
(c"StudioFrameAdvance", VOID, &[]),
(c"StudioFrameAdvanceManual", VOID, &[FLOAT]),
];
const SEQUENCES: [(&CStr, &CStr, f32); 2] = [
(c"stand_MELEE", c"ACT_MP_STAND_MELEE", 2.0),
(c"run_MELEE", c"ACT_MP_RUN_MELEE", 0.5),
];
const FIRST_ACTIVITY: c_int = 100;
const BONES: [&CStr; 2] = [c"bip_pelvis", c"bip_head"];
const POSE_PARAMETERS: [&CStr; 2] = [c"move_x", c"move_y"];
const BODYGROUPS: [(&CStr, &[&CStr]); 2] = [
(c"hat", &[c"hat_none", c"hat_beanie"]),
(c"backpack", &[c"backpack"]),
];
const MODEL: &CStr = c"models/bots/headless_hatman.mdl";
thread_local! {
static INDEX: Cell<c_int> = const { Cell::new(0) };
static LOOKED_UP: RefCell<Vec<(*mut c_void, CString)>> = const { RefCell::new(Vec::new()) };
static REJECTS: Cell<bool> = const { Cell::new(false) };
static STATE: RefCell<FakeState> = RefCell::new(FakeState::default());
}
#[derive(Debug, Default)]
struct FakeState {
sequence: c_int,
resets: Vec<c_int>,
cycle: f32,
rate: f32,
finished: bool,
bodygroups: [c_int; 2],
poses: [f32; 2],
scale: f32,
scale_durations: Vec<f32>,
skin: c_int,
advances: Vec<Option<f32>>,
models: Vec<CString>,
rejects: bool,
calls: usize,
}
fn state<T>(read: impl FnOnce(&FakeState) -> T) -> T {
STATE.with_borrow(read)
}
unsafe fn index_of<'a>(names: impl IntoIterator<Item = &'a CStr>, name: *const c_char) -> c_int {
let name = unsafe { CStr::from_ptr(name) };
names
.into_iter()
.position(|candidate| candidate == name)
.map_or(-1, |index| c_int::try_from(index).unwrap())
}
unsafe extern "C" fn adapter(
function: sys::ScriptFunctionBindingStorageType_t,
_: *mut c_void,
arguments: *mut sys::ScriptVariant_t,
count: c_int,
result: *mut sys::ScriptVariant_t,
) -> bool {
let name = unsafe { CStr::from_ptr(function.val_0 as *const c_char) };
let argument = |index: usize| {
assert!(index < count as usize);
unsafe { &(*arguments.add(index)).__bindgen_anon_1 }
};
let sequence_name =
|sequence: c_int, name: fn(&(&'static CStr, &'static CStr, f32)) -> &'static CStr| {
usize::try_from(sequence)
.ok()
.and_then(|sequence| SEQUENCES.get(sequence))
.map_or(c"Unknown", name)
};
STATE.with_borrow_mut(|state| {
state.calls += 1;
if state.rejects {
return false;
}
let value = unsafe {
match name.to_bytes() {
b"FindBodygroupByName" => Some(int(index_of(
BODYGROUPS.map(|(group, _)| group),
argument(0).m_pszString,
))),
b"GetAttachmentBone" => Some(int(argument(0).m_int * 10)),
b"GetBodygroup" => Some(int(state.bodygroups[argument(0).m_int as usize])),
b"GetBodygroupName" => Some(string(
BODYGROUPS
.get(argument(0).m_int as usize)
.map_or(c"", |(group, _)| group),
)),
b"GetBodygroupPartName" => Some(string(
BODYGROUPS
.get(argument(0).m_int as usize)
.and_then(|(_, parts)| parts.get(argument(1).m_int as usize))
.copied()
.unwrap_or(c""),
)),
b"GetCycle" => Some(float(state.cycle)),
b"GetModelScale" => Some(float(state.scale)),
b"GetPlaybackRate" => Some(float(state.rate)),
b"GetSequence" => Some(int(state.sequence)),
b"GetSequenceActivityName" => Some(string(sequence_name(
argument(0).m_int,
|(_, activity, _)| activity,
))),
b"GetSequenceDuration" => Some(float(SEQUENCES[argument(0).m_int as usize].2)),
b"GetSequenceName" => {
Some(string(sequence_name(argument(0).m_int, |(name, ..)| name)))
}
b"GetSkin" => Some(int(state.skin)),
b"IsSequenceFinished" => Some(boolean(state.finished)),
b"LookupActivity" => Some(int(
match index_of(
SEQUENCES.map(|(_, activity, _)| activity),
argument(0).m_pszString,
) {
-1 => -1,
index => FIRST_ACTIVITY + index,
},
)),
b"LookupBone" => Some(int(index_of(BONES, argument(0).m_pszString))),
b"LookupPoseParameter" => {
Some(int(index_of(POSE_PARAMETERS, argument(0).m_pszString)))
}
b"LookupSequence" => Some(int(
match index_of(SEQUENCES.map(|(name, ..)| name), argument(0).m_pszString) {
-1 => index_of(
SEQUENCES.map(|(_, activity, _)| activity),
argument(0).m_pszString,
),
index => index,
},
)),
b"ResetSequence" => {
state.sequence = argument(0).m_int;
state.resets.push(state.sequence);
state.rate = 1.0;
state.finished = false;
None
}
b"SetBodygroup" => {
state.bodygroups[argument(0).m_int as usize] = argument(1).m_int;
None
}
b"SetCycle" => {
state.cycle = argument(0).m_float;
None
}
b"SetModelScale" => {
state.scale = argument(0).m_float;
state.scale_durations.push(argument(1).m_float);
None
}
b"SetModelSimple" => {
state
.models
.push(CStr::from_ptr(argument(0).m_pszString).to_owned());
None
}
b"SetPlaybackRate" => {
state.rate = argument(0).m_float;
None
}
b"SetPoseParameter" => {
let value = argument(1).m_float.clamp(-1.0, 1.0);
state.poses[argument(0).m_int as usize] = value;
Some(float(value))
}
b"SetSequence" => {
state.sequence = argument(0).m_int;
None
}
b"SetSkin" => {
state.skin = argument(0).m_int;
None
}
b"StopAnimation" => {
state.rate = 0.0;
None
}
b"StudioFrameAdvance" => {
state.advances.push(None);
None
}
b"StudioFrameAdvanceManual" => {
state.advances.push(Some(argument(0).m_float));
None
}
other => unreachable!("no fake member {other:?}"),
}
};
if let Some(value) = value {
unsafe { result.write(value) };
}
true
})
}
fn prop_description() -> *mut sys::ScriptClassDesc_t {
let bindings = ANIMATING_METHODS
.iter()
.map(|&(method, returns, parameters)| {
let mut binding =
member_binding(method, returns, Vec::from(parameters).leak(), Some(adapter));
binding.m_pFunction.val_0 = method.as_ptr() as isize;
binding
})
.collect::<Vec<_>>();
let animating = leak(class_description(
c"CBaseAnimating",
bindings.leak(),
null_mut(),
));
leak(class_description(c"CDynamicProp", &mut [], animating))
}
fn mock_animating<'s>(
server: Server<'s>,
mock: &mut MockEntity,
) -> (Animating<'s>, Vec<*const ()>) {
let (entity, vtable) = described(mock);
set_datamap(state_maps(vec![(c"CBaseAnimating", Vec::new())]));
set_script_description(prop_description());
(Animating::new(server, entity).unwrap(), vtable)
}
unsafe extern "C" fn model_index(_: *const sys::IVModelInfo, name: *const c_char) -> c_int {
if unsafe { CStr::from_ptr(name) } == MODEL {
5
} else {
-1
}
}
fn models() -> ModelInfo<'static> {
let vtable = unsafe {
mock_vtable::<sys::IVModelInfo__bindgen_vtable>(unexpected_call as *const (), |vtable| {
(&raw mut (*vtable).IVModelInfo_GetModelIndex).write(model_index);
})
};
let raw = leak(sys::IVModelInfo {
vtable_: Box::leak(vtable),
});
unsafe { ModelInfo::from_raw(NonNull::new(raw).unwrap()) }
}
fn described(mock: &mut MockEntity) -> (Entity<'static>, Vec<*const ()>) {
let pointer = mock.as_ptr();
let mut vtable = vec![std::ptr::null::<()>(); SCRIPT_DESCRIPTION_SLOT + 1];
unsafe {
let original = pointer.cast::<*const *const ()>().read();
for (slot, entry) in vtable.iter_mut().enumerate().take(SCRIPT_DESCRIPTION_SLOT) {
*entry = original.add(slot).read();
}
}
vtable[SCRIPT_DESCRIPTION_SLOT] = script_description as *const ();
unsafe { pointer.cast::<*const *const ()>().write(vtable.as_ptr()) };
(
unsafe { Entity::from_raw(NonNull::new(pointer).unwrap()) },
vtable,
)
}
unsafe extern "C" fn lookup_adapter(
_: sys::ScriptFunctionBindingStorageType_t,
object: *mut c_void,
arguments: *mut sys::ScriptVariant_t,
count: c_int,
result: *mut sys::ScriptVariant_t,
) -> bool {
assert_eq!(count, 1);
if REJECTS.get() {
return false;
}
let name = unsafe { CStr::from_ptr((*arguments).__bindgen_anon_1.m_pszString) };
LOOKED_UP.with_borrow_mut(|looked_up| looked_up.push((object, name.to_owned())));
unsafe { result.write(sdk_raw::tf2::script_binding::int(INDEX.get())) };
true
}
#[test]
fn attachments_are_looked_up_through_the_animating_member() {
let scope = ();
let server = mock_server(&scope);
let mut parameters = [STRING];
let mut bindings = [member_binding(
c"LookupAttachment",
binding::INT,
&mut parameters,
Some(lookup_adapter),
)];
let mut animating = class_description(c"CBaseAnimating", &mut bindings, null_mut());
let binding = animating.m_FunctionBindings.m_Memory.m_pMemory;
let mut prop = class_description(c"CDynamicProp", &mut [], &raw mut animating);
let mut mock = MockEntity::new(1);
let (entity, _vtable) = described(&mut mock);
set_script_description(&raw mut prop);
LOOKED_UP.take();
INDEX.set(3);
assert_eq!(lookup_attachment(server, entity, c"head"), Ok(Some(3)));
assert_eq!(
LOOKED_UP.take(),
[(entity.as_ptr().cast(), c"head".to_owned())]
);
INDEX.set(0);
assert_eq!(lookup_attachment(server, entity, c"head"), Ok(None));
INDEX.set(256);
assert_eq!(
lookup_attachment(server, entity, c"head"),
Err(AttachmentError::OutOfRange(256))
);
INDEX.set(-1);
assert_eq!(
lookup_attachment(server, entity, c"head"),
Err(AttachmentError::OutOfRange(-1))
);
REJECTS.set(true);
assert_eq!(
lookup_attachment(server, entity, c"head"),
Err(AttachmentError::Rejected)
);
REJECTS.set(false);
mock.set_eflags(1);
assert_eq!(
lookup_attachment(server, entity, c"head"),
Err(AttachmentError::MarkedForDeletion)
);
mock.set_eflags(0);
LOOKED_UP.take();
unsafe { (*binding).m_desc.m_ReturnType = binding::FLOAT };
assert_eq!(
lookup_attachment(server, entity, c"head"),
Err(AttachmentError::UnsupportedMethod)
);
assert!(LOOKED_UP.take().is_empty());
unsafe { (*binding).m_desc.m_ReturnType = binding::INT };
let mut entity_description = class_description(c"CBaseEntity", &mut [], null_mut());
set_script_description(&raw mut entity_description);
assert_eq!(
lookup_attachment(server, entity, c"head"),
Err(AttachmentError::UnsupportedMethod)
);
assert!(LOOKED_UP.take().is_empty());
set_script_description(null_mut());
}
#[test]
fn lookups_find_the_models_names_or_nothing() {
let scope = ();
let mut mock = MockEntity::new(1);
let (animating, _vtable) = mock_animating(mock_server(&scope), &mut mock);
assert_eq!(animating.lookup_sequence(c"run_MELEE"), Ok(Some(1)));
assert_eq!(
animating.lookup_sequence(c"ACT_MP_STAND_MELEE"),
Ok(Some(0))
);
assert_eq!(animating.lookup_sequence(c"taunt01"), Ok(None));
assert_eq!(
animating.lookup_activity(c"ACT_MP_RUN_MELEE"),
Ok(Some(FIRST_ACTIVITY + 1))
);
assert_eq!(animating.lookup_activity(c"run_MELEE"), Ok(None));
assert_eq!(animating.lookup_bone(c"bip_head"), Ok(Some(1)));
assert_eq!(animating.lookup_bone(c"bip_tail"), Ok(None));
assert_eq!(animating.lookup_pose_parameter(c"move_y"), Ok(Some(1)));
assert_eq!(animating.lookup_pose_parameter(c"body_yaw"), Ok(None));
assert_eq!(animating.find_bodygroup_by_name(c"backpack"), Ok(Some(1)));
assert_eq!(animating.find_bodygroup_by_name(c"medal"), Ok(None));
assert_eq!(animating.attachment_bone(2), Ok(20));
assert_eq!(
animating.sequence_name(1),
Ok(Some(c"run_MELEE".to_owned()))
);
assert_eq!(animating.sequence_name(7), Ok(Some(c"Unknown".to_owned())));
assert_eq!(
animating.sequence_activity_name(0),
Ok(Some(c"ACT_MP_STAND_MELEE".to_owned()))
);
assert_eq!(animating.sequence_duration(1), Ok(0.5));
assert_eq!(animating.bodygroup_name(0), Ok(Some(c"hat".to_owned())));
assert_eq!(animating.bodygroup_name(2), Ok(Some(c"".to_owned())));
assert_eq!(
animating.bodygroup_part_name(0, 1),
Ok(Some(c"hat_beanie".to_owned()))
);
assert_eq!(animating.entity().as_ptr(), mock.as_ptr());
}
#[test]
fn sequences_play_and_advance_through_the_members() {
let scope = ();
let mut mock = MockEntity::new(1);
let (animating, _vtable) = mock_animating(mock_server(&scope), &mut mock);
animating.set_sequence(1).unwrap();
assert_eq!(animating.sequence(), Ok(1));
assert!(state(|state| state.resets.is_empty()));
animating.set_playback_rate(2.0).unwrap();
assert_eq!(animating.playback_rate(), Ok(2.0));
animating.reset_sequence(0).unwrap();
assert_eq!(state(|state| state.resets.clone()), [0]);
assert_eq!(animating.sequence(), Ok(0));
assert_eq!(animating.playback_rate(), Ok(1.0));
animating.set_cycle(0.25).unwrap();
assert_eq!(animating.cycle(), Ok(0.25));
animating.stop_animation().unwrap();
assert_eq!(animating.playback_rate(), Ok(0.0));
assert_eq!(animating.is_sequence_finished(), Ok(false));
STATE.with_borrow_mut(|state| state.finished = true);
assert_eq!(animating.is_sequence_finished(), Ok(true));
animating.studio_frame_advance().unwrap();
animating.studio_frame_advance_manual(0.1).unwrap();
assert_eq!(state(|state| state.advances.clone()), [None, Some(0.1)]);
}
#[test]
fn bodygroups_skins_poses_and_scale_are_set_through_the_members() {
let scope = ();
let mut mock = MockEntity::new(1);
let (animating, _vtable) = mock_animating(mock_server(&scope), &mut mock);
animating.set_bodygroup(0, 1).unwrap();
assert_eq!(animating.bodygroup(0), Ok(1));
assert_eq!(animating.bodygroup(1), Ok(0));
animating.set_skin(1).unwrap();
assert_eq!(animating.skin(), Ok(1));
assert_eq!(animating.set_pose_parameter(0, 3.0), Ok(1.0));
assert_eq!(animating.set_pose_parameter(1, -0.5), Ok(-0.5));
assert_eq!(state(|state| state.poses), [1.0, -0.5]);
animating.set_model_scale(2.0, 0.5).unwrap();
assert_eq!(animating.model_scale(), Ok(2.0));
assert_eq!(state(|state| state.scale_durations.clone()), [0.5]);
}
#[test]
fn only_precached_models_are_set() {
let scope = ();
let mut mock = MockEntity::new(1);
let (animating, _vtable) = mock_animating(mock_server(&scope), &mut mock);
let models = models();
animating.set_model(models, MODEL).unwrap();
assert_eq!(state(|state| state.models.clone()), [MODEL.to_owned()]);
assert_eq!(
animating.set_model(models, c"models/bots/merasmus/merasmus.mdl"),
Err(AnimatingError::ModelNotPrecached)
);
assert_eq!(state(|state| (state.models.len(), state.calls)), (1, 1));
}
#[test]
fn only_live_animating_entities_on_tf2_servers_are_used() {
let scope = ();
let server = mock_server(&scope);
let mut mock = MockEntity::new(1);
let (animating, _vtable) = mock_animating(server, &mut mock);
let entity = animating.entity();
assert_eq!(
Animating::new(null_server(Game::SourceSdk2013, &scope), entity),
Err(AnimatingError::NotTf2)
);
set_datamap(state_maps(Vec::new()));
assert_eq!(
Animating::new(server, entity),
Err(AnimatingError::NotAnimating)
);
set_datamap(state_maps(vec![(c"CBaseAnimating", Vec::new())]));
assert_eq!(Animating::new(server, entity), Ok(animating));
STATE.with_borrow_mut(|state| state.rejects = true);
assert_eq!(animating.skin(), Err(AnimatingError::Rejected));
assert_eq!(animating.sequence_name(0), Err(AnimatingError::Rejected));
STATE.with_borrow_mut(|state| state.rejects = false);
assert_eq!(state(|state| state.calls), 2);
mock.set_eflags(1);
assert_eq!(
Animating::new(server, entity),
Err(AnimatingError::MarkedForDeletion)
);
assert_eq!(animating.skin(), Err(AnimatingError::MarkedForDeletion));
assert_eq!(
animating.sequence_name(0),
Err(AnimatingError::MarkedForDeletion)
);
mock.set_eflags(0);
assert_eq!(state(|state| state.calls), 2);
let mut bare = class_description(c"CBaseAnimating", &mut [], null_mut());
set_script_description(&raw mut bare);
assert_eq!(animating.skin(), Err(AnimatingError::UnsupportedMethod));
assert_eq!(state(|state| state.calls), 2);
set_script_description(null_mut());
}