wrpl 0.10.0

A library/CLI to decode War Thunder replays (.wrpl).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
use crate::header::{parse_header, ReplayHeader, ReplaySettings};
use crate::packet::{
    parse_award_packet, parse_chat_packet, parse_kill_packet, read_packet_header, read_vlq_size,
    PacketInfo,
};
use crate::packet::{AwardInfo, ChatInfo, ReplayPacketType};
use crate::results::parse_replay_results_json;
use anyhow::{bail, Context, Result};
use flate2::read::ZlibDecoder;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::io::{self, Cursor, Read};
use std::sync::Arc;
use wt_blk::blk;
use wt_blk::blk::file::FileType;
use wt_blk::blk::name_map::NameMap;

/// Process replay data (potentially compressed) from a byte slice.
pub fn process_replay_data(
    data: &[u8],
    start_offset: u64,
    skip_zlib: bool,
) -> Result<ParsedReplay> {
    // Validate start_offset before slicing
    if start_offset > data.len() as u64 {
        bail!(
            "Start offset {:#0x} is beyond the data length ({} bytes)",
            start_offset,
            data.len()
        );
    }
    let input_data = &data[start_offset as usize..];

    let mut reader = std::io::BufReader::new(create_reader(input_data, skip_zlib)?);
    // if !skip_zlib {
    //     let peeked = reader.fill_buf().unwrap_or(&[]);
    //     if peeked.len() >= 3 {
    //         // second bytes seems to be E<anything> (E2, E6 i've seen)
    //         // not sure why.
    //         // additionally, some replays don't have what is matched...
    //         if peeked[0] != 0x40 || peeked[2] != 0x08 {
    //             warn!("Decompressed replay stream does not start with expected bytes.");
    //         }
    //     }
    // }

    let mut stats = ParsedReplay::default();
    let mut last_timestamp_ticks: u32 = 0;

    loop {
        debug!(
            "Processing Packet {} (Decompressed bytes read so far: {}) ---",
            stats.packet_count, stats.total_decompressed_bytes
        );

        let (decompressed_payload_size, prefix_bytes_read) = match read_vlq_size(&mut reader) {
            Ok(Some((size, bytes_read))) => (size, bytes_read),
            Ok(None) => {
                debug!("EOF reached while reading packet size prefix. End of stream.");
                break;
            }
            Err(e) => {
                if let Some(io_err) = e.downcast_ref::<io::Error>() {
                    if io_err.kind() == io::ErrorKind::UnexpectedEof {
                        warn!("Incomplete packet size prefix at end of stream: {}", e);
                        break; // treat as EOF
                    }
                }
                error!("Error reading packet size prefix: {:?}", e);
                bail!("Failed to read or parse packet size prefix");
            }
        };

        debug!(
            "Read size prefix ({} decomp. bytes): Expected payload size = {} bytes",
            prefix_bytes_read, decompressed_payload_size
        );
        stats.total_decompressed_bytes += prefix_bytes_read as u64;

        if decompressed_payload_size == 0 {
            warn!("Encountered zero-size packet payload. Continuing.");
        }

        let mut packet_data_with_header = vec![0u8; decompressed_payload_size as usize];
        let total_bytes_read_for_payload;

        match reader.read_exact(&mut packet_data_with_header) {
            Ok(_) => {
                total_bytes_read_for_payload = decompressed_payload_size as usize;
            }
            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
                warn!(
                    "Incomplete packet payload read. Expected {}, stream ended early.",
                    decompressed_payload_size
                );
                packet_data_with_header.fill(0);
                let mut temp_reader = (&mut reader).take(decompressed_payload_size as u64);
                match temp_reader.read_to_end(&mut packet_data_with_header) {
                    Ok(bytes_actually_read) => {
                        total_bytes_read_for_payload = bytes_actually_read;
                        packet_data_with_header.truncate(total_bytes_read_for_payload);
                        warn!(
                            "Read {} bytes of partial payload.",
                            total_bytes_read_for_payload
                        );
                    }
                    Err(read_err) => {
                        error!("Error attempting to read partial payload: {:?}", read_err);
                        bail!("Failed to read partial packet payload after EOF detected");
                    }
                }
                if total_bytes_read_for_payload == 0 {
                    info!("No payload data read after size prefix indicated > 0. Stopping.");
                    break;
                }
            }
            Err(e) => {
                error!("I/O error reading packet payload: {:?}", e);
                bail!("Failed to read packet payload");
            }
        }
        stats.total_decompressed_bytes += total_bytes_read_for_payload as u64;

        if total_bytes_read_for_payload > 0 {
            let mut payload_cursor = Cursor::new(&packet_data_with_header);

            match read_packet_header(&mut payload_cursor, last_timestamp_ticks) {
                Ok(Some((packet_type_val, timestamp_ticks, header_bytes_read))) => {
                    last_timestamp_ticks = timestamp_ticks;
                    debug!(
                        "Parsed Header ({} bytes): Type={}, Timestamp={}ms",
                        header_bytes_read, packet_type_val, timestamp_ticks
                    );

                    let header_len = header_bytes_read;
                    let payload_content = &packet_data_with_header[header_len..];

                    stats.packets.push(PacketInfo {
                        packet_type: match packet_type_val {
                            0 => ReplayPacketType::EndMarker,
                            1 => ReplayPacketType::StartMarker,
                            2 => ReplayPacketType::AircraftSmall,
                            3 => ReplayPacketType::Chat,
                            4 => ReplayPacketType::MPI,
                            5 => ReplayPacketType::NextSegment,
                            6 => ReplayPacketType::ECS,
                            7 => ReplayPacketType::Snapshot,
                            8 => ReplayPacketType::ReplayHeaderInfo,
                            _ => ReplayPacketType::Unknown,
                        },
                        timestamp_ticks,
                        payload: payload_content.to_vec(),
                    });

                    if packet_type_val == 3 {
                        if let Some(chat_info) = parse_chat_packet(payload_content, timestamp_ticks)
                        {
                            stats.chat_messages.push(chat_info);
                        }
                    }
                    if packet_type_val == 4 {
                        if payload_content.len() >= 4 {
                            let signature = &payload_content[..4];
                            debug!("MPI packet detected. Signature: {:02X?}", signature);

                            // 00 02 58 78 - Awards      (PacketTypeMPI_Award)
                            // 00 02 58 58 - Kill screen? (PacketTypeMPI_Kill)
                            // 00 02 58 74 - Model info (steering)
                            // 00 03 58 43 - Model info (turret angles)

                            match signature {
                                [0x00, 0x02, 0x58, 0x78] => {
                                    debug!("MPI Award signature matched");
                                    if let Some(award_info) =
                                        parse_award_packet(payload_content, timestamp_ticks)
                                    {
                                        stats.award_messages.push(award_info);
                                    }
                                }
                                [0x00, 0x02, 0x58, 0x58] => {
                                    debug!("MPI Kill signature matched");
                                    if let Some(kill_info) =
                                        parse_kill_packet(payload_content, timestamp_ticks)
                                    {
                                        // stats.kill_messages.push(kill_info);
                                        info!("{:?}", kill_info)
                                    }
                                }
                                [0x00, 0x02, 0x58, 0x74] => {
                                    debug!("MPI Model info (steering) signature matched");
                                }
                                [0x00, 0x03, 0x58, 0x43] => {
                                    debug!("MPI Model info (turret angles) signature matched");
                                }
                                unknown => {
                                    debug!("Unknown MPI signature: {:02X?}", unknown);
                                }
                            }
                        } else {
                            warn!("MPI packet too short to detect signature");
                        }
                    }
                }
                Ok(None) => {
                    warn!("Unexpected EOF reading packet header from payload buffer. Skipping payload.");
                    if total_bytes_read_for_payload < decompressed_payload_size as usize {
                        info!("Partial payload likely caused header read failure. Stopping.");
                        break;
                    }
                }
                Err(e) => {
                    error!("Error reading packet header from payload data: {:?}", e);
                    if total_bytes_read_for_payload < decompressed_payload_size as usize {
                        info!("Partial payload likely caused header read failure. Stopping.");
                        break;
                    }
                    bail!("Failed to parse packet header from payload");
                }
            }
        } else if decompressed_payload_size > 0 {
            info!("No payload data could be read even after partial attempt. Stopping.");
            break;
        }

        stats.packet_count += 1;
    }

    info!(
        "Processed {} packets ({} bytes)",
        stats.packet_count, stats.total_decompressed_bytes
    );

    if skip_zlib {
        stats.final_offset = start_offset + stats.total_decompressed_bytes;
        info!(
            "Final position in input (uncompressed): {:#0x}",
            stats.final_offset
        );
    } else {
        info!("Cannot determine exact final compressed offset after processing.");
        info!(
            "Total decompressed bytes processed: {}",
            stats.total_decompressed_bytes
        );
        stats.final_offset = 0;
    }

    Ok(stats)
}

