mirror of
https://github.com/tribufu/rust-gamedig
synced 2026-05-06 15:27:28 +00:00
Compare commits
625 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ef15909b | ||
|
|
6e5279b529 | ||
|
|
2432f3b770 | ||
|
|
61c0b529b2 | ||
|
|
94772752e6 | ||
|
|
b338062553 | ||
|
|
79fc1db14c | ||
|
|
b0725834e4 | ||
|
|
4ea333f16b | ||
|
|
a5f15a040a | ||
|
|
61ff780470 | ||
|
|
64608d95ee | ||
|
|
2b9a014484 | ||
|
|
91d4f03b1f | ||
|
|
8370020ae2 | ||
|
|
d519665c06 | ||
|
|
1b79a3b825 | ||
|
|
080f327ab6 | ||
|
|
d6fe78f0e6 | ||
|
|
4ad07377f2 | ||
|
|
90458ed443 | ||
|
|
085913f903 | ||
|
|
4ddb0fd960 | ||
|
|
380bbd4ab4 | ||
|
|
350f297193 | ||
|
|
ad8f1b8081 | ||
|
|
5d36f9bcff | ||
|
|
b44c85c118 | ||
|
|
d969e4e0b7 | ||
|
|
fddfd830b7 | ||
|
|
e87bde53e7 | ||
|
|
8160a866b0 | ||
|
|
0a7ba6848e | ||
|
|
b7144e015d | ||
|
|
4c5ffde2e5 | ||
|
|
7c4a5e4f9c | ||
|
|
0a304c7513 | ||
|
|
8ebb685b33 | ||
|
|
13814ee6e3 | ||
|
|
b3ae7b7fb1 | ||
|
|
6953840af9 | ||
|
|
3588b97b83 | ||
|
|
43a613b7c0 | ||
|
|
67b97ca1c9 | ||
|
|
373a4553ce | ||
|
|
fa84a07fca | ||
|
|
85a733386b | ||
|
|
07bfd69961 | ||
|
|
a9fe5858b0 | ||
|
|
fc5113507f | ||
|
|
d1eb6d7ca5 | ||
|
|
b6696e1af3 | ||
|
|
c04f442bbe | ||
|
|
68cd20963a | ||
|
|
adf65276dd | ||
|
|
961f57d6ec | ||
|
|
1d112cc661 | ||
|
|
1d4e415a5f | ||
|
|
4fb0d24a4e | ||
|
|
0321cfb34f | ||
|
|
e539104a8a | ||
|
|
54d3693cb0 | ||
|
|
7985fb2613 | ||
|
|
102e48914b | ||
|
|
98cff08512 | ||
|
|
3bcf9385f2 | ||
|
|
8fab167157 | ||
|
|
a2fe00e1c4 | ||
|
|
d66fe6baf4 | ||
|
|
f66b33f113 | ||
|
|
6aa900671e | ||
|
|
bcc92d17df | ||
|
|
24134d6f23 | ||
|
|
30ae60e4dc | ||
|
|
664cf8b2db | ||
|
|
1b1ecc651e | ||
|
|
c446bcab54 | ||
|
|
e4baf07e48 | ||
|
|
344622629e | ||
|
|
c7451b098b | ||
|
|
480ff2b531 | ||
|
|
3964735af8 | ||
|
|
c82dc7d653 | ||
|
|
4866003252 | ||
|
|
16fd486208 | ||
|
|
ada3c548f0 | ||
|
|
4784e0a281 | ||
|
|
786e9dad94 | ||
|
|
e39b880364 | ||
|
|
73f39510fe | ||
|
|
70571218b6 | ||
|
|
486ae3b52c | ||
|
|
11088e7786 | ||
|
|
41a3d88fb5 | ||
|
|
397817b6d6 | ||
|
|
3300c65b07 | ||
|
|
3aaa32edb8 | ||
|
|
1af2d146f3 | ||
|
|
5e5d5ab05e | ||
|
|
81c81e929c | ||
|
|
1a023d62eb | ||
|
|
cac5c234ee | ||
|
|
927c4dc30d | ||
|
|
d51e54452f | ||
|
|
723461399a | ||
|
|
79aeec8df6 | ||
|
|
27840e3ff3 | ||
|
|
6f358340f1 | ||
|
|
942ce2b601 | ||
|
|
14a4475d51 | ||
|
|
2663cf950d | ||
|
|
c18c6f202c | ||
|
|
e52d7fbd6f | ||
|
|
111cdd5eae | ||
|
|
196d7121de | ||
|
|
1481cfd41a | ||
|
|
48aa0ec221 | ||
|
|
49b95861f0 | ||
|
|
b2da9bee3c | ||
|
|
e57efb392d | ||
|
|
50ccac3cc9 | ||
|
|
78b52c0d23 | ||
|
|
c2f6a68648 | ||
|
|
32c267621e | ||
|
|
1877a16457 | ||
|
|
f53635993e | ||
|
|
d19c3696ad | ||
|
|
829ab5b7f2 | ||
|
|
ddd95be413 | ||
|
|
c357b4594b | ||
|
|
462677f928 | ||
|
|
4df721e3b6 | ||
|
|
e032eb3441 | ||
|
|
45ffa53de3 | ||
|
|
1620ba36b8 | ||
|
|
8a17bd6345 | ||
|
|
4651990e8b | ||
|
|
e9f5e3e5db | ||
|
|
66a9ed8b3f | ||
|
|
3c9f109942 | ||
|
|
4faf2f89f4 | ||
|
|
29f1098daf | ||
|
|
00840cb4a6 | ||
|
|
9264d5fe4a | ||
|
|
cca938674c | ||
|
|
a207c39915 | ||
|
|
14bf759fa7 | ||
|
|
40b70d6576 | ||
|
|
f488658afc | ||
|
|
6e53ef0c22 | ||
|
|
f54321da18 | ||
|
|
ea6140c5d9 | ||
|
|
275fb7d4cd | ||
|
|
bcb9ac64c0 | ||
|
|
328dfd312b | ||
|
|
04803996cd | ||
|
|
0f0a9da609 | ||
|
|
967dc37d64 | ||
|
|
82b7a5f169 | ||
|
|
b2e34b32f8 | ||
|
|
03fd0c10b2 | ||
|
|
8a38d742f6 | ||
|
|
a9fcfe1bb3 | ||
|
|
b913b0c7e7 | ||
|
|
1080a94bd2 | ||
|
|
4d214ad5fb | ||
|
|
c1454805bb | ||
|
|
ef017d4703 | ||
|
|
5d48de178b | ||
|
|
310b62664c | ||
|
|
2a65c39cb6 | ||
|
|
e86e80522b | ||
|
|
7369dbab19 | ||
|
|
730c938ad2 | ||
|
|
7b37e71221 | ||
|
|
9a2b953fff | ||
|
|
c34392a3da | ||
|
|
80129ce012 | ||
|
|
ca61c1c0b0 | ||
|
|
fd497764f8 | ||
|
|
175dcf6aa6 | ||
|
|
48aa5115c0 | ||
|
|
4675b24ff3 | ||
|
|
eb24b8ec3d | ||
|
|
744230455c | ||
|
|
5310200181 | ||
|
|
422cb57efa | ||
|
|
df51521a79 | ||
|
|
5365845bb5 | ||
|
|
8f381f733c | ||
|
|
76604ac3fc | ||
|
|
bba9f5f11b | ||
|
|
144d7ca03d | ||
|
|
89ed19f089 | ||
|
|
6d0c25d6ea | ||
|
|
f922270c60 | ||
|
|
2deb1df4ae | ||
|
|
5dff511e6f | ||
|
|
b1e42f9023 | ||
|
|
bedd277027 | ||
|
|
e615c63ed2 | ||
|
|
3eb20b9deb | ||
|
|
962c856418 | ||
|
|
b49525543d | ||
|
|
1991c9f5eb | ||
|
|
32dd486632 | ||
|
|
36d957ceb4 | ||
|
|
15004f3dae | ||
|
|
89d4ddeac7 | ||
|
|
c30f28741f | ||
|
|
61ecbab312 | ||
|
|
6cf6800bff | ||
|
|
6aee5ebb76 | ||
|
|
0543cabce2 | ||
|
|
3d47180e85 | ||
|
|
49096e46bb | ||
|
|
0f9bada4f3 | ||
|
|
07de5168f4 | ||
|
|
ba92466ae1 | ||
|
|
a3bc8b79e5 | ||
|
|
b248a7661e | ||
|
|
88bf996a5e | ||
|
|
94102d0d7b | ||
|
|
12a6c2af58 | ||
|
|
c71e783e1e | ||
|
|
109a3db13e | ||
|
|
90b038eed0 | ||
|
|
1333655d53 | ||
|
|
d9c0a63e8c | ||
|
|
ae9a38907f | ||
|
|
bd3727d7fe | ||
|
|
483d728ac8 | ||
|
|
a7ee331dc3 | ||
|
|
079e9877ba | ||
|
|
0e241056bf | ||
|
|
87ed02420e | ||
|
|
10169c9107 | ||
|
|
bdcf64facf | ||
|
|
99b0269ec2 | ||
|
|
8c52ca6ad3 | ||
|
|
44abf6ec71 | ||
|
|
a4bc430868 | ||
|
|
0aa498b30b | ||
|
|
f746fad157 | ||
|
|
9c3e6cb51f | ||
|
|
e1bffd2045 | ||
|
|
21205fc3cb | ||
|
|
486abbd9f7 | ||
|
|
f431508418 | ||
|
|
5d0834ac78 | ||
|
|
731818ffb1 | ||
|
|
f1094e0e68 | ||
|
|
2836536842 | ||
|
|
81e028e1a0 | ||
|
|
177d22e4b2 | ||
|
|
febba25a91 | ||
|
|
8b4f6083f1 | ||
|
|
1dc3c6dade | ||
|
|
e0cc2a2420 | ||
|
|
35c2aec19b | ||
|
|
bc2b69d183 | ||
|
|
55f498d45a | ||
|
|
dd204936f0 | ||
|
|
fb6f22b801 | ||
|
|
af8e1e9b1a | ||
|
|
dd037daa04 | ||
|
|
04da29f2a6 | ||
|
|
45f05aec13 | ||
|
|
decff82318 | ||
|
|
8d17ca4e48 | ||
|
|
abbcae618f | ||
|
|
3b1edd8e3d | ||
|
|
7510fe3de0 | ||
|
|
b3a29b15b1 | ||
|
|
0c7dbe76d7 | ||
|
|
3f1164ef5d | ||
|
|
e3bdbc2a41 | ||
|
|
7416d54b14 | ||
|
|
bd73b657c7 | ||
|
|
13f1c2bf35 | ||
|
|
0d27882150 | ||
|
|
f01cac8fed | ||
|
|
c9c8e700cb | ||
|
|
89222b1f44 | ||
|
|
a11ca7f9aa | ||
|
|
338df9144c | ||
|
|
963040fb84 | ||
|
|
92ad618723 | ||
|
|
1d7cb31bc4 | ||
|
|
529abe9d76 | ||
|
|
5c1568251a | ||
|
|
4bbe7e1780 | ||
|
|
a3740c5424 | ||
|
|
adb2109aea | ||
|
|
f11a50a415 | ||
|
|
1145a064a9 | ||
|
|
e3dd7cd1c7 | ||
|
|
2cc9e56168 | ||
|
|
501524b0da | ||
|
|
6b92e883ef | ||
|
|
9644163c8c | ||
|
|
d34d615784 | ||
|
|
1ca6e6e85c | ||
|
|
8a88e826fa | ||
|
|
7d4649b6f5 | ||
|
|
6084c56d4f | ||
|
|
80f6b87991 | ||
|
|
66ae3c296e | ||
|
|
ef29ba8eb4 | ||
|
|
5b5c41b468 | ||
|
|
3b9c784e70 | ||
|
|
c8a93357cf | ||
|
|
b7e1eff9b7 | ||
|
|
b584e11336 | ||
|
|
1e083c2df7 | ||
|
|
7164ab5f64 | ||
|
|
2106e965e4 | ||
|
|
53fe402f05 | ||
|
|
a3800f3ba4 | ||
|
|
40d4be2ceb | ||
|
|
311a5425a8 | ||
|
|
5280ecb3c6 | ||
|
|
b4c61781fb | ||
|
|
9db873e774 | ||
|
|
e7567c631e | ||
|
|
05eb902891 | ||
|
|
9107bf5ef2 | ||
|
|
efc1828b29 | ||
|
|
5bd609af72 | ||
|
|
c3281be419 | ||
|
|
3784d25774 | ||
|
|
52750fba76 | ||
|
|
14c3f4525b | ||
|
|
6b1e787cd2 | ||
|
|
a8489e4353 | ||
|
|
1a60a0496f | ||
|
|
1c9f2dc0a8 | ||
|
|
2eb1d12b3d | ||
|
|
8468c2b821 | ||
|
|
6179532065 | ||
|
|
cb0486bded | ||
|
|
995ab23b51 | ||
|
|
edbb0e6cf5 | ||
|
|
b418319e01 | ||
|
|
527f8f6369 | ||
|
|
7cfecbfff9 | ||
|
|
d97bd68ada | ||
|
|
6c1fdb1159 | ||
|
|
76a3ac2f78 | ||
|
|
89d69c1176 | ||
|
|
e1568f4019 | ||
|
|
a56ca45de6 | ||
|
|
211cd5fd5f | ||
|
|
51c7e2383e | ||
|
|
a8fc67412c | ||
|
|
7e028ce97d | ||
|
|
f50c50f0f1 | ||
|
|
c4097fae78 | ||
|
|
b35e52f795 | ||
|
|
071fc367df | ||
|
|
9d8fb1ba94 | ||
|
|
65c56dc196 | ||
|
|
c10555a9d7 | ||
|
|
b509a6dcc4 | ||
|
|
5aa2ce99ec | ||
|
|
4ff50ea711 | ||
|
|
47547a77bd | ||
|
|
c43cc0438a | ||
|
|
5e7e010d24 | ||
|
|
f7b5463073 | ||
|
|
fb447edbc2 | ||
|
|
a4df444c86 | ||
|
|
ada60f2376 | ||
|
|
a81a6ef968 | ||
|
|
bec0f518b4 | ||
|
|
84118d2590 | ||
|
|
23669531b6 | ||
|
|
ea1360441c | ||
|
|
66cc39eb26 | ||
|
|
a8342296d6 | ||
|
|
fb9d15f0cc | ||
|
|
bf8c087b94 | ||
|
|
e207e8dc95 | ||
|
|
f3a792e325 | ||
|
|
0f9a10f2fb | ||
|
|
58f7ff8aab | ||
|
|
f7e93fd7cd | ||
|
|
84ebeb8065 | ||
|
|
8316dac2cc | ||
|
|
dd80d6309f | ||
|
|
8fe521749a | ||
|
|
d61085b1ab | ||
|
|
c55254aaf6 | ||
|
|
b368877031 | ||
|
|
bf14ecb4a4 | ||
|
|
9c93e40650 | ||
|
|
89fbd81331 | ||
|
|
31162b6d6e | ||
|
|
c3e2d948e8 | ||
|
|
cb9384e474 | ||
|
|
01b47d54e1 | ||
|
|
7f9b4ca98a | ||
|
|
4b081371f4 | ||
|
|
08e00c64e4 | ||
|
|
6486c1e17b | ||
|
|
3fd3c7aa5b | ||
|
|
8bc05013ee | ||
|
|
a377b76a55 | ||
|
|
e44a680a59 | ||
|
|
d853189e06 | ||
|
|
26ad1f5d19 | ||
|
|
80637f2398 | ||
|
|
b95b2abe0f | ||
|
|
a6279177bb | ||
|
|
bfa2c9826f | ||
|
|
4a8ad7c3dc | ||
|
|
c73334f45d | ||
|
|
d1ca19647d | ||
|
|
e0830bdae5 | ||
|
|
596d15df78 | ||
|
|
3a9bd77efe | ||
|
|
a0681f4259 | ||
|
|
b3ba7df6d9 | ||
|
|
06a2ceeda9 | ||
|
|
af5e0d1fbf | ||
|
|
c874b463e3 | ||
|
|
f79f2ea2de | ||
|
|
0ceb31bf86 | ||
|
|
d302d1173f | ||
|
|
3dbc6498ed | ||
|
|
3f654e0dfd | ||
|
|
e620398615 | ||
|
|
a69896f737 | ||
|
|
f843780469 | ||
|
|
a8e2b51dbb | ||
|
|
a17f5ad4d2 | ||
|
|
fc52f3fe91 | ||
|
|
726bfd429f | ||
|
|
33e8f43cb8 | ||
|
|
3ef599056a | ||
|
|
8abb657800 | ||
|
|
9f22a4eadf | ||
|
|
4c4b9d6b45 | ||
|
|
6c9f554751 | ||
|
|
ed2934f3fa | ||
|
|
3b694815cc | ||
|
|
e159cfebbd | ||
|
|
780d42067e | ||
|
|
c5fd58f794 | ||
|
|
4122d34cfa | ||
|
|
348147b415 | ||
|
|
786da81ea5 | ||
|
|
1b13d39856 | ||
|
|
e023e13236 | ||
|
|
84af4230f7 | ||
|
|
bd2e373d66 | ||
|
|
7f73eb582d | ||
|
|
9f6b3bae18 | ||
|
|
7500b09b4d | ||
|
|
568c53f129 | ||
|
|
927d56b1ee | ||
|
|
3dacc09173 | ||
|
|
7352c595e9 | ||
|
|
bf2a05f488 | ||
|
|
2865543975 | ||
|
|
a3cbb24d0d | ||
|
|
9ad2f143dd | ||
|
|
e163774685 | ||
|
|
e6562d30cb | ||
|
|
14c5edc1be | ||
|
|
8992ffe4df | ||
|
|
9d0cc15f4c | ||
|
|
c7f706bf35 | ||
|
|
04299c1a2c | ||
|
|
59994bc086 | ||
|
|
5f06f58df8 | ||
|
|
f97de3bb63 | ||
|
|
950c08c18e | ||
|
|
5604436553 | ||
|
|
cd4cbc09db | ||
|
|
e26f0f871a | ||
|
|
99c87557c2 | ||
|
|
ab43675ae5 | ||
|
|
150bc1762e | ||
|
|
fe46359e47 | ||
|
|
719ae9d591 | ||
|
|
3231653e4c | ||
|
|
e16efee488 | ||
|
|
eca9757421 | ||
|
|
df9005cc9f | ||
|
|
bdaa1c4f64 | ||
|
|
649dfd81ed | ||
|
|
2312ba9114 | ||
|
|
bbd2dd7d97 | ||
|
|
dfe544c6aa | ||
|
|
6ec2b8952c | ||
|
|
21a27fd9cc | ||
|
|
f2ae81002e | ||
|
|
4fb1350753 | ||
|
|
e2f42008b2 | ||
|
|
9c9a096b16 | ||
|
|
f03a1de035 | ||
|
|
328de37b2d | ||
|
|
4a7eb400db | ||
|
|
9bcbfbc198 | ||
|
|
018935fd29 | ||
|
|
ff789fcb90 | ||
|
|
637252ca18 | ||
|
|
6786636945 | ||
|
|
824c4d34c0 | ||
|
|
3928d3a818 | ||
|
|
e72d7bdf8b | ||
|
|
c263b17651 | ||
|
|
e8619a7df1 | ||
|
|
c5a35016d1 | ||
|
|
5c664187f9 | ||
|
|
66be14df0c | ||
|
|
50012dd49f | ||
|
|
d8aef7d9e5 | ||
|
|
ededf93b38 | ||
|
|
1e99aebbac | ||
|
|
7d164d40a1 | ||
|
|
ef8ac92506 | ||
|
|
a37e2506b4 | ||
|
|
0e68f8c830 | ||
|
|
8c98433da9 | ||
|
|
b09fa4ada5 | ||
|
|
91f8bbb9fe | ||
|
|
ae14e37e60 | ||
|
|
e66fa014ca | ||
|
|
7828bb9433 | ||
|
|
d671bb0310 | ||
|
|
663fb6a66e | ||
|
|
1c173b76ca | ||
|
|
1ad5031c6f | ||
|
|
013da5f0c4 | ||
|
|
2e8aa50c3a | ||
|
|
a1d42af2df | ||
|
|
3d95f08ef4 | ||
|
|
77a68e4a0c | ||
|
|
259e21a4ab | ||
|
|
e709cb3ce5 | ||
|
|
ed681025b4 | ||
|
|
84d05bd958 | ||
|
|
9f861df96b | ||
|
|
aec145a847 | ||
|
|
645582868d | ||
|
|
3f58e99c28 | ||
|
|
de3ac9aad5 | ||
|
|
8c5ac24468 | ||
|
|
d086d49cdc | ||
|
|
21b7d91ee6 | ||
|
|
ed2161a6da | ||
|
|
7b2cad22ec | ||
|
|
999998f309 | ||
|
|
0a48b0e8eb | ||
|
|
e689bc766e | ||
|
|
2f640e93d5 | ||
|
|
f683c17c80 | ||
|
|
06b9ef0013 | ||
|
|
7498b68e81 | ||
|
|
462014c8ac | ||
|
|
4c7cecb5c3 | ||
|
|
0e1ca4304b | ||
|
|
e36161ce5a | ||
|
|
7b44c5f7eb | ||
|
|
dc0926bab7 | ||
|
|
304b8340d2 | ||
|
|
b988b51cff | ||
|
|
ee0223a7a3 | ||
|
|
974e093e23 | ||
|
|
f04c883269 | ||
|
|
a08afb2712 | ||
|
|
caa7329a68 | ||
|
|
d3b71fccf6 | ||
|
|
ac9d385fb6 | ||
|
|
d3a1dba3c1 | ||
|
|
96c2c8a335 | ||
|
|
9df4bddc09 | ||
|
|
faaedf44f0 | ||
|
|
3ac6a8b603 | ||
|
|
c0d07cf6f9 | ||
|
|
854d395aad | ||
|
|
8e2d76ecfb | ||
|
|
88a4c82158 | ||
|
|
83bbd5d428 | ||
|
|
14abf3d1ab | ||
|
|
a5bdd05c24 | ||
|
|
e8cbe7b9f5 | ||
|
|
6159a7c385 | ||
|
|
3b4dd9d9e4 | ||
|
|
e621a9aedd | ||
|
|
4e9458f102 | ||
|
|
d477bbb178 | ||
|
|
046544ea27 | ||
|
|
a5f9e755ff | ||
|
|
aefd8cc43c | ||
|
|
b5141e8196 | ||
|
|
675ed13493 | ||
|
|
15e6ad5892 | ||
|
|
526aef9acc | ||
|
|
aac3a483c0 | ||
|
|
21d2bc45a1 | ||
|
|
3c6cbda0f5 | ||
|
|
00ead6d946 | ||
|
|
40912bb192 | ||
|
|
8a93d2fb7d | ||
|
|
38d7758c4c | ||
|
|
401d499d61 | ||
|
|
5cf5615265 | ||
|
|
192d50a11d | ||
|
|
1cb00f826a | ||
|
|
92d9649008 | ||
|
|
9ab4b8a7fd | ||
|
|
544ce897c5 | ||
|
|
3a83588802 | ||
|
|
c2742fbcf0 | ||
|
|
11964d530f | ||
|
|
3ef9289dfb | ||
|
|
1fb3998033 | ||
|
|
fa4e72f80a | ||
|
|
5870a52ce2 | ||
|
|
73c8ade3a2 | ||
|
|
c9eb725a51 | ||
|
|
8098136d09 |
138 changed files with 13789 additions and 29 deletions
8
.actrc
Normal file
8
.actrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Configuration file for act (run github actions locally using docker)
|
||||
# https://github.com/nektos/act
|
||||
|
||||
# Swap docker image for the one containing the rust toolchain
|
||||
-P ubuntu-latest=ghcr.io/catthehacker/ubuntu:rust-latest
|
||||
|
||||
# Load custom event
|
||||
-e .github/.act-event.json
|
||||
6
.github/.act-event.json
vendored
Normal file
6
.github/.act-event.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"act": true,
|
||||
"repository": {
|
||||
"default_branch": "main"
|
||||
}
|
||||
}
|
||||
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report for a found bug
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps To Reproduce**
|
||||
Please provide the steps to reproduce the behavior (if not possible, describe as many details as possible).
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots or Data**
|
||||
If applicable, add screenshots/data to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest a feature
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**What is this feature about?**
|
||||
Shortly explain what your requested feature is about.
|
||||
|
||||
**Additional context/references**
|
||||
Add any other context or references about the feature request here.
|
||||
17
.github/ISSUE_TEMPLATE/help-regarding-code-protocol-errors.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/help-regarding-code-protocol-errors.md
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
name: Help regarding code/protocol errors
|
||||
about: Use this if you can't figure out how to use something.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**This issue shall be made only if you have already gone through the docs, have you done it?**
|
||||
Please state if there is something confusing regarding the docs (eg. location or wording).
|
||||
|
||||
**What's you problem?**
|
||||
State as concise as possible what you want to do and can't do.
|
||||
|
||||
**Suggestions to make this clearer**
|
||||
Mention how could stuff be improved so that someone doesn't have the same problem as you (eg. Error should give more information).
|
||||
0
.github/badges/.gitkeep
vendored
Normal file
0
.github/badges/.gitkeep
vendored
Normal file
20
.github/badges/node.svg
vendored
Normal file
20
.github/badges/node.svg
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<svg width="181.6" height="20" viewBox="0 0 1816 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Node game coverage: 23.46%">
|
||||
<title>Node game coverage: 23.46%</title>
|
||||
<linearGradient id="a" x2="0" y2="100%">
|
||||
<stop offset="0" stop-opacity=".1" stop-color="#EEE"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<mask id="m"><rect width="1816" height="200" rx="30" fill="#FFF"/></mask>
|
||||
<g mask="url(#m)">
|
||||
<rect width="1276" height="200" fill="#555"/>
|
||||
<rect width="540" height="200" fill="#0f80c1" x="1276"/>
|
||||
<rect width="1816" height="200" fill="url(#a)"/>
|
||||
</g>
|
||||
<g aria-hidden="true" fill="#fff" text-anchor="start" font-family="Verdana,DejaVu Sans,sans-serif" font-size="110">
|
||||
<text x="60" y="148" textLength="1176" fill="#000" opacity="0.25">Node game coverage</text>
|
||||
<text x="50" y="138" textLength="1176">Node game coverage</text>
|
||||
<text x="1331" y="148" textLength="440" fill="#000" opacity="0.25">23.46%</text>
|
||||
<text x="1321" y="138" textLength="440">23.46%</text>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
16
.github/dependabot.yml
vendored
Normal file
16
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/.github/workflows"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/crates/cli"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/crates/lib"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
18
.github/labeler.yml
vendored
Normal file
18
.github/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
ci:
|
||||
- .github/workflows/**
|
||||
- .github/labeler.yml
|
||||
- .actrc
|
||||
- .pre-commit-config.yaml
|
||||
|
||||
protocol:
|
||||
- crates/lib/src/protocols/**
|
||||
|
||||
game:
|
||||
- crates/lib/src/games/**
|
||||
|
||||
cli:
|
||||
- crates/cli/**
|
||||
|
||||
crate:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
20
.github/workflows/audit.yml
vendored
Normal file
20
.github/workflows/audit.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# yaml-language-server: $schema=https://raw.githubusercontent.com/softprops/github-actions-schemas/master/workflow.json
|
||||
name: Security audit
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "**/Cargo.toml"
|
||||
- "**/Cargo.lock"
|
||||
jobs:
|
||||
security_audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Generate Cargo.lock # https://github.com/rustsec/audit-check/issues/27
|
||||
run: cargo generate-lockfile
|
||||
|
||||
- name: Audit Check
|
||||
uses: rustsec/audit-check@v2.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
199
.github/workflows/ci.yml
vendored
Normal file
199
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# yaml-language-server: $schema=https://raw.githubusercontent.com/softprops/github-actions-schemas/master/workflow.json
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "**.rs" # Any rust file
|
||||
- "**/Cargo.toml" # Any Cargo.toml
|
||||
- ".rustfmt.toml"
|
||||
- ".github/workflows/ci.yml" # This action
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "**.rs" # Any rust file
|
||||
- "**/Cargo.toml" # Any Cargo.toml
|
||||
- ".rustfmt.toml"
|
||||
- ".github/workflows/ci.yml" # This action
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# First check that we can build EVERYTHING and that tests pass
|
||||
build_first:
|
||||
name: "Build, check, and test with all features"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cli: ${{ steps.filter.outputs.cli }}
|
||||
lib: ${{ steps.filter.outputs.lib }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "cargo-deps"
|
||||
cache-targets: false
|
||||
- name: Run Build
|
||||
run: cargo check --verbose --workspace --bins --lib --examples --all-features
|
||||
- name: Run Tests
|
||||
run: cargo test --verbose --workspace --bins --lib --examples --tests --all-features
|
||||
# Check what paths were modified so we only run the required tests
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
cli:
|
||||
- 'crates/cli/**'
|
||||
lib:
|
||||
- 'crates/lib/**'
|
||||
|
||||
# If we were able to build then test different feature combinations compile with the library
|
||||
build_lib:
|
||||
runs-on: ubuntu-latest
|
||||
needs: ["build_first"]
|
||||
# Only run if library files were modified
|
||||
if: ${{ needs.build_first.outputs.lib == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- build_type: ""
|
||||
build_name: "Default"
|
||||
- build_type: "--no-default-features"
|
||||
build_name: "No features"
|
||||
- build_type: "--no-default-features --features games"
|
||||
build_name: "Just games"
|
||||
- build_type: "--no-default-features --features services"
|
||||
build_name: "Just Services"
|
||||
- build_type: "--no-default-features --features game_defs"
|
||||
build_name: "Just Game definitions"
|
||||
- build_type: "--no-default-features --features serde"
|
||||
build_name: "Just serde"
|
||||
name: "Build library ${{ matrix.build_name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "cargo-deps"
|
||||
cache-targets: false
|
||||
save-if: false
|
||||
- name: Run Build
|
||||
run: cargo check -p gamedig --verbose --lib --examples --tests ${{ matrix.build_type }}
|
||||
|
||||
# If we were able to build then test different feature combinations compile with the CLI
|
||||
build_cli:
|
||||
runs-on: ubuntu-latest
|
||||
needs: ["build_first"]
|
||||
# Only run if CLI files were modified
|
||||
if: ${{ needs.build_first.outputs.cli == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- build_type: ""
|
||||
build_name: "Default"
|
||||
- build_type: "--no-default-features"
|
||||
build_name: "No features"
|
||||
name: "Build CLI ${{ matrix.build_name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "cargo-deps"
|
||||
cache-targets: false
|
||||
save-if: false
|
||||
- name: Run Build
|
||||
run: cargo check -p gamedig_cli --verbose --bins --examples --tests ${{ matrix.build_type }}
|
||||
|
||||
# If we were able to build then test the MSRV compiles (for the libary as not enforced for CLI)
|
||||
build_msrv:
|
||||
name: "Build using MSRV (lib only)"
|
||||
runs-on: ubuntu-latest
|
||||
needs: ["build_first"]
|
||||
# Only run if library files were modified
|
||||
if: ${{ needs.build_first.outputs.lib == 'true' }}
|
||||
# Unfortunate hard-coding of rustup directory so that rust-cache caches it
|
||||
env:
|
||||
RUSTUP_HOME: /home/runner/.rustup
|
||||
steps:
|
||||
# Act's rust runner has rustup in a different place
|
||||
- if: ${{ env.ACT }}
|
||||
run: mkdir -p /home/runner && ln -s /usr/share/rust/.rustup /home/runner/.rustup
|
||||
- uses: actions/checkout@v5
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-targets: false
|
||||
cache-directories: ${{ env.RUSTUP_HOME }}/toolchains
|
||||
- name: Install MSRV
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: 1.85.1
|
||||
override: true
|
||||
- name: Run MSRV
|
||||
run: cargo check -p gamedig
|
||||
# Check the code is formatted properly
|
||||
formatting:
|
||||
name: "Check code formatting"
|
||||
runs-on: ubuntu-latest
|
||||
# Unfortunate hard-coding of rustup directory so that rust-cache caches it
|
||||
env:
|
||||
RUSTUP_HOME: /home/runner/.rustup
|
||||
steps:
|
||||
# Act's rust runner has rustup in a different place
|
||||
- if: ${{ env.ACT }}
|
||||
run: mkdir -p /home/runner && ln -s /usr/share/rust/.rustup /home/runner/.rustup
|
||||
- uses: actions/checkout@v5
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-targets: false
|
||||
cache-directories: ${{ env.RUSTUP_HOME }}/toolchains
|
||||
- name: Install Formatting nightly
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: nightly-2025-04-19
|
||||
components: rustfmt
|
||||
override: true
|
||||
- name: Run Formatting check
|
||||
run: cargo fmt --check --verbose
|
||||
|
||||
# If we were able to build then lint the codebase with clippy
|
||||
clippy:
|
||||
name: "Run clippy tests"
|
||||
runs-on: ubuntu-latest
|
||||
needs: ["build_first"]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "cargo-deps"
|
||||
cache-targets: false
|
||||
save-if: false
|
||||
# Run github actions version of clippy that adds annotations
|
||||
- name: Run Clippy
|
||||
uses: actions-rs/clippy-check@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --workspace --bins --lib --examples --tests --all-features
|
||||
if: ${{ !env.ACT }} # skip during local actions testing
|
||||
# Run clippy binary
|
||||
- name: Run clippy (local)
|
||||
run: cargo clippy --verbose --workspace --bins --lib --examples --tests --all-features
|
||||
if: ${{ env.ACT }} # only run during local actions testing
|
||||
|
||||
# If we were able to build then test that rustdoc (and rustdoc examples) compile
|
||||
doc:
|
||||
name: "Check rustdoc"
|
||||
runs-on: ubuntu-latest
|
||||
needs: ["build_first"]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "cargo-deps"
|
||||
cache-targets: false
|
||||
save-if: false
|
||||
- name: "Run cargo doc"
|
||||
run: cargo doc --workspace
|
||||
env:
|
||||
RUSTDOCFLAGS: "-D warnings"
|
||||
44
.github/workflows/codeql.yml
vendored
Normal file
44
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '0 0 1 * *' # monthly on the 1st at 00:00 UTC
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (rust)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: rust
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
16
.github/workflows/labeler.yml
vendored
Normal file
16
.github/workflows/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/labeler@v4
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
dot: true
|
||||
63
.github/workflows/node-badge.yml
vendored
Normal file
63
.github/workflows/node-badge.yml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Based on: https://github.com/emibcn/badge-action/blob/master/.github/workflows/test.yml
|
||||
name: "Generate node comparison badge"
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "crates/lib/src/games/definitions.rs"
|
||||
- ".github/workflows/node-badge.yml"
|
||||
- ".github/workflows/scripts/node-badge.mjs"
|
||||
branches:
|
||||
- "main" # Limit badge commits to only happen on the main branch
|
||||
schedule: # This runs on the default branch only, it could still trigger on PRs but only if they develop on default branch and enable actions.
|
||||
- cron: "34 3 * * 2" # Update once a week in case node-gamedig has changed
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
badge:
|
||||
runs-on: "ubuntu-latest"
|
||||
name: Create node comparison badge
|
||||
env:
|
||||
BADGE_PATH: ".github/badges/node.svg"
|
||||
steps:
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: echo "branch=${GITHUB_REF#refs/heads/}" >> "${GITHUB_OUTPUT}"
|
||||
id: extract_branch
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "gamedig/node-gamedig"
|
||||
path: "node-gamedig"
|
||||
sparse-checkout: |
|
||||
lib/games.js
|
||||
package.json
|
||||
|
||||
- name: Calculate comparison
|
||||
id: comparison
|
||||
run: node .github/workflows/scripts/node-badge.mjs
|
||||
|
||||
- name: Generate the badge SVG image
|
||||
uses: emibcn/badge-action@v2.0.3
|
||||
id: badge
|
||||
with:
|
||||
label: "Node game coverage"
|
||||
status: "${{ steps.comparison.outputs.percent }}%"
|
||||
color: "0f80c1"
|
||||
path: ${{ env.BADGE_PATH }}
|
||||
|
||||
- name: "Commit badge"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add "${BADGE_PATH}"
|
||||
git commit -m "Add/Update badge"
|
||||
|
||||
- name: Push badge commit
|
||||
uses: ad-m/github-push-action@master
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: ${{ steps.extract_branch.outputs.branch }}
|
||||
63
.github/workflows/scripts/node-badge.mjs
vendored
Normal file
63
.github/workflows/scripts/node-badge.mjs
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use strict";
|
||||
|
||||
//! Calculate the percentage of games from node that we support
|
||||
// Expects node-gamedig checkout out in git root /node-gamedig
|
||||
// Expects the generic example to output a list of game IDs when no arguments are provided
|
||||
|
||||
import process from "node:process";
|
||||
import { closeSync, openSync, writeSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const setOutput = (key, value) => {
|
||||
const file = openSync(process.env.GITHUB_OUTPUT, "a");
|
||||
writeSync(file, `${key}=${value}\n`);
|
||||
closeSync(file);
|
||||
};
|
||||
|
||||
// Get node IDs
|
||||
// NOTE: Here we directly import from games to avoid loading
|
||||
// unecessary parts of the library that would require us
|
||||
// to install dependencies.
|
||||
import { games } from "../../../node-gamedig/lib/games.js";
|
||||
|
||||
const node_ids = new Set(Object.keys(games));
|
||||
const node_total = node_ids.size;
|
||||
|
||||
// Get rust IDs
|
||||
|
||||
const command = spawnSync("cargo", [
|
||||
"run",
|
||||
"-p",
|
||||
"gamedig",
|
||||
"--example",
|
||||
"generic",
|
||||
]);
|
||||
|
||||
if (command.status !== 0) {
|
||||
console.error(command.stderr.toString("utf8"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rust_ids_pretty = command.stdout.toString("utf8");
|
||||
const rust_ids = new Set(
|
||||
rust_ids_pretty
|
||||
.split("\n")
|
||||
.map((line) => line.split("\t")[0])
|
||||
.filter((id) => id.length > 0)
|
||||
);
|
||||
|
||||
// Detect missing node IDs
|
||||
|
||||
for (const id of rust_ids) {
|
||||
if (node_ids.delete(id)) {
|
||||
rust_ids.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Node remains", node_ids);
|
||||
console.log("Rust remains", rust_ids);
|
||||
|
||||
const percent = 1 - node_ids.size / node_total;
|
||||
|
||||
// Output percent to 2 decimal places
|
||||
setOutput("percent", Math.round(percent * 10000) / 100);
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -11,3 +11,7 @@ Cargo.lock
|
|||
|
||||
# Others
|
||||
.idea/
|
||||
.venv/
|
||||
.vscode/
|
||||
|
||||
test_everything.py
|
||||
|
|
|
|||
60
.pre-commit-config.yaml
Normal file
60
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: clippy
|
||||
name: Check clippy
|
||||
language: system
|
||||
files: '([.]rs|Cargo\.toml)$'
|
||||
pass_filenames: false
|
||||
entry: rustup run --install nightly-2025-04-19 cargo-clippy -- --workspace --all-features -- -D warnings
|
||||
|
||||
- id: build-no-features
|
||||
name: Check crate build with no features
|
||||
language: system
|
||||
files: '([.]rs|Cargo\.toml)$'
|
||||
pass_filenames: false
|
||||
entry: cargo check --workspace --no-default-features
|
||||
|
||||
- id: build-all-features
|
||||
name: Check crate builds with all features
|
||||
language: system
|
||||
files: '([.]rs|Cargo\.toml)$'
|
||||
pass_filenames: false
|
||||
entry: cargo check --workspace --all-features --lib --bins --examples
|
||||
|
||||
- id: test
|
||||
name: Check tests pass
|
||||
language: system
|
||||
files: '([.]rs|Cargo\.toml)$'
|
||||
pass_filenames: false
|
||||
entry: cargo test --workspace --bins --lib --examples --tests --all-features
|
||||
|
||||
- id: format
|
||||
name: Check rustfmt
|
||||
language: system
|
||||
files: '([.]rs|Cargo\.toml)$'
|
||||
pass_filenames: false
|
||||
entry: rustup run --install nightly-2025-04-19 cargo-fmt --check
|
||||
|
||||
- id: msrv
|
||||
name: Check MSRV compiles (lib only)
|
||||
language: system
|
||||
files: '([.]rs|Cargo\.toml)$'
|
||||
pass_filenames: false
|
||||
entry: rustup run --install 1.85.1 cargo check -p gamedig
|
||||
|
||||
- id: docs
|
||||
name: Check rustdoc compiles
|
||||
language: system
|
||||
files: '([.]rs|Cargo\.toml)$'
|
||||
pass_filenames: false
|
||||
entry: env RUSTDOCFLAGS="-D warnings" cargo doc
|
||||
|
||||
- id: actions
|
||||
name: Check actions work
|
||||
language: system
|
||||
files: '^[.]github/workflows/'
|
||||
pass_filenames: false
|
||||
entry: act --rm
|
||||
72
.rustfmt.toml
Normal file
72
.rustfmt.toml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
attr_fn_like_width = 70
|
||||
array_width = 60
|
||||
binop_separator = "Front"
|
||||
blank_lines_lower_bound = 0
|
||||
blank_lines_upper_bound = 1
|
||||
brace_style = "PreferSameLine"
|
||||
chain_width = 60
|
||||
color = "Auto"
|
||||
combine_control_expr = false
|
||||
comment_width = 80
|
||||
condense_wildcard_suffixes = true
|
||||
control_brace_style = "AlwaysSameLine"
|
||||
disable_all_formatting = false
|
||||
doc_comment_code_block_width = 100
|
||||
edition = "2021"
|
||||
emit_mode = "Files"
|
||||
empty_item_single_line = true
|
||||
error_on_line_overflow = false
|
||||
error_on_unformatted = false
|
||||
fn_call_width = 60
|
||||
fn_params_layout = "Tall"
|
||||
fn_single_line = true
|
||||
force_explicit_abi = true
|
||||
force_multiline_blocks = true
|
||||
format_generated_files = true
|
||||
format_macro_bodies = true
|
||||
format_strings = true
|
||||
group_imports = "Preserve"
|
||||
hard_tabs = false
|
||||
show_parse_errors = true
|
||||
hex_literal_case = "Preserve"
|
||||
ignore = []
|
||||
indent_style = "Block"
|
||||
imports_granularity = "Preserve"
|
||||
imports_indent = "Block"
|
||||
imports_layout = "HorizontalVertical"
|
||||
inline_attribute_width = 0
|
||||
make_backup = false
|
||||
match_arm_blocks = true
|
||||
match_arm_leading_pipes = "Never"
|
||||
match_block_trailing_comma = false
|
||||
max_width = 120
|
||||
merge_derives = true
|
||||
newline_style = "Auto"
|
||||
normalize_comments = true
|
||||
normalize_doc_attributes = false
|
||||
overflow_delimited_expr = false
|
||||
reorder_impl_items = false
|
||||
reorder_imports = true
|
||||
reorder_modules = true
|
||||
required_version = "1.8.0"
|
||||
short_array_element_width_threshold = 10
|
||||
single_line_if_else_max_width = 50
|
||||
skip_children = false
|
||||
space_after_colon = true
|
||||
space_before_colon = false
|
||||
spaces_around_ranges = true
|
||||
struct_field_align_threshold = 0
|
||||
struct_lit_single_line = true
|
||||
struct_lit_width = 18
|
||||
struct_variant_width = 35
|
||||
tab_spaces = 4
|
||||
trailing_comma = "Vertical"
|
||||
trailing_semicolon = true
|
||||
type_punctuation_density = "Wide"
|
||||
unstable_features = false
|
||||
use_field_init_shorthand = false
|
||||
use_small_heuristics = "Default"
|
||||
use_try_shorthand = true
|
||||
style_edition = "2021"
|
||||
where_single_line = true
|
||||
wrap_comments = true
|
||||
119
CONTRIBUTING.md
Normal file
119
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Contributing to rust-GameDig
|
||||
|
||||
This project is very open to new suggestions, additions and/or changes, these
|
||||
can come in the form of *discussions* about the project's state, *proposing a
|
||||
new feature*, *holding a few points on why we shall do X breaking change* or
|
||||
*submitting a fix*.
|
||||
|
||||
## Communications
|
||||
|
||||
GitHub is the place we use to track bugs and discuss new features/changes,
|
||||
although we have a [Discord](https://discord.gg/NVCMn3tnxH) server for the
|
||||
community, all bugs, suggestions and changes will be reported on GitHub
|
||||
alongside with their backing points to ensure the transparency of the project's
|
||||
development.
|
||||
|
||||
## Issues
|
||||
|
||||
Before opening an issue, check if there is an existing relevant issue first,
|
||||
someone might just have had your issue already, or you might find something
|
||||
related that could be of help.
|
||||
|
||||
When opening a new issue, make sure to fill the issue template. They are made
|
||||
to make the subject to be as understandable as possible, not doing so may result
|
||||
in your issue not being managed right away, if you don't understand something
|
||||
(be it regarding your own problem/the issue template/the library), please state
|
||||
so.
|
||||
|
||||
## Development
|
||||
|
||||
Note before contributing that everything done here is under the [MIT](https://opensource.org/license/mit/) license.
|
||||
|
||||
### Naming
|
||||
|
||||
Naming is an important matter, and it shouldn't be changed unless necessary.
|
||||
|
||||
Game **names** should be added as they appear on steam (or other storefront
|
||||
if not listed there) with the release year appended in brackets (except when the
|
||||
release year is already part of the name).
|
||||
If there is a mod that needs to be added (or it adds the support for server
|
||||
queries for the game), its name should be composed of the game name, a separating
|
||||
**bracket**, the mod name and the release year as specified previously
|
||||
(e.g. `Grand Theft Auto V - FiveM (2013)`).
|
||||
|
||||
A game's **identification** is a lowercase alphanumeric string will and be forged
|
||||
following these rules:
|
||||
|
||||
1. Names composed of a maximum of two words (unless #4 applies) will result in an
|
||||
id where the words are concatenated (`Dead Cells` -> `deadcells`), acronyms in
|
||||
the name count as a single word (`S.T.A.L.K.E.R.` -> `stalker`).
|
||||
2. Names of more than two words shall be made into an acronym made of the
|
||||
initial
|
||||
letters (`The Binding of Isaac` -> `tboi`), [hypenation composed words](https://prowritingaid.com/hyphenated-words)
|
||||
don't count as a single word, but of how many parts they are made of
|
||||
(`Dino D-Day`, 3 words, so `ddd`).
|
||||
3. If a game has the exact name as a previously existing id's game
|
||||
(`Star Wars Battlefront 2`, the 2005 and 2017 one), append the release year to
|
||||
the newer id (2005 would be `swb2` (suppose we already have this one supported)
|
||||
and 2017 would be `swb22017`).
|
||||
4. If a new id (`Day of Dragons` -> `dod`) results in an id that already exists
|
||||
(`Day of Defeat` -> `dod`), then the new name should ignore rule #2
|
||||
(`Day of Dragons` -> `dayofdragons`).
|
||||
5. Roman numbering will be converted to arabic numbering (`XIV` -> `14`).
|
||||
6. Unless numbers (years included) are at the end of a name, they will be considered
|
||||
words. If a number is not in the first position, its entire numeric digits will be
|
||||
used instead of the acronym of that number's digits (`Left 4 Dead` -> `l4d`). If the
|
||||
number is in the first position the longhand (words: 5 -> five) representation of the
|
||||
number will be used to create an acronym (`7 Days to Die` -> `sdtd`). Other examples:
|
||||
`Team Fortress 2` -> `teamfortress2`, `Unreal Tournament 2003` ->
|
||||
`unrealtournament2003`.
|
||||
7. If a game supports multiple protocols, multiple entries will be done for said game
|
||||
where the edition/protocol name (first disposable in this order) will be appended to
|
||||
the base game id's: `<game_id><protocol_id>` (where the protocol id will follow all
|
||||
rules except #2) (Minecraft is mainly divided by 2 editions, Java and Bedrock
|
||||
which will be `minecraftjava` and `minecraftbedrock` respectively, but it also has
|
||||
legacy versions, which use another protocol, an example would be the one for `1.6`,
|
||||
so the name would be `Legacy 1.6` which its id will be `legacy16`, resulting in the
|
||||
entry of `minecraftlegacy16`). One more entry can be added by the base name of the
|
||||
game, which queries in a group said supported protocols to make generic queries
|
||||
easier and disposable.
|
||||
8. If its actually about a mod that adds the ability for queries to be performed,
|
||||
process only the mod name.
|
||||
|
||||
### Making commits
|
||||
|
||||
Where possible please format commits as complete atomic changes that don't rely on
|
||||
any future commits. Also make sure that the commit message is as descriptive as
|
||||
possible.
|
||||
|
||||
To avoid CI failing when you make a PR you can use our pre-commit hooks: tests that
|
||||
run before you are able to make a commit (you can skip this at any time by adding
|
||||
the `-n` flag to `git commit`).
|
||||
|
||||
To set this up you need the following programs installed
|
||||
|
||||
- [pre-commit](https://pre-commit.com/)
|
||||
- [rustup](https://rustup.rs/)
|
||||
- [act](https://github.com/nektos/act) (If you want to test changes to github actions workflows)
|
||||
|
||||
Once these are installed you can enable the pre-commit hook by running the following in
|
||||
the root directory of the repository.
|
||||
|
||||
```shell
|
||||
$ pre-commit install
|
||||
```
|
||||
|
||||
### Priorities
|
||||
|
||||
Game suggestions will be prioritized by maintainers based on whether the game
|
||||
uses a protocol already implemented in the library (games that use already
|
||||
implemented protocols will be added first), except in the case where a
|
||||
contribution is made with the protocol needed to implement the game.
|
||||
|
||||
The same goes for protocols, if 2 were to be requested, the one implemented in
|
||||
the most games will be prioritized.
|
||||
|
||||
### Releases
|
||||
|
||||
Currently, there is no release schedule.
|
||||
Releases are made when the team decides one will be fitting to be done.
|
||||
34
Cargo.toml
34
Cargo.toml
|
|
@ -1,14 +1,22 @@
|
|||
[package]
|
||||
name = "gamedig"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
authors = ["CosminPerRam [cosmin.p@live.com]", "mmorrisontx [https://github.com/mmorrisontx]"]
|
||||
license-file = "LICENSE.md"
|
||||
description = "Check out servers with this."
|
||||
homepage = "https://github.com/CosminPerRam/rust-gamedig"
|
||||
documentation = "https://github.com/CosminPerRam/rust-gamedig"
|
||||
repository = "https://github.com/CosminPerRam/rust-gamedig"
|
||||
readme = "README.md"
|
||||
keywords = ["server", "valve", "games", "checker", "status"]
|
||||
[workspace]
|
||||
members = ["crates/cli", "crates/lib", "crates/id-tests"]
|
||||
|
||||
[dependencies]
|
||||
# Edition 2021, uses resolver = 2
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
debug = false
|
||||
rpath = true
|
||||
lto = 'fat'
|
||||
codegen-units = 1
|
||||
|
||||
[profile.release.package."*"]
|
||||
opt-level = 3
|
||||
|
||||
# When building locally, use the local version of the library
|
||||
# Comment this out when you want to resolve the library from crates.io
|
||||
# This is only for crates that use gamedig as a dependency
|
||||
# https://doc.rust-lang.org/cargo/reference/overriding-dependencies.html
|
||||
[patch.crates-io]
|
||||
gamedig = { path = "./crates/lib" }
|
||||
104
GAMES.md
Normal file
104
GAMES.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
A supported game is defined as a game that has been successfully tested, other games that are not present here but use
|
||||
one of the implemented protocols might work too, but that isn't guaranteed.
|
||||
Beware of the `Notes` column, as it contains information about query port offsets or other query
|
||||
requirements/information.
|
||||
|
||||
# Supported games:
|
||||
|
||||
| Game | Use name | Protocol | Notes |
|
||||
|------------------------------------|---------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Team Fortress 2 | TEAMFORTRESS2 | Valve | |
|
||||
| The Ship | THESHIP | Valve (*Altered) | |
|
||||
| Counter-Strike: Global Offensive | CSGO | Valve | The server must have the cvar `host_players_show` set to `2` to get the full player list. |
|
||||
| Counter-Strike: Source | CSS | Valve | |
|
||||
| Day of Defeat: Source | DODS | Valve | |
|
||||
| Left 4 Dead | LEFT4DEAD | Valve | |
|
||||
| Left 4 Dead 2 | LEFT4DEAD2 | Valve | |
|
||||
| Half-Life 2 Deathmatch | HL2D | Valve | |
|
||||
| Alien Swarm | ALIENSWARM | Valve | |
|
||||
| Alien Swarm: Reactive Drop | ASRD | Valve | |
|
||||
| Insurgency | INSURGENCY | Valve | |
|
||||
| Insurgency: Sandstorm | INSURGENCYSANDSTORM | Valve | Query port offset: 1. |
|
||||
| Insurgency: Modern Infantry Combat | IMIC | Valve | |
|
||||
| Counter-Strike: Condition Zero | CSCZ | Valve GoldSrc | |
|
||||
| Day of Defeat | DOD | Valve GoldSrc | |
|
||||
| Minecraft | MINECRAFT | Proprietary | Bedrock edition provides a different response compared to the Java edition, query specifically for bedrock to get them, otherwise, only matching fields will be provided. |
|
||||
| 7 Days To Die | SD2D | Valve | |
|
||||
| ARK: Survival Evolved | ASE | Valve | |
|
||||
| Unturned | UNTURNED | Valve | |
|
||||
| The Forest | THEFOREST | Valve GoldSrc | Query port offset: 1. |
|
||||
| Team Fortress Classic | TFC | Valve | |
|
||||
| Sven Co-op | SCO | Valve GoldSrc | |
|
||||
| Rust | RUST | Valve | |
|
||||
| Counter-Strike | COUNTERSTRIKE | Valve GoldSrc | |
|
||||
| Arma 2: Operation Arrowhead | A2OA | Valve | Query port offset: 1. |
|
||||
| Arma 3 | ARMA3 | Valve | |
|
||||
| Day of Infamy | DOI | Valve | |
|
||||
| Half-Life Deathmatch: Source | HLDS | Valve | |
|
||||
| Risk of Rain 2 | ROR2 | Valve | Query port offset: 1. |
|
||||
| Battalion 1944 | BATTALION1944 | Valve | Query port offset: 3. It is strongly recommended to also query the rules, as it sends basic server info in them. |
|
||||
| Black Mesa | BLACKMESA | Valve | |
|
||||
| Project Zomboid | PROJECTZOMBOID | Valve | |
|
||||
| Age of Chivalry | AOC | Valve | |
|
||||
| Don't Starve Together | DST | Valve | Query port is 27016. |
|
||||
| Colony Survival | COLONYSURVIVAL | Valve | |
|
||||
| Onset | ONSET | Valve | Query port is 7776. |
|
||||
| Codename CURE | CODENAMECURE | Valve | |
|
||||
| Ballistic Overkill | BALLISTICOVERKILL | Valve | Query port is 27016. |
|
||||
| BrainBread 2 | BRAINBREAD2 | Valve | |
|
||||
| Avorion | AVORION | Valve | Query port is 27020. |
|
||||
| Operation: Harsh Doorstop | OHD | Valve | Query port is 27005. |
|
||||
| V Rising | VRISING | Valve | Query port is 27016. |
|
||||
| Unreal Tournament | UNREALTOURNAMENT | GameSpy 1 | Query Port offset: 1. |
|
||||
| Battlefield 1942 | B1942 | GameSpy 1 | Query port is 23000. |
|
||||
| Serious Sam | SERIOUSSAM | GameSpy 1 | Query Port offset: 1. |
|
||||
| Frontlines: Fuel of War | FFOW | Valve (*Altered) | Query Port offset: 2. |
|
||||
| Crysis Wars | CRYSISWARS | GameSpy 3 | |
|
||||
| Quake 2 | QUAKE2 | Quake 2 | |
|
||||
| Quake 1 | QUAKE1 | Quake 1 | |
|
||||
| Quake 3: Arena | QUAKE3 | Quake 3 | |
|
||||
| Hell Let Loose | HLL | Valve Protocol | Query port is 26420. Note that on this port it might not send players data, as there might be another query port that does send players data. |
|
||||
| Soldier of Fortune 2 | SOF2 | Quake 3 | |
|
||||
| Halo: Combat Evolved | HCE | GameSpy 2 | |
|
||||
| Just Cause 2: Multiplayer | JC2M | GameSpy 3 (*Altered) | |
|
||||
| Warsow | WARSOW | Quake 3 | |
|
||||
| Creativerse | CREATIVERSE | Valve | Query Port offset: 1. |
|
||||
| Garry's Mod | GARRYSMOD | Valve | |
|
||||
| Barotrauma | BAROTRAUMA | Valve | Query Port offset: 1. |
|
||||
| Valheim | VALHEIM | Valve | Query Port offset: 1. Does not respond to the A2S rules. |
|
||||
| The Front | THEFRONT | Valve | Responds with wrong values on `name` (gives out a SteamID instead of the server name) and `players_maximum` (always 200). |
|
||||
| Conan Exiles | CONANEXILES | Valve | Does not respond to the players query. |
|
||||
| Darkest Hour: Europe '44-'45 | DARKESTHOUR | Unreal2 | Query port offset: 1 |
|
||||
| Devastation | DEVASTATION | Unreal2 | Query port offset: 1 |
|
||||
| Killing Floor | KILLINGFLOOR | Unreal2 | Query port offset: 1 |
|
||||
| Red Orchestra | REDORCHESTRA | Unreal2 | Query port offset: 1 |
|
||||
| Unreal Tournament 2003 | UT2003 | Unreal2 | Query port offset: 1 |
|
||||
| Unreal Tournament 2004 | UT2004 | Unreal2 | Query port offset: 1 |
|
||||
| Post Scriptum | POSTSCRIPTUM | Valve | |
|
||||
| Squad | SQUAD | Valve | |
|
||||
| Savage 2 | SAVAGE2 | Proprietary | |
|
||||
| Rising World | RISINGWORLD | Valve | Query port offset: -1 |
|
||||
| ATLAS | ATLAS | Valve | Query port offset: 51800 |
|
||||
| America's Army: Proving Grounds | AAPG | Valve | Query port: 27020. Does not respond to the rules query. |
|
||||
| Base Defense | BASEDEFENSE | Valve | Query port: 27015. Does not respond to the rules query. |
|
||||
| Zombie Panic: Source | ZPS | Valve | Query port: 27015. |
|
||||
| Call Of Duty: Black Ops 3 | CODBO3 | Valve | Query port: 27017. |
|
||||
| Counter-Strike 2 | COUNTERSTRIKE2 | Valve | |
|
||||
| Double Action: Boogaloo | DAB | Valve | |
|
||||
| Mordhau | MORDHAU | Valve | |
|
||||
| Enshrouded | ENSHROUDED | Valve | |
|
||||
| Myth of Empires | MOE | Valve | |
|
||||
| Pirates, Vikings, and Knights II | PVAK2 | Valve | |
|
||||
| PixARK | PIXARK | Valve | |
|
||||
| Ark: Survival Ascended | ASA | Epic | Available on the 'tls' feature |
|
||||
| Aliens vs. Predator 2010 | AVP | Valve | |
|
||||
| Arma Reforger | ARMAREFORGER | Valve | |
|
||||
| Nova-Life: Amboise | NLA | Valve | |
|
||||
| Abiotic Factor | ABIOTICFACTOR | Valve | |
|
||||
| Soulmask | SOULMASK | Valve | |
|
||||
| Starbound | STARBOUND | Valve | |
|
||||
| Minetest | MINETEST | Proprietary | Available on the 'tls', 'serde' and 'services' feature |
|
||||
|
||||
## Planned to add support:
|
||||
|
||||
_
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 CosminPerRam
|
||||
Copyright (c) 2022 - 2026 GameDig Organization & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
18
PROTOCOLS.md
Normal file
18
PROTOCOLS.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
A protocol is defined as proprietary if it is being used only for a single scope (or series, like Minecraft).
|
||||
|
||||
# Supported protocols:
|
||||
|
||||
| Name | For | Proprietary? | Documentation reference | Notes |
|
||||
|---------------------------|-------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Valve Protocol | Games | No | [Server Queries](https://developer.valvesoftware.com/wiki/Server_queries) | In some cases, the players details query might contain some 0-length named players. Multi-packet decompression not tested. |
|
||||
| Minecraft | Games | Yes | Java: [List Server Protocol](https://wiki.vg/Server_List_Ping) <br> Bedrock: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/minecraftbedrock.js) | |
|
||||
| GameSpy | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy3.js) | These protocols are not really standardized, gamedig tries to get the most common fields amongst its supported games, if there are parsing problems, use the `query_vars` function. |
|
||||
| Quake | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake3.js) | |
|
||||
| Just Cause 2: Multiplayer | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/jc2mp.js) |
|
||||
| Unreal 2 | Games | No | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/unreal2.js) | Sometimes servers send strings that node-gamedig would treat as latin1 that are UTF-8 encoded, when this happens the remove color code breaks because latin1 decodes the colour sequences differently. Some games provide additional info at the end of the server info packet, this is not currently handled (see the node implementation). Some games use a bot player to denote the team names, this is not currently handled. |
|
||||
| Savage 2 | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/savage2.js) | |
|
||||
| Epic | Games | No | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/epic.js) | Available only on the 'tls' feature. |
|
||||
|
||||
## Planned to add support:
|
||||
|
||||
_
|
||||
119
README.md
119
README.md
|
|
@ -1 +1,118 @@
|
|||
# rust-gamedig
|
||||
<h1 align="center">rust-GameDig</h1>
|
||||
|
||||
<h5 align="center">The fast library for querying game servers/services.</h5>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/gamedig/rust-gamedig/actions">
|
||||
<img src="https://github.com/gamedig/rust-gamedig/actions/workflows/ci.yml/badge.svg" alt="CI">
|
||||
</a>
|
||||
<a href="https://crates.io/crates/gamedig">
|
||||
<img src="https://img.shields.io/crates/v/gamedig.svg?color=orange" alt="Latest Version">
|
||||
</a>
|
||||
<a href="https://crates.io/crates/gamedig">
|
||||
<img src="https://img.shields.io/crates/d/gamedig?color=purple" alt="Crates.io">
|
||||
</a>
|
||||
<a href="https://github.com/gamedig/node-gamedig">
|
||||
<img src="https://raw.githubusercontent.com/gamedig/rust-gamedig/main/.github/badges/node.svg" alt="Node-GameDig Game Coverage">
|
||||
</a>
|
||||
<a href="https://deps.rs/crate/gamedig">
|
||||
<img src="https://deps.rs/crate/gamedig/latest/status.svg" alt="Rust-GameDig Dependencies">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h5 align="center">
|
||||
This library brings what
|
||||
<a href="https://github.com/gamedig/node-gamedig">
|
||||
node-GameDig
|
||||
</a>
|
||||
does (and not only), to pure Rust!
|
||||
</h5>
|
||||
|
||||
**Warning**: This project goes through frequent API breaking changes and hasn't been thoroughly tested.
|
||||
|
||||
## Community
|
||||
|
||||
Checkout the GameDig Community Discord Server [here](https://discord.gg/NVCMn3tnxH).
|
||||
Note that it isn't be a replacement for GitHub issues, if you have found a problem
|
||||
within the library or want to request a feature, it's better to do so here rather than
|
||||
on Discord.
|
||||
|
||||
## Usage
|
||||
|
||||
Minimum Supported Rust Version is `1.85.1` and the code is cross-platform.
|
||||
|
||||
Pick a game/service/protocol (check the [GAMES](GAMES.md), [SERVICES](SERVICES.md) and [PROTOCOLS](PROTOCOLS.md) files
|
||||
to see the currently supported ones), provide the ip and the port (be aware that some game servers use a separate port
|
||||
for the info queries, the port can also be optional if the server is running the default ports) then query on it.
|
||||
|
||||
[Team Fortress 2](https://store.steampowered.com/app/440/Team_Fortress_2/) query example:
|
||||
|
||||
```rust
|
||||
use gamedig::games::teamfortress2;
|
||||
|
||||
fn main() {
|
||||
let response = teamfortress2::query(&"127.0.0.1".parse().unwrap(), None);
|
||||
// None is the default port (which is 27015), could also be Some(27015)
|
||||
|
||||
match response { // Result type, must check what it is...
|
||||
Err(error) => println!("Couldn't query, error: {}", error),
|
||||
Ok(r) => println!("{:#?}", r)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response (note that some games have a different structure):
|
||||
|
||||
```json5
|
||||
{
|
||||
protocol: 17,
|
||||
name: "Team Fortress 2 Dedicated Server.",
|
||||
map: "ctf_turbine",
|
||||
game: "tf2",
|
||||
appid: 440,
|
||||
players_online: 0,
|
||||
players_details: [],
|
||||
players_maximum: 69,
|
||||
players_bots: 0,
|
||||
server_type: Dedicated,
|
||||
has_password: false,
|
||||
vac_secured: true,
|
||||
version: "7638371",
|
||||
port: Some(27015),
|
||||
steam_id: Some(69753253289735296),
|
||||
tv_port: None,
|
||||
tv_name: None,
|
||||
keywords: Some(
|
||||
"alltalk,nocrits"
|
||||
),
|
||||
rules: [
|
||||
"mp_autoteambalance"
|
||||
:
|
||||
"1",
|
||||
"mp_maxrounds"
|
||||
:
|
||||
"5",
|
||||
//....
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Want to see more examples? Checkout the [examples](crates/lib/examples) folder.
|
||||
|
||||
## Command Line Interface
|
||||
|
||||
The library also has an [official CLI](https://crates.io/crates/gamedig_cli) that you can use, it has
|
||||
MSRV of `1.85.1`.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation is available at [docs.rs](https://docs.rs/gamedig/latest/gamedig/).
|
||||
Curious about the history and what changed between versions?
|
||||
Everything is in the changelogs file: [lib](crates/lib/CHANGELOG.md) and [cli](crates/lib/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to see your favorite game/service being supported here, open an issue, and I'll prioritize it (or do a pull
|
||||
request if you want to implement it yourself)!
|
||||
|
||||
Before contributing please read [CONTRIBUTING](CONTRIBUTING.md).
|
||||
|
|
|
|||
78
RESPONSES.md
Normal file
78
RESPONSES.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
Every protocol has its own response type(s), below is a listing of the overlapping fields on these responses.
|
||||
|
||||
If a cell is blank it doesn't exist, otherwise it contains the type of that data in the current column's response type.
|
||||
In the case that a field that performs the same function exists in the current column's response type that name is
|
||||
annotated in brackets.
|
||||
|
||||
# Response table
|
||||
|
||||
| Field | Generic | GameSpy(1) | GameSpy(2) | GameSpy(3) | Minecraft(Java) | Minecraft(Bedrock) | Valve | Quake | Unreal2 | Epic | Proprietary: FFOW | Proprietary: TheShip | Proprietary: JC2MP | Proprietary: Savage 2 | Proprietary: Minetest |
|
||||
|----------------------|----------|------------|------------|------------|-----------------|--------------------|---------------|-----------|------------|----------|-------------------|----------------------|--------------------|-----------------------|-----------------------|
|
||||
| name | `Option` | `String` | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` |
|
||||
| description | `Option` | | | | `String` | | | | | | `String` | | `String` | | `String` |
|
||||
| game_mode | `Option` | `String` | | `String` | | `Option` | `String` | | `String` | | `String` | `String` | | `String` | |
|
||||
| game_version | `Option` | `String` | | `String` | `String` | | `String` | `String` | | `String` | `String` | `String` | `String` | | `String` |
|
||||
| map | `Option` | `String` | `String` | `String` | | `Option` | `String` | `String` | `String` | `String` | `String` | `String` | | `String` | |
|
||||
| players_maxmimum | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u32` |
|
||||
| players_online | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u32` |
|
||||
| players_bots | `Option` | | | | | | `u8` | | | | | `u8` | | | |
|
||||
| has_password | `Option` | `bool` | `bool` | `bool` | | | `bool` | | | `bool` | `bool` | `bool` | `bool` | | `Option` |
|
||||
| players_minimum | | `Option` | `Option` | `Option` | | | | | | | | | | `u8` | |
|
||||
| players | | `Vec` | `Vec` | `Vec` | `Option>` | | `Option>` | `Vec ` | `Vec` | `Vec` | | `Vec` | `Vec` | | `Vec` |
|
||||
| tournament | | `bool` | | `bool` | | | | | | | | | | | |
|
||||
| unused_entries | | `Hashmap` | | `HashMap` | | | | `HashMap` | | | | | | | |
|
||||
| teams | | | `Vec` | `Vec` | | | | | | | | | | | |
|
||||
| protocol_version | | | | | `i32` | `String` | `u8` | | | | `u8` | `u8` | | `String` | |
|
||||
| server_type | | | | | `Server` | `Server` | `Server` | | | | | `Server` | | | |
|
||||
| rules | | | | | | | `Option>` | | `HashMap>` | | | `HashMap` | | | |
|
||||
| environment_type | | | | | | | `Environment` | | | | `Environment` | | | | |
|
||||
| vac_secured | | | | | | | `bool` | | | | `bool` | `bool` | | | |
|
||||
| map_title | | `Option` | | | | | | | | | | | | | |
|
||||
| admin_contact | | `Option` | | | | | | | | | | | | | |
|
||||
| admin_name | | `Option` | | | | | | | | | | | | | |
|
||||
| favicon | | | | | `Option` | | | | | | | | | | |
|
||||
| previews_chat | | | | | `Option` | | | | | | | | | | |
|
||||
| enforces_secure_chat | | | | | `Option` | | | | | | | | | | |
|
||||
| edition | | | | | | `String` | | | | | | | | | |
|
||||
| id | | | | | | `String` | | | `String` | | | | | | `String` |
|
||||
| the_ship | | | | | | | `Option` | | | | | | | | |
|
||||
| is_mod | | | | | | | `bool` | | | | | | | | |
|
||||
| extra_data | | | | | | | `Option` | | | | | | | | |
|
||||
| mod_data | | | | | | | `Option` | | | | | | | | |
|
||||
| folder | | | | | | | `String` | | | | | | | | |
|
||||
| appid | | | | | | | `u32` | | | | | | | | |
|
||||
| active_mod | | | | | | | | | | | `String` | | | | |
|
||||
| round | | | | | | | | | | | `u8` | | | | |
|
||||
| rounds_maximum | | | | | | | | | | | `u8` | | | | |
|
||||
| time_left | | | | | | | | | | | `u16` | | | | |
|
||||
| port | | | | | | | | | `u32` | | | `Option` | | | `u32` |
|
||||
| steam_id | | | | | | | | | | | | `Option` | | | |
|
||||
| tv_port | | | | | | | | | | | | `Option` | | | |
|
||||
| tv_name | | | | | | | | | | | | `Option` | | | |
|
||||
| keywords | | | | | | | | | | | | `Option` | | | |
|
||||
| mode | | | | | | | | | | | | `u8` | | | |
|
||||
| witnesses | | | | | | | | | | | | `u8` | | | |
|
||||
| duration | | | | | | | | | | | | `u8` | | | |
|
||||
| query_port | | | | | | | | | `u32` | | | | | | |
|
||||
| ip | | | | | | | | | `String` | | | | | | `String` |
|
||||
| mutators | | | | | | | | | `HashSet` | | | | | | |
|
||||
| next_map | | | | | | | | | | | | | | `String` | |
|
||||
| location | | | | | | | | | | | | | | `String` | |
|
||||
| level_minimum | | | | | | | | | | | | | | `String` | |
|
||||
| time | | | | | | | | | | | | | | `String` | |
|
||||
| creative | | | | | | | | | | | | | | | `Option` |
|
||||
| damage | | | | | | | | | | | | | | | `bool` |
|
||||
| game_time | | | | | | | | | | | | | | | `u32` |
|
||||
| lag | | | | | | | | | | | | | | | `Option` |
|
||||
| proto_max | | | | | | | | | | | | | | | `u16` |
|
||||
| proto_min | | | | | | | | | | | | | | | `u16` |
|
||||
| pvp | | | | | | | | | | | | | | | `bool` |
|
||||
| uptime | | | | | | | | | | | | | | | `u32` |
|
||||
| url | | | | | | | | | | | | | | | `Option` |
|
||||
| update_time | | | | | | | | | | | | | | | `u32` |
|
||||
| start | | | | | | | | | | | | | | | `u32` |
|
||||
| clients_top | | | | | | | | | | | | | | | `u32` |
|
||||
| updates | | | | | | | | | | | | | | | `u32` |
|
||||
| pop_v | | | | | | | | | | | | | | | `f32` |
|
||||
| geo_continent | | | | | | | | | | | | | | | `Option` |
|
||||
| ping | | | | | | | | | | | | | | | `f32` |
|
||||
10
SERVICES.md
Normal file
10
SERVICES.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Supported services:
|
||||
|
||||
| Name | Documentation reference |
|
||||
|------------------------|-------------------------------------------------------------------------------------------------------|
|
||||
| Valve Master Server | [Master Server Query Protocol](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) |
|
||||
| MineTest Master Server | [Node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/minetest.js) |
|
||||
|
||||
## Planned to add support:
|
||||
|
||||
TeamSpeak
|
||||
31
VERSIONS.md
Normal file
31
VERSIONS.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# MSRV (Minimum Supported Rust Version)
|
||||
|
||||
Current: `1.85.1`
|
||||
|
||||
Places to update:
|
||||
|
||||
- `Cargo.toml`
|
||||
- `README.md`
|
||||
- `crates/lib/README.md`
|
||||
- `.github/workflows/ci.yml`
|
||||
- `.pre-commit-config.yaml`
|
||||
|
||||
# rustfmt version
|
||||
|
||||
Current: `1.8.0`
|
||||
|
||||
Places to update:
|
||||
|
||||
- `.rustfmt.toml`
|
||||
- The nightly rust version
|
||||
|
||||
# The nightly rust version
|
||||
|
||||
The toolchain version used to run rustfmt in CI
|
||||
|
||||
Current: `nightly-2025-04-19`
|
||||
|
||||
Places to update:
|
||||
|
||||
- `.github/workflows/ci.yml`
|
||||
- `.pre-commit-config.yaml`
|
||||
10
crates/cli/.cargo/config.toml
Normal file
10
crates/cli/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[profile.release]
|
||||
opt-level = 'z'
|
||||
debug = false
|
||||
rpath = true
|
||||
lto = 'fat'
|
||||
codegen-units = 1
|
||||
strip = 'debuginfo'
|
||||
|
||||
[profile.release.package."*"]
|
||||
opt-level = 'z'
|
||||
76
crates/cli/CHANGELOG.md
Normal file
76
crates/cli/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
Who knows what the future holds...
|
||||
|
||||
# X.Y.Z - DD/MM/YYYY
|
||||
|
||||
Nothing... yet.
|
||||
|
||||
# 0.5.0 - 22/02/2026
|
||||
|
||||
### Breaking Changes:
|
||||
|
||||
- MSRV has been updated to `1.85.1` to match the latest `gamedig` version.
|
||||
|
||||
### Changes:
|
||||
|
||||
- Updated dependencies
|
||||
|
||||
# 0.4.0 - 24/08/2025
|
||||
|
||||
### Breaking Changes:
|
||||
|
||||
- MSRV has been updated to `1.82.0` to match the latest `gamedig` version.
|
||||
|
||||
### Changes:
|
||||
|
||||
- Some minor clippy fixes
|
||||
- Updated dependencies
|
||||
|
||||
# 0.3.0 - 23/04/2025
|
||||
|
||||
### Changes:
|
||||
|
||||
- CLI now uses `gamedig` v0.7.0 (To update, run `cargo install gamedig_cli`).
|
||||
|
||||
### Breaking Changes:
|
||||
|
||||
- MSRV has been updated to `1.81.0` to match the latest `gamedig` version.
|
||||
|
||||
# 0.2.1 - 05/12/2024
|
||||
|
||||
Dependencies:
|
||||
- `gamedig`: `v0.6.0 -> v0.6.1`
|
||||
|
||||
# 0.2.0 - 26/11/2024
|
||||
|
||||
### Breaking Changes:
|
||||
|
||||
- Restructured the release flow to be more consistent (GitHub releases will no longer be available, use cargo instead).
|
||||
- Changed crate name from `gamedig-cli` to `gamedig_cli` to align with recommended naming conventions.
|
||||
- The CLI now requires a minimum Rust version of `1.74.1`.
|
||||
|
||||
# 0.1.1 - 15/07/2024
|
||||
|
||||
### Changes:
|
||||
|
||||
- Dependency updates (by @cainthebest)
|
||||
- `gamedig`: `v0.5.0 -> v0.5.1`
|
||||
- `clap`: `v4.1.11 -> v4.5.4`
|
||||
- `quick-xml`: `v0.31.0 -> v0.36.0`
|
||||
- `webbrowser`: `v0.8.12 -> v1.0.0`
|
||||
|
||||
# 0.1.0 - 15/03/2024
|
||||
|
||||
### Changes:
|
||||
|
||||
- Added the CLI (by @cainthebest).
|
||||
- Added DNS lookup support (by @Douile).
|
||||
- Added JSON output option (by @Douile).
|
||||
- Added BSON output in hex or base64 (by @cainthebest).
|
||||
- Added XML output option (by @cainthebest).
|
||||
- Added ExtraRequestSettings as CLI arguments (by @Douile).
|
||||
- Added TimeoutSettings as CLI argument (by @Douile).
|
||||
- Added Comprehensive end-user documentation for the CLI interface (by @Douile & @cainthebest).
|
||||
- Tweaked compile-time flags to allow for a more preformant binary (by @cainthebest).
|
||||
- Added client for socket capture, dev tools are not included by default (by @Douile).
|
||||
- Added license information to the CLI (by @cainthebest).
|
||||
- Added source code information to the CLI (by @cainthebest).
|
||||
52
crates/cli/Cargo.toml
Normal file
52
crates/cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
[package]
|
||||
name = "gamedig_cli"
|
||||
authors = ["rust-GameDig contributors [https://github.com/gamedig/rust-gamedig/contributors]"]
|
||||
description = "A command line interface for gamedig"
|
||||
license = "MIT"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
default-run = "gamedig_cli"
|
||||
homepage = "https://gamedig.github.io/"
|
||||
repository = "https://github.com/gamedig/rust-gamedig"
|
||||
readme = "README.md"
|
||||
keywords = ["server", "query", "game", "check", "status"]
|
||||
rust-version = "1.85.1"
|
||||
categories = ["command-line-interface"]
|
||||
|
||||
[features]
|
||||
default = ["json", "bson", "xml", "browser"]
|
||||
|
||||
# Tools
|
||||
packet_capture = ["gamedig/packet_capture"]
|
||||
|
||||
# Output formats
|
||||
bson = ["dep:serde", "dep:bson", "dep:hex", "dep:base64", "gamedig/serde"]
|
||||
json = ["dep:serde", "dep:serde_json", "gamedig/serde"]
|
||||
xml = ["dep:serde", "dep:serde_json", "dep:quick-xml", "gamedig/serde"]
|
||||
|
||||
# Misc
|
||||
browser = ["dep:webbrowser"]
|
||||
|
||||
[dependencies]
|
||||
# Core Dependencies
|
||||
thiserror = "2.0.18"
|
||||
clap = { version = "4.5.60", default-features = false, features = ["derive"] }
|
||||
gamedig = { version = "0.9.0", default-features = false, features = ["clap", "games", "game_defs"] }
|
||||
|
||||
# Feature Dependencies
|
||||
# Serialization / Deserialization
|
||||
serde = { version = "1", optional = true, default-features = false }
|
||||
|
||||
# BSON
|
||||
bson = { version = "2.15", optional = true, default-features = false }
|
||||
base64 = { version = "0.22", optional = true, default-features = false, features = ["std"] }
|
||||
hex = { version = "0.4.3", optional = true, default-features = false }
|
||||
|
||||
# JSON
|
||||
serde_json = { version = "1", optional = true, default-features = false }
|
||||
|
||||
# XML
|
||||
quick-xml = { version = "0.39.2", optional = true, default-features = false }
|
||||
|
||||
# Browser
|
||||
webbrowser = { version = "1.1.0", optional = true, default-features = false }
|
||||
21
crates/cli/LICENSE.md
Normal file
21
crates/cli/LICENSE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 - 2026 GameDig Organization & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
67
crates/cli/README.md
Normal file
67
crates/cli/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Rust GameDig CLI
|
||||
|
||||
The official [rust-GameDig](https://crates.io/crates/gamedig) Command Line Interface.
|
||||
|
||||
[](https://github.com/gamedig/rust-gamedig/actions) [](https://github.com/gamedig/rust-gamedig/blob/main/LICENSE.md) [](https://github.com/gamedig/node-gamedig)
|
||||
|
||||
## Installation
|
||||
|
||||
You can install the CLI via `cargo`:
|
||||
|
||||
```sh
|
||||
cargo install gamedig_cli
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```sh
|
||||
cargo install gamedig_cli --git https://github.com/gamedig/rust-gamedig.git
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Running `gamedig_cli` without any arguments will display the usage information. You can also use the `--help` (or `-h`) flag to see detailed usage instructions.
|
||||
|
||||
Here's also a quick rundown of a simple query with the `json-pretty` format:
|
||||
|
||||
Pick a game/service/protocol (check
|
||||
the [GAMES](https://github.com/gamedig/rust-gamedig/blob/main/GAMES.md), [SERVICES](https://github.com/gamedig/rust-gamedig/blob/main/SERVICES.md)
|
||||
and [PROTOCOLS](https://github.com/gamedig/rust-gamedig/blob/main/PROTOCOLS.md) files to see the currently supported
|
||||
ones), provide the ip and the port (be aware that some game servers use a separate port for the info queries, the port
|
||||
can also be optional if the server is running the default ports) then query on it.
|
||||
|
||||
[Team Fortress 2](https://store.steampowered.com/app/440/Team_Fortress_2/) query example:
|
||||
|
||||
```sh
|
||||
gamedig_cli query -g teamfortress2 -i 127.0.0.1 -f json-pretty
|
||||
```
|
||||
|
||||
What we are doing here:
|
||||
|
||||
- `-g` (or `--game`) specifies the game.
|
||||
- `-i` (or `--ip`) target ip.
|
||||
- `-f` (or `--format`) our preferred format.
|
||||
|
||||
Note: We haven't specified a port (via `-p` or `--port`), so the default one for the game will be used (`27015` in this
|
||||
case).
|
||||
|
||||
Response (note that some games have a different structure):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "A cool server.",
|
||||
"description": null,
|
||||
"game_mode": "Team Fortress",
|
||||
"game_version": "8690085",
|
||||
"map": "cp_foundry",
|
||||
"players_maximum": 24,
|
||||
"players_online": 0,
|
||||
"players_bots": 0,
|
||||
"has_password": false,
|
||||
"players": []
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Please read [CONTRIBUTING](https://github.com/gamedig/rust-gamedig/blob/main/CONTRIBUTING.md).
|
||||
31
crates/cli/src/error.rs
Normal file
31
crates/cli/src/error.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("IO Error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Clap Error: {0}")]
|
||||
Clap(#[from] clap::Error),
|
||||
|
||||
#[error("Gamedig Error: {0}")]
|
||||
Gamedig(#[from] gamedig::errors::GDError),
|
||||
|
||||
#[cfg(any(feature = "json", feature = "xml"))]
|
||||
#[error("Serde Error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[cfg(feature = "bson")]
|
||||
#[error("Bson Error: {0}")]
|
||||
Bson(#[from] bson::ser::Error),
|
||||
|
||||
#[cfg(feature = "xml")]
|
||||
#[error("Xml Error: {0}")]
|
||||
Xml(#[from] quick_xml::Error),
|
||||
|
||||
#[error("Unknown Game: {0}")]
|
||||
UnknownGame(String),
|
||||
|
||||
#[error("Invalid hostname: {0}")]
|
||||
InvalidHostname(String),
|
||||
}
|
||||
487
crates/cli/src/main.rs
Normal file
487
crates/cli/src/main.rs
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
use std::net::{IpAddr, ToSocketAddrs};
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use gamedig::{
|
||||
games::*,
|
||||
protocols::types::{CommonResponse, ExtraRequestSettings, TimeoutSettings},
|
||||
};
|
||||
|
||||
mod error;
|
||||
|
||||
use self::error::{Error, Result};
|
||||
|
||||
const GAMEDIG_HEADER: &str = r"
|
||||
|
||||
_____ _____ _ _____ _ _____
|
||||
/ ____| | __ \(_) / ____| | |_ _|
|
||||
| | __ __ _ _ __ ___ ___| | | |_ __ _ | | | | | |
|
||||
| | |_ |/ _` | '_ ` _ \ / _ \ | | | |/ _` | | | | | | |
|
||||
| |__| | (_| | | | | | | __/ |__| | | (_| | | |____| |____ _| |_
|
||||
\_____|\__,_|_| |_| |_|\___|_____/|_|\__, | \_____|______|_____|
|
||||
__/ |
|
||||
|___/
|
||||
|
||||
A command line interface for querying game servers.
|
||||
Copyright (C) 2022 - 2024 GameDig Organization & Contributors
|
||||
Licensed under the MIT license
|
||||
";
|
||||
|
||||
// NOTE: For some reason without setting long_about here the doc comment for
|
||||
// ExtraRequestSettings gets set as the about for the CLI.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(author, version, about = GAMEDIG_HEADER, long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
action: Action,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Action {
|
||||
/// Query game server information
|
||||
Query {
|
||||
/// Unique identifier of the game for which server information is being
|
||||
/// queried.
|
||||
#[arg(short, long)]
|
||||
game: String,
|
||||
|
||||
/// Hostname or IP address of the server.
|
||||
#[arg(short, long)]
|
||||
ip: String,
|
||||
|
||||
/// Optional query port number for the server. If not provided the
|
||||
/// default port for the game is used.
|
||||
#[arg(short, long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// Specifies the output format
|
||||
#[arg(short, long, default_value = "debug", value_enum)]
|
||||
format: OutputFormat,
|
||||
|
||||
/// Which response variant to use when outputting
|
||||
#[arg(short, long, default_value = "generic")]
|
||||
output_mode: OutputMode,
|
||||
|
||||
/// Optional file path for packet capture file writer
|
||||
///
|
||||
/// When set a PCAP file will be written to the location. This file can
|
||||
/// be read with a tool like wireshark. The PCAP contains a log of the
|
||||
/// TCP and UDP data sent/recieved by the gamedig library, it does not
|
||||
/// contain an accurate representation of the real packets sent on the
|
||||
/// wire as some information has to be hallucinated in order for it to
|
||||
/// display nicely.
|
||||
#[cfg(feature = "packet_capture")]
|
||||
#[arg(short, long)]
|
||||
capture: Option<std::path::PathBuf>,
|
||||
|
||||
/// Optional timeout settings for the server query
|
||||
#[command(flatten, next_help_heading = "Timeouts")]
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
|
||||
/// Optional extra settings for the server query
|
||||
#[command(flatten, next_help_heading = "Query options")]
|
||||
extra_options: Option<ExtraRequestSettings>,
|
||||
},
|
||||
|
||||
/// Check out the source code
|
||||
Source,
|
||||
/// Display the MIT License information
|
||||
License,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
|
||||
enum OutputMode {
|
||||
/// A generalised response that maps common fields from all game types to
|
||||
/// the same name.
|
||||
Generic,
|
||||
/// The raw result returned from the protocol query, formatted similarly to
|
||||
/// how the server returned it.
|
||||
ProtocolSpecific,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
|
||||
enum OutputFormat {
|
||||
/// Human readable structured output
|
||||
Debug,
|
||||
/// RFC 8259
|
||||
#[cfg(feature = "json")]
|
||||
JsonPretty,
|
||||
/// RFC 8259
|
||||
#[cfg(feature = "json")]
|
||||
Json,
|
||||
/// Parser tries to be mostly XML 1.1 (RFC 7303) compliant
|
||||
#[cfg(feature = "xml")]
|
||||
Xml,
|
||||
/// RFC 4648 section 8
|
||||
#[cfg(feature = "bson")]
|
||||
BsonHex,
|
||||
/// RFC 4648 section 4
|
||||
#[cfg(feature = "bson")]
|
||||
BsonBase64,
|
||||
}
|
||||
|
||||
/// Attempt to find a game from the [library game definitions](GAMES) based on
|
||||
/// its unique identifier.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `game_id` - A string slice containing the unique game identifier.
|
||||
///
|
||||
/// # Returns
|
||||
/// * Result<&'static [Game]> - On sucess returns a reference to the game
|
||||
/// definition; on failure returns a [Error::UnknownGame] error.
|
||||
fn find_game(game_id: &str) -> Result<&'static Game> {
|
||||
// Attempt to retrieve the game from the predefined game list
|
||||
GAMES
|
||||
.get(game_id)
|
||||
.ok_or_else(|| Error::UnknownGame(game_id.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve an IP address by either parsing an IP address or doing a DNS lookup.
|
||||
/// In the case of DNS lookup update extra request options with the hostname.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `host` - A string slice containing the IP address or hostname of a server
|
||||
/// to resolve.
|
||||
/// * `extra_options` - Mutable reference to extra options for the game query.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<IpAddr>` - On sucess returns a resolved IP address; on failure
|
||||
/// returns an [Error::InvalidHostname] error.
|
||||
fn resolve_ip_or_domain<T: AsRef<str>>(host: T, extra_options: &mut Option<ExtraRequestSettings>) -> Result<IpAddr> {
|
||||
let host_str = host.as_ref();
|
||||
if let Ok(parsed_ip) = host_str.parse() {
|
||||
Ok(parsed_ip)
|
||||
} else {
|
||||
set_hostname_if_missing(host_str, extra_options);
|
||||
resolve_domain(host_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a domain name to one of its IP addresses (the first one returned).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `domain` - A string slice containing the domain name to lookup.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<IpAddr>` - On success, returns one of the resolved IP addresses;
|
||||
/// on failure returns an [Error::InvalidHostname] error.
|
||||
fn resolve_domain(domain: &str) -> Result<IpAddr> {
|
||||
// Append a dummy port to perform socket address resolution and then extract the
|
||||
// IP
|
||||
Ok(format!("{domain}:0")
|
||||
.to_socket_addrs()
|
||||
.map_err(|_| Error::InvalidHostname(domain.to_string()))?
|
||||
.next()
|
||||
.ok_or_else(|| Error::InvalidHostname(domain.to_string()))?
|
||||
.ip())
|
||||
}
|
||||
|
||||
/// Sets the hostname on extra request settings if it is not already set.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `host` - A string slice containing the hostname.
|
||||
/// * `extra_options` - A mutable reference to optional [ExtraRequestSettings].
|
||||
fn set_hostname_if_missing(host: &str, extra_options: &mut Option<ExtraRequestSettings>) {
|
||||
if let Some(extra_options) = extra_options {
|
||||
if extra_options.hostname.is_none() {
|
||||
// If extra_options exists but hostname is None overwrite hostname in place
|
||||
extra_options.hostname = Some(host.to_string())
|
||||
}
|
||||
} else {
|
||||
// If extra_options is None create default settings with hostname
|
||||
*extra_options = Some(ExtraRequestSettings::default().set_hostname(host.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Output the result of a query to stdout.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `args` - A reference to the command line options.
|
||||
/// * `result` - A reference to the result of the query.
|
||||
fn output_result<T: CommonResponse + ?Sized>(output_mode: OutputMode, format: OutputFormat, result: &T) {
|
||||
match format {
|
||||
OutputFormat::Debug => {
|
||||
match output_mode {
|
||||
OutputMode::Generic => output_result_debug(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_debug(result.as_original()),
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "json")]
|
||||
OutputFormat::JsonPretty => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_json_pretty(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_json_pretty(result.as_original()),
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "json")]
|
||||
OutputFormat::Json => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_json(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_json(result.as_original()),
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "xml")]
|
||||
OutputFormat::Xml => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_xml(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_xml(result.as_original()),
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "bson")]
|
||||
OutputFormat::BsonHex => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_bson_hex(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_bson_hex(result.as_original()),
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "bson")]
|
||||
OutputFormat::BsonBase64 => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_bson_base64(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_bson_base64(result.as_original()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output the result using debug formatting.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `result` - A result that can be output using the debug formatter.
|
||||
fn output_result_debug<R: std::fmt::Debug>(result: R) {
|
||||
println!("{result:#?}");
|
||||
}
|
||||
|
||||
/// Output the result as a JSON object.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `result` - A serde serializable result.
|
||||
#[cfg(feature = "json")]
|
||||
fn output_result_json<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
println!("{}", serde_json::to_string(&result)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Output the result as a pretty printed JSON object.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `result` - A serde serializable result.
|
||||
#[cfg(feature = "json")]
|
||||
fn output_result_json_pretty<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Output the result as an XML object.
|
||||
/// # Arguments
|
||||
/// * `result` - A serde serializable result.
|
||||
#[cfg(feature = "xml")]
|
||||
fn output_result_xml<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
use quick_xml::{
|
||||
events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
|
||||
Writer,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
// Serialize the input `result` of generic type `T` into a JSON value.
|
||||
// This step converts the Rust data structure into a JSON format,
|
||||
// which will then be used to generate the corresponding XML.
|
||||
let json = serde_json::to_value(result)?;
|
||||
|
||||
// Initialize the XML writer with a new, empty vector to store the XML data.
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
|
||||
// Write the XML 1.1 declaration
|
||||
writer.write_event(Event::Decl(BytesDecl::new("1.1", Some("utf-8"), None)))?;
|
||||
|
||||
// Define a recursive function `json_to_xml` to convert the JSON value into XML
|
||||
// format. The function takes a mutable reference to the XML writer, an
|
||||
// optional key as a string slice, and a reference to the JSON value to be
|
||||
// converted.
|
||||
fn json_to_xml<W: std::io::Write>(writer: &mut Writer<W>, key: Option<&str>, value: &Value) -> Result<()> {
|
||||
match value {
|
||||
// If the JSON value is an object, iterate through its properties,
|
||||
// creating XML elements with corresponding keys and values.
|
||||
Value::Object(obj) => {
|
||||
if let Some(key) = key {
|
||||
// Start an XML element for the object.
|
||||
writer.write_event(Event::Start(BytesStart::new(key)))?;
|
||||
}
|
||||
|
||||
for (k, v) in obj {
|
||||
// Recursively process each property of the object.
|
||||
json_to_xml(writer, Some(k), v)?;
|
||||
}
|
||||
|
||||
if let Some(key) = key {
|
||||
// Close the XML element for the object.
|
||||
writer.write_event(Event::End(BytesEnd::new(key)))?;
|
||||
}
|
||||
}
|
||||
|
||||
// If the JSON value is an array, iterate through its elements,
|
||||
// creating XML elements for each item.
|
||||
Value::Array(arr) => {
|
||||
for v in arr {
|
||||
// Use "item" as the default key for array elements without keys.
|
||||
json_to_xml(writer, key.or(Some("item")), v)?;
|
||||
}
|
||||
}
|
||||
|
||||
// If the JSON value is null, create an empty XML element.
|
||||
Value::Null => {
|
||||
if let Some(key) = key {
|
||||
writer.write_event(Event::Empty(BytesStart::new(key)))?;
|
||||
}
|
||||
}
|
||||
|
||||
// For all other JSON value types (String, Number, Bool),
|
||||
// convert the value to a string and create an XML element with the text content.
|
||||
// Note: We handle null strings here as well, as they are treated as a string type.
|
||||
_ => {
|
||||
if let Some(key) = key {
|
||||
// Start the XML element with the given key.
|
||||
writer.write_event(Event::Start(BytesStart::new(key)))?;
|
||||
}
|
||||
|
||||
// Convert the JSON value to a string, trimming quotes for non-string values.
|
||||
let text_string = match value {
|
||||
Value::String(s) => s.to_string(),
|
||||
_ => value.to_string().trim_matches('"').to_string(),
|
||||
};
|
||||
|
||||
// Create a text node with the converted string value.
|
||||
writer.write_event(Event::Text(BytesText::new(&text_string)))?;
|
||||
|
||||
if let Some(key) = key {
|
||||
// Close the XML element.
|
||||
writer.write_event(Event::End(BytesEnd::new(key)))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Start the root XML element named "data".
|
||||
writer.write_event(Event::Start(BytesStart::new("data")))?;
|
||||
// Convert the top-level JSON value to XML.
|
||||
json_to_xml(&mut writer, None, &json)?;
|
||||
// Close the root XML element.
|
||||
writer.write_event(Event::End(BytesEnd::new("data")))?;
|
||||
|
||||
// Convert the XML data stored in the writer to a UTF-8 string.
|
||||
let xml_bytes = writer.into_inner();
|
||||
let xml_string = String::from_utf8(xml_bytes).expect("Failed to convert XML bytes to UTF-8 string");
|
||||
|
||||
println!("{xml_string}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Output the result as a BSON object encoded as a hex string.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `result` - A serde serializable result.
|
||||
#[cfg(feature = "bson")]
|
||||
fn output_result_bson_hex<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
let bson = bson::to_bson(&result)?;
|
||||
|
||||
if let bson::Bson::Document(document) = bson {
|
||||
let bytes = bson::to_vec(&document)?;
|
||||
|
||||
println!("{}", hex::encode(bytes));
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
panic!("Failed to convert result to BSON Hex (BSON_DOCUMENT_UNAVAILABLE)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Output the result as a BSON object encoded as a base64 string.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `result` - A serde serializable result.
|
||||
#[cfg(feature = "bson")]
|
||||
fn output_result_bson_base64<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let bson = bson::to_bson(&result)?;
|
||||
|
||||
if let bson::Bson::Document(document) = bson {
|
||||
let bytes = bson::to_vec(&document)?;
|
||||
|
||||
println!("{}", base64::prelude::BASE64_STANDARD.encode(bytes));
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
panic!("Failed to convert result to BSON Base64 (BSON_DOCUMENT_UNAVAILABLE)");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Cli::parse();
|
||||
|
||||
match args.action {
|
||||
Action::Query {
|
||||
game,
|
||||
ip,
|
||||
port,
|
||||
format,
|
||||
output_mode,
|
||||
#[cfg(feature = "packet_capture")]
|
||||
capture,
|
||||
timeout_settings,
|
||||
extra_options,
|
||||
} => {
|
||||
// Process the query command
|
||||
let game = find_game(&game)?;
|
||||
let mut extra_options = extra_options;
|
||||
let ip = resolve_ip_or_domain(&ip, &mut extra_options)?;
|
||||
|
||||
#[cfg(feature = "packet_capture")]
|
||||
gamedig::capture::setup_capture(capture);
|
||||
|
||||
let result = query_with_timeout_and_extra_settings(game, &ip, port, timeout_settings, extra_options)?;
|
||||
output_result(output_mode, format, result.as_ref());
|
||||
}
|
||||
Action::Source => {
|
||||
println!("{GAMEDIG_HEADER}");
|
||||
|
||||
#[cfg(feature = "browser")]
|
||||
{
|
||||
// Directly offering to open the URL
|
||||
println!("\nWould you like to open the GitHub repository in your default browser? [Y/n]");
|
||||
|
||||
let mut choice = String::new();
|
||||
std::io::stdin().read_line(&mut choice).unwrap();
|
||||
if choice.trim().eq_ignore_ascii_case("Y") {
|
||||
if webbrowser::open("https://github.com/gamedig/rust-gamedig").is_ok() {
|
||||
println!("Opening GitHub repository in default browser...");
|
||||
} else {
|
||||
println!("Failed to open GitHub repository in default browser.");
|
||||
println!("Please use the following URL: https://github.com/gamedig/rust-gamedig");
|
||||
}
|
||||
} else {
|
||||
println!("Not to worry, you can always open the repository manually");
|
||||
println!("by visiting the following URL: https://github.com/gamedig/rust-gamedig");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "browser"))]
|
||||
{
|
||||
println!("\nYou can find the source code for this project at the following URL:");
|
||||
println!("https://github.com/gamedig/rust-gamedig");
|
||||
}
|
||||
|
||||
println!("\nBe sure to leave a star if you like the project :)");
|
||||
}
|
||||
Action::License => {
|
||||
// Bake the license into the binary
|
||||
// so we don't have to ship it separately
|
||||
println!("{}", include_str!("../LICENSE.md"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
29
crates/id-tests/Cargo.toml
Normal file
29
crates/id-tests/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "gamedig-id-tests"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
authors = [
|
||||
"rust-GameDig contributors [https://github.com/gamedig/rust-gamedig/contributors]",
|
||||
"node-GameDig contributors [https://github.com/gamedig/node-gamedig/contributors]",
|
||||
]
|
||||
license = "MIT"
|
||||
description = "Test if IDs match the gamedig rules"
|
||||
homepage = "https://github.com/gamedig/rust-gamedig/CONTRIBUTING.md#naming"
|
||||
repository = "https://github.com/gamedig/rust-gamedig"
|
||||
readme = "README.md"
|
||||
rust-version = "1.65.0"
|
||||
|
||||
[features]
|
||||
cli = ["dep:serde_json", "dep:serde"]
|
||||
default = ["cli"]
|
||||
|
||||
[[bin]]
|
||||
name = "gamedig-id-tests"
|
||||
required-features = ["cli"]
|
||||
|
||||
[dependencies]
|
||||
number_to_words = "0.1"
|
||||
roman_numeral = "0.1"
|
||||
|
||||
serde_json = { version = "1", optional = true }
|
||||
serde = { version = "1", optional = true, features = ["derive"] }
|
||||
451
crates/id-tests/src/lib.rs
Normal file
451
crates/id-tests/src/lib.rs
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
mod utils;
|
||||
use utils::{extract_bracketed_suffix, split_on_switch_between_alpha_numeric};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IDRule {
|
||||
IDsMustBeLowerCase,
|
||||
NumbersAreTheirOwnWord,
|
||||
IfFirstWordNumberNoDigits,
|
||||
IfLastWordNumberMustBeAppended,
|
||||
ConvertRomanNumeralsToArabic,
|
||||
TwoWordsOrLessUseFullWords,
|
||||
MoreThanTwoWordsMakeAcronym,
|
||||
IfIDDuplicateSameGameAppendYearToNewer,
|
||||
IfIDDuplicateSameGameAppendProtocol,
|
||||
IfIDDuplicateNoAcronym,
|
||||
IfModForQueriesProcessOnlyModName,
|
||||
NoDuplicates,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IDFail {
|
||||
pub game_id: String,
|
||||
pub game_name: String,
|
||||
pub expected_id: String,
|
||||
pub rule_stack: Vec<IDRule>,
|
||||
}
|
||||
|
||||
impl IDFail {
|
||||
fn new(game_id: String, game_name: String, expected_id: String, rule_stack: Vec<IDRule>) -> Self {
|
||||
Self {
|
||||
game_id,
|
||||
game_name,
|
||||
expected_id,
|
||||
rule_stack,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a single game against the rules
|
||||
pub fn test_game_name_rule(
|
||||
seen_ids: &mut HashMap<String, Vec<String>>,
|
||||
id: &str,
|
||||
mut game: GameNameParsed,
|
||||
is_mod_name: bool,
|
||||
) -> Vec<IDFail> {
|
||||
let mut wrong_ids = Vec::new();
|
||||
|
||||
let mut rule_stack = Vec::new();
|
||||
if is_mod_name {
|
||||
rule_stack.push(IDRule::IfModForQueriesProcessOnlyModName);
|
||||
}
|
||||
|
||||
let mut suffix = String::new();
|
||||
|
||||
// A game's identification is a lowercase alphanumeric string will and be forged
|
||||
// following these rules:
|
||||
if id.to_lowercase().ne(id) {
|
||||
wrong_ids.push(IDFail::new(
|
||||
id.to_owned(),
|
||||
game.name.to_owned(),
|
||||
id.to_lowercase(),
|
||||
vec![IDRule::IDsMustBeLowerCase],
|
||||
));
|
||||
}
|
||||
|
||||
// 5. Roman numbering will be converted to arabic numbering (XIV -> 14).
|
||||
game.words = {
|
||||
let mut is_first = true;
|
||||
game.words
|
||||
.into_iter()
|
||||
.map(|w| {
|
||||
// First word will never be a numeral
|
||||
if is_first {
|
||||
is_first = false;
|
||||
w
|
||||
} else if let Ok(number) = roman_numeral::RomanNumeral::from_string(&w) {
|
||||
rule_stack.push(IDRule::ConvertRomanNumeralsToArabic);
|
||||
number.get_u32().to_string()
|
||||
} else {
|
||||
w
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// 6. Unless numbers are at the end of a name, they will be considered words,
|
||||
// but digits will always be used instead of the acronym (counter to #2)
|
||||
// (Left 4 Dead -> l4d) unless they at the start position (7 Days to Die ->
|
||||
// sdtd), if they are at the end (such as sequel number or the year), always
|
||||
// append them (Team Fortress 2 -> teamfortress2, Unreal Tournament 2003 ->
|
||||
// unrealtournament2003).
|
||||
game.words = game
|
||||
.words
|
||||
.into_iter()
|
||||
.flat_map(|w| {
|
||||
let n = split_on_switch_between_alpha_numeric(&w);
|
||||
if n.len() > 1 {
|
||||
rule_stack.push(IDRule::NumbersAreTheirOwnWord);
|
||||
}
|
||||
n
|
||||
})
|
||||
.collect();
|
||||
|
||||
// If first word is number make text
|
||||
if !game.words.is_empty() && game.words[0].chars().next().unwrap().is_ascii_digit() {
|
||||
game.words[0] = number_to_words::number_to_words(game.words[0].parse::<f64>().unwrap(), false);
|
||||
rule_stack.push(IDRule::IfFirstWordNumberNoDigits);
|
||||
}
|
||||
|
||||
// If last word is number append full number
|
||||
if let Some(last_word) = game.words.last() {
|
||||
if last_word.chars().all(|c| c.is_ascii_digit()) {
|
||||
suffix += &game.words.pop().unwrap();
|
||||
rule_stack.push(IDRule::IfLastWordNumberMustBeAppended);
|
||||
}
|
||||
}
|
||||
|
||||
let main = if game.words.len() <= 2 {
|
||||
// 1. Names composed of a maximum of two words (unless #4 applies) will result
|
||||
// in an id where the words are concatenated (Dead Cells -> deadcells),
|
||||
// acronyms in the name count as a single word (S.T.A.L.K.E.R. -> stalker).
|
||||
|
||||
rule_stack.push(IDRule::TwoWordsOrLessUseFullWords);
|
||||
|
||||
game.words
|
||||
.iter()
|
||||
.map(|w| w.trim_matches('-').to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
} else {
|
||||
// 2. Names of more than two words shall be made into an acronym made of the
|
||||
// initial letters (The Binding of Isaac -> tboi), hypenation composed words
|
||||
// don't count as a single word, but of how many parts they are made of (Dino
|
||||
// D-Day, 3 words, so ddd).
|
||||
|
||||
rule_stack.push(IDRule::MoreThanTwoWordsMakeAcronym);
|
||||
|
||||
game.words
|
||||
.iter()
|
||||
.map(|w| w.chars().next().unwrap())
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut expected_id = format!("{main}{suffix}").to_lowercase();
|
||||
|
||||
if let Some(other_game_name_words) = seen_ids.get(&expected_id) {
|
||||
let mut game_names_same = other_game_name_words.len() == game.words.len();
|
||||
// Check all words in game name are the same
|
||||
if game_names_same {
|
||||
for (our_word, their_word) in game.words.iter().zip(other_game_name_words.iter()) {
|
||||
if our_word.to_lowercase() != their_word.to_lowercase() {
|
||||
game_names_same = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if game_names_same {
|
||||
if let Some(year) = game.year {
|
||||
// 3. If a game has the exact name as a previously existing id's game (Star Wars
|
||||
// Battlefront 2, the 2005 and 2017 one), append the release year to the
|
||||
// newer id (2005 would be swbf2 (suppose we already have this one supported)
|
||||
// and 2017 would be swbf22017).
|
||||
|
||||
rule_stack.push(IDRule::IfIDDuplicateSameGameAppendYearToNewer);
|
||||
expected_id = format!("{expected_id}{year}").to_lowercase();
|
||||
} else if let Some(protocol) = game.optional_parts.first() {
|
||||
// 7. If a game supports multiple protocols, multiple entries will be done for
|
||||
// said game where the edition/protocol name (first disposable in this order)
|
||||
// will be appended to the game name (Minecraft is divided by 2 editions,
|
||||
// Java and Bedrock which will be minecraftjava and minecraftbedrock
|
||||
// respectively) and one more entry can be added by the base name of the game
|
||||
// which queries in a group said supported protocols to make generic queries
|
||||
// easier and disposable.
|
||||
|
||||
rule_stack.push(IDRule::IfIDDuplicateSameGameAppendProtocol);
|
||||
|
||||
// Parse the protocol as a game name so we can remove all non-valid characters
|
||||
let protocol_parsed = extract_game_parts_from_name(protocol);
|
||||
|
||||
expected_id = format!("{}{}", expected_id, protocol_parsed.words.concat(),);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If a new id (Day of Dragons -> dod) results in an id that already exists
|
||||
// (Day of Defeat -> dod), then the new name should ignore rule #2 (Day of
|
||||
// Dragons -> dayofdragons).
|
||||
if seen_ids.contains_key(&expected_id) {
|
||||
rule_stack.push(IDRule::IfIDDuplicateNoAcronym);
|
||||
|
||||
let main = game
|
||||
.words
|
||||
.iter()
|
||||
.map(|w| w.trim_matches('-').to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
expected_id = format!("{main}{suffix}").to_lowercase();
|
||||
}
|
||||
|
||||
// 8. If its actually about a mod that adds the ability for queries to be
|
||||
// performed, process only the mod name.
|
||||
if !is_mod_name && id != expected_id {
|
||||
if let Some((_, mod_game)) = game.name.split_once('-') {
|
||||
let mut result = test_game_name_rule(seen_ids, id, extract_game_parts_from_name(mod_game), true);
|
||||
|
||||
if result.is_empty() {
|
||||
return result;
|
||||
} else {
|
||||
wrong_ids.append(&mut result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duplicate = if seen_ids.insert(expected_id.clone(), game.words).is_some() {
|
||||
rule_stack.push(IDRule::NoDuplicates);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Check ID matches
|
||||
if id != expected_id || duplicate {
|
||||
wrong_ids.push(IDFail::new(
|
||||
id.to_owned(),
|
||||
game.name.to_owned(),
|
||||
expected_id,
|
||||
rule_stack,
|
||||
));
|
||||
}
|
||||
|
||||
wrong_ids
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GameNameParsed<'a> {
|
||||
name: &'a str,
|
||||
words: Vec<String>,
|
||||
optional_parts: Vec<&'a str>,
|
||||
year: Option<u16>,
|
||||
}
|
||||
|
||||
pub fn extract_game_parts_from_name(game: &str) -> GameNameParsed<'_> {
|
||||
// Separate game name into words
|
||||
// NOTE: we have to leave "-" in to prevent hyphenated prefixes being parsed as
|
||||
// numerals
|
||||
let mut optional_game_name_parts = Vec::new();
|
||||
|
||||
let (game, paren) = extract_bracketed_suffix(game);
|
||||
|
||||
if let Some(paren) = paren {
|
||||
optional_game_name_parts.push(paren);
|
||||
}
|
||||
|
||||
let mut number_accumulator: Option<String> = None;
|
||||
|
||||
// Filter map necessary to move out words
|
||||
#[allow(clippy::unnecessary_filter_map)]
|
||||
let game_name_words: Vec<_> = game
|
||||
// First split all text on space or dash
|
||||
.split_inclusive(&[' ', '-'])
|
||||
// Remove whitespace surrounding words (leave in dash because it is important information)
|
||||
.map(str::trim)
|
||||
// If a word is entirely surrounded in brackets move it to optional parts
|
||||
.filter_map(|w| {
|
||||
if w.starts_with('(') && w.ends_with(')') {
|
||||
optional_game_name_parts.push(w);
|
||||
None
|
||||
} else {
|
||||
Some(w)
|
||||
}
|
||||
})
|
||||
// Remove all characters that aren't alphanumeric or dashses
|
||||
.map(|w| {
|
||||
w.replace(
|
||||
|c: char| !c.is_ascii_digit() && !c.is_alphabetic() && c != '-',
|
||||
"",
|
||||
)
|
||||
})
|
||||
// Remove words that are empty (discounting strings that are just dashes)
|
||||
.filter(|w| !w.trim_matches('-').is_empty())
|
||||
// Combine numbers that are seperated by dashes
|
||||
// e.g. 44-45 = 4445
|
||||
// Panics if there is text after number with trailing dash (44-text)
|
||||
.filter_map(|w| {
|
||||
if number_accumulator.is_some() {
|
||||
if let Some(maybe_number) = w.strip_suffix('-') {
|
||||
if maybe_number.chars().all(|c| c.is_ascii_digit()) {
|
||||
number_accumulator.as_mut().unwrap().push_str(maybe_number);
|
||||
return None;
|
||||
} else {
|
||||
panic!("Text after number-");
|
||||
}
|
||||
} else if w.chars().all(|c| c.is_ascii_digit()) {
|
||||
let mut accumulator = number_accumulator.as_ref().unwrap().clone();
|
||||
number_accumulator = None;
|
||||
accumulator.push_str(&w);
|
||||
return Some(accumulator);
|
||||
} else {
|
||||
panic!("Text after number-");
|
||||
}
|
||||
} else if let Some(maybe_number) = w.strip_suffix('-') {
|
||||
if maybe_number.chars().all(|c| c.is_ascii_digit()) {
|
||||
number_accumulator = Some(maybe_number.to_string());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(w)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut game_year: Option<u16> = None;
|
||||
for optional_part in &optional_game_name_parts {
|
||||
if let Some(game_year_text) = optional_part
|
||||
.strip_prefix('(')
|
||||
.and_then(|s| s.strip_suffix(')'))
|
||||
{
|
||||
if let Ok(year) = game_year_text.parse() {
|
||||
game_year = Some(year);
|
||||
break;
|
||||
}
|
||||
} else if let Ok(year) = optional_part.parse() {
|
||||
game_year = Some(year);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
GameNameParsed {
|
||||
name: game,
|
||||
words: game_name_words,
|
||||
optional_parts: optional_game_name_parts,
|
||||
year: game_year,
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate game entries and validate the id matches current rules
|
||||
pub fn test_game_name_rules<'a, I: Iterator<Item = (&'a str, &'a str)>>(games: I) -> Vec<IDFail> {
|
||||
let mut wrong_ids = Vec::with_capacity(games.size_hint().0);
|
||||
|
||||
let mut seen_ids: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
// We must sort games by year so that rule 3 is applied correctly
|
||||
let mut sorted_games: Vec<_> = games
|
||||
.map(|(id, game)| {
|
||||
let game = extract_game_parts_from_name(game);
|
||||
|
||||
(id, game)
|
||||
})
|
||||
.collect();
|
||||
|
||||
sorted_games.sort_by(|(_, a_game), (_, b_game)| {
|
||||
a_game
|
||||
.year
|
||||
.cmp(&b_game.year)
|
||||
.then(a_game.name.len().cmp(&b_game.name.len()))
|
||||
});
|
||||
|
||||
let game_count = sorted_games.len();
|
||||
|
||||
for (id, game) in sorted_games {
|
||||
wrong_ids.append(&mut test_game_name_rule(&mut seen_ids, id, game, false))
|
||||
}
|
||||
|
||||
if !wrong_ids.is_empty() {
|
||||
for fail in &wrong_ids {
|
||||
println!("{fail:#?}");
|
||||
}
|
||||
let percentage = (wrong_ids.len() * 100) / game_count;
|
||||
println!(
|
||||
"{} ({}%) IDs didn't match naming rules",
|
||||
wrong_ids.len(),
|
||||
percentage
|
||||
);
|
||||
}
|
||||
|
||||
wrong_ids
|
||||
}
|
||||
|
||||
pub fn test_single_game_rule(id: &str, name: &str) -> Vec<IDFail> { test_game_name_rules(std::iter::once((id, name))) }
|
||||
|
||||
#[cfg(test)]
|
||||
mod id_tests {
|
||||
use super::{test_game_name_rules, test_single_game_rule};
|
||||
#[test]
|
||||
fn id_rule_one() {
|
||||
assert!(test_single_game_rule("testgame", "Test Game").is_empty());
|
||||
assert!(test_single_game_rule("testgame", "TestGame").is_empty());
|
||||
|
||||
assert!(test_single_game_rule("deadcells", "Dead Cells").is_empty());
|
||||
assert!(test_single_game_rule("stalker", "S.T.A.L.K.E.R").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_rule_two() {
|
||||
assert!(test_single_game_rule("tgt", "Test Game Three").is_empty());
|
||||
assert!(test_single_game_rule("tgt", "Test Game-Three").is_empty());
|
||||
|
||||
assert!(test_single_game_rule("tboi", "The Binding of Isaac").is_empty());
|
||||
assert!(test_single_game_rule("ddd", "Dino D-Day").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_rule_three() {
|
||||
let games = vec![
|
||||
("swb22017", "Star Wars Battlefront 2 (2017)"),
|
||||
("swb2", "Star Wars Battlefront 2 (2015)"),
|
||||
];
|
||||
assert!(test_game_name_rules(games.into_iter()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_rule_four() {
|
||||
let games = vec![("dod", "Day of Defeat"), ("dayofdragons", "Day of Dragons")];
|
||||
assert!(test_game_name_rules(games.into_iter()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_rule_five() {
|
||||
assert!(test_single_game_rule("gta14", "Grand Theft Auto XIV").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_rule_six() {
|
||||
assert!(test_single_game_rule("l4d", "Left 4 Dead").is_empty());
|
||||
assert!(test_single_game_rule("sdtd", "7 Days to Die").is_empty());
|
||||
assert!(test_single_game_rule("teamfortress2", "Team Fortress 2").is_empty());
|
||||
assert!(test_single_game_rule("unrealtournament2003", "Unreal Tournament 2003").is_empty());
|
||||
assert!(test_single_game_rule("dhe4445", "Darkest Hour: Europe '44-'45").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_rule_seven() {
|
||||
let games = vec![
|
||||
("minecraft", "Minecraft"),
|
||||
("minecraftjava", "Minecraft (java)"),
|
||||
("minecraftbedrock", "Minecraft (bedrock)"),
|
||||
];
|
||||
assert!(test_game_name_rules(games.into_iter()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_rule_eight() {
|
||||
assert!(test_single_game_rule("fivem", "Grand Theft Auto V - FiveM (2013)").is_empty());
|
||||
assert!(test_single_game_rule("jc3m", "Just Cause 3 - Multiplayer").is_empty());
|
||||
}
|
||||
}
|
||||
32
crates/id-tests/src/main.rs
Normal file
32
crates/id-tests/src/main.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#![cfg(feature = "cli")]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Format for input games (the same as used in node-gamedig/lib/games.js).
|
||||
type GamesInput = HashMap<String, Game>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
|
||||
struct Game {
|
||||
name: String,
|
||||
}
|
||||
|
||||
use gamedig_id_tests::test_game_name_rules;
|
||||
|
||||
fn main() {
|
||||
let games: GamesInput = std::env::args_os().nth(1).map_or_else(
|
||||
|| serde_json::from_reader(std::io::stdin().lock()).unwrap(),
|
||||
|file| {
|
||||
let file = std::fs::OpenOptions::new().read(true).open(file).unwrap();
|
||||
|
||||
serde_json::from_reader(file).unwrap()
|
||||
},
|
||||
);
|
||||
|
||||
let failed = test_game_name_rules(
|
||||
games
|
||||
.iter()
|
||||
.map(|(key, game)| (key.as_str(), game.name.as_str())),
|
||||
);
|
||||
|
||||
assert!(failed.is_empty());
|
||||
}
|
||||
66
crates/id-tests/src/utils.rs
Normal file
66
crates/id-tests/src/utils.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/// Split a str when characters swap between being digits and not digits.
|
||||
pub fn split_on_switch_between_alpha_numeric(text: &str) -> Vec<String> {
|
||||
if text.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut parts = Vec::with_capacity(text.len());
|
||||
let mut current = Vec::with_capacity(text.len());
|
||||
|
||||
let mut iter = text.chars();
|
||||
let c = iter.next().unwrap();
|
||||
let mut last_was_numeric = c.is_ascii_digit();
|
||||
current.push(c);
|
||||
|
||||
for c in iter {
|
||||
if c.is_ascii_digit() == last_was_numeric {
|
||||
current.push(c);
|
||||
} else {
|
||||
parts.push(current.iter().collect());
|
||||
current.clear();
|
||||
current.push(c);
|
||||
last_was_numeric = !last_was_numeric;
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(current.into_iter().collect());
|
||||
|
||||
parts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_correctly() {
|
||||
assert_eq!(
|
||||
split_on_switch_between_alpha_numeric("2D45A"),
|
||||
&["2", "D", "45", "A"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_symbol_broken_numbers() {
|
||||
let game_name = super::extract_game_parts_from_name("Darkest Hour: Europe '44-'45");
|
||||
assert_eq!(game_name.words, &["Darkest", "Hour", "Europe", "4445"]);
|
||||
}
|
||||
|
||||
/// Extract parts at end of string enclosed in brackets.
|
||||
pub fn extract_bracketed_suffix(text: &str) -> (&str, Option<&str>) {
|
||||
if let Some(text) = text.strip_suffix(')') {
|
||||
if let Some((text, extra)) = text.rsplit_once('(') {
|
||||
return (text, Some(extra));
|
||||
}
|
||||
}
|
||||
|
||||
(text, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_brackets_correctly() {
|
||||
assert_eq!(
|
||||
extract_bracketed_suffix("no brackets here"),
|
||||
("no brackets here", None)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_bracketed_suffix("Game name (with protocol here)"),
|
||||
("Game name ", Some("with protocol here"))
|
||||
);
|
||||
}
|
||||
674
crates/lib/CHANGELOG.md
Normal file
674
crates/lib/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
Who knows what the future holds...
|
||||
|
||||
# X.Y.Z - DD/MM/YYYY
|
||||
|
||||
# 0.9.0 22/02/2026
|
||||
|
||||
Breaking:
|
||||
|
||||
- MSRV is now `1.85.1` (was `1.82.0`), this is due to deps we rely on requiring a higher version
|
||||
|
||||
Games:
|
||||
|
||||
- Fixed minecraft java server side EncoderException error on query (by @paul-hansen).
|
||||
|
||||
Crate:
|
||||
|
||||
- Some minor clippy fixes
|
||||
- Updated some dependencies
|
||||
|
||||
# 0.8.0 24/08/2025
|
||||
|
||||
Breaking:
|
||||
|
||||
- MSRV is now `1.82.0` (was `1.81.0`), this is due to deps we rely on requiring a higher version
|
||||
|
||||
Crate:
|
||||
|
||||
- Some minor clippy fixes
|
||||
- Updated dependencies
|
||||
|
||||
# 0.7.0 - 23/04/2025
|
||||
|
||||
Breaking:
|
||||
|
||||
- MSRV is now `1.81.0` (was `1.71.1`), this is due to deps we rely on requiring a higher version
|
||||
|
||||
Games:
|
||||
|
||||
- Added `Arma 3` support (by @Perondas).
|
||||
|
||||
Crate:
|
||||
|
||||
- Some minor clippy fixes
|
||||
|
||||
# 0.6.1 - 05/12/2024
|
||||
|
||||
Games:
|
||||
|
||||
- Added `Starbound` support (by @Novaenia).
|
||||
|
||||
Protocols:
|
||||
|
||||
- Fixed enum cast error on valve when parsing uppercase envrionment and server type fields (by @Novaenia).
|
||||
|
||||
# 0.6.0 - 26/11/2024
|
||||
|
||||
Breaking:
|
||||
|
||||
- MSRV is now `1.71.1` (was `1.65.0`), this is due to deps we rely on requiring a higher version on linux builds (`1.65.0` is 2+ years old).
|
||||
|
||||
Games:
|
||||
|
||||
- [Minetest](https://www.minetest.net/) support (available on the `tls`, `serde` and `services` features) (#218 by
|
||||
@CosminPerRam).
|
||||
- Fixed the forest game failing when host has the client steam id (#232 by @paul-hansen).
|
||||
|
||||
# 0.5.2 - 20/10/2024
|
||||
|
||||
Games:
|
||||
|
||||
- [Soulmask](https://store.steampowered.com/app/2646460/Soulmask/) support (by @CosminPerRam).
|
||||
|
||||
Protocols:
|
||||
|
||||
- Fixed Epic (EOS) protocol to match ports on query (by @cainthebest).
|
||||
|
||||
Services:
|
||||
|
||||
- MineTest Master Server support (available only on the `tls` and `serde` feature) (by @CosminPerRam).
|
||||
|
||||
Crate:
|
||||
|
||||
- Performance improvements from clippy suggestions (by @CosminPerRam).
|
||||
- Feature gate some variables so that they are not unused (by @cainthebest).
|
||||
- Fixed a OOB panic that could occur when reading strings from the buffer (by @cainthebest).
|
||||
- Updated `pnet_packet` from `0.34.0` to `0.35.0`.
|
||||
|
||||
# 0.5.1 - 12/05/2024
|
||||
|
||||
Games:
|
||||
|
||||
- [Mordhau](https://store.steampowered.com/app/629760/MORDHAU/) support.
|
||||
- [Enshrouded](https://store.steampowered.com/app/1203620/Enshrouded/) support.
|
||||
- [Myth of Empires](https://store.steampowered.com/app/1371580/Myth_of_Empires/) support.
|
||||
- [Pirates, Vikings, and Knights II](https://store.steampowered.com/app/17570/Pirates_Vikings_and_Knights_II/) support.
|
||||
- [PixARK](https://store.steampowered.com/app/593600/PixARK/) support.
|
||||
- [Ark: Survival Ascended](https://store.steampowered.com/app/2399830/ARK_Survival_Ascended/) support, note: not yet in
|
||||
the games definitions.
|
||||
- [Aliens vs. Predator 2010](https://store.steampowered.com/app/10680/Aliens_vs_Predator/) support.
|
||||
- [Arma Reforger](https://store.steampowered.com/app/1874880/Arma_Reforger/) support.
|
||||
- [Nova-Life: Amboise](https://store.steampowered.com/app/885570/NovaLife_Amboise/) support.
|
||||
- [Abiotic Factor](https://store.steampowered.com/app/427410/Abiotic_Factor/) support.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Epic (EOS) support, available only on the `tls` feature.
|
||||
|
||||
Crate:
|
||||
|
||||
- Updated some dependencies: `crc32fast` to `1.4.0`, `clap` to `4.5.4` and `ureq` to `ureq`.
|
||||
|
||||
# 0.5.0 - 15/03/2024
|
||||
|
||||
### Changes:
|
||||
|
||||
Games:
|
||||
|
||||
- [Valheim](https://store.steampowered.com/app/892970/Valheim/) support.
|
||||
- [The Front](https://store.steampowered.com/app/2285150/The_Front/) support.
|
||||
- [Conan Exiles](https://store.steampowered.com/app/440900/Conan_Exiles/) support.
|
||||
- [Post Scriptum](https://store.steampowered.com/app/736220/Post_Scriptum/) support.
|
||||
- [Squad](https://store.steampowered.com/app/393380/Squad/) support.
|
||||
- [Savage 2](https://savage2.net/) support.
|
||||
- [Rising World](https://store.steampowered.com/app/324080/Rising_World/) support.
|
||||
- [ATLAS](https://store.steampowered.com/app/834910/ATLAS/) support.
|
||||
- [America's Army: Proving Grounds](https://store.steampowered.com/app/203290/Americas_Army_Proving_Grounds/) support.
|
||||
- [Base Defense](https://store.steampowered.com/app/632730/Base_Defense/) support.
|
||||
- [Zombie Panic: Source](https://store.steampowered.com/app/17500/Zombie_Panic_Source/) support.
|
||||
- Added a valve protocol query example.
|
||||
- Made all of Just Cause 2: Multiplayer Response and Player fields public.
|
||||
- [Mindustry](https://mindustrygame.github.io/) support.
|
||||
- [Eco](https://store.steampowered.com/app/382310/Eco/) support.
|
||||
- [Call Of Duty: Black Ops 3](https://store.steampowered.com/agecheck/app/311210/) support.
|
||||
- [Counter-Strike 2](https://store.steampowered.com/app/730/CounterStrike_2/) support.
|
||||
- [Double Action: Boogaloo](https://store.steampowered.com/app/317360/Double_Action_Boogaloo/) support.
|
||||
|
||||
Crate:
|
||||
|
||||
- Changed the serde feature to only enable serde derive for some types: serde and serde_json is now a dependecy by
|
||||
default.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Added the unreal2 protocol and its associated games: Darkest Hour, Devastation, Killing Floor, Red Orchestra, Unreal
|
||||
Tournament 2003, Unreal Tournament 2004 (by @Douile).
|
||||
- Added HTTPClient to allow use of HTTP(S) (and JSON) APIs (by @CosminPerRam & @Douile).
|
||||
|
||||
Crate:
|
||||
|
||||
- Added a `packet_capture` feature to capture the raw packets sent and received by the socket (by @Douile).
|
||||
- Added packet emulation and socket retrevial using the `packet_capture` feature (by @Douile).
|
||||
- Added PCAP writing support to the `packet_capture` feature (by @Douile & @cainthebest).
|
||||
- Refactored socket to use a custom implementation of socket for packet capture when using the `packet_capture`
|
||||
feature (by @Douile).
|
||||
|
||||
CLI:
|
||||
|
||||
- Added a CLI (by @cainthebest).
|
||||
- Added DNS lookup support (by @Douile).
|
||||
- Added JSON output option (by @Douile).
|
||||
- Added BSON output in hex or base64 (by @cainthebest).
|
||||
- Added XML output option (by @cainthebest).
|
||||
- Added ExtraRequestSettings as CLI arguments (by @Douile).
|
||||
- Added TimeoutSettings as CLI argument (by @Douile).
|
||||
- Added Comprehensive end-user documentation for the CLI interface (by @Douile & @cainthebest).
|
||||
- Tweaked compile-time flags to allow for a more preformant binary (by @cainthebest).
|
||||
- Added client for socket capture, dev tools are not included by default (by @Douile).
|
||||
- Added license information to the CLI (by @cainthebest).
|
||||
- Added source code information to the CLI (by @cainthebest).
|
||||
|
||||
### Breaking:
|
||||
|
||||
Game:
|
||||
|
||||
- Changed identifications of the following games as they weren't properly expecting the naming rules:
|
||||
-
|
||||
- Left 4 Dead: `left4dead` -> `l4d`.
|
||||
-
|
||||
- 7 Days to Die: `7d2d` in definitions and `sd2d` in game declaration -> `sdtd`.
|
||||
-
|
||||
- Quake 3 Arena: `quake3arena` -> `q3a`.
|
||||
-
|
||||
- Unreal tournament 2003: `ut2003` -> `unrealtournament2003`
|
||||
-
|
||||
- Unreal tournament 2004: `ut2004` -> `unrealtournament2004`
|
||||
-
|
||||
- Darkest Hour: Europe '44-'45: `darkesthour` -> `dhe4445`
|
||||
- Minecraft:
|
||||
-
|
||||
- Legacy 1.5 and 1.3 were renamed to 1.4 and beta 1.8 respectively to show the lowest version they support, this
|
||||
change includes Structs, Enum and game id renames, also removed the "v" from the game definition name.
|
||||
-
|
||||
- Moved the Minecraft protocol implementation in the games folder as its proprietary.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve: Removed `SteamApp` due to it not being really useful at all, replaced all instances with `Engine`.
|
||||
|
||||
Query:
|
||||
|
||||
- Added a connection timeout to TimeoutSettings (at the moment this only applies to TCP)
|
||||
- Sockets are now expected to apply timeout settings in new()
|
||||
|
||||
# 0.4.1 - 13/10/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
Game:
|
||||
|
||||
- Added [Barotrauma](https://store.steampowered.com/app/602960/Barotrauma/) support.
|
||||
|
||||
Crate:
|
||||
|
||||
- Added `Send` and `Sync` on `Error::source` to fix some async issues.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Minecraft Java: Add derives to `RequestSettings` and add `new_just_hostname` that creates new settings just by
|
||||
specifying
|
||||
the hostname, `protocol_version` defaults to -1.
|
||||
|
||||
Games:
|
||||
|
||||
- Organised game modules into protocols (when protocol used by other games),
|
||||
you can now access a game by its name or by its protocol name:
|
||||
- `use gamedig::games::teamfortress2;`
|
||||
- `use gamedig::games::valve::teamfortress2;`
|
||||
|
||||
Generics:
|
||||
|
||||
- Added standard derives to `ProprietaryProtocol`, `CommonResponseJson`, `CommonPlayerJson`, `TimeoutSettings` and
|
||||
`ExtraRequestSettings`.
|
||||
|
||||
### Breaking...
|
||||
|
||||
None, yaay!
|
||||
|
||||
# 0.4.0 - 07/10/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
Games:
|
||||
|
||||
- [Creativerse](https://store.steampowered.com/app/280790/Creativerse/) support.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Quake 2: Fixed a bug where the version tag wouldn't always be present.
|
||||
- The Ship: Removed instances of using `unwrap` without handling the panics.
|
||||
|
||||
Crate:
|
||||
|
||||
- Updated [byteorder](https://crates.io/crates/byteorder) dependency from 1.4 to 1.5.
|
||||
- Rich errors, capturing backtrace is done on `RUST_BACKTRACE=1`. (by @Douile)
|
||||
- Applied some nursery Clippy lints.
|
||||
- The `retries` field was added to `TimeoutSettings` that specifies the number of times to retry a failed request (
|
||||
request being individual send, receive sequence, some protocols can include multiple requests in a single query). (by
|
||||
@Douile)
|
||||
- By default `retries` is set to `0`, meaning no retries will be attempted
|
||||
|
||||
Generics:
|
||||
|
||||
- Added `ExtraRequestSettings` containing all possible extra request settings. (by @Douile)
|
||||
- Added `query_with_timeout_and_extra_settings()` to allow generic queries with extra settings. (by @Douile)
|
||||
|
||||
### Breaking...
|
||||
|
||||
Crate:
|
||||
|
||||
- The enum used for errors, `GDError` has been renamed to `GDErrorKind`.
|
||||
- `GDError` is now a struct that holds its kind, the source and a backtrace.
|
||||
- The `Socket::apply_timeout` method now borrows `TimeoutSettings` (`&Option<TimeoutSettings>`)
|
||||
- To make this easier to work with a new method was added
|
||||
to `TimeoutSettings`: `TimeoutSettings::get_read_and_write_or_defaults` this takes a borrowed
|
||||
optional `TimeoutSettings` and returns the contained read and write durations or the default read and write
|
||||
durations.
|
||||
|
||||
Generics:
|
||||
|
||||
- Renamed `CommonResponseJson`'s `game` field (and the function) to `game_mode`.
|
||||
- Changed `players_maximum` and `players_online` (and their functions) types from `u64` to `u32`.
|
||||
- Changed `score` type (and the function) of player from `u32` to `i32`.
|
||||
|
||||
Games:
|
||||
|
||||
- Rename some game definitions and implementations to follow a stable ID naming system.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve:
|
||||
|
||||
1. Renamed `protocol` to `protocol_version`.
|
||||
2. Renamed `version` to `game_version`.
|
||||
3. Renamed `game` to `game_mode`.
|
||||
4. Fixed `player`'s `score` field being `u32` when it needed to be `i32`, as specified in the protocol.
|
||||
5. Added the field `check_app_id` to `GatherSettings` which controls if the app id specified to the request and
|
||||
reported by the server are the same, errors if not, enabled by default. (by @Douile)
|
||||
6. Valve: Renamed SteamApp enum variants to match new definition names
|
||||
|
||||
- GameSpy (1, 2, 3):
|
||||
|
||||
1. Renamed `version` to `game_version`.
|
||||
2. Changed `players_maximum` and `players_online` (and their functions) types from `usize` to `u32`.
|
||||
|
||||
- GameSpy 1:
|
||||
|
||||
1. Renamed the player's `frags` to `score` and type from `u32` to `i32`.
|
||||
2. Made `Option` the following response fields `team`, `face`, `skin`, `mesh` and `secret` to fix missing fields
|
||||
issues. (by @Douile)
|
||||
|
||||
- Quake (1, 2):
|
||||
|
||||
1. Renamed `game_type` to `game_mode`.
|
||||
2. Changed `version` type from `String`to `Option<String>`.
|
||||
|
||||
- Minecraft Java
|
||||
|
||||
1. Renamed `version_protocol` to `protocol_version`.
|
||||
2. Renamed `version_name` to `game_version`.
|
||||
3. Renamed `players_sample` to `players`.
|
||||
4. Added an optional parameter, `RequestSettings`, which contains fields that are used when creating the handshake
|
||||
packet (this solves some servers not responding to the query). (by @Douile)
|
||||
5. Legacy versions naming has been changed to represent up to what version they can query, `LegacyBV1_8` (Beta 1.8 to
|
||||
1.3) -> `LegacyV1_3` and `LegacyV1_4` (1.4 to 1.5) -> `LegacyV1_5` (and their enums accordingly).
|
||||
|
||||
- Minecraft Bedrock
|
||||
|
||||
1. Renamed `version_protocol` to `protocol_version`.
|
||||
|
||||
- Minecraft:
|
||||
|
||||
1. Added an optional parameter, `request_settings` parameter to `query`.
|
||||
|
||||
- The Ship:
|
||||
|
||||
1. Renamed `protocol` to `protocol_version`.
|
||||
2. Renamed `max_players` to `players_maximum` and changed its type from `u64` to `u32`.
|
||||
3. Renamed `bots` to `players_bots`. and changed its type from `u64` to `u32`.
|
||||
4. Renamed `players` to `players_online`.
|
||||
5. Renamed `players_details` to `players`.
|
||||
6. Renamed `game` to `game_mode`.
|
||||
7. Added field `game_version`.
|
||||
8. Changed `players_bots` type from `Option<u64>` to `Option<u32>`.
|
||||
9. Changed `score` type of player from `u32` to `i32`.
|
||||
|
||||
- Frontlines: Fuel of War:
|
||||
|
||||
1. Renamed `game_mode` to `game`.
|
||||
2. Renamed `version` to `game_version`.
|
||||
3. Renamed `protocol` to `protocol_version`.
|
||||
4. Renamed `game` to `game_mode`.
|
||||
5. Changed `players_maximum` and `players_minimum` types from `usize` to `u32`.
|
||||
|
||||
- Just Cause 2: Multiplayer:
|
||||
|
||||
1. Renamed `version` to `game_version`.
|
||||
2. Changed `players_maximum` and `players_minimum` types from `usize` to `u32`.
|
||||
|
||||
# 0.3.0 - 18/07/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
Protocols:
|
||||
|
||||
- GameSpy 2 support.
|
||||
- Quake 2: Added Optional address field to Player.
|
||||
|
||||
Generic query:
|
||||
|
||||
- Added generic queries (by [@Douile](https://github.com/Douile)) which come with a common struct for the response
|
||||
fields.
|
||||
- The supported games list is available programmatically.
|
||||
|
||||
Games:
|
||||
|
||||
- [Halo: Combat Evolved](https://en.wikipedia.org/wiki/Halo:_Combat_Evolved) support.
|
||||
- [Just Cause 2: Multiplayer](https://store.steampowered.com/app/259080/Just_Cause_2_Multiplayer_Mod/) support.
|
||||
- [Warsow](https://warsow.net/) support.
|
||||
|
||||
Internal:
|
||||
|
||||
- Buffer reader rewrite, resulting in more data checks and better code quality (
|
||||
thanks [@cainthebest](https://github.com/cainthebest)).
|
||||
- Better CI to never break accidentally MSRV again (thanks [@Douile](https://github.com/Douile)).
|
||||
|
||||
### Breaking...
|
||||
|
||||
Protocols:
|
||||
|
||||
- Quake 2: Renamed the players `frags` field to `score` to be more inline with the other protocols.
|
||||
|
||||
Crate:
|
||||
|
||||
- `no_games` and `no_services` have been changed to `games` and `services`, this better represents that they are present
|
||||
by default (by [@Douile](https://github.com/Douile)).
|
||||
- Fixed crate's `rust-version`, it is now `1.60.0` (was `1.56.1`)
|
||||
|
||||
# 0.2.3 - 02/06/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve:
|
||||
|
||||
1. Added standard and serde derives to `GatheringSettings`.
|
||||
|
||||
- Quake 1, 2 and 3 support.
|
||||
|
||||
Games:
|
||||
|
||||
- [Quake 2](https://store.steampowered.com/app/2320/Quake_II/) support.
|
||||
- [Quake 1](https://store.steampowered.com/app/2310/Quake/) support.
|
||||
- [Quake 3: Arena](https://store.steampowered.com/app/2200/Quake_III_Arena/) support.
|
||||
- [Hell Let Loose](https://store.steampowered.com/app/686810/Hell_Let_Loose/) support.
|
||||
- [Soldier of Fortune 2](https://www.gog.com/en/game/soldier_of_fortune_ii_double_helix_gold_edition) support.
|
||||
|
||||
### Breaking:
|
||||
|
||||
- Every function that used `&str` for the address has been changed to `&IpAddr` (
|
||||
thanks [@Douile](https://github.com/Douile) for the re-re-write).
|
||||
- Protocols now use `&SocketAddr` instead of `address: &str, port: u16`.
|
||||
|
||||
Services:
|
||||
|
||||
- Valve Master Query:
|
||||
|
||||
1. Removed Filter and SearchFilters lifetimes and changed `&'a str` to `String` and `&'a [&'a str]` to `Vec<String>`
|
||||
|
||||
# 0.2.2 - 01/05/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
Crate:
|
||||
|
||||
- General optimizations thanks to [cargo clippy](https://github.com/rust-lang/rust-clippy)
|
||||
and [@cainthebest](https://github.com/cainthebest).
|
||||
- Added feature `serde` which enables json serialization/deserialization for all types (
|
||||
by [@cainthebest](https://github.com/cainthebest)).
|
||||
- Documentation improvements.
|
||||
|
||||
Protocols:
|
||||
|
||||
- GameSpy 1: Add key `admin` as a possible variable for `admin_name`.
|
||||
- GameSpy 3 support.
|
||||
|
||||
Games:
|
||||
|
||||
- [Serious Sam](https://www.gog.com/game/serious_sam_the_first_encounter) support.
|
||||
- [Frontlines: Fuel of War](https://store.steampowered.com/app/9460/Frontlines_Fuel_of_War/) support.
|
||||
- [Crysis Wars](https://steamcommunity.com/app/17340) support.
|
||||
|
||||
Services:
|
||||
|
||||
- [Valve Master Server Query](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) support.
|
||||
- Added feature `no_services` which disables the supported services.
|
||||
|
||||
### Breaking:
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve: Request type enums have been renamed from all caps to starting-only uppercase, ex: `INFO` to `Info`
|
||||
- GameSpy 1: `players_minimum` is now an `Option<u8>` instead of an `u8`
|
||||
- GameSpy 1: Is now under `protocols::gamespy::one` instead of `protocols::gamespy`
|
||||
|
||||
# 0.2.1 - 03/03/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
Crate:
|
||||
|
||||
- Added feature `no_games` which disables the supported games (useful when only the
|
||||
protocols/services are needed, also saves storage space).
|
||||
|
||||
Games:
|
||||
|
||||
- [V Rising](https://store.steampowered.com/app/1604030/V_Rising/) support.
|
||||
- [Unreal Tournament](https://en.wikipedia.org/wiki/Unreal_Tournament) support.
|
||||
- [Battlefield 1942](https://www.ea.com/games/battlefield/battlefield-1942) support.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve:
|
||||
|
||||
1. Reversed (from `0.1.0`) "Players with no name are no more added to the `players_details` field.", also added a note
|
||||
in the [protocols](PROTOCOLS.md) file regarding this.
|
||||
2. Fixed querying while multiple challenge responses might happen.
|
||||
|
||||
- GameSpy 1 support.
|
||||
|
||||
### Breaking:
|
||||
|
||||
None.
|
||||
|
||||
# 0.2.0 - 18/02/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
Games:
|
||||
|
||||
- [Don't Starve Together](https://store.steampowered.com/app/322330/Dont_Starve_Together/) support.
|
||||
- [Colony Survival](https://store.steampowered.com/app/366090/Colony_Survival/) support.
|
||||
- [Onset](https://store.steampowered.com/app/1105810/Onset/) support.
|
||||
- [Codename CURE](https://store.steampowered.com/app/355180/Codename_CURE/) support.
|
||||
- [Ballistic Overkill](https://store.steampowered.com/app/296300/Ballistic_Overkill/) support.
|
||||
- [BrainBread 2](https://store.steampowered.com/app/346330/BrainBread_2/) support.
|
||||
- [Avorion](https://store.steampowered.com/app/445220/Avorion/) support.
|
||||
- [Operation: Harsh Doorstop](https://store.steampowered.com/app/736590/Operation_Harsh_Doorstop/) support.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve:
|
||||
|
||||
1. `appid` is now a field in the `Response` struct.
|
||||
|
||||
### Breaking:
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve:
|
||||
due to some games being able to host a server from within the game AND from a dedicated server,
|
||||
if you were to query one of them, the query would fail for the other one, as the `SteamID` enum
|
||||
for that game could specify only one id.
|
||||
|
||||
1. `SteamID` is now `SteamApp`, was an u32 enum, and now it's a simple enum.
|
||||
2. `App` is now `Engine`, the `Source` enum's structure has been changed from `Option<u32>` to
|
||||
`Option<u32, Option<u32>>`, where the first parameter is the game app id and the second is
|
||||
the dedicated server app id (if there is one).
|
||||
|
||||
# 0.1.0 - 17/01/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
Games:
|
||||
|
||||
- [Risk of Rain 2](https://store.steampowered.com/app/632360/Risk_of_Rain_2/) support.
|
||||
- [Battalion 1944](https://store.steampowered.com/app/489940/BATTALION_Legacy/) support.
|
||||
- [Black Mesa](https://store.steampowered.com/app/362890/Black_Mesa/) support.
|
||||
- [Project Zomboid](https://store.steampowered.com/app/108600/Project_Zomboid/) support.
|
||||
- [Age of Chivalry](https://store.steampowered.com/app/17510/Age_of_Chivalry/) support.
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve: Players with no name are no more added to the `players_details` field.
|
||||
- Valve: Split packets are now appending in the correct order.
|
||||
|
||||
Crate:
|
||||
|
||||
- `MSRV` is now `1.56.1` (was `1.58.1`)
|
||||
|
||||
### Breaking:
|
||||
|
||||
Protocols:
|
||||
|
||||
- Valve: The rules field is now a `HashMap<String, String>` instead of a `Vec<ServerRule>` (where the `ServerRule`
|
||||
structure had a name and a value fields).
|
||||
- Valve: Structs that contained the `players`, `max_players` and `bots` fields have been renamed
|
||||
to `players_online`, `players_maximum` and `players_bots` respectively.
|
||||
- Minecraft: Structs that contained the `online_players`, `max_players` and `sample_players` fields have been renamed
|
||||
to `players_online`, `players_maximum` and `players_sample` respectively.
|
||||
- Minecraft: The Java query response struct named `Response` has been renamed to `JavaResponse`.
|
||||
|
||||
Errors:
|
||||
|
||||
- Besides the `BadGame` error, now no other errors returns details about what happened (as it was quite pointless).
|
||||
|
||||
Crate:
|
||||
|
||||
- `package.metadata.msrv` has been replaced with `package.rust-version`
|
||||
|
||||
# 0.0.7 - 03/01/2023
|
||||
|
||||
### Changes:
|
||||
|
||||
[Minecraft](https://www.minecraft.com) bedrock edition support.
|
||||
Fix Minecraft legacy v1.6 max/online players count being reversed.
|
||||
Added `query_legacy_specific` method to the Minecraft protocol.
|
||||
|
||||
### Breaking:
|
||||
|
||||
Removed `query_specific` from the mc protocol in favor of `query_java`, `query_legacy` and `query_legacy_specific`.
|
||||
Some public functions that are meant to be used only internally were made private.
|
||||
|
||||
# 0.0.6 - 28/11/2022
|
||||
|
||||
[Minecraft](https://www.minecraft.com) support (bedrock not supported yet).
|
||||
[7 Days To Die](https://store.steampowered.com/app/251570/7_Days_to_Die/) support.
|
||||
[ARK: Survival Evolved](https://store.steampowered.com/app/346110/ARK_Survival_Evolved/) support.
|
||||
[Unturned](https://store.steampowered.com/app/304930/Unturned/) support.
|
||||
[The Forest](https://store.steampowered.com/app/242760/The_Forest/) support.
|
||||
[Team Fortress Classic](https://store.steampowered.com/app/20/Team_Fortress_Classic/) support.
|
||||
[Sven Co-op](https://store.steampowered.com/app/225840/Sven_Coop/) support.
|
||||
[Rust](https://store.steampowered.com/app/252490/Rust/) support.
|
||||
[Counter-Strike](https://store.steampowered.com/app/10/CounterStrike/) support.
|
||||
[Arma 2: Operation Arrowhead](https://store.steampowered.com/app/33930/Arma_2_Operation_Arrowhead/) support.
|
||||
[Day of Infamy](https://store.steampowered.com/app/447820/Day_of_Infamy/) support.
|
||||
[Half-Life Deathmatch: Source](https://store.steampowered.com/app/360/HalfLife_Deathmatch_Source/) support.
|
||||
Successfully tested `Alien Swarm` and `Insurgency: Modern Infantry Combat`.
|
||||
Restored rules response for `Counter-Strike: Global Offensive` (note: for a full player list response, the
|
||||
cvar `host_players_show` must be set to `2`).
|
||||
Increased Valve Protocol `PACKET_SIZE` from 1400 to 6144 (because some games send larger packets than the specified
|
||||
protocol size).
|
||||
Removed DNS resolving as it was not needed.
|
||||
Valve Protocol minor optimizations.
|
||||
|
||||
# 0.0.5 - 15/11/2022
|
||||
|
||||
Added `SocketBind` error, regarding failing to bind a socket.
|
||||
Socket custom timeout capability (with an error if provided durations are zero).
|
||||
Because of this, a parameter similar to GatherSettings has been added on the Valve Protocol Query.
|
||||
Support for GoldSrc split packets and obsolete A2S_INFO response.
|
||||
Changed the Valve Protocol app parameter to represent the engine responses.
|
||||
It is now an enum of:
|
||||
|
||||
- `Source(Option<u32>)` - A Source response with optionally, the id (if the id is present and the response id is not the
|
||||
same, the query fails), if it isn't provided, find it.
|
||||
- `GoldSrc(bool)` - A GoldSrc response with the option to enforce the obsolete A2S_INFO response.
|
||||
|
||||
Fixed Source multi-packet response crash due to when a certain app with a certain protocol doesn't have the Size
|
||||
field.
|
||||
Reduced Valve Protocol `PACKET_SIZE` to be as specified from 2048 to 1400.
|
||||
[Counter-Strike: Condition Zero](https://store.steampowered.com/app/80/CounterStrike_Condition_Zero/) implementation.
|
||||
[Day of Defeat](https://store.steampowered.com/app/30/Day_of_Defeat/) implementation.
|
||||
Games besides CSGO and TS now have the same response structure.
|
||||
|
||||
# 0.0.4 - 23/10/2022
|
||||
|
||||
Queries now support DNS resolve.
|
||||
Changed uses a bit, example: from `use gamedig::valve::ValveProtocol::query`
|
||||
to `use gamedig::protocols::valve::query`.
|
||||
Changed Valve Protocol Query parameters to (ip, port, app, gather_settings), changes include:
|
||||
|
||||
- the app is now optional, being None means to anonymously query the server.
|
||||
- gather_settings is now also an optional, being None means all query settings.
|
||||
|
||||
Valve Protocol now supports querying anonymous apps (see previous lines).
|
||||
Better bad game error.
|
||||
[Alien Swarm](https://store.steampowered.com/app/630/Alien_Swarm/) implementation (not tested).
|
||||
[Alien Swarm: Reactive Drop](https://store.steampowered.com/app/563560/Alien_Swarm_Reactive_Drop/) implementation.
|
||||
[Insurgency](https://store.steampowered.com/app/222880/Insurgency/) implementation.
|
||||
[Insurgency: Sandstorm](https://store.steampowered.com/app/581320/Insurgency_Sandstorm/) implementation.
|
||||
[Insurgency: Modern Infantry Combat](https://store.steampowered.com/app/17700/INSURGENCY_Modern_Infantry_Combat/)
|
||||
implementation (not tested).
|
||||
|
||||
# 0.0.3 - 22/10/2022
|
||||
|
||||
Valve protocol now properly supports multi-packet responses (compressed ones not tested).
|
||||
CSGO, TF2 and TS now have independent Responses, if you want a generic one, query the protocol.
|
||||
[Counter Strike: Source](https://store.steampowered.com/app/240/CounterStrike_Source/) implementation (if protocol is 7,
|
||||
queries with multi-packet responses will crash).
|
||||
[Day of Defeat: Source](https://store.steampowered.com/app/300/Day_of_Defeat_Source/) implementation.
|
||||
[Garry's Mod](https://store.steampowered.com/app/4000/Garrys_Mod/) implementation.
|
||||
[Half-Life 2 Deathmatch](https://store.steampowered.com/app/320/HalfLife_2_Deathmatch/) implementation.
|
||||
[Left 4 Dead](https://store.steampowered.com/app/500/Left_4_Dead/) implementation.
|
||||
[Left 4 Dead 2](https://store.steampowered.com/app/550/Left_4_Dead_2/) implementation.
|
||||
|
||||
# 0.0.2 - 20/10/2022
|
||||
|
||||
Further implementation of the Valve protocol (PLAYERS and RULES queries).
|
||||
[Counter Strike: Global Offensive](https://store.steampowered.com/app/730/CounterStrike_Global_Offensive/)
|
||||
implementation.
|
||||
[The Ship](https://developer.valvesoftware.com/wiki/The_Ship) implementation.
|
||||
The library now has error handling.
|
||||
|
||||
# 0.0.1 - 16/10/2022
|
||||
|
||||
The first usable version of the crate, yay!
|
||||
It brings:
|
||||
Initial implementation of the [Valve server query protocol](https://developer.valvesoftware.com/wiki/Server_queries).
|
||||
Initial [Team Fortress 2](https://en.wikipedia.org/wiki/Team_Fortress_2) support.
|
||||
|
||||
# 0.0.0 - 15/10/2022
|
||||
|
||||
The first *markdown*, the crate is unusable as it doesn't contain anything helpful.
|
||||
78
crates/lib/Cargo.toml
Normal file
78
crates/lib/Cargo.toml
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
[package]
|
||||
name = "gamedig"
|
||||
version = "0.9.0"
|
||||
edition = "2021"
|
||||
authors = [
|
||||
"rust-GameDig contributors [https://github.com/gamedig/rust-gamedig/contributors]",
|
||||
"node-GameDig contributors [https://github.com/gamedig/node-gamedig/contributors]",
|
||||
]
|
||||
license = "MIT"
|
||||
description = "Query game servers and not only."
|
||||
homepage = "https://gamedig.github.io/"
|
||||
documentation = "https://docs.rs/gamedig/latest/gamedig/"
|
||||
repository = "https://github.com/gamedig/rust-gamedig"
|
||||
readme = "README.md"
|
||||
keywords = ["server", "query", "game", "check", "status"]
|
||||
rust-version = "1.85.1"
|
||||
categories = ["parser-implementations", "parsing", "network-programming", "encoding"]
|
||||
|
||||
[features]
|
||||
default = ["games", "services", "game_defs"]
|
||||
|
||||
# Enable query functions for specific games
|
||||
games = []
|
||||
# Enable game definitions for use with the generic query functions
|
||||
game_defs = ["dep:phf", "games"]
|
||||
|
||||
# Enable service querying
|
||||
services = []
|
||||
|
||||
# Enable serde derivations for our types
|
||||
serde = []
|
||||
|
||||
# Enable clap derivations for our types
|
||||
clap = ["dep:clap"]
|
||||
packet_capture = ["dep:pcap-file", "dep:pnet_packet", "dep:lazy_static"]
|
||||
|
||||
# Enable TLS for HTTP Client
|
||||
tls = ["ureq/tls"]
|
||||
|
||||
[dependencies]
|
||||
url = "2.5.8"
|
||||
byteorder = "1.5.0"
|
||||
bzip2-rs = "0.1.2"
|
||||
crc32fast = "1.5.0"
|
||||
base64 = "0.22.1"
|
||||
encoding_rs = "0.8.35"
|
||||
serde_json = { version = "1.0.149" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
ureq = { version = "2.12.1", default-features = false, features = ["gzip", "json"] }
|
||||
phf = { version = "0.13.1", optional = true, features = ["macros"] }
|
||||
clap = { version = "4.5.60", optional = true, features = ["derive"] }
|
||||
pcap-file = { version = "2.0.0", optional = true }
|
||||
pnet_packet = { version = "0.35.0", optional = true }
|
||||
lazy_static = { version = "1.5.0", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
gamedig-id-tests = { path = "../id-tests", default-features = false }
|
||||
|
||||
# Examples
|
||||
[[example]]
|
||||
name = "minecraft"
|
||||
required-features = ["games"]
|
||||
|
||||
[[example]]
|
||||
name = "teamfortress2"
|
||||
required-features = ["games"]
|
||||
|
||||
[[example]]
|
||||
name = "valve_master_server_query"
|
||||
required-features = ["services"]
|
||||
|
||||
[[example]]
|
||||
name = "test_eco"
|
||||
required-features = ["games"]
|
||||
|
||||
[[example]]
|
||||
name = "generic"
|
||||
required-features = ["games", "game_defs"]
|
||||
114
crates/lib/README.md
Normal file
114
crates/lib/README.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<h1 align="center">rust-GameDig</h1>
|
||||
|
||||
<h5 align="center">The fast library for querying game servers/services.</h5>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/gamedig/rust-gamedig/actions">
|
||||
<img src="https://github.com/gamedig/rust-gamedig/actions/workflows/ci.yml/badge.svg" alt="CI">
|
||||
</a>
|
||||
<a href="https://crates.io/crates/gamedig">
|
||||
<img src="https://img.shields.io/crates/v/gamedig.svg?color=yellow" alt="Latest Version">
|
||||
</a>
|
||||
<a href="https://crates.io/crates/gamedig">
|
||||
<img src="https://img.shields.io/crates/d/gamedig?color=purple" alt="Crates.io">
|
||||
</a>
|
||||
<a href="https://github.com/gamedig/node-gamedig">
|
||||
<img src="https://raw.githubusercontent.com/gamedig/rust-gamedig/main/.github/badges/node.svg" alt="Node-GameDig Game Coverage">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h5 align="center">
|
||||
This library brings what
|
||||
<a href="https://github.com/gamedig/node-gamedig">
|
||||
node-GameDig
|
||||
</a>
|
||||
does (and not only), to pure Rust!
|
||||
</h5>
|
||||
|
||||
**Warning**: This project goes through frequent API breaking changes and hasn't been thoroughly tested.
|
||||
|
||||
## Community
|
||||
|
||||
Checkout the GameDig Community Discord Server [here](https://discord.gg/NVCMn3tnxH).
|
||||
Note that it isn't be a replacement for GitHub issues, if you have found a problem
|
||||
within the library or want to request a feature, it's better to do so here rather than
|
||||
on Discord.
|
||||
|
||||
## Usage
|
||||
|
||||
Minimum Supported Rust Version is `1.85.1` and the code is cross-platform.
|
||||
|
||||
Pick a game/service/protocol (check
|
||||
the [GAMES](https://github.com/gamedig/rust-gamedig/blob/main/GAMES.md), [SERVICES](https://github.com/gamedig/rust-gamedig/blob/main/SERVICES.md)
|
||||
and [PROTOCOLS](https://github.com/gamedig/rust-gamedig/blob/main/PROTOCOLS.md) files to see the currently supported
|
||||
ones), provide the ip and the port (be aware that some game servers use a separate port for the info queries, the port
|
||||
can also be optional if the server is running the default ports) then query on it.
|
||||
|
||||
[Team Fortress 2](https://store.steampowered.com/app/440/Team_Fortress_2/) query example:
|
||||
|
||||
```rust
|
||||
use gamedig::games::teamfortress2;
|
||||
|
||||
fn main() {
|
||||
let response = teamfortress2::query(&"127.0.0.1".parse().unwrap(), None);
|
||||
// None is the default port (which is 27015), could also be Some(27015)
|
||||
|
||||
match response { // Result type, must check what it is...
|
||||
Err(error) => println!("Couldn't query, error: {}", error),
|
||||
Ok(r) => println!("{:#?}", r)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response (note that some games have a different structure):
|
||||
|
||||
```json5
|
||||
{
|
||||
protocol: 17,
|
||||
name: "Team Fortress 2 Dedicated Server.",
|
||||
map: "ctf_turbine",
|
||||
game: "tf2",
|
||||
appid: 440,
|
||||
players_online: 0,
|
||||
players_details: [],
|
||||
players_maximum: 69,
|
||||
players_bots: 0,
|
||||
server_type: Dedicated,
|
||||
has_password: false,
|
||||
vac_secured: true,
|
||||
version: "7638371",
|
||||
port: Some(27015),
|
||||
steam_id: Some(69753253289735296),
|
||||
tv_port: None,
|
||||
tv_name: None,
|
||||
keywords: Some(
|
||||
"alltalk,nocrits"
|
||||
),
|
||||
rules: [
|
||||
"mp_autoteambalance"
|
||||
:
|
||||
"1",
|
||||
"mp_maxrounds"
|
||||
:
|
||||
"5",
|
||||
//....
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Want to see more examples? Checkout
|
||||
the [examples](https://github.com/gamedig/rust-gamedig/tree/main/crates/lib/examples) folder.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation is available at [docs.rs](https://docs.rs/gamedig/latest/gamedig/).
|
||||
Curious about the history and what changed between versions? Everything is in
|
||||
the [CHANGELOG](https://github.com/gamedig/rust-gamedig/blob/main/crates/lib/CHANGELOG.md) file.
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to see your favorite game/service being supported here, open an issue, and I'll prioritize it (or do a pull
|
||||
request if you want to implement it yourself)!
|
||||
|
||||
Before contributing please read [CONTRIBUTING](https://github.com/gamedig/rust-gamedig/blob/main/CONTRIBUTING.md).
|
||||
|
||||
129
crates/lib/examples/generic.rs
Normal file
129
crates/lib/examples/generic.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use gamedig::{
|
||||
protocols::types::CommonResponse,
|
||||
query_with_timeout_and_extra_settings,
|
||||
ExtraRequestSettings,
|
||||
GDResult,
|
||||
Game,
|
||||
TimeoutSettings,
|
||||
GAMES,
|
||||
};
|
||||
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
|
||||
/// Make a query given the name of a game
|
||||
/// The `game` argument is taken from the [GAMES](gamedig::GAMES) map.
|
||||
fn generic_query(
|
||||
game: &Game,
|
||||
addr: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
extra_settings: Option<ExtraRequestSettings>,
|
||||
) -> GDResult<Box<dyn CommonResponse>> {
|
||||
println!("Querying {:#?} with game {:#?}.", addr, game);
|
||||
|
||||
let response = query_with_timeout_and_extra_settings(game, addr, port, timeout_settings, extra_settings)?;
|
||||
println!("Response: {:#?}", response.as_json());
|
||||
|
||||
let common = response.as_original();
|
||||
println!("Common response: {:#?}", common);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
|
||||
// Handle arguments
|
||||
if let Some(game_name) = args.next() {
|
||||
let hostname = args.next().expect("Must provide an address");
|
||||
// Use to_socket_addrs to resolve hostname to IP
|
||||
let addr: SocketAddr = format!("{}:0", hostname)
|
||||
.to_socket_addrs()
|
||||
.unwrap()
|
||||
.next()
|
||||
.expect("Could not lookup host");
|
||||
let port: Option<u16> = args.next().map(|s| s.parse().unwrap());
|
||||
|
||||
let timeout_settings = TimeoutSettings::new(
|
||||
TimeoutSettings::default().get_read(),
|
||||
TimeoutSettings::default().get_write(),
|
||||
TimeoutSettings::default().get_connect(),
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let game = GAMES
|
||||
.get(&game_name)
|
||||
.expect("Game doesn't exist, run without arguments to see a list of games");
|
||||
|
||||
let extra_settings = game
|
||||
.request_settings
|
||||
.clone()
|
||||
.set_hostname(hostname.to_string())
|
||||
.set_check_app_id(false);
|
||||
|
||||
generic_query(
|
||||
game,
|
||||
&addr.ip(),
|
||||
port,
|
||||
Some(timeout_settings),
|
||||
Some(extra_settings),
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
// Without arguments print a list of games
|
||||
for (name, game) in GAMES.entries() {
|
||||
println!("{}\t{}", name, game.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use gamedig::{protocols::types::TimeoutSettings, GAMES};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use super::generic_query;
|
||||
|
||||
const ADDR: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
||||
|
||||
fn test_game(game_name: &str) {
|
||||
let timeout_settings = Some(
|
||||
TimeoutSettings::new(
|
||||
Some(Duration::from_nanos(1)),
|
||||
Some(Duration::from_nanos(1)),
|
||||
Some(Duration::from_nanos(1)),
|
||||
0,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let game = GAMES
|
||||
.get(game_name)
|
||||
.expect("Game doesn't exist, run without arguments to see a list of games");
|
||||
|
||||
assert!(generic_query(game, &ADDR, None, timeout_settings, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn battlefield1942() { test_game("battlefield1942"); }
|
||||
|
||||
#[test]
|
||||
fn minecraft() { test_game("minecraft"); }
|
||||
|
||||
#[test]
|
||||
fn teamfortress2() { test_game("teamfortress2"); }
|
||||
|
||||
#[test]
|
||||
fn quake2() { test_game("quake2"); }
|
||||
|
||||
#[test]
|
||||
fn all_games() {
|
||||
for game_name in GAMES.keys() {
|
||||
test_game(game_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
crates/lib/examples/minecraft.rs
Normal file
31
crates/lib/examples/minecraft.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use gamedig::minecraft;
|
||||
use gamedig::minecraft::types::RequestSettings;
|
||||
|
||||
fn main() {
|
||||
// or Some(<port>), None is the default protocol port (which is 25565 for java
|
||||
// and 19132 for bedrock)
|
||||
let response = minecraft::query(&"127.0.0.1".parse().unwrap(), None);
|
||||
// This will fail if no server is available locally!
|
||||
|
||||
match response {
|
||||
Err(error) => println!("Couldn't query, error: {}", error),
|
||||
Ok(r) => println!("{:#?}", r),
|
||||
}
|
||||
|
||||
// This is an example to query a server with a hostname to be specified in the
|
||||
// packet. Passing -1 on the protocol_version means anything, note that
|
||||
// an invalid value here might result in server not responding.
|
||||
let response = minecraft::query_java(
|
||||
&"209.222.114.62".parse().unwrap(),
|
||||
Some(25565),
|
||||
Some(RequestSettings {
|
||||
hostname: "mc.hypixel.net".to_string(),
|
||||
protocol_version: -1,
|
||||
}),
|
||||
);
|
||||
|
||||
match response {
|
||||
Err(error) => println!("Couldn't query, error: {}", error),
|
||||
Ok(r) => println!("{:#?}", r),
|
||||
}
|
||||
}
|
||||
12
crates/lib/examples/teamfortress2.rs
Normal file
12
crates/lib/examples/teamfortress2.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
use gamedig::games::teamfortress2;
|
||||
|
||||
fn main() {
|
||||
let response = teamfortress2::query(&"127.0.0.1".parse().unwrap(), None);
|
||||
// or Some(27015), None is the default protocol port (which is 27015)
|
||||
|
||||
match response {
|
||||
// Result type, must check what it is...
|
||||
Err(error) => println!("Couldn't query, error: {}", error),
|
||||
Ok(r) => println!("{:#?}", r),
|
||||
}
|
||||
}
|
||||
10
crates/lib/examples/test_eco.rs
Normal file
10
crates/lib/examples/test_eco.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
use gamedig::games::eco;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn main() {
|
||||
let ip = IpAddr::from_str("142.132.154.69").unwrap();
|
||||
let port = 31111;
|
||||
let r = eco::query(&ip, Some(port));
|
||||
println!("{:#?}", r);
|
||||
}
|
||||
14
crates/lib/examples/valve_master_server_query.rs
Normal file
14
crates/lib/examples/valve_master_server_query.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use gamedig::valve_master_server::{query, Filter, Region, SearchFilters};
|
||||
|
||||
fn main() {
|
||||
let search_filters = SearchFilters::new()
|
||||
.insert(Filter::RunsAppID(440))
|
||||
.insert(Filter::CanBeEmpty(false))
|
||||
.insert(Filter::CanBeFull(false))
|
||||
.insert(Filter::CanHavePassword(false))
|
||||
.insert(Filter::IsSecured(true))
|
||||
.insert(Filter::HasTags(vec!["minecraft".to_string()]));
|
||||
|
||||
let ips = query(Region::Europe, Some(search_filters)).unwrap();
|
||||
println!("Servers: {:?} \n Amount: {}", ips, ips.len());
|
||||
}
|
||||
36
crates/lib/examples/valve_protocol_query.rs
Normal file
36
crates/lib/examples/valve_protocol_query.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
use gamedig::protocols::types::GatherToggle;
|
||||
use gamedig::protocols::valve;
|
||||
use gamedig::protocols::valve::{Engine, GatheringSettings};
|
||||
use gamedig::TimeoutSettings;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
let address = &SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 27015);
|
||||
let engine = Engine::Source(None); // We don't specify a steam app id, let the query try to find it.
|
||||
let gather_settings = GatheringSettings {
|
||||
players: GatherToggle::Enforce, // We want to query for players
|
||||
rules: GatherToggle::Skip, // We don't want to query for rules
|
||||
check_app_id: false, // Loosen up the query a bit by not checking app id
|
||||
};
|
||||
|
||||
let read_timeout = Duration::from_secs(2);
|
||||
let write_timeout = Duration::from_secs(3);
|
||||
let connect_timeout = Duration::from_secs(4);
|
||||
let retries = 1; // does another request if the first one fails.
|
||||
let timeout_settings = TimeoutSettings::new(
|
||||
Some(read_timeout),
|
||||
Some(write_timeout),
|
||||
Some(connect_timeout),
|
||||
retries,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let response = valve::query(
|
||||
address,
|
||||
engine,
|
||||
Some(gather_settings),
|
||||
Some(timeout_settings),
|
||||
);
|
||||
println!("{response:#?}");
|
||||
}
|
||||
612
crates/lib/src/buffer.rs
Normal file
612
crates/lib/src/buffer.rs
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
use crate::GDErrorKind::PacketBad;
|
||||
use crate::GDErrorKind::PacketUnderflow;
|
||||
use crate::GDResult;
|
||||
use byteorder::{BigEndian, ByteOrder, LittleEndian};
|
||||
use std::{convert::TryInto, marker::PhantomData};
|
||||
|
||||
/// A struct representing a buffer with a specific byte order.
|
||||
///
|
||||
/// It's comprised of a byte slice that it reads from, a cursor to keep track of
|
||||
/// the current position within the byte slice, and a `PhantomData` marker to
|
||||
/// bind it to a specific byte order (BigEndian or LittleEndian).
|
||||
///
|
||||
/// The byte order is defined by the `B: ByteOrder` generic parameter.
|
||||
pub struct Buffer<'a, B: ByteOrder> {
|
||||
/// The byte slice that the buffer reads from.
|
||||
data: &'a [u8],
|
||||
/// The cursor marking our current position in the buffer.
|
||||
cursor: usize,
|
||||
/// A phantom field used to bind the `Buffer` to a specific `ByteOrder`.
|
||||
_marker: PhantomData<B>,
|
||||
}
|
||||
|
||||
impl<'a, B: ByteOrder> Buffer<'a, B> {
|
||||
/// Creates and returns a new `Buffer` with the given data.
|
||||
///
|
||||
/// The cursor is set to the start of the buffer (position 0) upon
|
||||
/// initialization.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `data` - A byte slice that the buffer will read from.
|
||||
pub const fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
cursor: 0,
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn current_position(&self) -> usize { self.cursor }
|
||||
|
||||
/// Returns the length of the remaining bytes from the current cursor
|
||||
/// position.
|
||||
pub const fn remaining_length(&self) -> usize { self.data.len() - self.cursor }
|
||||
|
||||
/// Returns the length of the buffer data.
|
||||
pub const fn data_length(&self) -> usize { self.data.len() }
|
||||
|
||||
// TODO: Look into this to make it take ownership of data, not borrowing it
|
||||
// There are many instances where we transform this to a vector.
|
||||
/// Returns the remaining bytes that have not been read.
|
||||
pub fn remaining_bytes(&self) -> &[u8] { &self.data[self.cursor ..] }
|
||||
|
||||
/// Moves the cursor forward or backward by a specified offset.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `offset` - The amount to move the cursor. Use a negative value to move
|
||||
/// backwards.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `BufferError` if the attempted move would position the cursor
|
||||
/// out of bounds.
|
||||
pub fn move_cursor(&mut self, offset: isize) -> GDResult<()> {
|
||||
// Compute the new cursor position by adding the offset to the current cursor
|
||||
// position. The checked_add method is used for safe addition,
|
||||
// preventing overflow and underflow.
|
||||
let new_cursor = (self.cursor as isize).checked_add(offset);
|
||||
|
||||
match new_cursor {
|
||||
// If the addition was not successful (i.e., it resulted in an overflow or underflow),
|
||||
// return an error indicating that the cursor is out of bounds.
|
||||
None => Err(PacketBad.into()),
|
||||
|
||||
// If the new cursor position is either less than zero (i.e., before the start of the buffer)
|
||||
// or greater than the remaining length of the buffer (i.e., past the end of the buffer),
|
||||
// return an error indicating that the cursor is out of bounds.
|
||||
Some(x) if x < 0 || x as usize > self.data_length() => Err(PacketBad.into()),
|
||||
|
||||
// If the new cursor position is within the bounds of the buffer, update the cursor
|
||||
// position and return Ok.
|
||||
Some(x) => {
|
||||
self.cursor = x as usize;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a value of type `T` from the buffer, and advances the cursor by
|
||||
/// the size of `T`.
|
||||
///
|
||||
/// # Type Parameters
|
||||
///
|
||||
/// * `T` - The type of value to be read from the buffer. This type must
|
||||
/// implement the `BufferRead` trait with the same byte order as the
|
||||
/// buffer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `BufferError` if there is not enough data remaining in the
|
||||
/// buffer to read a value of type `T`.
|
||||
pub fn read<T: Sized + BufferRead<B>>(&mut self) -> GDResult<T> {
|
||||
// Get the size of `T` in bytes.
|
||||
let size = std::mem::size_of::<T>();
|
||||
// Calculate remaining length of the buffer.
|
||||
let remaining = self.remaining_length();
|
||||
|
||||
// If the size of `T` is larger than the remaining length, return an error
|
||||
// because we don't have enough data left to read.
|
||||
if size > remaining {
|
||||
return Err(PacketUnderflow.context(format!(
|
||||
"Size requested {size} was larger than remaining bytes {remaining}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Slice the data array from the current cursor position for `size` amount of
|
||||
// bytes.
|
||||
let bytes = &self.data[self.cursor .. self.cursor + size];
|
||||
|
||||
// Move the cursor forward by `size`.
|
||||
self.cursor += size;
|
||||
|
||||
// Use the `read_from_buffer` function of the `BufferRead` implementation for
|
||||
// `T` to convert the bytes into an instance of `T`.
|
||||
T::read_from_buffer(bytes)
|
||||
}
|
||||
|
||||
/// Reads a string from the buffer using a specified `StringDecoder`, until
|
||||
/// an optional delimiter.
|
||||
///
|
||||
/// # Type Parameters
|
||||
///
|
||||
/// * `D` - The type of string decoder to use. This type must implement the
|
||||
/// `StringDecoder` trait with the same byte order as the buffer.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `until` - An optional delimiter. If provided, the method will read
|
||||
/// until this delimiter is encountered. If not provided, the method will
|
||||
/// read until the default delimiter of the decoder.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `BufferError` if there is an error decoding the string.
|
||||
pub fn read_string<D: StringDecoder>(&mut self, until: Option<D::Delimiter>) -> GDResult<String> {
|
||||
// Check if the cursor is out of bounds.
|
||||
if self.cursor > self.data_length() {
|
||||
return Err(PacketUnderflow.context(format!(
|
||||
"Cursor position {} is out of bounds when reading string. Buffer length: {}",
|
||||
self.cursor,
|
||||
self.data_length()
|
||||
)));
|
||||
}
|
||||
|
||||
// Slice the data array from the current cursor position to the end.
|
||||
let data_slice = &self.data[self.cursor ..];
|
||||
|
||||
// Use the provided delimiter if one was given, or default to the
|
||||
// delimiter specified by the StringDecoder.
|
||||
let delimiter = until.unwrap_or(D::DELIMITER);
|
||||
|
||||
// Invoke the decode_string function of the provided StringDecoder,
|
||||
// passing in the remaining data slice, the mutable reference to the
|
||||
// cursor, and the delimiter.
|
||||
let result = D::decode_string(data_slice, &mut self.cursor, delimiter)?;
|
||||
|
||||
// If decoding was successful, return the decoded string. The cursor
|
||||
// position has been updated within the decode_string call to reflect
|
||||
// the new position after reading.
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait that provides an interface to switch endianness.
|
||||
///
|
||||
/// The trait `SwitchEndian` is used for types that have a specific
|
||||
/// byte order (endianness) and can switch to another byte order.
|
||||
/// The type of the switched endianness is determined by the associated
|
||||
/// type `Output`.
|
||||
///
|
||||
/// The associated type `Output` must implement the `ByteOrder` trait.
|
||||
pub trait SwitchEndian {
|
||||
type Output: ByteOrder;
|
||||
}
|
||||
|
||||
/// An implementation of `SwitchEndian` for `LittleEndian`.
|
||||
///
|
||||
/// The switched endianness type is `BigEndian`.
|
||||
impl SwitchEndian for LittleEndian {
|
||||
type Output = BigEndian;
|
||||
}
|
||||
|
||||
/// An implementation of `SwitchEndian` for `BigEndian`.
|
||||
///
|
||||
/// The switched endianness type is `LittleEndian`.
|
||||
impl SwitchEndian for BigEndian {
|
||||
type Output = LittleEndian;
|
||||
}
|
||||
|
||||
impl<'a, B: SwitchEndian + ByteOrder> Buffer<'a, B> {
|
||||
/// Switches the byte order of a chunk in the buffer.
|
||||
///
|
||||
/// This method consumes the buffer and returns a new buffer
|
||||
/// with a chunk of the original buffer's data, starting from the
|
||||
/// original cursor position and of the given size, where the byte
|
||||
/// order is switched according to the implementation
|
||||
/// of `SwitchEndian` for `B`.
|
||||
///
|
||||
/// Note: The method also advances the cursor of the original buffer
|
||||
/// by `size`.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `size`: The size of the chunk to be taken from the original buffer.
|
||||
pub fn switch_endian_chunk(&mut self, size: usize) -> GDResult<Buffer<'a, B::Output>> {
|
||||
let old_cursor = self.cursor;
|
||||
self.move_cursor(size as isize)?;
|
||||
|
||||
Ok(Buffer {
|
||||
data: &self.data[old_cursor .. old_cursor + size],
|
||||
cursor: 0,
|
||||
_marker: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait defining a protocol for reading values of a certain type from a
|
||||
/// buffer.
|
||||
///
|
||||
/// Implementors of this trait provide a method for reading their type from a
|
||||
/// byte buffer with a specific byte order.
|
||||
pub trait BufferRead<B: ByteOrder>: Sized {
|
||||
fn read_from_buffer(data: &[u8]) -> GDResult<Self>;
|
||||
}
|
||||
|
||||
/// Macro to implement the `BufferRead` trait for byte types.
|
||||
///
|
||||
/// This macro generates an implementation of the `BufferRead` trait for a
|
||||
/// specified byte type. The implementation will read a single byte from the
|
||||
/// buffer and convert it to the target type using the provided map function.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `$type` - The target type to implement `BufferRead` for.
|
||||
/// * `$map_func` - The function to map a byte to the target type.
|
||||
macro_rules! impl_buffer_read_byte {
|
||||
($type:ty, $map_func:expr) => {
|
||||
impl<B: ByteOrder> BufferRead<B> for $type {
|
||||
fn read_from_buffer(data: &[u8]) -> GDResult<Self> {
|
||||
// Use the `first` method to get the first byte from the data array.
|
||||
data.first()
|
||||
// Apply the $map_func function to convert the raw byte to the $type.
|
||||
.map($map_func)
|
||||
// If the data array is empty (and thus `first` returns None),
|
||||
// `ok_or_else` will return a BufferError.
|
||||
.ok_or_else(|| PacketBad.into())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Macro to implement the `BufferRead` trait for multi-byte types.
|
||||
///
|
||||
/// This macro generates an implementation of the `BufferRead` trait for a
|
||||
/// specified multi-byte type. The implementation will read the appropriate
|
||||
/// number of bytes from the buffer and convert them to the target type using
|
||||
/// the provided read function.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `$type` - The target type to implement `BufferRead` for.
|
||||
/// * `$read_func` - The function to read the bytes into the target type.
|
||||
macro_rules! impl_buffer_read {
|
||||
($type:ty, $read_func:ident) => {
|
||||
impl<B: ByteOrder> BufferRead<B> for $type {
|
||||
fn read_from_buffer(data: &[u8]) -> GDResult<Self> {
|
||||
// Convert the byte slice into an array of the appropriate type.
|
||||
let array = data.try_into().map_err(|e| {
|
||||
// If conversion fails, return an error indicating the required and provided
|
||||
// lengths.
|
||||
PacketBad.context(e)
|
||||
})?;
|
||||
|
||||
// Use the provided function to read the data from the array into the given
|
||||
// type.
|
||||
Ok(B::$read_func(array))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_buffer_read_byte!(u8, |&b| b);
|
||||
impl_buffer_read_byte!(i8, |&b| b as i8);
|
||||
|
||||
impl_buffer_read!(u16, read_u16);
|
||||
impl_buffer_read!(i16, read_i16);
|
||||
impl_buffer_read!(u32, read_u32);
|
||||
impl_buffer_read!(i32, read_i32);
|
||||
impl_buffer_read!(u64, read_u64);
|
||||
impl_buffer_read!(i64, read_i64);
|
||||
impl_buffer_read!(f32, read_f32);
|
||||
impl_buffer_read!(f64, read_f64);
|
||||
|
||||
/// A trait defining a protocol for decoding strings from a buffer.
|
||||
///
|
||||
/// This trait should be implemented by types that can decode strings from a
|
||||
/// byte buffer with a specific byte order and delimiter.
|
||||
pub trait StringDecoder {
|
||||
/// The type of the delimiter used by the decoder.
|
||||
type Delimiter: AsRef<[u8]>;
|
||||
|
||||
/// The default delimiter used by the decoder.
|
||||
const DELIMITER: Self::Delimiter;
|
||||
|
||||
/// Decodes a string from the provided byte slice, and updates the cursor
|
||||
/// position accordingly.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `data` - The byte slice to decode the string from.
|
||||
/// * `cursor` - The current position in the byte slice.
|
||||
/// * `delimiter` - The delimiter to use for decoding the string.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `BufferError` if there is an error decoding the string.
|
||||
fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult<String>;
|
||||
}
|
||||
|
||||
/// A decoder for UTF-8 encoded strings.
|
||||
///
|
||||
/// This decoder uses a single null byte (`0x00`) as the default delimiter.
|
||||
pub struct Utf8Decoder;
|
||||
|
||||
impl StringDecoder for Utf8Decoder {
|
||||
type Delimiter = [u8; 1];
|
||||
|
||||
const DELIMITER: Self::Delimiter = [0x00];
|
||||
|
||||
/// Decodes a UTF-8 string from the given data, updating the cursor position
|
||||
/// accordingly.
|
||||
fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult<String> {
|
||||
// Find the position of the delimiter in the data. If the delimiter is not
|
||||
// found, the length of the data is returned.
|
||||
let position = data
|
||||
// Create an iterator over the data.
|
||||
.iter()
|
||||
// Find the position of the delimiter
|
||||
.position(|&b| b == delimiter.as_ref()[0])
|
||||
// If the delimiter is not found, use the whole data slice.
|
||||
.unwrap_or(data.len());
|
||||
|
||||
// Convert the data until the found position into a UTF-8 string.
|
||||
let result = std::str::from_utf8(
|
||||
// Take a slice of data until the position.
|
||||
&data[.. position]
|
||||
)
|
||||
// If the data cannot be converted into a UTF-8 string, return an error
|
||||
.map_err(|e| PacketBad.context(e))?
|
||||
// Convert the resulting &str into a String
|
||||
.to_owned();
|
||||
|
||||
// Update the cursor position
|
||||
// The +1 is to skip the delimiter
|
||||
*cursor += position + 1;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoder for UTF-8 encoded strings prefixed by a single byte denoting the
|
||||
/// string's length.
|
||||
///
|
||||
/// This decoder uses a single null byte (`0x00`) as the default delimiter.
|
||||
pub struct Utf8LengthPrefixedDecoder;
|
||||
|
||||
impl StringDecoder for Utf8LengthPrefixedDecoder {
|
||||
type Delimiter = [u8; 1];
|
||||
|
||||
const DELIMITER: Self::Delimiter = [0x00];
|
||||
|
||||
/// Decodes a UTF-8 string from the given data, updating the cursor position
|
||||
/// accordingly.
|
||||
fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult<String> {
|
||||
// Find the maximum length of the string
|
||||
let length = *data
|
||||
.first()
|
||||
.ok_or_else(|| PacketBad.context("Length of string not found"))?;
|
||||
|
||||
// Find the position of the delimiter in the data. If the delimiter is not
|
||||
// found, the length is returned.
|
||||
let position = data
|
||||
// Create an iterator over the data.
|
||||
.iter()
|
||||
.skip(1)
|
||||
.take(length as usize)
|
||||
// Find the position of the delimiter
|
||||
.position(|&b| b == delimiter.as_ref()[0])
|
||||
// If the delimiter is not found, use the whole data slice.
|
||||
.unwrap_or(length as usize);
|
||||
|
||||
// Convert the data until the found position into a UTF-8 string.
|
||||
let result = std::str::from_utf8(
|
||||
// Take a slice of data until the position.
|
||||
&data[1 .. position + 1]
|
||||
)
|
||||
// If the data cannot be converted into a UTF-8 string, return an error
|
||||
.map_err(|e| PacketBad.context(e))?
|
||||
// Convert the resulting &str into a String
|
||||
.to_owned();
|
||||
|
||||
// Update the cursor position
|
||||
// The +1 is to skip t length
|
||||
*cursor += position + 1;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoder for UTF-16 encoded strings.
|
||||
///
|
||||
/// This decoder uses a pair of null bytes (`0x00, 0x00`) as the default
|
||||
/// delimiter.
|
||||
///
|
||||
/// # Type Parameters
|
||||
///
|
||||
/// * `B` - The byte order to use when decoding the string.
|
||||
pub struct Utf16Decoder<B: ByteOrder> {
|
||||
_marker: PhantomData<B>,
|
||||
}
|
||||
|
||||
impl<B: ByteOrder> StringDecoder for Utf16Decoder<B> {
|
||||
type Delimiter = [u8; 2];
|
||||
|
||||
const DELIMITER: Self::Delimiter = [0x00, 0x00];
|
||||
|
||||
/// Decodes a UTF-16 string from the given data, updating the cursor
|
||||
/// position accordingly.
|
||||
fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult<String> {
|
||||
// Try to find the position of the delimiter in the data
|
||||
let position = data
|
||||
// Split the data into 2-byte chunks (as UTF-16 uses 2 bytes per character)
|
||||
.chunks_exact(2)
|
||||
// Find the position of the delimiter
|
||||
.position(|chunk| chunk == delimiter.as_ref())
|
||||
// If the delimiter is not found, use the whole data, otherwise use the position of the delimiter
|
||||
.map_or(data.len(), |pos| pos * 2);
|
||||
|
||||
// Create a buffer of u16 values to hold the decoded characters
|
||||
let mut paired_buf: Vec<u16> = vec![0; position / 2];
|
||||
|
||||
// Decode the data into the buffer
|
||||
B::read_u16_into(&data[.. position], &mut paired_buf);
|
||||
|
||||
// Convert the buffer of u16 values into a String
|
||||
let result = String::from_utf16(&paired_buf).map_err(|e| PacketBad.context(e))?;
|
||||
|
||||
// Update the cursor position
|
||||
// The +2 accounts for the delimiter
|
||||
*cursor += position + 2;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use byteorder::BigEndian;
|
||||
|
||||
#[test]
|
||||
fn test_new_buffer() {
|
||||
let data: &[u8] = &[1, 2, 3, 4];
|
||||
let buffer = Buffer::<LittleEndian>::new(data);
|
||||
|
||||
assert_eq!(buffer.data, data);
|
||||
assert_eq!(buffer.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remaining_length() {
|
||||
let data: &[u8] = &[1, 2, 3, 4];
|
||||
let mut buffer = Buffer::<LittleEndian>::new(data);
|
||||
|
||||
assert_eq!(buffer.remaining_length(), 4);
|
||||
|
||||
buffer.cursor = 2;
|
||||
assert_eq!(buffer.remaining_length(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_cursor() {
|
||||
let data: &[u8] = &[1, 2, 3, 4];
|
||||
let mut buffer = Buffer::<LittleEndian>::new(data);
|
||||
|
||||
// Test moving forward
|
||||
assert!(buffer.move_cursor(2).is_ok());
|
||||
assert_eq!(buffer.cursor, 2);
|
||||
|
||||
// Test moving backward
|
||||
assert!(buffer.move_cursor(-1).is_ok());
|
||||
assert_eq!(buffer.cursor, 1);
|
||||
|
||||
// Test moving beyond data limits
|
||||
assert!(buffer.move_cursor(5).is_err());
|
||||
assert!(buffer.move_cursor(-2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_switch_endian_chunk_le_be() {
|
||||
let data = [0x01, 0x02, 0x03, 0x04];
|
||||
let mut buffer = Buffer::<LittleEndian>::new(&data[..]);
|
||||
|
||||
let switched_buffer = buffer.switch_endian_chunk(2).unwrap();
|
||||
|
||||
assert_eq!(switched_buffer.data, [0x01, 0x02]);
|
||||
assert_eq!(switched_buffer.cursor, 0);
|
||||
|
||||
assert_eq!(buffer.remaining_bytes(), [0x03, 0x04]);
|
||||
assert_eq!(buffer.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_switch_endian_chunk_be_le() {
|
||||
let data = [0x01, 0x02, 0x03, 0x04];
|
||||
let mut buffer = Buffer::<BigEndian>::new(&data[..]);
|
||||
|
||||
let switched_buffer = buffer.switch_endian_chunk(2).unwrap();
|
||||
|
||||
assert_eq!(switched_buffer.data, [0x01, 0x02]);
|
||||
assert_eq!(switched_buffer.cursor, 0);
|
||||
|
||||
assert_eq!(buffer.remaining_bytes(), [0x03, 0x04]);
|
||||
assert_eq!(buffer.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_read_u8() {
|
||||
let data: &[u8] = &[1, 2, 3, 4];
|
||||
let mut buffer = Buffer::<LittleEndian>::new(data);
|
||||
|
||||
let result: Result<u8, _> = buffer.read();
|
||||
assert_eq!(result.unwrap(), 1);
|
||||
assert_eq!(buffer.cursor, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_read_u16() {
|
||||
let data: &[u8] = &[1, 2, 3, 4];
|
||||
let mut buffer = Buffer::<LittleEndian>::new(data);
|
||||
|
||||
let result: Result<u16, _> = buffer.read();
|
||||
assert_eq!(result.unwrap(), 0x0201);
|
||||
assert_eq!(buffer.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_read_u16_big_endian() {
|
||||
let data: &[u8] = &[1, 2, 3, 4];
|
||||
let mut buffer = Buffer::<BigEndian>::new(data);
|
||||
|
||||
let result: Result<u16, _> = buffer.read();
|
||||
assert_eq!(result.unwrap(), 0x0102);
|
||||
assert_eq!(buffer.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_string_utf8() {
|
||||
let data: &[u8] = b"Hello\0World\0";
|
||||
let mut cursor = 0;
|
||||
let delimiter = [0x00];
|
||||
|
||||
let result = Utf8Decoder::decode_string(data, &mut cursor, delimiter);
|
||||
assert_eq!(result.unwrap(), "Hello");
|
||||
assert_eq!(cursor, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_string_utf16_le() {
|
||||
let data: &[u8] = &[0x48, 0x00, 0x65, 0x00, 0x00, 0x00];
|
||||
let mut cursor = 0;
|
||||
let delimiter = [0x00, 0x00];
|
||||
|
||||
let result = Utf16Decoder::<LittleEndian>::decode_string(data, &mut cursor, delimiter);
|
||||
assert_eq!(result.unwrap(), "He");
|
||||
assert_eq!(cursor, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_string_utf16_be() {
|
||||
let data: &[u8] = &[0x00, 0x48, 0x00, 0x65, 0x00, 0x00];
|
||||
let mut cursor = 0;
|
||||
let delimiter = [0x00, 0x00];
|
||||
|
||||
let result = Utf16Decoder::<BigEndian>::decode_string(data, &mut cursor, delimiter);
|
||||
assert_eq!(result.unwrap(), "He");
|
||||
assert_eq!(cursor, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_underflow_error() {
|
||||
let data: &[u8] = &[1, 2];
|
||||
let mut buffer = Buffer::<LittleEndian>::new(data);
|
||||
|
||||
let result: Result<u32, _> = buffer.read();
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
crate::GDErrorKind::PacketUnderflow.into()
|
||||
);
|
||||
}
|
||||
}
|
||||
39
crates/lib/src/capture/mod.rs
Normal file
39
crates/lib/src/capture/mod.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
pub(crate) mod packet;
|
||||
mod pcap;
|
||||
pub(crate) mod socket;
|
||||
pub(crate) mod writer;
|
||||
|
||||
use self::{pcap::Pcap, writer::Writer};
|
||||
use pcap_file::pcapng::{blocks::interface_description::InterfaceDescriptionBlock, PcapNgBlock, PcapNgWriter};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn setup_capture(file_path: Option<PathBuf>) {
|
||||
if let Some(file_path) = file_path {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(file_path.with_extension("pcap"))
|
||||
.unwrap();
|
||||
|
||||
let mut pcap_writer = PcapNgWriter::new(file).unwrap();
|
||||
|
||||
// Write headers
|
||||
let _ = pcap_writer.write_block(
|
||||
&InterfaceDescriptionBlock {
|
||||
linktype: pcap_file::DataLink::ETHERNET,
|
||||
snaplen: 0xFFFF,
|
||||
options: vec![],
|
||||
}
|
||||
.into_block(),
|
||||
);
|
||||
|
||||
let writer = Box::new(Pcap::new(pcap_writer));
|
||||
attach(writer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches a writer to the capture module.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an Error if the writer is already set.
|
||||
fn attach(writer: Box<dyn Writer + Send + Sync>) { crate::capture::socket::set_writer(writer); }
|
||||
203
crates/lib/src/capture/packet.rs
Normal file
203
crates/lib/src/capture/packet.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
/// Size of a standard network packet.
|
||||
pub(crate) const PACKET_SIZE: usize = 5012;
|
||||
/// Size of an Ethernet header.
|
||||
pub(crate) const HEADER_SIZE_ETHERNET: usize = 14;
|
||||
/// Size of an IPv4 header.
|
||||
pub(crate) const HEADER_SIZE_IP4: usize = 20;
|
||||
/// Size of an IPv6 header.
|
||||
pub(crate) const HEADER_SIZE_IP6: usize = 40;
|
||||
/// Size of a UDP header.
|
||||
pub(crate) const HEADER_SIZE_UDP: usize = 4;
|
||||
|
||||
/// Represents the direction of a network packet.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Direction {
|
||||
/// Packet is outgoing (sent by us).
|
||||
Send,
|
||||
/// Packet is incoming (received by us).
|
||||
Receive,
|
||||
}
|
||||
|
||||
/// Defines the protocol of a network packet.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Protocol {
|
||||
/// Transmission Control Protocol.
|
||||
Tcp,
|
||||
/// User Datagram Protocol.
|
||||
Udp,
|
||||
}
|
||||
|
||||
/// Trait for handling different types of IP addresses (IPv4, IPv6).
|
||||
pub(crate) trait IpAddress: Sized {
|
||||
/// Creates an instance from a standard `IpAddr`, returning `None` if the
|
||||
/// types are incompatible.
|
||||
fn from_std(ip: IpAddr) -> Option<Self>;
|
||||
}
|
||||
|
||||
/// Represents a captured network packet with metadata.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct CapturePacket<'a> {
|
||||
/// Direction of the packet (Send/Receive).
|
||||
pub(crate) direction: Direction,
|
||||
/// Protocol of the packet (Tcp/UDP).
|
||||
pub(crate) protocol: Protocol,
|
||||
/// Remote socket address.
|
||||
pub(crate) remote_address: &'a SocketAddr,
|
||||
/// Local socket address.
|
||||
pub(crate) local_address: &'a SocketAddr,
|
||||
}
|
||||
|
||||
impl CapturePacket<'_> {
|
||||
/// Retrieves the local and remote ports based on the packet's direction.
|
||||
///
|
||||
/// Returns:
|
||||
/// - (u16, u16): Tuple of (source port, destination port).
|
||||
pub(super) fn ports_by_direction(&self) -> (u16, u16) {
|
||||
let (local, remote) = (self.local_address.port(), self.remote_address.port());
|
||||
self.direction.order(local, remote)
|
||||
}
|
||||
|
||||
/// Retrieves the local and remote IP addresses.
|
||||
///
|
||||
/// Returns:
|
||||
/// - (IpAddr, IpAddr): Tuple of (local IP, remote IP).
|
||||
pub(super) fn ip_addr(&self) -> (IpAddr, IpAddr) {
|
||||
let (local, remote) = (self.local_address.ip(), self.remote_address.ip());
|
||||
(local, remote)
|
||||
}
|
||||
|
||||
/// Retrieves IP addresses of a specific type (IPv4 or IPv6) based on the
|
||||
/// packet's direction.
|
||||
///
|
||||
/// Panics if the IP type of the addresses does not match the requested
|
||||
/// type.
|
||||
///
|
||||
/// Returns:
|
||||
/// - (T, T): Tuple of (source IP, destination IP) of the specified type in
|
||||
/// order.
|
||||
pub(super) fn ipvt_by_direction<T: IpAddress>(&self) -> (T, T) {
|
||||
let (local, remote) = (
|
||||
T::from_std(self.local_address.ip()).expect("Incorrect IP type for local address"),
|
||||
T::from_std(self.remote_address.ip()).expect("Incorrect IP type for remote address"),
|
||||
);
|
||||
|
||||
self.direction.order(local, remote)
|
||||
}
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// Orders two elements (source and destination) based on the packet's
|
||||
/// direction.
|
||||
///
|
||||
/// Returns:
|
||||
/// - (T, T): Ordered tuple (source, destination).
|
||||
pub(self) const fn order<T>(&self, source: T, remote: T) -> (T, T) {
|
||||
match self {
|
||||
Direction::Send => (source, remote),
|
||||
Direction::Receive => (remote, source),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements the `IpAddress` trait for `Ipv4Addr`.
|
||||
impl IpAddress for Ipv4Addr {
|
||||
/// Creates an `Ipv4Addr` from a standard `IpAddr`, if it's IPv4.
|
||||
fn from_std(ip: IpAddr) -> Option<Self> {
|
||||
match ip {
|
||||
IpAddr::V4(ipv4) => Some(ipv4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements the `IpAddress` trait for `Ipv6Addr`.
|
||||
impl IpAddress for Ipv6Addr {
|
||||
/// Creates an `Ipv6Addr` from a standard `IpAddr`, if it's IPv6.
|
||||
fn from_std(ip: IpAddr) -> Option<Self> {
|
||||
match ip {
|
||||
IpAddr::V6(ipv6) => Some(ipv6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
// Helper function to create a SocketAddr from a string
|
||||
fn socket_addr(addr: &str) -> SocketAddr { SocketAddr::from_str(addr).unwrap() }
|
||||
|
||||
#[test]
|
||||
fn test_ports_by_direction() {
|
||||
let packet_send = CapturePacket {
|
||||
direction: Direction::Send,
|
||||
protocol: Protocol::Tcp,
|
||||
local_address: &socket_addr("127.0.0.1:8080"),
|
||||
remote_address: &socket_addr("192.168.1.1:80"),
|
||||
};
|
||||
|
||||
let packet_receive = CapturePacket {
|
||||
direction: Direction::Receive,
|
||||
protocol: Protocol::Tcp,
|
||||
local_address: &socket_addr("127.0.0.1:8080"),
|
||||
remote_address: &socket_addr("192.168.1.1:80"),
|
||||
};
|
||||
|
||||
assert_eq!(packet_send.ports_by_direction(), (8080, 80));
|
||||
assert_eq!(packet_receive.ports_by_direction(), (80, 8080));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ip_addr() {
|
||||
let packet_send = CapturePacket {
|
||||
direction: Direction::Send,
|
||||
protocol: Protocol::Tcp,
|
||||
local_address: &socket_addr("127.0.0.1:8080"),
|
||||
remote_address: &socket_addr("192.168.1.1:80"),
|
||||
};
|
||||
|
||||
let packet_receive = CapturePacket {
|
||||
direction: Direction::Receive,
|
||||
protocol: Protocol::Tcp,
|
||||
local_address: &socket_addr("127.0.0.1:8080"),
|
||||
remote_address: &socket_addr("192.168.1.1:80"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
packet_send.ip_addr(),
|
||||
(
|
||||
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
|
||||
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
packet_receive.ip_addr(),
|
||||
(
|
||||
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
|
||||
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ip_by_direction_type_specific() {
|
||||
let packet = CapturePacket {
|
||||
direction: Direction::Send,
|
||||
protocol: Protocol::Tcp,
|
||||
local_address: &socket_addr("127.0.0.1:8080"),
|
||||
remote_address: &socket_addr("192.168.1.1:80"),
|
||||
};
|
||||
|
||||
let ipv4_result: Result<(Ipv4Addr, Ipv4Addr), _> =
|
||||
std::panic::catch_unwind(|| packet.ipvt_by_direction::<Ipv4Addr>());
|
||||
assert!(ipv4_result.is_ok());
|
||||
|
||||
let ipv6_result: Result<(Ipv6Addr, Ipv6Addr), _> =
|
||||
std::panic::catch_unwind(|| packet.ipvt_by_direction::<Ipv6Addr>());
|
||||
assert!(ipv6_result.is_err());
|
||||
}
|
||||
}
|
||||
383
crates/lib/src/capture/pcap.rs
Normal file
383
crates/lib/src/capture/pcap.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
use pcap_file::pcapng::{blocks::enhanced_packet::EnhancedPacketOption, PcapNgBlock, PcapNgWriter};
|
||||
use pnet_packet::{
|
||||
ethernet::{EtherType, MutableEthernetPacket},
|
||||
ip::{IpNextHeaderProtocol, IpNextHeaderProtocols},
|
||||
ipv4::MutableIpv4Packet,
|
||||
ipv6::MutableIpv6Packet,
|
||||
tcp::{MutableTcpPacket, TcpFlags},
|
||||
udp::MutableUdpPacket,
|
||||
PacketSize,
|
||||
};
|
||||
use std::{io::Write, net::IpAddr, time::Instant};
|
||||
|
||||
use super::packet::{
|
||||
CapturePacket,
|
||||
Direction,
|
||||
Protocol,
|
||||
HEADER_SIZE_ETHERNET,
|
||||
HEADER_SIZE_IP4,
|
||||
HEADER_SIZE_IP6,
|
||||
HEADER_SIZE_UDP,
|
||||
PACKET_SIZE,
|
||||
};
|
||||
|
||||
const BUFFER_SIZE: usize = PACKET_SIZE - HEADER_SIZE_IP6 - HEADER_SIZE_ETHERNET;
|
||||
|
||||
pub(crate) struct Pcap<W: Write> {
|
||||
writer: PcapNgWriter<W>,
|
||||
pub(crate) state: State,
|
||||
}
|
||||
|
||||
pub(crate) struct State {
|
||||
pub(crate) start_time: Instant,
|
||||
pub(crate) send_seq: u32,
|
||||
pub(crate) rec_seq: u32,
|
||||
pub(crate) has_sent_handshake: bool,
|
||||
pub(crate) stream_count: u32,
|
||||
}
|
||||
|
||||
impl<W: Write> Pcap<W> {
|
||||
pub(crate) fn new(writer: PcapNgWriter<W>) -> Self {
|
||||
Self {
|
||||
writer,
|
||||
state: State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_transport_packet(&mut self, info: &CapturePacket, payload: &[u8]) {
|
||||
let mut buffer_array: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
|
||||
let buf: &mut [u8] = &mut buffer_array[..];
|
||||
|
||||
let (source_port, dest_port) = info.ports_by_direction();
|
||||
|
||||
match info.protocol {
|
||||
Protocol::Tcp => {
|
||||
let buf_size = {
|
||||
let mut tcp = MutableTcpPacket::new(buf).unwrap();
|
||||
tcp.set_source(source_port);
|
||||
tcp.set_destination(dest_port);
|
||||
tcp.set_payload(payload);
|
||||
tcp.set_data_offset(5);
|
||||
tcp.set_window(43440);
|
||||
match info.direction {
|
||||
Direction::Send => {
|
||||
tcp.set_sequence(self.state.send_seq);
|
||||
tcp.set_acknowledgement(self.state.rec_seq);
|
||||
|
||||
self.state.send_seq = self.state.send_seq.wrapping_add(payload.len() as u32);
|
||||
}
|
||||
Direction::Receive => {
|
||||
tcp.set_sequence(self.state.rec_seq);
|
||||
tcp.set_acknowledgement(self.state.send_seq);
|
||||
|
||||
self.state.rec_seq = self.state.rec_seq.wrapping_add(payload.len() as u32);
|
||||
}
|
||||
}
|
||||
tcp.set_flags(TcpFlags::PSH | TcpFlags::ACK);
|
||||
|
||||
tcp.packet_size()
|
||||
};
|
||||
|
||||
self.write_transport_payload(
|
||||
info,
|
||||
IpNextHeaderProtocols::Tcp,
|
||||
&buf[.. buf_size + payload.len()],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let mut info = info.clone();
|
||||
let buf_size = {
|
||||
let mut tcp = MutableTcpPacket::new(buf).unwrap();
|
||||
tcp.set_source(dest_port);
|
||||
tcp.set_destination(source_port);
|
||||
tcp.set_data_offset(5);
|
||||
tcp.set_window(43440);
|
||||
match &info.direction {
|
||||
Direction::Send => {
|
||||
tcp.set_sequence(self.state.rec_seq);
|
||||
tcp.set_acknowledgement(self.state.send_seq);
|
||||
|
||||
info.direction = Direction::Receive;
|
||||
}
|
||||
Direction::Receive => {
|
||||
tcp.set_sequence(self.state.send_seq);
|
||||
tcp.set_acknowledgement(self.state.rec_seq);
|
||||
|
||||
info.direction = Direction::Send;
|
||||
}
|
||||
}
|
||||
tcp.set_flags(TcpFlags::ACK);
|
||||
|
||||
tcp.packet_size()
|
||||
};
|
||||
|
||||
self.write_transport_payload(
|
||||
&info,
|
||||
IpNextHeaderProtocols::Tcp,
|
||||
&buf[.. buf_size],
|
||||
vec![EnhancedPacketOption::Comment("Generated TCP ACK".into())],
|
||||
);
|
||||
}
|
||||
Protocol::Udp => {
|
||||
let buf_size = {
|
||||
let mut udp = MutableUdpPacket::new(buf).unwrap();
|
||||
udp.set_source(source_port);
|
||||
udp.set_destination(dest_port);
|
||||
udp.set_length((payload.len() + HEADER_SIZE_UDP) as u16);
|
||||
udp.set_payload(payload);
|
||||
|
||||
udp.packet_size()
|
||||
};
|
||||
|
||||
self.write_transport_payload(
|
||||
info,
|
||||
IpNextHeaderProtocols::Udp,
|
||||
&buf[.. buf_size + payload.len()],
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a network layer (IP) packet with a payload.
|
||||
fn encode_ip_packet(
|
||||
&self,
|
||||
buf: &mut [u8],
|
||||
info: &CapturePacket,
|
||||
protocol: IpNextHeaderProtocol,
|
||||
payload: &[u8],
|
||||
) -> (usize, EtherType) {
|
||||
match info.ip_addr() {
|
||||
(IpAddr::V4(_), IpAddr::V4(_)) => {
|
||||
let (source, destination) = info.ipvt_by_direction();
|
||||
|
||||
let header_size = HEADER_SIZE_IP4 + (32 / 8);
|
||||
|
||||
let mut ip = MutableIpv4Packet::new(buf).unwrap();
|
||||
ip.set_version(4);
|
||||
ip.set_total_length((payload.len() + header_size) as u16);
|
||||
ip.set_next_level_protocol(protocol);
|
||||
// https://en.wikipedia.org/wiki/Internet_Protocol_version_4#Total_Length
|
||||
|
||||
ip.set_header_length((header_size / 4) as u8);
|
||||
ip.set_source(source);
|
||||
ip.set_destination(destination);
|
||||
ip.set_payload(payload);
|
||||
ip.set_ttl(64);
|
||||
ip.set_flags(pnet_packet::ipv4::Ipv4Flags::DontFragment);
|
||||
|
||||
let mut options_writer =
|
||||
pnet_packet::ipv4::MutableIpv4OptionPacket::new(ip.get_options_raw_mut()).unwrap();
|
||||
options_writer.set_copied(1);
|
||||
options_writer.set_class(0);
|
||||
options_writer.set_number(pnet_packet::ipv4::Ipv4OptionNumbers::SID);
|
||||
options_writer.set_length(&[4]);
|
||||
options_writer.set_data(&(self.state.stream_count as u16).to_be_bytes());
|
||||
|
||||
ip.set_checksum(pnet_packet::ipv4::checksum(&ip.to_immutable()));
|
||||
|
||||
(ip.packet_size(), pnet_packet::ethernet::EtherTypes::Ipv4)
|
||||
}
|
||||
(IpAddr::V6(_), IpAddr::V6(_)) => {
|
||||
let (source, destination) = info.ipvt_by_direction();
|
||||
|
||||
let mut ip = MutableIpv6Packet::new(buf).unwrap();
|
||||
ip.set_version(6);
|
||||
ip.set_payload_length(payload.len() as u16);
|
||||
ip.set_next_header(protocol);
|
||||
ip.set_source(source);
|
||||
ip.set_destination(destination);
|
||||
ip.set_hop_limit(64);
|
||||
ip.set_payload(payload);
|
||||
ip.set_flow_label(self.state.stream_count);
|
||||
|
||||
(ip.packet_size(), pnet_packet::ethernet::EtherTypes::Ipv6)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a physical layer (ethernet) packet with a payload.
|
||||
fn encode_ethernet_packet(
|
||||
&self,
|
||||
buf: &mut [u8],
|
||||
ethertype: pnet_packet::ethernet::EtherType,
|
||||
payload: &[u8],
|
||||
) -> usize {
|
||||
let mut ethernet = MutableEthernetPacket::new(buf).unwrap();
|
||||
ethernet.set_ethertype(ethertype);
|
||||
ethernet.set_payload(payload);
|
||||
|
||||
ethernet.packet_size()
|
||||
}
|
||||
|
||||
/// Write a TCP handshake.
|
||||
pub(crate) fn write_tcp_handshake(&mut self, info: &CapturePacket) {
|
||||
let (source_port, dest_port) = (info.local_address.port(), info.remote_address.port());
|
||||
|
||||
let mut info = info.clone();
|
||||
info.direction = Direction::Send;
|
||||
let mut buffer_array: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
|
||||
let buf: &mut [u8] = &mut buffer_array[..];
|
||||
// Add a generated comment to all packets
|
||||
let options = vec![
|
||||
pcap_file::pcapng::blocks::enhanced_packet::EnhancedPacketOption::Comment("Generated TCP handshake".into()),
|
||||
];
|
||||
|
||||
// SYN
|
||||
let buf_size = {
|
||||
let mut tcp = MutableTcpPacket::new(buf).unwrap();
|
||||
self.state.send_seq = 500;
|
||||
tcp.set_sequence(self.state.send_seq);
|
||||
tcp.set_flags(TcpFlags::SYN);
|
||||
tcp.set_source(source_port);
|
||||
tcp.set_destination(dest_port);
|
||||
tcp.set_window(43440);
|
||||
tcp.set_data_offset(5);
|
||||
|
||||
tcp.packet_size()
|
||||
};
|
||||
self.write_transport_payload(
|
||||
&info,
|
||||
IpNextHeaderProtocols::Tcp,
|
||||
&buf[.. buf_size],
|
||||
options.clone(),
|
||||
);
|
||||
|
||||
// SYN + ACK
|
||||
info.direction = Direction::Receive;
|
||||
let buf_size = {
|
||||
let mut tcp = MutableTcpPacket::new(buf).unwrap();
|
||||
self.state.send_seq = self.state.send_seq.wrapping_add(1);
|
||||
tcp.set_acknowledgement(self.state.send_seq);
|
||||
self.state.rec_seq = 1000;
|
||||
tcp.set_sequence(self.state.rec_seq);
|
||||
tcp.set_flags(TcpFlags::SYN | TcpFlags::ACK);
|
||||
tcp.set_source(dest_port);
|
||||
tcp.set_destination(source_port);
|
||||
tcp.set_window(43440);
|
||||
tcp.set_data_offset(5);
|
||||
|
||||
tcp.packet_size()
|
||||
};
|
||||
self.write_transport_payload(
|
||||
&info,
|
||||
IpNextHeaderProtocols::Tcp,
|
||||
&buf[.. buf_size],
|
||||
options.clone(),
|
||||
);
|
||||
|
||||
// ACK
|
||||
info.direction = Direction::Send;
|
||||
let buf_size = {
|
||||
let mut tcp = MutableTcpPacket::new(buf).unwrap();
|
||||
tcp.set_sequence(self.state.send_seq);
|
||||
self.state.rec_seq = self.state.rec_seq.wrapping_add(1);
|
||||
tcp.set_acknowledgement(self.state.rec_seq);
|
||||
tcp.set_flags(TcpFlags::ACK);
|
||||
tcp.set_source(source_port);
|
||||
tcp.set_destination(dest_port);
|
||||
tcp.set_window(43440);
|
||||
tcp.set_data_offset(5);
|
||||
|
||||
tcp.packet_size()
|
||||
};
|
||||
self.write_transport_payload(
|
||||
&info,
|
||||
IpNextHeaderProtocols::Tcp,
|
||||
&buf[.. buf_size],
|
||||
options,
|
||||
);
|
||||
|
||||
self.state.has_sent_handshake = true;
|
||||
}
|
||||
|
||||
pub(crate) fn send_tcp_fin(&mut self, info: &CapturePacket) {
|
||||
let mut buffer_array: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
|
||||
let buf: &mut [u8] = &mut buffer_array[..];
|
||||
let (source_port, dest_port) = info.ports_by_direction();
|
||||
|
||||
let buf_size = {
|
||||
let mut tcp = MutableTcpPacket::new(buf).unwrap();
|
||||
tcp.set_source(source_port);
|
||||
tcp.set_destination(dest_port);
|
||||
tcp.set_data_offset(5);
|
||||
tcp.set_window(43440);
|
||||
|
||||
match info.direction {
|
||||
Direction::Send => {
|
||||
tcp.set_sequence(self.state.send_seq);
|
||||
tcp.set_acknowledgement(self.state.rec_seq);
|
||||
}
|
||||
Direction::Receive => {
|
||||
tcp.set_sequence(self.state.rec_seq);
|
||||
tcp.set_acknowledgement(self.state.send_seq);
|
||||
}
|
||||
}
|
||||
|
||||
tcp.set_flags(TcpFlags::FIN | TcpFlags::ACK);
|
||||
tcp.packet_size()
|
||||
};
|
||||
|
||||
self.write_transport_payload(
|
||||
info,
|
||||
IpNextHeaderProtocols::Tcp,
|
||||
&buf[.. buf_size],
|
||||
vec![EnhancedPacketOption::Comment("Generated TCP FIN".into())],
|
||||
);
|
||||
|
||||
// Update sequence number
|
||||
match info.direction {
|
||||
Direction::Send => {
|
||||
self.state.send_seq = self.state.send_seq.wrapping_add(1);
|
||||
}
|
||||
Direction::Receive => {
|
||||
self.state.rec_seq = self.state.rec_seq.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_transport_payload(
|
||||
&mut self,
|
||||
info: &CapturePacket,
|
||||
protocol: IpNextHeaderProtocol,
|
||||
payload: &[u8],
|
||||
options: Vec<pcap_file::pcapng::blocks::enhanced_packet::EnhancedPacketOption>,
|
||||
) {
|
||||
let mut network_packet = vec![0; PACKET_SIZE - HEADER_SIZE_ETHERNET];
|
||||
let (network_size, ethertype) = self.encode_ip_packet(&mut network_packet, info, protocol, payload);
|
||||
let network_size = network_size + payload.len();
|
||||
network_packet.truncate(network_size);
|
||||
|
||||
let mut physical_packet = vec![0; PACKET_SIZE];
|
||||
let physical_size =
|
||||
self.encode_ethernet_packet(&mut physical_packet, ethertype, &network_packet) + network_size;
|
||||
|
||||
physical_packet.truncate(physical_size);
|
||||
|
||||
self.writer
|
||||
.write_block(
|
||||
&pcap_file::pcapng::blocks::enhanced_packet::EnhancedPacketBlock {
|
||||
original_len: physical_size as u32,
|
||||
data: physical_packet.into(),
|
||||
interface_id: 0,
|
||||
timestamp: self.state.start_time.elapsed(),
|
||||
options,
|
||||
}
|
||||
.into_block(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
start_time: Instant::now(),
|
||||
send_seq: 0,
|
||||
rec_seq: 0,
|
||||
has_sent_handshake: false,
|
||||
stream_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
214
crates/lib/src/capture/socket.rs
Normal file
214
crates/lib/src/capture/socket.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
use std::{marker::PhantomData, net::SocketAddr};
|
||||
|
||||
use crate::{
|
||||
capture::{
|
||||
packet::CapturePacket,
|
||||
packet::{Direction, Protocol},
|
||||
writer::{Writer, CAPTURE_WRITER},
|
||||
},
|
||||
protocols::types::TimeoutSettings,
|
||||
socket::{Socket, TcpSocketImpl, UdpSocketImpl},
|
||||
GDResult,
|
||||
};
|
||||
|
||||
/// Sets a global capture writer for handling all packet data.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if a capture writer is already set.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `writer` - A boxed writer that implements the `Writer` trait.
|
||||
pub(crate) fn set_writer(writer: Box<dyn Writer + Send + Sync>) {
|
||||
let mut lock = CAPTURE_WRITER.lock().unwrap();
|
||||
|
||||
if lock.is_some() {
|
||||
panic!("Capture writer already set");
|
||||
}
|
||||
|
||||
*lock = Some(writer);
|
||||
}
|
||||
|
||||
/// A trait representing a provider of a network protocol.
|
||||
pub(crate) trait ProtocolProvider {
|
||||
/// Returns the protocol used by the provider.
|
||||
fn protocol() -> Protocol;
|
||||
}
|
||||
|
||||
/// Represents the TCP protocol provider.
|
||||
pub(crate) struct ProtocolTCP;
|
||||
impl ProtocolProvider for ProtocolTCP {
|
||||
fn protocol() -> Protocol { Protocol::Tcp }
|
||||
}
|
||||
|
||||
/// Represents the UDP protocol provider.
|
||||
pub(crate) struct ProtocolUDP;
|
||||
impl ProtocolProvider for ProtocolUDP {
|
||||
fn protocol() -> Protocol { Protocol::Udp }
|
||||
}
|
||||
|
||||
/// A socket wrapper that allows capturing packets.
|
||||
///
|
||||
/// # Type parameters
|
||||
/// * `I` - The inner socket type.
|
||||
/// * `P` - The protocol provider.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct WrappedCaptureSocket<I: Socket, P: ProtocolProvider> {
|
||||
inner: I,
|
||||
remote_address: SocketAddr,
|
||||
_protocol: PhantomData<P>,
|
||||
}
|
||||
|
||||
impl<I: Socket, P: ProtocolProvider> Socket for WrappedCaptureSocket<I, P> {
|
||||
/// Creates a new wrapped socket for capturing packets.
|
||||
///
|
||||
/// Initializes a new socket of type `I`, wrapping it to enable packet
|
||||
/// capturing. Capturing is protocol-specific, as indicated by
|
||||
/// the `ProtocolProvider`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `address` - The address to connect the socket to.
|
||||
/// * `timeout_settings` - Optional timeout settings for the socket.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `GDResult` containing either the wrapped socket or an error.
|
||||
fn new(address: &SocketAddr, timeout_settings: &Option<TimeoutSettings>) -> GDResult<Self>
|
||||
where Self: Sized {
|
||||
let v = Self {
|
||||
inner: I::new(address, timeout_settings)?,
|
||||
remote_address: *address,
|
||||
_protocol: PhantomData,
|
||||
};
|
||||
|
||||
let info = CapturePacket {
|
||||
direction: Direction::Send,
|
||||
protocol: P::protocol(),
|
||||
remote_address: address,
|
||||
local_address: &v.local_addr().unwrap(),
|
||||
};
|
||||
|
||||
if let Some(writer) = CAPTURE_WRITER.lock().unwrap().as_mut() {
|
||||
writer.new_connect(&info)?;
|
||||
}
|
||||
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Sends data over the socket and captures the packet.
|
||||
///
|
||||
/// The method sends data using the inner socket and captures the sent
|
||||
/// packet if a capture writer is set.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Data to be sent.
|
||||
///
|
||||
/// # Returns
|
||||
/// A result indicating success or error in sending data.
|
||||
fn send(&mut self, data: &[u8]) -> GDResult<()> {
|
||||
let info = CapturePacket {
|
||||
direction: Direction::Send,
|
||||
protocol: P::protocol(),
|
||||
remote_address: &self.remote_address,
|
||||
local_address: &self.local_addr().unwrap(),
|
||||
};
|
||||
|
||||
if let Some(writer) = CAPTURE_WRITER.lock().unwrap().as_mut() {
|
||||
writer.write(&info, data)?;
|
||||
}
|
||||
|
||||
self.inner.send(data)
|
||||
}
|
||||
|
||||
/// Receives data from the socket and captures the packet.
|
||||
///
|
||||
/// The method receives data using the inner socket and captures the
|
||||
/// incoming packet if a capture writer is set.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `size` - Optional size of data to receive.
|
||||
///
|
||||
/// # Returns
|
||||
/// A result containing received data or an error.
|
||||
fn receive(&mut self, size: Option<usize>) -> crate::GDResult<Vec<u8>> {
|
||||
let data = self.inner.receive(size)?;
|
||||
let info = CapturePacket {
|
||||
direction: Direction::Receive,
|
||||
protocol: P::protocol(),
|
||||
remote_address: &self.remote_address,
|
||||
local_address: &self.local_addr().unwrap(),
|
||||
};
|
||||
|
||||
if let Some(writer) = CAPTURE_WRITER.lock().unwrap().as_mut() {
|
||||
writer.write(&info, &data)?;
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Applies timeout settings to the wrapped socket.
|
||||
///
|
||||
/// Delegates the operation to the inner socket implementation.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `timeout_settings` - Optional timeout settings to apply.
|
||||
///
|
||||
/// # Returns
|
||||
/// A result indicating success or error in applying timeouts.
|
||||
fn apply_timeout(
|
||||
&self,
|
||||
timeout_settings: &Option<crate::protocols::types::TimeoutSettings>,
|
||||
) -> crate::GDResult<()> {
|
||||
self.inner.apply_timeout(timeout_settings)
|
||||
}
|
||||
|
||||
/// Returns the remote port of the wrapped socket.
|
||||
///
|
||||
/// Delegates the operation to the inner socket implementation.
|
||||
///
|
||||
/// # Returns
|
||||
/// The remote port number.
|
||||
fn port(&self) -> u16 { self.inner.port() }
|
||||
|
||||
/// Returns the local SocketAddr of the wrapped socket.
|
||||
///
|
||||
/// Delegates the operation to the inner socket implementation.
|
||||
///
|
||||
/// # Returns
|
||||
/// The local SocketAddr.
|
||||
fn local_addr(&self) -> std::io::Result<SocketAddr> { self.inner.local_addr() }
|
||||
}
|
||||
|
||||
// this seems a bad way to do this, but its safe
|
||||
impl<I: Socket, P: ProtocolProvider> Drop for WrappedCaptureSocket<I, P> {
|
||||
fn drop(&mut self) {
|
||||
// Construct the CapturePacket info
|
||||
let info = CapturePacket {
|
||||
direction: Direction::Send,
|
||||
protocol: P::protocol(),
|
||||
remote_address: &self.remote_address,
|
||||
local_address: &self
|
||||
.local_addr()
|
||||
.unwrap_or_else(|_| SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)),
|
||||
};
|
||||
|
||||
// If a capture writer is set, close the connection and capture the packet.
|
||||
if let Some(writer) = CAPTURE_WRITER.lock().unwrap().as_mut() {
|
||||
let _ = writer.close_connection(&info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A specialized `WrappedCaptureSocket` for UDP, using `UdpSocketImpl` as
|
||||
/// the inner socket and `ProtocolUDP` as the protocol provider.
|
||||
///
|
||||
/// This type captures and processes UDP packets, wrapping around standard
|
||||
/// UDP socket functionalities with additional packet capture
|
||||
/// capabilities.
|
||||
pub(crate) type CapturedUdpSocket = WrappedCaptureSocket<UdpSocketImpl, ProtocolUDP>;
|
||||
|
||||
/// A specialized `WrappedCaptureSocket` for TCP, using `TcpSocketImpl` as
|
||||
/// the inner socket and `ProtocolTCP` as the protocol provider.
|
||||
///
|
||||
/// This type captures and processes TCP packets, wrapping around standard
|
||||
/// TCP socket functionalities with additional packet capture
|
||||
/// capabilities.
|
||||
pub(crate) type CapturedTcpSocket = WrappedCaptureSocket<TcpSocketImpl, ProtocolTCP>;
|
||||
86
crates/lib/src/capture/writer.rs
Normal file
86
crates/lib/src/capture/writer.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use std::{io::Write, sync::Mutex};
|
||||
|
||||
use super::{
|
||||
packet::{CapturePacket, Protocol},
|
||||
pcap::Pcap,
|
||||
};
|
||||
use crate::GDResult;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
/// A globally accessible, lazily-initialized static writer instance.
|
||||
/// This writer is intended for capturing and recording network packets.
|
||||
/// The writer is wrapped in a Mutex to ensure thread-safe access and modification.
|
||||
pub(crate) static ref CAPTURE_WRITER: Mutex<Option<Box<dyn Writer + Send + Sync>>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
/// Trait defining the functionality for a writer that handles network packet
|
||||
/// captures. This trait includes methods for writing packet data, handling new
|
||||
/// connections, and closing connections.
|
||||
pub(crate) trait Writer {
|
||||
/// Writes a given packet's data to an underlying storage or stream.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `packet` - Reference to the packet being captured.
|
||||
/// * `data` - The raw byte data associated with the packet.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `GDResult` indicating the success or failure of the write operation.
|
||||
fn write(&mut self, packet: &CapturePacket, data: &[u8]) -> GDResult<()>;
|
||||
|
||||
/// Handles the creation of a new connection, potentially logging or
|
||||
/// initializing resources.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `packet` - Reference to the packet indicating a new connection.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `GDResult` indicating the success or failure of handling the new
|
||||
/// connection.
|
||||
fn new_connect(&mut self, packet: &CapturePacket) -> GDResult<()>;
|
||||
|
||||
/// Closes a connection, handling any necessary cleanup or finalization.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `packet` - Reference to the packet indicating the closure of a
|
||||
/// connection.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `GDResult` indicating the success or failure of the connection closure
|
||||
/// operation.
|
||||
fn close_connection(&mut self, packet: &CapturePacket) -> GDResult<()>;
|
||||
}
|
||||
|
||||
/// Implementation of the `Writer` trait for the `Pcap` struct.
|
||||
/// This implementation enables writing, connection handling, and closure
|
||||
/// specific to PCAP (Packet Capture) format.
|
||||
impl<W: Write> Writer for Pcap<W> {
|
||||
fn write(&mut self, info: &CapturePacket, data: &[u8]) -> GDResult<()> {
|
||||
self.write_transport_packet(info, data);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_connect(&mut self, packet: &CapturePacket) -> GDResult<()> {
|
||||
match packet.protocol {
|
||||
Protocol::Tcp => {
|
||||
self.write_tcp_handshake(packet);
|
||||
}
|
||||
Protocol::Udp => {}
|
||||
}
|
||||
|
||||
self.state.stream_count = self.state.stream_count.wrapping_add(1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close_connection(&mut self, packet: &CapturePacket) -> GDResult<()> {
|
||||
match packet.protocol {
|
||||
Protocol::Tcp => {
|
||||
self.send_tcp_fin(packet);
|
||||
}
|
||||
Protocol::Udp => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
143
crates/lib/src/errors/error.rs
Normal file
143
crates/lib/src/errors/error.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
use crate::GDErrorKind;
|
||||
use std::error::Error;
|
||||
use std::fmt::Formatter;
|
||||
use std::{backtrace, fmt};
|
||||
|
||||
pub(crate) type ErrorSource = Box<dyn Error + 'static + Send + Sync>;
|
||||
|
||||
/// The GameDig error type.
|
||||
///
|
||||
/// Can be created in three ways (all of which will implicitly generate a
|
||||
/// backtrace):
|
||||
///
|
||||
/// Directly from an [error kind](GDErrorKind) (without a
|
||||
/// source).
|
||||
///
|
||||
/// ```
|
||||
/// use gamedig::{GDError, GDErrorKind};
|
||||
/// let _: GDError = GDErrorKind::PacketBad.into();
|
||||
/// ```
|
||||
///
|
||||
/// [From an error kind with a source](GDErrorKind::context) (any
|
||||
/// type that implements `Into<Box<dyn std::error::Error + 'static>>`).
|
||||
///
|
||||
/// ```
|
||||
/// use gamedig::{GDError, GDErrorKind};
|
||||
/// let _: GDError = GDErrorKind::PacketBad.context("Reason the packet was bad");
|
||||
/// ```
|
||||
///
|
||||
/// Using the [new helper](GDError::new).
|
||||
///
|
||||
/// ```
|
||||
/// use gamedig::{GDError, GDErrorKind};
|
||||
/// let _: GDError = GDError::new(GDErrorKind::PacketBad, Some("Reason the packet was bad".into()));
|
||||
/// ```
|
||||
pub struct GDError {
|
||||
pub kind: GDErrorKind,
|
||||
pub source: Option<ErrorSource>,
|
||||
pub backtrace: Option<backtrace::Backtrace>,
|
||||
}
|
||||
|
||||
impl From<GDErrorKind> for GDError {
|
||||
fn from(value: GDErrorKind) -> Self {
|
||||
let backtrace = Some(backtrace::Backtrace::capture());
|
||||
Self {
|
||||
kind: value,
|
||||
source: None,
|
||||
backtrace,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for GDError {
|
||||
fn eq(&self, other: &Self) -> bool { self.kind == other.kind }
|
||||
}
|
||||
|
||||
impl Error for GDError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> { self.source.as_ref().map(|err| Box::as_ref(err) as _) }
|
||||
}
|
||||
|
||||
impl fmt::Debug for GDError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
writeln!(f, "GDError{{ kind={:?}", self.kind)?;
|
||||
if let Some(source) = &self.source {
|
||||
writeln!(f, " source={source:?}")?;
|
||||
}
|
||||
if let Some(backtrace) = &self.backtrace {
|
||||
let bt = format!("{backtrace:#?}");
|
||||
writeln!(f, " backtrace={}", bt.replace('\n', "\n "))?;
|
||||
}
|
||||
writeln!(f, "}}")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GDError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{self:?}") }
|
||||
}
|
||||
|
||||
impl GDError {
|
||||
/// Create a new error (with automatic backtrace)
|
||||
pub fn new(kind: GDErrorKind, source: Option<ErrorSource>) -> Self {
|
||||
let backtrace = Some(backtrace::Backtrace::capture());
|
||||
Self {
|
||||
kind,
|
||||
source,
|
||||
backtrace,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new error using any type that can be converted to an error
|
||||
pub fn from_error<E: Into<ErrorSource>>(kind: GDErrorKind, source: E) -> Self {
|
||||
Self::new(kind, Some(source.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// test error trait GDError
|
||||
#[test]
|
||||
fn test_error_trait() {
|
||||
let source: Result<u32, _> = "nan".parse();
|
||||
let source_err = source.unwrap_err();
|
||||
|
||||
let error_with_context = GDErrorKind::TypeParse.context(source_err.clone());
|
||||
assert!(error_with_context.source().is_some());
|
||||
assert_eq!(
|
||||
format!("{}", error_with_context.source().unwrap()),
|
||||
format!("{source_err}")
|
||||
);
|
||||
|
||||
let error_without_context: GDError = GDErrorKind::TypeParse.into();
|
||||
assert!(error_without_context.source().is_none());
|
||||
}
|
||||
|
||||
// Test creating GDError with GDError::new
|
||||
#[test]
|
||||
fn test_create_new() {
|
||||
let error_from_new = GDError::new(GDErrorKind::InvalidInput, None);
|
||||
assert!(error_from_new.backtrace.is_some());
|
||||
assert_eq!(error_from_new.kind, GDErrorKind::InvalidInput);
|
||||
assert!(error_from_new.source.is_none());
|
||||
}
|
||||
|
||||
// Test creating GDError with GDErrorKind::context
|
||||
#[test]
|
||||
fn test_create_context() {
|
||||
let error_from_context = GDErrorKind::InvalidInput.context("test");
|
||||
assert!(error_from_context.backtrace.is_some());
|
||||
assert_eq!(error_from_context.kind, GDErrorKind::InvalidInput);
|
||||
assert!(error_from_context.source.is_some());
|
||||
}
|
||||
|
||||
// Test creating GDError with From<GDErrorKind> for GDError
|
||||
#[test]
|
||||
fn test_create_into() {
|
||||
let error_from_into: GDError = GDErrorKind::InvalidInput.into();
|
||||
assert!(error_from_into.backtrace.is_some());
|
||||
assert_eq!(error_from_into.kind, GDErrorKind::InvalidInput);
|
||||
assert!(error_from_into.source.is_none());
|
||||
}
|
||||
}
|
||||
75
crates/lib/src/errors/kind.rs
Normal file
75
crates/lib/src/errors/kind.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use crate::error::ErrorSource;
|
||||
use crate::GDError;
|
||||
|
||||
/// All GameDig Error kinds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GDErrorKind {
|
||||
/// The received packet was bigger than the buffer size.
|
||||
PacketOverflow,
|
||||
/// The received packet was shorter than the expected one.
|
||||
PacketUnderflow,
|
||||
/// The received packet is badly formatted.
|
||||
PacketBad,
|
||||
/// Couldn't send the packet.
|
||||
PacketSend,
|
||||
/// Couldn't receieve data when it was expected.
|
||||
PacketReceive,
|
||||
/// Couldn't decompress data.
|
||||
Decompress,
|
||||
/// Couldn't create a socket connection.
|
||||
SocketConnect,
|
||||
/// Couldn't bind a socket.
|
||||
SocketBind,
|
||||
/// Invalid input to the library.
|
||||
InvalidInput,
|
||||
/// The server response indicated that it is a different game than the game
|
||||
/// queried.
|
||||
BadGame,
|
||||
/// Couldn't automatically query (none of the attempted protocols were
|
||||
/// successful).
|
||||
AutoQuery,
|
||||
/// A protocol-defined expected format was not met.
|
||||
ProtocolFormat,
|
||||
/// Couldn't cast a value to an enum.
|
||||
UnknownEnumCast,
|
||||
/// Couldn't parse a json string.
|
||||
JsonParse,
|
||||
/// Couldn't parse a value.
|
||||
TypeParse,
|
||||
/// Couldn't find the host specified.
|
||||
HostLookup,
|
||||
}
|
||||
|
||||
impl GDErrorKind {
|
||||
/// Convert error kind into a full error with a source (and implicit
|
||||
/// backtrace)
|
||||
///
|
||||
/// ```
|
||||
/// use gamedig::{GDErrorKind, GDResult};
|
||||
/// let _: GDResult<u32> = "thing".parse().map_err(|e| GDErrorKind::TypeParse.context(e));
|
||||
/// ```
|
||||
pub fn context<E: Into<ErrorSource>>(self, source: E) -> GDError { GDError::from_error(self, source) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Testing cloning the GDErrorKind type
|
||||
#[test]
|
||||
fn test_cloning() {
|
||||
let error = GDErrorKind::BadGame;
|
||||
let cloned_error = error.clone();
|
||||
assert_eq!(error, cloned_error);
|
||||
}
|
||||
|
||||
// test display GDError
|
||||
#[test]
|
||||
fn test_display() {
|
||||
let err = GDErrorKind::BadGame.context("Rust is not a game");
|
||||
assert_eq!(
|
||||
format!("{err}"),
|
||||
"GDError{ kind=BadGame\n source=\"Rust is not a game\"\n backtrace=<disabled>\n}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
12
crates/lib/src/errors/mod.rs
Normal file
12
crates/lib/src/errors/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! Every GameDig errors.
|
||||
|
||||
/// The Error with backtrace.
|
||||
pub mod error;
|
||||
/// All defined Error kinds.
|
||||
pub mod kind;
|
||||
/// `GDResult`, a shorthand of `Result<T, GDError>`.
|
||||
pub mod result;
|
||||
|
||||
pub use error::*;
|
||||
pub use kind::*;
|
||||
pub use result::*;
|
||||
24
crates/lib/src/errors/result.rs
Normal file
24
crates/lib/src/errors/result.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
use crate::GDError;
|
||||
|
||||
/// `Result` of `T` and `GDError`.
|
||||
pub type GDResult<T> = Result<T, GDError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::GDErrorKind;
|
||||
|
||||
// Testing Ok variant of the GDResult type
|
||||
#[test]
|
||||
fn test_gdresult_ok() {
|
||||
let result: GDResult<u32> = Ok(42);
|
||||
assert_eq!(result, Ok(42));
|
||||
}
|
||||
|
||||
// Testing Err variant of the GDResult type
|
||||
#[test]
|
||||
fn test_gdresult_err() {
|
||||
let result: GDResult<u32> = Err(GDErrorKind::InvalidInput.into());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
47
crates/lib/src/games/battalion1944.rs
Normal file
47
crates/lib/src/games/battalion1944.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use crate::protocols::valve::Engine;
|
||||
use crate::{
|
||||
protocols::valve::{self, game},
|
||||
GDErrorKind::TypeParse,
|
||||
GDResult,
|
||||
};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
pub fn query(address: &IpAddr, port: Option<u16>) -> GDResult<game::Response> {
|
||||
let mut valve_response = valve::query(
|
||||
&SocketAddr::new(*address, port.unwrap_or(7780)),
|
||||
Engine::new(489_940),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
if let Some(rules) = &mut valve_response.rules {
|
||||
if let Some(bat_max_players) = rules.get("bat_max_players_i") {
|
||||
valve_response.info.players_maximum = bat_max_players.parse().map_err(|e| TypeParse.context(e))?;
|
||||
rules.remove("bat_max_players_i");
|
||||
}
|
||||
|
||||
if let Some(bat_player_count) = rules.get("bat_player_count_s") {
|
||||
valve_response.info.players_online = bat_player_count.parse().map_err(|e| TypeParse.context(e))?;
|
||||
rules.remove("bat_player_count_s");
|
||||
}
|
||||
|
||||
if let Some(bat_has_password) = rules.get("bat_has_password_s") {
|
||||
valve_response.info.has_password = bat_has_password == "Y";
|
||||
rules.remove("bat_has_password_s");
|
||||
}
|
||||
|
||||
if let Some(bat_name) = rules.get("bat_name_s") {
|
||||
valve_response.info.name.clone_from(bat_name);
|
||||
rules.remove("bat_name_s");
|
||||
}
|
||||
|
||||
if let Some(bat_gamemode) = rules.get("bat_gamemode_s") {
|
||||
valve_response.info.game_mode.clone_from(bat_gamemode);
|
||||
rules.remove("bat_gamemode_s");
|
||||
}
|
||||
|
||||
rules.remove("bat_map_s");
|
||||
}
|
||||
|
||||
Ok(game::Response::new_from_valve_response(valve_response))
|
||||
}
|
||||
160
crates/lib/src/games/definitions.rs
Normal file
160
crates/lib/src/games/definitions.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! Static definitions of currently supported games
|
||||
|
||||
use crate::games::minecraft::types::{LegacyGroup, Server};
|
||||
use crate::protocols::{gamespy::GameSpyVersion, quake::QuakeVersion, valve::Engine, Protocol};
|
||||
use crate::Game;
|
||||
|
||||
use crate::protocols::types::{GatherToggle, ProprietaryProtocol};
|
||||
use crate::protocols::valve::GatheringSettings;
|
||||
use phf::{phf_map, Map};
|
||||
|
||||
macro_rules! game {
|
||||
($name: literal, $default_port: expr, $protocol: expr) => {
|
||||
game!(
|
||||
$name,
|
||||
$default_port,
|
||||
$protocol,
|
||||
GatheringSettings::default().into_extra()
|
||||
)
|
||||
};
|
||||
|
||||
($name: literal, $default_port: expr, $protocol: expr, $extra_request_settings: expr) => {
|
||||
Game {
|
||||
name: $name,
|
||||
default_port: $default_port,
|
||||
protocol: $protocol,
|
||||
request_settings: $extra_request_settings,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Map of all currently supported games
|
||||
pub static GAMES: Map<&'static str, Game> = phf_map! {
|
||||
// Query with all minecraft protocols
|
||||
"minecraft" => game!("Minecraft", 25565, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(None))),
|
||||
// Query with specific minecraft protocols
|
||||
"minecraftbedrock" => game!("Minecraft (bedrock)", 19132, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Bedrock)))),
|
||||
"minecraftpocket" => game!("Minecraft (pocket)", 19132, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Bedrock)))),
|
||||
"minecraftjava" => game!("Minecraft (java)", 25565, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Java)))),
|
||||
"minecraftlegacy16" => game!("Minecraft (legacy 1.6)", 25565, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Legacy(LegacyGroup::V1_6))))),
|
||||
"minecraftlegacy14" => game!("Minecraft (legacy 1.4)", 25565, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Legacy(LegacyGroup::V1_4))))),
|
||||
"minecraftlegacyb18" => game!("Minecraft (legacy b1.8)", 25565, Protocol::PROPRIETARY(ProprietaryProtocol::Minecraft(Some(Server::Legacy(LegacyGroup::VB1_8))))),
|
||||
"aapg" => game!("America's Army: Proving Grounds", 27020, Protocol::Valve(Engine::new(203_290)), GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Skip,
|
||||
check_app_id: true,
|
||||
}.into_extra()),
|
||||
"abioticfactor" => game!("Abiotic Factor", 27015, Protocol::Valve(Engine::new(427_410))),
|
||||
"alienswarm" => game!("Alien Swarm", 27015, Protocol::Valve(Engine::new(630))),
|
||||
"aoc" => game!("Age of Chivalry", 27015, Protocol::Valve(Engine::new(17510))),
|
||||
"a2oa" => game!("ARMA 2: Operation Arrowhead", 2304, Protocol::Valve(Engine::new(33930))),
|
||||
"arma3" => game!("ARMA 3", 2303, Protocol::Valve(Engine::new(107_410))),
|
||||
"ase" => game!("ARK: Survival Evolved", 27015, Protocol::Valve(Engine::new(346_110))),
|
||||
"asrd" => game!("Alien Swarm: Reactive Drop", 2304, Protocol::Valve(Engine::new(563_560))),
|
||||
"armareforger" => game!("Arma Reforger", 17777, Protocol::Valve(Engine::new(1_874_880)), GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Enforce,
|
||||
check_app_id: false,
|
||||
}.into_extra()),
|
||||
"atlas" => game!("ATLAS", 57561, Protocol::Valve(Engine::new(834_910))),
|
||||
"avorion" => game!("Avorion", 27020, Protocol::Valve(Engine::new(445_220))),
|
||||
"avp2010" => game!("Aliens vs. Predator 2010", 27015, Protocol::Valve(Engine::new(10_680))),
|
||||
"barotrauma" => game!("Barotrauma", 27016, Protocol::Valve(Engine::new(602_960))),
|
||||
"basedefense" => game!("Base Defense", 27015, Protocol::Valve(Engine::new(632_730)), GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Skip,
|
||||
check_app_id: true,
|
||||
}.into_extra()),
|
||||
"battalion1944" => game!("Battalion 1944", 7780, Protocol::Valve(Engine::new(489_940))),
|
||||
"brainbread2" => game!("BrainBread 2", 27015, Protocol::Valve(Engine::new(346_330))),
|
||||
"battlefield1942" => game!("Battlefield 1942", 23000, Protocol::Gamespy(GameSpyVersion::One)),
|
||||
"blackmesa" => game!("Black Mesa", 27015, Protocol::Valve(Engine::new(362_890))),
|
||||
"ballisticoverkill" => game!("Ballistic Overkill", 27016, Protocol::Valve(Engine::new(296_300))),
|
||||
"codbo3" => game!("Call Of Duty: Black Ops 3", 27017, Protocol::Valve(Engine::new(311_210))),
|
||||
"codenamecure" => game!("Codename CURE", 27015, Protocol::Valve(Engine::new(355_180))),
|
||||
"colonysurvival" => game!("Colony Survival", 27004, Protocol::Valve(Engine::new(366_090))),
|
||||
"conanexiles" => game!("Conan Exiles", 27015, Protocol::Valve(Engine::new(440_900)), GatheringSettings {
|
||||
players: GatherToggle::Skip,
|
||||
rules: GatherToggle::Enforce,
|
||||
check_app_id: true,
|
||||
}.into_extra()),
|
||||
"counterstrike" => game!("Counter-Strike", 27015, Protocol::Valve(Engine::new_gold_src(false))),
|
||||
"counterstrike2" => game!("Counter-Strike 2", 27015, Protocol::Valve(Engine::new(730))),
|
||||
"cscz" => game!("Counter Strike: Condition Zero", 27015, Protocol::Valve(Engine::new_gold_src(false))),
|
||||
"csgo" => game!("Counter-Strike: Global Offensive", 27015, Protocol::Valve(Engine::new(730))),
|
||||
"css" => game!("Counter-Strike: Source", 27015, Protocol::Valve(Engine::new(240))),
|
||||
"creativerse" => game!("Creativerse", 26901, Protocol::Valve(Engine::new(280_790))),
|
||||
"crysiswars" => game!("Crysis Wars", 64100, Protocol::Gamespy(GameSpyVersion::Three)),
|
||||
"dab" => game!("Double Action: Boogaloo", 27015, Protocol::Valve(Engine::new(317_360))),
|
||||
"dod" => game!("Day of Defeat", 27015, Protocol::Valve(Engine::new_gold_src(false))),
|
||||
"dods" => game!("Day of Defeat: Source", 27015, Protocol::Valve(Engine::new(300))),
|
||||
"doi" => game!("Day of Infamy", 27015, Protocol::Valve(Engine::new(447_820))),
|
||||
"dst" => game!("Don't Starve Together", 27016, Protocol::Valve(Engine::new(322_320))),
|
||||
"enshrouded" => game!("Enshrouded", 15637, Protocol::Valve(Engine::new(1_203_620))),
|
||||
"ffow" => game!("Frontlines: Fuel of War", 5478, Protocol::PROPRIETARY(ProprietaryProtocol::FFOW)),
|
||||
"garrysmod" => game!("Garry's Mod", 27016, Protocol::Valve(Engine::new(4000))),
|
||||
"hl2d" => game!("Half-Life 2 Deathmatch", 27015, Protocol::Valve(Engine::new(320))),
|
||||
"hce" => game!("Halo: Combat Evolved", 2302, Protocol::Gamespy(GameSpyVersion::Two)),
|
||||
"hlds" => game!("Half-Life Deathmatch: Source", 27015, Protocol::Valve(Engine::new(360))),
|
||||
"hll" => game!("Hell Let Loose", 26420, Protocol::Valve(Engine::new(686_810))),
|
||||
"insurgency" => game!("Insurgency", 27015, Protocol::Valve(Engine::new(222_880))),
|
||||
"imic" => game!("Insurgency: Modern Infantry Combat", 27015, Protocol::Valve(Engine::new(17700))),
|
||||
"insurgencysandstorm" => game!("Insurgency: Sandstorm", 27131, Protocol::Valve(Engine::new(581_320))),
|
||||
"l4d" => game!("Left 4 Dead", 27015, Protocol::Valve(Engine::new(500))),
|
||||
"l4d2" => game!("Left 4 Dead 2", 27015, Protocol::Valve(Engine::new(550))),
|
||||
"ohd" => game!("Operation: Harsh Doorstop", 27005, Protocol::Valve(Engine::new_with_dedicated(736_590, 950_900))),
|
||||
"onset" => game!("Onset", 7776, Protocol::Valve(Engine::new(1_105_810))),
|
||||
"pixark" => game!("PixARK", 27015, Protocol::Valve(Engine::new(593_600))),
|
||||
"postscriptum" => game!("Post Scriptum", 10037, Protocol::Valve(Engine::new(736_220))),
|
||||
"projectzomboid" => game!("Project Zomboid", 16261, Protocol::Valve(Engine::new(108_600))),
|
||||
"pvak2" => game!("Pirates, Vikings, and Knights II", 27015, Protocol::Valve(Engine::new(17_570))),
|
||||
"quake1" => game!("Quake 1", 27500, Protocol::Quake(QuakeVersion::One)),
|
||||
"quake2" => game!("Quake 2", 27910, Protocol::Quake(QuakeVersion::Two)),
|
||||
"q3a" => game!("Quake 3 Arena", 27960, Protocol::Quake(QuakeVersion::Three)),
|
||||
"risingworld" => game!("Rising World", 4254, Protocol::Valve(Engine::new(324_080)), GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Skip,
|
||||
check_app_id: true,
|
||||
}.into_extra()),
|
||||
"ror2" => game!("Risk of Rain 2", 27016, Protocol::Valve(Engine::new(632_360))),
|
||||
"rust" => game!("Rust", 27015, Protocol::Valve(Engine::new(252_490))),
|
||||
"savage2" => game!("Savage 2", 11235, Protocol::PROPRIETARY(ProprietaryProtocol::Savage2)),
|
||||
"sco" => game!("Sven Co-op", 27015, Protocol::Valve(Engine::new_gold_src(false))),
|
||||
"sdtd" => game!("7 Days to Die", 26900, Protocol::Valve(Engine::new(251_570))),
|
||||
"sof2" => game!("Soldier of Fortune 2", 20100, Protocol::Quake(QuakeVersion::Three)),
|
||||
"soulmask" => game!("Soulmask", 27015, Protocol::Valve(Engine::new(2_646_460))),
|
||||
"serioussam" => game!("Serious Sam", 25601, Protocol::Gamespy(GameSpyVersion::One)),
|
||||
"squad" => game!("Squad", 27165, Protocol::Valve(Engine::new(393_380))),
|
||||
"starbound" => game!("Starbound", 21025, Protocol::Valve(Engine::new(211_820)), GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Enforce,
|
||||
check_app_id: false,
|
||||
}.into_extra()),
|
||||
"theforest" => game!("The Forest", 27016, Protocol::Valve(Engine::new_with_dedicated(242_760, 556_450))),
|
||||
"thefront" => game!("The Front", 27015, Protocol::Valve(Engine::new(2_285_150))),
|
||||
"teamfortress2" => game!("Team Fortress 2", 27015, Protocol::Valve(Engine::new(440))),
|
||||
"tfc" => game!("Team Fortress Classic", 27015, Protocol::Valve(Engine::new_gold_src(false))),
|
||||
"theship" => game!("The Ship", 27015, Protocol::PROPRIETARY(ProprietaryProtocol::TheShip)),
|
||||
"unturned" => game!("Unturned", 27015, Protocol::Valve(Engine::new(304_930))),
|
||||
"unrealtournament" => game!("Unreal Tournament", 7778, Protocol::Gamespy(GameSpyVersion::One)),
|
||||
"valheim" => game!("Valheim", 2457, Protocol::Valve(Engine::new(892_970)), GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Skip,
|
||||
check_app_id: true,
|
||||
}.into_extra()),
|
||||
"vrising" => game!("V Rising", 27016, Protocol::Valve(Engine::new(1_604_030))),
|
||||
"jc2m" => game!("Just Cause 2: Multiplayer", 7777, Protocol::PROPRIETARY(ProprietaryProtocol::JC2M)),
|
||||
"warsow" => game!("Warsow", 44400, Protocol::Quake(QuakeVersion::Three)),
|
||||
"dhe4445" => game!("Darkest Hour: Europe '44-'45 (2008)", 7758, Protocol::Unreal2),
|
||||
"devastation" => game!("Devastation (2003)", 7778, Protocol::Unreal2),
|
||||
"killingfloor" => game!("Killing Floor", 7708, Protocol::Unreal2),
|
||||
"redorchestra" => game!("Red Orchestra", 7759, Protocol::Unreal2),
|
||||
"unrealtournament2003" => game!("Unreal Tournament 2003", 7758, Protocol::Unreal2),
|
||||
"unrealtournament2004" => game!("Unreal Tournament 2004", 7778, Protocol::Unreal2),
|
||||
"eco" => game!("Eco", 3000, Protocol::PROPRIETARY(ProprietaryProtocol::Eco)),
|
||||
"zps" => game!("Zombie Panic: Source", 27015, Protocol::Valve(Engine::new(17_500))),
|
||||
"moe" => game!("Myth Of Empires", 12888, Protocol::Valve(Engine::new(1_371_580))),
|
||||
"mordhau" => game!("Mordhau", 27015, Protocol::Valve(Engine::new(629_760))),
|
||||
"mindustry" => game!("Mindustry", crate::games::mindustry::DEFAULT_PORT, Protocol::PROPRIETARY(ProprietaryProtocol::Mindustry)),
|
||||
"nla" => game!("Nova-Life: Amboise", 27015, Protocol::Valve(Engine::new(885_570))),
|
||||
};
|
||||
8
crates/lib/src/games/eco/mod.rs
Normal file
8
crates/lib/src/games/eco/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// The implementation.
|
||||
/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/ffow.js)
|
||||
pub mod protocol;
|
||||
/// All types used by the implementation.
|
||||
pub mod types;
|
||||
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
37
crates/lib/src/games/eco/protocol.rs
Normal file
37
crates/lib/src/games/eco/protocol.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use crate::eco::{EcoRequestSettings, Response, Root};
|
||||
use crate::http::HttpClient;
|
||||
use crate::{GDResult, TimeoutSettings};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
/// Query an eco server.
|
||||
#[inline]
|
||||
pub fn query(address: &IpAddr, port: Option<u16>) -> GDResult<Response> { query_with_timeout(address, port, &None) }
|
||||
|
||||
/// Query an eco server.
|
||||
#[inline]
|
||||
pub fn query_with_timeout(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
) -> GDResult<Response> {
|
||||
query_with_timeout_and_extra_settings(address, port, timeout_settings, None)
|
||||
}
|
||||
|
||||
/// Query an eco server.
|
||||
pub fn query_with_timeout_and_extra_settings(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
extra_settings: Option<EcoRequestSettings>,
|
||||
) -> GDResult<Response> {
|
||||
let address = &SocketAddr::new(*address, port.unwrap_or(3001));
|
||||
let mut client = HttpClient::new(
|
||||
address,
|
||||
timeout_settings,
|
||||
extra_settings.unwrap_or_default().into(),
|
||||
)?;
|
||||
|
||||
let response = client.get_json::<Root>("/frontpage", None)?;
|
||||
|
||||
Ok(response.into())
|
||||
}
|
||||
241
crates/lib/src/games/eco/types.rs
Normal file
241
crates/lib/src/games/eco/types.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::http::{HttpProtocol, HttpSettings};
|
||||
use crate::protocols::types::{CommonPlayer, CommonResponse};
|
||||
use crate::ExtraRequestSettings;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Root {
|
||||
#[serde(rename = "Info")]
|
||||
pub info: Info,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Info {
|
||||
#[serde(rename = "External")]
|
||||
pub external: bool,
|
||||
#[serde(rename = "GamePort")]
|
||||
pub game_port: u32,
|
||||
#[serde(rename = "WebPort")]
|
||||
pub web_port: u32,
|
||||
#[serde(rename = "IsLAN")]
|
||||
pub is_lan: bool,
|
||||
#[serde(rename = "Description")]
|
||||
pub description: String,
|
||||
#[serde(rename = "DetailedDescription")]
|
||||
pub detailed_description: String,
|
||||
#[serde(rename = "Category")]
|
||||
pub category: String,
|
||||
#[serde(rename = "OnlinePlayers")]
|
||||
pub online_players: u32,
|
||||
#[serde(rename = "TotalPlayers")]
|
||||
pub total_players: u32,
|
||||
#[serde(rename = "OnlinePlayersNames")]
|
||||
pub online_players_names: Vec<String>,
|
||||
#[serde(rename = "AdminOnline")]
|
||||
pub admin_online: bool,
|
||||
#[serde(rename = "TimeSinceStart")]
|
||||
pub time_since_start: f64,
|
||||
#[serde(rename = "TimeLeft")]
|
||||
pub time_left: f64,
|
||||
#[serde(rename = "Animals")]
|
||||
pub animals: u32,
|
||||
#[serde(rename = "Plants")]
|
||||
pub plants: u32,
|
||||
#[serde(rename = "Laws")]
|
||||
pub laws: u32,
|
||||
#[serde(rename = "WorldSize")]
|
||||
pub world_size: String,
|
||||
#[serde(rename = "Version")]
|
||||
pub version: String,
|
||||
#[serde(rename = "EconomyDesc")]
|
||||
pub economy_desc: String,
|
||||
#[serde(rename = "SkillSpecializationSetting")]
|
||||
pub skill_specialization_setting: String,
|
||||
#[serde(rename = "Language")]
|
||||
pub language: String,
|
||||
#[serde(rename = "HasPassword")]
|
||||
pub has_password: bool,
|
||||
#[serde(rename = "HasMeteor")]
|
||||
pub has_meteor: bool,
|
||||
#[serde(rename = "DistributionStationItems")]
|
||||
pub distribution_station_items: String,
|
||||
#[serde(rename = "Playtimes")]
|
||||
pub playtimes: String,
|
||||
#[serde(rename = "DiscordAddress")]
|
||||
pub discord_address: String,
|
||||
#[serde(rename = "IsPaused")]
|
||||
pub is_paused: bool,
|
||||
#[serde(rename = "ActiveAndOnlinePlayers")]
|
||||
pub active_and_online_players: u32,
|
||||
#[serde(rename = "PeakActivePlayers")]
|
||||
pub peak_active_players: u32,
|
||||
#[serde(rename = "MaxActivePlayers")]
|
||||
pub max_active_players: u32,
|
||||
#[serde(rename = "ShelfLifeMultiplier")]
|
||||
pub shelf_life_multiplier: f64,
|
||||
#[serde(rename = "ExhaustionAfterHours")]
|
||||
pub exhaustion_after_hours: f64,
|
||||
#[serde(rename = "IsLimitingHours")]
|
||||
pub is_limiting_hours: bool,
|
||||
#[serde(rename = "ServerAchievementsDict")]
|
||||
pub server_achievements_dict: HashMap<String, String>,
|
||||
#[serde(rename = "RelayAddress")]
|
||||
pub relay_address: String,
|
||||
#[serde(rename = "Access")]
|
||||
pub access: String,
|
||||
#[serde(rename = "JoinUrl")]
|
||||
pub join_url: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Player {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl CommonPlayer for Player {
|
||||
fn as_original(&self) -> crate::protocols::types::GenericPlayer<'_> {
|
||||
crate::protocols::types::GenericPlayer::Eco(self)
|
||||
}
|
||||
|
||||
fn name(&self) -> &str { &self.name }
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Response {
|
||||
pub external: bool,
|
||||
pub port: u32,
|
||||
pub query_port: u32,
|
||||
pub is_lan: bool,
|
||||
pub description: String, // this and other fields require some text filtering
|
||||
pub description_detailed: String,
|
||||
pub description_economy: String,
|
||||
pub category: String,
|
||||
pub players_online: u32,
|
||||
pub players_maximum: u32,
|
||||
pub players: Vec<Player>,
|
||||
pub admin_online: bool,
|
||||
pub time_since_start: f64,
|
||||
pub time_left: f64,
|
||||
pub animals: u32,
|
||||
pub plants: u32,
|
||||
pub laws: u32,
|
||||
pub world_size: String,
|
||||
pub game_version: String,
|
||||
pub skill_specialization_setting: String,
|
||||
pub language: String,
|
||||
pub has_password: bool,
|
||||
pub has_meteor: bool,
|
||||
pub distribution_station_items: String,
|
||||
pub playtimes: String,
|
||||
pub discord_address: String,
|
||||
pub is_paused: bool,
|
||||
pub active_and_online_players: u32,
|
||||
pub peak_active_players: u32,
|
||||
pub max_active_players: u32,
|
||||
pub shelf_life_multiplier: f64,
|
||||
pub exhaustion_after_hours: f64,
|
||||
pub is_limiting_hours: bool,
|
||||
pub server_achievements_dict: HashMap<String, String>,
|
||||
pub relay_address: String,
|
||||
pub access: String,
|
||||
pub connect: String,
|
||||
}
|
||||
|
||||
impl From<Root> for Response {
|
||||
fn from(root: Root) -> Self {
|
||||
let value = root.info;
|
||||
Self {
|
||||
external: value.external,
|
||||
port: value.game_port,
|
||||
query_port: value.web_port,
|
||||
is_lan: value.is_lan,
|
||||
description: value.description,
|
||||
description_detailed: value.detailed_description,
|
||||
description_economy: value.economy_desc,
|
||||
category: value.category,
|
||||
players_online: value.online_players,
|
||||
players_maximum: value.total_players,
|
||||
players: value
|
||||
.online_players_names
|
||||
.iter()
|
||||
.map(|player| {
|
||||
Player {
|
||||
name: player.clone(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
admin_online: value.admin_online,
|
||||
time_since_start: value.time_since_start,
|
||||
time_left: value.time_left,
|
||||
animals: value.animals,
|
||||
plants: value.plants,
|
||||
laws: value.laws,
|
||||
world_size: value.world_size,
|
||||
game_version: value.version,
|
||||
skill_specialization_setting: value.skill_specialization_setting,
|
||||
language: value.language,
|
||||
has_password: value.has_password,
|
||||
has_meteor: value.has_meteor,
|
||||
distribution_station_items: value.distribution_station_items,
|
||||
playtimes: value.playtimes,
|
||||
discord_address: value.discord_address,
|
||||
is_paused: value.is_paused,
|
||||
active_and_online_players: value.active_and_online_players,
|
||||
peak_active_players: value.peak_active_players,
|
||||
max_active_players: value.max_active_players,
|
||||
shelf_life_multiplier: value.shelf_life_multiplier,
|
||||
exhaustion_after_hours: value.exhaustion_after_hours,
|
||||
is_limiting_hours: value.is_limiting_hours,
|
||||
server_achievements_dict: value.server_achievements_dict,
|
||||
relay_address: value.relay_address,
|
||||
access: value.access,
|
||||
connect: value.join_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonResponse for Response {
|
||||
fn as_original(&self) -> crate::protocols::GenericResponse<'_> { crate::protocols::GenericResponse::Eco(self) }
|
||||
|
||||
fn players_online(&self) -> u32 { self.players_online }
|
||||
|
||||
fn players_maximum(&self) -> u32 { self.players_maximum }
|
||||
|
||||
fn description(&self) -> Option<&str> { Some(&self.description) }
|
||||
|
||||
fn game_version(&self) -> Option<&str> { Some(&self.game_version) }
|
||||
|
||||
fn has_password(&self) -> Option<bool> { Some(self.has_password) }
|
||||
|
||||
fn players(&self) -> Option<Vec<&dyn CommonPlayer>> { Some(self.players.iter().map(|p| p as _).collect()) }
|
||||
}
|
||||
|
||||
/// Extra request settings for eco queries.
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq)]
|
||||
pub struct EcoRequestSettings {
|
||||
hostname: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ExtraRequestSettings> for EcoRequestSettings {
|
||||
fn from(value: ExtraRequestSettings) -> Self {
|
||||
Self {
|
||||
hostname: value.hostname,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EcoRequestSettings> for HttpSettings<String> {
|
||||
fn from(value: EcoRequestSettings) -> Self {
|
||||
Self {
|
||||
protocol: HttpProtocol::Http,
|
||||
hostname: value.hostname,
|
||||
headers: Vec::with_capacity(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
15
crates/lib/src/games/epic.rs
Normal file
15
crates/lib/src/games/epic.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! Unreal2 game query modules
|
||||
|
||||
use crate::protocols::epic::game_query_mod;
|
||||
|
||||
game_query_mod!(
|
||||
asa,
|
||||
"Ark: Survival Ascended",
|
||||
7777,
|
||||
Credentials {
|
||||
deployment: "ad9a8feffb3b4b2ca315546f038c3ae2",
|
||||
id: "xyza7891muomRmynIIHaJB9COBKkwj6n",
|
||||
secret: "PP5UGxysEieNfSrEicaD1N2Bb3TdXuD7xHYcsdUHZ7s",
|
||||
auth_by_external: false,
|
||||
}
|
||||
);
|
||||
8
crates/lib/src/games/ffow/mod.rs
Normal file
8
crates/lib/src/games/ffow/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// The implementation.
|
||||
/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/ffow.js)
|
||||
pub mod protocol;
|
||||
/// All types used by the implementation.
|
||||
pub mod types;
|
||||
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
66
crates/lib/src/games/ffow/protocol.rs
Normal file
66
crates/lib/src/games/ffow/protocol.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use crate::buffer::{Buffer, Utf8Decoder};
|
||||
use crate::games::ffow::types::Response;
|
||||
use crate::protocols::types::TimeoutSettings;
|
||||
use crate::protocols::valve::{Engine, Environment, Server, ValveProtocol};
|
||||
use crate::GDResult;
|
||||
use byteorder::LittleEndian;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
pub fn query(address: &IpAddr, port: Option<u16>) -> GDResult<Response> { query_with_timeout(address, port, None) }
|
||||
|
||||
pub fn query_with_timeout(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
) -> GDResult<Response> {
|
||||
let mut client = ValveProtocol::new(
|
||||
&SocketAddr::new(*address, port.unwrap_or(5478)),
|
||||
timeout_settings,
|
||||
)?;
|
||||
let data = client.get_request_data(
|
||||
&Engine::GoldSrc(true),
|
||||
0,
|
||||
0x46,
|
||||
String::from("LSQ").into_bytes(),
|
||||
)?;
|
||||
|
||||
let mut buffer = Buffer::<LittleEndian>::new(&data);
|
||||
|
||||
let protocol_version = buffer.read::<u8>()?;
|
||||
let name = buffer.read_string::<Utf8Decoder>(None)?;
|
||||
let map = buffer.read_string::<Utf8Decoder>(None)?;
|
||||
let active_mod = buffer.read_string::<Utf8Decoder>(None)?;
|
||||
let game_mode = buffer.read_string::<Utf8Decoder>(None)?;
|
||||
let description = buffer.read_string::<Utf8Decoder>(None)?;
|
||||
let game_version = buffer.read_string::<Utf8Decoder>(None)?;
|
||||
buffer.move_cursor(2)?;
|
||||
let players_online = buffer.read::<u8>()?;
|
||||
let players_maximum = buffer.read::<u8>()?;
|
||||
let server_type = Server::from_gldsrc(buffer.read::<u8>()?)?;
|
||||
let environment_type = Environment::from_gldsrc(buffer.read::<u8>()?)?;
|
||||
let has_password = buffer.read::<u8>()? == 1;
|
||||
let vac_secured = buffer.read::<u8>()? == 1;
|
||||
buffer.move_cursor(1)?; // average fps
|
||||
let round = buffer.read::<u8>()?;
|
||||
let rounds_maximum = buffer.read::<u8>()?;
|
||||
let time_left = buffer.read::<u16>()?;
|
||||
|
||||
Ok(Response {
|
||||
protocol_version,
|
||||
name,
|
||||
active_mod,
|
||||
game_mode,
|
||||
game_version,
|
||||
description,
|
||||
map,
|
||||
players_online,
|
||||
players_maximum,
|
||||
server_type,
|
||||
environment_type,
|
||||
has_password,
|
||||
vac_secured,
|
||||
round,
|
||||
rounds_maximum,
|
||||
time_left,
|
||||
})
|
||||
}
|
||||
56
crates/lib/src/games/ffow/types.rs
Normal file
56
crates/lib/src/games/ffow/types.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use crate::protocols::types::CommonResponse;
|
||||
use crate::protocols::valve::{Environment, Server};
|
||||
use crate::protocols::GenericResponse;
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The query response.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Response {
|
||||
/// Protocol used by the server.
|
||||
pub protocol_version: u8,
|
||||
/// Name of the server.
|
||||
pub name: String,
|
||||
/// Map name.
|
||||
pub active_mod: String,
|
||||
/// Running game mode.
|
||||
pub game_mode: String,
|
||||
/// The version that the server is running on.
|
||||
pub game_version: String,
|
||||
/// Description of the server.
|
||||
pub description: String,
|
||||
/// Current map.
|
||||
pub map: String,
|
||||
/// Number of players on the server.
|
||||
pub players_online: u8,
|
||||
/// Maximum number of players the server reports it can hold.
|
||||
pub players_maximum: u8,
|
||||
/// Dedicated, NonDedicated or SourceTV
|
||||
pub server_type: Server,
|
||||
/// The Operating System that the server is on.
|
||||
pub environment_type: Environment,
|
||||
/// Indicates whether the server requires a password.
|
||||
pub has_password: bool,
|
||||
/// Indicates whether the server uses VAC.
|
||||
pub vac_secured: bool,
|
||||
/// Current round index.
|
||||
pub round: u8,
|
||||
/// Maximum amount of rounds.
|
||||
pub rounds_maximum: u8,
|
||||
/// Time left for the current round in seconds.
|
||||
pub time_left: u16,
|
||||
}
|
||||
|
||||
impl CommonResponse for Response {
|
||||
fn as_original(&self) -> GenericResponse<'_> { GenericResponse::FFOW(self) }
|
||||
|
||||
fn name(&self) -> Option<&str> { Some(&self.name) }
|
||||
fn game_mode(&self) -> Option<&str> { Some(&self.game_mode) }
|
||||
fn description(&self) -> Option<&str> { Some(&self.description) }
|
||||
fn game_version(&self) -> Option<&str> { Some(&self.game_version) }
|
||||
fn map(&self) -> Option<&str> { Some(&self.map) }
|
||||
fn has_password(&self) -> Option<bool> { Some(self.has_password) }
|
||||
fn players_maximum(&self) -> u32 { self.players_maximum.into() }
|
||||
fn players_online(&self) -> u32 { self.players_online.into() }
|
||||
}
|
||||
9
crates/lib/src/games/gamespy.rs
Normal file
9
crates/lib/src/games/gamespy.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//! Gamespy game query modules
|
||||
|
||||
use crate::protocols::gamespy::game_query_mod;
|
||||
|
||||
game_query_mod!(battlefield1942, "Battlefield 1942", one, 23000);
|
||||
game_query_mod!(crysiswars, "Crysis Wars", three, 64100);
|
||||
game_query_mod!(hce, "Halo: Combat Evolved", two, 2302);
|
||||
game_query_mod!(serioussam, "Serious Sam", one, 25601);
|
||||
game_query_mod!(unrealtournament, "Unreal Tournament", one, 7778);
|
||||
8
crates/lib/src/games/jc2m/mod.rs
Normal file
8
crates/lib/src/games/jc2m/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// The implementation.
|
||||
/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/jc2mp.js)
|
||||
pub mod protocol;
|
||||
/// All types used by the implementation.
|
||||
pub mod types;
|
||||
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
75
crates/lib/src/games/jc2m/protocol.rs
Normal file
75
crates/lib/src/games/jc2m/protocol.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use crate::buffer::{Buffer, Utf8Decoder};
|
||||
use crate::jc2m::{Player, Response};
|
||||
use crate::protocols::gamespy::common::has_password;
|
||||
use crate::protocols::gamespy::three::{data_to_map, GameSpy3};
|
||||
use crate::protocols::types::TimeoutSettings;
|
||||
use crate::GDErrorKind::{PacketBad, TypeParse};
|
||||
use crate::GDResult;
|
||||
use byteorder::BigEndian;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
fn parse_players_and_teams(packet: &[u8]) -> GDResult<Vec<Player>> {
|
||||
let mut buf = Buffer::<BigEndian>::new(packet);
|
||||
|
||||
let count = buf.read::<u16>()?;
|
||||
let mut players = Vec::with_capacity(count as usize);
|
||||
|
||||
while buf.remaining_length() != 0 {
|
||||
players.push(Player {
|
||||
name: buf.read_string::<Utf8Decoder>(None)?,
|
||||
steam_id: buf.read_string::<Utf8Decoder>(None)?,
|
||||
ping: buf.read::<u16>()?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(players)
|
||||
}
|
||||
|
||||
pub fn query(address: &IpAddr, port: Option<u16>) -> GDResult<Response> { query_with_timeout(address, port, None) }
|
||||
|
||||
pub fn query_with_timeout(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
) -> GDResult<Response> {
|
||||
let mut client = GameSpy3::new_custom(
|
||||
&SocketAddr::new(*address, port.unwrap_or(7777)),
|
||||
timeout_settings,
|
||||
[0xFF, 0xFF, 0xFF, 0x02],
|
||||
true,
|
||||
)?;
|
||||
|
||||
let packets = client.get_server_packets()?;
|
||||
let data = packets
|
||||
.first()
|
||||
.ok_or_else(|| PacketBad.context("First packet missing"))?;
|
||||
|
||||
let (mut server_vars, remaining_data) = data_to_map(data)?;
|
||||
let players = parse_players_and_teams(&remaining_data)?;
|
||||
|
||||
let players_maximum = server_vars
|
||||
.remove("maxplayers")
|
||||
.ok_or_else(|| PacketBad.context("Server variables missing maxplayers"))?
|
||||
.parse()
|
||||
.map_err(|e| TypeParse.context(e))?;
|
||||
let players_online = match server_vars.remove("numplayers") {
|
||||
None => players.len(),
|
||||
Some(v) => {
|
||||
let reported_players = v.parse().map_err(|e| TypeParse.context(e))?;
|
||||
match reported_players < players.len() {
|
||||
true => players.len(),
|
||||
false => reported_players,
|
||||
}
|
||||
}
|
||||
} as u32;
|
||||
|
||||
Ok(Response {
|
||||
game_version: server_vars.remove("version").ok_or(PacketBad)?,
|
||||
description: server_vars.remove("description").ok_or(PacketBad)?,
|
||||
name: server_vars.remove("hostname").ok_or(PacketBad)?,
|
||||
has_password: has_password(&mut server_vars)?,
|
||||
players,
|
||||
players_maximum,
|
||||
players_online,
|
||||
})
|
||||
}
|
||||
50
crates/lib/src/games/jc2m/types.rs
Normal file
50
crates/lib/src/games/jc2m/types.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
use crate::protocols::types::{CommonPlayer, CommonResponse, GenericPlayer};
|
||||
use crate::protocols::GenericResponse;
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Player {
|
||||
pub name: String,
|
||||
pub steam_id: String,
|
||||
pub ping: u16,
|
||||
}
|
||||
|
||||
impl CommonPlayer for Player {
|
||||
fn as_original(&self) -> GenericPlayer<'_> { GenericPlayer::JCMP2(self) }
|
||||
|
||||
fn name(&self) -> &str { &self.name }
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Response {
|
||||
pub game_version: String,
|
||||
pub description: String,
|
||||
pub name: String,
|
||||
pub has_password: bool,
|
||||
pub players: Vec<Player>,
|
||||
pub players_maximum: u32,
|
||||
pub players_online: u32,
|
||||
}
|
||||
|
||||
impl CommonResponse for Response {
|
||||
fn as_original(&self) -> GenericResponse<'_> { GenericResponse::JC2M(self) }
|
||||
|
||||
fn game_version(&self) -> Option<&str> { Some(&self.game_version) }
|
||||
fn description(&self) -> Option<&str> { Some(&self.description) }
|
||||
fn name(&self) -> Option<&str> { Some(&self.name) }
|
||||
fn has_password(&self) -> Option<bool> { Some(self.has_password) }
|
||||
fn players_maximum(&self) -> u32 { self.players_maximum }
|
||||
fn players_online(&self) -> u32 { self.players_online }
|
||||
|
||||
fn players(&self) -> Option<Vec<&dyn CommonPlayer>> {
|
||||
Some(
|
||||
self.players
|
||||
.iter()
|
||||
.map(|p| p as &dyn CommonPlayer)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
25
crates/lib/src/games/mindustry/mod.rs
Normal file
25
crates/lib/src/games/mindustry/mod.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! Mindustry game ping (v146)
|
||||
//!
|
||||
//! [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/ArcNetProvider.java#L225-L259)
|
||||
|
||||
use std::{net::IpAddr, net::SocketAddr};
|
||||
|
||||
use crate::{GDResult, TimeoutSettings};
|
||||
|
||||
use self::types::ServerData;
|
||||
|
||||
pub mod types;
|
||||
|
||||
pub mod protocol;
|
||||
|
||||
/// Default mindustry server port
|
||||
///
|
||||
/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/Vars.java#L141-L142)
|
||||
pub const DEFAULT_PORT: u16 = 6567;
|
||||
|
||||
/// Query a mindustry server.
|
||||
pub fn query(ip: &IpAddr, port: Option<u16>, timeout_settings: &Option<TimeoutSettings>) -> GDResult<ServerData> {
|
||||
let address = SocketAddr::new(*ip, port.unwrap_or(DEFAULT_PORT));
|
||||
|
||||
protocol::query_with_retries(&address, timeout_settings)
|
||||
}
|
||||
58
crates/lib/src/games/mindustry/protocol.rs
Normal file
58
crates/lib/src/games/mindustry/protocol.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use crate::{
|
||||
buffer::{self, Buffer},
|
||||
socket::{Socket, UdpSocket},
|
||||
utils,
|
||||
GDResult,
|
||||
TimeoutSettings,
|
||||
};
|
||||
|
||||
use super::types::ServerData;
|
||||
|
||||
/// Mindustry max datagram packet size.
|
||||
pub const MAX_BUFFER_SIZE: usize = 500;
|
||||
|
||||
/// Send a ping packet.
|
||||
///
|
||||
/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/ArcNetProvider.java#L248)
|
||||
pub(crate) fn send_ping(socket: &mut UdpSocket) -> GDResult<()> { socket.send(&[-2i8 as u8, 1i8 as u8]) }
|
||||
|
||||
/// Parse server data.
|
||||
///
|
||||
/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/NetworkIO.java#L122-L135)
|
||||
pub fn parse_server_data<B: byteorder::ByteOrder, D: buffer::StringDecoder>(
|
||||
buffer: &mut Buffer<B>,
|
||||
) -> GDResult<ServerData> {
|
||||
Ok(ServerData {
|
||||
host: buffer.read_string::<D>(None)?,
|
||||
map: buffer.read_string::<D>(None)?,
|
||||
players: buffer.read()?,
|
||||
wave: buffer.read()?,
|
||||
version: buffer.read()?,
|
||||
version_type: buffer.read_string::<D>(None)?,
|
||||
gamemode: buffer.read::<u8>()?.try_into()?,
|
||||
player_limit: buffer.read()?,
|
||||
description: buffer.read_string::<D>(None)?,
|
||||
mode_name: buffer.read_string::<D>(None).ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Query a Mindustry server (without retries).
|
||||
pub fn query(address: &SocketAddr, timeout_settings: &Option<TimeoutSettings>) -> GDResult<ServerData> {
|
||||
let mut socket = UdpSocket::new(address, timeout_settings)?;
|
||||
|
||||
send_ping(&mut socket)?;
|
||||
|
||||
let socket_data = socket.receive(Some(MAX_BUFFER_SIZE))?;
|
||||
let mut buffer = Buffer::new(&socket_data);
|
||||
|
||||
parse_server_data::<byteorder::BigEndian, buffer::Utf8LengthPrefixedDecoder>(&mut buffer)
|
||||
}
|
||||
|
||||
/// Query a Mindustry server.
|
||||
pub fn query_with_retries(address: &SocketAddr, timeout_settings: &Option<TimeoutSettings>) -> GDResult<ServerData> {
|
||||
let retries = TimeoutSettings::get_retries_or_default(timeout_settings);
|
||||
|
||||
utils::retry_on_timeout(retries, || query(address, timeout_settings))
|
||||
}
|
||||
107
crates/lib/src/games/mindustry/types.rs
Normal file
107
crates/lib/src/games/mindustry/types.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
use crate::{
|
||||
protocols::types::{CommonResponse, GenericResponse},
|
||||
GDErrorKind,
|
||||
};
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Mindustry sever data
|
||||
///
|
||||
/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/NetworkIO.java#L122-L135)
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct ServerData {
|
||||
pub host: String,
|
||||
pub map: String,
|
||||
pub players: i32,
|
||||
pub wave: i32,
|
||||
pub version: i32,
|
||||
pub version_type: String,
|
||||
pub gamemode: GameMode,
|
||||
pub player_limit: i32,
|
||||
pub description: String,
|
||||
pub mode_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Mindustry game mode
|
||||
///
|
||||
/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/game/Gamemode.java)
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum GameMode {
|
||||
Survival,
|
||||
Sandbox,
|
||||
Attack,
|
||||
PVP,
|
||||
Editor,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for GameMode {
|
||||
type Error = GDErrorKind;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
use GameMode::*;
|
||||
Ok(match value {
|
||||
0 => Survival,
|
||||
1 => Sandbox,
|
||||
2 => Attack,
|
||||
3 => PVP,
|
||||
4 => Editor,
|
||||
_ => return Err(GDErrorKind::TypeParse),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GameMode {
|
||||
const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Survival => "survival",
|
||||
Self::Sandbox => "sandbox",
|
||||
Self::Attack => "attack",
|
||||
Self::PVP => "pvp",
|
||||
Self::Editor => "editor",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonResponse for ServerData {
|
||||
fn as_original(&self) -> GenericResponse<'_> { GenericResponse::Mindustry(self) }
|
||||
|
||||
fn players_online(&self) -> u32 { self.players.try_into().unwrap_or(0) }
|
||||
fn players_maximum(&self) -> u32 { self.player_limit.try_into().unwrap_or(0) }
|
||||
|
||||
fn game_mode(&self) -> Option<&str> { Some(self.gamemode.as_str()) }
|
||||
|
||||
fn map(&self) -> Option<&str> { Some(&self.map) }
|
||||
fn description(&self) -> Option<&str> { Some(&self.description) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::protocols::types::CommonResponse;
|
||||
|
||||
use super::ServerData;
|
||||
|
||||
#[test]
|
||||
fn common_impl() {
|
||||
let data = ServerData {
|
||||
host: String::from("host"),
|
||||
map: String::from("map"),
|
||||
players: 5,
|
||||
wave: 2,
|
||||
version: 142,
|
||||
version_type: String::from("steam"),
|
||||
gamemode: super::GameMode::PVP,
|
||||
player_limit: 20,
|
||||
description: String::from("description"),
|
||||
mode_name: Some(String::from("campaign")),
|
||||
};
|
||||
|
||||
let common: &dyn CommonResponse = &data;
|
||||
|
||||
assert_eq!(common.players_online(), 5);
|
||||
assert_eq!(common.players_maximum(), 20);
|
||||
assert_eq!(common.game_mode(), Some("pvp"));
|
||||
assert_eq!(common.map(), Some("map"));
|
||||
assert_eq!(common.description(), Some("description"));
|
||||
}
|
||||
}
|
||||
69
crates/lib/src/games/minecraft/mod.rs
Normal file
69
crates/lib/src/games/minecraft/mod.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/// The implementation.
|
||||
/// Reference: [Server List Ping](https://wiki.vg/Server_List_Ping)
|
||||
pub mod protocol;
|
||||
/// All types used by the implementation.
|
||||
pub mod types;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
|
||||
use crate::{GDErrorKind, GDResult};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
/// Query with all the protocol variants one by one (Java -> Bedrock -> Legacy
|
||||
/// (1.6 -> 1.4 -> Beta 1.8)).
|
||||
pub fn query(address: &IpAddr, port: Option<u16>) -> GDResult<JavaResponse> {
|
||||
if let Ok(response) = query_java(address, port, None) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if let Ok(response) = query_bedrock(address, port) {
|
||||
return Ok(JavaResponse::from_bedrock_response(response));
|
||||
}
|
||||
|
||||
if let Ok(response) = query_legacy(address, port) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
Err(GDErrorKind::AutoQuery.into())
|
||||
}
|
||||
|
||||
/// Query a Java Server.
|
||||
pub fn query_java(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
request_settings: Option<RequestSettings>,
|
||||
) -> GDResult<JavaResponse> {
|
||||
protocol::query_java(
|
||||
&SocketAddr::new(*address, port_or_java_default(port)),
|
||||
None,
|
||||
request_settings,
|
||||
)
|
||||
}
|
||||
|
||||
/// Query a (Java) Legacy Server (1.6 -> 1.4 -> Beta 1.8).
|
||||
pub fn query_legacy(address: &IpAddr, port: Option<u16>) -> GDResult<JavaResponse> {
|
||||
protocol::query_legacy(&SocketAddr::new(*address, port_or_java_default(port)), None)
|
||||
}
|
||||
|
||||
/// Query a specific (Java) Legacy Server.
|
||||
pub fn query_legacy_specific(group: LegacyGroup, address: &IpAddr, port: Option<u16>) -> GDResult<JavaResponse> {
|
||||
protocol::query_legacy_specific(
|
||||
group,
|
||||
&SocketAddr::new(*address, port_or_java_default(port)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Query a Bedrock Server.
|
||||
pub fn query_bedrock(address: &IpAddr, port: Option<u16>) -> GDResult<BedrockResponse> {
|
||||
protocol::query_bedrock(
|
||||
&SocketAddr::new(*address, port_or_bedrock_default(port)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn port_or_java_default(port: Option<u16>) -> u16 { port.unwrap_or(25565) }
|
||||
|
||||
fn port_or_bedrock_default(port: Option<u16>) -> u16 { port.unwrap_or(19132) }
|
||||
111
crates/lib/src/games/minecraft/protocol/bedrock.rs
Normal file
111
crates/lib/src/games/minecraft/protocol/bedrock.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// This file has code that has been documented by the NodeJS GameDig library
|
||||
// (MIT) from https://github.com/gamedig/node-gamedig/blob/master/protocols/minecraftbedrock.js
|
||||
use crate::{
|
||||
buffer::{Buffer, Utf8Decoder},
|
||||
games::minecraft::{BedrockResponse, GameMode, Server},
|
||||
protocols::types::TimeoutSettings,
|
||||
socket::{Socket, UdpSocket},
|
||||
utils::{error_by_expected_size, retry_on_timeout},
|
||||
GDErrorKind::{PacketBad, TypeParse},
|
||||
GDResult,
|
||||
};
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use byteorder::LittleEndian;
|
||||
|
||||
pub struct Bedrock {
|
||||
socket: UdpSocket,
|
||||
retry_count: usize,
|
||||
}
|
||||
|
||||
impl Bedrock {
|
||||
fn new(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<Self> {
|
||||
let socket = UdpSocket::new(address, &timeout_settings)?;
|
||||
|
||||
let retry_count = TimeoutSettings::get_retries_or_default(&timeout_settings);
|
||||
Ok(Self {
|
||||
socket,
|
||||
retry_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn send_status_request(&mut self) -> GDResult<()> {
|
||||
self.socket.send(&[
|
||||
0x01, // Message ID: ID_UNCONNECTED_PING
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, // Nonce / timestamp
|
||||
0x00, 0xff, 0xff, 0x00, 0xfe, 0xfe, 0xfe, 0xfe, 0xfd, 0xfd, 0xfd, 0xfd, 0x12, 0x34, // Magic
|
||||
0x56, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Client GUID
|
||||
])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a status request, and parse the response.
|
||||
/// This function will retry fetch on timeouts.
|
||||
fn get_info(&mut self) -> GDResult<BedrockResponse> {
|
||||
retry_on_timeout(self.retry_count, move || self.get_info_impl())
|
||||
}
|
||||
|
||||
/// Send a status request, and parse the response (without retry logic).
|
||||
fn get_info_impl(&mut self) -> GDResult<BedrockResponse> {
|
||||
self.send_status_request()?;
|
||||
|
||||
let received = self.socket.receive(None)?;
|
||||
let mut buffer = Buffer::<LittleEndian>::new(&received);
|
||||
|
||||
if buffer.read::<u8>()? != 0x1c {
|
||||
return Err(PacketBad.context("Expected 0x1c"));
|
||||
}
|
||||
|
||||
// Checking for our nonce directly from a u64 (as the nonce is 8 bytes).
|
||||
if buffer.read::<u64>()? != 9_833_440_827_789_222_417 {
|
||||
return Err(PacketBad.context("Invalid nonce"));
|
||||
}
|
||||
|
||||
// These 8 bytes are identical to the serverId string we receive in decimal
|
||||
// below
|
||||
buffer.move_cursor(8)?;
|
||||
|
||||
// Verifying the magic value (as we need 16 bytes, cast to two u64 values)
|
||||
if buffer.read::<u64>()? != 18_374_403_896_610_127_616 {
|
||||
return Err(PacketBad.context("Invalid magic"));
|
||||
}
|
||||
|
||||
if buffer.read::<u64>()? != 8_671_175_388_723_805_693 {
|
||||
return Err(PacketBad.context("Invalid magic"));
|
||||
}
|
||||
|
||||
let remaining_length = buffer.switch_endian_chunk(2)?.read::<u16>()? as usize;
|
||||
|
||||
error_by_expected_size(remaining_length, buffer.remaining_length())?;
|
||||
|
||||
let binding = buffer.read_string::<Utf8Decoder>(None)?;
|
||||
let status: Vec<&str> = binding.split(';').collect();
|
||||
|
||||
// We must have at least 6 values
|
||||
if status.len() < 6 {
|
||||
return Err(PacketBad.context("Not enough values"));
|
||||
}
|
||||
|
||||
Ok(BedrockResponse {
|
||||
edition: status[0].to_string(),
|
||||
name: status[1].to_string(),
|
||||
version_name: status[3].to_string(),
|
||||
protocol_version: status[2].to_string(),
|
||||
players_maximum: status[5].parse().map_err(|e| TypeParse.context(e))?,
|
||||
players_online: status[4].parse().map_err(|e| TypeParse.context(e))?,
|
||||
id: status.get(6).map(std::string::ToString::to_string),
|
||||
map: status.get(7).map(std::string::ToString::to_string),
|
||||
game_mode: match status.get(8) {
|
||||
None => None,
|
||||
Some(v) => Some(GameMode::from_bedrock(v)?),
|
||||
},
|
||||
server_type: Server::Bedrock,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn query(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<BedrockResponse> {
|
||||
Self::new(address, timeout_settings)?.get_info()
|
||||
}
|
||||
}
|
||||
178
crates/lib/src/games/minecraft/protocol/java.rs
Normal file
178
crates/lib/src/games/minecraft/protocol/java.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
use crate::{
|
||||
buffer::Buffer,
|
||||
games::minecraft::{as_string, as_varint, get_string, get_varint, JavaResponse, Player, RequestSettings, Server},
|
||||
protocols::types::TimeoutSettings,
|
||||
socket::{Socket, TcpSocket},
|
||||
utils::retry_on_timeout,
|
||||
GDErrorKind::{JsonParse, PacketBad},
|
||||
GDResult,
|
||||
};
|
||||
|
||||
use byteorder::LittleEndian;
|
||||
use serde_json::Value;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub struct Java {
|
||||
socket: TcpSocket,
|
||||
request_settings: RequestSettings,
|
||||
retry_count: usize,
|
||||
}
|
||||
|
||||
impl Java {
|
||||
fn new(
|
||||
address: &SocketAddr,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
request_settings: Option<RequestSettings>,
|
||||
) -> GDResult<Self> {
|
||||
let socket = TcpSocket::new(address, &timeout_settings)?;
|
||||
|
||||
let retry_count = TimeoutSettings::get_retries_or_default(&timeout_settings);
|
||||
Ok(Self {
|
||||
socket,
|
||||
request_settings: request_settings.unwrap_or_default(),
|
||||
retry_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn send(&mut self, data: Vec<u8>) -> GDResult<()> {
|
||||
self.socket
|
||||
.send(&[as_varint(data.len() as i32), data].concat())
|
||||
}
|
||||
|
||||
fn receive(&mut self) -> GDResult<Vec<u8>> {
|
||||
let data = &self.socket.receive(None)?;
|
||||
let mut buffer = Buffer::<LittleEndian>::new(data);
|
||||
|
||||
let _packet_length = get_varint(&mut buffer)? as usize;
|
||||
// this declared 'packet length' from within the packet might be wrong (?), not
|
||||
// checking with it...
|
||||
|
||||
Ok(buffer.remaining_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn send_handshake(&mut self) -> GDResult<()> {
|
||||
let handshake_payload = [
|
||||
&[
|
||||
// Packet ID (0)
|
||||
0x00,
|
||||
], // Protocol Version (-1 to determine version)
|
||||
as_varint(self.request_settings.protocol_version).as_slice(),
|
||||
// Server address (can be anything)
|
||||
as_string(&self.request_settings.hostname)?.as_slice(),
|
||||
// Server port (can be anything)
|
||||
&self.socket.port().to_le_bytes(),
|
||||
&[
|
||||
// Next state (1 for status)
|
||||
0x01,
|
||||
],
|
||||
]
|
||||
.concat();
|
||||
|
||||
self.send(handshake_payload)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_status_request(&mut self) -> GDResult<()> {
|
||||
self.send(
|
||||
[0x00] // Packet ID (0)
|
||||
.to_vec(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_ping_request(&mut self) -> GDResult<()> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
|
||||
let mut payload = [0x01].to_vec(); // Packet ID (1)
|
||||
payload.extend_from_slice(×tamp.to_be_bytes()); // Timestamp (long, 8 bytes)
|
||||
|
||||
self.send(payload)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send minecraft ping request and parse the response.
|
||||
/// This function will retry fetch on timeouts.
|
||||
fn get_info(&mut self) -> GDResult<JavaResponse> {
|
||||
retry_on_timeout(self.retry_count, move || self.get_info_impl())
|
||||
}
|
||||
|
||||
/// Send minecraft ping request and parse the response (without retry
|
||||
/// logic).
|
||||
fn get_info_impl(&mut self) -> GDResult<JavaResponse> {
|
||||
self.send_handshake()?;
|
||||
self.send_status_request()?;
|
||||
self.send_ping_request()?;
|
||||
|
||||
let socket_data = self.receive()?;
|
||||
let mut buffer = Buffer::<LittleEndian>::new(&socket_data);
|
||||
|
||||
if get_varint(&mut buffer)? != 0 {
|
||||
// first var int is the packet id
|
||||
return Err(PacketBad.context("Expected 0"));
|
||||
}
|
||||
|
||||
let json_response = get_string(&mut buffer)?;
|
||||
let value_response: Value = serde_json::from_str(&json_response).map_err(|e| JsonParse.context(e))?;
|
||||
|
||||
let game_version = value_response["version"]["name"]
|
||||
.as_str()
|
||||
.ok_or(PacketBad)?
|
||||
.to_string();
|
||||
let protocol_version = value_response["version"]["protocol"]
|
||||
.as_i64()
|
||||
.ok_or(PacketBad)? as i32;
|
||||
|
||||
let max_players = value_response["players"]["max"].as_u64().ok_or(PacketBad)? as u32;
|
||||
let online_players = value_response["players"]["online"]
|
||||
.as_u64()
|
||||
.ok_or(PacketBad)? as u32;
|
||||
let players: Option<Vec<Player>> = match value_response["players"]["sample"].is_null() {
|
||||
true => None,
|
||||
false => {
|
||||
Some({
|
||||
let players_values = value_response["players"]["sample"]
|
||||
.as_array()
|
||||
.ok_or(PacketBad)?;
|
||||
|
||||
let mut players = Vec::with_capacity(players_values.len());
|
||||
for player in players_values {
|
||||
players.push(Player {
|
||||
name: player["name"].as_str().ok_or(PacketBad)?.to_string(),
|
||||
id: player["id"].as_str().ok_or(PacketBad)?.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
players
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(JavaResponse {
|
||||
game_version,
|
||||
protocol_version,
|
||||
players_maximum: max_players,
|
||||
players_online: online_players,
|
||||
players,
|
||||
description: value_response["description"].to_string(),
|
||||
favicon: value_response["favicon"].as_str().map(str::to_string),
|
||||
previews_chat: value_response["previewsChat"].as_bool(),
|
||||
enforces_secure_chat: value_response["enforcesSecureChat"].as_bool(),
|
||||
server_type: Server::Java,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn query(
|
||||
address: &SocketAddr,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
request_settings: Option<RequestSettings>,
|
||||
) -> GDResult<JavaResponse> {
|
||||
Self::new(address, timeout_settings, request_settings)?.get_info()
|
||||
}
|
||||
}
|
||||
83
crates/lib/src/games/minecraft/protocol/legacy_v1_4.rs
Normal file
83
crates/lib/src/games/minecraft/protocol/legacy_v1_4.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use byteorder::BigEndian;
|
||||
|
||||
use crate::minecraft::protocol::legacy_v1_6::LegacyV1_6;
|
||||
use crate::{
|
||||
buffer::{Buffer, Utf16Decoder},
|
||||
games::minecraft::{JavaResponse, LegacyGroup, Server},
|
||||
protocols::types::TimeoutSettings,
|
||||
socket::{Socket, TcpSocket},
|
||||
utils::{error_by_expected_size, retry_on_timeout},
|
||||
GDErrorKind::{PacketBad, ProtocolFormat},
|
||||
GDResult,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
pub struct LegacyV1_4 {
|
||||
socket: TcpSocket,
|
||||
retry_count: usize,
|
||||
}
|
||||
|
||||
impl LegacyV1_4 {
|
||||
fn new(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<Self> {
|
||||
let socket = TcpSocket::new(address, &timeout_settings)?;
|
||||
|
||||
let retry_count = TimeoutSettings::get_retries_or_default(&timeout_settings);
|
||||
Ok(Self {
|
||||
socket,
|
||||
retry_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn send_initial_request(&mut self) -> GDResult<()> { self.socket.send(&[0xFE, 0x01]) }
|
||||
|
||||
/// Send info request and parse response.
|
||||
/// This function will retry fetch on timeouts.
|
||||
fn get_info(&mut self) -> GDResult<JavaResponse> {
|
||||
retry_on_timeout(self.retry_count, move || self.get_info_impl())
|
||||
}
|
||||
|
||||
/// Send info request and parse response (without retry logic).
|
||||
fn get_info_impl(&mut self) -> GDResult<JavaResponse> {
|
||||
self.send_initial_request()?;
|
||||
|
||||
let data = self.socket.receive(None)?;
|
||||
let mut buffer = Buffer::<BigEndian>::new(&data);
|
||||
|
||||
if buffer.read::<u8>()? != 0xFF {
|
||||
return Err(ProtocolFormat.context("Expected 0xFF"));
|
||||
}
|
||||
|
||||
let length = buffer.read::<u16>()? * 2;
|
||||
error_by_expected_size((length + 3) as usize, data.len())?;
|
||||
|
||||
if LegacyV1_6::is_protocol(&mut buffer)? {
|
||||
return LegacyV1_6::get_response(&mut buffer);
|
||||
}
|
||||
|
||||
let packet_string = buffer.read_string::<Utf16Decoder<BigEndian>>(None)?;
|
||||
|
||||
let split: Vec<&str> = packet_string.split('§').collect();
|
||||
error_by_expected_size(3, split.len())?;
|
||||
|
||||
let description = split[0].to_string();
|
||||
let online_players = split[1].parse().map_err(|e| PacketBad.context(e))?;
|
||||
let max_players = split[2].parse().map_err(|e| PacketBad.context(e))?;
|
||||
|
||||
Ok(JavaResponse {
|
||||
game_version: "1.4+".to_string(),
|
||||
protocol_version: -1,
|
||||
players_maximum: max_players,
|
||||
players_online: online_players,
|
||||
players: None,
|
||||
description,
|
||||
favicon: None,
|
||||
previews_chat: None,
|
||||
enforces_secure_chat: None,
|
||||
server_type: Server::Legacy(LegacyGroup::V1_4),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn query(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<JavaResponse> {
|
||||
Self::new(address, timeout_settings)?.get_info()
|
||||
}
|
||||
}
|
||||
116
crates/lib/src/games/minecraft/protocol/legacy_v1_6.rs
Normal file
116
crates/lib/src/games/minecraft/protocol/legacy_v1_6.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use byteorder::BigEndian;
|
||||
|
||||
use crate::{
|
||||
buffer::{Buffer, Utf16Decoder},
|
||||
games::minecraft::{JavaResponse, LegacyGroup, Server},
|
||||
protocols::types::TimeoutSettings,
|
||||
socket::{Socket, TcpSocket},
|
||||
utils::{error_by_expected_size, retry_on_timeout},
|
||||
GDErrorKind::{PacketBad, ProtocolFormat},
|
||||
GDResult,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
pub struct LegacyV1_6 {
|
||||
socket: TcpSocket,
|
||||
retry_count: usize,
|
||||
}
|
||||
|
||||
impl LegacyV1_6 {
|
||||
fn new(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<Self> {
|
||||
let socket = TcpSocket::new(address, &timeout_settings)?;
|
||||
|
||||
let retry_count = TimeoutSettings::get_retries_or_default(&timeout_settings);
|
||||
Ok(Self {
|
||||
socket,
|
||||
retry_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn send_initial_request(&mut self) -> GDResult<()> {
|
||||
self.socket.send(&[
|
||||
0xfe, // Packet ID (FE)
|
||||
0x01, // Ping payload (01)
|
||||
0xfa, // Packet identifier for plugin message
|
||||
0x00, 0x07, // Length of 'GameDig' string (7) as unsigned short
|
||||
0x00, 0x47, 0x00, 0x61, 0x00, 0x6D, 0x00, 0x65, 0x00, 0x44, 0x00, 0x69, 0x00,
|
||||
0x67, // 'GameDig' string as UTF-16BE
|
||||
])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_protocol(buffer: &mut Buffer<BigEndian>) -> GDResult<bool> {
|
||||
let state = buffer
|
||||
.remaining_bytes()
|
||||
.starts_with(&[0x00, 0xA7, 0x00, 0x31, 0x00, 0x00]);
|
||||
|
||||
if state {
|
||||
buffer.move_cursor(6)?;
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub(crate) fn get_response(buffer: &mut Buffer<BigEndian>) -> GDResult<JavaResponse> {
|
||||
// This is a specific order!
|
||||
let protocol_version = buffer
|
||||
.read_string::<Utf16Decoder<BigEndian>>(None)?
|
||||
.parse()
|
||||
.map_err(|e| PacketBad.context(e))?;
|
||||
let game_version = buffer.read_string::<Utf16Decoder<BigEndian>>(None)?;
|
||||
let description = buffer.read_string::<Utf16Decoder<BigEndian>>(None)?;
|
||||
let online_players = buffer
|
||||
.read_string::<Utf16Decoder<BigEndian>>(None)?
|
||||
.parse()
|
||||
.map_err(|e| PacketBad.context(e))?;
|
||||
let max_players = buffer
|
||||
.read_string::<Utf16Decoder<BigEndian>>(None)?
|
||||
.parse()
|
||||
.map_err(|e| PacketBad.context(e))?;
|
||||
|
||||
Ok(JavaResponse {
|
||||
game_version,
|
||||
protocol_version,
|
||||
players_maximum: max_players,
|
||||
players_online: online_players,
|
||||
players: None,
|
||||
description,
|
||||
favicon: None,
|
||||
previews_chat: None,
|
||||
enforces_secure_chat: None,
|
||||
server_type: Server::Legacy(LegacyGroup::V1_6),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send info request and parse response.
|
||||
/// This function will retry fetch on timeouts.
|
||||
fn get_info(&mut self) -> GDResult<JavaResponse> {
|
||||
retry_on_timeout(self.retry_count, move || self.get_info_impl())
|
||||
}
|
||||
|
||||
/// Send info request and parse response (without retry logic).
|
||||
fn get_info_impl(&mut self) -> GDResult<JavaResponse> {
|
||||
self.send_initial_request()?;
|
||||
|
||||
let data = self.socket.receive(None)?;
|
||||
let mut buffer = Buffer::<BigEndian>::new(&data);
|
||||
|
||||
if buffer.read::<u8>()? != 0xFF {
|
||||
return Err(ProtocolFormat.context("Expected 0xFF"));
|
||||
}
|
||||
|
||||
let length = buffer.read::<u16>()? * 2;
|
||||
error_by_expected_size((length + 3) as usize, data.len())?;
|
||||
|
||||
if !Self::is_protocol(&mut buffer)? {
|
||||
return Err(ProtocolFormat.context("Not legacy 1.6 protocol"));
|
||||
}
|
||||
|
||||
Self::get_response(&mut buffer)
|
||||
}
|
||||
|
||||
pub fn query(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<JavaResponse> {
|
||||
Self::new(address, timeout_settings)?.get_info()
|
||||
}
|
||||
}
|
||||
79
crates/lib/src/games/minecraft/protocol/legacy_vb1_8.rs
Normal file
79
crates/lib/src/games/minecraft/protocol/legacy_vb1_8.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use crate::{
|
||||
buffer::{Buffer, Utf16Decoder},
|
||||
games::minecraft::{JavaResponse, LegacyGroup, Server},
|
||||
protocols::types::TimeoutSettings,
|
||||
socket::{Socket, TcpSocket},
|
||||
utils::{error_by_expected_size, retry_on_timeout},
|
||||
GDErrorKind::{PacketBad, ProtocolFormat},
|
||||
GDResult,
|
||||
};
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use byteorder::BigEndian;
|
||||
|
||||
pub struct LegacyVB1_8 {
|
||||
socket: TcpSocket,
|
||||
retry_count: usize,
|
||||
}
|
||||
|
||||
impl LegacyVB1_8 {
|
||||
fn new(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<Self> {
|
||||
let socket = TcpSocket::new(address, &timeout_settings)?;
|
||||
|
||||
let retry_count = TimeoutSettings::get_retries_or_default(&timeout_settings);
|
||||
Ok(Self {
|
||||
socket,
|
||||
retry_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn send_initial_request(&mut self) -> GDResult<()> { self.socket.send(&[0xFE]) }
|
||||
|
||||
/// Send request for info and parse response.
|
||||
/// This function will retry fetch on timeouts.
|
||||
fn get_info(&mut self) -> GDResult<JavaResponse> {
|
||||
retry_on_timeout(self.retry_count, move || self.get_info_impl())
|
||||
}
|
||||
|
||||
/// Send request for info and parse response (without retry logic).
|
||||
fn get_info_impl(&mut self) -> GDResult<JavaResponse> {
|
||||
self.send_initial_request()?;
|
||||
|
||||
let data = self.socket.receive(None)?;
|
||||
let mut buffer = Buffer::<BigEndian>::new(&data);
|
||||
|
||||
if buffer.read::<u8>()? != 0xFF {
|
||||
return Err(ProtocolFormat.context("Expected 0xFF"));
|
||||
}
|
||||
|
||||
let length = buffer.read::<u16>()? * 2;
|
||||
error_by_expected_size((length + 3) as usize, data.len())?;
|
||||
|
||||
let packet_string = buffer.read_string::<Utf16Decoder<BigEndian>>(None)?;
|
||||
|
||||
let split: Vec<&str> = packet_string.split('§').collect();
|
||||
error_by_expected_size(3, split.len())?;
|
||||
|
||||
let description = split[0].to_string();
|
||||
let online_players = split[1].parse().map_err(|e| PacketBad.context(e))?;
|
||||
let max_players = split[2].parse().map_err(|e| PacketBad.context(e))?;
|
||||
|
||||
Ok(JavaResponse {
|
||||
game_version: "Beta 1.8+".to_string(),
|
||||
protocol_version: -1,
|
||||
players_maximum: max_players,
|
||||
players_online: online_players,
|
||||
players: None,
|
||||
description,
|
||||
favicon: None,
|
||||
previews_chat: None,
|
||||
enforces_secure_chat: None,
|
||||
server_type: Server::Legacy(LegacyGroup::VB1_8),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn query(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<JavaResponse> {
|
||||
Self::new(address, timeout_settings)?.get_info()
|
||||
}
|
||||
}
|
||||
91
crates/lib/src/games/minecraft/protocol/mod.rs
Normal file
91
crates/lib/src/games/minecraft/protocol/mod.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
use crate::games::minecraft::types::RequestSettings;
|
||||
use crate::{
|
||||
games::minecraft::{
|
||||
protocol::{
|
||||
bedrock::Bedrock,
|
||||
java::Java,
|
||||
legacy_v1_4::LegacyV1_4,
|
||||
legacy_v1_6::LegacyV1_6,
|
||||
legacy_vb1_8::LegacyVB1_8,
|
||||
},
|
||||
BedrockResponse,
|
||||
JavaResponse,
|
||||
LegacyGroup,
|
||||
},
|
||||
protocols::types::TimeoutSettings,
|
||||
GDErrorKind::AutoQuery,
|
||||
GDResult,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
mod bedrock;
|
||||
mod java;
|
||||
mod legacy_v1_4;
|
||||
mod legacy_v1_6;
|
||||
mod legacy_vb1_8;
|
||||
|
||||
/// Queries a Minecraft server with all the protocol variants one by one (Java
|
||||
/// -> Bedrock -> Legacy (1.6 -> 1.4 -> Beta 1.8)).
|
||||
pub fn query(
|
||||
address: &SocketAddr,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
request_settings: Option<RequestSettings>,
|
||||
) -> GDResult<JavaResponse> {
|
||||
if let Ok(response) = query_java(address, timeout_settings, request_settings) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if let Ok(response) = query_bedrock(address, timeout_settings) {
|
||||
return Ok(JavaResponse::from_bedrock_response(response));
|
||||
}
|
||||
|
||||
if let Ok(response) = query_legacy(address, timeout_settings) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
Err(AutoQuery.into())
|
||||
}
|
||||
|
||||
/// Query a Java Server.
|
||||
pub fn query_java(
|
||||
address: &SocketAddr,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
request_settings: Option<RequestSettings>,
|
||||
) -> GDResult<JavaResponse> {
|
||||
Java::query(address, timeout_settings, request_settings)
|
||||
}
|
||||
|
||||
/// Query a (Java) Legacy Server (1.6 -> 1.4 -> Beta 1.8).
|
||||
pub fn query_legacy(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<JavaResponse> {
|
||||
if let Ok(response) = query_legacy_specific(LegacyGroup::V1_6, address, timeout_settings) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if let Ok(response) = query_legacy_specific(LegacyGroup::V1_4, address, timeout_settings) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if let Ok(response) = query_legacy_specific(LegacyGroup::VB1_8, address, timeout_settings) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
Err(AutoQuery.into())
|
||||
}
|
||||
|
||||
/// Query a specific (Java) Legacy Server.
|
||||
pub fn query_legacy_specific(
|
||||
group: LegacyGroup,
|
||||
address: &SocketAddr,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
) -> GDResult<JavaResponse> {
|
||||
match group {
|
||||
LegacyGroup::V1_6 => LegacyV1_6::query(address, timeout_settings),
|
||||
LegacyGroup::V1_4 => LegacyV1_4::query(address, timeout_settings),
|
||||
LegacyGroup::VB1_8 => LegacyVB1_8::query(address, timeout_settings),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query a Bedrock Server.
|
||||
pub fn query_bedrock(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<BedrockResponse> {
|
||||
Bedrock::query(address, timeout_settings)
|
||||
}
|
||||
337
crates/lib/src/games/minecraft/types.rs
Normal file
337
crates/lib/src/games/minecraft/types.rs
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
// Although its a lightly modified version, this file contains code
|
||||
// by Jaiden Bernard (2021-2022 - MIT) from
|
||||
// https://github.com/thisjaiden/golden_apple/blob/master/src/lib.rs
|
||||
|
||||
use crate::{
|
||||
buffer::Buffer,
|
||||
protocols::{
|
||||
types::{CommonPlayer, CommonResponse, ExtraRequestSettings, GenericPlayer},
|
||||
GenericResponse,
|
||||
},
|
||||
GDErrorKind::{InvalidInput, PacketBad, UnknownEnumCast},
|
||||
GDResult,
|
||||
};
|
||||
|
||||
use byteorder::ByteOrder;
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The type of Minecraft Server you want to query.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Server {
|
||||
/// Java Edition.
|
||||
Java,
|
||||
/// Legacy Java.
|
||||
Legacy(LegacyGroup),
|
||||
/// Bedrock Edition.
|
||||
Bedrock,
|
||||
}
|
||||
|
||||
/// Legacy Java (Versions) Groups.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum LegacyGroup {
|
||||
/// 1.6
|
||||
V1_6,
|
||||
/// 1.4 - 1.5
|
||||
V1_4,
|
||||
/// Beta 1.8 - 1.3
|
||||
VB1_8,
|
||||
}
|
||||
|
||||
/// Information about a player.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Player {
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl CommonPlayer for Player {
|
||||
fn as_original(&self) -> GenericPlayer<'_> { GenericPlayer::Minecraft(self) }
|
||||
|
||||
fn name(&self) -> &str { &self.name }
|
||||
}
|
||||
|
||||
/// Versioned response type
|
||||
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VersionedResponse<'a> {
|
||||
Bedrock(&'a BedrockResponse),
|
||||
Java(&'a JavaResponse),
|
||||
}
|
||||
|
||||
/// A Java query response.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct JavaResponse {
|
||||
/// Version name, example: "1.19.2".
|
||||
pub game_version: String,
|
||||
/// Protocol version, example: 760 (for 1.19.1 or 1.19.2).
|
||||
/// Note that for versions below 1.6 this field is always -1.
|
||||
pub protocol_version: i32,
|
||||
/// Number of server capacity.
|
||||
pub players_maximum: u32,
|
||||
/// Number of online players.
|
||||
pub players_online: u32,
|
||||
/// Some online players (can be missing).
|
||||
pub players: Option<Vec<Player>>,
|
||||
/// Server's description or MOTD.
|
||||
pub description: String,
|
||||
/// The favicon (can be missing).
|
||||
pub favicon: Option<String>,
|
||||
/// Tells if the chat preview is enabled (can be missing).
|
||||
pub previews_chat: Option<bool>,
|
||||
/// Tells if secure chat is enforced (can be missing).
|
||||
pub enforces_secure_chat: Option<bool>,
|
||||
/// Tell's the server type.
|
||||
pub server_type: Server,
|
||||
}
|
||||
|
||||
/// Java-only additional request settings.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct RequestSettings {
|
||||
/// Some Minecraft servers do not respond as expected if this
|
||||
/// isn't a specific value, `mc.hypixel.net` is an example.
|
||||
pub hostname: String,
|
||||
/// Specifies the client [protocol version number](https://wiki.vg/Protocol_version_numbers),
|
||||
/// `-1` means anything.
|
||||
pub protocol_version: i32,
|
||||
}
|
||||
|
||||
impl Default for RequestSettings {
|
||||
/// `hostname`: "gamedig"
|
||||
/// `protocol_version`: -1
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hostname: "gamedig".to_string(),
|
||||
protocol_version: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestSettings {
|
||||
/// Make a new *RequestSettings* with just the hostname, the protocol
|
||||
/// version defaults to -1
|
||||
pub const fn new_just_hostname(hostname: String) -> Self {
|
||||
Self {
|
||||
hostname,
|
||||
protocol_version: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExtraRequestSettings> for RequestSettings {
|
||||
fn from(value: ExtraRequestSettings) -> Self {
|
||||
let default = Self::default();
|
||||
Self {
|
||||
hostname: value.hostname.unwrap_or(default.hostname),
|
||||
protocol_version: value.protocol_version.unwrap_or(default.protocol_version),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonResponse for JavaResponse {
|
||||
fn as_original(&self) -> GenericResponse<'_> { GenericResponse::Minecraft(VersionedResponse::Java(self)) }
|
||||
|
||||
fn description(&self) -> Option<&str> { Some(&self.description) }
|
||||
fn players_maximum(&self) -> u32 { self.players_maximum }
|
||||
fn players_online(&self) -> u32 { self.players_online }
|
||||
fn game_version(&self) -> Option<&str> { Some(&self.game_version) }
|
||||
|
||||
fn players(&self) -> Option<Vec<&dyn CommonPlayer>> {
|
||||
self.players
|
||||
.as_ref()
|
||||
.map(|players| players.iter().map(|p| p as &dyn CommonPlayer).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// A Bedrock Edition query response.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct BedrockResponse {
|
||||
/// Server's edition.
|
||||
pub edition: String,
|
||||
/// Server's name.
|
||||
pub name: String,
|
||||
/// Version name, example: "1.19.40".
|
||||
pub version_name: String,
|
||||
/// Protocol version, example: 760 (for 1.19.2).
|
||||
pub protocol_version: String,
|
||||
/// Maximum number of players the server reports it can hold.
|
||||
pub players_maximum: u32,
|
||||
/// Number of players on the server.
|
||||
pub players_online: u32,
|
||||
/// Server id.
|
||||
pub id: Option<String>,
|
||||
/// Currently running map's name.
|
||||
pub map: Option<String>,
|
||||
/// Current game mode.
|
||||
pub game_mode: Option<GameMode>,
|
||||
/// Tells the server type.
|
||||
pub server_type: Server,
|
||||
}
|
||||
|
||||
impl CommonResponse for BedrockResponse {
|
||||
fn as_original(&self) -> GenericResponse<'_> { GenericResponse::Minecraft(VersionedResponse::Bedrock(self)) }
|
||||
|
||||
fn name(&self) -> Option<&str> { Some(&self.name) }
|
||||
fn map(&self) -> Option<&str> { self.map.as_deref() }
|
||||
fn game_version(&self) -> Option<&str> { Some(&self.version_name) }
|
||||
fn players_maximum(&self) -> u32 { self.players_maximum }
|
||||
fn players_online(&self) -> u32 { self.players_online }
|
||||
}
|
||||
|
||||
impl JavaResponse {
|
||||
pub fn from_bedrock_response(response: BedrockResponse) -> Self {
|
||||
Self {
|
||||
game_version: response.version_name,
|
||||
protocol_version: 0,
|
||||
players_maximum: response.players_maximum,
|
||||
players_online: response.players_online,
|
||||
players: None,
|
||||
description: response.name,
|
||||
favicon: None,
|
||||
previews_chat: None,
|
||||
enforces_secure_chat: None,
|
||||
server_type: Server::Bedrock,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A server's game mode (used only by Bedrock servers.
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum GameMode {
|
||||
Survival,
|
||||
Creative,
|
||||
Hardcore,
|
||||
Spectator,
|
||||
Adventure,
|
||||
}
|
||||
|
||||
impl GameMode {
|
||||
pub fn from_bedrock(value: &&str) -> GDResult<Self> {
|
||||
match *value {
|
||||
"Survival" => Ok(Self::Survival),
|
||||
"Creative" => Ok(Self::Creative),
|
||||
"Hardcore" => Ok(Self::Hardcore),
|
||||
"Spectator" => Ok(Self::Spectator),
|
||||
"Adventure" => Ok(Self::Adventure),
|
||||
_ => Err(UnknownEnumCast.context(format!("Unknown gamemode {value:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_varint<B: ByteOrder>(buffer: &mut Buffer<B>) -> GDResult<i32> {
|
||||
let mut result = 0;
|
||||
|
||||
let msb: u8 = 0b1000_0000;
|
||||
let mask: u8 = !msb;
|
||||
|
||||
for i in 0 .. 5 {
|
||||
let current_byte = buffer.read::<u8>()?;
|
||||
|
||||
result |= ((current_byte & mask) as i32) << (7 * i);
|
||||
|
||||
// The 5th byte is only allowed to have the 4 smallest bits set
|
||||
if i == 4 && (current_byte & 0xf0 != 0) {
|
||||
return Err(PacketBad.context("Bad 5th byte"));
|
||||
}
|
||||
|
||||
if (current_byte & msb) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn as_varint(value: i32) -> Vec<u8> {
|
||||
let mut bytes = vec![];
|
||||
let mut reading_value = value;
|
||||
|
||||
let msb: u8 = 0b1000_0000;
|
||||
let mask: i32 = 0b0111_1111;
|
||||
|
||||
for _ in 0 .. 5 {
|
||||
let tmp = (reading_value & mask) as u8;
|
||||
|
||||
reading_value &= !mask;
|
||||
reading_value = reading_value.rotate_right(7);
|
||||
|
||||
if reading_value == 0 {
|
||||
bytes.push(tmp);
|
||||
break;
|
||||
}
|
||||
|
||||
bytes.push(tmp | msb);
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
pub(crate) fn get_string<B: ByteOrder>(buffer: &mut Buffer<B>) -> GDResult<String> {
|
||||
let length = get_varint(buffer)? as usize;
|
||||
let mut text = Vec::with_capacity(length);
|
||||
|
||||
for _ in 0 .. length {
|
||||
text.push(buffer.read::<u8>()?)
|
||||
}
|
||||
|
||||
String::from_utf8(text).map_err(|e| PacketBad.context(e))
|
||||
}
|
||||
|
||||
pub(crate) fn as_string(value: &str) -> GDResult<Vec<u8>> {
|
||||
let length = value
|
||||
.len()
|
||||
.try_into()
|
||||
.map_err(|e| InvalidInput.context(e))?;
|
||||
let mut buf = as_varint(length);
|
||||
buf.extend(value.as_bytes());
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{as_string, as_varint, get_varint};
|
||||
use crate::buffer::Buffer;
|
||||
use crate::minecraft::get_string;
|
||||
use byteorder::LittleEndian;
|
||||
|
||||
#[test]
|
||||
fn int_as_varint() {
|
||||
assert_eq!(as_varint(1), [1]);
|
||||
assert_eq!(as_varint(25565), [221, 199, 1]);
|
||||
assert_eq!(as_varint(1298923567), [175, 128, 176, 235, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varint_as_int() {
|
||||
let mut buffer = Buffer::<LittleEndian>::new(&[1, 127, 221, 199, 1, 0]);
|
||||
assert_eq!(get_varint(&mut buffer), Ok(1));
|
||||
assert_eq!(get_varint(&mut buffer), Ok(127));
|
||||
assert_eq!(get_varint(&mut buffer), Ok(25565));
|
||||
assert_eq!(buffer.remaining_bytes(), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_as_minecraft_string() {
|
||||
assert_eq!(as_string("A"), Ok(vec![1, 65]));
|
||||
assert_eq!(
|
||||
as_string("VarString"),
|
||||
Ok(vec![9, 86, 97, 114, 83, 116, 114, 105, 110, 103])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minecraft_get_string() {
|
||||
let mut buffer = Buffer::<LittleEndian>::new(&[3, 65, 65, 65, 1, 66]);
|
||||
assert_eq!(get_string(&mut buffer), Ok("AAA".to_string()));
|
||||
assert_eq!(get_string(&mut buffer), Ok("B".to_string()));
|
||||
assert_eq!(buffer.remaining_length(), 0);
|
||||
}
|
||||
}
|
||||
8
crates/lib/src/games/minetest/mod.rs
Normal file
8
crates/lib/src/games/minetest/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// The implementation.
|
||||
/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/minetest.js)
|
||||
pub mod protocol;
|
||||
/// All types used by the implementation.
|
||||
pub mod types;
|
||||
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
23
crates/lib/src/games/minetest/protocol.rs
Normal file
23
crates/lib/src/games/minetest/protocol.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use crate::minetest::Response;
|
||||
use crate::{minetest_master_server, GDErrorKind, GDResult, TimeoutSettings};
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub fn query(address: &IpAddr, port: Option<u16>) -> GDResult<Response> { query_with_timeout(address, port, &None) }
|
||||
|
||||
pub fn query_with_timeout(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
) -> GDResult<Response> {
|
||||
let address = address.to_string();
|
||||
let port = port.unwrap_or(30000);
|
||||
|
||||
let servers = minetest_master_server::query(timeout_settings.unwrap_or_default())?;
|
||||
for server in servers.list {
|
||||
if server.ip == address && server.port == port {
|
||||
return Ok(server.into());
|
||||
}
|
||||
}
|
||||
|
||||
Err(GDErrorKind::AutoQuery.context("Server not found in the master query list."))
|
||||
}
|
||||
108
crates/lib/src/games/minetest/types.rs
Normal file
108
crates/lib/src/games/minetest/types.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use crate::minetest_master_server::Server;
|
||||
use crate::protocols::types::{CommonPlayer, CommonResponse, GenericPlayer};
|
||||
use crate::protocols::GenericResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Player {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl CommonPlayer for Player {
|
||||
fn as_original(&self) -> GenericPlayer { GenericPlayer::Minetest(self) }
|
||||
|
||||
fn name(&self) -> &str { &self.name }
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Response {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub game_version: String,
|
||||
pub players_maximum: u32,
|
||||
pub players_online: u32,
|
||||
pub has_password: Option<bool>,
|
||||
pub players: Vec<Player>,
|
||||
pub id: String,
|
||||
pub ip: String,
|
||||
pub port: u16,
|
||||
pub creative: Option<bool>,
|
||||
pub damage: bool,
|
||||
pub game_time: u32,
|
||||
pub lag: Option<f32>,
|
||||
pub proto_max: u16,
|
||||
pub proto_min: u16,
|
||||
pub pvp: bool,
|
||||
pub uptime: u32,
|
||||
pub url: Option<String>,
|
||||
pub update_time: u32,
|
||||
pub start: u32,
|
||||
pub clients_top: u32,
|
||||
pub updates: u32,
|
||||
pub pop_v: f32,
|
||||
pub geo_continent: Option<String>,
|
||||
pub ping: f32,
|
||||
}
|
||||
|
||||
impl From<Server> for Response {
|
||||
fn from(server: Server) -> Self {
|
||||
Self {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
game_version: server.version,
|
||||
players_maximum: server.clients_max,
|
||||
players_online: server.total_clients,
|
||||
has_password: server.password,
|
||||
players: server
|
||||
.clients_list
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|name| Player { name })
|
||||
.collect(),
|
||||
ip: server.address,
|
||||
creative: server.creative,
|
||||
damage: server.damage,
|
||||
game_time: server.game_time,
|
||||
id: server.gameid,
|
||||
lag: server.lag,
|
||||
port: server.port,
|
||||
proto_max: server.proto_max,
|
||||
proto_min: server.proto_min,
|
||||
pvp: server.pvp,
|
||||
uptime: server.uptime,
|
||||
url: server.url,
|
||||
update_time: server.update_time,
|
||||
start: server.start,
|
||||
clients_top: server.clients_top,
|
||||
updates: server.updates,
|
||||
pop_v: server.pop_v,
|
||||
geo_continent: server.geo_continent,
|
||||
ping: server.ping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonResponse for Response {
|
||||
fn as_original(&self) -> GenericResponse { GenericResponse::Minetest(self) }
|
||||
|
||||
fn name(&self) -> Option<&str> { Some(&self.name) }
|
||||
|
||||
fn description(&self) -> Option<&str> { Some(&self.description) }
|
||||
|
||||
fn game_version(&self) -> Option<&str> { Some(&self.game_version) }
|
||||
|
||||
fn players_maximum(&self) -> u32 { self.players_maximum }
|
||||
|
||||
fn players_online(&self) -> u32 { self.players_online }
|
||||
|
||||
fn has_password(&self) -> Option<bool> { self.has_password }
|
||||
|
||||
fn players(&self) -> Option<Vec<&dyn CommonPlayer>> {
|
||||
Some(
|
||||
self.players
|
||||
.iter()
|
||||
.map(|p| p as &dyn CommonPlayer)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
50
crates/lib/src/games/mod.rs
Normal file
50
crates/lib/src/games/mod.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! Currently supported games.
|
||||
|
||||
#[cfg(feature = "tls")]
|
||||
pub mod epic;
|
||||
pub mod gamespy;
|
||||
pub mod quake;
|
||||
pub mod unreal2;
|
||||
pub mod valve;
|
||||
|
||||
#[cfg(all(feature = "tls", feature = "serde", feature = "services"))]
|
||||
pub mod minetest;
|
||||
|
||||
#[cfg(feature = "tls")]
|
||||
pub use epic::*;
|
||||
pub use gamespy::*;
|
||||
pub use quake::*;
|
||||
pub use unreal2::*;
|
||||
pub use valve::*;
|
||||
|
||||
#[cfg(all(feature = "tls", feature = "serde", feature = "services"))]
|
||||
pub use minetest::*;
|
||||
|
||||
/// Battalion 1944
|
||||
pub mod battalion1944;
|
||||
/// Eco
|
||||
pub mod eco;
|
||||
/// Frontlines: Fuel of War
|
||||
pub mod ffow;
|
||||
/// Just Cause 2: Multiplayer
|
||||
pub mod jc2m;
|
||||
/// Mindustry
|
||||
pub mod mindustry;
|
||||
/// Minecraft
|
||||
pub mod minecraft;
|
||||
/// Savage 2
|
||||
pub mod savage2;
|
||||
/// The Ship
|
||||
pub mod theship;
|
||||
|
||||
pub mod types;
|
||||
pub use types::*;
|
||||
|
||||
pub mod query;
|
||||
pub use query::*;
|
||||
|
||||
#[cfg(feature = "game_defs")]
|
||||
mod definitions;
|
||||
|
||||
#[cfg(feature = "game_defs")]
|
||||
pub use definitions::GAMES;
|
||||
9
crates/lib/src/games/quake.rs
Normal file
9
crates/lib/src/games/quake.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//! Quake game query modules
|
||||
|
||||
use crate::protocols::quake::game_query_mod;
|
||||
|
||||
game_query_mod!(quake1, "Quake 1", one, 27500);
|
||||
game_query_mod!(quake2, "Quake 2", two, 27910);
|
||||
game_query_mod!(q3a, "Quake 3 Arena", three, 27960);
|
||||
game_query_mod!(sof2, "Soldier of Fortune 2", three, 20100);
|
||||
game_query_mod!(warsow, "Warsow", three, 44400);
|
||||
137
crates/lib/src/games/query.rs
Normal file
137
crates/lib/src/games/query.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! Generic query functions
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
#[cfg(all(feature = "services", feature = "tls", feature = "serde"))]
|
||||
use crate::games::minetest;
|
||||
use crate::games::types::Game;
|
||||
use crate::games::{eco, ffow, jc2m, mindustry, minecraft, savage2, theship};
|
||||
use crate::protocols;
|
||||
use crate::protocols::gamespy::GameSpyVersion;
|
||||
use crate::protocols::quake::QuakeVersion;
|
||||
use crate::protocols::types::{CommonResponse, ExtraRequestSettings, ProprietaryProtocol, Protocol, TimeoutSettings};
|
||||
use crate::GDResult;
|
||||
|
||||
/// Make a query given a game definition
|
||||
#[inline]
|
||||
pub fn query(game: &Game, address: &IpAddr, port: Option<u16>) -> GDResult<Box<dyn CommonResponse>> {
|
||||
query_with_timeout_and_extra_settings(game, address, port, None, None)
|
||||
}
|
||||
|
||||
/// Make a query given a game definition and timeout settings
|
||||
#[inline]
|
||||
pub fn query_with_timeout(
|
||||
game: &Game,
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
) -> GDResult<Box<dyn CommonResponse>> {
|
||||
query_with_timeout_and_extra_settings(game, address, port, timeout_settings, None)
|
||||
}
|
||||
|
||||
/// Make a query given a game definition, timeout settings, and extra settings
|
||||
pub fn query_with_timeout_and_extra_settings(
|
||||
game: &Game,
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
extra_settings: Option<ExtraRequestSettings>,
|
||||
) -> GDResult<Box<dyn CommonResponse>> {
|
||||
let socket_addr = SocketAddr::new(*address, port.unwrap_or(game.default_port));
|
||||
Ok(match &game.protocol {
|
||||
Protocol::Valve(engine) => {
|
||||
protocols::valve::query(
|
||||
&socket_addr,
|
||||
*engine,
|
||||
extra_settings
|
||||
.or_else(|| Option::from(game.request_settings.clone()))
|
||||
.map(ExtraRequestSettings::into),
|
||||
timeout_settings,
|
||||
)
|
||||
.map(Box::new)?
|
||||
}
|
||||
#[cfg(feature = "tls")]
|
||||
Protocol::Epic(credentials) => {
|
||||
protocols::epic::query_with_timeout(credentials.clone(), &socket_addr, timeout_settings).map(Box::new)?
|
||||
}
|
||||
Protocol::Gamespy(version) => {
|
||||
match version {
|
||||
GameSpyVersion::One => protocols::gamespy::one::query(&socket_addr, timeout_settings).map(Box::new)?,
|
||||
GameSpyVersion::Two => protocols::gamespy::two::query(&socket_addr, timeout_settings).map(Box::new)?,
|
||||
GameSpyVersion::Three => {
|
||||
protocols::gamespy::three::query(&socket_addr, timeout_settings).map(Box::new)?
|
||||
}
|
||||
}
|
||||
}
|
||||
Protocol::Quake(version) => {
|
||||
match version {
|
||||
QuakeVersion::One => protocols::quake::one::query(&socket_addr, timeout_settings).map(Box::new)?,
|
||||
QuakeVersion::Two => protocols::quake::two::query(&socket_addr, timeout_settings).map(Box::new)?,
|
||||
QuakeVersion::Three => protocols::quake::three::query(&socket_addr, timeout_settings).map(Box::new)?,
|
||||
}
|
||||
}
|
||||
Protocol::Unreal2 => {
|
||||
protocols::unreal2::query(
|
||||
&socket_addr,
|
||||
&extra_settings
|
||||
.map(ExtraRequestSettings::into)
|
||||
.unwrap_or_default(),
|
||||
timeout_settings,
|
||||
)
|
||||
.map(Box::new)?
|
||||
}
|
||||
Protocol::PROPRIETARY(protocol) => {
|
||||
match protocol {
|
||||
ProprietaryProtocol::Savage2 => {
|
||||
savage2::query_with_timeout(address, port, timeout_settings).map(Box::new)?
|
||||
}
|
||||
ProprietaryProtocol::TheShip => {
|
||||
theship::query_with_timeout(address, port, timeout_settings).map(Box::new)?
|
||||
}
|
||||
ProprietaryProtocol::FFOW => ffow::query_with_timeout(address, port, timeout_settings).map(Box::new)?,
|
||||
ProprietaryProtocol::JC2M => jc2m::query_with_timeout(address, port, timeout_settings).map(Box::new)?,
|
||||
ProprietaryProtocol::Mindustry => mindustry::query(address, port, &timeout_settings).map(Box::new)?,
|
||||
ProprietaryProtocol::Minecraft(version) => {
|
||||
match version {
|
||||
Some(minecraft::Server::Java) => {
|
||||
minecraft::protocol::query_java(
|
||||
&socket_addr,
|
||||
timeout_settings,
|
||||
extra_settings.map(ExtraRequestSettings::into),
|
||||
)
|
||||
.map(Box::new)?
|
||||
}
|
||||
Some(minecraft::Server::Bedrock) => {
|
||||
minecraft::protocol::query_bedrock(&socket_addr, timeout_settings).map(Box::new)?
|
||||
}
|
||||
Some(minecraft::Server::Legacy(group)) => {
|
||||
minecraft::protocol::query_legacy_specific(*group, &socket_addr, timeout_settings)
|
||||
.map(Box::new)?
|
||||
}
|
||||
None => {
|
||||
minecraft::protocol::query(
|
||||
&socket_addr,
|
||||
timeout_settings,
|
||||
extra_settings.map(ExtraRequestSettings::into),
|
||||
)
|
||||
.map(Box::new)?
|
||||
}
|
||||
}
|
||||
}
|
||||
ProprietaryProtocol::Eco => {
|
||||
eco::query_with_timeout_and_extra_settings(
|
||||
address,
|
||||
port,
|
||||
&timeout_settings,
|
||||
extra_settings.map(ExtraRequestSettings::into),
|
||||
)
|
||||
.map(Box::new)?
|
||||
}
|
||||
#[cfg(all(feature = "services", feature = "tls", feature = "serde"))]
|
||||
ProprietaryProtocol::Minetest => {
|
||||
minetest::query_with_timeout(address, port, &timeout_settings).map(Box::new)?
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
8
crates/lib/src/games/savage2/mod.rs
Normal file
8
crates/lib/src/games/savage2/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// The implementation.
|
||||
/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/savage2.js)
|
||||
pub mod protocol;
|
||||
/// All types used by the implementation.
|
||||
pub mod types;
|
||||
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
37
crates/lib/src/games/savage2/protocol.rs
Normal file
37
crates/lib/src/games/savage2/protocol.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use crate::buffer::{Buffer, Utf8Decoder};
|
||||
use crate::games::savage2::types::Response;
|
||||
use crate::protocols::types::TimeoutSettings;
|
||||
use crate::socket::{Socket, UdpSocket};
|
||||
use crate::GDResult;
|
||||
use byteorder::LittleEndian;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
pub fn query(address: &IpAddr, port: Option<u16>) -> GDResult<Response> { query_with_timeout(address, port, None) }
|
||||
|
||||
pub fn query_with_timeout(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
) -> GDResult<Response> {
|
||||
let addr = &SocketAddr::new(*address, port.unwrap_or(11235));
|
||||
let mut socket = UdpSocket::new(addr, &timeout_settings)?;
|
||||
socket.send(&[0x01])?;
|
||||
let data = socket.receive(None)?;
|
||||
let mut buffer = Buffer::<LittleEndian>::new(&data);
|
||||
|
||||
buffer.move_cursor(12)?;
|
||||
|
||||
Ok(Response {
|
||||
name: buffer.read_string::<Utf8Decoder>(None)?,
|
||||
players_online: buffer.read::<u8>()?,
|
||||
players_maximum: buffer.read::<u8>()?,
|
||||
time: buffer.read_string::<Utf8Decoder>(None)?,
|
||||
map: buffer.read_string::<Utf8Decoder>(None)?,
|
||||
next_map: buffer.read_string::<Utf8Decoder>(None)?,
|
||||
location: buffer.read_string::<Utf8Decoder>(None)?,
|
||||
players_minimum: buffer.read::<u8>()?,
|
||||
game_mode: buffer.read_string::<Utf8Decoder>(None)?,
|
||||
protocol_version: buffer.read_string::<Utf8Decoder>(None)?,
|
||||
level_minimum: buffer.read::<u8>()?,
|
||||
})
|
||||
}
|
||||
30
crates/lib/src/games/savage2/types.rs
Normal file
30
crates/lib/src/games/savage2/types.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use crate::protocols::types::CommonResponse;
|
||||
use crate::protocols::GenericResponse;
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Response {
|
||||
pub name: String,
|
||||
pub players_online: u8,
|
||||
pub players_maximum: u8,
|
||||
pub players_minimum: u8,
|
||||
pub time: String,
|
||||
pub map: String,
|
||||
pub next_map: String,
|
||||
pub location: String,
|
||||
pub game_mode: String,
|
||||
pub protocol_version: String,
|
||||
pub level_minimum: u8,
|
||||
}
|
||||
|
||||
impl CommonResponse for Response {
|
||||
fn as_original(&self) -> GenericResponse<'_> { GenericResponse::Savage2(self) }
|
||||
|
||||
fn name(&self) -> Option<&str> { Some(&self.name) }
|
||||
fn game_mode(&self) -> Option<&str> { Some(&self.game_mode) }
|
||||
fn map(&self) -> Option<&str> { Some(&self.map) }
|
||||
fn players_maximum(&self) -> u32 { self.players_maximum.into() }
|
||||
fn players_online(&self) -> u32 { self.players_online.into() }
|
||||
}
|
||||
8
crates/lib/src/games/theship/mod.rs
Normal file
8
crates/lib/src/games/theship/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// The implementation.
|
||||
/// Reference: [server queries](https://developer.valvesoftware.com/wiki/Server_queries)
|
||||
pub mod protocol;
|
||||
/// All types used by the implementation.
|
||||
pub mod types;
|
||||
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
23
crates/lib/src/games/theship/protocol.rs
Normal file
23
crates/lib/src/games/theship/protocol.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use crate::games::theship::types::Response;
|
||||
use crate::protocols::types::TimeoutSettings;
|
||||
use crate::protocols::valve;
|
||||
use crate::protocols::valve::Engine;
|
||||
use crate::GDResult;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
pub fn query(address: &IpAddr, port: Option<u16>) -> GDResult<Response> { query_with_timeout(address, port, None) }
|
||||
|
||||
pub fn query_with_timeout(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
) -> GDResult<Response> {
|
||||
let valve_response = valve::query(
|
||||
&SocketAddr::new(*address, port.unwrap_or(27015)),
|
||||
Engine::new(2400),
|
||||
None,
|
||||
timeout_settings,
|
||||
)?;
|
||||
|
||||
Response::new_from_valve_response(valve_response)
|
||||
}
|
||||
122
crates/lib/src/games/theship/types.rs
Normal file
122
crates/lib/src/games/theship/types.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use crate::protocols::types::{CommonPlayer, CommonResponse, GenericPlayer};
|
||||
use crate::protocols::valve::{get_optional_extracted_data, Server, ServerPlayer};
|
||||
use crate::protocols::{valve, GenericResponse};
|
||||
use crate::GDErrorKind::PacketBad;
|
||||
use crate::GDResult;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
||||
pub struct TheShipPlayer {
|
||||
pub name: String,
|
||||
pub score: i32,
|
||||
pub duration: f32,
|
||||
pub deaths: u32,
|
||||
pub money: u32,
|
||||
}
|
||||
|
||||
impl TheShipPlayer {
|
||||
pub fn new_from_valve_player(player: &ServerPlayer) -> GDResult<Self> {
|
||||
Ok(Self {
|
||||
name: player.name.clone(),
|
||||
score: player.score,
|
||||
duration: player.duration,
|
||||
deaths: player.deaths.ok_or(PacketBad)?,
|
||||
money: player.money.ok_or(PacketBad)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonPlayer for TheShipPlayer {
|
||||
fn as_original(&self) -> GenericPlayer<'_> { GenericPlayer::TheShip(self) }
|
||||
|
||||
fn name(&self) -> &str { &self.name }
|
||||
fn score(&self) -> Option<i32> { Some(self.score) }
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Response {
|
||||
pub protocol_version: u8,
|
||||
pub name: String,
|
||||
pub map: String,
|
||||
pub game_mode: String,
|
||||
pub game_version: String,
|
||||
pub players: Vec<TheShipPlayer>,
|
||||
pub players_online: u8,
|
||||
pub players_maximum: u8,
|
||||
pub players_bots: u8,
|
||||
pub server_type: Server,
|
||||
pub has_password: bool,
|
||||
pub vac_secured: bool,
|
||||
pub port: Option<u16>,
|
||||
pub steam_id: Option<u64>,
|
||||
pub tv_port: Option<u16>,
|
||||
pub tv_name: Option<String>,
|
||||
pub keywords: Option<String>,
|
||||
pub rules: HashMap<String, String>,
|
||||
pub mode: u8,
|
||||
pub witnesses: u8,
|
||||
pub duration: u8,
|
||||
}
|
||||
|
||||
impl CommonResponse for Response {
|
||||
fn as_original(&self) -> GenericResponse<'_> { GenericResponse::TheShip(self) }
|
||||
|
||||
fn name(&self) -> Option<&str> { Some(&self.name) }
|
||||
fn map(&self) -> Option<&str> { Some(&self.map) }
|
||||
fn game_mode(&self) -> Option<&str> { Some(&self.game_mode) }
|
||||
fn players_maximum(&self) -> u32 { self.players_maximum.into() }
|
||||
fn players_online(&self) -> u32 { self.players_online.into() }
|
||||
fn players_bots(&self) -> Option<u32> { Some(self.players_bots.into()) }
|
||||
fn has_password(&self) -> Option<bool> { Some(self.has_password) }
|
||||
|
||||
fn players(&self) -> Option<Vec<&dyn CommonPlayer>> {
|
||||
Some(
|
||||
self.players
|
||||
.iter()
|
||||
.map(|p| p as &dyn CommonPlayer)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn new_from_valve_response(response: valve::Response) -> GDResult<Self> {
|
||||
let (port, steam_id, tv_port, tv_name, keywords) = get_optional_extracted_data(response.info.extra_data);
|
||||
|
||||
let the_unwrapped_ship = response.info.the_ship.ok_or(PacketBad)?;
|
||||
|
||||
Ok(Self {
|
||||
protocol_version: response.info.protocol_version,
|
||||
name: response.info.name,
|
||||
map: response.info.map,
|
||||
game_mode: response.info.game_mode,
|
||||
game_version: response.info.game_version,
|
||||
players_online: response.info.players_online,
|
||||
players: response
|
||||
.players
|
||||
.ok_or(PacketBad)?
|
||||
.iter()
|
||||
.map(TheShipPlayer::new_from_valve_player)
|
||||
.collect::<GDResult<Vec<TheShipPlayer>>>()?,
|
||||
players_maximum: response.info.players_maximum,
|
||||
players_bots: response.info.players_bots,
|
||||
server_type: response.info.server_type,
|
||||
has_password: response.info.has_password,
|
||||
vac_secured: response.info.vac_secured,
|
||||
port,
|
||||
steam_id,
|
||||
tv_port,
|
||||
tv_name,
|
||||
keywords,
|
||||
rules: response.rules.ok_or(PacketBad)?,
|
||||
mode: the_unwrapped_ship.mode,
|
||||
witnesses: the_unwrapped_ship.witnesses,
|
||||
duration: the_unwrapped_ship.duration,
|
||||
})
|
||||
}
|
||||
}
|
||||
20
crates/lib/src/games/types.rs
Normal file
20
crates/lib/src/games/types.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Game related types
|
||||
|
||||
use crate::protocols::types::{ExtraRequestSettings, Protocol};
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Definition of a game
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Game {
|
||||
/// Full name of the game
|
||||
pub name: &'static str,
|
||||
/// Default port used by game
|
||||
pub default_port: u16,
|
||||
/// The protocol the game's query uses
|
||||
pub protocol: Protocol,
|
||||
/// Request settings.
|
||||
pub request_settings: ExtraRequestSettings,
|
||||
}
|
||||
10
crates/lib/src/games/unreal2.rs
Normal file
10
crates/lib/src/games/unreal2.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//! Unreal2 game query modules
|
||||
|
||||
use crate::protocols::unreal2::game_query_mod;
|
||||
|
||||
game_query_mod!(darkesthour, "Darkest Hour: Europe '44-'45 (2008)", 7758);
|
||||
game_query_mod!(devastation, "Devastation (2003)", 7778);
|
||||
game_query_mod!(killingfloor, "Killing Floor", 7708);
|
||||
game_query_mod!(redorchestra, "Red Orchestra", 7759);
|
||||
game_query_mod!(ut2003, "Unreal Tournament 2003", 7758);
|
||||
game_query_mod!(ut2004, "Unreal Tournament 2004", 7778);
|
||||
201
crates/lib/src/games/valve.rs
Normal file
201
crates/lib/src/games/valve.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! Valve game query modules
|
||||
|
||||
use crate::protocols::valve::game_query_mod;
|
||||
|
||||
game_query_mod!(abioticfactor, "Abiotic Factor", Engine::new(427_410), 27015);
|
||||
game_query_mod!(
|
||||
a2oa,
|
||||
"ARMA 2: Operation Arrowhead",
|
||||
Engine::new(33930),
|
||||
2304
|
||||
);
|
||||
game_query_mod!(arma3, "ARMA 3", Engine::new(107_410), 2303);
|
||||
game_query_mod!(basedefense, "Base Defense", Engine::new(632_730), 27015);
|
||||
game_query_mod!(alienswarm, "Alien Swarm", Engine::new(630), 27015);
|
||||
game_query_mod!(aoc, "Age of Chivalry", Engine::new(17510), 27015);
|
||||
game_query_mod!(
|
||||
aapg,
|
||||
"America's Army: Proving Grounds",
|
||||
Engine::new(203_290),
|
||||
27020,
|
||||
GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Skip,
|
||||
check_app_id: true,
|
||||
}
|
||||
);
|
||||
game_query_mod!(ase, "ARK: Survival Evolved", Engine::new(346_110), 27015);
|
||||
game_query_mod!(
|
||||
asrd,
|
||||
"Alien Swarm: Reactive Drop",
|
||||
Engine::new(563_560),
|
||||
2304
|
||||
);
|
||||
game_query_mod!(atlas, "ATLAS", Engine::new(834_910), 57561);
|
||||
game_query_mod!(avorion, "Avorion", Engine::new(445_220), 27020);
|
||||
game_query_mod!(
|
||||
ballisticoverkill,
|
||||
"Ballistic Overkill",
|
||||
Engine::new(296_300),
|
||||
27016
|
||||
);
|
||||
game_query_mod!(
|
||||
armareforger,
|
||||
"Arma Reforger",
|
||||
Engine::new(0),
|
||||
17777,
|
||||
GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Enforce,
|
||||
check_app_id: false,
|
||||
}
|
||||
);
|
||||
game_query_mod!(
|
||||
avp2010,
|
||||
"Aliens vs. Predator 2010",
|
||||
Engine::new(10_680),
|
||||
27015
|
||||
);
|
||||
game_query_mod!(barotrauma, "Barotrauma", Engine::new(602_960), 27016);
|
||||
game_query_mod!(blackmesa, "Black Mesa", Engine::new(362_890), 27015);
|
||||
game_query_mod!(brainbread2, "BrainBread 2", Engine::new(346_330), 27015);
|
||||
game_query_mod!(
|
||||
codbo3,
|
||||
"Call Of Duty: Black Ops 3",
|
||||
Engine::new(311_210),
|
||||
27017
|
||||
);
|
||||
game_query_mod!(codenamecure, "Codename CURE", Engine::new(355_180), 27015);
|
||||
game_query_mod!(
|
||||
colonysurvival,
|
||||
"Colony Survival",
|
||||
Engine::new(366_090),
|
||||
27004
|
||||
);
|
||||
game_query_mod!(
|
||||
conanexiles,
|
||||
"Conan Exiles",
|
||||
Engine::new(440_900),
|
||||
27015,
|
||||
GatheringSettings {
|
||||
players: GatherToggle::Skip,
|
||||
rules: GatherToggle::Enforce,
|
||||
check_app_id: true,
|
||||
}
|
||||
);
|
||||
game_query_mod!(
|
||||
counterstrike,
|
||||
"Counter-Strike",
|
||||
Engine::new_gold_src(false),
|
||||
27015
|
||||
);
|
||||
game_query_mod!(counterstrike2, "Counter-Strike 2", Engine::new(730), 27015);
|
||||
game_query_mod!(creativerse, "Creativerse", Engine::new(280_790), 26901);
|
||||
game_query_mod!(
|
||||
cscz,
|
||||
"Counter Strike: Condition Zero",
|
||||
Engine::new_gold_src(false),
|
||||
27015
|
||||
);
|
||||
game_query_mod!(
|
||||
csgo,
|
||||
"Counter-Strike: Global Offensive",
|
||||
Engine::new(730),
|
||||
27015
|
||||
);
|
||||
game_query_mod!(css, "Counter-Strike: Source", Engine::new(240), 27015);
|
||||
game_query_mod!(dab, "Double Action: Boogaloo", Engine::new(317_360), 27015);
|
||||
game_query_mod!(dod, "Day of Defeat", Engine::new_gold_src(false), 27015);
|
||||
game_query_mod!(dods, "Day of Defeat: Source", Engine::new(300), 27015);
|
||||
game_query_mod!(doi, "Day of Infamy", Engine::new(447_820), 27015);
|
||||
game_query_mod!(dst, "Don't Starve Together", Engine::new(322_320), 27016);
|
||||
game_query_mod!(enshrouded, "Enshrouded", Engine::new(1_203_620), 15637);
|
||||
game_query_mod!(garrysmod, "Garry's Mod", Engine::new(4000), 27016);
|
||||
game_query_mod!(hl2d, "Half-Life 2 Deathmatch", Engine::new(320), 27015);
|
||||
game_query_mod!(
|
||||
hlds,
|
||||
"Half-Life Deathmatch: Source",
|
||||
Engine::new(360),
|
||||
27015
|
||||
);
|
||||
game_query_mod!(hll, "Hell Let Loose", Engine::new(686_810), 26420);
|
||||
game_query_mod!(
|
||||
imic,
|
||||
"Insurgency: Modern Infantry Combat",
|
||||
Engine::new(17700),
|
||||
27015
|
||||
);
|
||||
game_query_mod!(insurgency, "Insurgency", Engine::new(222_880), 27015);
|
||||
game_query_mod!(
|
||||
insurgencysandstorm,
|
||||
"Insurgency: Sandstorm",
|
||||
Engine::new(581_320),
|
||||
27131
|
||||
);
|
||||
game_query_mod!(l4d, "Left 4 Dead", Engine::new(500), 27015);
|
||||
game_query_mod!(l4d2, "Left 4 Dead 2", Engine::new(550), 27015);
|
||||
game_query_mod!(
|
||||
ohd,
|
||||
"Operation: Harsh Doorstop",
|
||||
Engine::new_with_dedicated(736_590, 950_900),
|
||||
27005
|
||||
);
|
||||
game_query_mod!(onset, "Onset", Engine::new(1_105_810), 7776);
|
||||
game_query_mod!(postscriptum, "Post Scriptum", Engine::new(736_220), 10037);
|
||||
game_query_mod!(
|
||||
projectzomboid,
|
||||
"Project Zomboid",
|
||||
Engine::new(108_600),
|
||||
16261
|
||||
);
|
||||
game_query_mod!(risingworld, "Rising World", Engine::new(324_080), 4254);
|
||||
game_query_mod!(ror2, "Risk of Rain 2", Engine::new(632_360), 27016);
|
||||
game_query_mod!(rust, "Rust", Engine::new(252_490), 27015);
|
||||
game_query_mod!(sco, "Sven Co-op", Engine::new_gold_src(false), 27015);
|
||||
game_query_mod!(sdtd, "7 Days to Die", Engine::new(251_570), 26900);
|
||||
game_query_mod!(soulmask, "Soulmask", Engine::new(2_646_460), 27015);
|
||||
game_query_mod!(squad, "Squad", Engine::new(393_380), 27165);
|
||||
game_query_mod!(
|
||||
starbound,
|
||||
"Starbound",
|
||||
Engine::new(211_820),
|
||||
21025,
|
||||
GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Enforce,
|
||||
check_app_id: false,
|
||||
}
|
||||
);
|
||||
game_query_mod!(teamfortress2, "Team Fortress 2", Engine::new(440), 27015);
|
||||
game_query_mod!(
|
||||
tfc,
|
||||
"Team Fortress Classic",
|
||||
Engine::new_gold_src(false),
|
||||
27015
|
||||
);
|
||||
game_query_mod!(theforest, "The Forest", Engine::new(556_450), 27016);
|
||||
game_query_mod!(thefront, "The Front", Engine::new(2_285_150), 27015);
|
||||
game_query_mod!(unturned, "Unturned", Engine::new(304_930), 27015);
|
||||
game_query_mod!(
|
||||
valheim,
|
||||
"Valheim",
|
||||
Engine::new(892_970),
|
||||
2457,
|
||||
GatheringSettings {
|
||||
players: GatherToggle::Enforce,
|
||||
rules: GatherToggle::Skip,
|
||||
check_app_id: true,
|
||||
}
|
||||
);
|
||||
game_query_mod!(vrising, "V Rising", Engine::new(1_604_030), 27016);
|
||||
game_query_mod!(zps, "Zombie Panic: Source", Engine::new(17_500), 27015);
|
||||
game_query_mod!(moe, "Myth of Empires", Engine::new(1_371_580), 12888);
|
||||
game_query_mod!(mordhau, "Mordhau", Engine::new(629_760), 27015);
|
||||
game_query_mod!(
|
||||
pvak2,
|
||||
"Pirates, Vikings, and Knights II",
|
||||
Engine::new(17_570),
|
||||
27015
|
||||
);
|
||||
game_query_mod!(nla, "Nova-Life: Amboise", Engine::new(885_570), 27015);
|
||||
game_query_mod!(pixark, "PixARK", Engine::new(593_600), 27015);
|
||||
491
crates/lib/src/http.rs
Normal file
491
crates/lib/src/http.rs
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
//! Client for making HTTP requests.
|
||||
//!
|
||||
//! This is the first draft implementation: feel free to change things to suit
|
||||
//! your needs.
|
||||
|
||||
// Because this is first draft some functionality is not used yet.
|
||||
// TODO: When this is used in more places remove this and refine the interface.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::GDErrorKind::{HostLookup, InvalidInput, PacketReceive, PacketSend, ProtocolFormat};
|
||||
use crate::{GDResult, TimeoutSettings};
|
||||
|
||||
use std::io::Read;
|
||||
use std::net::{SocketAddr, SocketAddrV4, SocketAddrV6, ToSocketAddrs};
|
||||
|
||||
use ureq::{Agent, AgentBuilder, Request};
|
||||
use url::{Host, Url};
|
||||
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
/// Max length of HTTP responses in bytes: 1GB
|
||||
const MAX_RESPONSE_LENGTH: usize = 1024 * 1024 * 1024;
|
||||
|
||||
/// HTTP request client. Define parameters host parameters on new, then re-use
|
||||
/// for each request.
|
||||
///
|
||||
/// When making requests directly to the server use [HttpClient::new] as this
|
||||
/// allows directly specifying the IP to connect to.
|
||||
///
|
||||
/// When requests must go through an intermediatary (that we don't know the IP
|
||||
/// of) use [HttpClient::from_url] which will perform a DNS lookup internally.
|
||||
///
|
||||
/// For example usage see [tests].
|
||||
pub struct HttpClient {
|
||||
client: Agent,
|
||||
address: Url,
|
||||
headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// HttpHeaders for use with a single request.
|
||||
pub type HttpHeaders<'a> = Option<&'a [(&'a str, &'a str)]>;
|
||||
|
||||
/// HTTP Protocols.
|
||||
///
|
||||
/// Note: if the `tls` feature is disabled this will only contain Http.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub enum HttpProtocol {
|
||||
#[default]
|
||||
Http,
|
||||
#[cfg(feature = "tls")]
|
||||
Https,
|
||||
}
|
||||
|
||||
impl HttpProtocol {
|
||||
/// Convert [Protocol] to a static str for use in a [Url].
|
||||
/// e.g. "http:"
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
use HttpProtocol::*;
|
||||
match self {
|
||||
Http => "http:",
|
||||
#[cfg(feature = "tls")]
|
||||
Https => "https:",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Additional settings for HTTPClients.
|
||||
///
|
||||
/// # Can be created using builder functions:
|
||||
/// ```ignore, We cannot test private functionality
|
||||
/// use gamedig::http::{HttpSettings, HttpProtocol};
|
||||
///
|
||||
/// let _ = HttpSettings::default()
|
||||
/// .protocol(HttpProtocol::Http)
|
||||
/// .hostname(String::from("test.com"))
|
||||
/// .header(String::from("Authorization"), String::from("Bearer Token"));
|
||||
/// ```
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq)]
|
||||
pub struct HttpSettings<S: Into<String>> {
|
||||
/// Choose whether to use HTTP or HTTPS.
|
||||
pub protocol: HttpProtocol,
|
||||
/// Choose a hostname override (used to set the [Host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) header) and for TLS.
|
||||
pub hostname: Option<S>,
|
||||
/// Choose HTTP headers to send with requests.
|
||||
pub headers: Vec<(S, S)>,
|
||||
}
|
||||
|
||||
impl<S: Into<String>> HttpSettings<S> {
|
||||
/// Set the HTTP protocol (defaults to HTTP).
|
||||
pub const fn protocol(mut self, protocol: HttpProtocol) -> HttpSettings<S> {
|
||||
self.protocol = protocol;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the desired HTTP host name: used for the HTTP Host header and for
|
||||
/// TLS negotiation.
|
||||
pub fn hostname(mut self, hostname: S) -> HttpSettings<S> {
|
||||
self.hostname = Some(hostname);
|
||||
self
|
||||
}
|
||||
|
||||
/// Overwrite all the current HTTP headers with new headers.
|
||||
pub fn headers(mut self, headers: Vec<(S, S)>) -> HttpSettings<S> {
|
||||
self.headers = headers;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set one HTTP header value.
|
||||
pub fn header(mut self, name: S, value: S) -> HttpSettings<S> {
|
||||
self.headers.push((name, value));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
/// Creates a new HTTPClient that can be used to send requests.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - [address](SocketAddr): The IP and port the HTTP request will connect
|
||||
/// to.
|
||||
/// - [timeout_settings](TimeoutSettings): Used to set the connect and
|
||||
/// socket timeouts for the requests.
|
||||
/// - [http_settings](HttpSettings): Additional settings for the HTTPClient.
|
||||
pub fn new<S: Into<String>>(
|
||||
address: &SocketAddr,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
http_settings: HttpSettings<S>,
|
||||
) -> GDResult<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let mut client_builder = AgentBuilder::new();
|
||||
|
||||
// Set timeout settings
|
||||
let (read_timeout, write_timeout) = TimeoutSettings::get_read_and_write_or_defaults(timeout_settings);
|
||||
|
||||
if let Some(read_timeout) = read_timeout {
|
||||
client_builder = client_builder.timeout_read(read_timeout);
|
||||
}
|
||||
|
||||
if let Some(write_timeout) = write_timeout {
|
||||
client_builder = client_builder.timeout_write(write_timeout);
|
||||
}
|
||||
|
||||
if let Some(connect_timeout) = TimeoutSettings::get_connect_or_default(timeout_settings) {
|
||||
client_builder = client_builder.timeout_connect(connect_timeout);
|
||||
}
|
||||
|
||||
// Every request sent from this client will connect to the address set
|
||||
{
|
||||
let address = *address;
|
||||
client_builder = client_builder.resolver(move |_: &str| Ok(vec![address]));
|
||||
}
|
||||
|
||||
// Set a friendly user-agent string
|
||||
client_builder = client_builder.user_agent(concat!(
|
||||
env!("CARGO_PKG_NAME"),
|
||||
"/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
));
|
||||
|
||||
let client = client_builder.build();
|
||||
|
||||
let host = http_settings
|
||||
.hostname
|
||||
.map(S::into)
|
||||
.unwrap_or_else(|| address.ip().to_string());
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
// TODO: Use Url from_parts if it gets added
|
||||
address: Url::parse(&format!(
|
||||
"{}//{}:{}",
|
||||
http_settings.protocol.as_str(),
|
||||
host,
|
||||
address.port()
|
||||
))
|
||||
.map_err(|e| InvalidInput.context(e))?,
|
||||
headers: http_settings
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.into(), v.into()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new HTTP client from a pre-existing URL, performing a DNS
|
||||
/// lookup on the host when necessary.
|
||||
///
|
||||
/// This is aimed to be used when we know the domain of the server but not
|
||||
/// the IP i.e. when the server is not the service being directly queried
|
||||
/// for server info.
|
||||
pub fn from_url<U: TryInto<Url>>(
|
||||
url: U,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
headers: Option<Vec<(&str, &str)>>,
|
||||
) -> GDResult<Self>
|
||||
where
|
||||
U::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let url: Url = url.try_into().map_err(|e| InvalidInput.context(e))?;
|
||||
|
||||
let host = url
|
||||
.host()
|
||||
.ok_or_else(|| InvalidInput.context("URL used to create a HTTPClient must have a host"))?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| InvalidInput.context("URL used to create HttpClient must have a port"))?;
|
||||
|
||||
let address = match host {
|
||||
Host::Ipv4(ip) => SocketAddr::V4(SocketAddrV4::new(ip, port)),
|
||||
Host::Ipv6(ip) => SocketAddr::V6(SocketAddrV6::new(ip, port, 0, 0)),
|
||||
Host::Domain(domain) => {
|
||||
format!("{domain}:{port}")
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| HostLookup.context(e))?
|
||||
.next()
|
||||
.ok_or_else(|| HostLookup.context("No socket addresses found for host"))?
|
||||
}
|
||||
};
|
||||
|
||||
let http_settings = HttpSettings {
|
||||
hostname: url.host_str(),
|
||||
protocol: match url.scheme() {
|
||||
#[cfg(feature = "tls")]
|
||||
"https" => HttpProtocol::Https,
|
||||
_ => HttpProtocol::Http,
|
||||
},
|
||||
headers: headers.unwrap_or_default(),
|
||||
};
|
||||
|
||||
Self::new(&address, timeout_settings, http_settings)
|
||||
}
|
||||
|
||||
/// Send a HTTP GET request and return the response data as a buffer.
|
||||
pub fn get(&mut self, path: &str, headers: HttpHeaders) -> GDResult<Vec<u8>> { self.request("GET", path, headers) }
|
||||
|
||||
/// Send a HTTP GET request and parse the JSON resonse.
|
||||
pub fn get_json<T: DeserializeOwned>(&mut self, path: &str, headers: HttpHeaders) -> GDResult<T> {
|
||||
self.request_json("GET", path, headers)
|
||||
}
|
||||
|
||||
/// Send a HTTP Post request with JSON data and parse a JSON response.
|
||||
pub fn post_json<T: DeserializeOwned, S: Serialize>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
headers: HttpHeaders,
|
||||
data: S,
|
||||
) -> GDResult<T> {
|
||||
self.request_with_json_data("POST", path, headers, data)
|
||||
}
|
||||
|
||||
/// Send a HTTP Post request with FORM data and parse a JSON response.
|
||||
pub fn post_json_with_form<T: DeserializeOwned>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
headers: HttpHeaders,
|
||||
data: &[(&str, &str)],
|
||||
) -> GDResult<T> {
|
||||
self.request_with_form_data("POST", path, headers, data)
|
||||
}
|
||||
|
||||
// NOTE: More methods can be added here as required using the request_json or
|
||||
// request_with_json methods
|
||||
|
||||
fn make_request(&self, method: &str, headers: HttpHeaders) -> Request {
|
||||
let mut request = self.client.request_url(method, &self.address);
|
||||
|
||||
// Set the request headers.
|
||||
for (key, value) in self.headers.iter() {
|
||||
request = request.set(key, value);
|
||||
}
|
||||
|
||||
if let Some(headers) = headers {
|
||||
for (key, value) in headers {
|
||||
request = request.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
request
|
||||
}
|
||||
|
||||
/// Internal request method, makes a request with an arbitrary HTTP method.
|
||||
#[inline]
|
||||
fn request(&mut self, method: &str, path: &str, headers: HttpHeaders) -> GDResult<Vec<u8>> {
|
||||
// Append the path to the pre-parsed URL and create a request object.
|
||||
self.address.set_path(path);
|
||||
let request = self.make_request(method, headers);
|
||||
|
||||
// Send the request.
|
||||
let http_response = request.call().map_err(|e| PacketSend.context(e))?;
|
||||
|
||||
let length = if let Some(length) = http_response.header("Content-Length") {
|
||||
length
|
||||
.parse::<usize>()
|
||||
.map_err(|e| ProtocolFormat.context(e))?
|
||||
.min(MAX_RESPONSE_LENGTH)
|
||||
} else {
|
||||
5012 // Sensible default allocation
|
||||
};
|
||||
|
||||
let mut buffer: Vec<u8> = Vec::with_capacity(length);
|
||||
|
||||
let _ = http_response
|
||||
.into_reader()
|
||||
.take(MAX_RESPONSE_LENGTH as u64)
|
||||
.read_to_end(&mut buffer)
|
||||
.map_err(|e| PacketReceive.context(e))?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// Send a HTTP request without any data and parse the JSON response.
|
||||
#[inline]
|
||||
fn request_json<T: DeserializeOwned>(&mut self, method: &str, path: &str, headers: HttpHeaders) -> GDResult<T> {
|
||||
// Append the path to the pre-parsed URL and create a request object.
|
||||
self.address.set_path(path);
|
||||
let request = self.make_request(method, headers);
|
||||
|
||||
// Send the request and parse the response as JSON.
|
||||
request
|
||||
.call()
|
||||
.map_err(|e| PacketSend.context(e))?
|
||||
.into_json::<T>()
|
||||
.map_err(|e| ProtocolFormat.context(e))
|
||||
}
|
||||
|
||||
/// Send a HTTP request with JSON data and parse the JSON response.
|
||||
#[inline]
|
||||
fn request_with_json_data<T: DeserializeOwned, S: Serialize>(
|
||||
&mut self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
headers: HttpHeaders,
|
||||
data: S,
|
||||
) -> GDResult<T> {
|
||||
self.address.set_path(path);
|
||||
let request = self.make_request(method, headers);
|
||||
|
||||
request
|
||||
.send_json(data)
|
||||
.map_err(|e| PacketSend.context(e))?
|
||||
.into_json::<T>()
|
||||
.map_err(|e| ProtocolFormat.context(e))
|
||||
}
|
||||
|
||||
/// Send a HTTP request with FORM data and parse the JSON response.
|
||||
#[inline]
|
||||
fn request_with_form_data<T: DeserializeOwned>(
|
||||
&mut self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
headers: HttpHeaders,
|
||||
data: &[(&str, &str)],
|
||||
) -> GDResult<T> {
|
||||
self.address.set_path(path);
|
||||
let request = self.make_request(method, headers);
|
||||
|
||||
request
|
||||
.send_form(data)
|
||||
.map_err(|e| PacketSend.context(e))?
|
||||
.into_json::<T>()
|
||||
.map_err(|e| ProtocolFormat.context(e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, SocketAddrV4, ToSocketAddrs};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn http_settings_builder() {
|
||||
const HOSTNAME: &str = "example.org";
|
||||
|
||||
#[cfg(feature = "tls")]
|
||||
const PROTOCOL: HttpProtocol = HttpProtocol::Https;
|
||||
#[cfg(not(feature = "tls"))]
|
||||
const PROTOCOL: HttpProtocol = HttpProtocol::Http;
|
||||
|
||||
let settings = HttpSettings::default()
|
||||
.hostname(HOSTNAME)
|
||||
.protocol(PROTOCOL)
|
||||
.header("Gamedig", "Is Awesome")
|
||||
.headers(vec![("Foo", "bar")])
|
||||
.header("Baz", "Buzz");
|
||||
|
||||
assert_eq!(settings.hostname, Some(HOSTNAME));
|
||||
assert_eq!(settings.protocol, PROTOCOL);
|
||||
assert_eq!(settings.headers, vec![("Foo", "bar"), ("Baz", "Buzz"),]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_client_new() {
|
||||
const PROTOCOL: HttpProtocol = HttpProtocol::Http;
|
||||
|
||||
const ADDRESS: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8000));
|
||||
|
||||
let settings = HttpSettings {
|
||||
protocol: PROTOCOL,
|
||||
hostname: Some("github.com"),
|
||||
headers: vec![("Authorization", "UUDDLRLRBA")],
|
||||
};
|
||||
|
||||
let client = HttpClient::new(&ADDRESS, &None, settings).unwrap();
|
||||
|
||||
assert_eq!(client.address.as_str(), "http://github.com:8000/");
|
||||
assert_eq!(
|
||||
client.headers,
|
||||
vec![(String::from("Authorization"), String::from("UUDDLRLRBA")),]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "tls")]
|
||||
#[test]
|
||||
#[ignore = "HTTP requests won't work without internet"]
|
||||
fn https_json_get_request() {
|
||||
let address = "api.github.com:443"
|
||||
.to_socket_addrs()
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let settings = HttpSettings::default()
|
||||
.protocol(HttpProtocol::Https)
|
||||
.hostname("api.github.com");
|
||||
|
||||
let mut client = HttpClient::new(&address, &None, settings).unwrap();
|
||||
|
||||
let response: serde_json::Value = client.get_json("/events", None).unwrap();
|
||||
|
||||
println!("{:?}", response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "HTTP requests won't work without internet"]
|
||||
fn http_json_get_request() {
|
||||
let address = "postman-echo.com:80"
|
||||
.to_socket_addrs()
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let settings = HttpSettings::default().hostname("postman-echo.com");
|
||||
|
||||
let mut client = HttpClient::new(&address, &None, settings).unwrap();
|
||||
|
||||
let response: serde_json::Value = client.get_json("/get", None).unwrap();
|
||||
|
||||
println!("{:?}", response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "HTTP requests won't work without internet"]
|
||||
fn http_get_request() {
|
||||
let address = "ifconfig.me:80".to_socket_addrs().unwrap().next().unwrap();
|
||||
|
||||
let settings = HttpSettings::default()
|
||||
.hostname("ifconfig.me")
|
||||
.header("User-Agent", "Curl/8.6.0");
|
||||
|
||||
let mut client = HttpClient::new(&address, &None, settings).unwrap();
|
||||
|
||||
let response = client.get("/", None).unwrap();
|
||||
|
||||
println!("{:?}", std::str::from_utf8(&response));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "HTTP requests won't work without internet"]
|
||||
fn http_get_from_url() {
|
||||
let mut client = HttpClient::from_url("http://postman-echo.com/path-is-ignored", &None, None).unwrap();
|
||||
|
||||
let response: serde_json::Value = client.get_json("/get", None).unwrap();
|
||||
|
||||
println!("{:?}", response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "HTTP requests won't work without internet"]
|
||||
fn http_get_from_url_parsed() {
|
||||
let url = Url::parse("http://postman-echo.com/path-is-ignored").unwrap();
|
||||
|
||||
let mut client = HttpClient::from_url(url, &None, None).unwrap();
|
||||
|
||||
let response: serde_json::Value = client.get_json("/get", None).unwrap();
|
||||
|
||||
println!("{:?}", response);
|
||||
}
|
||||
}
|
||||
65
crates/lib/src/lib.rs
Normal file
65
crates/lib/src/lib.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! Game Server Query Library.
|
||||
//!
|
||||
//! # Usage example:
|
||||
//!
|
||||
//! ## For a specific game
|
||||
//! ```
|
||||
//! use gamedig::games::teamfortress2;
|
||||
//!
|
||||
//! let response = teamfortress2::query(&"127.0.0.1".parse().unwrap(), None); // None is the default port (which is 27015), could also be Some(27015)
|
||||
//! match response { // Result type, must check what it is...
|
||||
//! Err(error) => println!("Couldn't query, error: {}", error),
|
||||
//! Ok(r) => println!("{:#?}", r)
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Using a game definition
|
||||
//! ```
|
||||
//! use gamedig::{GAMES, query};
|
||||
//!
|
||||
//! let game = GAMES.get("teamfortress2").unwrap(); // Get a game definition, the full list can be found in src/games/mod.rs
|
||||
//! let response = query(game, &"127.0.0.1".parse().unwrap(), None); // None will use the default port
|
||||
//! match response {
|
||||
//! Err(error) => println!("Couldn't query, error: {}", error),
|
||||
//! Ok(r) => println!("{:#?}", r.as_json()),
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Crate features:
|
||||
//! Enabled by default: `games`, `game_defs`, `services`
|
||||
//!
|
||||
//! `serde` - enables serde serialization/deserialization for many gamedig types
|
||||
//! using serde derive. <br>
|
||||
//! `games` - include games support. <br>
|
||||
//! `services` - include services support. <br>
|
||||
//! `game_defs` - include game definitions for programmatic access (enabled by
|
||||
//! default). <br>
|
||||
//! `clap` - enable clap derivations for gamedig settings types. <br>
|
||||
//! `tls` - enable TLS support for the HTTP client.
|
||||
|
||||
pub mod errors;
|
||||
#[cfg(feature = "games")]
|
||||
pub mod games;
|
||||
pub mod protocols;
|
||||
#[cfg(feature = "services")]
|
||||
pub mod services;
|
||||
|
||||
mod buffer;
|
||||
mod http;
|
||||
mod socket;
|
||||
mod utils;
|
||||
|
||||
#[cfg(feature = "packet_capture")]
|
||||
pub mod capture;
|
||||
|
||||
pub use errors::*;
|
||||
#[cfg(feature = "games")]
|
||||
pub use games::*;
|
||||
#[allow(unused_imports)]
|
||||
#[cfg(feature = "games")]
|
||||
pub use query::*;
|
||||
#[cfg(feature = "services")]
|
||||
pub use services::*;
|
||||
|
||||
// Re-export types needed to call games::query::query in the root
|
||||
pub use protocols::types::{ExtraRequestSettings, TimeoutSettings};
|
||||
58
crates/lib/src/protocols/epic/mod.rs
Normal file
58
crates/lib/src/protocols/epic/mod.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/// The implementation.
|
||||
pub mod protocol;
|
||||
/// All types used by the implementation.
|
||||
pub mod types;
|
||||
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
|
||||
/// Generate a module containing a query function for an epic (EOS) game.
|
||||
///
|
||||
/// * `mod_name` - The name to be given to the game module (see ID naming
|
||||
/// conventions in CONTRIBUTING.md).
|
||||
/// * `pretty_name` - The full name of the game, will be used as the
|
||||
/// documentation for the created module.
|
||||
/// * `steam_app`, `default_port` - Passed through to [game_query_fn].
|
||||
#[cfg(feature = "games")]
|
||||
macro_rules! game_query_mod {
|
||||
($mod_name: ident, $pretty_name: expr, $default_port: literal, $credentials: expr) => {
|
||||
#[doc = $pretty_name]
|
||||
pub mod $mod_name {
|
||||
use crate::protocols::epic::Credentials;
|
||||
|
||||
crate::protocols::epic::game_query_fn!($pretty_name, $default_port, $credentials);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(feature = "games")]
|
||||
pub(crate) use game_query_mod;
|
||||
|
||||
/// Generate a query function for an epic (EOS) game.
|
||||
///
|
||||
/// * `default_port` - The default port the game uses.
|
||||
/// * `credentials` - Credentials to access EOS.
|
||||
#[cfg(feature = "games")]
|
||||
macro_rules! game_query_fn {
|
||||
($pretty_name: expr, $default_port: literal, $credentials: expr) => {
|
||||
crate::protocols::epic::game_query_fn! {@gen $default_port, concat!(
|
||||
"Make a Epic query for ", $pretty_name, ".\n\n",
|
||||
"If port is `None`, then the default port (", stringify!($default_port), ") will be used."), $credentials}
|
||||
};
|
||||
|
||||
(@gen $default_port: literal, $doc: expr, $credentials: expr) => {
|
||||
#[doc = $doc]
|
||||
pub fn query(
|
||||
address: &std::net::IpAddr,
|
||||
port: Option<u16>,
|
||||
) -> crate::GDResult<crate::protocols::epic::Response> {
|
||||
crate::protocols::epic::query(
|
||||
$credentials,
|
||||
&std::net::SocketAddr::new(*address, port.unwrap_or($default_port)),
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(feature = "games")]
|
||||
pub(crate) use game_query_fn;
|
||||
183
crates/lib/src/protocols/epic/protocol.rs
Normal file
183
crates/lib/src/protocols/epic/protocol.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
use crate::http::HttpClient;
|
||||
use crate::protocols::epic::Response;
|
||||
use crate::GDErrorKind::{JsonParse, PacketBad};
|
||||
use crate::{GDResult, TimeoutSettings};
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use serde::Deserialize;
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
const EPIC_API_ENDPOINT: &str = "https://api.epicgames.dev";
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Credentials {
|
||||
#[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))]
|
||||
pub deployment: &'static str,
|
||||
#[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))]
|
||||
pub id: &'static str,
|
||||
#[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))]
|
||||
pub secret: &'static str,
|
||||
pub auth_by_external: bool,
|
||||
}
|
||||
|
||||
pub struct EpicProtocol {
|
||||
client: HttpClient,
|
||||
credentials: Credentials,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ClientTokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryResponse {
|
||||
sessions: Value,
|
||||
}
|
||||
|
||||
macro_rules! extract_optional_field {
|
||||
($value:expr, $fields:expr, $map_func:expr) => {
|
||||
$fields
|
||||
.iter()
|
||||
.fold(Some(&$value), |acc, &key| acc.and_then(|val| val.get(key)))
|
||||
.map($map_func)
|
||||
.flatten()
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! extract_field {
|
||||
($value:expr, $fields:expr, $map_func:expr) => {
|
||||
extract_optional_field!($value, $fields, $map_func)
|
||||
.ok_or(PacketBad.context("Field is missing or is not parsable."))?
|
||||
};
|
||||
}
|
||||
|
||||
impl EpicProtocol {
|
||||
pub fn new(credentials: Credentials, timeout_settings: TimeoutSettings) -> GDResult<Self> {
|
||||
Ok(Self {
|
||||
client: HttpClient::from_url(EPIC_API_ENDPOINT, &Some(timeout_settings), None)?,
|
||||
credentials,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn auth_by_external(&self) -> GDResult<String> { Ok(String::new()) }
|
||||
|
||||
pub fn auth_by_client(&mut self) -> GDResult<String> {
|
||||
let body = [
|
||||
("grant_type", "client_credentials"),
|
||||
("deployment_id", self.credentials.deployment),
|
||||
];
|
||||
|
||||
let auth_format = format!("{}:{}", self.credentials.id, self.credentials.secret);
|
||||
let auth_base = BASE64_STANDARD.encode(auth_format);
|
||||
let auth = format!("Basic {}", auth_base.as_str());
|
||||
let authorization = auth.as_str();
|
||||
|
||||
let headers = [
|
||||
("Authorization", authorization),
|
||||
("Content-Type", "application/x-www-form-urlencoded"),
|
||||
];
|
||||
|
||||
let response =
|
||||
self.client
|
||||
.post_json_with_form::<ClientTokenResponse>("/auth/v1/oauth/token", Some(&headers), &body)?;
|
||||
Ok(response.access_token)
|
||||
}
|
||||
|
||||
pub fn query_raw(&mut self, address: &SocketAddr) -> GDResult<Value> {
|
||||
let port = address.port();
|
||||
let address = address.ip().to_string();
|
||||
|
||||
let body = format!(
|
||||
"{{\"criteria\":[{{\"key\":\"attributes.ADDRESS_s\",\"op\":\"EQUAL\",\"value\":\"{}\"}}]}}",
|
||||
address
|
||||
);
|
||||
let body = serde_json::from_str::<Value>(body.as_str()).map_err(|e| JsonParse.context(e))?;
|
||||
|
||||
let token = if self.credentials.auth_by_external {
|
||||
self.auth_by_external()?
|
||||
} else {
|
||||
self.auth_by_client()?
|
||||
};
|
||||
let authorization = format!("Bearer {}", token);
|
||||
let headers = [
|
||||
("Content-Type", "application/json"),
|
||||
("Accept", "application/json"),
|
||||
("Authorization", authorization.as_str()),
|
||||
];
|
||||
|
||||
let url = format!("/matchmaking/v1/{}/filter", self.credentials.deployment);
|
||||
let response: QueryResponse = self.client.post_json(url.as_str(), Some(&headers), body)?;
|
||||
|
||||
if let Value::Array(sessions) = response.sessions {
|
||||
if sessions.is_empty() {
|
||||
return Err(PacketBad.context("No servers provided."));
|
||||
}
|
||||
|
||||
for session in sessions.into_iter() {
|
||||
let attributes = session
|
||||
.get("attributes")
|
||||
.ok_or(PacketBad.context("Expected attributes field missing in sessions."))?;
|
||||
|
||||
let address_match = attributes
|
||||
.get("ADDRESSBOUND_s")
|
||||
.and_then(Value::as_str)
|
||||
.map_or(false, |v| v == address || v == format!("0.0.0.0:{}", port))
|
||||
|| attributes
|
||||
.get("GAMESERVER_PORT_1")
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(false, |v| v == port as u64);
|
||||
|
||||
if address_match {
|
||||
return Ok(session);
|
||||
}
|
||||
}
|
||||
|
||||
return Err(
|
||||
PacketBad.context("Servers were provided but the specified one couldn't be found amongst them.")
|
||||
);
|
||||
}
|
||||
|
||||
Err(PacketBad.context("Expected session field to be an array."))
|
||||
}
|
||||
|
||||
pub fn query(&mut self, address: &SocketAddr) -> GDResult<Response> {
|
||||
let value = self.query_raw(address)?;
|
||||
|
||||
let build_version = extract_optional_field!(value, ["attributes", "BUILDID_s"], Value::as_str);
|
||||
let minor_version = extract_optional_field!(value, ["attributes", "MINORBUILDID_s"], Value::as_str);
|
||||
|
||||
let game_version = match (build_version, minor_version) {
|
||||
(Some(b), Some(m)) => Some(format!("{b}.{m}")),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(Response {
|
||||
name: extract_field!(value, ["attributes", "CUSTOMSERVERNAME_s"], Value::as_str).to_string(),
|
||||
map: extract_field!(value, ["attributes", "MAPNAME_s"], Value::as_str).to_string(),
|
||||
has_password: extract_field!(value, ["attributes", "SERVERPASSWORD_b"], Value::as_bool),
|
||||
players_online: extract_field!(value, ["totalPlayers"], Value::as_u64) as u32,
|
||||
players_maxmimum: extract_field!(value, ["settings", "maxPublicPlayers"], Value::as_u64) as u32,
|
||||
players: vec![],
|
||||
game_version,
|
||||
raw: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query(credentials: Credentials, address: &SocketAddr) -> GDResult<Response> {
|
||||
query_with_timeout(credentials, address, None)
|
||||
}
|
||||
|
||||
pub fn query_with_timeout(
|
||||
credentials: Credentials,
|
||||
address: &SocketAddr,
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
) -> GDResult<Response> {
|
||||
let mut client = EpicProtocol::new(credentials, timeout_settings.unwrap_or_default())?;
|
||||
client.query(address)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue