#[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::players::UserId;
use crate::tf2::PlayerClass;
use crate::{Game, InterfaceError, Server};
use sdk_raw::edicts::MAX_CHANGE_OFFSETS;
use sdk_raw::tf2::scoreboard::{
ELEMENT_SIZE, KILL_STREAK, STREAKS_PER_SLOT, TF_TEAM_BLUE, TF_TEAM_RED,
};
use sdk_raw::vcall;
use std::collections::BTreeMap;
use std::ffi::{CStr, c_int};
use std::ops::RangeInclusive;
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)]
struct ArrayLayout {
first: usize,
len: usize,
range: NetRange,
}
impl ArrayLayout {
fn resolve(
dll: ServerGameDll<'_>,
class: ServerClass<'_>,
field: PlayerField,
) -> Result<Self, ScoreboardError> {
let needed = field.element(1).map_or(usize::MAX, |element| element + 1);
let unexpected = || ScoreboardError::UnexpectedLayout {
name: field.display_name(),
needed,
};
let array = dll.net_prop(class, field.name())?;
let len = array
.element_count()
.filter(|&len| len >= needed)
.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 grouped(self, slots: usize) -> Result<Self, ScoreboardError> {
if slots.checked_mul(STREAKS_PER_SLOT) == Some(self.len) {
Ok(self)
} else {
Err(ScoreboardError::UnexpectedStreaks {
len: self.len,
slots,
})
}
}
fn offset(self, field: PlayerField, slot: usize) -> Option<usize> {
let element = field.element(slot).filter(|&element| element < self.len)?;
Some(self.first + element * ELEMENT_SIZE)
}
}
#[derive(Debug)]
struct Changes {
full: bool,
offsets: Vec<usize>,
}
impl Changes {
const fn new(full: bool) -> Self {
Self {
full,
offsets: Vec::new(),
}
}
fn flush(self, engine: ValveEngine<'_>, edict: Edict<'_>) {
let offsets: Option<Vec<u16>> = self
.offsets
.iter()
.map(|&offset| u16::try_from(offset).ok())
.collect();
match offsets {
Some(offsets) if !self.full && 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()?,
})
}
fn owner(self, slot: usize) -> Option<UserId> {
let edict = self.engine.edict_of_index(c_int::try_from(slot).ok()?)?;
self.engine.user_id_of_edict(edict)
}
}
#[derive(Debug, Clone, Copy, Default)]
struct FieldState {
overridden: Option<Override>,
real: Option<i32>,
applied: Option<i32>,
restored: bool,
}
impl FieldState {
fn after_frame(
&mut self,
variable: IntField<'_>,
range: NetRange,
changes: &mut Changes,
always_flag: bool,
apply: bool,
) {
let restored = std::mem::take(&mut self.restored);
if !self.is_active() {
return;
}
let memory = variable.read();
let real = match (self.applied, self.real) {
(Some(applied), Some(real)) if !restored && memory == applied => real,
_ => memory,
};
match self.overridden.filter(|_| apply) {
Some(value) => {
let shown = value.resolve(real, range);
if memory != shown {
variable.write(shown);
}
if self.applied != Some(shown) || always_flag {
changes.mark(variable.offset);
}
self.real = Some(real);
self.applied = Some(shown);
}
None => {
if self.applied.take().is_some() {
if memory != real {
variable.write(real);
}
changes.mark(variable.offset);
}
if self.overridden.is_some() {
self.real = Some(real);
}
}
}
}
fn before_frame(
&mut self,
variable: IntField<'_>,
changes: &mut Changes,
always_flag: bool,
) -> bool {
self.restored = false;
let Some(applied) = self.applied else {
return false;
};
let memory = variable.read();
if memory == applied {
if let Some(real) = self.real
&& real != memory
{
variable.write(real);
}
if self.overridden.is_some() && !always_flag {
self.restored = true;
return true;
}
changes.mark(variable.offset);
} else {
self.real = Some(memory);
if self.overridden.is_none() || always_flag {
changes.mark(variable.offset);
}
}
self.applied = None;
false
}
const fn forget_memory(&mut self) {
self.real = None;
self.applied = None;
self.restored = false;
}
const fn is_active(&self) -> bool {
self.overridden.is_some() || self.applied.is_some()
}
const fn prune(&mut self) {
if !self.is_active() {
self.real = None;
}
}
fn restore(&mut self, variable: IntField<'_>, changes: &mut Changes) {
self.restored = false;
let Some(applied) = self.applied.take() else {
return;
};
let memory = variable.read();
if memory != applied {
self.real = Some(memory);
} else if let Some(real) = self.real
&& real != memory
{
variable.write(real);
}
changes.mark(variable.offset);
}
}
#[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, Hash)]
pub enum LevelPolicy {
KeepOverrides,
ClearOverrides,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct NetRange {
min: i32,
max: i32,
}
impl NetRange {
fn check(self, name: &'static str, value: i32) -> Result<(), ScoreboardError> {
if (self.min..=self.max).contains(&value) {
Ok(())
} else {
Err(ScoreboardError::OutOfRange {
name,
value,
min: self.min,
max: self.max,
})
}
}
fn clamp(self, value: i64) -> i32 {
saturate(value.clamp(i64::from(self.min), i64::from(self.max)))
}
const fn inclusive(self) -> RangeInclusive<i32> {
self.min..=self.max
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Override {
Fixed(i32),
Offset(i32),
}
impl Override {
fn resolve(self, real: i32, range: NetRange) -> i32 {
match self {
Self::Fixed(value) => range.clamp(i64::from(value)),
Self::Offset(offset) => range.clamp(i64::from(real) + i64::from(offset)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum Phase {
#[default]
Idle,
Restored { unflagged: bool },
Applied,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PlayerField {
Stat(PlayerStat),
Class,
Alive,
}
impl PlayerField {
const ALL: [Self; 16] = [
Self::Stat(PlayerStat::Score),
Self::Stat(PlayerStat::Kills),
Self::Stat(PlayerStat::Deaths),
Self::Stat(PlayerStat::Ping),
Self::Stat(PlayerStat::Dominations),
Self::Stat(PlayerStat::Killstreak),
Self::Stat(PlayerStat::Damage),
Self::Stat(PlayerStat::BossDamage),
Self::Stat(PlayerStat::Healing),
Self::Stat(PlayerStat::DamageAssist),
Self::Stat(PlayerStat::HealingAssist),
Self::Stat(PlayerStat::DamageBlocked),
Self::Stat(PlayerStat::BonusPoints),
Self::Stat(PlayerStat::CurrencyCollected),
Self::Class,
Self::Alive,
];
fn display_name(self) -> &'static str {
self.name().to_str().unwrap_or_default()
}
fn element(self, slot: usize) -> Option<usize> {
match self {
Self::Stat(PlayerStat::Killstreak) => {
slot.checked_mul(STREAKS_PER_SLOT)?.checked_add(KILL_STREAK)
}
_ => Some(slot),
}
}
const fn index(self) -> usize {
match self {
Self::Stat(stat) => stat as usize,
Self::Class => PlayerStat::ALL.len(),
Self::Alive => PlayerStat::ALL.len() + 1,
}
}
const fn name(self) -> &'static CStr {
match self {
Self::Stat(stat) => stat.name(),
Self::Class => c"m_iPlayerClass",
Self::Alive => c"m_bAlive",
}
}
}
#[derive(Debug, Default)]
struct PlayerSlot {
owner: Option<UserId>,
fields: [FieldState; PlayerField::ALL.len()],
}
impl PlayerSlot {
fn disown(&mut self) {
self.owner = None;
for field in &mut self.fields {
field.overridden = None;
}
}
fn forget_memory(&mut self) {
self.fields.iter_mut().for_each(FieldState::forget_memory);
}
fn prune(&mut self) -> bool {
self.fields.iter_mut().for_each(FieldState::prune);
self.fields.iter().any(FieldState::is_active)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PlayerStat {
#[doc(alias("m_iTotalScore"))]
Score,
#[doc(alias("m_iScore"))]
Kills,
#[doc(alias("m_iDeaths"))]
Deaths,
#[doc(alias("m_iPing"))]
Ping,
#[doc(alias("m_iActiveDominations"))]
Dominations,
#[doc(alias("m_iStreaks", "kTFStreak_Kills"))]
Killstreak,
#[doc(alias("m_iDamage"))]
Damage,
#[doc(alias("m_iDamageBoss"))]
BossDamage,
#[doc(alias("m_iHealing"))]
Healing,
#[doc(alias("m_iDamageAssist"))]
DamageAssist,
#[doc(alias("m_iHealingAssist"))]
HealingAssist,
#[doc(alias("m_iDamageBlocked"))]
DamageBlocked,
#[doc(alias("m_iBonusPoints"))]
BonusPoints,
#[doc(alias("m_iCurrencyCollected"))]
CurrencyCollected,
}
impl PlayerStat {
pub const ALL: [Self; 14] = [
Self::Score,
Self::Kills,
Self::Deaths,
Self::Ping,
Self::Dominations,
Self::Killstreak,
Self::Damage,
Self::BossDamage,
Self::Healing,
Self::DamageAssist,
Self::HealingAssist,
Self::DamageBlocked,
Self::BonusPoints,
Self::CurrencyCollected,
];
pub const fn name(self) -> &'static CStr {
match self {
Self::Score => c"m_iTotalScore",
Self::Kills => c"m_iScore",
Self::Deaths => c"m_iDeaths",
Self::Ping => c"m_iPing",
Self::Dominations => c"m_iActiveDominations",
Self::Killstreak => c"m_iStreaks",
Self::Damage => c"m_iDamage",
Self::BossDamage => c"m_iDamageBoss",
Self::Healing => c"m_iHealing",
Self::DamageAssist => c"m_iDamageAssist",
Self::HealingAssist => c"m_iHealingAssist",
Self::DamageBlocked => c"m_iDamageBlocked",
Self::BonusPoints => c"m_iBonusPoints",
Self::CurrencyCollected => c"m_iCurrencyCollected",
}
}
}
#[derive(Debug)]
struct ResourceCache {
handle: EntityHandle,
class: usize,
fields: [Result<ArrayLayout, ScoreboardError>; PlayerField::ALL.len()],
}
impl ResourceCache {
fn resolve(dll: ServerGameDll<'_>, entity: Entity<'_>, class: ServerClass<'_>) -> Self {
let mut fields = PlayerField::ALL.map(|field| ArrayLayout::resolve(dll, class, field));
let slots = fields[PlayerField::Stat(PlayerStat::Score).index()]
.as_ref()
.map(|layout| layout.len)
.map_err(Clone::clone);
let streaks = &mut fields[PlayerField::Stat(PlayerStat::Killstreak).index()];
if let Ok(&layout) = streaks.as_ref() {
*streaks = slots.and_then(|slots| layout.grouped(slots));
}
Self {
handle: entity.handle(),
class: class.as_ptr().addr(),
fields,
}
}
fn locate(&self, field: PlayerField, slot: usize) -> Option<(usize, NetRange)> {
let layout = self.fields[field.index()].as_ref().ok()?;
Some((layout.offset(field, slot)?, layout.range))
}
}
#[derive(Debug, Clone, Copy)]
struct Scalar {
offset: usize,
range: NetRange,
}
impl Scalar {
fn resolve(
dll: ServerGameDll<'_>,
class: ServerClass<'_>,
stat: TeamStat,
) -> Result<Self, ScoreboardError> {
let variable = dll.net_prop(class, stat.name())?;
Ok(Self {
offset: variable.offset(),
range: int_range(variable.prop(), variable.storage(), stat.display_name())?,
})
}
}
#[doc(alias("CTFPlayerResource", "tf_player_manager"))]
#[must_use = "overrides only reach clients while the store's frame callbacks run"]
#[derive(Debug, Default)]
pub struct Scoreboard {
always_flag: bool,
phase: Phase,
players: BTreeMap<usize, PlayerSlot>,
resource: Option<ResourceCache>,
team_caches: [Option<TeamCache>; ScoringTeam::ALL.len()],
teams: [TeamSlot; ScoringTeam::ALL.len()],
_not_thread_safe: NotThreadSafe,
}
impl Scoreboard {
pub fn new() -> Self {
Self::default()
}
pub fn after_frame(&mut self, server: Server<'_>) {
let always_flag = self.always_flag;
let apply = self.phase != Phase::Idle;
let phase = if apply { Phase::Applied } else { Phase::Idle };
if !self.has_active() {
self.phase = phase;
return;
}
let Ok(context) = Context::new(server) else {
return;
};
self.phase = phase;
for (&slot, state) in &mut self.players {
if let Some(owner) = state.owner
&& context.owner(slot) != Some(owner)
{
state.disown();
}
}
self.visit(
context,
false,
FieldState::is_active,
|field, variable, range, changes| {
field.after_frame(variable, range, changes, always_flag, apply);
},
);
self.prune();
}
pub fn before_frame(&mut self, server: Server<'_>) {
let heal = self.phase == Phase::Restored { unflagged: true };
let always_flag = self.always_flag;
let mut unflagged = false;
for field in self.fields_mut() {
field.restored = false;
}
if self.has_applied()
&& let Ok(context) = Context::new(server)
{
self.visit(
context,
heal,
|field| field.applied.is_some(),
|field, variable, _, changes| {
unflagged |= field.before_frame(variable, changes, always_flag);
},
);
}
self.phase = Phase::Restored { unflagged };
self.prune();
}
pub fn clear_all_overrides(&mut self) {
for slot in self.players.values_mut() {
slot.disown();
}
for team in &mut self.teams {
for field in &mut team.fields {
field.overridden = None;
}
}
self.prune();
}
pub fn clear_player_alive(&mut self, player: UserId) {
self.clear_player_field(player, PlayerField::Alive);
}
pub fn clear_player_class(&mut self, player: UserId) {
self.clear_player_field(player, PlayerField::Class);
}
fn clear_player_field(&mut self, player: UserId, field: PlayerField) {
if let Some(slot) = self.slot_of(player)
&& let Some(state) = self.players.get_mut(&slot)
{
state.fields[field.index()].overridden = None;
}
self.prune();
}
pub fn clear_player_stat(&mut self, player: UserId, stat: PlayerStat) {
self.clear_player_field(player, PlayerField::Stat(stat));
}
pub fn clear_team_stat(&mut self, team: ScoringTeam, stat: TeamStat) {
self.teams[team.index()].fields[stat.index()].overridden = None;
self.prune();
}
fn fields(&self) -> impl Iterator<Item = &FieldState> {
self.players
.values()
.flat_map(|slot| &slot.fields)
.chain(self.teams.iter().flat_map(|team| &team.fields))
}
fn fields_mut(&mut self) -> impl Iterator<Item = &mut FieldState> {
self.players
.values_mut()
.flat_map(|slot| &mut slot.fields)
.chain(self.teams.iter_mut().flat_map(|team| &mut team.fields))
}
pub fn forget_player(&mut self, player: UserId) {
if let Some(slot) = self.slot_of(player)
&& let Some(state) = self.players.get_mut(&slot)
{
state.disown();
}
self.prune();
}
fn has_active(&self) -> bool {
self.fields().any(FieldState::is_active)
}
fn has_applied(&self) -> bool {
self.fields().any(|field| field.applied.is_some())
}
pub fn level_shutdown(&mut self, policy: LevelPolicy) {
self.phase = Phase::Idle;
self.resource = None;
self.team_caches = Default::default();
self.fields_mut().for_each(FieldState::forget_memory);
if policy == LevelPolicy::ClearOverrides {
self.clear_all_overrides();
}
self.prune();
}
pub fn overridden_players(&self) -> impl Iterator<Item = UserId> + '_ {
self.players
.values()
.filter(|slot| slot.fields.iter().any(|field| field.overridden.is_some()))
.filter_map(|slot| slot.owner)
}
pub fn player_alive_override(&self, player: UserId) -> Option<bool> {
match self.player_field_override(player, PlayerField::Alive)? {
Override::Fixed(alive) => Some(alive != 0),
Override::Offset(_) => None,
}
}
pub fn player_class_override(&self, player: UserId) -> Option<PlayerClass> {
match self.player_field_override(player, PlayerField::Class)? {
Override::Fixed(class) => PlayerClass::from_raw(class),
Override::Offset(_) => None,
}
}
fn player_field_override(&self, player: UserId, field: PlayerField) -> Option<Override> {
let slot = self.players.get(&self.slot_of(player)?)?;
slot.fields[field.index()].overridden
}
pub fn player_override(&self, player: UserId, stat: PlayerStat) -> Option<Override> {
self.player_field_override(player, PlayerField::Stat(stat))
}
pub fn player_stat_range(
&mut self,
server: Server<'_>,
stat: PlayerStat,
) -> Result<RangeInclusive<i32>, ScoreboardError> {
check_game(server)?;
let context = Context::new(server)?;
let (_, _, cache) = resource_entity(&mut self.resource, &mut self.players, context)?;
let layout = cache.fields[PlayerField::Stat(stat).index()].clone()?;
Ok(layout.range.inclusive())
}
fn prune(&mut self) {
self.players.retain(|_, slot| slot.prune());
for team in &mut self.teams {
team.fields.iter_mut().for_each(FieldState::prune);
}
}
pub fn real_player_stat(&self, player: UserId, stat: PlayerStat) -> Option<i32> {
let slot = self.players.get(&self.slot_of(player)?)?;
slot.fields[PlayerField::Stat(stat).index()].real
}
pub fn real_team_stat(&self, team: ScoringTeam, stat: TeamStat) -> Option<i32> {
self.teams[team.index()].fields[stat.index()].real
}
pub fn restore_all(&mut self, server: Server<'_>) {
self.phase = Phase::Idle;
if self.has_applied()
&& let Ok(context) = Context::new(server)
{
self.visit(
context,
false,
|field| field.applied.is_some(),
|field, variable, _, changes| field.restore(variable, changes),
);
}
self.prune();
}
pub const fn set_always_flag(&mut self, always: bool) {
self.always_flag = always;
}
#[doc(alias("m_bAlive"))]
pub fn set_player_alive(
&mut self,
server: Server<'_>,
player: UserId,
alive: bool,
) -> Result<(), ScoreboardError> {
self.set_player_field(
server,
player,
PlayerField::Alive,
Override::Fixed(i32::from(alive)),
)
}
#[doc(alias("m_iPlayerClass"))]
pub fn set_player_class(
&mut self,
server: Server<'_>,
player: UserId,
class: PlayerClass,
) -> Result<(), ScoreboardError> {
self.set_player_field(
server,
player,
PlayerField::Class,
Override::Fixed(class.to_raw()),
)
}
fn set_player_field(
&mut self,
server: Server<'_>,
owner: UserId,
field: PlayerField,
value: Override,
) -> Result<(), ScoreboardError> {
check_game(server)?;
let context = Context::new(server)?;
let slot = context
.engine
.edict_of_user_id(owner)
.and_then(|edict| usize::try_from(edict.index()).ok())
.ok_or(ScoreboardError::NotConnected)?;
let (_, _, cache) = resource_entity(&mut self.resource, &mut self.players, context)?;
let layout = cache.fields[field.index()].clone()?;
if layout.offset(field, slot).is_none() {
return Err(ScoreboardError::UnexpectedLayout {
name: field.display_name(),
needed: field
.element(slot)
.map_or(usize::MAX, |element| element + 1),
});
}
if let Override::Fixed(value) = value {
layout.range.check(field.display_name(), value)?;
}
for (&other, state) in &mut self.players {
if other != slot && state.owner == Some(owner) {
state.disown();
}
}
let state = self.players.entry(slot).or_default();
if state.owner != Some(owner) {
state.disown();
state.owner = Some(owner);
}
state.fields[field.index()].overridden = Some(value);
Ok(())
}
pub fn set_player_stat(
&mut self,
server: Server<'_>,
player: UserId,
stat: PlayerStat,
value: Override,
) -> Result<(), ScoreboardError> {
self.set_player_field(server, player, PlayerField::Stat(stat), value)
}
#[doc(alias("SetScore", "SetFlagCaptures"))]
pub fn set_real_team_stat(
&mut self,
server: Server<'_>,
team: ScoringTeam,
stat: TeamStat,
value: i32,
) -> Result<(), ScoreboardError> {
check_game(server)?;
let context = Context::new(server)?;
let index = team.index();
let (entity, edict, cache) = team_entity(
&mut self.team_caches[index],
&mut self.teams[index],
context,
team,
)?;
let scalar = cache.fields[stat.index()].clone()?;
scalar.range.check(stat.display_name(), value)?;
let variable = unsafe { IntField::new(entity, scalar.offset) };
let mut changes = Changes::new(false);
variable.write(value);
changes.mark(scalar.offset);
changes.flush(context.engine, edict);
let field = &mut self.teams[index].fields[stat.index()];
field.real = Some(value);
field.applied = None;
field.restored = false;
self.prune();
Ok(())
}
pub fn set_team_stat(
&mut self,
server: Server<'_>,
team: ScoringTeam,
stat: TeamStat,
value: Override,
) -> Result<(), ScoreboardError> {
check_game(server)?;
let context = Context::new(server)?;
let index = team.index();
let (_, _, cache) = team_entity(
&mut self.team_caches[index],
&mut self.teams[index],
context,
team,
)?;
let scalar = cache.fields[stat.index()].clone()?;
if let Override::Fixed(value) = value {
scalar.range.check(stat.display_name(), value)?;
}
self.teams[index].fields[stat.index()].overridden = Some(value);
Ok(())
}
fn slot_of(&self, player: UserId) -> Option<usize> {
self.players
.iter()
.find(|(_, slot)| slot.owner == Some(player))
.map(|(&slot, _)| slot)
}
pub fn team_override(&self, team: ScoringTeam, stat: TeamStat) -> Option<Override> {
self.teams[team.index()].fields[stat.index()].overridden
}
pub fn team_stat_range(
&mut self,
server: Server<'_>,
team: ScoringTeam,
stat: TeamStat,
) -> Result<RangeInclusive<i32>, ScoreboardError> {
check_game(server)?;
let context = Context::new(server)?;
let index = team.index();
let (_, _, cache) = team_entity(
&mut self.team_caches[index],
&mut self.teams[index],
context,
team,
)?;
let scalar = cache.fields[stat.index()].clone()?;
Ok(scalar.range.inclusive())
}
fn visit(
&mut self,
context: Context<'_>,
full: bool,
wanted: fn(&FieldState) -> bool,
mut step: impl FnMut(&mut FieldState, IntField<'_>, NetRange, &mut Changes),
) {
let Self {
players,
resource,
team_caches,
teams,
..
} = self;
if players.values().any(|slot| slot.fields.iter().any(wanted))
&& let Ok((entity, edict, cache)) = resource_entity(resource, players, context)
{
let mut changes = Changes::new(full);
for (&slot, state) in players.iter_mut() {
for field in PlayerField::ALL {
let entry = &mut state.fields[field.index()];
if !wanted(entry) {
continue;
}
match cache.locate(field, slot) {
Some((offset, range)) => {
let variable = unsafe { IntField::new(entity, offset) };
step(entry, variable, range, &mut changes);
}
None => *entry = FieldState::default(),
}
}
}
changes.flush(context.engine, edict);
}
for team in ScoringTeam::ALL {
let index = team.index();
let state = &mut teams[index];
if !state.fields.iter().any(wanted) {
continue;
}
let Ok((entity, edict, cache)) =
team_entity(&mut team_caches[index], state, context, team)
else {
continue;
};
let mut changes = Changes::new(full);
for stat in TeamStat::ALL {
let entry = &mut state.fields[stat.index()];
if !wanted(entry) {
continue;
}
match cache.fields[stat.index()].as_ref() {
Ok(scalar) => {
let variable = unsafe { IntField::new(entity, scalar.offset) };
step(entry, variable, scalar.range, &mut changes);
}
Err(_) => *entry = FieldState::default(),
}
}
changes.flush(context.engine, edict);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ScoreboardError {
#[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("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("no connected client has the user ID")]
NotConnected,
#[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(
"`{name}` is not a contiguous array of 32-bit integers with at least {needed} elements"
)]
UnexpectedLayout {
name: &'static str,
needed: usize,
},
#[error(
"`m_iStreaks` has {len} elements, not {per_slot} for each of {slots} player slots",
per_slot = STREAKS_PER_SLOT
)]
UnexpectedStreaks {
len: usize,
slots: usize,
},
}
#[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
}
}
#[derive(Debug)]
struct TeamCache {
handle: EntityHandle,
class: usize,
fields: [Result<Scalar, ScoreboardError>; 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, Default)]
struct TeamSlot {
fields: [FieldState; TeamStat::ALL.len()],
}
#[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",
}
}
}
fn check_game(server: Server<'_>) -> Result<(), ScoreboardError> {
if server.game() == Game::TeamFortress2 {
Ok(())
} else {
Err(ScoreboardError::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>, ScoreboardError> {
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(ScoreboardError::NoTeam(team))
}
fn int_range(
prop: SendProp<'_>,
storage: Storage,
name: &'static str,
) -> Result<NetRange, ScoreboardError> {
if prop.kind() == PropKind::Int && storage.is_compatible(Storage::I32) {
Ok(encodable_range(prop))
} else {
Err(ScoreboardError::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<'_>), ScoreboardError> {
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<(), ScoreboardError> {
check_game(server)?;
if !player.has_data_map_class(c"CTFPlayer") {
return Err(ScoreboardError::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>,
players: &mut BTreeMap<usize, PlayerSlot>,
context: Context<'s>,
) -> Result<(Entity<'s>, Edict<'s>, &'c ResourceCache), ScoreboardError> {
let found = cache
.as_ref()
.and_then(|cached| live(context.tools, cached.handle, cached.class));
let (entity, edict) = match found {
Some(found) => found,
None => {
if cache.take().is_some() {
players.values_mut().for_each(PlayerSlot::forget_memory);
}
let entity = context
.tools
.find_by_class_name(None, RESOURCE_CLASS_NAME)
.ok_or(ScoreboardError::NoPlayerResource)?;
let (class, edict) = networking(entity)?;
*cache = Some(ResourceCache::resolve(context.dll, entity, class));
(entity, edict)
}
};
let cached = cache.as_ref().ok_or(ScoreboardError::NoPlayerResource)?;
Ok((entity, edict, cached))
}
fn saturate(value: i64) -> i32 {
i32::try_from(value).unwrap_or(if value < 0 { i32::MIN } else { i32::MAX })
}
#[doc(alias("m_iDeaths"))]
pub fn set_deaths(
server: Server<'_>,
player: Entity<'_>,
deaths: i32,
) -> Result<(), ScoreboardError> {
set_player_count(server, player, c"m_iDeaths", deaths)
}
#[doc(alias("m_iFrags"))]
pub fn set_frags(
server: Server<'_>,
player: Entity<'_>,
frags: i32,
) -> Result<(), ScoreboardError> {
set_player_count(server, player, c"m_iFrags", frags)
}
fn set_player_count(
server: Server<'_>,
player: Entity<'_>,
name: &'static CStr,
value: i32,
) -> Result<(), ScoreboardError> {
check_game(server)?;
if !player.has_data_map_class(c"CTFPlayer") {
return Err(ScoreboardError::NotTfPlayer);
}
let offset = player
.data_maps()
.find(|&map| map.class_name() == Some(c"CBasePlayer"))
.and_then(|map| map.field_offset(name, sys::_fieldtypes_FIELD_INTEGER))
.filter(|&offset| offset < MAX_FIELD_OFFSET && offset.is_multiple_of(ELEMENT_SIZE))
.ok_or_else(|| ScoreboardError::MissingField {
class: "CBasePlayer",
name: name.to_str().unwrap_or_default(),
})?;
unsafe { IntField::new(player, offset) }.write(value);
Ok(())
}
#[doc(alias("SetFlagCaptures", "m_nFlagCaptures"))]
pub fn set_team_flag_captures(
server: Server<'_>,
team: ScoringTeam,
captures: i32,
) -> Result<(), ScoreboardError> {
Scoreboard::new().set_real_team_stat(server, team, TeamStat::FlagCaptures, captures)
}
#[doc(alias("SetScore", "m_iScore"))]
pub fn set_team_score(
server: Server<'_>,
team: ScoringTeam,
score: i32,
) -> Result<(), ScoreboardError> {
Scoreboard::new().set_real_team_stat(server, team, TeamStat::Score, score)
}
fn team_entity<'c, 's>(
cache: &'c mut Option<TeamCache>,
state: &mut TeamSlot,
context: Context<'s>,
team: ScoringTeam,
) -> Result<(Entity<'s>, Edict<'s>, &'c TeamCache), ScoreboardError> {
let found = cache
.as_ref()
.and_then(|cached| live(context.tools, cached.handle, cached.class));
let (entity, edict) = match found {
Some(found) => found,
None => {
if cache.take().is_some() {
state.fields.iter_mut().for_each(FieldState::forget_memory);
}
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(ScoreboardError::NoTeam(team))?;
Ok((entity, edict, cached))
}