/// Creates the appropriate reader (direct or zlib) based on the flag.
fn create_reader<'a>(input_data: &'a [u8], skip_zlib: bool) -> Result<Box<dyn Read + 'a>> {
    let cursor = Cursor::new(input_data);
    let reader: Box<dyn Read + 'a> = if skip_zlib {
        info!("Processing stream directly (zlib decoding skipped).");
        Box::new(cursor)
    } else {
        info!("Processing stream with zlib decoder.");
        Box::new(ZlibDecoder::new(cursor))
    };
    Ok(reader)
}

/// Processes the replay stream provided as a byte slice.
pub fn process_replay_stream(
    replay_data: &[u8],
    start_offset: u64,
    skip_zlib: bool,
    header: Option<&ReplayHeader>,
) -> Result<ParsedReplay> {
    if start_offset > 0 {
        info!(
            "Seeking to stream offset {:#0x} ({}) in input data.",
            start_offset, start_offset
        );
        if skip_zlib {
            info!("Will read raw packet data from this offset.");
        }
    } else {
        info!("Starting processing from beginning of input data (offset 0).");
    }

    let mut stats = process_replay_data(replay_data, start_offset, skip_zlib)?;

    if let Some(header) = header {
        if header.settings_size > 0 {
            // (client_2) 0x000004CA = 0x01
            // https://github.com/Warthunder-Open-Source-Foundation/wt_blk/blob/master/src/blk/file.rs#L10
            // using mimi here won't work as it uses memory size, not disk.
            // also +2 is to skip over some mystery bytes
            let hdr_size = usize::try_from(header._total_length)? + 2;
            info!("Header size: {}", hdr_size);
            let settings_size = header.settings_size as usize;
            let settings_start = hdr_size;
            let settings_end = settings_start.saturating_add(settings_size);
            if settings_end <= replay_data.len() {
                info!(
                    "Parsing settings BLK at offset {} ({} bytes)",
                    settings_start, settings_size
                );
                let blk_data = &replay_data[settings_start..settings_end];
                match decompress_blk(blk_data) {
                    Ok(json) => {
                        println!("{}", &json);
                        match serde_json::from_str::<ReplaySettings>(&json) {
                            Ok(deserialized) => {
                                stats.replay_settings = Some(deserialized);
                                info!("Successfully parsed settings");
                            }
                            Err(e) => {
                                bail!("Failed to deserialize settings: {}", e);
                            }
                        }
                    }
                    Err(e) => {
                        warn!("Failed to parse settings: {}", e);
                    }
                }
            } else {
                warn!(
                    "Settings range {}..{} out of data bounds",
                    settings_start, settings_end
                );
            }
        }
    }

    // Then parse end-of-replay results if available
    if let Some(header) = header {
        if header.rez_offset > 0 && header.rez_offset < replay_data.len() as u32 {
            info!(
                "Attempting to parse end-of-replay results at offset {}",
                header.rez_offset
            );
            stats.replay_results = parse_replay_results(replay_data, header.rez_offset as usize);

            if stats.replay_results.is_some() {
                info!("Successfully parsed end-of-replay results");
                println!("{:?}", stats.replay_settings);
            } else {
                warn!("Failed to parse end-of-replay results (compression not yet implemented)");
            }
        } else {
            warn!("No valid rez_offset found in header, skipping result parsing");
        }
    }

    Ok(stats)
}

