1
0
Fork 0
mirror of https://codeberg.org/demostf/parser.git synced 2026-06-03 18:24:05 +02:00

Merge pull request #10 from yep-tf2/player_summaries

Add an analyzer to extract player scoreboard information
This commit is contained in:
Robin Appelman 2023-01-04 17:22:39 +01:00 committed by GitHub
commit b26f775c2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 391 additions and 13 deletions

View file

@ -21,9 +21,12 @@ which will place the binary at `target/release/parse_demo`
Basic usage is as simple as `parse_demo demofile.dem` which will output a "summary" of the demo file in JSON format. Basic usage is as simple as `parse_demo demofile.dem` which will output a "summary" of the demo file in JSON format.
Passing the `detailed_summary` argument to the end of `parse_demo` will output a table with scoreboard information for all players who were ever on the server while the demo
was being recorded. The player who created the demo will be highlighted in the output.
## Advanced usage ## Advanced usage
### Loop trough every packet ### Loop through every packet
```rust ```rust
use bitbuffer::BitRead; use bitbuffer::BitRead;

View file

@ -3,6 +3,8 @@ use std::fs;
use main_error::MainError; use main_error::MainError;
pub use tf_demo_parser::{Demo, DemoParser, Parse, ParseError, ParserState, Stream}; pub use tf_demo_parser::{Demo, DemoParser, Parse, ParseError, ParserState, Stream};
use tf_demo_parser::demo::parser::player_summary_analyzer::PlayerSummaryAnalyzer;
#[cfg(feature = "jemallocator")] #[cfg(feature = "jemallocator")]
#[global_allocator] #[global_allocator]
@ -21,12 +23,13 @@ fn main() -> Result<(), MainError> {
return Ok(()); return Ok(());
} }
let path = args[1].clone(); let path = args[1].clone();
let all = args let all = args.contains(&std::string::String::from("all"));
.get(2) let detailed_summaries = args.contains(&std::string::String::from("detailed_summaries"));
.map(|arg| arg.as_str() == "all")
.unwrap_or_default();
let file = fs::read(path)?; let file = fs::read(path)?;
let demo = Demo::new(&file); let demo = Demo::new(&file);
if !detailed_summaries {
// Use the default (simple) analyzer to track kills, assists, and deaths
let parser = if all { let parser = if all {
DemoParser::new_all(demo.get_stream()) DemoParser::new_all(demo.get_stream())
} else { } else {
@ -34,5 +37,63 @@ fn main() -> Result<(), MainError> {
}; };
let (_, state) = parser.parse()?; let (_, state) = parser.parse()?;
println!("{}", serde_json::to_string(&state)?); println!("{}", serde_json::to_string(&state)?);
} else {
let parser = DemoParser::new_with_analyser(demo.get_stream(), PlayerSummaryAnalyzer::new());
let (header, state) = parser.parse()?;
println!("{:?}", header);
let table_header = "Player | Points | Kills | Deaths | Assists | Destruction | Captures | Defenses | Domination | Revenge | Ubers | Headshots | Teleports | Healing | Backstabs | Bonus | Support | Damage Dealt";
let divider = "---------------------------------|------------|------------|------------|------------|-------------|------------|------------|------------|------------|------------|------------|------------|------------|------------|------------|------------|-------------";
println!("{}", table_header);
println!("{}", divider);
for (user_id, user_data) in state.users {
let player_name = user_data.name;
let summary = state.player_summaries.get(&user_id);
match summary {
Some(s) => {
let (color_code_start, color_code_end) = if player_name == header.nick {
// Give the line for the player a green background with white text
// ANSI color codes are in hex, since rust doesn't support octal literals in strings
// See: https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797
("\x1b[1;42;37m", "\x1b[0m")
} else {
("", "")
};
println!(
"{}{:32} | {:10} | {:10} | {:10} | {:10} | {:11} | {:10} | {:10} | {:10} | {:10} | {:10} | {:10} | {:10} | {:10} | {:10} | {:10} | {:10} | {:12}{}",
color_code_start,
player_name,
s.points,
s.kills,
s.deaths,
s.assists,
s.buildings_destroyed,
s.captures,
s.defenses,
s.dominations,
s.revenges,
s.ubercharges,
s.headshots,
s.teleports,
s.healing,
s.backstabs,
s.bonus_points,
s.support,
s.damage_dealt,
color_code_end,
);
},
None => {
// No summary for this player - they likely joined at the end of the match, or left before they did anything noteworthy
}
}
}
}
Ok(()) Ok(())
} }

