From 53ed08570eee9c3b4599a56c6fe7d5fd8ca1fd5a Mon Sep 17 00:00:00 2001 From: Andrei Benea Date: Sun, 10 Sep 2023 14:49:40 +0200 Subject: [PATCH] cs2: Parse game events. Also refactor the code to expose the DemoInfo format written by the parser. The new incomplete CS2 parser is behind an environment variable for now to prevent accidentally adding incomplete data to the HeadhshotBox server. --- Cargo.lock | 452 ++++++++++------ Cargo.toml | 5 + cs2-demo/Cargo.toml | 18 + cs2-demo/build.rs | 17 + cs2-demo/proto/README.md | 1 + cs2-demo/proto/demo.proto | 160 ++++++ cs2-demo/proto/gameevents.proto | 49 ++ cs2-demo/src/demo_command.rs | 67 +++ cs2-demo/src/lib.rs | 58 +++ cs2-demo/src/packet.rs | 111 ++++ cs2-demo/src/proto.rs | 1 + csdemoparser/Cargo.toml | 4 +- csdemoparser/src/cs2.rs | 182 +++++++ csdemoparser/src/csgo.rs | 616 ++++++++++++++++++++++ csdemoparser/src/demoinfo.rs | 122 +++++ csdemoparser/src/entity.rs | 237 +-------- csdemoparser/src/entity/serverclass.rs | 3 +- csdemoparser/src/game_event.rs | 205 ++++++++ csdemoparser/src/game_event/de.rs | 242 +++++++++ csdemoparser/src/lib.rs | 679 +------------------------ csdemoparser/src/string_table.rs | 3 +- csgo-demo-parser | 2 +- demo-format/Cargo.toml | 9 + demo-format/src/lib.rs | 4 + demo-format/src/read.rs | 255 ++++++++++ parsetest/Cargo.toml | 6 +- 26 files changed, 2441 insertions(+), 1067 deletions(-) create mode 100644 cs2-demo/Cargo.toml create mode 100644 cs2-demo/build.rs create mode 100644 cs2-demo/proto/README.md create mode 100644 cs2-demo/proto/demo.proto create mode 100644 cs2-demo/proto/gameevents.proto create mode 100644 cs2-demo/src/demo_command.rs create mode 100644 cs2-demo/src/lib.rs create mode 100644 cs2-demo/src/packet.rs create mode 100644 cs2-demo/src/proto.rs create mode 100644 csdemoparser/src/cs2.rs create mode 100644 csdemoparser/src/csgo.rs create mode 100644 csdemoparser/src/demoinfo.rs create mode 100644 csdemoparser/src/game_event.rs create mode 100644 csdemoparser/src/game_event/de.rs create mode 100644 demo-format/Cargo.toml create mode 100644 demo-format/src/lib.rs create mode 100644 demo-format/src/read.rs diff --git a/Cargo.lock b/Cargo.lock index 51f3166..4ae233e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,32 +2,40 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5d730647d4fadd988536d06fecce94b7b4f2a7efdae548f1cf4b63205518ab" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" -version = "0.3.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", - "is-terminal", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" +checksum = "b84bf0a05bbb2a83e5eb6fa36bb6e87baa08193c35ff52bbf6b38d8af2890e46" [[package]] name = "anstyle-parse" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" dependencies = [ "utf8parse", ] @@ -43,9 +51,9 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "1.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd" dependencies = [ "anstyle", "windows-sys 0.48.0", @@ -53,9 +61,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.71" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" [[package]] name = "assert-json-diff" @@ -79,11 +87,17 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + [[package]] name = "bitstream-io" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d28070975aaf4ef1fd0bd1f29b739c06c2cdd9972e090617fb6dca3b2cb564e" +checksum = "82704769cb85a22df2c54d6bdd6a158b7931d256cf3248a07d6ecbe9d58b31d7" [[package]] name = "byteorder" @@ -93,15 +107,18 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] name = "cc" -version = "1.0.79" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] [[package]] name = "cfg-if" @@ -111,45 +128,43 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.3.1" +version = "4.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ed2379f8603fa2b7509891660e802b88c70a79a6427a70abb5968054de2c28" +checksum = "b1d7b8d5ec32af0fadc644bf1fd509a688c2103b185644bb1e29d164e0703136" dependencies = [ "clap_builder", "clap_derive", - "once_cell", ] [[package]] name = "clap_builder" -version = "4.3.1" +version = "4.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72394f3339a76daf211e57d4bcb374410f3965dcc606dd0e03738c7888766980" +checksum = "5179bb514e4d7c2051749d8fcefa2ed6d06a9f4e6d69faf3805f5d80b8cf8d56" dependencies = [ "anstream", "anstyle", - "bitflags", "clap_lex", "strsim", ] [[package]] name = "clap_derive" -version = "4.3.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e9ef9a08ee1c0e1f2e162121665ac45ac3783b0f897db7244ae75ad9a8f65b" +checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.37", ] [[package]] name = "clap_lex" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" +checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" [[package]] name = "colorchoice" @@ -190,16 +205,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" -dependencies = [ - "cfg-if", - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.3" @@ -213,9 +218,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.14" +version = "0.9.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46bd5f3f85273295a9d14aedfb86f6aadbff6d8f5295c4a9edb08e819dcf5695" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" dependencies = [ "autocfg", "cfg-if", @@ -226,13 +231,27 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.15" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" dependencies = [ "cfg-if", ] +[[package]] +name = "cs2-demo" +version = "0.0.0" +dependencies = [ + "bitstream-io", + "demo-format", + "paste", + "protobuf", + "protobuf-codegen", + "snap", + "thiserror", + "tracing", +] + [[package]] name = "csdemoparser" version = "0.1.0" @@ -241,7 +260,9 @@ dependencies = [ "assert-json-diff", "bitstream-io", "byteorder", + "cs2-demo", "csgo-demo-parser", + "demo-format", "protobuf", "serde", "serde_json", @@ -263,11 +284,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "demo-format" +version = "0.1.0" +dependencies = [ + "bitstream-io", +] + [[package]] name = "either" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "encode_unicode" @@ -277,9 +305,9 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" [[package]] name = "errno" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd" dependencies = [ "errno-dragonfly", "libc", @@ -296,6 +324,12 @@ dependencies = [ "libc", ] +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + [[package]] name = "getset" version = "0.1.2" @@ -314,6 +348,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "heck" version = "0.4.1" @@ -321,25 +361,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] -name = "hermit-abi" -version = "0.2.6" +name = "home" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" dependencies = [ - "libc", + "windows-sys 0.48.0", ] [[package]] -name = "hermit-abi" -version = "0.3.1" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", +] [[package]] name = "indicatif" -version = "0.17.5" +version = "0.17.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ff8cc23a7393a397ed1d7f56e6365cba772aba9f9912ab968b03043c395d057" +checksum = "0b297dc40733f23a0e52728a58fa9489a5b7638a324932de16b41adc3ef80730" dependencies = [ "console", "instant", @@ -358,34 +402,11 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi 0.3.1", - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "is-terminal" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" -dependencies = [ - "hermit-abi 0.3.1", - "io-lifetimes", - "rustix", - "windows-sys 0.48.0", -] - [[package]] name = "itoa" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "lazy_static" @@ -395,33 +416,35 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.144" +version = "0.2.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" +checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" [[package]] name = "linux-raw-sys" -version = "0.3.8" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" [[package]] -name = "memoffset" -version = "0.8.0" +name = "log" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61c719bcfbcf5d62b3a09efa6088de8c54bc0bfcd3ea7ae39fcc186108b8de1" -dependencies = [ - "autocfg", -] +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] -name = "num_cpus" -version = "1.15.0" +name = "memchr" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" dependencies = [ - "hermit-abi 0.2.6", - "libc", + "autocfg", ] [[package]] @@ -432,9 +455,9 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] name = "once_cell" -version = "1.17.2" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9670a07f94779e00908f3e686eab508878ebb390ba6e604d3a284c00e8d0487b" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parsetest" @@ -453,21 +476,21 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.12" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" [[package]] name = "portable-atomic" -version = "1.3.3" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "767eb9f07d4a5ebcb39bbf2d452058a93c011373abf6832e24194a1c3f004794" +checksum = "31114a898e107c51bb1609ffaf55a0e011cf6a4d7f1170d0015a165082c0338b" [[package]] name = "proc-macro-error" @@ -495,9 +518,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.59" +version = "1.0.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b" +checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" dependencies = [ "unicode-ident", ] @@ -514,6 +537,37 @@ dependencies = [ "thiserror", ] +[[package]] +name = "protobuf-codegen" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd418ac3c91caa4032d37cb80ff0d44e2ebe637b2fb243b6234bf89cdac4901" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror", +] + +[[package]] +name = "protobuf-parse" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d39b14605eaa1f6a340aec7f320b34064feb26c93aec35d6a9a2272a8ddfa49" +dependencies = [ + "anyhow", + "indexmap", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror", + "which", +] + [[package]] name = "protobuf-support" version = "3.2.0" @@ -525,18 +579,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.28" +version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ "proc-macro2", ] [[package]] name = "rayon" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" +checksum = "9c27db03db7734835b3f53954b534c91069375ce6ccaa2e065441e07d9b6cdb1" dependencies = [ "either", "rayon-core", @@ -544,25 +598,60 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" +checksum = "5ce3fb6ad83f861aac485e76e1985cd109d9a3713802152be56c3b1f0e0658ed" dependencies = [ - "crossbeam-channel", "crossbeam-deque", "crossbeam-utils", - "num_cpus", ] +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + [[package]] name = "rustix" -version = "0.37.19" +version = "0.38.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" +checksum = "747c788e9ce8e92b12cd485c49ddf90723550b654b32508f979b71a7b1ecda4f" dependencies = [ - "bitflags", + "bitflags 2.4.0", "errno", - "io-lifetimes", "libc", "linux-raw-sys", "windows-sys 0.48.0", @@ -570,47 +659,53 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.13" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" [[package]] name = "scopeguard" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.163" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" +checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.163" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e" +checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.37", ] [[package]] name = "serde_json" -version = "1.0.96" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" dependencies = [ "itoa", "ryu", "serde", ] +[[package]] +name = "snap" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e9f0ab6ef7eb7353d9119c170a436d1bf248eea575ac42d19d12f4e34130831" + [[package]] name = "strsim" version = "0.10.0" @@ -630,33 +725,46 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.18" +version = "2.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys 0.48.0", +] + [[package]] name = "thiserror" -version = "1.0.40" +version = "1.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +checksum = "9d6d7a740b8a666a7e828dd00da9c0dc290dff53154ea77ac109281de90589b7" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.40" +version = "1.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.37", ] [[package]] @@ -673,13 +781,13 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" +checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.37", ] [[package]] @@ -693,15 +801,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.9" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-width" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] name = "unicode-xid" @@ -721,6 +829,18 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -736,7 +856,7 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.48.0", + "windows-targets 0.48.5", ] [[package]] @@ -756,17 +876,17 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm 0.48.0", - "windows_aarch64_msvc 0.48.0", - "windows_i686_gnu 0.48.0", - "windows_i686_msvc 0.48.0", - "windows_x86_64_gnu 0.48.0", - "windows_x86_64_gnullvm 0.48.0", - "windows_x86_64_msvc 0.48.0", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -777,9 +897,9 @@ checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_msvc" @@ -789,9 +909,9 @@ checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_i686_gnu" @@ -801,9 +921,9 @@ checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_msvc" @@ -813,9 +933,9 @@ checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_i686_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_x86_64_gnu" @@ -825,9 +945,9 @@ checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnullvm" @@ -837,9 +957,9 @@ checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_msvc" @@ -849,6 +969,6 @@ checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] name = "windows_x86_64_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/Cargo.toml b/Cargo.toml index 4c34201..db8cbc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,11 @@ members = [ "csgo-demo-parser", + "cs2-demo", "csdemoparser", + "demo-format", "parsetest", ] + +[workspace.dependencies] +bitstream-io = "1.7" diff --git a/cs2-demo/Cargo.toml b/cs2-demo/Cargo.toml new file mode 100644 index 0000000..e6e16d6 --- /dev/null +++ b/cs2-demo/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cs2-demo" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bitstream-io.workspace = true +demo-format = { path = "../demo-format" } +protobuf = { version = "3.2.0", features = ["with-bytes"] } +snap = "1.1" +thiserror = "1.0" +tracing = "0.1" +paste = "1.0" + +[build-dependencies] +protobuf-codegen = "3.2" diff --git a/cs2-demo/build.rs b/cs2-demo/build.rs new file mode 100644 index 0000000..79f5656 --- /dev/null +++ b/cs2-demo/build.rs @@ -0,0 +1,17 @@ +use std::{ffi::OsStr, fs}; + +fn main() { + let proto_dir = "proto"; + println!("cargo:rerun-if-changed={proto_dir}"); + + let proto_files = fs::read_dir(proto_dir) + .unwrap() + .filter_map(|res| res.map(|e| e.path()).ok()) + .filter(|p| p.extension() == Some(OsStr::new("proto"))) + .collect::>(); + protobuf_codegen::Codegen::new() + .include(proto_dir) + .inputs(proto_files) + .cargo_out_dir("proto") + .run_from_script(); +} diff --git a/cs2-demo/proto/README.md b/cs2-demo/proto/README.md new file mode 100644 index 0000000..e728541 --- /dev/null +++ b/cs2-demo/proto/README.md @@ -0,0 +1 @@ +These proto files are copied from [SteamDatabase/GameTracking-CS2](https://github.com/SteamDatabase/GameTracking-CS2/tree/master/Protobufs). diff --git a/cs2-demo/proto/demo.proto b/cs2-demo/proto/demo.proto new file mode 100644 index 0000000..e1eb1bb --- /dev/null +++ b/cs2-demo/proto/demo.proto @@ -0,0 +1,160 @@ +enum EDemoCommands { + DEM_Error = -1; + DEM_Stop = 0; + DEM_FileHeader = 1; + DEM_FileInfo = 2; + DEM_SyncTick = 3; + DEM_SendTables = 4; + DEM_ClassInfo = 5; + DEM_StringTables = 6; + DEM_Packet = 7; + DEM_SignonPacket = 8; + DEM_ConsoleCmd = 9; + DEM_CustomData = 10; + DEM_CustomDataCallbacks = 11; + DEM_UserCmd = 12; + DEM_FullPacket = 13; + DEM_SaveGame = 14; + DEM_SpawnGroups = 15; + DEM_AnimationData = 16; + DEM_Max = 17; + DEM_IsCompressed = 64; +} + +message CDemoFileHeader { + required string demo_file_stamp = 1; + optional int32 network_protocol = 2; + optional string server_name = 3; + optional string client_name = 4; + optional string map_name = 5; + optional string game_directory = 6; + optional int32 fullpackets_version = 7; + optional bool allow_clientside_entities = 8; + optional bool allow_clientside_particles = 9; + optional string addons = 10; + optional string demo_version_name = 11; + optional string demo_version_guid = 12; + optional int32 build_num = 13; + optional string game = 14; +} + +message CGameInfo { + message CDotaGameInfo { + message CPlayerInfo { + optional string hero_name = 1; + optional string player_name = 2; + optional bool is_fake_client = 3; + optional uint64 steamid = 4; + optional int32 game_team = 5; + } + + message CHeroSelectEvent { + optional bool is_pick = 1; + optional uint32 team = 2; + optional uint32 hero_id = 3; + } + + optional uint64 match_id = 1; + optional int32 game_mode = 2; + optional int32 game_winner = 3; + repeated .CGameInfo.CDotaGameInfo.CPlayerInfo player_info = 4; + optional uint32 leagueid = 5; + repeated .CGameInfo.CDotaGameInfo.CHeroSelectEvent picks_bans = 6; + optional uint32 radiant_team_id = 7; + optional uint32 dire_team_id = 8; + optional string radiant_team_tag = 9; + optional string dire_team_tag = 10; + optional uint32 end_time = 11; + } + + optional .CGameInfo.CDotaGameInfo dota = 4; +} + +message CDemoFileInfo { + optional float playback_time = 1; + optional int32 playback_ticks = 2; + optional int32 playback_frames = 3; + optional .CGameInfo game_info = 4; +} + +message CDemoPacket { + optional bytes data = 3; +} + +message CDemoFullPacket { + optional .CDemoStringTables string_table = 1; + optional .CDemoPacket packet = 2; +} + +message CDemoSaveGame { + optional bytes data = 1; + optional fixed64 steam_id = 2; + optional fixed64 signature = 3; + optional int32 version = 4; +} + +message CDemoSyncTick { +} + +message CDemoConsoleCmd { + optional string cmdstring = 1; +} + +message CDemoSendTables { + optional bytes data = 1; +} + +message CDemoClassInfo { + message class_t { + optional int32 class_id = 1; + optional string network_name = 2; + optional string table_name = 3; + } + + repeated .CDemoClassInfo.class_t classes = 1; +} + +message CDemoCustomData { + optional int32 callback_index = 1; + optional bytes data = 2; +} + +message CDemoCustomDataCallbacks { + repeated string save_id = 1; +} + +message CDemoAnimationData { + optional sint32 entity_id = 1; + optional int32 start_tick = 2; + optional int32 end_tick = 3; + optional bytes data = 4; + optional int64 data_checksum = 5; +} + +message CDemoStringTables { + message items_t { + optional string str = 1; + optional bytes data = 2; + } + + message table_t { + optional string table_name = 1; + repeated .CDemoStringTables.items_t items = 2; + repeated .CDemoStringTables.items_t items_clientside = 3; + optional int32 table_flags = 4; + } + + repeated .CDemoStringTables.table_t tables = 1; +} + +message CDemoStop { +} + +message CDemoUserCmd { + optional int32 cmd_number = 1; + optional bytes data = 2; +} + +message CDemoSpawnGroups { + repeated bytes msgs = 3; +} diff --git a/cs2-demo/proto/gameevents.proto b/cs2-demo/proto/gameevents.proto new file mode 100644 index 0000000..426aec5 --- /dev/null +++ b/cs2-demo/proto/gameevents.proto @@ -0,0 +1,49 @@ +enum EBaseGameEvents { + GE_VDebugGameSessionIDEvent = 200; + GE_PlaceDecalEvent = 201; + GE_ClearWorldDecalsEvent = 202; + GE_ClearEntityDecalsEvent = 203; + GE_ClearDecalsForSkeletonInstanceEvent = 204; + GE_Source1LegacyGameEventList = 205; + GE_Source1LegacyListenEvents = 206; + GE_Source1LegacyGameEvent = 207; + GE_SosStartSoundEvent = 208; + GE_SosStopSoundEvent = 209; + GE_SosSetSoundEventParams = 210; + GE_SosSetLibraryStackFields = 211; + GE_SosStopSoundEventHash = 212; +} + +message CMsgSource1LegacyGameEventList { + message key_t { + optional int32 type = 1; + optional string name = 2; + } + + message descriptor_t { + optional int32 eventid = 1; + optional string name = 2; + repeated .CMsgSource1LegacyGameEventList.key_t keys = 3; + } + + repeated .CMsgSource1LegacyGameEventList.descriptor_t descriptors = 1; +} + +message CMsgSource1LegacyGameEvent { + message key_t { + optional int32 type = 1; + optional string val_string = 2; + optional float val_float = 3; + optional int32 val_long = 4; + optional int32 val_short = 5; + optional int32 val_byte = 6; + optional bool val_bool = 7; + optional uint64 val_uint64 = 8; + } + + optional string event_name = 1; + optional int32 eventid = 2; + repeated .CMsgSource1LegacyGameEvent.key_t keys = 3; + optional int32 server_tick = 4; + optional int32 passthrough = 5; +} diff --git a/cs2-demo/src/demo_command.rs b/cs2-demo/src/demo_command.rs new file mode 100644 index 0000000..6ab8ea4 --- /dev/null +++ b/cs2-demo/src/demo_command.rs @@ -0,0 +1,67 @@ +use std::fmt; + +use super::packet::Packet; +use super::proto::demo::{CDemoFileHeader, CDemoPacket, CDemoSendTables}; +use super::{Error, Result}; +use protobuf::Message; + +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum DemoCommand { + /// The last packet dispatched. It means there are no more packet left to + /// parse. + Stop, + /// The first packet. + FileHeader(CDemoFileHeader), + FileInfo, + /// A sync tick. It contains no data. + SyncTick, + SendTables(CDemoSendTables), + ClassInfo, + StringTables, + Packet(Packet), + ConsoleCmd, + CustomData, + CustomDataCallbacks, + UserCmd, + FullPacket, + SaveGame, + SpawnGroups, + AnimationData, +} + +impl DemoCommand { + pub fn try_new(cmd: u32, data: &[u8]) -> Result { + let content = match cmd { + 0 => DemoCommand::Stop, + 1 => DemoCommand::FileHeader(CDemoFileHeader::parse_from_bytes(data)?), + 2 => DemoCommand::FileInfo, + 3 => DemoCommand::SyncTick, + 4 => DemoCommand::SendTables(CDemoSendTables::parse_from_bytes(data)?), + 5 => DemoCommand::ClassInfo, + 6 => DemoCommand::StringTables, + // SignonPacket seems to be identical to Packet. + 7 | 8 => DemoCommand::Packet(Packet::try_new(CDemoPacket::parse_from_bytes(data)?)?), + 9 => DemoCommand::ConsoleCmd, + 10 => DemoCommand::CustomData, + 11 => DemoCommand::CustomDataCallbacks, + 12 => DemoCommand::UserCmd, + 13 => DemoCommand::FullPacket, + 14 => DemoCommand::SaveGame, + 15 => DemoCommand::SpawnGroups, + 16 => DemoCommand::AnimationData, + _ => return Err(Error::UnknownPacketCommand(cmd)), + }; + Ok(content) + } +} + +impl fmt::Display for DemoCommand { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + DemoCommand::FileHeader(m) => write!(f, "FileHeader {}", m), + DemoCommand::SendTables(m) => write!(f, "SendTables {} bytes", m.data().len()), + _ => write!(f, "{:?}", self), + } + } +} diff --git a/cs2-demo/src/lib.rs b/cs2-demo/src/lib.rs new file mode 100644 index 0000000..54d0370 --- /dev/null +++ b/cs2-demo/src/lib.rs @@ -0,0 +1,58 @@ +mod demo_command; +pub mod packet; +pub mod proto; + +use self::proto::demo::EDemoCommands; +use protobuf::CodedInputStream; +use std::io; + +pub use self::demo_command::DemoCommand; + +/// Error type for this library. +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Protobuf(#[from] protobuf::Error), + #[error("invalid demo type (expected: PBDEMS2, found: {found})")] + InvalidDemoType { found: String }, + #[error("unknown packet command found: {0}")] + UnknownPacketCommand(u32), + #[error(transparent)] + Decompression(#[from] snap::Error), +} + +pub type Result = std::result::Result; + +pub type Tick = i32; + +pub struct DemoParser<'a> { + reader: CodedInputStream<'a>, +} + +impl<'a> DemoParser<'a> { + pub fn try_new_after_demo_type(read: &'a mut dyn io::Read) -> Result { + let mut reader = CodedInputStream::new(read); + reader.skip_raw_bytes(8)?; + Ok(Self { reader }) + } + + pub fn parse_next_demo_command(&mut self) -> Result> { + if self.reader.eof()? { + return Ok(None); + } + let cmd_flags = self.reader.read_raw_varint32()?; + let cmd = cmd_flags & !(EDemoCommands::DEM_IsCompressed as u32); + let compressed = (cmd_flags & (EDemoCommands::DEM_IsCompressed as u32)) != 0; + let tick = self.reader.read_raw_varint32()? as i32; + let size = self.reader.read_raw_varint32()?; + let data = self.reader.read_raw_bytes(size)?; + let data = if compressed { + snap::raw::Decoder::new().decompress_vec(data.as_slice())? + } else { + data + }; + Ok(Some((tick, DemoCommand::try_new(cmd, &data)?))) + } +} diff --git a/cs2-demo/src/packet.rs b/cs2-demo/src/packet.rs new file mode 100644 index 0000000..069b23a --- /dev/null +++ b/cs2-demo/src/packet.rs @@ -0,0 +1,111 @@ +use super::proto::demo::CDemoPacket; +use super::proto::gameevents::{ + CMsgSource1LegacyGameEvent, CMsgSource1LegacyGameEventList, EBaseGameEvents, +}; +use super::Result; +use bitstream_io::BitRead; +use demo_format::read::ValveBitReader; +use paste::paste; +use protobuf::Message as protobuf_Message; +use std::fmt; +use std::io::{Cursor, Read, Seek, SeekFrom}; + +/// Generates an enum with a variant for each supported Packet message type. +/// +/// $enum is a proto enum listing the Packet message identifiers for a category of messages +/// $enum_prefix is the prefix for all the items in $enum +/// $msg_prefix is the prefix for proto message type names +/// $name is the proto message type name without $msg_prefix +macro_rules! create_message_impl { + ($( + ($enum:ident, $enum_prefix:ident, $msg_prefix:ident) + => [ $($name:ident),* ] + ),*) => {paste! { + pub enum Message { + Unknown(u32), + $($($name([<$msg_prefix $name>])),*),* + } + + impl Message { + pub(crate) fn try_new( + reader: &mut bitstream_io::BitReader, + mut buffer: &mut Vec, + ) -> Result { + $($(const [<$name:upper>]: u32 = $enum::[<$enum_prefix _ $name>] as u32;)*)* + let msg_type = reader.read_ubitvar()?; + let size = reader.read_varint32()? as usize; + match msg_type { + $($( + [<$name:upper>] => { + let msg = [<$msg_prefix $name>]::parse_from_bytes(read_buffer( + &mut buffer, size, reader)?)?; + Ok(Message::$name(msg)) + } + )*)* + _ => { + reader.seek_bits(SeekFrom::Current(size as i64 * 8))?; + Ok(Message::Unknown(msg_type)) + } + } + } + } + + impl fmt::Debug for Message { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Message::Unknown(t) => write!(f, "Unknown({})", t), + $($(Message::$name(m) => write!(f, "{}({})", stringify!($name), m),)*)* + } + } + } + }}; +} + +create_message_impl! { + (EBaseGameEvents, GE, CMsg) => [ + Source1LegacyGameEvent, + Source1LegacyGameEventList + ] +} + +pub struct Packet { + pub messages: Vec, +} + +impl Packet { + pub fn try_new(packet: CDemoPacket) -> Result { + let mut buffer = Vec::with_capacity(1024); + let mut messages = Vec::new(); + let mut reader = bitstream_io::BitReader::new(Cursor::new(packet.data())); + while reader.position_in_bits()? < packet.data().len() as u64 * 8 - 7 { + messages.push(Message::try_new(&mut reader, &mut buffer)?); + } + Ok(Packet { messages }) + } +} + +impl fmt::Debug for Packet { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if !self.messages.is_empty() { + writeln!(f, "Packet [")?; + for msg in &self.messages { + writeln!(f, " {:?}", msg)?; + } + write!(f, "]") + } else { + write!(f, "Packet []") + } + } +} + +fn read_buffer<'a, R: Read + Seek>( + buffer: &'a mut Vec, + size: usize, + reader: &mut bitstream_io::BitReader, +) -> Result<&'a [u8]> { + if buffer.len() < size { + buffer.resize(size, 0); + } + reader.read_bytes(&mut buffer[0..size])?; + Ok(&buffer[0..size]) +} diff --git a/cs2-demo/src/proto.rs b/cs2-demo/src/proto.rs new file mode 100644 index 0000000..a456482 --- /dev/null +++ b/cs2-demo/src/proto.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/proto/mod.rs")); diff --git a/csdemoparser/Cargo.toml b/csdemoparser/Cargo.toml index 6f8fcc3..24cd1af 100644 --- a/csdemoparser/Cargo.toml +++ b/csdemoparser/Cargo.toml @@ -7,9 +7,11 @@ edition = "2021" [dependencies] anyhow = "1.0" -bitstream-io = "1.0" +bitstream-io.workspace = true byteorder = "1.4" csgo-demo-parser = { path = "../csgo-demo-parser" } +cs2-demo = { path = "../cs2-demo" } +demo-format = { path = "../demo-format" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" diff --git a/csdemoparser/src/cs2.rs b/csdemoparser/src/cs2.rs new file mode 100644 index 0000000..b649288 --- /dev/null +++ b/csdemoparser/src/cs2.rs @@ -0,0 +1,182 @@ +use crate::demoinfo::{ + BombDefused, BombExploded, Event, EventTick, PlayerDeath, PlayerDisconnect, PlayerHurt, + RoundEnd, RoundStart, +}; + +use crate::game_event::{from_cs2_event, parse_game_event_list_impl, GameEvent}; +use crate::{game_event, DemoInfo}; +use cs2_demo::packet::Message; +use cs2_demo::proto::demo::CDemoFileHeader; +use cs2_demo::proto::gameevents::CMsgSource1LegacyGameEventList; +use cs2_demo::{DemoCommand, Tick}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::io; +use std::rc::Rc; +use tracing::trace; + +pub fn parse(read: &mut dyn io::Read) -> anyhow::Result { + let mut parser = cs2_demo::DemoParser::try_new_after_demo_type(read)?; + let mut state = GameState::new(); + while let Some((tick, cmd)) = parser.parse_next_demo_command()? { + trace!("t#{tick:?} {cmd}"); + match cmd { + DemoCommand::FileHeader(header) => { + state.handle_file_header(header)?; + } + DemoCommand::Packet(p) => { + for msg in p.messages { + state.handle_packet(msg, tick)?; + } + } + _ => {} + } + } + state.get_info() +} + +#[derive(Default)] +struct GameState { + game_event_descriptors: HashMap, + events: Vec, + jumped_last: HashMap, + demoinfo: Rc>, +} + +impl GameState { + fn new() -> Self { + GameState { + ..Default::default() + } + } + + fn handle_file_header(&mut self, header: CDemoFileHeader) -> anyhow::Result<()> { + let mut demoinfo = self.demoinfo.borrow_mut(); + demoinfo.servername = header.server_name().to_string(); + demoinfo.map = header.map_name().to_string(); + Ok(()) + } + + fn handle_packet(&mut self, msg: Message, tick: Tick) -> anyhow::Result<()> { + match msg { + Message::Source1LegacyGameEvent(event) => { + if let Some(descriptor) = self.game_event_descriptors.get(&event.eventid()) { + let event = from_cs2_event(event, descriptor)?; + self.handle_game_event(event, tick)?; + } + } + Message::Source1LegacyGameEventList(gel) => { + self.game_event_descriptors = parse_game_event_list(gel); + } + Message::Unknown(_) => (), + } + Ok(()) + } + + fn get_info(self) -> anyhow::Result { + let mut demoinfo = self.demoinfo.borrow_mut(); + demoinfo.events = self + .events + .iter() + .map(serde_json::to_value) + .collect::>()?; + Ok(demoinfo.clone()) + } + + fn add_event(&mut self, tick: Tick, event: Event) { + self.events.push(EventTick { tick, event }) + } + + fn maybe_xuid(&mut self, userid: i32) -> u64 { + // TODO: lookup self.players + userid as u64 + } + + fn handle_game_event(&mut self, ge: GameEvent, tick: Tick) -> anyhow::Result<()> { + trace!("GameEvent {:?}", ge); + match ge { + GameEvent::BombDefused(e) => { + let userid = self.maybe_xuid(e.userid); + self.add_event(tick, Event::BombDefused(BombDefused { userid })) + } + GameEvent::BombExploded(e) => { + let userid = self.maybe_xuid(e.userid); + self.add_event(tick, Event::BombExploded(BombExploded { userid })) + } + GameEvent::PlayerDeath(e) => { + // TODO: add processing + let userid = self.maybe_xuid(e.userid); + let attacker = self.maybe_xuid(e.attacker); + let assister = self.maybe_xuid(e.assister); + self.add_event( + tick, + Event::PlayerDeath(PlayerDeath { + userid, + attacker, + assister, + assistedflash: e.assistedflash, + weapon: e.weapon, + headshot: e.headshot, + penetrated: e.penetrated, + noscope: e.noscope, + thrusmoke: e.thrusmoke, + attackerblind: e.attackerblind, + distance: e.distance, + }), + ) + } + GameEvent::PlayerConnect(_) => { + // TODO: processing + } + GameEvent::PlayerDisconnect(e) => { + // TODO: processing + let userid = self.maybe_xuid(e.userid); + self.add_event(tick, Event::PlayerDisconnected(PlayerDisconnect { userid })) + } + GameEvent::PlayerHurt(e) => { + let userid = self.maybe_xuid(e.userid); + let attacker = self.maybe_xuid(e.attacker); + self.add_event( + tick, + Event::PlayerHurt(PlayerHurt { + userid, + attacker, + dmg_health: e.dmg_health, + }), + ) + } + GameEvent::PlayerJump(e) => { + self.jumped_last.insert(e.userid, tick); + } + GameEvent::PlayerSpawn(_) => { + // In CS:GO, player_spawn was used to determine the team composition + // for each round. But in CS2, the PlayerSpawn event doesn't have a + // teamnum, so it is useless. + } + GameEvent::RoundStart(e) => { + // TODO: add processing + self.add_event( + tick, + Event::RoundStart(RoundStart { + timelimit: e.timelimit, + }), + ) + } + GameEvent::RoundEnd(e) => self.add_event( + tick, + Event::RoundEnd(RoundEnd { + winner: e.winner, + reason: e.reason, + message: e.message, + }), + ), + GameEvent::RoundOfficiallyEnded => self.add_event(tick, Event::RoundOfficiallyEnded), + // In CS2 we always have player_death.thrusmoke. + GameEvent::SmokegrenadeDetonate(_) => (), + GameEvent::SmokegrenadeExpired(_) => (), + } + Ok(()) + } +} + +parse_game_event_list_impl!(CMsgSource1LegacyGameEventList); diff --git a/csdemoparser/src/csgo.rs b/csdemoparser/src/csgo.rs new file mode 100644 index 0000000..089b14c --- /dev/null +++ b/csdemoparser/src/csgo.rs @@ -0,0 +1,616 @@ +use crate::entity::{Entities, Entity, EntityId, PropValue, Scalar}; +use crate::entity::{ServerClasses, TrackProp}; +use crate::game_event::parse_game_event_list_impl; +use crate::geometry::{through_smoke, Point}; +use crate::string_table::{self, PlayerInfo, StringTables}; +use crate::{ + account_id_to_xuid, game_event, guid_to_xuid, maybe_get_i32, maybe_get_u16, DemoInfo, TeamScore, +}; +use anyhow::bail; +use csgo_demo_parser::messages::{CSVCMsg_GameEvent, CSVCMsg_GameEventList}; +use csgo_demo_parser::parser::packet::message::{Message, SvcMessage, UsrMessage}; +use csgo_demo_parser::parser::string_table::StringTable; +use csgo_demo_parser::parser::PacketContent; +use demo_format::Tick; +use serde_json::json; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::io; +use std::rc::Rc; +use tracing::instrument; + +const VEC_ORIGIN_XY: &str = "m_vecOrigin"; +const VEC_ORIGIN_Z: &str = "m_vecOrigin[2]"; +const VEC_VELOCITY_Z: &str = "m_vecVelocity[2]"; +const IS_SCOPED: &str = "m_bIsScoped"; + +const PLAYER_CLASS: &str = "CCSPlayer"; + +const GAME_RULES_CLASS: &str = "CCSGameRulesProxy"; +const GAME_RESTART: &str = "m_bGameRestart"; + +const TEAM_CLASS: &str = "CCSTeam"; + +pub fn parse(read: &mut dyn io::Read) -> anyhow::Result { + let mut parser = csgo_demo_parser::DemoParser::try_new_after_demo_type(read)?; + let server_name = parser.header().server_name().to_string(); + let mut server_classes = None; + let mut packets = vec![]; + while let Some((header, content)) = parser.parse_next_packet()? { + match content { + PacketContent::DataTables(dt) => { + server_classes = Some(ServerClasses::try_new(dt)?); + break; + } + PacketContent::Packet(pv) => packets.push((pv, *header.tick())), + PacketContent::StringTables(_) => todo!(), + _ => (), + } + if packets.len() > 1000 { + bail!("no DataTables in the first 1000 packets") + } + } + + let Some(mut server_classes) = server_classes else { bail!("no data tables before the first event") }; + let mut hsbox = HeadshotBoxParser::new(server_name, &mut server_classes); + for (pv, tick) in packets { + for p in pv { + hsbox.handle_packet(p, tick)?; + } + } + while let Some((header, content)) = parser.parse_next_packet()? { + match content { + PacketContent::Packet(pv) => { + for p in pv { + hsbox.handle_packet(p, *header.tick())?; + } + } + PacketContent::StringTables(st) => hsbox.handle_string_tables(st)?, + _ => (), + } + } + hsbox.get_info() +} + +type GameEvent = serde_json::Map; + +struct HeadshotBoxParser<'a> { + game_event_descriptors: HashMap, + string_tables: StringTables, + players: HashMap, + jumped_last: HashMap, + tick_interval: f32, + entities: Entities<'a>, + smokes: BTreeMap, + bot_takeover: HashMap, + scoped_since: Rc>>, + score: Rc>, + demoinfo: Rc>, +} + +impl<'a> HeadshotBoxParser<'a> { + fn new(server_name: String, server_classes: &'a mut ServerClasses) -> Self { + let scoped_since = Rc::new(RefCell::new(HashMap::new())); + let score: Rc> = Rc::new(RefCell::new(Default::default())); + let demoinfo = Rc::new(RefCell::new(DemoInfo { + servername: server_name, + ..Default::default() + })); + + for sc in server_classes.server_classes.iter_mut() { + for prop in sc.props.iter_mut() { + prop.track = match (sc.name.as_str(), prop.name.as_str()) { + (TEAM_CLASS, "m_iTeamNum") => { + let score = Rc::clone(&score); + TrackProp::Changes(Rc::new(move |entity, _, value| match value { + PropValue::Scalar(Scalar::I32(id)) if id == &2 || id == &3 => { + score.borrow_mut().team_entity_id[(id - 2) as usize] + .replace(entity.id); + } + _ => (), + })) + } + (TEAM_CLASS, "m_scoreTotal") => { + let score = Rc::clone(&score); + let demoinfo = Rc::clone(&demoinfo); + TrackProp::Changes(Rc::new(move |entity, tick, value| { + let mut score = score.borrow_mut(); + if score.update(entity, value) { + demoinfo.borrow_mut().events.push(json!({ + "type": "score_changed", + "tick": tick, + "score": [score.score[0], score.score[1]], + })); + } + })) + } + (GAME_RULES_CLASS, GAME_RESTART) => { + let demoinfo = Rc::clone(&demoinfo); + TrackProp::Changes(Rc::new(move |_, tick, value| { + if let PropValue::Scalar(Scalar::I32(1)) = value { + demoinfo + .borrow_mut() + .events + .push(json!({"type": "game_restart", "tick": tick})); + } + })) + } + (PLAYER_CLASS, VEC_ORIGIN_XY | VEC_ORIGIN_Z | VEC_VELOCITY_Z) => { + TrackProp::Value + } + (PLAYER_CLASS, IS_SCOPED) => { + let scoped_since = Rc::clone(&scoped_since); + TrackProp::Changes(Rc::new(move |entity, tick, value| { + if let PropValue::Scalar(Scalar::I32(1)) = value { + scoped_since.borrow_mut().insert(entity.id, tick); + } else { + scoped_since.borrow_mut().remove(&entity.id); + } + })) + } + _ => TrackProp::No, + } + } + } + + HeadshotBoxParser { + game_event_descriptors: Default::default(), + string_tables: StringTables::new(), + players: Default::default(), + jumped_last: HashMap::new(), + tick_interval: 0.0, + entities: Entities::new(server_classes), + smokes: Default::default(), + bot_takeover: Default::default(), + scoped_since, + score, + demoinfo, + } + } + + fn update_players( + players: &mut HashMap, + demoinfo: &Rc>, + player_info: PlayerInfo, + ) { + let mut demoinfo = demoinfo.borrow_mut(); + if !player_info.fakeplayer && !player_info.is_hltv { + demoinfo + .player_slots + .insert(player_info.xuid.to_string(), player_info.entity_id); + demoinfo + .player_names + .insert(player_info.xuid.to_string(), player_info.name.to_string()); + } + players.retain(|_, p| p.entity_id != player_info.entity_id); + players.insert(player_info.user_id, player_info); + } + + fn handle_string_tables(&mut self, st: Vec) -> anyhow::Result<()> { + // demoinfogo clears the players but I don't think this is correct + self.players.clear(); + for st in st.iter().filter(|st| st.name() == "userinfo") { + for (entity_id, string) in st.strings().iter().enumerate() { + if let Some(data) = string.data() { + let player_info = string_table::parse_player_info(data, entity_id as i32)?; + Self::update_players(&mut self.players, &self.demoinfo, player_info); + } + } + } + Ok(()) + } + + #[instrument(skip_all)] + fn handle_packet(&mut self, p: Message, tick: Tick) -> anyhow::Result<()> { + match p { + Message::Svc(SvcMessage::ServerInfo(info)) => { + self.demoinfo.borrow_mut().map = info.map_name().to_string(); + self.tick_interval = info.tick_interval(); + } + Message::Svc(SvcMessage::CreateStringTable(table)) => { + let mut updates = self.string_tables.create_string_table(&table); + while let Some(player_info) = updates.next()? { + Self::update_players(&mut self.players, &self.demoinfo, player_info); + } + } + Message::Svc(SvcMessage::UpdateStringTable(table)) => { + let mut updates = self.string_tables.update_string_table(&table)?; + while let Some(player_info) = updates.next()? { + Self::update_players(&mut self.players, &self.demoinfo, player_info); + } + } + Message::Svc(SvcMessage::GameEventList(gel)) => { + self.game_event_descriptors = parse_game_event_list(gel) + } + Message::Svc(SvcMessage::GameEvent(event)) => { + if let Some(descriptor) = self.game_event_descriptors.get(&event.eventid()) { + let attrs = self.event_map(event, descriptor, tick)?; + self.handle_game_event(attrs, tick)?; + } + } + Message::Usr(UsrMessage::ServerRankUpdate(ranks)) => { + let mut mm_rank_update = serde_json::Map::new(); + for update in ranks.rank_update { + let mut attr = serde_json::Map::new(); + if update.has_num_wins() { + attr.insert("num_wins".to_string(), json!(update.num_wins())); + } + if update.has_rank_old() { + attr.insert("rank_old".to_string(), json!(update.rank_old())); + } + if update.has_rank_new() { + attr.insert("rank_new".to_string(), json!(update.rank_new())); + } + if update.has_rank_change() { + attr.insert("rank_change".to_string(), json!(update.rank_change())); + } + let xuid = account_id_to_xuid(update.account_id()); + mm_rank_update.insert(xuid.to_string(), serde_json::Value::Object(attr)); + } + self.demoinfo.borrow_mut().mm_rank_update = + Some(serde_json::Value::Object(mm_rank_update)); + } + Message::Svc(SvcMessage::PacketEntities(msg)) => { + self.entities.read_packet_entities(msg, tick)? + } + _ => (), + } + Ok(()) + } + + fn add_smoke(&mut self, attrs: &GameEvent) -> Option<()> { + let entity_id = maybe_get_u16(attrs.get("entityid"))?; + let x = attrs.get("x")?.as_f64()?; + let y = attrs.get("y")?.as_f64()?; + let z = attrs.get("z")?.as_f64()?; + let p = Point::new(x, y, z); + self.smokes.insert(entity_id, p); + None + } + + fn handle_game_event(&mut self, mut attrs: GameEvent, tick: Tick) -> anyhow::Result<()> { + let emit = |attrs| { + self.demoinfo + .borrow_mut() + .events + .push(serde_json::Value::Object(attrs)) + }; + match attrs.get("type").unwrap().as_str().unwrap() { + "player_jump" => { + if let Some(user_id) = maybe_get_i32(attrs.get("userid")) { + self.jumped_last.insert(user_id, tick); + } + } + "smokegrenade_detonate" => { + self.add_smoke(&attrs); + } + "smokegrenade_expired" => { + if let Some(entity_id) = maybe_get_u16(attrs.get("entityid")) { + self.smokes.remove(&entity_id); + } + } + "round_start" => { + self.smokes.clear(); + self.bot_takeover.clear(); + self.scoped_since.borrow_mut().clear(); + self.score.borrow_mut().set_round_start(); + emit(attrs); + } + "player_death" => { + if let Some(attacker_user_id) = maybe_get_i32(attrs.get("attacker")) { + if self.players.get(&attacker_user_id).is_some() { + if let Some(jump) = self.jumped_last(attacker_user_id, tick) { + attrs.insert("jump".to_string(), json!(jump)); + } + } + } + let attacker_info = self.get_player_info("attacker", &attrs); + let victim = self.get_player_entity("userid", &attrs); + let attacker = self.get_player_entity("attacker", &attrs); + self.replace_user_id_with_xuid("userid", &mut attrs); + self.replace_user_id_with_xuid("attacker", &mut attrs); + self.replace_user_id_with_xuid("assister", &mut attrs); + if let (Some(victim), Some(attacker)) = (victim, attacker) { + self.add_player_death_attrs(&mut attrs, victim, attacker); + } + if let Some(attacker) = attacker { + if let Some(PropValue::Scalar(Scalar::F32(z))) = + attacker.get_prop(VEC_VELOCITY_Z) + { + attrs.insert("air_velocity".into(), json!(z)); + } + if let Some(since) = self.scoped_since.borrow().get(&attacker.id) { + if let Some(false) = attacker_info.map(|a| a.fakeplayer) { + attrs.insert("scoped_since".to_string(), json!(since)); + } + } + } + emit(attrs); + } + "bot_takeover" => { + if let Some(player_info) = self.get_player_info("userid", &attrs) { + if let Some(botid) = maybe_get_i32(attrs.get("botid")) { + self.bot_takeover.insert(player_info.xuid, botid); + } + } + } + "player_connect" => { + if let Some(player_info) = self.handle_player_connect(attrs) { + Self::update_players(&mut self.players, &self.demoinfo, player_info); + } + } + "player_disconnect" => { + let user_id = maybe_get_i32(attrs.get("userid")); + self.replace_user_id_with_xuid("userid", &mut attrs); + attrs.insert("type".to_string(), json!("player_disconnected")); + attrs.remove("networkid"); + if let Some(user_id) = user_id { + self.players.remove(&user_id); + } + emit(attrs); + } + _ => { + self.replace_user_id_with_xuid("userid", &mut attrs); + self.replace_user_id_with_xuid("attacker", &mut attrs); + emit(attrs); + } + } + Ok(()) + } + + fn handle_player_connect(&self, attrs: GameEvent) -> Option { + let user_id = maybe_get_i32(attrs.get("userid"))?; + let name = attrs.get("name")?.as_str()?.to_owned(); + let entity_id = maybe_get_i32(attrs.get("index"))?; + let guid = attrs.get("networkid")?.as_str()?.to_string(); + let fakeplayer = guid == "BOT"; + let xuid = guid_to_xuid(&guid).unwrap_or(0); + Some(PlayerInfo { + version: 0, + xuid, + name, + user_id, + guid, + friends_id: 0, + friends_name: "".to_owned(), + fakeplayer, + is_hltv: false, + files_downloaded: 0, + entity_id, + }) + } + + fn get_info(self) -> anyhow::Result { + let mut demoinfo = self.demoinfo.borrow_mut(); + demoinfo.gotv_bots = self + .players + .values() + .filter(|p| p.is_hltv) + .map(|p| p.name.to_string()) + .collect(); + demoinfo.tickrate = self.tick_interval; + // TODO: this is slow + Ok((*demoinfo).clone()) + } + + fn event_map( + &self, + event: CSVCMsg_GameEvent, + descriptor: &game_event::Descriptor, + tick: Tick, + ) -> anyhow::Result { + let mut attrs = serde_json::Map::new(); + for (i, descriptor_key) in descriptor.keys.iter().enumerate() { + let event_key = &event.keys[i]; + let key = descriptor_key.name.clone(); + if event_key.type_() != descriptor_key.type_ { + bail!("event key type does not match descriptor type"); + } + let val = match descriptor_key.type_ { + 1 => json!(event_key.val_string()), + 2 => json!(event_key.val_float()), + 3 => json!(event_key.val_long()), + 4 => json!(event_key.val_short()), + 5 => json!(event_key.val_byte()), + 6 => json!(event_key.val_bool()), + 7 => json!(event_key.val_uint64()), + e => bail!("unknown event key type {}", e), + }; + attrs.insert(key, val); + } + attrs.insert("type".into(), json!(descriptor.name)); + attrs.insert("tick".into(), json!(tick)); + Ok(attrs) + } + + fn jumped_last(&self, user_id: i32, tick: Tick) -> Option { + let &jumped_last = self.jumped_last.get(&user_id)?; + const JUMP_DURATION: f64 = 0.75; + if self.tick_interval > 0_f32 + && jumped_last as f64 >= tick as f64 - JUMP_DURATION / self.tick_interval as f64 + { + Some(tick - jumped_last) + } else { + None + } + } + + fn get_player_info(&self, key: &str, attrs: &GameEvent) -> Option<&PlayerInfo> { + let user_id = maybe_get_i32(attrs.get(key))?; + let player_info = self.players.get(&user_id)?; + Some(player_info) + } + + fn get_player_entity(&self, key: &str, attrs: &GameEvent) -> Option<&Entity> { + let user_id = maybe_get_i32(attrs.get(key))?; + let player_info = self.players.get(&user_id)?; + let entity_id: EntityId = player_info.entity_id as u16 + 1; + self.entities.get(entity_id) + } + + fn replace_user_id_with_xuid(&self, key: &str, attrs: &mut GameEvent) { + if let Some(player_info) = self.get_player_info(key, attrs) { + if player_info.fakeplayer { + return; + } + let mut xuid = player_info.xuid; + if let Some(&botid) = self.bot_takeover.get(&xuid) { + match (attrs.get("type").and_then(serde_json::Value::as_str), key) { + // player_spawn happens before round_start when we clear bot_takeover. + (Some("player_spawn"), _) => {} + (Some("player_disconnect"), _) => {} + // CS:GO awards assists to the controlling human instead of the bot. + (Some("player_death"), "assister") => {} + _ => xuid = botid as u64, + } + } + attrs.insert(key.to_string(), json!(xuid)); + } + } + + fn add_player_death_attrs(&self, attrs: &mut GameEvent, victim: &Entity, attacker: &Entity) { + if let (Some(victim_pos), Some(attacker_pos)) = + (self.get_position(victim), self.get_position(attacker)) + { + let smokes: Vec = self + .smokes + .values() + .filter(|smoke| through_smoke(&attacker_pos, &victim_pos, smoke)) + .map(|smoke| (*smoke).into()) + .collect(); + if !smokes.is_empty() { + attrs.insert("smoke".into(), json!(smokes)); + } + attrs.insert("attacker_pos".into(), attacker_pos.into()); + attrs.insert("victim_pos".into(), victim_pos.into()); + } + } + + fn get_position(&self, entity: &Entity) -> Option { + if let PropValue::Scalar(Scalar::Vector(xy)) = entity.get_prop(VEC_ORIGIN_XY)? { + if let PropValue::Scalar(Scalar::F32(z)) = entity.get_prop(VEC_ORIGIN_Z)? { + return Some(Point::new(xy.x as f64, xy.y as f64, *z as f64)); + } + } + None + } +} + +parse_game_event_list_impl!(CSVCMsg_GameEventList); + +#[cfg(test)] +mod tests { + use super::*; + use assert_json_diff::assert_json_eq; + + fn make_parser(server_classes: &mut ServerClasses) -> HeadshotBoxParser { + let mut parser = HeadshotBoxParser::new("".to_owned(), server_classes); + parser.tick_interval = 1f32 / 64f32; + parser.players.insert( + 7, + PlayerInfo { + xuid: 1007, + ..Default::default() + }, + ); + parser + } + + fn make_server_classes() -> ServerClasses { + ServerClasses { + bits: 0, + server_classes: vec![], + } + } + + fn make_game_event(object: serde_json::Value) -> GameEvent { + object.as_object().unwrap().clone() + } + + fn emitted_event( + parser: &mut HeadshotBoxParser, + event: serde_json::Value, + tick: Tick, + expected: serde_json::Value, + ) { + handle_event(parser, event, tick); + assert_json_eq!(parser.demoinfo.borrow().events.last().unwrap(), expected); + } + + fn handle_event(parser: &mut HeadshotBoxParser, event: serde_json::Value, tick: Tick) { + let attrs = make_game_event(event); + parser.handle_game_event(attrs, tick).unwrap() + } + + #[test] + fn jump_death() { + let mut server_classes = make_server_classes(); + let mut parser = make_parser(&mut server_classes); + handle_event(&mut parser, json!({"type": "player_jump", "userid": 7}), 1); + emitted_event( + &mut parser, + json!({"type": "player_death", "userid": 7, "attacker": 7}), + 2, + json!({"type": "player_death", "userid": 1007, "attacker": 1007, "jump": 1}), + ); + } + + #[test] + fn jump_disconnect_death() { + let mut server_classes = make_server_classes(); + let mut parser = make_parser(&mut server_classes); + handle_event(&mut parser, json!({"type": "player_jump", "userid": 7}), 1); + handle_event( + &mut parser, + json!({"type": "player_disconnect", "userid": 7}), + 2, + ); + emitted_event( + &mut parser, + json!({"type": "player_death", "userid": 7, "attacker": 7}), + 2, + json!({"type": "player_death", "userid": 7, "attacker": 7}), + ); + } + + #[test] + fn bot_takeover() { + let mut server_classes = make_server_classes(); + let mut parser = make_parser(&mut server_classes); + handle_event( + &mut parser, + json!({"type": "bot_takeover", "userid": 7, "botid": 31}), + 1, + ); + emitted_event( + &mut parser, + json!({"type": "player_hurt", "attacker": 7}), + 2, + json!({"type": "player_hurt", "attacker": 31}), + ); + emitted_event( + &mut parser, + json!({"type": "player_death", "attacker": 7}), + 2, + json!({"type": "player_death", "attacker": 31}), + ); + emitted_event( + &mut parser, + json!({"type": "player_death", "assister": 7}), + 2, + json!({"type": "player_death", "assister": 1007}), + ); + emitted_event( + &mut parser, + json!({"type": "player_spawn", "userid": 7}), + 3, + json!({"type": "player_spawn", "userid": 1007}), + ); + emitted_event( + &mut parser, + json!({"type": "player_disconnect", "userid": 7}), + 3, + json!({"type": "player_disconnected", "userid": 1007}), + ); + } +} diff --git a/csdemoparser/src/demoinfo.rs b/csdemoparser/src/demoinfo.rs new file mode 100644 index 0000000..a1eb111 --- /dev/null +++ b/csdemoparser/src/demoinfo.rs @@ -0,0 +1,122 @@ +use demo_format::Tick; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// The output of csdemoparser. +#[derive(Serialize, Deserialize, Clone)] +pub struct DemoInfo { + // TODO: use Vec instead. + pub events: Vec, + pub gotv_bots: Vec, + pub map: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mm_rank_update: Option, + pub player_names: HashMap, + pub player_slots: HashMap, + pub servername: String, + pub tickrate: f32, +} + +impl Default for DemoInfo { + fn default() -> Self { + DemoInfo { + events: Vec::new(), + map: String::new(), + gotv_bots: Vec::new(), + mm_rank_update: None, + player_names: Default::default(), + player_slots: Default::default(), + servername: String::new(), + tickrate: 0.0, + } + } +} + +#[derive(Serialize)] +pub struct EventTick { + pub tick: Tick, + #[serde(flatten)] + pub event: Event, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Event { + BombDefused(BombDefused), + BombExploded(BombExploded), + PlayerHurt(PlayerHurt), + PlayerDeath(PlayerDeath), + PlayerDisconnected(PlayerDisconnect), + RoundStart(RoundStart), + RoundEnd(RoundEnd), + RoundOfficiallyEnded, +} + +type Xuid = u64; + +#[derive(Serialize)] +pub struct BombDefused { + pub userid: Xuid, +} + +#[derive(Serialize)] +pub struct BombExploded { + pub userid: Xuid, +} + +#[derive(Serialize)] +pub struct PlayerHurt { + pub userid: Xuid, + pub attacker: Xuid, + pub dmg_health: i32, +} + +#[derive(Serialize)] +pub struct PlayerDeath { + pub userid: Xuid, + pub attacker: Xuid, + pub assister: Xuid, + pub assistedflash: bool, + pub weapon: String, + pub headshot: bool, + pub penetrated: i32, + pub noscope: bool, + pub thrusmoke: bool, + pub attackerblind: bool, + pub distance: f32, +} + +#[derive(Serialize)] +pub struct PlayerDisconnect { + pub userid: Xuid, +} + +#[derive(Serialize)] +pub struct RoundStart { + pub timelimit: i32, +} + +#[derive(Serialize)] +pub struct RoundEnd { + pub winner: i32, + pub reason: i32, + pub message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn game_event_json() -> anyhow::Result<()> { + let defuse = EventTick { + tick: 1, + event: Event::BombDefused(BombDefused { userid: 2 }), + }; + assert_eq!( + serde_json::to_string(&defuse)?, + r#"{"tick":1,"type":"bomb_defused","userid":2}"# + ); + Ok(()) + } +} diff --git a/csdemoparser/src/entity.rs b/csdemoparser/src/entity.rs index 5226ed5..25fdb7c 100644 --- a/csdemoparser/src/entity.rs +++ b/csdemoparser/src/entity.rs @@ -1,11 +1,12 @@ /// https://developer.valvesoftware.com/wiki/Networking_Entities mod serverclass; -use crate::BitReader; -use crate::Tick; use anyhow::{bail, Context}; use bitstream_io::BitRead; use csgo_demo_parser::messages::CSVCMsg_PacketEntities; +use demo_format::read::ValveBitReader; +use demo_format::BitReader; +use demo_format::Tick; use serverclass::ServerClass; pub(crate) use serverclass::ServerClasses; use std::io; @@ -225,235 +226,3 @@ fn read_entity_field_index( Ok(Some(last_index + 1 + offset as i32)) } } - -enum CoordType { - None, - LowPrecision, - Integral, -} - -const COORD_INTEGER_BITS: u32 = 14; -const COORD_INTEGER_BITS_MP: u32 = 11; -const COORD_FRACTIONAL_BITS: u32 = 5; -const COORD_FRACTIONAL_BITS_LOWPRECISION: u32 = 3; -const COORD_RESOLUTION: f32 = 1_f32 / (1 << COORD_FRACTIONAL_BITS) as f32; -const COORD_RESOLUTION_LOWPRECISION: f32 = 1_f32 / (1 << COORD_FRACTIONAL_BITS_LOWPRECISION) as f32; - -#[inline] -fn read_coord_fraction(reader: &mut BitReader) -> io::Result { - Ok(reader.read::(COORD_FRACTIONAL_BITS)? as f32 * COORD_RESOLUTION) -} - -#[inline] -fn read_coord_fraction_low_precision(reader: &mut BitReader) -> io::Result { - Ok( - reader.read::(COORD_FRACTIONAL_BITS_LOWPRECISION)? as f32 - * COORD_RESOLUTION_LOWPRECISION, - ) -} - -trait EntityReader { - fn read_ubitvar(&mut self) -> io::Result; - fn read_varint32(&mut self) -> io::Result; - fn read_signed_varint32(&mut self) -> io::Result; - - fn read_coord(&mut self) -> io::Result; - fn skip_coord(&mut self) -> io::Result<()>; - - fn read_coord_mp(&mut self, coord_type: CoordType) -> io::Result; - fn skip_coord_mp(&mut self, coord_type: CoordType) -> io::Result<()>; - - fn read_cell_coord(&mut self, int_bits: u32, coord_type: CoordType) -> io::Result; - fn skip_cell_coord(&mut self, int_bits: u32, coord_type: CoordType) -> io::Result<()>; - - fn read_normal(&mut self) -> io::Result; - fn skip_normal(&mut self) -> io::Result<()>; -} - -impl<'a> EntityReader for BitReader<'a> { - /// Read a 32-bit value using the UBitVar Valve format. - fn read_ubitvar(&mut self) -> io::Result { - let tmp = self.read::(6)?; - let last4 = tmp & 15; - match tmp & (16 | 32) { - 16 => { - let ret = (self.read::(4)? << 4) | last4; - debug_assert!(ret >= 16); - Ok(ret) - } - 32 => { - let ret = (self.read::(8)? << 4) | last4; - debug_assert!(ret >= 256); - Ok(ret) - } - 48 => { - let ret = (self.read::(32 - 4)? << 4) | last4; - debug_assert!(ret >= 4096); - Ok(ret) - } - _ => Ok(last4), - } - } - - fn read_varint32(&mut self) -> io::Result { - let mut result = 0; - for byte in 0..5 { - let b = self.read::(8)?; - result |= ((b & 0x7F) as u32) << (byte * 7); - if b & 0x80 == 0 { - break; - } - } - Ok(result) - } - - fn read_signed_varint32(&mut self) -> io::Result { - Ok(zigzag_decode(self.read_varint32()?)) - } - - fn read_coord(&mut self) -> io::Result { - let int = self.read_bit()?; - let fract = self.read_bit()?; - if !int && !fract { - return Ok(0_f32); - } - let sign = self.read_bit()?; - let intval = if int { - self.read::(COORD_INTEGER_BITS)? + 1 - } else { - 0 - }; - let fractval = if fract { - read_coord_fraction(self)? - } else { - 0_f32 - }; - let abs = intval as f32 + fractval; - Ok(if sign { -abs } else { abs }) - } - - fn skip_coord(&mut self) -> io::Result<()> { - let int_bits = self.read_bit()? as u32 * COORD_INTEGER_BITS; - let fract_bits = self.read_bit()? as u32 * COORD_FRACTIONAL_BITS; - let bits = int_bits + fract_bits + if int_bits + fract_bits > 0 { 1 } else { 0 }; - self.skip(bits) - } - - fn read_coord_mp(&mut self, coord_type: CoordType) -> io::Result { - let int_bits = if self.read_bit()? { - COORD_INTEGER_BITS_MP - } else { - COORD_INTEGER_BITS - }; - let has_int = self.read_bit()?; - let negative = match coord_type { - CoordType::Integral if !has_int => false, - _ => self.read_bit()?, - }; - let int = if has_int { - self.read::(int_bits)? + 1 - } else { - 0 - }; - let fract = match coord_type { - CoordType::None => read_coord_fraction(self)?, - CoordType::LowPrecision => read_coord_fraction_low_precision(self)?, - CoordType::Integral => 0_f32, - }; - let abs = int as f32 + fract; - Ok(if negative { -abs } else { abs }) - } - - fn skip_coord_mp(&mut self, coord_type: CoordType) -> io::Result<()> { - let mut int_bits = if self.read_bit()? { - COORD_INTEGER_BITS_MP - } else { - COORD_INTEGER_BITS - }; - let has_int = self.read_bit()?; - if !has_int { - int_bits = 0; - } - let bits = match coord_type { - CoordType::None => int_bits + 1 + COORD_FRACTIONAL_BITS, - CoordType::LowPrecision => int_bits + 1 + COORD_FRACTIONAL_BITS_LOWPRECISION, - CoordType::Integral => int_bits + has_int as u32, - }; - self.skip(bits) - } - - fn read_cell_coord(&mut self, int_bits: u32, coord_type: CoordType) -> io::Result { - let int = self.read::(int_bits)? as f32; - let val = match coord_type { - CoordType::None => int + read_coord_fraction(self)?, - CoordType::LowPrecision => int + read_coord_fraction_low_precision(self)?, - CoordType::Integral => int, - }; - Ok(val) - } - - fn skip_cell_coord(&mut self, int_bits: u32, coord_type: CoordType) -> io::Result<()> { - match coord_type { - CoordType::None => self.skip(int_bits + COORD_FRACTIONAL_BITS), - CoordType::LowPrecision => self.skip(int_bits + COORD_FRACTIONAL_BITS_LOWPRECISION), - CoordType::Integral => self.skip(int_bits), - } - } - - fn read_normal(&mut self) -> io::Result { - let sign = self.read_bit()?; - let fract = self.read::(11)? as f32; - let abs = fract * 1_f32 / ((1 << 11) - 1) as f32; - Ok(if sign { -abs } else { abs }) - } - - fn skip_normal(&mut self) -> io::Result<()> { - self.skip(12) - } -} - -/// The ZigZag transform is used to minimize the number of bytes needed by the unsigned varint -/// encoding. -/// -/// Otherwise, small negative numbers like -1 reinterpreted as u32 would be a very large number -/// and use all 5 bytes in the varint encoding. -fn zigzag_decode(n: u32) -> i32 { - ((n >> 1) as i32) ^ -((n & 1) as i32) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn varint() { - let mut read = BitReader::new(&[0x01]); - assert_eq!(read.read_varint32().unwrap(), 1); - - let mut read = BitReader::new(&[0x81, 0x23]); - assert_eq!(read.read_varint32().unwrap(), 4481); - - let mut read = BitReader::new(&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); - assert_eq!(read.read_varint32().unwrap(), 4294967295); - } - - #[test] - fn zigzag() { - let cases: [(u32, i32); 7] = [ - (0, 0), - (1, -1), - (2, 1), - (3, -2), - (4, 2), - (4294967294, 2147483647), - (4294967295, -2147483648), - ]; - for (encoded, decoded) in cases { - assert_eq!( - decoded, - zigzag_decode(encoded), - "zigzag_decode({encoded}) should be {decoded}" - ); - } - } -} diff --git a/csdemoparser/src/entity/serverclass.rs b/csdemoparser/src/entity/serverclass.rs index 911fab4..884e3f4 100644 --- a/csdemoparser/src/entity/serverclass.rs +++ b/csdemoparser/src/entity/serverclass.rs @@ -1,4 +1,4 @@ -use crate::entity::{BitReader, CoordType, EntityReader, PropValue, Scalar, Vector}; +use crate::entity::{BitReader, PropValue, Scalar, ValveBitReader, Vector}; use crate::num_bits; use anyhow::bail; use bitstream_io::BitRead; @@ -6,6 +6,7 @@ use csgo_demo_parser::messages::csvcmsg_class_info; use csgo_demo_parser::messages::csvcmsg_send_table::Sendprop_t; use csgo_demo_parser::messages::CSVCMsg_SendTable; use csgo_demo_parser::parser::data_table::DataTables; +use demo_format::read::CoordType; use std::collections::HashMap; use std::io; use std::string::FromUtf8Error; diff --git a/csdemoparser/src/game_event.rs b/csdemoparser/src/game_event.rs new file mode 100644 index 0000000..1a6b4c5 --- /dev/null +++ b/csdemoparser/src/game_event.rs @@ -0,0 +1,205 @@ +#![allow(dead_code)] + +mod de; + +pub(crate) use de::from_cs2_event; +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum GameEvent { + BombDefused(BombDefused), + BombExploded(BombExploded), + PlayerDeath(PlayerDeath), + PlayerHurt(PlayerHurt), + PlayerJump(PlayerJump), + PlayerSpawn(PlayerSpawn), + PlayerConnect(PlayerConnect), + PlayerDisconnect(PlayerDisconnect), + RoundStart(RoundStart), + RoundEnd(RoundEnd), + RoundOfficiallyEnded, + SmokegrenadeDetonate(SmokegrenadeDetonate), + SmokegrenadeExpired(SmokegrenadeExpired), +} + +#[derive(Debug, Deserialize)] +pub(crate) struct BombDefused { + pub userid: i32, // short, playercontroller +} + +#[derive(Debug, Deserialize)] +pub(crate) struct BombExploded { + pub userid: i32, // short, playercontroller +} + +#[derive(Debug, Deserialize)] +pub(crate) struct PlayerConnect { + pub name: String, + pub userid: i32, // short, playercontroller + pub networkid: String, + pub xuid: u64, + pub bot: bool, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +pub(crate) struct PlayerDisconnect { + pub userid: i32, // short, playercontroller + pub reason: i32, + pub name: String, + pub networkid: String, + pub xuid: u64, + pub PlayerID: i32, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct PlayerHurt { + pub userid: i32, // short, playercontroller + pub attacker: i32, // short, playercontroller + pub dmg_health: i32, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct PlayerDeath { + pub userid: i32, // short, playercontroller + pub attacker: i32, // short, playercontroller + pub assister: i32, // short, playercontroller + pub assistedflash: bool, + pub weapon: String, + pub headshot: bool, + pub penetrated: i32, + pub noscope: bool, + pub thrusmoke: bool, + pub attackerblind: bool, + pub distance: f32, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct PlayerSpawn { + pub userid: i32, // short, playercontroller +} + +#[derive(Debug, Deserialize)] +pub(crate) struct PlayerJump { + pub userid: i32, // short, playercontroller +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RoundStart { + pub timelimit: i32, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RoundEnd { + pub winner: i32, + pub reason: i32, + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct SmokegrenadeDetonate { + pub userid: i32, // short, playercontroller + pub entityid: i32, + pub x: f32, + pub y: f32, + pub z: f32, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct SmokegrenadeExpired { + pub userid: i32, // short, playercontroller + pub entityid: i32, + pub x: f32, + pub y: f32, + pub z: f32, +} + +pub(crate) struct DescriptorKey { + pub type_: i32, + pub name: String, +} + +pub(crate) struct Descriptor { + pub name: String, + pub keys: Vec, +} + +impl std::fmt::Display for Descriptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "struct {} {{", self.name)?; + for key in &self.keys { + match key.type_ { + 1 => writeln!(f, " {}: String,", key.name)?, + 2 => writeln!(f, " {}: f32,", key.name)?, + 3 => writeln!(f, " {}: i32, // long", key.name)?, + 4 => writeln!(f, " {}: i32, // short", key.name)?, + 5 => writeln!(f, " {}: i32, // byte", key.name)?, + 6 => writeln!(f, " {}: bool,", key.name)?, + 7 => writeln!(f, " {}: u64,", key.name)?, + 8 => writeln!(f, " {}: i32, // long, strict_ehandle", key.name)?, + 9 => writeln!(f, " {}: i32, // short, playercontroller", key.name)?, + t => writeln!(f, " {}: ", key.name)?, + }; + } + writeln!(f, "}}") + } +} + +#[allow(dead_code)] +pub(crate) fn dump_descriptors(descriptors: HashMap) { + let mut sorted: Vec<_> = descriptors.values().collect(); + sorted.sort_by_key(|d| &d.name); + for d in sorted { + println!("{d}"); + } +} + +macro_rules! parse_game_event_list_impl { + ($game_event_list:ty) => { + fn parse_game_event_list( + gel: $game_event_list, + ) -> HashMap { + let hsbox_events = std::collections::HashSet::from([ + "bomb_defused", + "bomb_exploded", + "bot_takeover", + "game_restart", + "player_connect", + "player_death", + "player_disconnect", + "player_hurt", + "player_jump", + "player_spawn", + "round_end", + "round_officially_ended", + "round_start", + "score_changed", + "smokegrenade_detonate", + "smokegrenade_expired", + ]); + gel.descriptors + .into_iter() + .filter(|d| hsbox_events.contains(d.name())) + .map(|d| { + ( + d.eventid(), + crate::game_event::Descriptor { + name: d.name().to_string(), + keys: d + .keys + .iter() + .map(|k| game_event::DescriptorKey { + name: k.name().to_string(), + type_: k.type_(), + }) + .collect(), + }, + ) + }) + .collect() + } + }; +} +pub(crate) use parse_game_event_list_impl; diff --git a/csdemoparser/src/game_event/de.rs b/csdemoparser/src/game_event/de.rs new file mode 100644 index 0000000..9df39c6 --- /dev/null +++ b/csdemoparser/src/game_event/de.rs @@ -0,0 +1,242 @@ +use super::Descriptor; +use cs2_demo::proto::gameevents::{cmsg_source1legacy_game_event, CMsgSource1LegacyGameEvent}; +use serde::{de, forward_to_deserialize_any}; +use std::fmt::Display; + +pub(crate) type Result = std::result::Result; + +#[derive(thiserror::Error, Debug)] +pub(crate) enum Error { + #[error("{0}")] + Message(String), + #[error("event {event}.{key} expected type {descriptor_type} but got type {event_type}")] + DescriptorMismatch { + event: String, + key: String, + descriptor_type: i32, + event_type: i32, + }, + #[error("event {event}.{key} has type {event_type} but deserializer requested {rust_type}")] + TypeMismatch { + event: String, + key: String, + event_type: i32, + rust_type: &'static str, + }, +} + +pub(crate) fn from_cs2_event<'a, T: serde::Deserialize<'a>>( + event: CMsgSource1LegacyGameEvent, + descriptor: &'a Descriptor, +) -> Result { + let mut deserializer = Deserializer { + event, + descriptor, + index: -1, + }; + T::deserialize(&mut deserializer) +} + +impl de::Error for Error { + fn custom(msg: T) -> Self { + Error::Message(msg.to_string()) + } +} + +struct Deserializer<'de> { + event: CMsgSource1LegacyGameEvent, + descriptor: &'de Descriptor, + index: i32, +} + +impl<'a> Deserializer<'a> { + fn current_ekey(&'a self) -> Result<&'a cmsg_source1legacy_game_event::Key_t> { + let ekey = &self.event.keys[self.index as usize]; + let desc = &self.descriptor.keys[self.index as usize]; + if ekey.type_() != desc.type_ { + return Err(Error::DescriptorMismatch { + event: self.descriptor.name.clone(), + key: desc.name.clone(), + descriptor_type: desc.type_, + event_type: ekey.type_(), + }); + } + Ok(ekey) + } + + fn type_mismatch_error(&'a self, rust_type: &'static str) -> Error { + let ekey = &self.event.keys[self.index as usize]; + let desc = &self.descriptor.keys[self.index as usize]; + Error::TypeMismatch { + event: self.descriptor.name.clone(), + key: desc.name.clone(), + event_type: ekey.type_(), + rust_type, + } + } +} + +impl<'de> de::Deserializer<'de> for &mut Deserializer<'de> { + type Error = Error; + + fn deserialize_any>(self, _visitor: V) -> Result { + panic!("GameEvent deserializer internal error") + } + + forward_to_deserialize_any! { i8 i16 i64 u8 u16 u32 f64 char str bytes + byte_buf option unit unit_struct newtype_struct seq tuple tuple_struct map + } + + fn deserialize_bool>(self, visitor: V) -> Result { + let ekey = self.current_ekey()?; + let value = match ekey.type_() { + 6 => ekey.val_bool(), + _ => return Err(self.type_mismatch_error("bool")), + }; + visitor.visit_bool(value) + } + + fn deserialize_i32>(self, visitor: V) -> Result { + let ekey = self.current_ekey()?; + let value = match ekey.type_() { + 3 | 8 => ekey.val_long(), + 4 | 9 => ekey.val_short(), + 5 => ekey.val_byte(), + _ => return Err(self.type_mismatch_error("i32")), + }; + visitor.visit_i32(value) + } + + fn deserialize_u64>(self, visitor: V) -> Result { + let ekey = self.current_ekey()?; + let value = match ekey.type_() { + 7 => ekey.val_uint64(), + _ => return Err(self.type_mismatch_error("u64")), + }; + visitor.visit_u64(value) + } + + fn deserialize_f32>(self, visitor: V) -> Result { + let ekey = self.current_ekey()?; + let value = match ekey.type_() { + 2 => ekey.val_float(), + _ => return Err(self.type_mismatch_error("f32")), + }; + visitor.visit_f32(value) + } + + fn deserialize_string>(self, visitor: V) -> Result { + let ekey = self.current_ekey()?; + let value = match ekey.type_() { + 1 => ekey.val_string(), + _ => return Err(self.type_mismatch_error("str")), + }; + visitor.visit_str(value) + } + + fn deserialize_struct>( + self, + _name: &'static str, + _fields: &'static [&'static str], + visitor: V, + ) -> Result { + visitor.visit_map(self) + } + + fn deserialize_enum>( + self, + _name: &'static str, + _variants: &'static [&'static str], + visitor: V, + ) -> Result { + visitor.visit_enum(Enum { de: self }) + } + + fn deserialize_identifier>(self, visitor: V) -> Result { + if self.index == -1 { + visitor.visit_borrowed_str(self.descriptor.name.as_str()) + } else { + visitor.visit_borrowed_str(self.descriptor.keys[self.index as usize].name.as_str()) + } + } + + fn deserialize_ignored_any>(self, visitor: V) -> Result { + visitor.visit_bool(false) + } +} + +impl<'de> de::MapAccess<'de> for Deserializer<'de> { + type Error = Error; + + fn next_key_seed(&mut self, seed: K) -> Result> + where + K: de::DeserializeSeed<'de>, + { + self.index += 1; + if self.index >= self.descriptor.keys.len() as i32 { + return Ok(None); + } + seed.deserialize(self).map(Some) + } + + fn next_value_seed(&mut self, seed: V) -> Result + where + V: de::DeserializeSeed<'de>, + { + seed.deserialize(self) + } +} + +struct Enum<'a, 'de> { + de: &'a mut Deserializer<'de>, +} + +impl<'a, 'de> de::EnumAccess<'de> for Enum<'a, 'de> { + type Error = Error; + type Variant = Self; + + fn variant_seed(self, seed: V) -> Result<(V::Value, Self::Variant)> + where + V: de::DeserializeSeed<'de>, + { + let val = seed.deserialize(&mut *self.de)?; + Ok((val, self)) + } +} + +impl<'de, 'a> de::VariantAccess<'de> for Enum<'a, 'de> { + type Error = Error; + + fn unit_variant(self) -> std::result::Result<(), Self::Error> { + Ok(()) + } + + fn newtype_variant_seed(self, seed: T) -> std::result::Result + where + T: de::DeserializeSeed<'de>, + { + seed.deserialize(self.de) + } + + fn tuple_variant( + self, + _len: usize, + _visitor: V, + ) -> std::result::Result + where + V: de::Visitor<'de>, + { + panic!("GameEvent deserializer internal error") + } + + fn struct_variant( + self, + _fields: &'static [&'static str], + _visitor: V, + ) -> std::result::Result + where + V: de::Visitor<'de>, + { + panic!("GameEvent deserializer internal error") + } +} diff --git a/csdemoparser/src/lib.rs b/csdemoparser/src/lib.rs index 17dfb48..db9693b 100644 --- a/csdemoparser/src/lib.rs +++ b/csdemoparser/src/lib.rs @@ -1,109 +1,32 @@ +mod cs2; +mod csgo; +pub mod demoinfo; mod entity; +mod game_event; mod geometry; mod string_table; -use crate::entity::ServerClasses; -use crate::entity::{Entities, Entity, EntityId, PropValue, Scalar}; -use crate::geometry::{through_smoke, Point}; -use crate::string_table::{PlayerInfo, StringTables}; -use anyhow::bail; -use csgo_demo_parser::messages::csvcmsg_game_event_list::Descriptor_t; -use csgo_demo_parser::messages::{CSVCMsg_GameEvent, CSVCMsg_GameEventList}; -use csgo_demo_parser::parser::packet::message::{Message, SvcMessage, UsrMessage}; -use csgo_demo_parser::parser::string_table::StringTable; -use csgo_demo_parser::parser::PacketContent; -use csgo_demo_parser::DemoParser; -use entity::TrackProp; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::cell::RefCell; -use std::collections::{BTreeMap, HashMap, HashSet}; +use crate::entity::{Entity, EntityId, PropValue, Scalar}; +use demo_format::read::ReadExt; +use demoinfo::DemoInfo; use std::io; -use std::rc::Rc; -use tracing::instrument; -type BitReader<'a> = bitstream_io::BitReader<&'a [u8], bitstream_io::LittleEndian>; +const SOURCE1_DEMO_TYPE: &str = "HL2DEMO"; +const SOURCE2_DEMO_TYPE: &str = "PBDEMS2"; -const PLAYER_CLASS: &str = "CCSPlayer"; -const VEC_ORIGIN_XY: &str = "m_vecOrigin"; -const VEC_ORIGIN_Z: &str = "m_vecOrigin[2]"; -const VEC_VELOCITY_Z: &str = "m_vecVelocity[2]"; -const IS_SCOPED: &str = "m_bIsScoped"; - -const GAME_RULES_CLASS: &str = "CCSGameRulesProxy"; -const GAME_RESTART: &str = "m_bGameRestart"; - -const TEAM_CLASS: &str = "CCSTeam"; - -type Tick = i32; - -#[derive(Serialize, Deserialize, Clone)] -pub struct DemoInfo { - pub events: Vec, - pub gotv_bots: Vec, - pub map: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub mm_rank_update: Option, - pub player_names: HashMap, - pub player_slots: HashMap, - pub servername: String, - pub tickrate: f32, -} - -impl DemoInfo { - fn new(servername: String) -> Self { - DemoInfo { - events: Vec::new(), - map: String::new(), - gotv_bots: Vec::new(), - mm_rank_update: None, - player_names: Default::default(), - player_slots: Default::default(), - servername, - tickrate: 0.0, - } - } -} - -pub fn parse(read: &mut dyn io::Read) -> anyhow::Result { - let mut parser = DemoParser::try_new(read)?; - let server_name = parser.header().server_name().to_string(); - let mut server_classes = None; - let mut packets = vec![]; - while let Some((header, content)) = parser.parse_next_packet()? { - match content { - PacketContent::DataTables(dt) => { - server_classes = Some(ServerClasses::try_new(dt)?); - break; +pub fn parse(mut read: &mut dyn io::Read) -> anyhow::Result { + let demo_type = read.read_string_limited(8)?; + match demo_type.as_str() { + SOURCE1_DEMO_TYPE => csgo::parse(read), + SOURCE2_DEMO_TYPE => { + if std::env::var("CS2_EXPERIMENTAL_PARSER").is_ok() { + cs2::parse(read) + } else { + panic!("CS2 demo parser is not complete. You can test it by seting the CS2_EXPERIMENTAL_PARSER environment variable.") } - PacketContent::Packet(pv) => packets.push((pv, *header.tick())), - PacketContent::StringTables(_) => todo!(), - _ => (), - } - if packets.len() > 1000 { - bail!("no DataTables in the first 1000 packets") - } - } - - let Some(mut server_classes) = server_classes else { bail!("no data tables before the first event") }; - let mut hsbox = HeadshotBoxParser::new(server_name, &mut server_classes); - for (pv, tick) in packets { - for p in pv { - hsbox.handle_packet(p, tick)?; } + _ => Err(cs2_demo::Error::InvalidDemoType { found: demo_type }.into()), } - while let Some((header, content)) = parser.parse_next_packet()? { - match content { - PacketContent::Packet(pv) => { - for p in pv { - hsbox.handle_packet(p, *header.tick())?; - } - } - PacketContent::StringTables(st) => hsbox.handle_string_tables(st)?, - _ => (), - } - } - hsbox.get_info() } #[derive(Default)] @@ -135,427 +58,6 @@ impl TeamScore { } } -type GameEvent = serde_json::Map; - -struct HeadshotBoxParser<'a> { - eventlist_descriptors: HashMap, - string_tables: StringTables, - players: HashMap, - jumped_last: HashMap, - tick_interval: f32, - entities: Entities<'a>, - smokes: BTreeMap, - bot_takeover: HashMap, - scoped_since: Rc>>, - score: Rc>, - demoinfo: Rc>, -} - -impl<'a> HeadshotBoxParser<'a> { - fn new(server_name: String, server_classes: &'a mut ServerClasses) -> Self { - let scoped_since = Rc::new(RefCell::new(HashMap::new())); - let score: Rc> = Rc::new(RefCell::new(Default::default())); - let demoinfo = Rc::new(RefCell::new(DemoInfo::new(server_name))); - - for sc in server_classes.server_classes.iter_mut() { - for prop in sc.props.iter_mut() { - prop.track = match (sc.name.as_str(), prop.name.as_str()) { - (TEAM_CLASS, "m_iTeamNum") => { - let score = Rc::clone(&score); - TrackProp::Changes(Rc::new(move |entity, _, value| match value { - PropValue::Scalar(Scalar::I32(id)) if id == &2 || id == &3 => { - score.borrow_mut().team_entity_id[(id - 2) as usize] - .replace(entity.id); - } - _ => (), - })) - } - (TEAM_CLASS, "m_scoreTotal") => { - let score = Rc::clone(&score); - let demoinfo = Rc::clone(&demoinfo); - TrackProp::Changes(Rc::new(move |entity, tick, value| { - let mut score = score.borrow_mut(); - if score.update(entity, value) { - demoinfo.borrow_mut().events.push(json!({ - "type": "score_changed", - "tick": tick, - "score": [score.score[0], score.score[1]], - })); - } - })) - } - (GAME_RULES_CLASS, GAME_RESTART) => { - let demoinfo = Rc::clone(&demoinfo); - TrackProp::Changes(Rc::new(move |_, tick, value| { - if let PropValue::Scalar(Scalar::I32(1)) = value { - demoinfo - .borrow_mut() - .events - .push(json!({"type": "game_restart", "tick": tick})); - } - })) - } - (PLAYER_CLASS, VEC_ORIGIN_XY | VEC_ORIGIN_Z | VEC_VELOCITY_Z) => { - TrackProp::Value - } - (PLAYER_CLASS, IS_SCOPED) => { - let scoped_since = Rc::clone(&scoped_since); - TrackProp::Changes(Rc::new(move |entity, tick, value| { - if let PropValue::Scalar(Scalar::I32(1)) = value { - scoped_since.borrow_mut().insert(entity.id, tick); - } else { - scoped_since.borrow_mut().remove(&entity.id); - } - })) - } - _ => TrackProp::No, - } - } - } - - HeadshotBoxParser { - eventlist_descriptors: Default::default(), - string_tables: StringTables::new(), - players: Default::default(), - jumped_last: HashMap::new(), - tick_interval: 0.0, - entities: Entities::new(server_classes), - smokes: Default::default(), - bot_takeover: Default::default(), - scoped_since, - score, - demoinfo, - } - } - - fn update_players( - players: &mut HashMap, - demoinfo: &Rc>, - player_info: PlayerInfo, - ) { - let mut demoinfo = demoinfo.borrow_mut(); - if !player_info.fakeplayer && !player_info.is_hltv { - demoinfo - .player_slots - .insert(player_info.xuid.to_string(), player_info.entity_id); - demoinfo - .player_names - .insert(player_info.xuid.to_string(), player_info.name.to_string()); - } - players.retain(|_, p| p.entity_id != player_info.entity_id); - players.insert(player_info.user_id, player_info); - } - - fn handle_string_tables(&mut self, st: Vec) -> anyhow::Result<()> { - // demoinfogo clears the players but I don't think this is correct - self.players.clear(); - for st in st.iter().filter(|st| st.name() == "userinfo") { - for (entity_id, string) in st.strings().iter().enumerate() { - if let Some(data) = string.data() { - let player_info = string_table::parse_player_info(data, entity_id as i32)?; - Self::update_players(&mut self.players, &self.demoinfo, player_info); - } - } - } - Ok(()) - } - - #[instrument(skip_all)] - fn handle_packet(&mut self, p: Message, tick: Tick) -> anyhow::Result<()> { - match p { - Message::Svc(SvcMessage::ServerInfo(info)) => { - self.demoinfo.borrow_mut().map = info.map_name().to_string(); - self.tick_interval = info.tick_interval(); - } - Message::Svc(SvcMessage::CreateStringTable(table)) => { - let mut updates = self.string_tables.create_string_table(&table); - while let Some(player_info) = updates.next()? { - Self::update_players(&mut self.players, &self.demoinfo, player_info); - } - } - Message::Svc(SvcMessage::UpdateStringTable(table)) => { - let mut updates = self.string_tables.update_string_table(&table)?; - while let Some(player_info) = updates.next()? { - Self::update_players(&mut self.players, &self.demoinfo, player_info); - } - } - Message::Svc(SvcMessage::GameEventList(el)) => { - self.eventlist_descriptors = parse_game_event_list(el) - } - Message::Svc(SvcMessage::GameEvent(event)) => { - if let Some(descriptor) = self.eventlist_descriptors.get(&event.eventid()) { - let attrs = self.event_map(event, descriptor, tick)?; - self.handle_game_event(attrs, tick)?; - } - } - Message::Usr(UsrMessage::ServerRankUpdate(ranks)) => { - let mut mm_rank_update = serde_json::Map::new(); - for update in ranks.rank_update { - let mut attr = serde_json::Map::new(); - if update.has_num_wins() { - attr.insert("num_wins".to_string(), json!(update.num_wins())); - } - if update.has_rank_old() { - attr.insert("rank_old".to_string(), json!(update.rank_old())); - } - if update.has_rank_new() { - attr.insert("rank_new".to_string(), json!(update.rank_new())); - } - if update.has_rank_change() { - attr.insert("rank_change".to_string(), json!(update.rank_change())); - } - let xuid = account_id_to_xuid(update.account_id()); - mm_rank_update.insert(xuid.to_string(), serde_json::Value::Object(attr)); - } - self.demoinfo.borrow_mut().mm_rank_update = - Some(serde_json::Value::Object(mm_rank_update)); - } - Message::Svc(SvcMessage::PacketEntities(msg)) => { - self.entities.read_packet_entities(msg, tick)? - } - _ => (), - } - Ok(()) - } - - fn add_smoke(&mut self, attrs: &GameEvent) -> Option<()> { - let entity_id = maybe_get_u16(attrs.get("entityid"))?; - let x = attrs.get("x")?.as_f64()?; - let y = attrs.get("y")?.as_f64()?; - let z = attrs.get("z")?.as_f64()?; - let p = Point::new(x, y, z); - self.smokes.insert(entity_id, p); - None - } - - fn handle_game_event(&mut self, mut attrs: GameEvent, tick: Tick) -> anyhow::Result<()> { - let emit = |attrs| { - self.demoinfo - .borrow_mut() - .events - .push(serde_json::Value::Object(attrs)) - }; - match attrs.get("type").unwrap().as_str().unwrap() { - "player_jump" => { - if let Some(user_id) = maybe_get_i32(attrs.get("userid")) { - self.jumped_last.insert(user_id, tick); - } - } - "smokegrenade_detonate" => { - self.add_smoke(&attrs); - } - "smokegrenade_expired" => { - if let Some(entity_id) = maybe_get_u16(attrs.get("entityid")) { - self.smokes.remove(&entity_id); - } - } - "round_start" => { - self.smokes.clear(); - self.bot_takeover.clear(); - self.scoped_since.borrow_mut().clear(); - self.score.borrow_mut().set_round_start(); - emit(attrs); - } - "player_death" => { - if let Some(attacker_user_id) = maybe_get_i32(attrs.get("attacker")) { - if self.players.get(&attacker_user_id).is_some() { - if let Some(jump) = self.jumped_last(attacker_user_id, tick) { - attrs.insert("jump".to_string(), json!(jump)); - } - } - } - let attacker_info = self.get_player_info("attacker", &attrs); - let victim = self.get_player_entity("userid", &attrs); - let attacker = self.get_player_entity("attacker", &attrs); - self.replace_user_id_with_xuid("userid", &mut attrs); - self.replace_user_id_with_xuid("attacker", &mut attrs); - self.replace_user_id_with_xuid("assister", &mut attrs); - if let (Some(victim), Some(attacker)) = (victim, attacker) { - self.add_player_death_attrs(&mut attrs, victim, attacker); - } - if let Some(attacker) = attacker { - if let Some(PropValue::Scalar(Scalar::F32(z))) = - attacker.get_prop(VEC_VELOCITY_Z) - { - attrs.insert("air_velocity".into(), json!(z)); - } - if let Some(since) = self.scoped_since.borrow().get(&attacker.id) { - if let Some(false) = attacker_info.map(|a| a.fakeplayer) { - attrs.insert("scoped_since".to_string(), json!(since)); - } - } - } - emit(attrs); - } - "bot_takeover" => { - if let Some(player_info) = self.get_player_info("userid", &attrs) { - if let Some(botid) = maybe_get_i32(attrs.get("botid")) { - self.bot_takeover.insert(player_info.xuid, botid); - } - } - } - "player_connect" => { - if let Some(player_info) = self.handle_player_connect(attrs) { - Self::update_players(&mut self.players, &self.demoinfo, player_info); - } - } - "player_disconnect" => { - let user_id = maybe_get_i32(attrs.get("userid")); - self.replace_user_id_with_xuid("userid", &mut attrs); - attrs.insert("type".to_string(), json!("player_disconnected")); - attrs.remove("networkid"); - if let Some(user_id) = user_id { - self.players.remove(&user_id); - } - emit(attrs); - } - _ => { - self.replace_user_id_with_xuid("userid", &mut attrs); - self.replace_user_id_with_xuid("attacker", &mut attrs); - emit(attrs); - } - } - Ok(()) - } - - fn handle_player_connect(&self, attrs: GameEvent) -> Option { - let user_id = maybe_get_i32(attrs.get("userid"))?; - let name = attrs.get("name")?.as_str()?.to_owned(); - let entity_id = maybe_get_i32(attrs.get("index"))?; - let guid = attrs.get("networkid")?.as_str()?.to_string(); - let fakeplayer = guid == "BOT"; - let xuid = guid_to_xuid(&guid).unwrap_or(0); - Some(PlayerInfo { - version: 0, - xuid, - name, - user_id, - guid, - friends_id: 0, - friends_name: "".to_owned(), - fakeplayer, - is_hltv: false, - files_downloaded: 0, - entity_id, - }) - } - - fn get_info(self) -> anyhow::Result { - let mut demoinfo = self.demoinfo.borrow_mut(); - demoinfo.gotv_bots = self - .players - .values() - .filter(|p| p.is_hltv) - .map(|p| p.name.to_string()) - .collect(); - demoinfo.tickrate = self.tick_interval; - // TODO: this is slow - Ok((*demoinfo).clone()) - } - - fn event_map( - &self, - event: CSVCMsg_GameEvent, - descriptor: &Descriptor_t, - tick: Tick, - ) -> anyhow::Result { - let mut attrs = serde_json::Map::new(); - for (i, descriptor_key) in descriptor.keys.iter().enumerate() { - let event_key = &event.keys[i]; - let key = descriptor_key.name().to_string(); - if event_key.type_() != descriptor_key.type_() { - bail!("event key type does not match descriptor type"); - } - let val = match descriptor_key.type_() { - 1 => json!(event_key.val_string()), - 2 => json!(event_key.val_float()), - 3 => json!(event_key.val_long()), - 4 => json!(event_key.val_short()), - 5 => json!(event_key.val_byte()), - 6 => json!(event_key.val_bool()), - 7 => json!(event_key.val_uint64()), - e => bail!("unknown event key type {}", e), - }; - attrs.insert(key, val); - } - attrs.insert("type".into(), json!(descriptor.name())); - attrs.insert("tick".into(), json!(tick)); - Ok(attrs) - } - - fn jumped_last(&self, user_id: i32, tick: Tick) -> Option { - let &jumped_last = self.jumped_last.get(&user_id)?; - const JUMP_DURATION: f64 = 0.75; - if self.tick_interval > 0_f32 - && jumped_last as f64 >= tick as f64 - JUMP_DURATION / self.tick_interval as f64 - { - Some(tick - jumped_last) - } else { - None - } - } - - fn get_player_info(&self, key: &str, attrs: &GameEvent) -> Option<&PlayerInfo> { - let user_id = maybe_get_i32(attrs.get(key))?; - let player_info = self.players.get(&user_id)?; - Some(player_info) - } - - fn get_player_entity(&self, key: &str, attrs: &GameEvent) -> Option<&Entity> { - let user_id = maybe_get_i32(attrs.get(key))?; - let player_info = self.players.get(&user_id)?; - let entity_id: EntityId = player_info.entity_id as u16 + 1; - self.entities.get(entity_id) - } - - fn replace_user_id_with_xuid(&self, key: &str, attrs: &mut GameEvent) { - if let Some(player_info) = self.get_player_info(key, attrs) { - if player_info.fakeplayer { - return; - } - let mut xuid = player_info.xuid; - if let Some(&botid) = self.bot_takeover.get(&xuid) { - match (attrs.get("type").and_then(serde_json::Value::as_str), key) { - // player_spawn happens before round_start when we clear bot_takeover. - (Some("player_spawn"), _) => {} - (Some("player_disconnect"), _) => {} - // CS:GO awards assists to the controlling human instead of the bot. - (Some("player_death"), "assister") => {} - _ => xuid = botid as u64, - } - } - attrs.insert(key.to_string(), json!(xuid)); - } - } - - fn add_player_death_attrs(&self, attrs: &mut GameEvent, victim: &Entity, attacker: &Entity) { - if let (Some(victim_pos), Some(attacker_pos)) = - (self.get_position(victim), self.get_position(attacker)) - { - let smokes: Vec = self - .smokes - .values() - .filter(|smoke| through_smoke(&attacker_pos, &victim_pos, smoke)) - .map(|smoke| (*smoke).into()) - .collect(); - if !smokes.is_empty() { - attrs.insert("smoke".into(), json!(smokes)); - } - attrs.insert("attacker_pos".into(), attacker_pos.into()); - attrs.insert("victim_pos".into(), victim_pos.into()); - } - } - - fn get_position(&self, entity: &Entity) -> Option { - if let PropValue::Scalar(Scalar::Vector(xy)) = entity.get_prop(VEC_ORIGIN_XY)? { - if let PropValue::Scalar(Scalar::F32(z)) = entity.get_prop(VEC_ORIGIN_Z)? { - return Some(Point::new(xy.x as f64, xy.y as f64, *z as f64)); - } - } - None - } -} - fn guid_to_xuid(guid: &str) -> anyhow::Result { let high_bits = guid.chars().skip(10).collect::().parse::()?; let low_bit: i32 = if let Some('1') = guid.chars().nth(8) { @@ -579,36 +81,6 @@ fn maybe_get_i32(v: Option<&serde_json::Value>) -> Option { Some(v?.as_i64()? as i32) } -fn parse_game_event_list(el: CSVCMsg_GameEventList) -> HashMap { - let mut eventlist_descriptors = HashMap::new(); - let hsbox_events: HashSet<&str> = HashSet::from([ - "bomb_defused", - "bomb_exploded", - "bot_takeover", - "game_restart", - "player_connect", - "player_death", - "player_disconnect", - "player_hurt", - "player_jump", - "player_spawn", - "round_end", - "round_officially_ended", - "round_start", - "score_changed", - "smokegrenade_detonate", - "smokegrenade_expired", - ]); - for descriptor in el - .descriptors - .iter() - .filter(|d| hsbox_events.contains(d.name())) - { - eventlist_descriptors.insert(descriptor.eventid(), descriptor.clone()); - } - eventlist_descriptors -} - // Number of bits needed to represent values in the 0..=n interval. fn num_bits(n: u32) -> u32 { if n == 0 { @@ -621,119 +93,6 @@ fn num_bits(n: u32) -> u32 { #[cfg(test)] mod tests { use super::*; - use assert_json_diff::assert_json_eq; - - fn make_parser(server_classes: &mut ServerClasses) -> HeadshotBoxParser { - let mut parser = HeadshotBoxParser::new("".to_owned(), server_classes); - parser.tick_interval = 1f32 / 64f32; - parser.players.insert( - 7, - PlayerInfo { - xuid: 1007, - ..Default::default() - }, - ); - parser - } - - fn make_server_classes() -> ServerClasses { - ServerClasses { - bits: 0, - server_classes: vec![], - } - } - - fn make_game_event(object: serde_json::Value) -> GameEvent { - object.as_object().unwrap().clone() - } - - fn emitted_event( - parser: &mut HeadshotBoxParser, - event: serde_json::Value, - tick: Tick, - expected: serde_json::Value, - ) { - handle_event(parser, event, tick); - assert_json_eq!(parser.demoinfo.borrow().events.last().unwrap(), expected); - } - - fn handle_event(parser: &mut HeadshotBoxParser, event: serde_json::Value, tick: Tick) { - let attrs = make_game_event(event); - parser.handle_game_event(attrs, tick).unwrap() - } - - #[test] - fn jump_death() { - let mut server_classes = make_server_classes(); - let mut parser = make_parser(&mut server_classes); - handle_event(&mut parser, json!({"type": "player_jump", "userid": 7}), 1); - emitted_event( - &mut parser, - json!({"type": "player_death", "userid": 7, "attacker": 7}), - 2, - json!({"type": "player_death", "userid": 1007, "attacker": 1007, "jump": 1}), - ); - } - - #[test] - fn jump_disconnect_death() { - let mut server_classes = make_server_classes(); - let mut parser = make_parser(&mut server_classes); - handle_event(&mut parser, json!({"type": "player_jump", "userid": 7}), 1); - handle_event( - &mut parser, - json!({"type": "player_disconnect", "userid": 7}), - 2, - ); - emitted_event( - &mut parser, - json!({"type": "player_death", "userid": 7, "attacker": 7}), - 2, - json!({"type": "player_death", "userid": 7, "attacker": 7}), - ); - } - - #[test] - fn bot_takeover() { - let mut server_classes = make_server_classes(); - let mut parser = make_parser(&mut server_classes); - handle_event( - &mut parser, - json!({"type": "bot_takeover", "userid": 7, "botid": 31}), - 1, - ); - emitted_event( - &mut parser, - json!({"type": "player_hurt", "attacker": 7}), - 2, - json!({"type": "player_hurt", "attacker": 31}), - ); - emitted_event( - &mut parser, - json!({"type": "player_death", "attacker": 7}), - 2, - json!({"type": "player_death", "attacker": 31}), - ); - emitted_event( - &mut parser, - json!({"type": "player_death", "assister": 7}), - 2, - json!({"type": "player_death", "assister": 1007}), - ); - emitted_event( - &mut parser, - json!({"type": "player_spawn", "userid": 7}), - 3, - json!({"type": "player_spawn", "userid": 1007}), - ); - emitted_event( - &mut parser, - json!({"type": "player_disconnect", "userid": 7}), - 3, - json!({"type": "player_disconnected", "userid": 1007}), - ); - } - #[test] fn test_guid_to_xuid() { assert_eq!( diff --git a/csdemoparser/src/string_table.rs b/csdemoparser/src/string_table.rs index c4cc2cf..61e3e23 100644 --- a/csdemoparser/src/string_table.rs +++ b/csdemoparser/src/string_table.rs @@ -5,8 +5,9 @@ use anyhow::bail; use bitstream_io::BitRead; use byteorder::{BigEndian, LittleEndian, ReadBytesExt}; use csgo_demo_parser::messages::{CSVCMsg_CreateStringTable, CSVCMsg_UpdateStringTable}; +use demo_format::BitReader; -use crate::{num_bits, BitReader}; +use crate::num_bits; #[derive(Debug, Clone)] struct StringTableDescriptor { diff --git a/csgo-demo-parser b/csgo-demo-parser index e51ea2e..42374ca 160000 --- a/csgo-demo-parser +++ b/csgo-demo-parser @@ -1 +1 @@ -Subproject commit e51ea2ea77aa7865077c77b6a271547c88f6363d +Subproject commit 42374ca0836e541da4aee3b9526ea86949246aef diff --git a/demo-format/Cargo.toml b/demo-format/Cargo.toml new file mode 100644 index 0000000..4b293d8 --- /dev/null +++ b/demo-format/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "demo-format" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bitstream-io = "1.7" diff --git a/demo-format/src/lib.rs b/demo-format/src/lib.rs new file mode 100644 index 0000000..03acf9a --- /dev/null +++ b/demo-format/src/lib.rs @@ -0,0 +1,4 @@ +pub mod read; + +pub type Tick = i32; +pub type BitReader<'a> = bitstream_io::BitReader<&'a [u8], bitstream_io::LittleEndian>; diff --git a/demo-format/src/read.rs b/demo-format/src/read.rs new file mode 100644 index 0000000..ac73ff5 --- /dev/null +++ b/demo-format/src/read.rs @@ -0,0 +1,255 @@ +use bitstream_io::BitRead; +use std::io; + +pub trait ReadExt: io::Read { + /// Read `size` bytes and convert them to a [`String`]. + /// Every trailing null terminator will be removed. + fn read_string_limited(&mut self, size: usize) -> io::Result; +} + +impl ReadExt for R { + fn read_string_limited(&mut self, size: usize) -> io::Result { + let mut buf: Vec = vec![0; size]; + self.read_exact(buf.as_mut_slice())?; + + let s = String::from_utf8_lossy(&buf).into_owned(); + Ok(s.trim_end_matches('\0').to_string()) + } +} + +pub enum CoordType { + None, + LowPrecision, + Integral, +} + +const COORD_INTEGER_BITS: u32 = 14; +const COORD_INTEGER_BITS_MP: u32 = 11; +const COORD_FRACTIONAL_BITS: u32 = 5; +const COORD_FRACTIONAL_BITS_LOWPRECISION: u32 = 3; +const COORD_RESOLUTION: f32 = 1_f32 / (1 << COORD_FRACTIONAL_BITS) as f32; +const COORD_RESOLUTION_LOWPRECISION: f32 = 1_f32 / (1 << COORD_FRACTIONAL_BITS_LOWPRECISION) as f32; + +#[inline] +fn read_coord_fraction( + reader: &mut bitstream_io::BitReader, +) -> io::Result { + Ok(reader.read::(COORD_FRACTIONAL_BITS)? as f32 * COORD_RESOLUTION) +} + +#[inline] +fn read_coord_fraction_low_precision( + reader: &mut bitstream_io::BitReader, +) -> io::Result { + Ok( + reader.read::(COORD_FRACTIONAL_BITS_LOWPRECISION)? as f32 + * COORD_RESOLUTION_LOWPRECISION, + ) +} + +pub trait ValveBitReader { + fn read_ubitvar(&mut self) -> io::Result; + fn read_varint32(&mut self) -> io::Result; + fn read_signed_varint32(&mut self) -> io::Result; + + fn read_coord(&mut self) -> io::Result; + fn skip_coord(&mut self) -> io::Result<()>; + + fn read_coord_mp(&mut self, coord_type: CoordType) -> io::Result; + fn skip_coord_mp(&mut self, coord_type: CoordType) -> io::Result<()>; + + fn read_cell_coord(&mut self, int_bits: u32, coord_type: CoordType) -> io::Result; + fn skip_cell_coord(&mut self, int_bits: u32, coord_type: CoordType) -> io::Result<()>; + + fn read_normal(&mut self) -> io::Result; + fn skip_normal(&mut self) -> io::Result<()>; +} + +impl ValveBitReader for bitstream_io::BitReader { + /// Read a 32-bit value using the UBitVar Valve format. + fn read_ubitvar(&mut self) -> io::Result { + let tmp = self.read::(6)?; + let last4 = tmp & 15; + match tmp & (16 | 32) { + 16 => { + let ret = (self.read::(4)? << 4) | last4; + debug_assert!(ret >= 16); + Ok(ret) + } + 32 => { + let ret = (self.read::(8)? << 4) | last4; + debug_assert!(ret >= 256); + Ok(ret) + } + 48 => { + let ret = (self.read::(32 - 4)? << 4) | last4; + debug_assert!(ret >= 4096); + Ok(ret) + } + _ => Ok(last4), + } + } + + fn read_varint32(&mut self) -> io::Result { + let mut result = 0; + for byte in 0..5 { + let b = self.read::(8)?; + result |= ((b & 0x7F) as u32) << (byte * 7); + if b & 0x80 == 0 { + break; + } + } + Ok(result) + } + + fn read_signed_varint32(&mut self) -> io::Result { + Ok(zigzag_decode(self.read_varint32()?)) + } + + fn read_coord(&mut self) -> io::Result { + let int = self.read_bit()?; + let fract = self.read_bit()?; + if !int && !fract { + return Ok(0_f32); + } + let sign = self.read_bit()?; + let intval = if int { + self.read::(COORD_INTEGER_BITS)? + 1 + } else { + 0 + }; + let fractval = if fract { + read_coord_fraction(self)? + } else { + 0_f32 + }; + let abs = intval as f32 + fractval; + Ok(if sign { -abs } else { abs }) + } + + fn skip_coord(&mut self) -> io::Result<()> { + let int_bits = self.read_bit()? as u32 * COORD_INTEGER_BITS; + let fract_bits = self.read_bit()? as u32 * COORD_FRACTIONAL_BITS; + let bits = int_bits + fract_bits + if int_bits + fract_bits > 0 { 1 } else { 0 }; + self.skip(bits) + } + + fn read_coord_mp(&mut self, coord_type: CoordType) -> io::Result { + let int_bits = if self.read_bit()? { + COORD_INTEGER_BITS_MP + } else { + COORD_INTEGER_BITS + }; + let has_int = self.read_bit()?; + let negative = match coord_type { + CoordType::Integral if !has_int => false, + _ => self.read_bit()?, + }; + let int = if has_int { + self.read::(int_bits)? + 1 + } else { + 0 + }; + let fract = match coord_type { + CoordType::None => read_coord_fraction(self)?, + CoordType::LowPrecision => read_coord_fraction_low_precision(self)?, + CoordType::Integral => 0_f32, + }; + let abs = int as f32 + fract; + Ok(if negative { -abs } else { abs }) + } + + fn skip_coord_mp(&mut self, coord_type: CoordType) -> io::Result<()> { + let mut int_bits = if self.read_bit()? { + COORD_INTEGER_BITS_MP + } else { + COORD_INTEGER_BITS + }; + let has_int = self.read_bit()?; + if !has_int { + int_bits = 0; + } + let bits = match coord_type { + CoordType::None => int_bits + 1 + COORD_FRACTIONAL_BITS, + CoordType::LowPrecision => int_bits + 1 + COORD_FRACTIONAL_BITS_LOWPRECISION, + CoordType::Integral => int_bits + has_int as u32, + }; + self.skip(bits) + } + + fn read_cell_coord(&mut self, int_bits: u32, coord_type: CoordType) -> io::Result { + let int = self.read::(int_bits)? as f32; + let val = match coord_type { + CoordType::None => int + read_coord_fraction(self)?, + CoordType::LowPrecision => int + read_coord_fraction_low_precision(self)?, + CoordType::Integral => int, + }; + Ok(val) + } + + fn skip_cell_coord(&mut self, int_bits: u32, coord_type: CoordType) -> io::Result<()> { + match coord_type { + CoordType::None => self.skip(int_bits + COORD_FRACTIONAL_BITS), + CoordType::LowPrecision => self.skip(int_bits + COORD_FRACTIONAL_BITS_LOWPRECISION), + CoordType::Integral => self.skip(int_bits), + } + } + + fn read_normal(&mut self) -> io::Result { + let sign = self.read_bit()?; + let fract = self.read::(11)? as f32; + let abs = fract * 1_f32 / ((1 << 11) - 1) as f32; + Ok(if sign { -abs } else { abs }) + } + + fn skip_normal(&mut self) -> io::Result<()> { + self.skip(12) + } +} + +/// The ZigZag transform is used to minimize the number of bytes needed by the unsigned varint +/// encoding. +/// +/// Otherwise, small negative numbers like -1 reinterpreted as u32 would be a very large number +/// and use all 5 bytes in the varint encoding. +fn zigzag_decode(n: u32) -> i32 { + ((n >> 1) as i32) ^ -((n & 1) as i32) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::BitReader; + + #[test] + fn varint() { + let mut read = BitReader::new(&[0x01]); + assert_eq!(read.read_varint32().unwrap(), 1); + + let mut read = BitReader::new(&[0x81, 0x23]); + assert_eq!(read.read_varint32().unwrap(), 4481); + + let mut read = BitReader::new(&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); + assert_eq!(read.read_varint32().unwrap(), 4294967295); + } + + #[test] + fn zigzag() { + let cases: [(u32, i32); 7] = [ + (0, 0), + (1, -1), + (2, 1), + (3, -2), + (4, 2), + (4294967294, 2147483647), + (4294967295, -2147483648), + ]; + for (encoded, decoded) in cases { + assert_eq!( + decoded, + zigzag_decode(encoded), + "zigzag_decode({encoded}) should be {decoded}" + ); + } + } +} diff --git a/parsetest/Cargo.toml b/parsetest/Cargo.toml index c75a482..36b8ed7 100644 --- a/parsetest/Cargo.toml +++ b/parsetest/Cargo.toml @@ -6,12 +6,12 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0.71" +anyhow = "1.0" clap = { version = "4.3.0", features = ["derive"] } console = "0.15.7" glob = "0.3.1" indicatif = { version = "0.17.3", features = ["rayon"] } once_cell = "1.17.2" rayon = "1.7.0" -serde = { version = "1.0.163", features = ["derive"] } -serde_json = "1.0.96" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0"