tf_demo_parser/demo/parser/player_summary_analyzer.rs
1use crate::demo::data::DemoTick;
2use crate::demo::message::packetentities::EntityId;
3use crate::demo::message::packetentities::PacketEntity;
4use crate::demo::message::{Message, MessageType};
5use crate::demo::packet::datatable::ClassId;
6use crate::demo::packet::stringtable::StringTableEntry;
7use crate::demo::parser::analyser::UserInfo;
8use crate::demo::parser::gamestateanalyser::UserId;
9use crate::demo::parser::handler::{BorrowMessageHandler, MessageHandler};
10use crate::demo::sendprop::SendProp;
11use crate::{ParserState, ReadResult, Stream};
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, HashMap};
14
15/**
16 * An analyzer that extracts player scoreboard information to get the stats for every player by the
17 * end of the demo. Essentially, this will capture all the information that would appear on the
18 * scoreboard for every player if they took a snapshot at the time the demo finishes (such as the end
19 * of a match or round).
20 */
21#[derive(Default, Debug, Serialize, Deserialize, PartialEq)]
22pub struct PlayerSummaryAnalyzer {
23 state: PlayerSummaryState,
24 user_id_map: HashMap<EntityId, UserId>,
25}
26
27#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
28pub struct PlayerSummary {
29 pub points: u32,
30 pub kills: u32,
31 pub assists: u32,
32 pub deaths: u32,
33 pub buildings_destroyed: u32,
34 pub captures: u32,
35 pub defenses: u32,
36 pub dominations: u32,
37 pub revenges: u32,
38 pub ubercharges: u32,
39 pub headshots: u32,
40 pub teleports: u32,
41 pub healing: u32,
42 pub backstabs: u32,
43 pub bonus_points: u32,
44 pub support: u32,
45 pub damage_dealt: u32,
46}
47
48#[derive(Default, Debug, Serialize, Deserialize, PartialEq)]
49pub struct PlayerSummaryState {
50 pub player_summaries: HashMap<UserId, PlayerSummary>,
51 pub users: BTreeMap<UserId, UserInfo>,
52}
53
54impl MessageHandler for PlayerSummaryAnalyzer {
55 type Output = PlayerSummaryState;
56
57 fn does_handle(message_type: MessageType) -> bool {
58 matches!(message_type, MessageType::PacketEntities)
59 }
60
61 fn handle_message(&mut self, message: &Message, _tick: DemoTick, parser_state: &ParserState) {
62 if let Message::PacketEntities(message) = message {
63 for entity in message.entities.iter() {
64 self.handle_packet_entity(entity, parser_state);
65 }
66 }
67 }
68
69 fn into_output(self, _parser_state: &ParserState) -> <Self as MessageHandler>::Output {
70 self.state
71 }
72
73 fn handle_string_entry(
74 &mut self,
75 table: &str,
76 index: usize,
77 entry: &StringTableEntry,
78 _parser_state: &ParserState,
79 ) {
80 if table == "userinfo" {
81 let _ = self.parse_user_info(
82 index,
83 entry.text.as_ref().map(|s| s.as_ref()),
84 entry.extra_data.as_ref().map(|data| data.data.clone()),
85 );
86 }
87 }
88}
89
90impl BorrowMessageHandler for PlayerSummaryAnalyzer {
91 fn borrow_output(&self, _state: &ParserState) -> &Self::Output {
92 &self.state
93 }
94}
95
96/**
97 * Helper function to make processing integer properties easier.
98 *
99 * parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iPoints", |points| { println!("Scored {} points", points) });
100 */
101fn parse_integer_prop<F>(
102 packet: &PacketEntity,
103 table: &str,
104 name: &str,
105 parser_state: &ParserState,
106 handler: F,
107) where
108 F: FnOnce(u32),
109{
110 use crate::demo::sendprop::SendPropValue;
111
112 if let Some(SendProp {
113 value: SendPropValue::Integer(val),
114 ..
115 }) = packet.get_prop_by_name(table, name, parser_state)
116 {
117 handler(val as u32);
118 }
119}
120
121impl PlayerSummaryAnalyzer {
122 pub fn new() -> Self {
123 Self::default()
124 }
125
126 fn handle_packet_entity(&mut self, packet: &PacketEntity, parser_state: &ParserState) {
127 use crate::demo::sendprop::SendPropValue;
128
129 // println!("Known server classes: {:?}", parser_state.server_classes);
130
131 if let Some(class) = parser_state
132 .server_classes
133 .get(<ClassId as Into<usize>>::into(packet.server_class))
134 {
135 // println!("Got a {} data packet: {:?}", class.name, packet);
136 match class.name.as_str() {
137 "CTFPlayer" => {
138 if let Some(user_id) = self.user_id_map.get(&packet.entity_index) {
139 let summaries = &mut self.state.player_summaries;
140 let player_summary = summaries.entry(*user_id).or_default();
141
142 // Extract scoreboard information, if present, and update the player's summary accordingly
143 // NOTE: Multiple DT_TFPlayerScoringDataExclusive structures may be present - one for the entire match,
144 // and one for just the current round. Since we're only interested in the overall match scores,
145 // we need to ignore the round-specific values. Fortunately, this is easy - just ignore the
146 // lesser value (if multiple values are present), since none of these scores are able to decrement.
147
148 /*
149 * Member: m_iCaptures (offset 4) (type integer) (bits 10) (Unsigned)
150 * Member: m_iDefenses (offset 8) (type integer) (bits 10) (Unsigned)
151 * Member: m_iKills (offset 12) (type integer) (bits 10) (Unsigned)
152 * Member: m_iDeaths (offset 16) (type integer) (bits 10) (Unsigned)
153 * Member: m_iSuicides (offset 20) (type integer) (bits 10) (Unsigned)
154 * Member: m_iDominations (offset 24) (type integer) (bits 10) (Unsigned)
155 * Member: m_iRevenge (offset 28) (type integer) (bits 10) (Unsigned)
156 * Member: m_iBuildingsBuilt (offset 32) (type integer) (bits 10) (Unsigned)
157 * Member: m_iBuildingsDestroyed (offset 36) (type integer) (bits 10) (Unsigned)
158 * Member: m_iHeadshots (offset 40) (type integer) (bits 10) (Unsigned)
159 * Member: m_iBackstabs (offset 44) (type integer) (bits 10) (Unsigned)
160 * Member: m_iHealPoints (offset 48) (type integer) (bits 20) (Unsigned)
161 * Member: m_iInvulns (offset 52) (type integer) (bits 10) (Unsigned)
162 * Member: m_iTeleports (offset 56) (type integer) (bits 10) (Unsigned)
163 * Member: m_iDamageDone (offset 60) (type integer) (bits 20) (Unsigned)
164 * Member: m_iCrits (offset 64) (type integer) (bits 10) (Unsigned)
165 * Member: m_iResupplyPoints (offset 68) (type integer) (bits 10) (Unsigned)
166 * Member: m_iKillAssists (offset 72) (type integer) (bits 12) (Unsigned)
167 * Member: m_iBonusPoints (offset 76) (type integer) (bits 10) (Unsigned)
168 * Member: m_iPoints (offset 80) (type integer) (bits 10) (Unsigned)
169 *
170 * NOTE: support points aren't included here, but is equal to the sum of m_iHealingAssist and m_iDamageAssist
171 * TODO: pull data for support points
172 */
173 parse_integer_prop(
174 packet,
175 "DT_TFPlayerScoringDataExclusive",
176 "m_iCaptures",
177 parser_state,
178 |captures| {
179 if captures > player_summary.captures {
180 player_summary.captures = captures;
181 }
182 },
183 );
184 parse_integer_prop(
185 packet,
186 "DT_TFPlayerScoringDataExclusive",
187 "m_iDefenses",
188 parser_state,
189 |defenses| {
190 if defenses > player_summary.defenses {
191 player_summary.defenses = defenses;
192 }
193 },
194 );
195 parse_integer_prop(
196 packet,
197 "DT_TFPlayerScoringDataExclusive",
198 "m_iKills",
199 parser_state,
200 |kills| {
201 if kills > player_summary.kills {
202 // TODO: This might not be accruate. Tested with a demo file with 89 kills (88 on the scoreboard),
203 // but only a 83 were reported in the scoring data.
204 player_summary.kills = kills;
205 }
206 },
207 );
208 parse_integer_prop(
209 packet,
210 "DT_TFPlayerScoringDataExclusive",
211 "m_iDeaths",
212 parser_state,
213 |deaths| {
214 if deaths > player_summary.deaths {
215 player_summary.deaths = deaths;
216 }
217 },
218 );
219 // ignore m_iSuicides
220 parse_integer_prop(
221 packet,
222 "DT_TFPlayerScoringDataExclusive",
223 "m_iDominations",
224 parser_state,
225 |dominations| {
226 if dominations > player_summary.dominations {
227 player_summary.dominations = dominations;
228 }
229 },
230 );
231 parse_integer_prop(
232 packet,
233 "DT_TFPlayerScoringDataExclusive",
234 "m_iRevenge",
235 parser_state,
236 |revenges| {
237 if revenges > player_summary.revenges {
238 player_summary.revenges = revenges;
239 }
240 },
241 );
242 // ignore m_iBuildingsBuilt
243 parse_integer_prop(
244 packet,
245 "DT_TFPlayerScoringDataExclusive",
246 "m_iBuildingsDestroyed",
247 parser_state,
248 |buildings_destroyed| {
249 if buildings_destroyed > player_summary.buildings_destroyed {
250 player_summary.buildings_destroyed = buildings_destroyed;
251 }
252 },
253 );
254 parse_integer_prop(
255 packet,
256 "DT_TFPlayerScoringDataExclusive",
257 "m_iHeadshots",
258 parser_state,
259 |headshots| {
260 if headshots > player_summary.headshots {
261 player_summary.headshots = headshots;
262 }
263 },
264 );
265 parse_integer_prop(
266 packet,
267 "DT_TFPlayerScoringDataExclusive",
268 "m_iBackstabs",
269 parser_state,
270 |backstabs| {
271 if backstabs > player_summary.backstabs {
272 player_summary.backstabs = backstabs;
273 }
274 },
275 );
276 parse_integer_prop(
277 packet,
278 "DT_TFPlayerScoringDataExclusive",
279 "m_iHealPoints",
280 parser_state,
281 |healing| {
282 if healing > player_summary.healing {
283 player_summary.healing = healing;
284 }
285 },
286 );
287 parse_integer_prop(
288 packet,
289 "DT_TFPlayerScoringDataExclusive",
290 "m_iInvulns",
291 parser_state,
292 |ubercharges| {
293 if ubercharges > player_summary.ubercharges {
294 player_summary.ubercharges = ubercharges;
295 }
296 },
297 );
298 parse_integer_prop(
299 packet,
300 "DT_TFPlayerScoringDataExclusive",
301 "m_iTeleports",
302 parser_state,
303 |teleports| {
304 if teleports > player_summary.teleports {
305 player_summary.teleports = teleports;
306 }
307 },
308 );
309 parse_integer_prop(
310 packet,
311 "DT_TFPlayerScoringDataExclusive",
312 "m_iDamageDone",
313 parser_state,
314 |damage_dealt| {
315 if damage_dealt > player_summary.damage_dealt {
316 player_summary.damage_dealt = damage_dealt;
317 }
318 },
319 );
320 // ignore m_iCrits
321 // ignore m_iResupplyPoints
322 parse_integer_prop(
323 packet,
324 "DT_TFPlayerScoringDataExclusive",
325 "m_iKillAssists",
326 parser_state,
327 |assists| {
328 if assists > player_summary.assists {
329 player_summary.assists = assists;
330 }
331 },
332 );
333 parse_integer_prop(
334 packet,
335 "DT_TFPlayerScoringDataExclusive",
336 "m_iBonusPoints",
337 parser_state,
338 |bonus_points| {
339 if bonus_points > player_summary.bonus_points {
340 player_summary.bonus_points = bonus_points;
341 }
342 },
343 );
344 parse_integer_prop(
345 packet,
346 "DT_TFPlayerScoringDataExclusive",
347 "m_iPoints",
348 parser_state,
349 |points| {
350 if points > player_summary.points {
351 player_summary.points = points;
352 }
353 },
354 );
355 }
356 }
357 "CTFPlayerResource" => {
358 // Player summaries - including entity IDs!
359 // look for props like m_iUserID.<entity_id> = <user_id>
360 // for example, `m_iUserID.024 = 2523` means entity 24 is user 2523
361 for i in 0..33 {
362 // 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
363 if let Some(SendProp {
364 value: SendPropValue::Integer(x),
365 ..
366 }) = packet.get_prop_by_name(
367 "m_iUserID",
368 format!("{:0>3}", i).as_str(),
369 parser_state,
370 ) {
371 let entity_id = EntityId::from(i as u32);
372 let user_id = UserId::from(x as u32);
373 self.user_id_map.insert(entity_id, user_id);
374 }
375 }
376 }
377 _other => {
378 // Don't care
379 }
380 }
381 }
382 }
383
384 fn parse_user_info(
385 &mut self,
386 index: usize,
387 text: Option<&str>,
388 data: Option<Stream>,
389 ) -> ReadResult<()> {
390 if let Some(user_info) =
391 crate::demo::data::UserInfo::parse_from_string_table(index as u16, text, data)?
392 {
393 self.state
394 .users
395 .entry(user_info.player_info.user_id)
396 .and_modify(|info| {
397 info.entity_id = user_info.entity_id;
398 })
399 .or_insert_with(|| user_info.into());
400 }
401
402 Ok(())
403 }
404}