pub fn process_parted_replay(buffers: &[Vec<u8>], skip_zlib: bool) -> Result<ParsedReplay> {
    if buffers.is_empty() {
        bail!("No replay parts provided");
    }
    let mut combined = ParsedReplay::default();
    for buf in buffers {
        let hdr = parse_header(buf).context("parsing replay segment header")?;
        let offset = hdr._total_length + 2 + hdr.settings_size as u64;
        let part = process_replay_stream(buf, offset, skip_zlib, None)?;
        combined.packet_count += part.packet_count;
        combined.total_decompressed_bytes += part.total_decompressed_bytes;
        combined.packets.extend(part.packets);
        combined.chat_messages.extend(part.chat_messages);
        combined.award_messages.extend(part.award_messages);
    }
    Ok(combined)
}

/// The result of a parsed replay.
#[derive(Debug, Default)]
pub struct ParsedReplay {
    /// Total number of packets processed.
    pub packet_count: u64,
    /// Total bytes read *after* decompression (if any).
    /// If zlib is skipped, this is raw bytes read.
    pub total_decompressed_bytes: u64,
    pub final_offset: u64,
    /// List of packets.
    pub packets: Vec<PacketInfo>,
    /// List of chat messages.
    pub chat_messages: Vec<ChatInfo>,
    /// List of award messages.
    pub award_messages: Vec<AwardInfo>,
    /// Parsed settings data(if available).
    pub replay_settings: Option<ReplaySettings>,
    /// End-of-replay results data (if available).
    pub replay_results: Option<ReplayResults>,
}

