#[cfg(test)]
#[path = "../../tests/tf2/scoreboard.rs"]
mod tests;
use crate::NotThreadSafe;
use crate::datatables::{NetPropError, PropFlags, PropKind, SendProp, ServerClass, Storage};
use crate::edicts::Edict;
use crate::entities::{Entity, EntityHandle};
use crate::interfaces::{ServerGameDll, ServerTools, ValveEngine};
use crate::{Game, InterfaceError, Server};
use sdk_raw::edicts::MAX_CHANGE_OFFSETS;
use sdk_raw::tf2::scoreboard::{
ELEMENT_SIZE, GameStats, GameStatsError, KILL_STREAK, KillStats, MAX_PLAYERS_ARRAY_SAFE,
PlayerStats, RoundStats, TF_TEAM_BLUE, TF_TEAM_RED, stat,
};
use sdk_raw::vcall;
use std::ffi::{CStr, c_int};
use std::mem::offset_of;
use std::ops::RangeInclusive;
use std::ptr::NonNull;
const MAX_FIELD_OFFSET: usize = 1 << 16;
const RESOURCE_CLASS_NAME: &CStr = c"tf_player_manager";
const TEAM_CLASS_NAME: &CStr = c"tf_team";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Adjust {
pub round: bool,
}
impl Default for Adjust {
fn default() -> Self {
Self { round: true }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Applied {
pub round_shown: i32,
pub shown: i32,
}
#[derive(Debug, Clone, Copy)]
struct ArrayLayout {
first: usize,
len: usize,
range: NetRange,
}
impl ArrayLayout {
fn resolve(
dll: ServerGameDll<'_>,
class: ServerClass<'_>,
name: &'static CStr,
) -> Result<Self, ScoreError> {
let display_name = name.to_str().unwrap_or_default();
let unexpected = || ScoreError::UnexpectedLayout {
name: display_name,
needed: 2,
};
let array = dll.net_prop(class, name)?;
let len = array
.element_count()
.filter(|&len| len >= 2)
.ok_or_else(unexpected)?;
let first = array.element(0)?;
for index in 0..len {
let element = array.element(index)?;
let expected = index
.checked_mul(ELEMENT_SIZE)
.and_then(|bytes| first.offset().checked_add(bytes));
if element.prop().kind() != PropKind::Int
|| !element.storage().is_compatible(Storage::I32)
|| Some(element.offset()) != expected
{
return Err(unexpected());
}
}
Ok(Self {
first: first.offset(),
len,
range: encodable_range(first.prop()),
})
}
fn offset(self, name: &'static str, slot: usize) -> Result<usize, ScoreError> {
if slot < self.len {
Ok(self.first + slot * ELEMENT_SIZE)
} else {
Err(ScoreError::UnexpectedLayout {
name,
needed: slot.saturating_add(1),
})
}
}
}
#[derive(Debug, Default)]
struct Changes {
offsets: Vec<usize>,
}
impl Changes {
fn flush(self, engine: ValveEngine<'_>, edict: Edict<'_>) {
if self.offsets.is_empty() {
return;
}
let offsets: Option<Vec<u16>> = self
.offsets
.iter()
.map(|&offset| u16::try_from(offset).ok())
.collect();
match offsets {
Some(offsets) if offsets.len() <= usize::from(MAX_CHANGE_OFFSETS) => {
for offset in offsets {
edict.state_changed(engine, offset);
}
}
_ => edict.full_state_changed(engine),
}
}
fn mark(&mut self, offset: usize) {
if !self.offsets.contains(&offset) {
self.offsets.push(offset);
}
}
}
#[derive(Debug, Clone, Copy)]
struct Context<'s> {
dll: ServerGameDll<'s>,
engine: ValveEngine<'s>,
tools: ServerTools<'s>,
}
impl<'s> Context<'s> {
fn new(server: Server<'s>) -> Result<Self, InterfaceError> {
Ok(Self {
dll: server.server_game_dll()?,
engine: server.valve_engine()?,
tools: server.server_tools()?,
})
}
}
#[derive(Debug, Clone, Copy)]
struct Count<'s> {
field: IntField<'s>,
mirror: IntField<'s>,
}
impl Count<'_> {
fn set(self, value: i32) -> bool {
let agreed = self.mirror.read() == self.field.read();
self.field.write(value);
if agreed {
self.mirror.write(value);
}
agreed
}
}
#[derive(Debug, Clone, Copy)]
struct IntField<'s> {
entity: Entity<'s>,
offset: usize,
}
impl<'s> IntField<'s> {
const unsafe fn new(entity: Entity<'s>, offset: usize) -> Self {
Self { entity, offset }
}
fn read(self) -> i32 {
unsafe {
self.entity
.as_ptr()
.byte_add(self.offset)
.cast::<i32>()
.read_unaligned()
}
}
fn write(self, value: i32) {
unsafe {
self.entity
.as_ptr()
.byte_add(self.offset)
.cast::<i32>()
.write_unaligned(value);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct NetRange {
min: i32,
max: i32,
}
impl NetRange {
fn check(self, name: &'static str, value: i32) -> Result<(), ScoreError> {
if (self.min..=self.max).contains(&value) {
Ok(())
} else {
Err(ScoreError::OutOfRange {
name,
value,
min: self.min,
max: self.max,
})
}
}
const fn inclusive(self) -> RangeInclusive<i32> {
self.min..=self.max
}
}
#[derive(Debug, Clone, Copy)]
struct PlayerLayout {
class: usize,
points: usize,
round_points: usize,
}
impl PlayerLayout {
fn resolve(dll: ServerGameDll<'_>, class: ServerClass<'_>) -> Result<Self, ScoreError> {
Ok(Self {
class: class.as_ptr().addr(),
points: scoring_points(dll, class, c"m_ScoreData")?,
round_points: scoring_points(dll, class, c"m_RoundScoreData")?,
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct PlayerScore<'s> {
edict: Edict<'s>,
game_stats: GameStats,
index: usize,
layout: PlayerLayout,
player: Entity<'s>,
resource: Entity<'s>,
resource_edict: Edict<'s>,
server: Server<'s>,
stats: NonNull<PlayerStats>,
total_score: usize,
}
impl<'s> PlayerScore<'s> {
pub fn new(server: Server<'s>, player: Entity<'s>) -> Result<Self, ScoreError> {
ScoreboardLayout::new().player(server, player)
}
pub fn add_deaths(self, delta: i32) -> Result<bool, ScoreError> {
let count = self.count(c"m_iDeaths", offset_of!(sys::CPlayerState, deaths))?;
let deaths = count
.field
.read()
.checked_add(delta)
.ok_or(ScoreError::Overflow)?;
Ok(count.set(deaths))
}
pub fn add_frags(self, delta: i32) -> Result<bool, ScoreError> {
let count = self.count(c"m_iFrags", offset_of!(sys::CPlayerState, frags))?;
let frags = count
.field
.read()
.checked_add(delta)
.ok_or(ScoreError::Overflow)?;
Ok(count.set(frags))
}
pub fn add_points(self, delta: i32, adjust: Adjust) -> Result<Applied, ScoreError> {
self.add_stat(Stat::KillsRuneCarrier, delta, adjust)
}
pub fn add_stat(self, stat: Stat, delta: i32, adjust: Adjust) -> Result<Applied, ScoreError> {
let index = stat.index();
let session = self.read_block(StatScope::Session);
let round = self.read_block(StatScope::Round);
let mut new_session = session;
new_session.stat[index] = session.stat[index]
.checked_add(delta)
.ok_or(ScoreError::Overflow)?;
let new_round = if adjust.round {
let mut new_round = round;
new_round.stat[index] = round.stat[index]
.checked_add(delta)
.ok_or(ScoreError::Overflow)?;
Some(new_round)
} else {
None
};
let shown = self.score(&new_session) - self.score(&session);
let round_shown =
new_round.map_or(0, |new_round| self.score(&new_round) - self.score(&round));
let fields = self.reported_fields();
let old = fields.read();
let compensate =
|value: i32, shown: i32| value.checked_add(shown).ok_or(ScoreError::Overflow);
let new = ReportedScores {
total: compensate(old.total, shown)?,
points: compensate(old.points, shown)?,
round_points: compensate(old.round_points, round_shown)?,
};
let engine = self.server.valve_engine()?;
self.write_stat(StatScope::Session, index, new_session.stat[index]);
if let Some(new_round) = new_round {
self.write_stat(StatScope::Round, index, new_round.stat[index]);
}
self.write_reported(fields, new, engine);
Ok(Applied { round_shown, shown })
}
fn block(self, scope: StatScope) -> *mut RoundStats {
match scope {
StatScope::Session => PlayerStats::accumulated(self.stats.as_ptr()),
StatScope::Round => PlayerStats::current_round(self.stats.as_ptr()),
}
}
fn column_range(self, name: &'static CStr) -> Result<RangeInclusive<i32>, ScoreError> {
let (class, _) = networking(self.resource)?;
let layout = ArrayLayout::resolve(self.server.server_game_dll()?, class, name)?;
layout.offset(name.to_str().unwrap_or_default(), self.index)?;
Ok(layout.range.inclusive())
}
fn count(self, name: &'static CStr, mirror: usize) -> Result<Count<'s>, ScoreError> {
let missing = |name: &'static CStr| ScoreError::MissingField {
class: "CBasePlayer",
name: name.to_str().unwrap_or_default(),
};
let map = self
.player
.data_maps()
.find(|&map| map.class_name() == Some(c"CBasePlayer"))
.ok_or_else(|| missing(name))?;
let offset = map
.field_offset(name, sys::_fieldtypes_FIELD_INTEGER)
.filter(|&offset| offset < MAX_FIELD_OFFSET && offset.is_multiple_of(ELEMENT_SIZE))
.ok_or_else(|| missing(name))?;
let state = map
.fields()
.iter()
.filter(|field| field.fieldType == sys::_fieldtypes_FIELD_EMBEDDED)
.find(|field| field.name() == Some(c"pl"))
.filter(|field| {
field.embedded().next().is_some_and(|state| {
state.class_name() == Some(c"CPlayerState")
&& state.field_offset(c"deadflag", sys::_fieldtypes_FIELD_BOOLEAN)
== Some(offset_of!(sys::CPlayerState, deadflag))
&& state.field_offset(c"v_angle", sys::_fieldtypes_FIELD_VECTOR)
== Some(offset_of!(sys::CPlayerState, v_angle))
})
})
.and_then(|field| field.offset())
.filter(|&state| {
state.is_multiple_of(align_of::<sys::CPlayerState>())
&& state
.checked_add(size_of::<sys::CPlayerState>())
.is_some_and(|end| end <= MAX_FIELD_OFFSET)
})
.ok_or_else(|| missing(c"pl"))?;
Ok(unsafe {
Count {
field: IntField::new(self.player, offset),
mirror: IntField::new(self.player, state + mirror),
}
})
}
#[doc(alias("m_iDeaths", "DeathCount"))]
pub fn deaths(self) -> Result<i32, ScoreError> {
Ok(self
.count(c"m_iDeaths", offset_of!(sys::CPlayerState, deaths))?
.field
.read())
}
pub fn deaths_range(self) -> Result<RangeInclusive<i32>, ScoreError> {
self.column_range(c"m_iDeaths")
}
#[doc(alias("m_iFrags", "FragCount"))]
pub fn frags(self) -> Result<i32, ScoreError> {
Ok(self
.count(c"m_iFrags", offset_of!(sys::CPlayerState, frags))?
.field
.read())
}
pub fn frags_range(self) -> Result<RangeInclusive<i32>, ScoreError> {
self.column_range(c"m_iScore")
}
pub const fn index(self) -> usize {
self.index
}
#[doc(alias("statsKills", "KillStats_t"))]
pub fn kill_stats(self) -> KillStats {
unsafe { PlayerStats::kills(self.stats.as_ptr()).read() }
}
#[doc(alias("m_nStreaks", "kTFStreak_Kills", "GetStreak"))]
pub fn killstreak(self) -> Result<i32, ScoreError> {
Ok(self.streak()?.read())
}
pub const fn player(self) -> Entity<'s> {
self.player
}
pub fn points(self) -> i32 {
self.stat(Stat::KillsRuneCarrier, StatScope::Session)
}
fn read_block(self, scope: StatScope) -> RoundStats {
unsafe { self.block(scope).read() }
}
#[doc(alias("m_iTotalScore", "kKillEaterEvent_PointsScored"))]
pub fn rebase(self) -> Result<Rebased, ScoreError> {
let engine = self.server.valve_engine()?;
let total = self.total();
let fields = self.reported_fields();
let old = fields.read();
self.write_reported(
fields,
ReportedScores {
total,
points: total,
round_points: self.round_total(),
},
engine,
);
Ok(Rebased {
discarded: total.wrapping_sub(old.total),
points_discarded: total.wrapping_sub(old.points),
})
}
fn reported_fields(self) -> ReportedFields<'s> {
unsafe {
ReportedFields {
total: IntField::new(self.resource, self.total_score),
points: IntField::new(self.player, self.layout.points),
round_points: IntField::new(self.player, self.layout.round_points),
}
}
}
#[doc(alias("iNumKilledByUnanswered"))]
pub fn reset_unanswered_kills(self) {
let kills = PlayerStats::kills(self.stats.as_ptr());
unsafe {
(&raw mut (*kills).killed_by_unanswered).write([0; MAX_PLAYERS_ARRAY_SAFE]);
}
}
pub fn round_total(self) -> i32 {
self.score(&self.read_block(StatScope::Round))
}
fn score(self, stats: &RoundStats) -> i32 {
unsafe {
self.game_stats
.calc_player_score(stats, self.player.as_ptr().cast::<sys::CTFPlayer>())
}
}
#[doc(alias("m_iDeaths"))]
pub fn set_deaths(self, deaths: i32) -> Result<bool, ScoreError> {
Ok(self
.count(c"m_iDeaths", offset_of!(sys::CPlayerState, deaths))?
.set(deaths))
}
#[doc(alias("m_iFrags"))]
pub fn set_frags(self, frags: i32) -> Result<bool, ScoreError> {
Ok(self
.count(c"m_iFrags", offset_of!(sys::CPlayerState, frags))?
.set(frags))
}
#[doc(alias("m_nStreaks", "kTFStreak_Kills"))]
pub fn set_killstreak(self, killstreak: i32) -> Result<(), ScoreError> {
let (field, range) = self.streak_with_range()?;
let engine = self.server.valve_engine()?;
range.check("m_nStreaks", killstreak)?;
field.write(killstreak);
let mut changes = Changes::default();
changes.mark(field.offset);
changes.flush(engine, self.edict);
Ok(())
}
pub fn set_stat(self, stat: Stat, value: i32, adjust: Adjust) -> Result<Applied, ScoreError> {
let delta = value
.checked_sub(self.stat(stat, StatScope::Session))
.ok_or(ScoreError::Overflow)?;
self.add_stat(stat, delta, adjust)
}
pub fn set_stats(
self,
scope: StatScope,
stats: &[(Stat, i32)],
total: u32,
) -> Result<Applied, ScoreError> {
let total = i32::try_from(total).map_err(|_| ScoreError::Overflow)?;
let old = self.read_block(scope);
let mut new = old;
for &(stat, value) in stats {
new.stat[stat.index()] = value;
}
let unclamped = self.unclamped(new)?;
if total != 0 || unclamped > 0 {
let points = new.stat[stat::KILLS_RUNECARRIER];
new.stat[stat::KILLS_RUNECARRIER] = total
.checked_sub(unclamped)
.and_then(|delta| points.checked_add(delta))
.ok_or(ScoreError::Overflow)?;
}
if new.stat == old.stat {
return Ok(Applied::default());
}
let change = self.score(&new) - self.score(&old);
let fields = self.reported_fields();
let mut reported = fields.read();
let compensate = |value: i32| value.checked_add(change).ok_or(ScoreError::Overflow);
let applied = match scope {
StatScope::Session => {
reported.total = compensate(reported.total)?;
reported.points = compensate(reported.points)?;
Applied {
round_shown: 0,
shown: change,
}
}
StatScope::Round => {
reported.round_points = compensate(reported.round_points)?;
Applied {
round_shown: change,
shown: 0,
}
}
};
let engine = self.server.valve_engine()?;
for (index, (&old, &new)) in old.stat.iter().zip(&new.stat).enumerate() {
if new != old {
self.write_stat(scope, index, new);
}
}
self.write_reported(fields, reported, engine);
Ok(applied)
}
pub fn set_total(self, target: u32, adjust: Adjust) -> Result<Applied, ScoreError> {
let target = i32::try_from(target).map_err(|_| ScoreError::Overflow)?;
let unclamped = self.unclamped(self.read_block(StatScope::Session))?;
if target == 0 && unclamped <= 0 {
return Ok(Applied::default());
}
let delta = target.checked_sub(unclamped).ok_or(ScoreError::Overflow)?;
self.add_points(delta, adjust)
}
pub fn stat(self, stat: Stat, scope: StatScope) -> i32 {
self.read_block(scope).stat[stat.index()]
}
fn streak(self) -> Result<IntField<'s>, ScoreError> {
self.streak_with_range().map(|(field, _)| field)
}
fn streak_with_range(self) -> Result<(IntField<'s>, NetRange), ScoreError> {
let element = self
.server
.server_game_dll()?
.entity_net_prop(self.player, c"m_nStreaks")?
.element(KILL_STREAK)?;
let range = int_range(element.prop(), element.storage(), "m_nStreaks")?;
let field = unsafe { IntField::new(self.player, element.offset()) };
Ok((
field,
NetRange {
min: range.min.max(0),
max: range.max,
},
))
}
#[doc(alias("m_iTotalScore", "CalcPlayerScore"))]
pub fn total(self) -> i32 {
self.score(&self.read_block(StatScope::Session))
}
fn unclamped(self, stats: RoundStats) -> Result<i32, ScoreError> {
let score = self.score(&stats);
if score > 0 {
return Ok(score);
}
let points = stats.stat[stat::KILLS_RUNECARRIER];
let mut lift: i32 = 1;
loop {
let mut probe = stats;
probe.stat[stat::KILLS_RUNECARRIER] =
points.checked_add(lift).ok_or(ScoreError::Overflow)?;
let score = self.score(&probe);
if score > 0 {
return Ok(score - lift);
}
lift = lift.checked_mul(2).ok_or(ScoreError::Overflow)?;
}
}
fn write_reported(
self,
fields: ReportedFields<'s>,
new: ReportedScores,
engine: ValveEngine<'_>,
) {
let old = fields.read();
let mut resource_changes = Changes::default();
let mut player_changes = Changes::default();
if new.total != old.total {
fields.total.write(new.total);
resource_changes.mark(fields.total.offset);
}
if new.points != old.points {
fields.points.write(new.points);
player_changes.mark(fields.points.offset);
}
if new.round_points != old.round_points {
fields.round_points.write(new.round_points);
player_changes.mark(fields.round_points.offset);
}
resource_changes.flush(engine, self.resource_edict);
player_changes.flush(engine, self.edict);
}
fn write_stat(self, scope: StatScope, index: usize, value: i32) {
unsafe {
(&raw mut (*self.block(scope)).stat)
.cast::<c_int>()
.add(index)
.write(value);
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Rebased {
pub discarded: i32,
pub points_discarded: i32,
}
#[derive(Debug, Clone, Copy)]
struct ReportedFields<'s> {
total: IntField<'s>,
points: IntField<'s>,
round_points: IntField<'s>,
}
impl ReportedFields<'_> {
fn read(self) -> ReportedScores {
ReportedScores {
total: self.total.read(),
points: self.points.read(),
round_points: self.round_points.read(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ReportedScores {
total: i32,
points: i32,
round_points: i32,
}
#[derive(Debug)]
struct ResourceCache {
handle: EntityHandle,
class: usize,
total_score: Result<ArrayLayout, ScoreError>,
}
impl ResourceCache {
fn resolve(dll: ServerGameDll<'_>, entity: Entity<'_>, class: ServerClass<'_>) -> Self {
Self {
handle: entity.handle(),
class: class.as_ptr().addr(),
total_score: ArrayLayout::resolve(dll, class, c"m_iTotalScore"),
}
}
}
#[derive(Debug, Clone, Copy)]
struct Scalar {
offset: usize,
range: NetRange,
}
impl Scalar {
fn resolve(
dll: ServerGameDll<'_>,
class: ServerClass<'_>,
stat: TeamStat,
) -> Result<Self, ScoreError> {
let variable = dll.net_prop(class, stat.name())?;
Ok(Self {
offset: variable.offset(),
range: int_range(variable.prop(), variable.storage(), stat.display_name())?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ScoreError {
#[error(transparent)]
GameStats(#[from] GameStatsError),
#[error(transparent)]
Interface(#[from] InterfaceError),
#[error("the `{class}` datamap has no usable `{name}` field")]
MissingField {
class: &'static str,
name: &'static str,
},
#[error(transparent)]
NetProp(#[from] NetPropError),
#[error("no `tf_player_manager` entity exists; no level is running")]
NoPlayerResource,
#[error("the game keeps no statistics for the player's entity index")]
NoPlayerStats,
#[error("no `tf_team` entity has team number {}", .0.to_raw())]
NoTeam(ScoringTeam),
#[error("`{name}` is not a networked 32-bit integer")]
NotAnInteger {
name: &'static str,
},
#[error("the scoreboard requires a TF2 server")]
NotTf2,
#[error("the entity is not a TF2 player")]
NotTfPlayer,
#[error("{value} cannot be networked as `{name}`, which holds {min}..={max}")]
OutOfRange {
name: &'static str,
value: i32,
min: i32,
max: i32,
},
#[error("the change would take a value outside 32-bit integers")]
Overflow,
#[error(
"`{name}` is not a contiguous array of 32-bit integers with at least {needed} elements"
)]
UnexpectedLayout {
name: &'static str,
needed: usize,
},
}
#[derive(Debug, Default)]
pub struct ScoreboardLayout {
game_stats: Option<(usize, GameStats)>,
player: Option<PlayerLayout>,
resource: Option<ResourceCache>,
teams: [Option<TeamCache>; ScoringTeam::ALL.len()],
_not_thread_safe: NotThreadSafe,
}
impl ScoreboardLayout {
pub fn new() -> Self {
Self::default()
}
pub fn add_team_stat(
&mut self,
server: Server<'_>,
team: ScoringTeam,
stat: TeamStat,
delta: i32,
) -> Result<i32, ScoreError> {
let (variable, range, edict, engine) = self.team_variable(server, team, stat)?;
let value = variable
.read()
.checked_add(delta)
.ok_or(ScoreError::Overflow)?;
range.check(stat.display_name(), value)?;
write_team_variable(variable, value, edict, engine);
Ok(value)
}
pub fn player<'s>(
&mut self,
server: Server<'s>,
player: Entity<'s>,
) -> Result<PlayerScore<'s>, ScoreError> {
self.player_with(server, player, || {
unsafe { GameStats::cached(server.game_server_factory().as_raw()) }
})
}
fn player_layout(
&mut self,
dll: ServerGameDll<'_>,
class: ServerClass<'_>,
) -> Result<PlayerLayout, ScoreError> {
if let Some(layout) = self.player
&& layout.class == class.as_ptr().addr()
{
return Ok(layout);
}
let layout = PlayerLayout::resolve(dll, class)?;
self.player = Some(layout);
Ok(layout)
}
fn player_with<'s>(
&mut self,
server: Server<'s>,
player: Entity<'s>,
game_stats: impl FnOnce() -> Result<GameStats, GameStatsError>,
) -> Result<PlayerScore<'s>, ScoreError> {
check_game(server)?;
if !player.has_data_map_class(c"CTFPlayer") {
return Err(ScoreError::NotTfPlayer);
}
let index = player
.index()
.filter(|index| (1..MAX_PLAYERS_ARRAY_SAFE).contains(index))
.ok_or(ScoreError::NoPlayerStats)?;
let (class, edict) = networking(player)?;
let context = Context::new(server)?;
let factory = server.game_server_factory().as_raw() as usize;
let game_stats = match self.game_stats {
Some((module, game_stats)) if module == factory => game_stats,
_ => {
let game_stats = game_stats()?;
self.game_stats = Some((factory, game_stats));
game_stats
}
};
let base = NonNull::new(player.as_ptr().cast::<sys::CBasePlayer>())
.ok_or(ScoreError::NotTfPlayer)?;
let stats =
unsafe { game_stats.player_stats(base, index) }.ok_or(ScoreError::NoPlayerStats)?;
let layout = self.player_layout(context.dll, class)?;
let (resource, resource_edict, cache) = resource_entity(&mut self.resource, context)?;
let total_score = cache.total_score.clone()?.offset("m_iTotalScore", index)?;
Ok(PlayerScore {
edict,
game_stats,
index,
layout,
player,
resource,
resource_edict,
server,
stats,
total_score,
})
}
#[doc(alias("SetScore", "SetFlagCaptures"))]
pub fn set_team_stat(
&mut self,
server: Server<'_>,
team: ScoringTeam,
stat: TeamStat,
value: i32,
) -> Result<(), ScoreError> {
let (variable, range, edict, engine) = self.team_variable(server, team, stat)?;
range.check(stat.display_name(), value)?;
write_team_variable(variable, value, edict, engine);
Ok(())
}
pub fn team_stat(
&mut self,
server: Server<'_>,
team: ScoringTeam,
stat: TeamStat,
) -> Result<i32, ScoreError> {
let (variable, ..) = self.team_variable(server, team, stat)?;
Ok(variable.read())
}
pub fn team_stat_range(
&mut self,
server: Server<'_>,
team: ScoringTeam,
stat: TeamStat,
) -> Result<RangeInclusive<i32>, ScoreError> {
let (_, range, ..) = self.team_variable(server, team, stat)?;
Ok(range.inclusive())
}
fn team_variable<'s>(
&mut self,
server: Server<'s>,
team: ScoringTeam,
stat: TeamStat,
) -> Result<(IntField<'s>, NetRange, Edict<'s>, ValveEngine<'s>), ScoreError> {
check_game(server)?;
let context = Context::new(server)?;
let (entity, edict, cache) = team_entity(&mut self.teams[team.index()], context, team)?;
let scalar = cache.fields[stat.index()].clone()?;
let variable = unsafe { IntField::new(entity, scalar.offset) };
Ok((variable, scalar.range, edict, context.engine))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
pub enum ScoringTeam {
#[doc(alias("TF_TEAM_RED"))]
Red = TF_TEAM_RED,
#[doc(alias("TF_TEAM_BLUE"))]
Blue = TF_TEAM_BLUE,
}
impl ScoringTeam {
pub const ALL: [Self; 2] = [Self::Red, Self::Blue];
pub const fn from_raw(raw: i32) -> Option<Self> {
match raw {
TF_TEAM_RED => Some(Self::Red),
TF_TEAM_BLUE => Some(Self::Blue),
_ => None,
}
}
const fn index(self) -> usize {
match self {
Self::Red => 0,
Self::Blue => 1,
}
}
pub const fn to_raw(self) -> i32 {
self as i32
}
}
#[doc(alias("TFStatType_t"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(usize)]
pub enum Stat {
#[doc(alias("TFSTAT_SHOTS_HIT"))]
ShotsHit = stat::SHOTS_HIT,
#[doc(alias("TFSTAT_SHOTS_FIRED"))]
ShotsFired = stat::SHOTS_FIRED,
#[doc(alias("TFSTAT_KILLS"))]
Kills = stat::KILLS,
#[doc(alias("TFSTAT_DEATHS"))]
Deaths = stat::DEATHS,
#[doc(alias("TFSTAT_DAMAGE"))]
Damage = stat::DAMAGE,
#[doc(alias("TFSTAT_CAPTURES"))]
Captures = stat::CAPTURES,
#[doc(alias("TFSTAT_DEFENSES"))]
Defenses = stat::DEFENSES,
#[doc(alias("TFSTAT_DOMINATIONS"))]
Dominations = stat::DOMINATIONS,
#[doc(alias("TFSTAT_REVENGE"))]
Revenge = stat::REVENGE,
#[doc(alias("TFSTAT_POINTSSCORED"))]
PointsScored = stat::POINTSSCORED,
#[doc(alias("TFSTAT_BUILDINGSDESTROYED"))]
BuildingsDestroyed = stat::BUILDINGSDESTROYED,
#[doc(alias("TFSTAT_HEADSHOTS"))]
Headshots = stat::HEADSHOTS,
#[doc(alias("TFSTAT_PLAYTIME"))]
PlayTime = stat::PLAYTIME,
#[doc(alias("TFSTAT_HEALING"))]
Healing = stat::HEALING,
#[doc(alias("TFSTAT_INVULNS"))]
Invulns = stat::INVULNS,
#[doc(alias("TFSTAT_KILLASSISTS"))]
KillAssists = stat::KILLASSISTS,
#[doc(alias("TFSTAT_BACKSTABS"))]
Backstabs = stat::BACKSTABS,
#[doc(alias("TFSTAT_HEALTHLEACHED"))]
HealthLeached = stat::HEALTHLEACHED,
#[doc(alias("TFSTAT_BUILDINGSBUILT"))]
BuildingsBuilt = stat::BUILDINGSBUILT,
#[doc(alias("TFSTAT_MAXSENTRYKILLS"))]
MaxSentryKills = stat::MAXSENTRYKILLS,
#[doc(alias("TFSTAT_TELEPORTS"))]
Teleports = stat::TELEPORTS,
#[doc(alias("TFSTAT_FIREDAMAGE"))]
FireDamage = stat::FIREDAMAGE,
#[doc(alias("TFSTAT_BONUS_POINTS"))]
BonusPoints = stat::BONUS_POINTS,
#[doc(alias("TFSTAT_BLASTDAMAGE"))]
BlastDamage = stat::BLASTDAMAGE,
#[doc(alias("TFSTAT_DAMAGETAKEN"))]
DamageTaken = stat::DAMAGETAKEN,
#[doc(alias("TFSTAT_HEALTHKITS"))]
HealthKits = stat::HEALTHKITS,
#[doc(alias("TFSTAT_AMMOKITS"))]
AmmoKits = stat::AMMOKITS,
#[doc(alias("TFSTAT_CLASSCHANGES"))]
ClassChanges = stat::CLASSCHANGES,
#[doc(alias("TFSTAT_CRITS"))]
Crits = stat::CRITS,
#[doc(alias("TFSTAT_SUICIDES"))]
Suicides = stat::SUICIDES,
#[doc(alias("TFSTAT_CURRENCY_COLLECTED"))]
CurrencyCollected = stat::CURRENCY_COLLECTED,
#[doc(alias("TFSTAT_DAMAGE_ASSIST"))]
DamageAssist = stat::DAMAGE_ASSIST,
#[doc(alias("TFSTAT_HEALING_ASSIST"))]
HealingAssist = stat::HEALING_ASSIST,
#[doc(alias("TFSTAT_DAMAGE_BOSS"))]
DamageBoss = stat::DAMAGE_BOSS,
#[doc(alias("TFSTAT_DAMAGE_BLOCKED"))]
DamageBlocked = stat::DAMAGE_BLOCKED,
#[doc(alias("TFSTAT_DAMAGE_RANGED"))]
DamageRanged = stat::DAMAGE_RANGED,
#[doc(alias("TFSTAT_DAMAGE_RANGED_CRIT_RANDOM"))]
DamageRangedCritRandom = stat::DAMAGE_RANGED_CRIT_RANDOM,
#[doc(alias("TFSTAT_DAMAGE_RANGED_CRIT_BOOSTED"))]
DamageRangedCritBoosted = stat::DAMAGE_RANGED_CRIT_BOOSTED,
#[doc(alias("TFSTAT_REVIVED"))]
Revived = stat::REVIVED,
#[doc(alias("TFSTAT_THROWABLEHIT"))]
ThrowableHit = stat::THROWABLEHIT,
#[doc(alias("TFSTAT_THROWABLEKILL"))]
ThrowableKill = stat::THROWABLEKILL,
#[doc(alias("TFSTAT_KILLSTREAK_MAX"))]
KillstreakMax = stat::KILLSTREAK_MAX,
#[doc(alias("TFSTAT_KILLS_RUNECARRIER"))]
KillsRuneCarrier = stat::KILLS_RUNECARRIER,
#[doc(alias("TFSTAT_FLAGRETURNS"))]
FlagReturns = stat::FLAGRETURNS,
}
impl Stat {
pub const fn index(self) -> usize {
self as usize
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StatScope {
#[doc(alias("statsAccumulated"))]
Session,
#[doc(alias("statsCurrentRound"))]
Round,
}
#[derive(Debug)]
struct TeamCache {
handle: EntityHandle,
class: usize,
fields: [Result<Scalar, ScoreError>; TeamStat::ALL.len()],
}
impl TeamCache {
fn resolve(dll: ServerGameDll<'_>, entity: Entity<'_>, class: ServerClass<'_>) -> Self {
Self {
handle: entity.handle(),
class: class.as_ptr().addr(),
fields: TeamStat::ALL.map(|stat| Scalar::resolve(dll, class, stat)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TeamStat {
#[doc(alias("m_iScore"))]
Score,
#[doc(alias("m_nFlagCaptures"))]
FlagCaptures,
}
impl TeamStat {
pub const ALL: [Self; 2] = [Self::Score, Self::FlagCaptures];
fn display_name(self) -> &'static str {
self.name().to_str().unwrap_or_default()
}
const fn index(self) -> usize {
match self {
Self::Score => 0,
Self::FlagCaptures => 1,
}
}
pub const fn name(self) -> &'static CStr {
match self {
Self::Score => c"m_iScore",
Self::FlagCaptures => c"m_nFlagCaptures",
}
}
}
#[doc(alias("AddScore", "m_iScore"))]
pub fn add_team_score(
server: Server<'_>,
team: ScoringTeam,
delta: i32,
) -> Result<i32, ScoreError> {
ScoreboardLayout::new().add_team_stat(server, team, TeamStat::Score, delta)
}
fn check_game(server: Server<'_>) -> Result<(), ScoreError> {
if server.game() == Game::TeamFortress2 {
Ok(())
} else {
Err(ScoreError::NotTf2)
}
}
fn encodable_range(prop: SendProp<'_>) -> NetRange {
let flags = prop.flags();
let bits = match prop.bits() {
_ if flags.contains(PropFlags::NORMAL) => 32,
bits @ 1..=32 => bits,
_ => 32,
};
let (min, max) = if flags.contains(PropFlags::UNSIGNED) {
(0, (1_i64 << bits) - 1)
} else {
(-(1_i64 << (bits - 1)), (1_i64 << (bits - 1)) - 1)
};
NetRange {
min: saturate(min),
max: saturate(max),
}
}
fn find_team<'s>(context: Context<'s>, team: ScoringTeam) -> Result<Entity<'s>, ScoreError> {
let mut after = None;
for _ in 0..EntityHandle::SLOTS {
let Some(entity) = context.tools.find_by_class_name(after, TEAM_CLASS_NAME) else {
break;
};
let number = context
.dll
.entity_net_prop(entity, c"m_iTeamNum")?
.get::<i32>(entity)?;
if number == team.to_raw() {
return Ok(entity);
}
after = Some(entity);
}
Err(ScoreError::NoTeam(team))
}
fn int_range(
prop: SendProp<'_>,
storage: Storage,
name: &'static str,
) -> Result<NetRange, ScoreError> {
if prop.kind() == PropKind::Int && storage.is_compatible(Storage::I32) {
Ok(encodable_range(prop))
} else {
Err(ScoreError::NotAnInteger { name })
}
}
fn live<'s>(
tools: ServerTools<'s>,
handle: EntityHandle,
class: usize,
) -> Option<(Entity<'s>, Edict<'s>)> {
let entity = tools.entity_by_handle(handle)?;
let (found, edict) = networking(entity).ok()?;
(found.as_ptr().addr() == class).then_some((entity, edict))
}
fn networking(entity: Entity<'_>) -> Result<(ServerClass<'_>, Edict<'_>), ScoreError> {
match (entity.server_class(), entity.edict()) {
(Some(class), Some(edict)) => Ok((class, edict)),
_ => Err(NetPropError::NotNetworked {
class_name: entity.class_name().to_string_lossy().into_owned(),
}
.into()),
}
}
#[doc(alias("ResetScores"))]
pub fn reset_scores(server: Server<'_>, player: Entity<'_>) -> Result<(), ScoreError> {
check_game(server)?;
if !player.has_data_map_class(c"CTFPlayer") {
return Err(ScoreError::NotTfPlayer);
}
let player = player.as_ptr().cast::<sys::CTFPlayer>();
unsafe {
vcall!(player as sys::CTFPlayer__bindgen_vtable => CTFPlayer_ResetScores());
}
Ok(())
}
fn resource_entity<'c, 's>(
cache: &'c mut Option<ResourceCache>,
context: Context<'s>,
) -> Result<(Entity<'s>, Edict<'s>, &'c ResourceCache), ScoreError> {
let found = cache
.as_ref()
.and_then(|cached| live(context.tools, cached.handle, cached.class));
let (entity, edict) = match found {
Some(found) => found,
None => {
*cache = None;
let entity = context
.tools
.find_by_class_name(None, RESOURCE_CLASS_NAME)
.ok_or(ScoreError::NoPlayerResource)?;
let (class, edict) = networking(entity)?;
*cache = Some(ResourceCache::resolve(context.dll, entity, class));
(entity, edict)
}
};
let cached = cache.as_ref().ok_or(ScoreError::NoPlayerResource)?;
Ok((entity, edict, cached))
}
fn saturate(value: i64) -> i32 {
i32::try_from(value).unwrap_or(if value < 0 { i32::MIN } else { i32::MAX })
}
fn scoring_points(
dll: ServerGameDll<'_>,
class: ServerClass<'_>,
table: &'static CStr,
) -> Result<usize, ScoreError> {
let data = dll.net_prop(class, table)?;
if data.prop().kind() == PropKind::DataTable {
for index in 0..data.element_count().unwrap_or(0) {
let element = data.element(index)?;
if element.prop().name() == c"m_iPoints" {
int_range(element.prop(), element.storage(), "m_iPoints")?;
return Ok(element.offset());
}
}
}
Err(NetPropError::NotFound {
table: table.to_string_lossy().into_owned(),
name: "m_iPoints".to_owned(),
}
.into())
}
#[doc(alias("SetFlagCaptures", "m_nFlagCaptures"))]
pub fn set_team_flag_captures(
server: Server<'_>,
team: ScoringTeam,
captures: i32,
) -> Result<(), ScoreError> {
ScoreboardLayout::new().set_team_stat(server, team, TeamStat::FlagCaptures, captures)
}
#[doc(alias("SetScore", "m_iScore"))]
pub fn set_team_score(server: Server<'_>, team: ScoringTeam, score: i32) -> Result<(), ScoreError> {
ScoreboardLayout::new().set_team_stat(server, team, TeamStat::Score, score)
}
fn team_entity<'c, 's>(
cache: &'c mut Option<TeamCache>,
context: Context<'s>,
team: ScoringTeam,
) -> Result<(Entity<'s>, Edict<'s>, &'c TeamCache), ScoreError> {
let found = cache
.as_ref()
.and_then(|cached| live(context.tools, cached.handle, cached.class));
let (entity, edict) = match found {
Some(found) => found,
None => {
*cache = None;
let entity = find_team(context, team)?;
let (class, edict) = networking(entity)?;
*cache = Some(TeamCache::resolve(context.dll, entity, class));
(entity, edict)
}
};
let cached = cache.as_ref().ok_or(ScoreError::NoTeam(team))?;
Ok((entity, edict, cached))
}
fn write_team_variable(
variable: IntField<'_>,
value: i32,
edict: Edict<'_>,
engine: ValveEngine<'_>,
) {
let mut changes = Changes::default();
variable.write(value);
changes.mark(variable.offset);
changes.flush(engine, edict);
}