View file

@ -21,7 +21,7 @@ struct RawPlayerInfo {
pub more_extra: u8, pub more_extra: u8,
} }
#[derive(BitWrite, Debug, Clone)] #[derive(BitWrite, Debug, Clone, Default)]
pub struct PlayerInfo { pub struct PlayerInfo {
#[size = 32] #[size = 32]
pub name: String, pub name: String,
@ -60,7 +60,7 @@ impl From<RawPlayerInfo> for PlayerInfo {
} }
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, Default)]
pub struct UserInfo { pub struct UserInfo {
pub entity_id: EntityId, pub entity_id: EntityId,
pub player_info: PlayerInfo, pub player_info: PlayerInfo,

View file

@ -17,6 +17,7 @@ pub mod error;
pub mod gamestateanalyser; pub mod gamestateanalyser;
pub mod handler; pub mod handler;
pub mod messagetypeanalyser; pub mod messagetypeanalyser;
pub mod player_summary_analyzer;
pub mod state; pub mod state;
pub use self::error::*; pub use self::error::*;

View file

@ -0,0 +1,313 @@
use crate::demo::data::DemoTick;
use crate::demo::message::packetentities::EntityId;
use crate::demo::message::packetentities::PacketEntity;
use crate::demo::message::{Message, MessageType};
use crate::demo::packet::datatable::ClassId;
use crate::demo::packet::stringtable::StringTableEntry;
use crate::demo::parser::analyser::UserInfo;
use crate::demo::parser::gamestateanalyser::UserId;
use crate::demo::parser::handler::{BorrowMessageHandler, MessageHandler};
use crate::{ParserState, ReadResult, Stream};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
/**
* An analyzer that extracts player scoreboard information to get the stats for every player by the
* end of the demo. Essentially, this will capture all the information that would appear on the
* scoreboard for every player if they took a snapshot at the time the demo finishes (such as the end
* of a match or round).
*/
#[derive(Default, Debug, Serialize, Deserialize, PartialEq)]
pub struct PlayerSummaryAnalyzer {
state: PlayerSummaryState,
user_id_map: HashMap<EntityId, UserId>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
pub struct PlayerSummary {
pub points: u32,
pub kills: u32,
pub assists: u32,
pub deaths: u32,
pub buildings_destroyed: u32,
pub captures: u32,
pub defenses: u32,
pub dominations: u32,
pub revenges: u32,
pub ubercharges: u32,
pub headshots: u32,
pub teleports: u32,
pub healing: u32,
pub backstabs: u32,
pub bonus_points: u32,
pub support: u32,
pub damage_dealt: u32,
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq)]
pub struct PlayerSummaryState {
pub player_summaries: HashMap<UserId, PlayerSummary>,
pub users: BTreeMap<UserId, UserInfo>,
}
impl MessageHandler for PlayerSummaryAnalyzer {
type Output = PlayerSummaryState;
fn does_handle(message_type: MessageType) -> bool {
matches!(
message_type,
MessageType::PacketEntities
)
}
fn handle_message(&mut self, message: &Message, tick: DemoTick, parser_state: &ParserState) {
match message {
Message::PacketEntities(message) => {
for entity in message.entities.iter() {
self.handle_packet_entity(&entity, parser_state);
}
},
_ => {}
}
}
fn into_output(self, _parser_state: &ParserState) -> <Self as MessageHandler>::Output {
self.state
}
fn handle_string_entry(
&mut self,
table: &str,
index: usize,
entry: &StringTableEntry,
_parser_state: &ParserState,
) {
if table == "userinfo" {
let _ = self.parse_user_info(
index,
entry.text.as_ref().map(|s| s.as_ref()),
entry.extra_data.as_ref().map(|data| data.data.clone()),
);
}
}
}
impl BorrowMessageHandler for PlayerSummaryAnalyzer {
fn borrow_output(&self, _state: &ParserState) -> &Self::Output {
&self.state
}
}
/**
* Helper function to make processing integer properties easier.
*
* parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iPoints", |points| { println!("Scored {} points", points) });
*/
fn parse_integer_prop<F>(packet: &PacketEntity, table: &str, name: &str, parser_state: &ParserState, handler: F)
where F: FnOnce(u32) {
use crate::demo::sendprop::SendPropValue;
match packet.get_prop_by_name(table, name, parser_state) {
Some(prop) => {
match prop.value {
SendPropValue::Integer(val) => handler(val as u32),
_ => {} // not an integer, ignore
}
},
None => {} // the packet doesn't have this property
}
}
impl PlayerSummaryAnalyzer {
pub fn new() -> Self {
Self::default()
}
fn handle_packet_entity(&mut self, packet: &PacketEntity, parser_state: &ParserState) {
use crate::demo::sendprop::SendPropValue;
// println!("Known server classes: {:?}", parser_state.server_classes);
if let Some(class) = parser_state.server_classes.get(<ClassId as Into<usize>>::into(packet.server_class)) {
// println!("Got a {} data packet: {:?}", class.name, packet);
match class.name.as_str() {
"CTFPlayer" => {
match self.user_id_map.get(&packet.entity_index) {
Some(user_id) => {
let summaries = &mut self.state.player_summaries;
let player_summary = summaries.entry(*user_id).or_default();
// Extract scoreboard information, if present, and update the player's summary accordingly
// NOTE: Multiple DT_TFPlayerScoringDataExclusive structures may be present - one for the entire match,
// and one for just the current round. Since we're only interested in the overall match scores,
// we need to ignore the round-specific values. Fortunately, this is easy - just ignore the
// lesser value (if multiple values are present), since none of these scores are able to decrement.
/*
* Member: m_iCaptures (offset 4) (type integer) (bits 10) (Unsigned)
* Member: m_iDefenses (offset 8) (type integer) (bits 10) (Unsigned)
* Member: m_iKills (offset 12) (type integer) (bits 10) (Unsigned)
* Member: m_iDeaths (offset 16) (type integer) (bits 10) (Unsigned)
* Member: m_iSuicides (offset 20) (type integer) (bits 10) (Unsigned)
* Member: m_iDominations (offset 24) (type integer) (bits 10) (Unsigned)
* Member: m_iRevenge (offset 28) (type integer) (bits 10) (Unsigned)
* Member: m_iBuildingsBuilt (offset 32) (type integer) (bits 10) (Unsigned)
* Member: m_iBuildingsDestroyed (offset 36) (type integer) (bits 10) (Unsigned)
* Member: m_iHeadshots (offset 40) (type integer) (bits 10) (Unsigned)
* Member: m_iBackstabs (offset 44) (type integer) (bits 10) (Unsigned)
* Member: m_iHealPoints (offset 48) (type integer) (bits 20) (Unsigned)
* Member: m_iInvulns (offset 52) (type integer) (bits 10) (Unsigned)
* Member: m_iTeleports (offset 56) (type integer) (bits 10) (Unsigned)
* Member: m_iDamageDone (offset 60) (type integer) (bits 20) (Unsigned)
* Member: m_iCrits (offset 64) (type integer) (bits 10) (Unsigned)
* Member: m_iResupplyPoints (offset 68) (type integer) (bits 10) (Unsigned)
* Member: m_iKillAssists (offset 72) (type integer) (bits 12) (Unsigned)
* Member: m_iBonusPoints (offset 76) (type integer) (bits 10) (Unsigned)
* Member: m_iPoints (offset 80) (type integer) (bits 10) (Unsigned)
*
* NOTE: support points aren't included here, but is equal to the sum of m_iHealingAssist and m_iDamageAssist
* TODO: pull data for support points
*/
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iCaptures", parser_state, |captures| {
if captures > player_summary.captures {
player_summary.captures = captures;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iDefenses", parser_state, |defenses| {
if defenses > player_summary.defenses {
player_summary.defenses = defenses;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iKills", parser_state, |kills| {
if kills > player_summary.kills {
// TODO: This might not be accruate. Tested with a demo file with 89 kills (88 on the scoreboard),
// but only a 83 were reported in the scoring data.
player_summary.kills = kills;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iDeaths", parser_state, |deaths| {
if deaths > player_summary.deaths {
player_summary.deaths = deaths;
}
});
// ignore m_iSuicides
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iDominations", parser_state, |dominations| {
if dominations > player_summary.dominations {
player_summary.dominations = dominations;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iRevenge", parser_state, |revenges| {
if revenges > player_summary.revenges {
player_summary.revenges = revenges;
}
});
// ignore m_iBuildingsBuilt
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iBuildingsDestroyed", parser_state, |buildings_destroyed| {
if buildings_destroyed > player_summary.buildings_destroyed {
player_summary.buildings_destroyed = buildings_destroyed;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iHeadshots", parser_state, |headshots| {
if headshots > player_summary.headshots {
player_summary.headshots = headshots;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iBackstabs", parser_state, |backstabs| {
if backstabs > player_summary.backstabs {
player_summary.backstabs = backstabs;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iHealPoints", parser_state, |healing| {
if healing > player_summary.healing {
player_summary.healing = healing;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iInvulns", parser_state, |ubercharges| {
if ubercharges > player_summary.ubercharges {
player_summary.ubercharges = ubercharges;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iTeleports", parser_state, |teleports| {
if teleports > player_summary.teleports {
player_summary.teleports = teleports;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iDamageDone", parser_state, |damage_dealt| {
if damage_dealt > player_summary.damage_dealt {
player_summary.damage_dealt = damage_dealt;
}
});
// ignore m_iCrits
// ignore m_iResupplyPoints
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iKillAssists", parser_state, |assists| {
if assists > player_summary.assists {
player_summary.assists = assists;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iBonusPoints", parser_state, |bonus_points| {
if bonus_points > player_summary.bonus_points {
player_summary.bonus_points = bonus_points;
}
});
parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iPoints", parser_state, |points| {
if points > player_summary.points {
player_summary.points = points;
}
});
},
None => {
// Player entity likely spawned before the player was assigned to it?
// This can rarely happen, but doesn't seem to affect the end result
},
}
},
"CTFPlayerResource" => {
// Player summaries - including entity IDs!
// look for props like m_iUserID.<entity_id> = <user_id>
// for example, `m_iUserID.024 = 2523` means entity 24 is user 2523
for i in 0..33 { // 0 to 32, inclusive (1..33 might also work, not sure if there's a user 0 or not). Not exhaustive and doesn't work for servers with > 32 players
match packet.get_prop_by_name("m_iUserID", format!("{:0>3}", i).as_str(), parser_state) {
Some(prop) => {
match prop.value {
SendPropValue::Integer(x) => {
let entity_id = EntityId::from(i as u32);
let user_id = UserId::from(x as u32);
self.user_id_map.insert(entity_id, user_id);
},
_ => {
// These properties should all be integers...
}
}
},
None => {} // ignore, no property for this entity was included
}
}
},
_other => {
// Don't care
},
}
}
}
fn parse_user_info(
&mut self,
index: usize,
text: Option<&str>,
data: Option<Stream>,
) -> ReadResult<()> {
if let Some(user_info) =
crate::demo::data::UserInfo::parse_from_string_table(index as u16, text, data)?
{
self.state
.users
.entry(user_info.player_info.user_id.into())
.and_modify(|info| {
info.entity_id = user_info.entity_id;
})
.or_insert_with(|| user_info.into());
}
Ok(())
}
}