Compare commits

..
Author SHA1 Message Date
eigenein 59353a9aca Break: fix winner_team_number for a draw 2023-05-03 00:48:10 +02:00
eigenein c9a2adff4c Break: rename subtype 2F to «update arena» 2023-05-03 00:41:45 +02:00
eigenein 72165ae52c Break: decode the players packet 2023-04-30 22:13:51 +02:00
eigenein e48fe1dd0d Break: refactor the packet payloads 2023-04-30 19:32:35 +02:00
eigenein 9d601082d6 New: temporarily dump Subtype2F payload in order to investigate it 2023-04-30 18:57:01 +02:00
eigenein 632f1781e7 Fix: average_mmr may be optional 2023-04-29 23:33:28 +02:00
eigenein fa6ac70d21 New: add TurboBattlesStatistics 2023-04-29 22:52:24 +02:00
eigenein 1fb4bea267 Fix: include raw payload in Payload::Unknown as well 2023-04-29 22:32:46 +02:00
eigenein 705280fc19 Fix: serialize EntityMethod::Unknown::payload as hex 2023-04-29 22:29:52 +02:00
eigenein 3f557e33bf Fix: doctests 2023-04-29 22:20:46 +02:00
eigenein fb5f52038d Break: read replay packets 2023-04-29 22:17:12 +02:00
eigenein 615537ce45 Break: read some of data.wotreplay 2023-04-29 19:05:40 +02:00
eigenein 8e96f4847b Break: make meta optional 2023-04-28 16:22:10 +02:00
eigenein 14148c7410 Break: add initial meta.json support 2023-04-24 22:38:23 +02:00
eigenein 903cd00f32 Break: support arena_unique_id 2023-04-24 22:13:03 +02:00
eigenein e0ce6a22b1 Opt: add the docstrings and TODO 2023-04-20 01:24:07 +02:00
eigenein fb9beb5ecc Fix: remove the explicit cargo login call 2023-04-17 00:16:23 +02:00
eigenein 06de455795 Fix: workflow status badge 2023-04-17 00:11:52 +02:00
eigenein ac256d4c9d New: add Author::team_number 2023-04-17 00:05:03 +02:00
eigenein e853224017 Break: rename parse into from_buffer 2023-04-16 23:09:05 +02:00
eigenein 04420da334 New: impl TryInto<BattleResults> for BattleResultsDat 2023-04-16 23:05:41 +02:00
eigenein cd933f6584 Opt: chores 2023-04-16 21:35:20 +02:00
eigenein ba28620be7 New: add AvatarInfo 2022-12-07 15:40:34 +01:00
eigenein 3913046be7 Fix: forgotten AccountId leftover 2022-12-07 15:22:11 +01:00
eigenein de6deb3a06 Break: extract the re-imports to prelude 2022-12-07 15:21:21 +01:00
eigenein e1d9c27bda Break: rename author_results into author and add author.account_id 2022-12-07 14:17:23 +01:00
eigenein d2fb61425a New: initial author_results 2022-12-06 19:45:01 +01:00
eigenein 93cea446f5 New: add winning_team 2022-12-06 19:08:56 +01:00
eigenein 8d45bb28ed Opt: add .gitattributes 2022-12-06 01:14:01 +01:00
eigenein 1e0ce05c18 New: derive Serialize on battle results 2022-12-05 22:26:47 +01:00
eigenein f63ba599ec Fix: improved the docstring 2022-12-05 20:11:00 +01:00
eigenein f619ffd93e New: add LICENSE 2022-12-05 19:47:14 +01:00
Pavel Perestoronin 7d99456aaa Opt: add the docstrings, make the BattleResultsDat's fields public 2022-12-05 16:03:26 +01:00
Pavel Perestoronin be4a6c21b5 New: extract Replay::read_battle_results_dat 2022-12-05 15:50:13 +01:00
27 changed files with 750 additions and 84 deletions

No files matched your search