/// Complete replay results containing battle outcome and player statistics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayResults {
    /// Battle result ("won", "lost", "left").
    pub status: String,
    /// Time played in seconds.
    pub time_played: f64,
    /// User ID of the replay author.
    pub author_user_id: String,
    /// Username of the replay author.
    pub author: String,
    /// List of players and their results.
    pub players: Vec<PlayerData>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerData {
    pub player_info: PlayerInfo,
    pub replay_data: PlayerReplayData,
}

/// Player profile information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerInfo {
    /// Unique user ID.
    pub user_id: String,
    /// Display username.
    pub username: String,
    /// Squadron/clan ID.
    pub squadron_id: String,
    /// Squadron/clan tag.
    pub squadron_tag: String,
    /// Player's platform ("win64", "macosx", etc.)
    pub platform: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerReplayData {
    pub user_id: String,
    pub squad: i32,
    pub auto_squad: bool,
    pub team: i32,
    pub wait_time: f32,
    pub kills: i32,
    pub ground_kills: i32,
    pub naval_kills: i32,
    pub team_kills: i32,
    pub ai_kills: i32,
    pub ai_ground_kills: i32,
    pub ai_naval_kills: i32,
    pub assists: i32,
    pub deaths: i32,
    pub capture_zone: i32,
    pub damage_zone: i32,
    pub score: i32,
    pub award_damage: i32,
    pub missile_evades: i32,
    pub lineup: Vec<String>,
}

/// Parses end-of-replay results from offset.
pub fn parse_replay_results(data: &[u8], rez_offset: usize) -> Option<ReplayResults> {
    if rez_offset >= data.len() {
        error!(
            "Rez offset {} is beyond data length {}",
            rez_offset,
            data.len()
        );
        return None;
    }

    let compressed_data = &data[rez_offset..];

    debug!(
        "Found compressed data at offset {} with {} bytes",
        rez_offset,
        compressed_data.len()
    );

    let json_data = match decompress_blk(compressed_data) {
        Ok(data) => data,
        Err(e) => {
            error!("Failed to decompress replay results data: {}", e);
            return None;
        }
    };

    match parse_replay_results_json(&json_data) {
        Ok(results) => Some(results),
        Err(e) => {
            error!("Failed to parse replay results JSON: {}", e);
            None
        }
    }
}

fn decompress_blk(compressed_data: &[u8]) -> Result<String> {
    if compressed_data.is_empty() {
        bail!("No data provided for decompress_blk");
    }

    let zstd_dict = None;
    let nm: Option<Arc<NameMap>> = None;

    match FileType::from_byte(compressed_data[0])? {
        FileType::BBF => {}
        FileType::FAT => {}
        FileType::FAT_ZSTD => {}
        FileType::SLIM => {}
        FileType::SLIM_ZSTD => {}
        FileType::SLIM_ZST_DICT => {
            bail!("ZSTD dictionary compressed BLK not supported");
        }
    }

    let mut compressed_vec = compressed_data.to_vec();
    let mut parsed = blk::unpack_blk(&mut compressed_vec, zstd_dict, nm)
        .map_err(|e| anyhow::anyhow!("blk::unpack_blk failed: {}", e))?;

    let _ = parsed.merge_fields();
    let json_bytes = parsed
        .as_serde_json()
        .map_err(|e| anyhow::anyhow!("blk::as_serde_json failed: {}", e))?;

    let json_output = String::from_utf8(json_bytes)
        .context("Couldn't parse BLK JSON output (UTF-8 conversion failed)")?;

    Ok(json_output)
}