#[cfg(test)]
#[path = "../tests/tf2/custom_votes.rs"]
mod tests;
use crate::entities::Entity;
use crate::server::InterfaceError;
use crate::tf2::game_rules::GameRules;
use crate::user_messages::messages::{TextDestination, TextMsg};
use crate::user_messages::{self, Recipients};
use crate::{Game, Server, ServerBinding};
use sdk_raw::tf2::custom_votes::{
ACTIVE_ISSUE_INDEX_OFFSET, DEAD_VTABLE, ISSUE_TAG_OFFSET, IssueMethods, IssueVtable,
LIVE_VTABLE, MAX_ISSUE_NAME_LEN, MAX_VOTE_DETAILS_LENGTH, ProcessResultsFn, TaggedVtable,
VOTE_ACTION_FAIL, VOTE_ACTION_PASS, VOTE_INDEX_OFFSET, dead_vtable, is_tagged, live_vtable,
new_issue, potential_issues, vtable_rtti,
};
use sdk_raw::tf2::voting::{DEDICATED_SERVER, IssueVtables};
use sdk_raw::util::{self, ModuleCache, ModuleKey};
use std::cell::{Cell, RefCell};
use std::ffi::{CStr, CString, c_char, c_int};
use std::marker::PhantomData;
use std::mem::offset_of;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::ptr::NonNull;
use std::rc::Rc;
pub const MAX_NAME_LEN: usize = MAX_ISSUE_NAME_LEN;
pub const MAX_TEXT_LEN: usize = MAX_VOTE_DETAILS_LENGTH - 1;
const SHOW_DETAILS: &CStr = c"#TF_playerid_noteam";
static RESTART_VTABLE: ModuleCache<usize> = ModuleCache::new();
thread_local! {
static REGISTRY: Cell<Option<NonNull<RefCell<Registry>>>> = const { Cell::new(None) };
}
const _: () = assert!(MAX_NAME_LEN < ISSUE_TAG_OFFSET);
const _: () = assert!(offset_of!(sys::CBaseIssue, vtable_) == 0);
const DESTRUCTORS: sdk_raw::tf2::custom_votes::IssueDestructors =
cfg_select! {
target_os = "windows" => destroy,
target_os = "linux" => (destroy_in_place, destroy_and_free),
};
pub trait CustomVote: 'static {
fn call(&self, server: Server<'_>, call: VoteCall<'_>) -> Result<VoteText, VoteRefusal>;
fn label(&self) -> &'static CStr;
fn name(&self) -> &'static CStr;
fn offered(&self, server: Server<'_>) -> bool;
fn pass(&self, server: Server<'_>, argument: &CStr);
fn usage(&self) -> &'static CStr {
c""
}
}
#[derive(Debug, thiserror::Error)]
pub enum CustomVoteError {
#[error("custom votes are already installed")]
AlreadyInstalled,
#[error("two custom votes are named {0:?}")]
DuplicateName(CString),
#[error("the game module could not be inspected")]
Image(#[from] std::io::Error),
#[error(transparent)]
Interface(#[from] InterfaceError),
#[error("the game module has an unsupported executable image")]
InvalidImage,
#[error("invalid custom vote name {0:?}")]
InvalidName(CString),
#[error("the vote controller's layout differs from the generated one")]
LayoutMismatch,
#[error("the vote controller already has an issue named {0:?}")]
NameTaken(CString),
#[error("TF2's restart vote has no unique primary vtable")]
NoBaseIssue,
#[error("the level has no global vote controller")]
NoController,
#[error("the vote controller has room for {free} more issues, not {needed}")]
NoRoom {
needed: usize,
free: usize,
},
#[error("TF2's restart vote does not behave as expected")]
UnexpectedBase,
#[error("custom votes require Team Fortress 2")]
WrongGame,
}
impl From<util::Error> for CustomVoteError {
fn from(error: util::Error) -> Self {
match error {
util::Error::InvalidImage => Self::InvalidImage,
util::Error::Io(error) => Self::Image(error),
}
}
}
#[derive(Debug)]
#[must_use = "dropping the votes detaches them"]
pub struct CustomVotes {
_not_thread_safe: PhantomData<Rc<()>>,
}
impl CustomVotes {
pub fn install(
server: Server<'_>,
binding: ServerBinding,
votes: Vec<Box<dyn CustomVote>>,
) -> Result<Self, CustomVoteError> {
if server.game() != Game::TeamFortress2 {
return Err(CustomVoteError::WrongGame);
}
if with_registry(|_| ()).is_some() {
return Err(CustomVoteError::AlreadyInstalled);
}
check_names(&votes)?;
let factory = server.game_server_factory().as_raw();
let restart_vtable = unsafe {
let key = ModuleKey::of(factory as usize)?;
RESTART_VTABLE.get_or_resolve(key, || {
IssueVtables::load(factory)?
.find("CRestartGameIssue")
.map(|vtable| vtable.as_ptr() as usize)
.ok_or(CustomVoteError::NoBaseIssue)
})?
};
Ok(install_with(
binding,
votes,
restart_vtable as *const IssueVtable,
))
}
pub fn attach(&self, server: Server<'_>) -> Result<(), CustomVoteError> {
if server.game() != Game::TeamFortress2 {
return Err(CustomVoteError::WrongGame);
}
let restart_vtable = with_registry(|registry| registry.restart_vtable)
.ok_or(CustomVoteError::NoController)?;
let (controller, restart) = global_controller(server, restart_vtable)?;
unsafe { attach_to(controller, restart) }
}
pub fn detach(&self) {
detach_all();
}
pub fn is_attached(&self) -> bool {
with_registry(|registry| {
let Some(tables) = registry.tables else {
return false;
};
let live = TaggedVtable::issue_vtable(tables.live);
!registry.issues.is_empty()
&& registry.issues.iter().all(|issue| {
live == unsafe { (&raw const (*issue.raw.as_ptr()).vtable_).read() }
})
})
.unwrap_or(false)
}
}
impl Drop for CustomVotes {
fn drop(&mut self) {
detach_all();
if let Some(registry) = REGISTRY.take() {
drop(unsafe { Box::from_raw(registry.as_ptr()) });
}
}
}
#[derive(Debug)]
struct Issue {
raw: NonNull<sys::CBaseIssue>,
vote: usize,
owned: bool,
pending: Option<VoteText>,
current: Option<VoteText>,
lockouts: Vec<(CString, f32)>,
}
impl Issue {
const fn new(raw: NonNull<sys::CBaseIssue>, vote: usize, owned: bool) -> Self {
Self {
raw,
vote,
owned,
pending: None,
current: None,
lockouts: Vec::new(),
}
}
}
struct Registry {
binding: ServerBinding,
votes: Vec<Rc<dyn CustomVote>>,
restart_vtable: *const IssueVtable,
tables: Option<Tables>,
issues: Vec<Issue>,
}
#[derive(Debug, Clone, Copy)]
enum Step {
Keep(NonNull<sys::CBaseIssue>),
Revive(usize, NonNull<sys::CBaseIssue>),
New(usize),
}
#[derive(Debug, Clone, Copy)]
struct Tables {
live: *const TaggedVtable,
dead: *const TaggedVtable,
process_results: ProcessResultsFn,
}
#[derive(Debug, Clone, Copy)]
pub struct VoteCall<'a> {
pub caller_entity_index: c_int,
pub argument: &'a CStr,
}
impl VoteCall<'_> {
pub const fn is_server_request(self) -> bool {
self.caller_entity_index == DEDICATED_SERVER
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum VoteRefusal {
Cooldown(u16),
Disabled,
Generic,
Handled,
InvalidArgument,
AlreadyActive,
WaitingForPlayers,
}
impl VoteRefusal {
const fn to_raw(self) -> (sys::vote_create_failed_t, c_int) {
match self {
Self::AlreadyActive => (
sys::vote_create_failed_t_VOTE_FAILED_MODIFICATION_ALREADY_ACTIVE,
0,
),
Self::Cooldown(seconds) => (
sys::vote_create_failed_t_VOTE_FAILED_ON_COOLDOWN,
seconds as c_int,
),
Self::Disabled => (sys::vote_create_failed_t_VOTE_FAILED_ISSUE_DISABLED, 0),
Self::Generic => (sys::vote_create_failed_t_VOTE_FAILED_GENERIC, 0),
Self::Handled => (
sys::vote_create_failed_t_VOTE_FAILED_REQUEST_HANDLED_BY_ISSUE,
0,
),
Self::InvalidArgument => (sys::vote_create_failed_t_VOTE_FAILED_INVALID_ARGUMENT, 0),
Self::WaitingForPlayers => (sys::vote_create_failed_t_VOTE_FAILED_WAITINGFORPLAYERS, 0),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct VoteText {
pub argument: CString,
pub question: CString,
pub passed: CString,
}
unsafe fn attach_to(
controller: NonNull<sys::CVoteController>,
restart: NonNull<sys::CBaseIssue>,
) -> Result<(), CustomVoteError> {
let controller = controller.as_ptr();
let (list, base) = unsafe {
(
potential_issues(controller),
(&raw const (*restart.as_ptr()).vtable_).read(),
)
};
let (memory, size, capacity) = unsafe {
(
(*list).m_Memory.m_pMemory,
(*list).m_Size,
(*list).m_Memory.m_nAllocationCount,
)
};
let (Ok(size), Ok(capacity)) = (usize::try_from(size), usize::try_from(capacity)) else {
return Err(CustomVoteError::LayoutMismatch);
};
if size > capacity || (memory.is_null() && capacity > 0) {
return Err(CustomVoteError::LayoutMismatch);
}
let tables = match with_registry(|registry| registry.tables) {
None => return Err(CustomVoteError::NoController),
Some(Some(tables)) => tables,
Some(None) => {
let tables = unsafe { make_tables(restart, base) }?;
with_registry_mut(|registry| registry.tables = Some(tables));
tables
}
};
let issues: Vec<*mut sys::CBaseIssue> = if size == 0 {
Vec::new()
} else {
unsafe { std::slice::from_raw_parts(memory, size) }.to_vec()
};
let plan = with_registry_mut(|registry| {
registry
.issues
.retain(|issue| issues.contains(&issue.raw.as_ptr()));
plan(registry, &issues, tables)
})
.ok_or(CustomVoteError::NoController)??;
let needed = plan
.iter()
.filter(|step| matches!(step, Step::New(_)))
.count();
let free = capacity - size;
if needed > free {
return Err(CustomVoteError::NoRoom { needed, free });
}
let live = TaggedVtable::issue_vtable(tables.live);
let mut size = size;
with_registry_mut(|registry| {
for step in plan {
match step {
Step::Keep(raw) => {
unsafe { (&raw mut (*raw.as_ptr()).vtable_).write(live) };
}
Step::Revive(vote, raw) => {
unsafe { (&raw mut (*raw.as_ptr()).vtable_).write(live) };
registry.issues.push(Issue::new(raw, vote, false));
}
Step::New(vote) => {
let issue = new_issue(registry.votes[vote].name(), live, controller)
.expect("names were checked as installed");
let raw = NonNull::from(Box::leak(Box::new(issue)));
unsafe { memory.add(size).write(raw.as_ptr()) };
size += 1;
registry.issues.push(Issue::new(raw, vote, true));
}
}
}
});
unsafe {
(&raw mut (*list).m_Size).write(size as c_int);
(&raw mut (*list).m_pElements).write(memory);
}
Ok(())
}
fn check_names(votes: &[Box<dyn CustomVote>]) -> Result<(), CustomVoteError> {
for (index, vote) in votes.iter().enumerate() {
let name = vote.name();
if !is_valid_name(name) {
return Err(CustomVoteError::InvalidName(name.to_owned()));
}
if votes[..index]
.iter()
.any(|other| names_match(other.name(), name))
{
return Err(CustomVoteError::DuplicateName(name.to_owned()));
}
}
Ok(())
}
fn contain<R>(f: impl FnOnce() -> R) -> Option<R> {
catch_unwind(AssertUnwindSafe(f)).ok()
}
fn cut_text(text: &[u8]) -> &[u8] {
if text.len() <= MAX_TEXT_LEN {
return text;
}
let end = match str::from_utf8(text) {
Ok(text) => text.floor_char_boundary(MAX_TEXT_LEN),
Err(_) => MAX_TEXT_LEN,
};
&text[..end]
}
#[cfg(target_os = "windows")]
unsafe extern "C" fn destroy(
this: *mut sys::CBaseIssue,
flags: std::ffi::c_uint,
) -> *mut std::ffi::c_void {
unsafe { forget(this, flags & 1 != 0) };
this.cast()
}
#[cfg(target_os = "linux")]
unsafe extern "C" fn destroy_and_free(this: *mut sys::CBaseIssue) {
unsafe { forget(this, true) };
}
#[cfg(target_os = "linux")]
unsafe extern "C" fn destroy_in_place(this: *mut sys::CBaseIssue) {
unsafe { forget(this, false) };
}
fn detach_all() {
with_registry(|registry| {
let Some(tables) = registry.tables else {
return;
};
let dead = TaggedVtable::issue_vtable(tables.dead);
for issue in ®istry.issues {
unsafe { (&raw mut (*issue.raw.as_ptr()).vtable_).write(dead) };
}
});
}
unsafe extern "C" fn execute_command(this: *mut sys::CBaseIssue) {
let Some((binding, vote)) = lookup(this) else {
return;
};
let Some(Some(text)) = with_issue(this, |issue| issue.current.take()) else {
return;
};
let scope = ();
let server = unsafe { binding.server(&scope) };
contain(|| vote.pass(server, &text.argument));
}
unsafe fn forget(this: *mut sys::CBaseIssue, free: bool) {
let owned = with_registry_mut(|registry| {
let index = registry
.issues
.iter()
.position(|issue| issue.raw.as_ptr() == this)?;
Some(registry.issues.swap_remove(index).owned)
})
.flatten()
.unwrap_or(false);
if free && owned {
drop(unsafe { Box::from_raw(this) });
}
}
unsafe extern "C" fn get_display_string(_: *mut sys::CBaseIssue) -> *const c_char {
SHOW_DETAILS.as_ptr()
}
unsafe extern "C" fn get_type_string_localized(this: *mut sys::CBaseIssue) -> *const c_char {
lookup(this).map_or(c"".as_ptr(), |(_, vote)| {
contain(|| vote.label()).unwrap_or_default().as_ptr()
})
}
unsafe extern "C" fn get_vote_passed_string(this: *mut sys::CBaseIssue) -> *const c_char {
unsafe { get_display_string(this) }
}
fn global_controller(
server: Server<'_>,
restart_vtable: *const IssueVtable,
) -> Result<(NonNull<sys::CVoteController>, NonNull<sys::CBaseIssue>), CustomVoteError> {
let tools = server.server_tools()?;
let dll = server.server_game_dll()?;
for entity in tools.entities_by_class(c"vote_controller") {
let class = entity
.server_class()
.ok_or(CustomVoteError::LayoutMismatch)?;
for (name, offset) in [
(c"m_iActiveIssueIndex", ACTIVE_ISSUE_INDEX_OFFSET),
(c"m_nVoteIdx", VOTE_INDEX_OFFSET),
] {
let prop = dll
.net_prop(class, name)
.map_err(|_| CustomVoteError::LayoutMismatch)?;
if prop.offset() != offset {
return Err(CustomVoteError::LayoutMismatch);
}
}
let controller = NonNull::new(entity.as_ptr().cast::<sys::CVoteController>())
.ok_or(CustomVoteError::LayoutMismatch)?;
if let Some(restart) = unsafe { restart_issue(controller, restart_vtable) } {
return Ok((controller, restart));
}
}
Err(CustomVoteError::NoController)
}
fn install_with(
binding: ServerBinding,
votes: Vec<Box<dyn CustomVote>>,
restart_vtable: *const IssueVtable,
) -> CustomVotes {
let votes = votes.into_iter().map(Rc::from).collect();
let registry = Box::leak(Box::new(RefCell::new(Registry {
binding,
votes,
restart_vtable,
tables: None,
issues: Vec::new(),
})));
REGISTRY.set(Some(NonNull::from(registry)));
CustomVotes {
_not_thread_safe: PhantomData,
}
}
unsafe extern "C" fn is_enabled(this: *mut sys::CBaseIssue) -> bool {
let Some((binding, vote)) = lookup(this) else {
return false;
};
let scope = ();
let server = unsafe { binding.server(&scope) };
contain(|| vote.offered(server)).unwrap_or(false)
}
fn is_valid_name(name: &CStr) -> bool {
let name = name.to_bytes();
!name.is_empty()
&& name.len() <= MAX_NAME_LEN
&& name
.iter()
.all(|&byte| byte.is_ascii_alphanumeric() || byte == b'_')
}
fn is_waiting_for_players(server: Server<'_>) -> bool {
let waiting = GameRules::get(server)
.and_then(GameRules::is_waiting_for_players)
.unwrap_or(false);
let tournament = server
.cvar()
.ok()
.and_then(|cvar| cvar.find_var(c"mp_tournament"))
.is_some_and(|variable| variable.int() != 0);
waiting && !tournament
}
unsafe extern "C" fn list_issue_details(this: *mut sys::CBaseIssue, player: *mut sys::CBasePlayer) {
let Some((binding, vote)) = lookup(this) else {
return;
};
let Some(player) = NonNull::new(player.cast::<sys::CBaseEntity>()) else {
return;
};
let scope = ();
let server = unsafe { binding.server(&scope) };
if !contain(|| vote.offered(server)).unwrap_or(false) {
return;
}
let Some(edict) = unsafe { Entity::from_live(server, player) }.edict() else {
return;
};
let Some(line) = contain(|| usage_line(vote.name(), vote.usage())) else {
return;
};
let message = TextMsg {
destination: TextDestination::Console,
message: &line,
arguments: [c""; 4],
};
let _ = user_messages::send(server, &Recipients::player(edict).reliable(), &message);
}
fn locked_out(lockouts: &[(CString, f32)], argument: &CStr, now: f32) -> Option<u16> {
lockouts.iter().find_map(|(locked, until)| {
let remaining = (until - now) as c_int;
(names_match(locked, argument) && remaining > 1)
.then(|| u16::try_from(remaining).unwrap_or(u16::MAX))
})
}
fn lookup(this: *mut sys::CBaseIssue) -> Option<(ServerBinding, Rc<dyn CustomVote>)> {
with_registry(|registry| {
let issue = registry
.issues
.iter()
.find(|issue| issue.raw.as_ptr() == this)?;
Some((registry.binding, Rc::clone(®istry.votes[issue.vote])))
})
.flatten()
}
unsafe fn make_tables(
restart: NonNull<sys::CBaseIssue>,
base: *const IssueVtable,
) -> Result<Tables, CustomVoteError> {
let restart = restart.as_ptr();
let (rtti, base) = unsafe { (vtable_rtti(base), &*base) };
let (options, restricted, label) = unsafe {
(
(base.CBaseIssue_GetNumberVoteOptions)(restart),
(base.CBaseIssue_IsTeamRestrictedVote)(restart),
util::cstr::borrow_cstr((base.CBaseIssue_GetTypeStringLocalized)(restart)),
)
};
if options != 2 || restricted || label.is_none_or(|label| !label.is_empty()) {
return Err(CustomVoteError::UnexpectedBase);
}
let methods = IssueMethods {
destructors: DESTRUCTORS,
get_type_string_localized,
set_issue_details,
on_vote_failed,
is_enabled,
request_call_vote,
get_display_string,
execute_command,
list_issue_details,
get_vote_passed_string,
process_results,
};
let live = Box::leak(Box::new(TaggedVtable {
rtti,
methods: live_vtable(base, &methods),
state: LIVE_VTABLE,
}));
let dead = Box::leak(Box::new(TaggedVtable {
rtti,
methods: unsafe { dead_vtable(base) },
state: DEAD_VTABLE,
}));
Ok(Tables {
live,
dead,
process_results: base.CBaseIssue_ProcessResults,
})
}
fn names_match(a: &CStr, b: &CStr) -> bool {
a.to_bytes().eq_ignore_ascii_case(b.to_bytes())
}
fn now(server: Server<'_>) -> Option<f32> {
Some(
server
.player_info_manager()
.ok()?
.global_vars()?
.current_time(),
)
}
unsafe extern "C" fn on_vote_failed(this: *mut sys::CBaseIssue, caller: c_int) {
let Some((binding, _)) = lookup(this) else {
return;
};
let scope = ();
let server = unsafe { binding.server(&scope) };
let lockout = server
.cvar()
.ok()
.and_then(|cvar| cvar.find_var(c"sv_vote_failure_timer"))
.map(|variable| variable.float());
let now = now(server);
with_issue(this, |issue| {
let Some(text) = issue.current.take() else {
return;
};
let (Some(now), Some(lockout)) = (now, lockout) else {
return;
};
if caller == DEDICATED_SERVER {
return;
}
issue
.lockouts
.retain(|(argument, _)| !names_match(argument, &text.argument));
issue.lockouts.push((text.argument, now + lockout));
});
}
fn plan(
registry: &Registry,
issues: &[*mut sys::CBaseIssue],
tables: Tables,
) -> Result<Vec<Step>, CustomVoteError> {
let mut plan = Vec::with_capacity(registry.votes.len());
for (index, vote) in registry.votes.iter().enumerate() {
if let Some(issue) = registry.issues.iter().find(|issue| issue.vote == index) {
plan.push(Step::Keep(issue.raw));
continue;
}
let named = issues
.iter()
.copied()
.filter_map(NonNull::new)
.find(|&issue| {
unsafe { type_string(issue) }.is_some_and(|name| names_match(&name, vote.name()))
});
let Some(issue) = named else {
plan.push(Step::New(index));
continue;
};
let state = unsafe {
is_tagged(issue.as_ptr()).then(|| {
let vtable =
TaggedVtable::from_issue_vtable((&raw const (*issue.as_ptr()).vtable_).read());
(vtable, (&raw const (*vtable).state).read())
})
};
match state {
Some((vtable, _)) if vtable == tables.live => plan.push(Step::Keep(issue)),
Some((_, DEAD_VTABLE)) => plan.push(Step::Revive(index, issue)),
_ => return Err(CustomVoteError::NameTaken(vote.name().to_owned())),
}
}
Ok(plan)
}
unsafe extern "C" fn process_results(
this: *mut sys::CBaseIssue,
options: *const sys::CUtlVector<*const c_char, sys::CUtlMemory<*const c_char>>,
counts: *const c_int,
votes: *const sys::__BindgenOpaqueArray8<[u8; 40]>,
highest: c_int,
total: c_int,
potential: c_int,
) -> sys::CBaseIssue_EVoteAction {
let Some(Some(tables)) = with_registry(|registry| registry.tables) else {
return VOTE_ACTION_FAIL;
};
let action = unsafe {
(tables.process_results)(this, options, counts, votes, highest, total, potential)
};
if action == VOTE_ACTION_PASS {
let passed = with_issue(this, |issue| {
issue.current.as_ref().map(|text| text.passed.clone())
})
.flatten();
if let Some(passed) = passed {
unsafe { write_details(this, &passed) };
}
}
action
}
unsafe extern "C" fn request_call_vote(
this: *mut sys::CBaseIssue,
caller: c_int,
details: *const c_char,
failure: *mut sys::vote_create_failed_t,
time: *mut c_int,
) -> bool {
let refused = |refusal: VoteRefusal| {
let (code, seconds) = refusal.to_raw();
unsafe {
if !failure.is_null() {
failure.write(code);
}
if !time.is_null() {
time.write(seconds);
}
}
false
};
let Some((binding, vote)) = lookup(this) else {
return refused(VoteRefusal::Generic);
};
if caller == -1 {
return refused(VoteRefusal::Generic);
}
let scope = ();
let server = unsafe { binding.server(&scope) };
let argument = unsafe { util::cstr::borrow_cstr(details) }.unwrap_or_default();
let call = VoteCall {
caller_entity_index: caller,
argument,
};
if !call.is_server_request() && is_waiting_for_players(server) {
return refused(VoteRefusal::WaitingForPlayers);
}
if !contain(|| vote.offered(server)).unwrap_or(false) {
return refused(VoteRefusal::Disabled);
}
let text = match contain(|| vote.call(server, call)) {
Some(Ok(text)) => text,
Some(Err(refusal)) => return refused(refusal),
None => return refused(VoteRefusal::Generic),
};
let now = now(server);
let accepted = with_issue(this, |issue| {
if !call.is_server_request()
&& let Some(remaining) =
now.and_then(|now| locked_out(&issue.lockouts, &text.argument, now))
{
return Err(VoteRefusal::Cooldown(remaining));
}
issue.pending = Some(text);
Ok(())
});
match accepted {
Some(Ok(())) => true,
Some(Err(refusal)) => refused(refusal),
None => refused(VoteRefusal::Generic),
}
}
unsafe fn restart_issue(
controller: NonNull<sys::CVoteController>,
restart_vtable: *const IssueVtable,
) -> Option<NonNull<sys::CBaseIssue>> {
let controller = controller.as_ptr();
let (memory, size, capacity) = unsafe {
let list = potential_issues(controller);
(
(&raw const (*list).m_Memory.m_pMemory).read(),
(&raw const (*list).m_Size).read(),
(&raw const (*list).m_Memory.m_nAllocationCount).read(),
)
};
if memory.is_null() || size <= 0 || size > capacity {
return None;
}
(0..size as usize).find_map(|index| {
let issue = NonNull::new(unsafe { memory.add(index).read() })?;
let (vtable, owner) = unsafe {
(
(&raw const (*issue.as_ptr()).vtable_).read(),
(&raw const (*issue.as_ptr()).m_pVoteController).read(),
)
};
let named =
unsafe { type_string(issue) }.is_some_and(|name| name.as_c_str() == c"RestartGame");
(vtable == restart_vtable && owner == controller && named).then_some(issue)
})
}
unsafe extern "C" fn set_issue_details(this: *mut sys::CBaseIssue, details: *const c_char) {
let question = with_issue(this, |issue| {
issue.current = issue.pending.take();
issue.current.as_ref().map(|text| text.question.clone())
})
.flatten();
let text = question
.or_else(|| unsafe { util::cstr::borrow_cstr(details) }.map(CStr::to_owned))
.unwrap_or_default();
unsafe { write_details(this, &text) };
}
unsafe fn type_string(issue: NonNull<sys::CBaseIssue>) -> Option<CString> {
let bytes = unsafe { (&raw const (*issue.as_ptr()).m_szTypeString).read() };
let bytes = bytes.map(|byte| byte as u8);
CStr::from_bytes_until_nul(&bytes).ok().map(CStr::to_owned)
}
fn usage_line(name: &CStr, usage: &CStr) -> CString {
let mut line = b"callvote ".to_vec();
line.extend_from_slice(name.to_bytes());
if !usage.is_empty() {
line.push(b' ');
line.extend_from_slice(usage.to_bytes());
}
line.push(b'\n');
CString::new(line).expect("C strings hold no NUL")
}
fn with_issue<R>(this: *mut sys::CBaseIssue, f: impl FnOnce(&mut Issue) -> R) -> Option<R> {
with_registry_mut(|registry| {
registry
.issues
.iter_mut()
.find(|issue| issue.raw.as_ptr() == this)
.map(f)
})
.flatten()
}
fn with_registry<R>(f: impl FnOnce(&Registry) -> R) -> Option<R> {
let registry = unsafe { REGISTRY.get()?.as_ref() };
Some(f(&*registry.try_borrow().ok()?))
}
fn with_registry_mut<R>(f: impl FnOnce(&mut Registry) -> R) -> Option<R> {
let registry = unsafe { REGISTRY.get()?.as_ref() };
Some(f(&mut *registry.try_borrow_mut().ok()?))
}
unsafe fn write_details(issue: *mut sys::CBaseIssue, text: &CStr) {
let mut details = [0; MAX_VOTE_DETAILS_LENGTH];
for (to, &from) in details.iter_mut().zip(cut_text(text.to_bytes())) {
*to = from as c_char;
}
unsafe { (&raw mut (*issue).m_szDetailsString).write(details) };
}