+18 -6
View File
@@ -20,32 +20,44 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- name: 📥 Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up the toolchain
- name: ✨ Set up the toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
components: rustfmt, clippy
- name: Rust Cache
- name: ⚡️ Rust Cache
uses: Swatinem/rust-cache@v1
with:
cache-on-failure: true
- name: Test
- name: 🧪 Test without optional features
uses: actions-rs/cargo@v1
with:
command: test
args: --doc
args: --all-targets
- name: Clippy
- name: 🧪 Test
uses: actions-rs/cargo@v1
with:
command: test
args: --all-features
- name: 🪄 Clippy
uses: actions-rs/clippy-check@v1
continue-on-error: true
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-targets --all-features --tests -- -D warnings
- name: 📤 Publish the crates (dry run)
run: |
cargo publish --allow-dirty --dry-run
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+5 -6
View File
@@ -15,12 +15,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
- name: 📥 Checkout the repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set the versions
- name: 🪄 Set the versions
run: |
sed -i -e 's/version = "0\.0\.0"/version = "${{ github.ref_name }}"/g' Cargo.toml
- name: Rustup
@@ -29,13 +29,13 @@ jobs:
profile: minimal
toolchain: stable
- name: Build the changelog
- name: 📝 Build the changelog
id: changelog
uses: Bullrich/generate-release-changelog@master
env:
REPO: ${{ github.repository }}
- name: Update the release
- name: 🪄 Update the release
uses: tubone24/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -43,9 +43,8 @@ jobs:
release_name: ${{ github.ref_name }}
body: ${{ steps.changelog.outputs.changelog }}
- name: Publish the crates
- name: 📤 Publish the crates
run: |
cargo login
cargo publish --allow-dirty
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+5
View File
@@ -12,12 +12,17 @@ categories = ["parsing"]
[features]
default = []
meta = ["dep:serde_json"]
[dependencies]
bytes = "1.4.0"
byteorder = "1.4.3"
prost = "0.11.3"
serde = { version = "1.0.148", features = ["derive"] }
serde_bytes = "0.11.7"
serde-pickle = "1.1.1"
serde_json = { version = "1.0.96", optional = true }
serde_with = { version = "2.3.3", features = ["hex"] }
thiserror = "1.0.37"
zip = { version = "0.6.3", default-features = false, features = ["deflate"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Pavel Perestoronin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -11
View File
@@ -4,7 +4,7 @@ World of Tanks Blitz replay parser in Rust.
[![Crates.io](https://img.shields.io/crates/v/wotbreplay-parser)](https://crates.io/crates/wotbreplay-parser)
[![Last commit](https://img.shields.io/github/last-commit/eigenein/wotbreplay-parser)](https://github.com/eigenein/wotbreplay-parser/commits/main)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/eigenein/wotbreplay-parser/Check)](https://github.com/eigenein/wotbreplay-parser/actions)
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/eigenein/wotbreplay-parser/check.yaml)](https://github.com/eigenein/wotbreplay-parser/actions)
![License: MIT](https://img.shields.io/crates/l/wotbreplay-parser)
[![docs.rs](https://img.shields.io/docsrs/wotbreplay-parser)](https://docs.rs/wotbreplay-parser)
@@ -14,24 +14,24 @@ World of Tanks Blitz replay parser in Rust.
use std::fs::File;
use anyhow::Result;
use wotbreplay_parser::Replay;
use wotbreplay_parser::models::TeamNumber;
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")?)?
let battle_results = Replay::open(File::open("tests/replays/player_results.wotbreplay")?)?
.read_battle_results()?;
assert_eq!(battle_results.timestamp, 1670018359);
assert_eq!(battle_results.timestamp, 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_number(), 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_number(), 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(())
}
+1
View File
@@ -8,3 +8,4 @@ reorder_impl_items = true
unstable_features = true
use_field_init_shorthand = true
version = "Two"
struct_lit_width = 35
+20 -10
View File
@@ -2,18 +2,28 @@ use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("failed to open the ZIP-archive")]
OpenArchiveFailed(#[source] zip::result::ZipError),
#[error("ZIP error")]
ZipError(#[from] zip::result::ZipError),
#[error("failed to open the battle results entry in the replay archive")]
OpenBattleResultsFailed(#[source] zip::result::ZipError),
#[error("Protocol Buffers error")]
ProtobufError(#[from] prost::DecodeError),
#[error("failed to read the archive entry")]
ReadEntryFailed(#[source] std::io::Error),
#[error("Pickle error")]
PickleError(#[from] serde_pickle::Error),
#[error("failed to decode")]
DecodeFailed(#[source] prost::DecodeError),
#[error("I/O error")]
IoError(#[from] std::io::Error),
#[error("failed to un-pickle")]
UnpickleFailed(#[source] serde_pickle::Error),
#[error("UTF-8 error")]
StringDecodeError(#[from] std::string::FromUtf8Error),
#[cfg(feature = "meta")]
#[error("JSON error")]
JsonError(#[from] serde_json::Error),
#[error("invalid magic: {0:#X}, expected: {1:#X}")]
InvalidMagic(u32, u32),
#[error("failed to read a packet length, got {0} bytes")]
PacketLengthError(usize),
}
+9 -9
View File
@@ -2,22 +2,22 @@
#![warn(
clippy::all,
clippy::missing_const_for_fn,
clippy::trivially_copy_pass_by_ref,
clippy::map_unwrap_or,
clippy::explicit_into_iter_loop,
clippy::unused_self,
clippy::needless_pass_by_value
clippy::manual_let_else,
clippy::map_unwrap_or,
clippy::missing_const_for_fn,
clippy::needless_pass_by_value,
clippy::trivially_copy_pass_by_ref,
clippy::unused_self
)]
mod error;
pub mod models;
mod replay;
pub mod replay;
mod result;
pub use crate::error::Error;
pub use crate::replay::Replay;
pub use crate::result::Result;
pub use self::error::Error;
pub use self::result::Result;
#[cfg(doctest)]
mod test_readme {
+4 -5
View File
@@ -1,5 +1,4 @@
mod battle_results;
mod battle_results_dat;
pub use self::battle_results::*;
pub use self::battle_results_dat::*;
pub mod battle_results;
pub mod battle_results_dat;
pub mod data;
pub mod meta;
+74 -12
View File
@@ -1,25 +1,38 @@
use prost::bytes::Buf;
use prost::Message;
use prost::{Enumeration, Message};
use serde::Serialize;
use crate::error::Error;
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.
pub fn parse(buffer: impl Buf) -> Result<Self> {
Self::decode(buffer).map_err(Error::DecodeFailed)
pub fn from_buffer(buffer: impl Buf) -> Result<Self> {
Ok(Self::decode(buffer)?)
}
}
/// Parsed battle results.
#[derive(Message)]
///
/// TODO: 150[0].21[0].8[0].801[0].unsigned is arena end time.
#[derive(Message, Serialize)]
pub struct BattleResults {
/// Battle timestamp.
#[prost(int64, tag = "2")]
pub timestamp: i64,
#[prost(enumeration = "WinnerTeamNumber", tag = "3")]
pub winner_team_number: i32,
/// Replay's author results.
#[prost(message, required, tag = "8")]
pub author: Author,
/// Author's free XP, including premium.
#[prost(uint32, tag = "137")]
pub free_xp: u32,
/// Players in the battle.
#[prost(message, repeated, tag = "201")]
pub players: Vec<Player>,
@@ -30,7 +43,7 @@ pub struct BattleResults {
}
/// Battle player.
#[derive(Message)]
#[derive(Message, Serialize)]
pub struct Player {
/// Player's account ID.
#[prost(uint32, tag = "1")]
@@ -42,7 +55,7 @@ pub struct Player {
}
/// Player's extended information.
#[derive(Message)]
#[derive(Message, Serialize)]
pub struct PlayerInfo {
/// Player's nickname.
#[prost(string, tag = "1")]
@@ -56,21 +69,32 @@ pub struct PlayerInfo {
/// Player's team assignment.
#[prost(enumeration = "TeamNumber", tag = "3")]
pub team_number: i32,
pub team: i32,
#[prost(string, optional, tag = "5")]
pub clan_tag: Option<String>,
#[prost(message, required, tag = "7")]
pub avatar: Avatar,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, prost::Enumeration)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Enumeration, Serialize)]
#[repr(i32)]
pub enum TeamNumber {
One = 1,
Two = 2,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Enumeration, Serialize)]
#[repr(i32)]
pub enum WinnerTeamNumber {
Neither = 0,
One = 1,
Two = 2,
}
/// Player's battle results.
#[derive(Message)]
#[derive(Message, Serialize)]
pub struct PlayerResults {
/// Looks like some sort of sequential result ID. They reference each other,
/// but I haven't figured this out yet.
@@ -83,11 +107,11 @@ pub struct PlayerResults {
}
/// Extended player's results information.
#[derive(Message)]
#[derive(Message, Serialize)]
pub struct PlayerResultsInfo {
/// Credits earned – without special awards and medals and premium account excluded.
#[prost(uint32, tag = "2")]
pub credits: u32,
pub credits_earned: u32,
/// Base XP (the total without multipliers).
#[prost(uint32, tag = "3")]
@@ -160,3 +184,41 @@ impl PlayerResultsInfo {
.map(|mm_rating| (mm_rating * 10.0 + 3000.0) as u32)
}
}
#[derive(Message, Serialize)]
pub struct Author {
/// Earned credits, including premium and special awards and medals.
#[prost(uint32, tag = "2")]
pub credits_earned: u32,
/// Combat XP, including premium.
#[prost(uint32, tag = "3")]
pub combat_xp: u32,
#[prost(uint32, tag = "8")]
pub damage_dealt: u32,
#[prost(uint32, tag = "101")]
pub account_id: u32,
#[prost(enumeration = "TeamNumber", tag = "102")]
pub team_number: i32,
}
#[derive(Message, Serialize)]
pub struct Avatar {
#[prost(message, required, tag = "2")]
pub info: AvatarInfo,
}
#[derive(Message, Serialize)]
pub struct AvatarInfo {
#[prost(string, tag = "2")]
pub gfx_url: String,
#[prost(string, tag = "3")]
pub gfx2_url: String,
#[prost(string, tag = "4")]
pub kind: String,
}
+28 -10
View File
@@ -4,26 +4,44 @@ use prost::Message;
use serde::Deserialize;
use serde_bytes::ByteBuf;
use crate::models::BattleResults;
use crate::{Error, Result};
use crate::error::Error;
use crate::models::battle_results::BattleResults;
use crate::result::Result;
/// Represents un-pickled `battle_results.dat`.
#[derive(Debug, Deserialize)]
pub struct BattleResultsDat(
/// No idea what this is.
u64,
#[serde(from = "(u64, ByteBuf)")]
pub struct BattleResultsDat {
pub arena_unique_id: u64,
/// Protobuf-serialized battle results.
ByteBuf,
);
pub buffer: ByteBuf,
}
impl From<(u64, ByteBuf)> for BattleResultsDat {
fn from((arena_unique_id, buffer): (u64, ByteBuf)) -> Self {
Self {
arena_unique_id,
buffer,
}
}
}
impl BattleResultsDat {
/// Parses 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`.
pub fn from_reader(reader: impl Read) -> Result<Self> {
serde_pickle::from_reader(reader, Default::default()).map_err(Error::UnpickleFailed)
Ok(serde_pickle::from_reader(reader, Default::default())?)
}
}
impl TryInto<BattleResults> for BattleResultsDat {
type Error = Error;
/// Decodes the battle results from the internal buffer.
pub fn decode_battle_results(&self) -> Result<BattleResults> {
BattleResults::decode(self.1.as_ref()).map_err(Error::DecodeFailed)
fn try_into(self) -> Result<BattleResults> {
Ok(BattleResults::decode(self.buffer.as_ref())?)
}
}
+97
View File
@@ -0,0 +1,97 @@
//! `data.wotreplay` models.
pub mod entity_method;
pub mod packet;
pub mod payload;
pub mod type_0;
pub mod update_arena;
use std::io::Read;
use byteorder::{LittleEndian, ReadBytesExt};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::models::data::packet::Packet;
use crate::result::Result;
use crate::Error;
/// `data.wotreplay` root structure.
#[derive(Debug, Serialize)]
pub struct Data {
/// For example: `9.8.5_apple`.
pub client_version: String,
pub packets: Vec<Packet>,
}
impl Data {
pub fn from_reader(mut reader: impl Read) -> Result<Self> {
assert_magic(reader.read_u32::<LittleEndian>()?, 0x12345678)?;
// No idea what it is:
reader.read_u64::<LittleEndian>()?;
// Some sort of client hash, e.g.: `6CF2A9EFA5C52D6F6CE43A6D4A699C05`:
read_length_delimited(&mut reader)?;
let client_version = read_string(&mut reader)?;
// Some extra byte, no idea:
reader.read_u8()?;
let mut packets = Vec::new();
while let Some(packet) = Packet::from_reader(&mut reader)? {
packets.push(packet);
}
Ok(Self { client_version, packets })
}
}
#[inline]
fn assert_magic<T: Into<u32> + PartialEq>(actual: T, expected: T) -> Result {
if actual == expected {
Ok(())
} else {
Err(Error::InvalidMagic(actual.into(), expected.into()))
}
}
#[inline]
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);
reader.read_exact(&mut buffer)?;
Ok(buffer)
}
#[inline]
fn read_string(reader: &mut impl Read) -> Result<String> {
Ok(String::from_utf8(read_length_delimited(reader)?)?)
}
#[inline]
fn read_pickled<T: DeserializeOwned>(reader: &mut impl Read, length: usize) -> Result<T> {
let mut buffer = Vec::new();
buffer.resize(length, 0);
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.
///
/// P.S. Okay, Wargaming, I've no idea what you smoke, but I want some!
#[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),
}
}
+57
View File
@@ -0,0 +1,57 @@
use byteorder::{LittleEndian, ReadBytesExt};
use prost::encoding::decode_varint;
use prost::Message;
use serde::Serialize;
use serde_with::serde_as;
use crate::models::data::read_quirky_length;
use crate::models::data::update_arena::UpdateArena;
use crate::result::Result;
#[serde_as]
#[derive(Debug, Serialize)]
pub enum EntityMethod {
Subtype2F {
field_number: u64,
sub_type_2f: UpdateArena,
/// Original payload for investigation.
#[serde_as(as = "serde_with::hex::Hex")]
payload: Vec<u8>,
},
/// Default variant when subtype is not known by the parser.
Unknown {
sub_type: u32,
/// Whole packet payload (including the sub-type).
#[serde_as(as = "serde_with::hex::Hex")]
payload: Vec<u8>,
},
}
impl EntityMethod {
pub fn new(payload: Vec<u8>) -> Result<Self> {
let mut reader = payload.as_slice();
reader.read_u32::<LittleEndian>()?;
let sub_type = reader.read_u32::<LittleEndian>()?;
let this = match sub_type {
0x2F => {
let _inner_length = reader.read_u32::<LittleEndian>()?;
let field_number = decode_varint(&mut reader)?;
let message_length = read_quirky_length(&mut reader)?;
Self::Subtype2F {
field_number,
sub_type_2f: UpdateArena::decode(&reader[..message_length])?,
payload,
}
}
_ => Self::Unknown { sub_type, payload },
};
Ok(this)
}
}
+32
View File
@@ -0,0 +1,32 @@
use std::io::Read;
use byteorder::{LittleEndian, ReadBytesExt};
use serde::Serialize;
use crate::models::data::payload::Payload;
use crate::result::Result;
#[derive(Debug, Serialize)]
pub struct Packet {
pub clock: f32,
pub payload: Payload,
}
impl Packet {
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 };
Ok(Some(this))
}
fn read_payload(reader: &mut impl Read, length: usize) -> Result<Vec<u8>> {
let mut buffer = Vec::new();
buffer.resize(length, 0);
reader.read_exact(&mut buffer)?;
Ok(buffer)
}
}
+63
View File
@@ -0,0 +1,63 @@
use std::io::Read;
use byteorder::{LittleEndian, ReadBytesExt};
use serde::Serialize;
use serde_with::serde_as;
use crate::models::data::entity_method::EntityMethod;
use crate::models::data::type_0::Type0;
use crate::models::data::{read_pickled, read_quirky_length, read_string};
use crate::result::Result;
#[serde_as]
#[derive(Debug, Serialize)]
pub enum Payload {
/// Looks like some sort of replay summary.
Type0 {
author_nickname: String,
arena_unique_id: u64,
arena_type_id: u32,
type_0: Box<Type0>,
},
EntityMethod(EntityMethod),
/// Default payload when type is not known by the parser.
Unknown {
type_: u32,
/// Whole packet payload.
#[serde_as(as = "serde_with::hex::Hex")]
raw: Vec<u8>,
},
}
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])?;
let author_nickname = read_string(&mut reader)?;
let arena_unique_id = reader.read_u64::<LittleEndian>()?;
let arena_type_id = reader.read_u32::<LittleEndian>()?;
let type_0 = {
let pickled_length = read_quirky_length(&mut reader)?;
read_pickled(&mut reader, pickled_length)?
};
Self::Type0 {
type_0,
author_nickname,
arena_unique_id,
arena_type_id,
}
}
8 => Self::EntityMethod(EntityMethod::new(payload)?),
_ => Self::Unknown { raw: payload, type_ },
};
Ok(this)
}
}
+73
View File
@@ -0,0 +1,73 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Looks like some sort of replay summary.
#[derive(Debug, Serialize, Deserialize)]
pub struct Type0 {
#[serde(rename(deserialize = "playersBattleCategoriesIds"))]
pub players_battle_categories_ids: 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,
#[serde(rename(deserialize = "avgMmr"))]
pub average_mmr: Vec<Option<f64>>,
#[serde(rename(deserialize = "playerWaitTimes"))]
pub player_wait_times: HashMap<u32, f64>,
#[serde(rename(deserialize = "accountDatabaseIds"))]
pub account_database_ids: Vec<u32>,
#[serde(rename(deserialize = "turboBattlesStats"))]
pub turbo_battles_statistics: TurboBattlesStatistics,
}
#[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,
}
+33
View File
@@ -0,0 +1,33 @@
use prost::Message;
use serde::Serialize;
/// Subtype `0x2F`.
#[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>,
}
+29
View File
@@ -0,0 +1,29 @@
#![cfg(feature = "meta")]
use std::io::Read;
use serde::Deserialize;
use crate::result::Result;
/// The model for `meta.json`.
#[derive(Deserialize)]
pub struct Meta {
#[serde(rename = "playerName")]
pub player_name: String,
#[serde(rename = "arenaUniqueId")]
pub arena_unique_id: String,
#[serde(rename = "battleDuration")]
pub battle_duration_secs: f64,
#[serde(rename = "vehicleCompDescriptor")]
pub tank_id: u16,
}
impl Meta {
pub fn from_reader(reader: impl Read) -> Result<Self> {
Ok(serde_json::from_reader(reader)?)
}
}
+22 -10
View File
@@ -2,8 +2,11 @@ use std::io::{Read, Seek};
use zip::ZipArchive;
use crate::error::Error;
use crate::models::{BattleResults, BattleResultsDat};
use crate::models::battle_results::BattleResults;
use crate::models::battle_results_dat::BattleResultsDat;
use crate::models::data::Data;
#[cfg(feature = "meta")]
use crate::models::meta::Meta;
use crate::result::Result;
/// Opened (but not yet parsed) replay.
@@ -12,17 +15,26 @@ pub struct Replay<R>(pub(crate) ZipArchive<R>);
impl<R: Read + Seek> Replay<R> {
/// Opens a replay from the reader which contains a `*.wotbreplay`.
pub fn open(reader: R) -> Result<Self> {
ZipArchive::new(reader)
.map_err(Error::OpenArchiveFailed)
.map(Self)
Ok(Self(ZipArchive::new(reader)?))
}
/// Reads and parses the battle results from the replay.
pub fn read_battle_results(&mut self) -> Result<BattleResults> {
let pickled_battle_results = self
.0
.by_name("battle_results.dat")
.map_err(Error::OpenBattleResultsFailed)?;
BattleResultsDat::from_reader(pickled_battle_results)?.decode_battle_results()
self.read_battle_results_dat()?.try_into()
}
/// Reads and parses the included `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)
}
#[cfg(feature = "meta")]
pub fn read_meta(&mut self) -> Result<Meta> {
Meta::from_reader(self.0.by_name("meta.json")?)
}
pub fn read_data(&mut self) -> Result<Data> {
Data::from_reader(self.0.by_name("data.wotreplay")?)
}
}
+36 -5
View File
@@ -1,12 +1,24 @@
use std::fs::File;
use anyhow::Result;
use wotbreplay_parser::Replay;
use wotbreplay_parser::models::battle_results::BattleResults;
use wotbreplay_parser::replay::Replay;
#[test]
fn player_results_ok() -> Result<()> {
let battle_results = Replay::open(File::open("tests/replays/player_results.wotbreplay")?)?
.read_battle_results()?;
let battle_results_dat = Replay::open(File::open("tests/replays/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.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);
@@ -37,7 +49,7 @@ fn player_results_ok() -> Result<()> {
assert_eq!(zeekrab_info.victory_points_earned, 40);
assert_eq!(zeekrab_info.victory_points_seized, 40);
assert_eq!(zeekrab_info.tank_id, 26657);
assert_eq!(zeekrab_info.credits, 37679);
assert_eq!(zeekrab_info.credits_earned, 37679);
assert_eq!(zeekrab_info.n_enemies_damaged, 2);
assert_eq!(zeekrab_info.n_enemies_destroyed, 1);
@@ -67,12 +79,23 @@ fn player_results_ok() -> Result<()> {
}
#[test]
fn clan_tags_ok() -> Result<()> {
fn player_info_ok() -> Result<()> {
let battle_results = Replay::open(File::open("tests/replays/player_results.wotbreplay")?)?
.read_battle_results()?;
assert_eq!(battle_results.players[1].info.clan_tag.as_deref(), Some("AN0NY"));
assert_eq!(battle_results.players[10].info.clan_tag.as_deref(), Some("BBS"));
assert_eq!(battle_results.players[10].info.avatar.info.kind, "legendary");
assert_eq!(
battle_results.players[10].info.avatar.info.gfx_url,
"https://stufficons.wgcdn.co/Gfx/avatar_UFO_legendary_anim/220523_104739_752791.webp"
);
assert_eq!(
battle_results.players[10].info.avatar.info.gfx2_url,
"https://stufficons.wgcdn.co/Gfx2/avatar_UFO_legendary_anim/220523_104755_251331.webp"
);
assert_eq!(battle_results.players[11].info.clan_tag, None);
Ok(())
@@ -94,3 +117,11 @@ fn victory_points_ok() -> Result<()> {
Ok(())
}
#[test]
fn draw_ok() -> Result<()> {
let battle_results = Replay::open(File::open("tests/replays/20230503_0027__helaas_pindakaas_A140_ASTRON_REX_105_2319058483532631656.wotbreplay")?)?
.read_battle_results()?;
assert_eq!(battle_results.winner_team_number, 0);
Ok(())
}
+93
View File
@@ -0,0 +1,93 @@
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 parse_ok() -> Result<()> {
let mut replay = Replay::open(File::open(
"tests/replays/20230429_0126__helaas_pindakaas_A140_ASTRON_REX_105_16114615898101270.wotbreplay",
)?)?;
let data = replay.read_data()?;
assert_eq!(data.client_version, "9.8.5_apple");
assert_eq!(data.packets.len(), 60139);
Ok(())
}
#[test]
fn type0_ok() -> Result<()> {
let mut replay = Replay::open(File::open(
"tests/replays/20230429_0126__helaas_pindakaas_A140_ASTRON_REX_105_16114615898101270.wotbreplay",
)?)?;
let data = replay.read_data()?;
let (author_nickname, arena_unique_id, arena_type_id, type_0) = data
.packets
.iter()
.filter_map(|packet| {
if let Payload::Type0 {
author_nickname,
arena_unique_id,
arena_type_id,
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!(type_0.battle_level, 9);
assert_eq!(type_0.battle_category_id, 13);
assert!(type_0.is_mouse_enabled);
assert_eq!(type_0.matchmaker_type, 2);
assert_eq!(type_0.camouflage_slot, 1);
assert_eq!(type_0.average_mmr, [Some(59.29457753400008), Some(99.34182121604681)]);
assert_eq!(
type_0.account_database_ids,
[
517033644, 537867068, 597583401, 601893865, 576810156, 531053956, 599580824, 558025185,
526927603, 594778041, 505587490, 597422303, 531923111, 587617355
]
);
Ok(())
}
#[test]
fn subtype_2f_players_ok() -> Result<()> {
let mut replay = Replay::open(File::open(
"tests/replays/20230429_0126__helaas_pindakaas_A140_ASTRON_REX_105_16114615898101270.wotbreplay",
)?)?;
let data = replay.read_data()?;
let players = data
.packets
.iter()
.filter_map(|packet| match &packet.payload {
Payload::EntityMethod(EntityMethod::Subtype2F { sub_type_2f, .. }) => {
sub_type_2f.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(())
}
+18
View File
@@ -0,0 +1,18 @@
#![cfg(feature = "meta")]
use std::fs::File;
use anyhow::Result;
use wotbreplay_parser::replay::Replay;
#[test]
fn meta_ok() -> Result<()> {
let meta = Replay::open(File::open("tests/replays/player_results.wotbreplay")?)?.read_meta()?;
assert_eq!(meta.player_name, "zeekrab");
assert_eq!(meta.arena_unique_id, "1661909200500084");
assert_eq!(meta.battle_duration_secs, 164.08485);
assert_eq!(meta.tank_id, 26657);
Ok(())
}
+1
View File
@@ -0,0 +1 @@
*.wotbreplay linguist-generated
Binary file not shown.