mirror of
https://github.com/eigenein/wotbreplay-parser.git
synced 2026-10-21 10:27:01 +02:00
Compare commits
17
Commits
0.3.0-alpha.3
...
0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c986abca5d | ||
|
|
838a49ee68 | ||
|
|
a177447ca1 | ||
|
|
2d2c85c18a | ||
|
|
0f437c39de | ||
|
|
12aa208217 | ||
|
|
181da1ae96 | ||
|
|
0ac983832b | ||
|
|
1830536224 | ||
|
|
897f98c17c | ||
|
|
59353a9aca | ||
|
|
c9a2adff4c | ||
|
|
72165ae52c | ||
|
|
e48fe1dd0d | ||
|
|
9d601082d6 | ||
|
|
632f1781e7 | ||
|
|
fa6ac70d21 |
No files matched your search
@@ -1,5 +1 @@
|
||||
github: [eigenein]
|
||||
custom:
|
||||
- 'https://www.paypal.me/eigenein'
|
||||
- 'https://bunq.me/eigenein'
|
||||
- 'https://money.yandex.ru/to/410011693851843'
|
||||
@@ -43,17 +43,11 @@ jobs:
|
||||
command: test
|
||||
args: --all-targets
|
||||
|
||||
- name: 🧪 Test all features
|
||||
- name: 🧪 Test
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --all-targets --all-features
|
||||
|
||||
- name: 🧪 Test docs
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --doc --all-features
|
||||
args: --all-features
|
||||
|
||||
- name: 🪄 Clippy
|
||||
uses: actions-rs/clippy-check@v1
|
||||
|
||||
Generated
+1
@@ -4,6 +4,7 @@
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/benches" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
+7
-1
@@ -12,7 +12,8 @@ categories = ["parsing"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
meta = ["dep:serde_json"]
|
||||
meta = ["dep:serde_json"] # `meta.json` parsing
|
||||
raw-payload = [] # save raw packet payloads, enabling increases memory usage and decreases performance
|
||||
|
||||
[dependencies]
|
||||
bytes = "1.4.0"
|
||||
@@ -28,3 +29,8 @@ zip = { version = "0.6.3", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1.0.66"
|
||||
criterion = "0.4.0"
|
||||
|
||||
[[bench]]
|
||||
name = "data"
|
||||
harness = false
|
||||
@@ -18,44 +18,20 @@ use wotbreplay_parser::models::battle_results::TeamNumber;
|
||||
use wotbreplay_parser::replay::Replay;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let battle_results = Replay::open(File::open("tests/replays/battle_results.wotbreplay")?)?
|
||||
.read_battle_results()?;
|
||||
let battle_results = Replay::open(File::open("replays/20221203_player_results.wotbreplay")?)?.read_battle_results()?;
|
||||
|
||||
assert_eq!(battle_results.timestamp, 1670018359);
|
||||
assert_eq!(battle_results.timestamp_secs, 1670083956);
|
||||
assert_eq!(battle_results.players.len(), 14);
|
||||
|
||||
assert_eq!(battle_results.players[0].account_id, 520886428);
|
||||
assert_eq!(battle_results.players[0].info.nickname, "77mmmr");
|
||||
assert_eq!(battle_results.players[0].info.team(), TeamNumber::Two);
|
||||
assert_eq!(battle_results.players[0].info.platoon_id, None);
|
||||
assert_eq!(battle_results.players[0].account_id, 595693744);
|
||||
assert_eq!(battle_results.players[0].info.nickname, "yuranhik_hustriy26");
|
||||
assert_eq!(battle_results.players[0].info.team(), TeamNumber::One);
|
||||
assert_eq!(battle_results.players[0].info.platoon_id, Some(545104609));
|
||||
|
||||
assert_eq!(battle_results.players[1].info.nickname, "SNAK_THE_RIPPER");
|
||||
assert_eq!(battle_results.players[1].info.team(), TeamNumber::One);
|
||||
assert_eq!(battle_results.players[1].info.platoon_id, Some(547466834));
|
||||
assert_eq!(battle_results.players[1].info.team(), TeamNumber::Two);
|
||||
assert_eq!(battle_results.players[1].info.platoon_id, Some(273692628));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Replay structure
|
||||
|
||||
`*.wotbreplay` is a ZIP-archive containing:
|
||||
- `battle_results.dat`
|
||||
- `meta.json`
|
||||
- `data.wotreplay`
|
||||
|
||||
### `battle_results.dat`
|
||||
|
||||
This is a [pickled](https://docs.python.org/3/library/pickle.html) 2-tuple:
|
||||
- Some big number
|
||||
- Battle results serialized with [Protocol Buffers](https://developers.google.com/protocol-buffers)
|
||||
|
||||
#### Useful tools
|
||||
|
||||
- [Protobuf Decoder](https://protobuf-decoder.netlify.app/)
|
||||
- [`protobuf-inspector`](https://github.com/mildsunrise/protobuf-inspector)
|
||||
- [Hex Fiend](https://hexfiend.com/)
|
||||
|
||||
### `meta.json`
|
||||
|
||||
### `data.wotreplay`
|
||||
@@ -0,0 +1,20 @@
|
||||
use std::fs::File;
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use wotbreplay_parser::replay::Replay;
|
||||
|
||||
fn bench_read_data(criterion: &mut Criterion) {
|
||||
let mut group = criterion.benchmark_group("read_data");
|
||||
for path in [
|
||||
"replays/20230429_0126__helaas_pindakaas.wotbreplay",
|
||||
"replays/20230503_0027__helaas_pindakaas.wotbreplay",
|
||||
"replays/20230429_1682194799_malinovka.wotbreplay",
|
||||
] {
|
||||
let mut replay = Replay::open(File::open(path).unwrap()).unwrap();
|
||||
group.bench_function(path, |bencher| bencher.iter(|| replay.read_data().unwrap()));
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_read_data);
|
||||
criterion_main!(benches);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -8,3 +8,4 @@ reorder_impl_items = true
|
||||
unstable_features = true
|
||||
use_field_init_shorthand = true
|
||||
version = "Two"
|
||||
struct_lit_width = 35
|
||||
+11
-7
@@ -6,10 +6,10 @@ pub enum Error {
|
||||
ZipError(#[from] zip::result::ZipError),
|
||||
|
||||
#[error("Protocol Buffers error")]
|
||||
DecodeFailed(#[from] prost::DecodeError),
|
||||
ProtobufError(#[from] prost::DecodeError),
|
||||
|
||||
#[error("Pickle error")]
|
||||
UnpickleFailed(#[from] serde_pickle::Error),
|
||||
PickleError(#[from] serde_pickle::Error),
|
||||
|
||||
#[error("I/O error")]
|
||||
IoError(#[from] std::io::Error),
|
||||
@@ -19,11 +19,15 @@ pub enum Error {
|
||||
|
||||
#[cfg(feature = "meta")]
|
||||
#[error("JSON error")]
|
||||
JsonDecodeFailed(#[from] serde_json::Error),
|
||||
JsonError(#[from] serde_json::Error),
|
||||
|
||||
#[error("invalid magic: {0:#X}, expected: {1:#X}")]
|
||||
InvalidMagic(u32, u32),
|
||||
#[error("invalid magic: {actual:#X}, expected: {expected:#X}")]
|
||||
InvalidMagic { expected: u32, actual: u32 },
|
||||
|
||||
#[error("failed to read a packet length, got {0} bytes")]
|
||||
PacketLengthError(usize),
|
||||
#[error("failed to parse type {type_} packet's payload at replay time {clock_secs}s")]
|
||||
PacketPayloadParsingError {
|
||||
source: Box<Error>,
|
||||
type_: u32,
|
||||
clock_secs: f32,
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use prost::bytes::Buf;
|
||||
use prost::Message;
|
||||
use prost::{Enumeration, Message};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::result::Result;
|
||||
@@ -7,7 +7,10 @@ use crate::result::Result;
|
||||
impl BattleResults {
|
||||
/// Parse battle results from the buffer.
|
||||
///
|
||||
/// Note, that this does NOT parse `battle_results.dat` itself, but the un-pickled tuple element.
|
||||
/// # Note
|
||||
///
|
||||
/// This can **not** parse `battle_results.dat` itself, but the un-pickled tuple element.
|
||||
/// To parse `battle_results.dat`, use [`crate::models::battle_results_dat::BattleResultsDat`].
|
||||
pub fn from_buffer(buffer: impl Buf) -> Result<Self> {
|
||||
Ok(Self::decode(buffer)?)
|
||||
}
|
||||
@@ -20,10 +23,10 @@ impl BattleResults {
|
||||
pub struct BattleResults {
|
||||
/// Battle timestamp.
|
||||
#[prost(int64, tag = "2")]
|
||||
pub timestamp: i64,
|
||||
pub timestamp_secs: i64,
|
||||
|
||||
#[prost(enumeration = "TeamNumber", tag = "3")]
|
||||
pub winner_team_number: i32,
|
||||
#[prost(enumeration = "TeamNumber", optional, tag = "3")]
|
||||
pub winner_team_number: Option<i32>,
|
||||
|
||||
/// Replay's author results.
|
||||
#[prost(message, required, tag = "8")]
|
||||
@@ -37,7 +40,7 @@ pub struct BattleResults {
|
||||
#[prost(message, repeated, tag = "201")]
|
||||
pub players: Vec<Player>,
|
||||
|
||||
/// Player's results.
|
||||
/// Individual player's results.
|
||||
#[prost(message, repeated, tag = "301")]
|
||||
pub player_results: Vec<PlayerResults>,
|
||||
}
|
||||
@@ -62,8 +65,9 @@ pub struct PlayerInfo {
|
||||
pub nickname: String,
|
||||
|
||||
/// Some sort of platoon ID:
|
||||
/// - contains same ID for a platoon members, or
|
||||
/// - `None` for non-platoon players
|
||||
///
|
||||
/// - contains same ID for the platoon members, or
|
||||
/// - [`None`] for non-platoon players
|
||||
#[prost(uint32, optional, tag = "2")]
|
||||
pub platoon_id: Option<u32>,
|
||||
|
||||
@@ -78,9 +82,7 @@ pub struct PlayerInfo {
|
||||
pub avatar: Avatar,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, prost::Enumeration, Serialize,
|
||||
)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Enumeration, Serialize)]
|
||||
#[repr(i32)]
|
||||
pub enum TeamNumber {
|
||||
One = 1,
|
||||
@@ -164,6 +166,8 @@ pub struct PlayerResultsInfo {
|
||||
///
|
||||
/// Note, that this is **not** the game client's displayed rating.
|
||||
/// This field matches the `mm_rating` as returned by the Wargaming.net API.
|
||||
///
|
||||
/// The display rating is calculated as: `3000.0 + mm_rating * 10.0`.
|
||||
#[prost(float, optional, tag = "107")]
|
||||
pub mm_rating: Option<f32>,
|
||||
|
||||
@@ -172,7 +176,7 @@ pub struct PlayerResultsInfo {
|
||||
}
|
||||
|
||||
impl PlayerResultsInfo {
|
||||
/// Returns displayed rating as per the game client.
|
||||
/// Calculate displayed rating according to the game client.
|
||||
pub fn display_rating(&self) -> Option<u32> {
|
||||
self.mm_rating
|
||||
.map(|mm_rating| (mm_rating * 10.0 + 3000.0) as u32)
|
||||
|
||||
@@ -8,7 +8,14 @@ use crate::error::Error;
|
||||
use crate::models::battle_results::BattleResults;
|
||||
use crate::result::Result;
|
||||
|
||||
/// Represents un-pickled `battle_results.dat`.
|
||||
/// Un-pickled `battle_results.dat`.
|
||||
///
|
||||
/// # `battle_results.dat` structure
|
||||
///
|
||||
/// Entire file is a [pickled](https://docs.python.org/3/library/pickle.html) 2-tuple:
|
||||
///
|
||||
/// - Arena unique ID
|
||||
/// - Battle results serialized with [Protocol Buffers](https://developers.google.com/protocol-buffers)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(from = "(u64, ByteBuf)")]
|
||||
pub struct BattleResultsDat {
|
||||
@@ -20,15 +27,12 @@ pub struct BattleResultsDat {
|
||||
|
||||
impl From<(u64, ByteBuf)> for BattleResultsDat {
|
||||
fn from((arena_unique_id, buffer): (u64, ByteBuf)) -> Self {
|
||||
Self {
|
||||
arena_unique_id,
|
||||
buffer,
|
||||
}
|
||||
Self { arena_unique_id, buffer }
|
||||
}
|
||||
}
|
||||
|
||||
impl BattleResultsDat {
|
||||
/// Parses the pickled structure from the reader which contains `battle_results.dat`.
|
||||
/// Parse the pickled structure from the reader which contains `battle_results.dat`.
|
||||
///
|
||||
/// It can be used either on the `battle_results.dat` for a replay archive,
|
||||
/// or directly on `**/DAVAProject/battle_results/*/*_full.dat`.
|
||||
@@ -40,7 +44,7 @@ impl BattleResultsDat {
|
||||
impl TryInto<BattleResults> for BattleResultsDat {
|
||||
type Error = Error;
|
||||
|
||||
/// Decodes the battle results from the internal buffer.
|
||||
/// Decode the battle results from the internal buffer.
|
||||
fn try_into(self) -> Result<BattleResults> {
|
||||
Ok(BattleResults::decode(self.buffer.as_ref())?)
|
||||
}
|
||||
|
||||
+34
-35
@@ -1,10 +1,9 @@
|
||||
//! `data.wotreplay` models.
|
||||
//! Models for `data.wotreplay` contents.
|
||||
|
||||
pub mod base_player_create;
|
||||
pub mod entity_method;
|
||||
pub mod packet;
|
||||
pub mod payload;
|
||||
pub mod replay_header;
|
||||
pub mod summary;
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
@@ -12,11 +11,7 @@ use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use self::entity_method::*;
|
||||
pub use self::summary::*;
|
||||
use crate::models::data::packet::Packet;
|
||||
use crate::models::data::payload::Payload;
|
||||
use crate::models::data::replay_header::ReplayHeader;
|
||||
use crate::result::Result;
|
||||
use crate::Error;
|
||||
|
||||
@@ -26,9 +21,7 @@ pub struct Data {
|
||||
/// For example: `9.8.5_apple`.
|
||||
pub client_version: String,
|
||||
|
||||
pub replay_header: Option<ReplayHeader>,
|
||||
|
||||
pub other_packets: Vec<Packet>,
|
||||
pub packets: Vec<Packet>,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
@@ -39,36 +32,19 @@ impl Data {
|
||||
reader.read_u64::<LittleEndian>()?;
|
||||
|
||||
// Some sort of client hash, e.g.: `6CF2A9EFA5C52D6F6CE43A6D4A699C05`:
|
||||
read_bytes(&mut reader)?;
|
||||
read_length_delimited(&mut reader)?;
|
||||
|
||||
let client_version = read_string(&mut reader)?;
|
||||
|
||||
// Some extra byte, no idea:
|
||||
reader.read_u8()?;
|
||||
|
||||
let mut this = Self::new(client_version);
|
||||
|
||||
loop {
|
||||
let Some(packet) = Packet::from_reader(&mut reader)? else { break };
|
||||
match packet.payload {
|
||||
Payload::ReplayHeader(header) => {
|
||||
this.replay_header = Some(header);
|
||||
}
|
||||
_ => {
|
||||
this.other_packets.push(packet);
|
||||
}
|
||||
}
|
||||
let mut packets = Vec::new();
|
||||
while let Some(packet) = Packet::from_reader(&mut reader)? {
|
||||
packets.push(packet);
|
||||
}
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
pub const fn new(client_version: String) -> Self {
|
||||
Self {
|
||||
client_version,
|
||||
replay_header: None,
|
||||
other_packets: Vec::new(),
|
||||
}
|
||||
Ok(Self { client_version, packets })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,12 +53,15 @@ fn assert_magic<T: Into<u32> + PartialEq>(actual: T, expected: T) -> Result {
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::InvalidMagic(actual.into(), expected.into()))
|
||||
Err(Error::InvalidMagic {
|
||||
actual: actual.into(),
|
||||
expected: expected.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_bytes(reader: &mut impl Read) -> Result<Vec<u8>> {
|
||||
fn read_length_delimited(reader: &mut impl Read) -> Result<Vec<u8>> {
|
||||
let length = reader.read_u8()? as usize;
|
||||
let mut buffer = Vec::new();
|
||||
buffer.resize(length, 0);
|
||||
@@ -92,7 +71,7 @@ fn read_bytes(reader: &mut impl Read) -> Result<Vec<u8>> {
|
||||
|
||||
#[inline]
|
||||
fn read_string(reader: &mut impl Read) -> Result<String> {
|
||||
Ok(String::from_utf8(read_bytes(reader)?)?)
|
||||
Ok(String::from_utf8(read_length_delimited(reader)?)?)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -102,3 +81,23 @@ fn read_pickled<T: DeserializeOwned>(reader: &mut impl Read, length: usize) -> R
|
||||
reader.read_exact(&mut buffer)?;
|
||||
Ok(serde_pickle::from_slice(&buffer, Default::default())?)
|
||||
}
|
||||
|
||||
/// Read 2-byte length surrounded by the magical `0xFF` and `0x00`,
|
||||
/// unless it's a simple 1-byte length.
|
||||
///
|
||||
/// # Context
|
||||
///
|
||||
/// It seems that for payloads larger than 254 bytes, Wargaming writes `00 <XX XX> FF`,
|
||||
/// where the word in the middle is the actual length of a payload, and the `00` and `FF`
|
||||
/// are magic values.
|
||||
#[inline]
|
||||
fn read_quirky_length(reader: &mut impl Read) -> Result<usize> {
|
||||
match reader.read_u8()? {
|
||||
0xFF => {
|
||||
let length = reader.read_u16::<LittleEndian>()?;
|
||||
assert_magic(reader.read_u8()?, 0x00)?;
|
||||
Ok(length as usize)
|
||||
}
|
||||
length => Ok(length as usize),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BasePlayerCreate {
|
||||
#[serde(default, rename(deserialize = "playersBattleCategoriesIds"))]
|
||||
pub players_battle_categories_ids: Option<HashMap<u32, (u8, u32)>>,
|
||||
|
||||
#[serde(rename(deserialize = "battleLevel"))]
|
||||
pub battle_level: u8,
|
||||
|
||||
#[serde(rename(deserialize = "battleCategoryId"))]
|
||||
pub battle_category_id: u8,
|
||||
|
||||
#[serde(rename(deserialize = "mouseEnabled"))]
|
||||
pub is_mouse_enabled: bool,
|
||||
|
||||
#[serde(rename(deserialize = "mmType"))]
|
||||
pub matchmaker_type: u8,
|
||||
|
||||
#[serde(rename(deserialize = "camouflageSlot"))]
|
||||
pub camouflage_slot: u8,
|
||||
|
||||
/// Average team ratings for rating battles.
|
||||
///
|
||||
/// # Contains
|
||||
///
|
||||
/// - [`Some`] 2-vector, if the attribute is present in the replay. Each element represents one team:
|
||||
/// - [`Some`] rating, if known
|
||||
/// - [`None`], if the average rating is unknown (all the players are calibrating)
|
||||
/// - [`None`], if the attribute is missing in the replay
|
||||
#[serde(default, rename(deserialize = "avgMmr"))]
|
||||
pub average_mm_ratings: Option<Vec<Option<f64>>>,
|
||||
|
||||
#[serde(default, rename(deserialize = "playerWaitTimes"))]
|
||||
pub player_wait_times: Option<HashMap<u32, f64>>,
|
||||
|
||||
#[serde(rename(deserialize = "accountDatabaseIds"))]
|
||||
pub account_database_ids: Vec<u32>,
|
||||
|
||||
#[serde(default, rename(deserialize = "turboBattlesStats"))]
|
||||
pub turbo_battles_statistics: Option<TurboBattlesStatistics>,
|
||||
}
|
||||
|
||||
/// I don't know what that is:
|
||||
///
|
||||
/// - On EU, Asia, and NA the values are just constants
|
||||
/// - On Russia, the `alpha` and `beta` are different constants, and the chances actually differ for different replays
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TurboBattlesStatistics {
|
||||
#[serde(rename(deserialize = "turboBattleChance"))]
|
||||
pub battle_chance: f64,
|
||||
|
||||
#[serde(rename(deserialize = "turboLeaveChance"))]
|
||||
pub leave_chance: f64,
|
||||
|
||||
#[serde(rename(deserialize = "possibleTurboBattlesNotForSkip"))]
|
||||
pub possible_battles_not_for_skip: u32,
|
||||
|
||||
#[serde(rename(deserialize = "possibleTurboBattlesForSkip"))]
|
||||
pub possible_battles_for_skip: u32,
|
||||
|
||||
#[serde(rename(deserialize = "turboBattlesForSkip"))]
|
||||
pub battles_for_skip: u32,
|
||||
|
||||
#[serde(rename(deserialize = "turboBattlesNotForSkip"))]
|
||||
pub battles_not_for_skip: u32,
|
||||
|
||||
#[serde(rename(deserialize = "abSkip"))]
|
||||
pub ab_skip: i32,
|
||||
|
||||
#[serde(rename(deserialize = "beta"))]
|
||||
pub beta: f64,
|
||||
|
||||
#[serde(rename(deserialize = "premiumVehDiff"))]
|
||||
pub premium_vehicle_difference: i32,
|
||||
|
||||
#[serde(rename(deserialize = "htDiff"))]
|
||||
pub ht_difference: i32,
|
||||
|
||||
#[serde(rename(deserialize = "alpha"))]
|
||||
pub alpha: f64,
|
||||
}
|
||||
@@ -1,35 +1,51 @@
|
||||
//! Structures for «entity method» call packets.
|
||||
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use prost::encoding::decode_varint;
|
||||
use prost::Message;
|
||||
use serde::Serialize;
|
||||
use serde_with::serde_as;
|
||||
|
||||
use self::update_arena::UpdateArena;
|
||||
use crate::models::data::read_quirky_length;
|
||||
use crate::result::Result;
|
||||
|
||||
pub mod update_arena;
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum EntityMethod {
|
||||
/// TODO: team composition.
|
||||
Subtype2F,
|
||||
|
||||
/// Default variant when subtype is not known.
|
||||
Unknown {
|
||||
sub_type: u32,
|
||||
|
||||
/// Whole packet payload (including the sub-type).
|
||||
#[serde_as(as = "serde_with::hex::Hex")]
|
||||
payload: Vec<u8>,
|
||||
/// Subtype 47.
|
||||
UpdateArena {
|
||||
field_number: u64,
|
||||
arguments: UpdateArena,
|
||||
},
|
||||
|
||||
/// Default variant when subtype is not known by the parser.
|
||||
Unknown { sub_type: u32 },
|
||||
}
|
||||
|
||||
impl EntityMethod {
|
||||
pub fn new(payload: Vec<u8>) -> Result<Self> {
|
||||
let mut reader = payload.as_slice();
|
||||
/// Parse the entity method raw payload.
|
||||
pub fn new(raw_payload: &[u8]) -> Result<Self> {
|
||||
let mut raw_payload = raw_payload;
|
||||
|
||||
reader.read_u32::<LittleEndian>()?;
|
||||
let sub_type = reader.read_u32::<LittleEndian>()?;
|
||||
raw_payload.read_u32::<LittleEndian>()?;
|
||||
let sub_type = raw_payload.read_u32::<LittleEndian>()?;
|
||||
|
||||
let this = match sub_type {
|
||||
0x2F => Self::Subtype2F,
|
||||
_ => Self::Unknown { sub_type, payload },
|
||||
47 => {
|
||||
let _inner_length = raw_payload.read_u32::<LittleEndian>()?;
|
||||
|
||||
let field_number = decode_varint(&mut raw_payload)?;
|
||||
let message_length = read_quirky_length(&mut raw_payload)?;
|
||||
Self::UpdateArena {
|
||||
field_number,
|
||||
arguments: UpdateArena::decode(&raw_payload[..message_length])?,
|
||||
}
|
||||
}
|
||||
|
||||
_ => Self::Unknown { sub_type },
|
||||
};
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use prost::Message;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Message, Serialize)]
|
||||
pub struct UpdateArena {
|
||||
#[prost(message, tag = "1")]
|
||||
pub players: Option<Players>,
|
||||
}
|
||||
|
||||
#[derive(Message, Serialize)]
|
||||
pub struct Players {
|
||||
#[prost(message, repeated, tag = "1")]
|
||||
pub players: Vec<Player>,
|
||||
}
|
||||
|
||||
#[derive(Message, Serialize)]
|
||||
pub struct Player {
|
||||
#[prost(string, required, tag = "3")]
|
||||
pub nickname: String,
|
||||
|
||||
#[prost(uint32, required, tag = "4")]
|
||||
pub team_number: u32,
|
||||
|
||||
#[prost(uint32, required, tag = "7")]
|
||||
pub account_id: u32,
|
||||
|
||||
#[prost(string, optional, tag = "8")]
|
||||
pub clan_tag: Option<String>,
|
||||
|
||||
#[prost(uint32, optional, tag = "11")]
|
||||
pub platoon_number: Option<u32>,
|
||||
}
|
||||
@@ -2,28 +2,61 @@ use std::io::Read;
|
||||
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use serde::Serialize;
|
||||
use serde_with::serde_as;
|
||||
|
||||
use crate::models::data::payload::Payload;
|
||||
use crate::result::Result;
|
||||
use crate::Error;
|
||||
|
||||
/// «Packet» is a single traffic message in a replay,
|
||||
/// packets tell us what is going on during the battle.
|
||||
#[serde_as]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Packet {
|
||||
pub clock: f32,
|
||||
/// Replay time, starting from zero.
|
||||
#[serde(rename = "clock")]
|
||||
pub clock_secs: f32,
|
||||
|
||||
/// Parsed packet payload.
|
||||
pub payload: Payload,
|
||||
|
||||
/// Raw packet payload, excluding the clock and packet type, and including optional subtype.
|
||||
///
|
||||
/// This attribute requires the `raw-payload` feature.
|
||||
#[cfg(feature = "raw-payload")]
|
||||
#[serde_as(as = "serde_with::hex::Hex")]
|
||||
pub raw_payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Packet {
|
||||
/// Parse next packet from the reader.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Parsed packet, or [`None`], when no packets are left.
|
||||
pub fn from_reader(reader: &mut impl Read) -> Result<Option<Self>> {
|
||||
let Ok(length) = reader.read_u32::<LittleEndian>() else { return Ok(None) };
|
||||
let type_ = reader.read_u32::<LittleEndian>()?;
|
||||
let clock = reader.read_f32::<LittleEndian>()?;
|
||||
let payload = Self::read_payload(reader, length as usize)?;
|
||||
let payload = Payload::new(type_, payload)?;
|
||||
let this = Self { clock, payload };
|
||||
let clock_secs = reader.read_f32::<LittleEndian>()?;
|
||||
let raw_payload = Self::read_raw_payload(reader, length as usize)?;
|
||||
let payload = Payload::new(type_, &raw_payload).map_err(|source| {
|
||||
Error::PacketPayloadParsingError {
|
||||
source: Box::new(source),
|
||||
type_,
|
||||
clock_secs,
|
||||
}
|
||||
})?;
|
||||
let this = Self {
|
||||
clock_secs,
|
||||
payload,
|
||||
|
||||
#[cfg(feature = "raw-payload")]
|
||||
raw_payload,
|
||||
};
|
||||
Ok(Some(this))
|
||||
}
|
||||
|
||||
fn read_payload(reader: &mut impl Read, length: usize) -> Result<Vec<u8>> {
|
||||
fn read_raw_payload(reader: &mut impl Read, length: usize) -> Result<Vec<u8>> {
|
||||
let mut buffer = Vec::new();
|
||||
buffer.resize(length, 0);
|
||||
reader.read_exact(&mut buffer)?;
|
||||
|
||||
+33
-32
@@ -4,56 +4,57 @@ use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use serde::Serialize;
|
||||
use serde_with::serde_as;
|
||||
|
||||
use crate::models::data::replay_header::ReplayHeader;
|
||||
use crate::models::data::{assert_magic, read_pickled, read_string, EntityMethod};
|
||||
use crate::models::data::base_player_create::BasePlayerCreate;
|
||||
use crate::models::data::entity_method::EntityMethod;
|
||||
use crate::models::data::{read_pickled, read_quirky_length, read_string};
|
||||
use crate::result::Result;
|
||||
|
||||
/// Packet payload.
|
||||
#[serde_as]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum Payload {
|
||||
ReplayHeader(ReplayHeader),
|
||||
/// Type 0.
|
||||
BasePlayerCreate {
|
||||
author_nickname: String,
|
||||
arena_unique_id: u64,
|
||||
arena_type_id: u32,
|
||||
arguments: Box<BasePlayerCreate>,
|
||||
},
|
||||
|
||||
/// Type 8.
|
||||
EntityMethod(EntityMethod),
|
||||
|
||||
/// Default payload when type is not known.
|
||||
Unknown {
|
||||
type_: u32,
|
||||
|
||||
/// Whole packet payload.
|
||||
#[serde_as(as = "serde_with::hex::Hex")]
|
||||
raw: Vec<u8>,
|
||||
},
|
||||
/// Default payload when type is not known by the parser.
|
||||
Unknown { packet_type: u32 },
|
||||
}
|
||||
|
||||
impl Payload {
|
||||
pub fn new(type_: u32, payload: Vec<u8>) -> Result<Self> {
|
||||
let this = match type_ {
|
||||
0 => {
|
||||
let mut reader = payload.as_slice();
|
||||
reader.read_exact(&mut [0; 10])?;
|
||||
/// Parse the raw packet payload.
|
||||
pub fn new(packet_type: u32, raw_payload: &[u8]) -> Result<Self> {
|
||||
let mut raw_payload = raw_payload;
|
||||
|
||||
let author_nickname = read_string(&mut reader)?;
|
||||
let arena_unique_id = reader.read_u64::<LittleEndian>()?;
|
||||
let arena_type_id = reader.read_u32::<LittleEndian>()?;
|
||||
let summary = {
|
||||
assert_magic(reader.read_u8()?, 0xFF)?;
|
||||
let pickled_length = reader.read_u16::<LittleEndian>()?;
|
||||
assert_magic(reader.read_u8()?, 0x00)?;
|
||||
read_pickled(&mut reader, pickled_length as usize)?
|
||||
let this = match packet_type {
|
||||
0 => {
|
||||
raw_payload.read_exact(&mut [0; 10])?;
|
||||
|
||||
let author_nickname = read_string(&mut raw_payload)?;
|
||||
let arena_unique_id = raw_payload.read_u64::<LittleEndian>()?;
|
||||
let arena_type_id = raw_payload.read_u32::<LittleEndian>()?;
|
||||
let arguments = {
|
||||
let pickled_length = read_quirky_length(&mut raw_payload)?;
|
||||
read_pickled(&mut raw_payload, pickled_length)?
|
||||
};
|
||||
Self::ReplayHeader(ReplayHeader {
|
||||
summary,
|
||||
Self::BasePlayerCreate {
|
||||
arguments,
|
||||
author_nickname,
|
||||
arena_unique_id,
|
||||
arena_type_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
8 => Self::EntityMethod(EntityMethod::new(payload)?),
|
||||
8 => Self::EntityMethod(EntityMethod::new(raw_payload)?),
|
||||
|
||||
_ => Self::Unknown {
|
||||
raw: payload,
|
||||
type_,
|
||||
},
|
||||
_ => Self::Unknown { packet_type },
|
||||
};
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::models::data::Summary;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReplayHeader {
|
||||
pub summary: Summary,
|
||||
pub author_nickname: String,
|
||||
pub arena_unique_id: u64,
|
||||
pub arena_type_id: u32,
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Summary {
|
||||
#[serde(rename = "playersBattleCategoriesIds")]
|
||||
pub players_battle_categories_ids: HashMap<u32, (u8, u32)>,
|
||||
|
||||
#[serde(rename = "battleLevel")]
|
||||
pub battle_level: u8,
|
||||
|
||||
#[serde(rename = "battleCategoryId")]
|
||||
pub battle_category_id: u8,
|
||||
|
||||
#[serde(rename = "mouseEnabled")]
|
||||
pub is_mouse_enabled: bool,
|
||||
|
||||
#[serde(rename = "mmType")]
|
||||
pub matchmaker_type: u8,
|
||||
|
||||
#[serde(rename = "camouflageSlot")]
|
||||
pub camouflage_slot: u8,
|
||||
|
||||
#[serde(rename = "avgMmr")]
|
||||
pub average_mmr: Vec<f64>,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
//! Models for `meta.json` contents.
|
||||
#![cfg(feature = "meta")]
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
+14
-4
@@ -9,31 +9,41 @@ use crate::models::data::Data;
|
||||
use crate::models::meta::Meta;
|
||||
use crate::result::Result;
|
||||
|
||||
/// Opened (but not yet parsed) replay.
|
||||
/// World of Tanks Blitz replay.
|
||||
///
|
||||
/// # Replay structure
|
||||
///
|
||||
/// `*.wotbreplay` is a ZIP-archive containing:
|
||||
///
|
||||
/// - `battle_results.dat`
|
||||
/// - `meta.json`
|
||||
/// - `data.wotreplay`
|
||||
pub struct Replay<R>(pub(crate) ZipArchive<R>);
|
||||
|
||||
impl<R: Read + Seek> Replay<R> {
|
||||
/// Opens a replay from the reader which contains a `*.wotbreplay`.
|
||||
/// Open a replay from the reader which contains a `*.wotbreplay`.
|
||||
pub fn open(reader: R) -> Result<Self> {
|
||||
Ok(Self(ZipArchive::new(reader)?))
|
||||
}
|
||||
|
||||
/// Reads and parses the battle results from the replay.
|
||||
/// Read and parse the full battle results structure from `battle_results.dat`.
|
||||
pub fn read_battle_results(&mut self) -> Result<BattleResults> {
|
||||
self.read_battle_results_dat()?.try_into()
|
||||
}
|
||||
|
||||
/// Reads and parses the included `battle_results.dat`.
|
||||
/// Read and parse the root 2-tuple from `battle_results.dat`.
|
||||
pub fn read_battle_results_dat(&mut self) -> Result<BattleResultsDat> {
|
||||
let pickled_battle_results = self.0.by_name("battle_results.dat")?;
|
||||
BattleResultsDat::from_reader(pickled_battle_results)
|
||||
}
|
||||
|
||||
/// Read and parse the included `meta.json`.
|
||||
#[cfg(feature = "meta")]
|
||||
pub fn read_meta(&mut self) -> Result<Meta> {
|
||||
Meta::from_reader(self.0.by_name("meta.json")?)
|
||||
}
|
||||
|
||||
/// Read and parse the included `data.wotreplay`.
|
||||
pub fn read_data(&mut self) -> Result<Data> {
|
||||
Data::from_reader(self.0.by_name("data.wotreplay")?)
|
||||
}
|
||||
|
||||
+17
-15
@@ -6,13 +6,20 @@ use wotbreplay_parser::replay::Replay;
|
||||
|
||||
#[test]
|
||||
fn player_results_ok() -> Result<()> {
|
||||
let battle_results_dat = Replay::open(File::open("tests/replays/player_results.wotbreplay")?)?
|
||||
.read_battle_results_dat()?;
|
||||
let battle_results_dat =
|
||||
Replay::open(File::open("replays/20221203_player_results.wotbreplay")?)?
|
||||
.read_battle_results_dat()?;
|
||||
assert_eq!(battle_results_dat.arena_unique_id, 1661909200500084);
|
||||
let battle_results: BattleResults = battle_results_dat.try_into()?;
|
||||
|
||||
assert_eq!(battle_results.winner_team_number, 2);
|
||||
assert_eq!(battle_results.winner_team_number, Some(2));
|
||||
|
||||
assert_eq!(battle_results.author.team_number, 2);
|
||||
assert_eq!(battle_results.author.credits_earned, 59518);
|
||||
assert_eq!(battle_results.author.combat_xp, 5255);
|
||||
assert_eq!(battle_results.author.damage_dealt, 1438);
|
||||
assert_eq!(battle_results.author.account_id, 594778041);
|
||||
assert_eq!(battle_results.free_xp, 260);
|
||||
|
||||
let snak_the_ripper_info = &battle_results.player_results[3].info;
|
||||
assert_eq!(snak_the_ripper_info.base_xp, 929);
|
||||
@@ -74,7 +81,7 @@ fn player_results_ok() -> Result<()> {
|
||||
|
||||
#[test]
|
||||
fn player_info_ok() -> Result<()> {
|
||||
let battle_results = Replay::open(File::open("tests/replays/player_results.wotbreplay")?)?
|
||||
let battle_results = Replay::open(File::open("replays/20221203_player_results.wotbreplay")?)?
|
||||
.read_battle_results()?;
|
||||
|
||||
assert_eq!(battle_results.players[1].info.clan_tag.as_deref(), Some("AN0NY"));
|
||||
@@ -97,7 +104,7 @@ fn player_info_ok() -> Result<()> {
|
||||
|
||||
#[test]
|
||||
fn victory_points_ok() -> Result<()> {
|
||||
let battle_results = Replay::open(File::open("tests/replays/victory_points.wotbreplay")?)?
|
||||
let battle_results = Replay::open(File::open("replays/20221203_victory_points.wotbreplay")?)?
|
||||
.read_battle_results()?;
|
||||
|
||||
assert_eq!(battle_results.player_results[1].info.victory_points_seized, 40);
|
||||
@@ -113,15 +120,10 @@ fn victory_points_ok() -> Result<()> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_results_ok() -> Result<()> {
|
||||
let battle_results = Replay::open(File::open("tests/replays/author_results.wotbreplay")?)?
|
||||
.read_battle_results()?;
|
||||
|
||||
assert_eq!(battle_results.author.credits_earned, 53623);
|
||||
assert_eq!(battle_results.author.combat_xp, 2370);
|
||||
assert_eq!(battle_results.author.damage_dealt, 1738);
|
||||
assert_eq!(battle_results.author.account_id, 594778041);
|
||||
assert_eq!(battle_results.free_xp, 116);
|
||||
|
||||
fn draw_ok() -> Result<()> {
|
||||
let battle_results =
|
||||
Replay::open(File::open("replays/20230503_0027__helaas_pindakaas.wotbreplay")?)?
|
||||
.read_battle_results()?;
|
||||
assert_eq!(battle_results.winner_team_number, None);
|
||||
Ok(())
|
||||
}
|
||||
+89
-15
@@ -1,26 +1,100 @@
|
||||
use std::fs::File;
|
||||
|
||||
use anyhow::Result;
|
||||
use wotbreplay_parser::models::data::entity_method::EntityMethod;
|
||||
use wotbreplay_parser::models::data::payload::Payload;
|
||||
use wotbreplay_parser::replay::Replay;
|
||||
|
||||
#[test]
|
||||
fn data_ok() -> Result<()> {
|
||||
let mut replay = Replay::open(File::open("tests/replays/player_results.wotbreplay")?)?;
|
||||
fn parse_ok() -> Result<()> {
|
||||
let mut replay =
|
||||
Replay::open(File::open("replays/20230429_0126__helaas_pindakaas.wotbreplay")?)?;
|
||||
let data = replay.read_data()?;
|
||||
|
||||
assert_eq!(data.client_version, "9.4.0_apple");
|
||||
assert_eq!(data.other_packets.len(), 44720);
|
||||
|
||||
let header = data.replay_header.unwrap();
|
||||
assert_eq!(header.author_nickname, "zeekrab");
|
||||
assert_eq!(header.arena_unique_id, 1661909200500084);
|
||||
assert_eq!(header.arena_type_id, 65567);
|
||||
assert_eq!(header.summary.battle_level, 8);
|
||||
assert_eq!(header.summary.battle_category_id, 13);
|
||||
assert!(header.summary.is_mouse_enabled);
|
||||
assert_eq!(header.summary.matchmaker_type, 2);
|
||||
assert_eq!(header.summary.camouflage_slot, 1);
|
||||
assert_eq!(header.summary.average_mmr, [-25.396859946846963, 77.58806455880404]);
|
||||
assert_eq!(data.client_version, "9.8.5_apple");
|
||||
assert_eq!(data.packets.len(), 60139);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_player_create_ok() -> Result<()> {
|
||||
let mut replay =
|
||||
Replay::open(File::open("replays/20230429_0126__helaas_pindakaas.wotbreplay")?)?;
|
||||
let data = replay.read_data()?;
|
||||
|
||||
let (author_nickname, arena_unique_id, arena_type_id, arguments) = data
|
||||
.packets
|
||||
.iter()
|
||||
.filter_map(|packet| {
|
||||
if let Payload::BasePlayerCreate {
|
||||
author_nickname,
|
||||
arena_unique_id,
|
||||
arena_type_id,
|
||||
arguments: type_0,
|
||||
} = &packet.payload
|
||||
{
|
||||
Some((author_nickname, arena_unique_id, arena_type_id, type_0))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(author_nickname, "helaas_pindakaas");
|
||||
assert_eq!(*arena_unique_id, 16114615898101270);
|
||||
assert_eq!(*arena_type_id, 43);
|
||||
assert_eq!(arguments.battle_level, 9);
|
||||
assert_eq!(arguments.battle_category_id, 13);
|
||||
assert!(arguments.is_mouse_enabled);
|
||||
assert_eq!(arguments.matchmaker_type, 2);
|
||||
assert_eq!(arguments.camouflage_slot, 1);
|
||||
assert_eq!(
|
||||
arguments.average_mm_ratings,
|
||||
Some(vec![Some(59.29457753400008), Some(99.34182121604681)])
|
||||
);
|
||||
assert_eq!(
|
||||
arguments.account_database_ids,
|
||||
[
|
||||
517033644, 537867068, 597583401, 601893865, 576810156, 531053956, 599580824, 558025185,
|
||||
526927603, 594778041, 505587490, 597422303, 531923111, 587617355
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_arena_ok() -> Result<()> {
|
||||
let mut replay =
|
||||
Replay::open(File::open("replays/20230429_0126__helaas_pindakaas.wotbreplay")?)?;
|
||||
let data = replay.read_data()?;
|
||||
|
||||
let players = data
|
||||
.packets
|
||||
.iter()
|
||||
.filter_map(|packet| match &packet.payload {
|
||||
Payload::EntityMethod(EntityMethod::UpdateArena { arguments, .. }) => {
|
||||
arguments.players.as_ref()
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(players.players.len(), 14);
|
||||
assert_eq!(players.players[1].team_number, 1);
|
||||
assert_eq!(players.players[1].nickname, "Igrok_WoT_blitz");
|
||||
assert_eq!(players.players[1].account_id, 597583401);
|
||||
assert_eq!(players.players[1].platoon_number, None);
|
||||
assert_eq!(players.players[1].clan_tag, Some("F_ANG".to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_room_ok() -> Result<()> {
|
||||
let mut replay = Replay::open(File::open("replays/20230503_training_room.wotbreplay")?)?;
|
||||
replay.read_data()?;
|
||||
Ok(())
|
||||
}
|
||||
+2
-1
@@ -7,7 +7,8 @@ use wotbreplay_parser::replay::Replay;
|
||||
|
||||
#[test]
|
||||
fn meta_ok() -> Result<()> {
|
||||
let meta = Replay::open(File::open("tests/replays/player_results.wotbreplay")?)?.read_meta()?;
|
||||
let meta =
|
||||
Replay::open(File::open("replays/20221203_player_results.wotbreplay")?)?.read_meta()?;
|
||||
|
||||
assert_eq!(meta.player_name, "zeekrab");
|
||||
assert_eq!(meta.arena_unique_id, "1661909200500084");
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user