diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..515c3a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Форматирование + run: cargo fmt --all --check + + # Крейт client не собирается в CI: SDL3 собирается из исходников и требует + # системных X11/Wayland-заголовков. Появится отдельный job позже. + - name: Clippy (tier, math, fs) + run: cargo clippy -p tier -p math -p fs --all-targets -- -D warnings + + - name: Тесты (tier, math, fs) + run: cargo test -p tier -p math -p fs --all-targets diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..abcd183 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1772 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "client" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "crc32fast", + "fs", + "pollster", + "sdl3", + "tier", + "tracing", + "wgpu", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs" +version = "0.1.0" +dependencies = [ + "crc32fast", + "rustc-hash 2.1.3", + "thiserror", + "tracing", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", + "windows", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "math" +version = "0.1.0" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rpkg-config" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a2d2f3481209a6b42eec2fbb49063fb4e8d35b57023401495d4fe0f85c817f0" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdl3" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90c470b564b34e7f9ff0719ed7dc05ec71bc73fa4b28e0750a17b8987cf45f8" +dependencies = [ + "bitflags", + "libc", + "objc2", + "raw-window-handle", + "sdl3-image-sys", + "sdl3-mixer-sys", + "sdl3-net-sys", + "sdl3-sys", + "sdl3-ttf-sys", +] + +[[package]] +name = "sdl3-image-src" +version = "3.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77980b408defef1b774b067b8f19d8dd0f2e19f39e0859b13a16211b38f046a" + +[[package]] +name = "sdl3-image-sys" +version = "0.7.1+SDL-image-3.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eeed3ac9786736021364dae342070ac3e9e0074b420b130d70a37ed4b879cdd" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-image-src", + "sdl3-sys", + "vcpkg", +] + +[[package]] +name = "sdl3-mixer-src" +version = "3.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd815ae87084588c7dbd027c1667b0a5e21a6b5ae7b22ecb230bc21c7063eca" + +[[package]] +name = "sdl3-mixer-sys" +version = "0.7.0+SDL-mixer-3.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6150136321198ec95cccfb6168d838f114273a7615d21b513e5277c86f7d3aa1" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-mixer-src", + "sdl3-sys", + "vcpkg", +] + +[[package]] +name = "sdl3-net-src" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0461903a7545a85fc0ae64bdd642ca86fc58c7e0a9422aa7ab108aba25dc1b9" + +[[package]] +name = "sdl3-net-sys" +version = "0.7.0+SDL-net-3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2db35fcc4c810d47fbac59147c1157618d84d7b52aa8644c997e995eef43c5c3" +dependencies = [ + "cmake", + "rpkg-config", + "sdl3-net-src", + "sdl3-sys", +] + +[[package]] +name = "sdl3-src" +version = "3.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "400d0fb006a4485fde477cfc56a250c99d7bfb183fefd145c15fe7607db7fc49" + +[[package]] +name = "sdl3-sys" +version = "0.7.2+SDL-3.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b6a7f3cad40c5b318b3539bb4624389f4a03504cd5f4c65030787b962505ac" +dependencies = [ + "cc", + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-src", + "vcpkg", +] + +[[package]] +name = "sdl3-ttf-src" +version = "3.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28923d2ce72ff317d8eb7cec97ddfb8f351c330d7bcbf8c76e91332122c93b3" + +[[package]] +name = "sdl3-ttf-sys" +version = "0.7.1+SDL-ttf-3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d254e537d2d96b3e2d6e8a3dd4850a1361b8689c3d95c6c1dfdc02b091307eef" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-sys", + "sdl3-ttf-src", + "vcpkg", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tier" +version = "0.1.0" +dependencies = [ + "parking_lot", + "serde", + "thiserror", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.8", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..172e773 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[workspace] +resolver = "2" +members = [ + "crates/tier", + "crates/math", + "crates/fs", + "crates/client", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.85" + +[workspace.dependencies] +# внутренние крейты +tier = { path = "crates/tier" } +math = { path = "crates/math" } +fs = { path = "crates/fs" } + +# внешние +anyhow = "1" +clap = { version = "4.5", features = ["derive"] } +crc32fast = "1" +parking_lot = "0.12" +pollster = "0.4" +rustc-hash = "2" +serde = { version = "1", features = ["derive"] } +sdl3 = { version = "0.20", features = ["raw-window-handle", "build-from-source"] } +thiserror = "2" +toml = "0.9" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +wgpu = "30" + +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 2 diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..017af6a --- /dev/null +++ b/config.example.toml @@ -0,0 +1,13 @@ +# Пример конфигурации Source RS. +# Скопируйте в ~/.config/source-rs/config.toml и поправьте пути. + +# Корень установки Garry's Mod (папка с garrysmod/ и sourceengine/). +# Можно не указывать — тогда используется авто-определение +# (~/gmod_server/server, затем установки Steam). +# assets = "/home/user/gmod_server/server" + +# Каталог кэшей (индексы VPK, обработанные ассеты и т.п.). +# cache_dir = "/home/user/.cache/source-rs" + +# Сервер по умолчанию. +server = "194.147.90.27:24215" diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml new file mode 100644 index 0000000..59463bf --- /dev/null +++ b/crates/client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[[bin]] +name = "gmod-rs" +path = "src/main.rs" + +[dependencies] +fs.workspace = true +tier.workspace = true + +anyhow.workspace = true +clap.workspace = true +crc32fast.workspace = true +pollster.workspace = true +sdl3.workspace = true +tracing.workspace = true +wgpu.workspace = true diff --git a/crates/client/src/convars.rs b/crates/client/src/convars.rs new file mode 100644 index 0000000..79d909b --- /dev/null +++ b/crates/client/src/convars.rs @@ -0,0 +1,26 @@ +//! Конвары клиента (регистрируются при старте). + +use tier::convars::ConVar; + +/// Ограничение FPS (0 — без ограничения). +pub static FPS_MAX: ConVar = + ConVar::new_int("fps_max", 0, "Максимальный FPS (0 — без ограничения)"); + +/// Красная компонента цвета фона окна. +pub static R_CLEAR_R: ConVar = + ConVar::new_float("r_clear_r", 0.02, "Красная компонента цвета фона"); + +/// Зелёная компонента цвета фона окна. +pub static R_CLEAR_G: ConVar = + ConVar::new_float("r_clear_g", 0.03, "Зелёная компонента цвета фона"); + +/// Синяя компонента цвета фона окна. +pub static R_CLEAR_B: ConVar = ConVar::new_float("r_clear_b", 0.08, "Синяя компонента цвета фона"); + +/// Регистрирует все конвары клиента. +pub fn register_all() { + tier::convars::register(&FPS_MAX); + tier::convars::register(&R_CLEAR_R); + tier::convars::register(&R_CLEAR_G); + tier::convars::register(&R_CLEAR_B); +} diff --git a/crates/client/src/doctor.rs b/crates/client/src/doctor.rs new file mode 100644 index 0000000..b465cd1 --- /dev/null +++ b/crates/client/src/doctor.rs @@ -0,0 +1,259 @@ +//! Команды `fs doctor` и `fs cat` — диагностика ассетов и чтение файлов. + +use anyhow::Context; +use fs::{VirtualFs, vpk::Vpk}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use tier::paths::AssetLayout; + +/// `fs doctor`: найти ассеты, разобрать все VPK, проверить выборку CRC. +pub fn doctor(assets: Option) -> anyhow::Result<()> { + let config = tier::config::Config::load(assets, None); + println!("Source RS fs doctor"); + println!("конфиг: {}", config.describe()); + + let Some(layout) = tier::paths::detect(config.assets.as_deref()) else { + println!("ассеты Garry's Mod не найдены. Укажите --assets <корень установки>."); + print_convars(); + return Ok(()); + }; + println!("ассеты: {}", layout.describe()); + + let dir_vpks = collect_dir_vpks(&layout); + if dir_vpks.is_empty() { + println!("VPK не найдены в {}", layout.root.display()); + } + + let mut total_entries = 0usize; + let mut total_archives = 0usize; + let mut failed = 0usize; + + for path in &dir_vpks { + match inspect_vpk(path) { + Ok((entries, archives_seen, ok, sampled)) => { + total_entries += entries; + total_archives += archives_seen; + failed += sampled.saturating_sub(ok); + } + Err(err) => { + failed += 1; + println!(" ОШИБКА {}: {err:#}", path.display()); + } + } + } + + println!( + "итого: {} VPK, {} записей, {} архивов, ошибок: {}", + dir_vpks.len(), + total_entries, + total_archives, + failed + ); + + print_convars(); + Ok(()) +} + +/// `fs cat`: прочитать файл через смонтированные пути. +pub fn cat( + assets: Option, + path: &str, + unverified: bool, + out: Option, +) -> anyhow::Result<()> { + let config = tier::config::Config::load(assets, None); + let layout = tier::paths::detect(config.assets.as_deref()).ok_or_else(|| { + anyhow::anyhow!("ассеты Garry's Mod не найдены; укажите --assets <корень>") + })?; + + let mut vfs = VirtualFs::new(); + for vpk in collect_dir_vpks(&layout) { + vfs.mount_vpk(&vpk) + .with_context(|| format!("монтирование {}", vpk.display()))?; + } + vfs.mount_dir(layout.game.clone()); + if let Some(p) = &layout.source_engine { + vfs.mount_dir(p.clone()); + } + if let Some(p) = &layout.platform { + vfs.mount_dir(p.clone()); + } + + for line in vfs.describe() { + tracing::debug!(source = %line, "источник"); + } + + let data = if unverified { + vfs.read_unverified(path) + } else { + vfs.read(path) + } + .with_context(|| format!("чтение {path}"))?; + + println!( + "{path}: {} байт, CRC32 {:08x}", + data.len(), + crc32fast::hash(&data) + ); + + match out { + Some(dest) => { + std::fs::write(&dest, &data).with_context(|| format!("запись {}", dest.display()))?; + println!("сохранено: {}", dest.display()); + } + None => preview(path, &data), + } + Ok(()) +} + +/// Собирает `*_dir.vpk` в порядке приоритета монтирования. +pub fn collect_dir_vpks(layout: &AssetLayout) -> Vec { + let mut out: Vec = Vec::new(); + + let push = |path: PathBuf, out: &mut Vec| { + if path.is_file() && !out.contains(&path) { + out.push(path); + } + }; + + // 1. Основные VPK игры (garrysmod важнее fallbacks). + push(layout.game.join("garrysmod_dir.vpk"), &mut out); + push(layout.game.join("fallbacks_dir.vpk"), &mut out); + + // 2. Остальные VPK в каталогах игры/движка/платформы (по алфавиту). + let dirs = [ + Some(layout.game.clone()), + layout.source_engine.clone(), + layout.platform.clone(), + ]; + for dir in dirs.into_iter().flatten() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + let mut found: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .map(|n| n.to_string_lossy().ends_with("_dir.vpk")) + .unwrap_or(false) + }) + .collect(); + found.sort(); + for path in found { + push(path, &mut out); + } + } + + out +} + +fn inspect_vpk(path: &Path) -> anyhow::Result<(usize, usize, usize, usize)> { + let started = std::time::Instant::now(); + let vpk = Vpk::parse(path)?; + let info = vpk.info(); + + let mut archives: BTreeSet = BTreeSet::new(); + for entry in vpk.entries() { + if entry.length > 0 && !entry.is_embedded() { + archives.insert(entry.archive_index); + } + } + let present = archives + .iter() + .filter(|index| vpk.archive_path(**index).is_file()) + .count(); + + let readable: Vec<_> = vpk + .entries() + .filter(|e| { + e.length > 0 && (e.is_embedded() || vpk.archive_path(e.archive_index).is_file()) + }) + .collect(); + let step = (readable.len() / 32).max(1); + let mut sampled = 0usize; + let mut ok = 0usize; + let mut bytes = 0u64; + for entry in readable.iter().step_by(step).take(32) { + sampled += 1; + match vpk.read_entry(entry, true) { + Ok(data) => { + ok += 1; + bytes += data.len() as u64; + } + Err(err) => println!(" CRC/чтение: {} — {err}", entry.path), + } + } + + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + println!( + " {name:<28} v{} записей {:>7} дерево {:>6} КиБ архивов {}/{} CRC {}/{} ({:.1} МиБ) за {:?}", + info.version, + info.entry_count, + info.tree_size / 1024, + present, + archives.len(), + ok, + sampled, + bytes as f64 / 1_048_576.0, + started.elapsed() + ); + + if ok != sampled { + anyhow::bail!("{}/{} записей не прошли проверку", sampled - ok, sampled); + } + Ok((info.entry_count, archives.len(), ok, sampled)) +} + +fn print_convars() { + let convars = tier::convars::list(); + println!("конвары ({}):", convars.len()); + for cv in convars { + println!( + " {:<16} {:<6} = {:<8} (по умолчанию {}) — {}", + cv.name(), + cv.type_name(), + cv.value_string(), + cv.default_string(), + cv.description() + ); + } +} + +fn preview(path: &str, data: &[u8]) { + if data.is_empty() { + println!("<пустой файл>"); + return; + } + + if data.len() <= 64 * 1024 { + if let Ok(text) = std::str::from_utf8(data) { + let printable = text + .chars() + .all(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t')); + if printable { + println!("{text}"); + return; + } + } + } + + println!("<бинарь {path}: первые {} байт>", data.len().min(256)); + for (i, chunk) in data[..data.len().min(256)].chunks(16).enumerate() { + let hex: Vec = chunk.iter().map(|b| format!("{b:02x}")).collect(); + let ascii: String = chunk + .iter() + .map(|&b| { + if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + } + }) + .collect(); + println!(" {:08x} {:<47} {}", i * 16, hex.join(" "), ascii); + } +} diff --git a/crates/client/src/gfx.rs b/crates/client/src/gfx.rs new file mode 100644 index 0000000..3cde21a --- /dev/null +++ b/crates/client/src/gfx.rs @@ -0,0 +1,182 @@ +//! Инициализация wgpu поверх окна SDL3. + +use anyhow::Context; +use sdl3::video::Window; +use wgpu::rwh::{HasDisplayHandle, HasWindowHandle}; + +/// Обёртка для `create_surface`: wgpu требует `Send + Sync` от оконного хэндла, +/// SDL3-окно таковым не помечено. Доступ к окну идёт только с главного потока, +/// поэтому это безопасно (ограничено данным модулем). +struct WindowHandle<'a>(&'a Window); + +unsafe impl Send for WindowHandle<'_> {} +unsafe impl Sync for WindowHandle<'_> {} + +impl HasWindowHandle for WindowHandle<'_> { + fn window_handle(&self) -> Result, wgpu::rwh::HandleError> { + self.0.window_handle() + } +} + +impl HasDisplayHandle for WindowHandle<'_> { + fn display_handle(&self) -> Result, wgpu::rwh::HandleError> { + self.0.display_handle() + } +} + +/// GPU-контекст: поверхность, устройство, очередь. +pub struct Gfx<'win> { + surface: wgpu::Surface<'win>, + device: wgpu::Device, + queue: wgpu::Queue, + config: wgpu::SurfaceConfiguration, + adapter_info: wgpu::AdapterInfo, +} + +impl<'win> Gfx<'win> { + /// Создаёт контекст для окна. + pub fn new(window: &'win Window) -> anyhow::Result { + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + + let surface = instance + .create_surface(WindowHandle(window)) + .context("create_surface")?; + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: Some(&surface), + apply_limit_buckets: false, + })) + .context("request_adapter")?; + + let adapter_info = adapter.get_info(); + tracing::debug!(?adapter_info, "выбран GPU-адаптер"); + + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("source-rs device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::Performance, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + trace: wgpu::Trace::Off, + })) + .context("request_device")?; + + let caps = surface.get_capabilities(&adapter); + let Some(format) = caps + .formats + .iter() + .copied() + .find(wgpu::TextureFormat::is_srgb) + .or_else(|| caps.formats.first().copied()) + else { + anyhow::bail!("поверхность не поддерживает ни одного формата"); + }; + + let present_mode = if caps.present_modes.contains(&wgpu::PresentMode::Fifo) { + wgpu::PresentMode::Fifo + } else { + caps.present_modes + .first() + .copied() + .unwrap_or(wgpu::PresentMode::Fifo) + }; + + let (width, height) = window.size(); + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + color_space: wgpu::SurfaceColorSpace::Auto, + width: width.max(1), + height: height.max(1), + present_mode, + alpha_mode: caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto), + desired_maximum_frame_latency: 2, + view_formats: vec![], + }; + surface.configure(&device, &config); + + Ok(Self { + surface, + device, + queue, + config, + adapter_info, + }) + } + + /// Информация о выбранном адаптере. + pub fn adapter_info(&self) -> &wgpu::AdapterInfo { + &self.adapter_info + } + + /// Текущий размер поверхности. + pub fn size(&self) -> (u32, u32) { + (self.config.width, self.config.height) + } + + /// Пересоздаёт поверхность под новый размер. + pub fn resize(&mut self, width: u32, height: u32) { + if width == 0 || height == 0 { + return; + } + if width == self.config.width && height == self.config.height { + return; + } + self.config.width = width; + self.config.height = height; + self.surface.configure(&self.device, &self.config); + tracing::debug!(width, height, "поверхность переконфигурирована"); + } + + /// Очищает кадр заданным цветом и презентует. + pub fn render_clear(&mut self, color: wgpu::Color) -> anyhow::Result<()> { + use wgpu::CurrentSurfaceTexture as Cst; + + let frame = match self.surface.get_current_texture() { + Cst::Success(frame) => frame, + Cst::Timeout | Cst::Occluded => return Ok(()), + Cst::Outdated | Cst::Lost => { + self.surface.configure(&self.device, &self.config); + return Ok(()); + } + other => anyhow::bail!("поверхность недоступна: {other:?}"), + }; + + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame"), + }); + { + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(color), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + } + self.queue.submit(std::iter::once(encoder.finish())); + self.queue.present(frame); + Ok(()) + } +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs new file mode 100644 index 0000000..3480f48 --- /dev/null +++ b/crates/client/src/main.rs @@ -0,0 +1,78 @@ +//! Source RS — клиент Garry's Mod, переписанный на Rust. + +mod convars; +mod doctor; +mod gfx; +mod run; + +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command( + name = "gmod-rs", + version, + about = "Source RS — клиент Garry's Mod на Rust" +)] +struct Cli { + /// Корень установки Garry's Mod (например, ~/gmod_server/server) + #[arg(long, global = true, value_name = "PATH")] + assets: Option, + + /// Подробность логов: -v — debug, -vv — trace + #[arg(short, long, global = true, action = clap::ArgAction::Count)] + verbose: u8, + + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Запустить окно клиента (действие по умолчанию) + Run(run::RunArgs), + + /// Операции с игровой файловой системой + Fs { + #[command(subcommand)] + cmd: FsCmd, + }, +} + +#[derive(Debug, Subcommand)] +enum FsCmd { + /// Найти ассеты, разобрать VPK, проверить выборку CRC + Doctor, + + /// Вывести файл из смонтированных путей + Cat { + /// Путь внутри игры, например models/props_c17/oildrum001.mdl + path: String, + + /// Не проверять CRC + #[arg(long)] + unverified: bool, + + /// Сохранить в файл вместо вывода + #[arg(long, value_name = "PATH")] + out: Option, + }, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + tier::logging::init(cli.verbose); + convars::register_all(); + + match cli.command.unwrap_or(Command::Run(run::RunArgs::default())) { + Command::Run(args) => run::run(cli.assets, args), + Command::Fs { cmd } => match cmd { + FsCmd::Doctor => doctor::doctor(cli.assets), + FsCmd::Cat { + path, + unverified, + out, + } => doctor::cat(cli.assets, &path, unverified, out), + }, + } +} diff --git a/crates/client/src/run.rs b/crates/client/src/run.rs new file mode 100644 index 0000000..1a506d3 --- /dev/null +++ b/crates/client/src/run.rs @@ -0,0 +1,153 @@ +//! Запуск окна клиента: SDL3 + wgpu, замер FPS, аккуратное завершение. + +use crate::convars; +use crate::gfx::Gfx; +use anyhow::Context; +use sdl3::event::{Event, WindowEvent}; +use sdl3::keyboard::Keycode; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tracing::{info, warn}; + +/// Аргументы команды `run`. +#[derive(Debug, Default, clap::Args)] +pub struct RunArgs { + /// Выйти после N кадров (для тестов и CI) + #[arg(long)] + pub frames: Option, + + /// Создать скрытое окно (удобно для автотестов) + #[arg(long)] + pub hidden: bool, +} + +/// Открывает окно и крутит цикл кадров до выхода пользователя +/// (или до `--frames N` кадров). +pub fn run(assets: Option, args: RunArgs) -> anyhow::Result<()> { + info!("Source RS {} — запуск", tier::VERSION); + + let config = tier::config::Config::load(assets, None); + info!("{}", config.describe()); + match tier::paths::detect(config.assets.as_deref()) { + Some(layout) => info!("ассеты: {}", layout.describe()), + None => warn!("ассеты Garry's Mod не найдены — окно всё равно откроется"), + } + + let sdl = sdl3::init().map_err(|e| anyhow::anyhow!("SDL3 init: {e}"))?; + let video = sdl + .video() + .map_err(|e| anyhow::anyhow!("SDL3 video: {e}"))?; + + let mut builder = video.window("Source RS", 1280, 720); + builder.position_centered().resizable(); + if args.hidden { + builder.hidden(); + } + let window = builder + .build() + .map_err(|e| anyhow::anyhow!("SDL3 window: {e}"))?; + + let (width, height) = window.size(); + info!("окно SDL3: {width}x{height}"); + + let mut gfx = Gfx::new(&window).context("инициализация wgpu")?; + let (surface_w, surface_h) = gfx.size(); + info!("поверхность wgpu: {surface_w}x{surface_h}"); + let adapter = gfx.adapter_info(); + info!( + "GPU: {} ({:?}, драйвер {}, тип {:?})", + adapter.name, adapter.backend, adapter.driver, adapter.device_type + ); + + let mut events = sdl + .event_pump() + .map_err(|e| anyhow::anyhow!("SDL3 event pump: {e}"))?; + + let start = Instant::now(); + let mut last_frame = Instant::now(); + let mut frames_drawn: u64 = 0; + let mut fps_window_start = Instant::now(); + let mut frames_in_window: u64 = 0; + let mut fps: f32 = 0.0; + + 'running: loop { + for event in events.poll_iter() { + match event { + Event::Quit { .. } => break 'running, + Event::KeyDown { + keycode: Some(Keycode::Escape), + .. + } => break 'running, + Event::Window { + win_event: WindowEvent::PixelSizeChanged(w, h) | WindowEvent::Resized(w, h), + .. + } => { + gfx.resize(w.max(1) as u32, h.max(1) as u32); + } + _ => {} + } + } + + if let Some(max_frames) = args.frames { + if frames_drawn >= max_frames { + break 'running; + } + } + + // Пульсирующий фон — просто чтобы видеть, что кадры идут. + let t = start.elapsed().as_secs_f64(); + let pulse = 0.5 + 0.5 * (t * 0.8).sin(); + let color = wgpu::Color { + r: (convars::R_CLEAR_R.get_float() + 0.02 * pulse).clamp(0.0, 1.0), + g: convars::R_CLEAR_G.get_float().clamp(0.0, 1.0), + b: (convars::R_CLEAR_B.get_float() + 0.05 * pulse).clamp(0.0, 1.0), + a: 1.0, + }; + if let Err(err) = gfx.render_clear(color) { + warn!("кадр не отрисован: {err:#}"); + } + + frames_drawn += 1; + frames_in_window += 1; + + // Ограничение FPS (fps_max), если задано. + let fps_cap = convars::FPS_MAX.get_int(); + if fps_cap > 0 { + let target = Duration::from_secs_f64(1.0 / fps_cap as f64); + if let Some(remaining) = target.checked_sub(last_frame.elapsed()) { + std::thread::sleep(remaining); + } + } + last_frame = Instant::now(); + + // Раз в секунду — FPS в заголовок окна и в лог. + let elapsed = fps_window_start.elapsed(); + if elapsed >= Duration::from_secs(1) { + fps = frames_in_window as f32 / elapsed.as_secs_f32(); + frames_in_window = 0; + fps_window_start = Instant::now(); + if !args.hidden { + set_window_title(&window, &format!("Source RS — {fps:.0} FPS")); + } + info!(frames = frames_drawn, fps = format!("{fps:.1}"), "кадры"); + } + } + + info!( + "завершение: {frames_drawn} кадров за {:.2} с (последний FPS {fps:.1})", + start.elapsed().as_secs_f32() + ); + Ok(()) +} + +/// Устанавливает заголовок окна через C-API: метод `Window::set_title` требует +/// `&mut Window`, а окно уже занято неизменяемой ссылкой внутри поверхности wgpu. +fn set_window_title(window: &sdl3::video::Window, title: &str) { + let Ok(cstr) = std::ffi::CString::new(title) else { + return; + }; + // Безопасность: raw-указатель валиден, пока живо окно; вызов на главном потоке. + unsafe { + sdl3::sys::video::SDL_SetWindowTitle(window.raw(), cstr.as_ptr()); + } +} diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml new file mode 100644 index 0000000..3cd6655 --- /dev/null +++ b/crates/fs/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fs" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +crc32fast.workspace = true +rustc-hash.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/fs/src/error.rs b/crates/fs/src/error.rs new file mode 100644 index 0000000..8161eda --- /dev/null +++ b/crates/fs/src/error.rs @@ -0,0 +1,38 @@ +//! Ошибки файловой системы. + +use std::path::PathBuf; + +/// Ошибка чтения/разбора игровых данных. +#[derive(Debug, thiserror::Error)] +pub enum FsError { + #[error("файл не найден: {0}")] + NotFound(String), + + #[error("ошибка ввода-вывода: {0}")] + Io(#[from] std::io::Error), + + #[error("не VPK: магия 0x{found:08x}, ожидалось 0x55aa1234")] + VpkBadMagic { found: u32 }, + + #[error("неподдерживаемая версия VPK: {0}")] + VpkBadVersion(u32), + + #[error("повреждённый VPK: {0}")] + VpkCorrupt(String), + + #[error("некорректный UTF-8 в индексе VPK: {0}")] + VpkUtf8(String), + + #[error("файл архива VPK не найден: {0}")] + MissingArchive(PathBuf), + + #[error("недопустимый путь: {0}")] + InvalidPath(String), + + #[error("CRC не совпал для {path}: ожидалось {expected:08x}, получилось {actual:08x}")] + CrcMismatch { + path: String, + expected: u32, + actual: u32, + }, +} diff --git a/crates/fs/src/lib.rs b/crates/fs/src/lib.rs new file mode 100644 index 0000000..cb9ff11 --- /dev/null +++ b/crates/fs/src/lib.rs @@ -0,0 +1,14 @@ +//! `fs` — игровая файловая система: поисковые пути, VPK (v1/v2), позже GMA/BSP-pakfile. +//! +//! Принципы: +//! - любые внешние данные (файлы) разбираются bounded-парсером с явными лимитами; +//! - никаких паник на некорректных данных — только типизированные ошибки; +//! - пути нормализуются, обход каталогов (`..`, абсолютные пути) запрещён. + +pub mod error; +pub mod mount; +pub mod vpk; + +pub use error::FsError; +pub use mount::{MountSource, VirtualFs}; +pub use vpk::Vpk; diff --git a/crates/fs/src/mount.rs b/crates/fs/src/mount.rs new file mode 100644 index 0000000..3f8a31f --- /dev/null +++ b/crates/fs/src/mount.rs @@ -0,0 +1,164 @@ +//! Поисковые пути и виртуальная файловая система. +//! +//! Порядок монтирования определяет приоритет: первый смонтированный источник +//! находит файл первым. Позже (M1) сюда добавятся GMA (workshop), BSP-pakfile +//! и кэш скачанных с сервера файлов. + +use crate::error::FsError; +use crate::vpk::{Vpk, normalize_lookup}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Источник файлов. +pub enum MountSource { + /// Каталог на диске. + Dir(PathBuf), + /// VPK-контейнер. + Vpk(Arc), +} + +/// Виртуальная файловая система с упорядоченными источниками. +#[derive(Default)] +pub struct VirtualFs { + mounts: Vec, +} + +impl VirtualFs { + pub fn new() -> Self { + Self { mounts: Vec::new() } + } + + /// Монтирует каталог. + pub fn mount_dir(&mut self, path: impl Into) -> &mut Self { + self.mounts.push(MountSource::Dir(path.into())); + self + } + + /// Разбирает и монтирует `*_dir.vpk`. + pub fn mount_vpk(&mut self, dir_vpk: impl AsRef) -> Result<&mut Self, FsError> { + let vpk = Vpk::parse(dir_vpk.as_ref())?; + self.mounts.push(MountSource::Vpk(Arc::new(vpk))); + Ok(self) + } + + /// Монтирует уже разобранный VPK. + pub fn mount_vpk_parsed(&mut self, vpk: Arc) -> &mut Self { + self.mounts.push(MountSource::Vpk(vpk)); + self + } + + /// Список источников (для диагностики). + pub fn describe(&self) -> Vec { + self.mounts + .iter() + .map(|m| match m { + MountSource::Dir(p) => format!("dir {}", p.display()), + MountSource::Vpk(v) => format!( + "vpk {} ({} записей)", + v.dir_path().display(), + v.entry_count() + ), + }) + .collect() + } + + /// Читает файл (с проверкой CRC для VPK). + pub fn read(&self, path: &str) -> Result, FsError> { + self.read_inner(path, true) + } + + /// Читает файл без проверки CRC. + pub fn read_unverified(&self, path: &str) -> Result, FsError> { + self.read_inner(path, false) + } + + /// Есть ли файл. + pub fn exists(&self, path: &str) -> bool { + let key = match normalize_lookup(path) { + Some(k) => k, + None => return false, + }; + self.mounts.iter().any(|m| match m { + MountSource::Dir(root) => dir_file_path(root, path).is_some_and(|p| p.is_file()), + MountSource::Vpk(vpk) => vpk.find(&key).is_some(), + }) + } + + fn read_inner(&self, path: &str, verify_crc: bool) -> Result, FsError> { + let key = normalize_lookup(path).ok_or_else(|| FsError::InvalidPath(path.to_string()))?; + + for mount in &self.mounts { + match mount { + MountSource::Dir(root) => { + if let Some(full) = dir_file_path(root, path) { + if full.is_file() { + return Ok(std::fs::read(full)?); + } + } + } + MountSource::Vpk(vpk) => { + if let Some(entry) = vpk.find(&key) { + return vpk.read_entry(entry, verify_crc); + } + } + } + } + + Err(FsError::NotFound(path.to_string())) + } +} + +/// Безопасно собирает путь внутри каталога-источника. +/// `normalize_lookup` уже отсекает `..`, поэтому join не выйдет наружу. +fn dir_file_path(root: &Path, path: &str) -> Option { + normalize_lookup(path)?; + let relative = path.replace('\\', "/"); + let relative = relative.trim_start_matches('/'); + Some(root.join(relative)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dir_mount_reads_and_rejects_traversal() { + let dir = std::env::temp_dir().join(format!("source-rs-fs-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + std::fs::write(dir.join("hello.txt"), b"world").expect("write"); + + let mut vfs = VirtualFs::new(); + vfs.mount_dir(&dir); + assert_eq!(vfs.read("hello.txt").expect("read"), b"world"); + assert!( + matches!(vfs.read("Hello.TXT"), Err(FsError::NotFound(_))), + "дисковые пути регистрозависимы" + ); + assert!(vfs.exists("hello.txt")); + assert!(!vfs.exists("../hello.txt")); + assert!(matches!( + vfs.read("../hello.txt"), + Err(FsError::InvalidPath(_)) + )); + assert!(matches!(vfs.read("nope.txt"), Err(FsError::NotFound(_)))); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn first_mount_wins() { + let base = std::env::temp_dir().join(format!("source-rs-fs-prio-{}", std::process::id())); + let a = base.join("a"); + let b = base.join("b"); + std::fs::create_dir_all(&a).expect("a"); + std::fs::create_dir_all(&b).expect("b"); + std::fs::write(a.join("f.txt"), b"A").expect("write a"); + std::fs::write(b.join("f.txt"), b"B").expect("write b"); + + let mut vfs = VirtualFs::new(); + vfs.mount_dir(&a).mount_dir(&b); + assert_eq!(vfs.read("f.txt").expect("read"), b"A"); + + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/crates/fs/src/vpk.rs b/crates/fs/src/vpk.rs new file mode 100644 index 0000000..1d3d47f --- /dev/null +++ b/crates/fs/src/vpk.rs @@ -0,0 +1,654 @@ +//! Разбор и чтение контейнеров VPK (Valve Pak), версии 1 и 2. +//! +//! Структура (упрощённо): +//! ```text +//! header (12 байт v1 / 28 байт v2) +//! tree (дерево расширений/каталогов/файлов, нуль-термированные строки) +//! [v2] file data section (встроенные данные) +//! [v2] archive MD5 section (28 байт на запись) +//! [v2] other MD5 section (48 байт) +//! [v2] signature section +//! ``` +//! Файлы с `archive_index == 0x7FFF` лежат в самом `_dir.vpk` (смещение +//! отсчитывается от конца дерева), остальные — во внешних архивах +//! `_NNN.vpk` (stem — имя dir-файла без `_dir.vpk`). + +use crate::error::FsError; +use rustc_hash::FxHashMap; +use std::fs::File; +use std::path::{Path, PathBuf}; + +pub const VPK_MAGIC: u32 = 0x55aa_1234; +/// Терминатор записи каталога. +pub const VPK_DIRECTORY_ENTRY_TERMINATOR: u16 = 0xffff; +/// Данные хранятся в dir-файле, а не во внешнем архиве. +pub const VPK_EMBEDDED_ARCHIVE_INDEX: u16 = 0x7fff; + +const HEADER_SIZE_V1: u64 = 12; +const HEADER_SIZE_V2: u64 = 28; + +// --- Лимиты (bounded-парсер) ------------------------------------------------- +/// Максимальный размер дерева. +const MAX_TREE_SIZE: u64 = 1 << 30; +/// Максимальное число записей. +const MAX_ENTRIES: usize = 8 * 1024 * 1024; +/// Максимальная длина строки в дереве. +const MAX_STRING_LEN: usize = 4096; +/// Максимальный размер dir-файла, который мы готовы прочитать целиком. +const MAX_DIR_FILE: u64 = 256 * 1024 * 1024; + +/// Запись индекса VPK. +#[derive(Debug, Clone)] +pub struct VpkEntry { + /// Путь в исходном регистре (с `/`). + pub path: String, + /// CRC32 содержимого (preload + данные архива). + pub crc32: u32, + /// Размер preload-части (хранится прямо в дереве). + pub preload_size: u16, + /// Индекс архива (`0x7FFF` — данные в dir-файле). + pub archive_index: u16, + /// Смещение данных в архиве (или в data-секции dir-файла). + pub offset: u32, + /// Длина данных в архиве. + pub length: u32, + /// Preload-байты (обычно пусто). + pub preload: Vec, +} + +impl VpkEntry { + /// Полная длина содержимого. + pub fn total_length(&self) -> usize { + self.preload.len() + self.length as usize + } + + /// Данные лежат в dir-файле. + pub fn is_embedded(&self) -> bool { + self.archive_index == VPK_EMBEDDED_ARCHIVE_INDEX + } +} + +/// Информация о контейнере (для диагностики). +#[derive(Debug, Clone)] +pub struct VpkInfo { + pub version: u32, + pub entry_count: usize, + pub header_size: u64, + pub tree_size: u32, + pub file_data_section_size: u32, + pub archive_md5_section_size: u32, + pub other_md5_section_size: u32, + pub signature_section_size: u32, +} + +/// Разобранный VPK-контейнер. +pub struct Vpk { + dir_path: PathBuf, + stem: String, + header_size: u64, + tree_size: u32, + version: u32, + file_data_section_size: u32, + archive_md5_section_size: u32, + other_md5_section_size: u32, + signature_section_size: u32, + entries: FxHashMap, +} + +impl Vpk { + /// Читает и разбирает `*_dir.vpk`. + pub fn parse(dir_path: &Path) -> Result { + let meta = std::fs::metadata(dir_path)?; + if meta.len() > MAX_DIR_FILE { + return Err(FsError::VpkCorrupt(format!( + "{}: dir-файл больше {} МиБ — потоковый разбор появится в M1", + dir_path.display(), + MAX_DIR_FILE / (1024 * 1024) + ))); + } + let data = std::fs::read(dir_path)?; + Self::parse_bytes(dir_path, &data) + } + + /// Разбирает VPK из буфера (используется тестами и fuzzing'ом). + pub fn parse_bytes(dir_path: &Path, data: &[u8]) -> Result { + if data.len() < HEADER_SIZE_V1 as usize { + return Err(FsError::VpkCorrupt("файл короче заголовка".to_string())); + } + + let magic = read_u32(data, 0)?; + if magic != VPK_MAGIC { + return Err(FsError::VpkBadMagic { found: magic }); + } + let version = read_u32(data, 4)?; + if version != 1 && version != 2 { + return Err(FsError::VpkBadVersion(version)); + } + let tree_size = read_u32(data, 8)?; + + let header_size = if version == 2 { + HEADER_SIZE_V2 + } else { + HEADER_SIZE_V1 + }; + let mut file_data_section_size = 0; + let mut archive_md5_section_size = 0; + let mut other_md5_section_size = 0; + let mut signature_section_size = 0; + + if version == 2 { + file_data_section_size = read_u32(data, 12)?; + archive_md5_section_size = read_u32(data, 16)?; + other_md5_section_size = read_u32(data, 20)?; + signature_section_size = read_u32(data, 24)?; + } + + if tree_size as u64 > MAX_TREE_SIZE { + return Err(FsError::VpkCorrupt(format!( + "дерево слишком большое: {tree_size}" + ))); + } + + let header_end = header_size; + let tree_end = header_end + .checked_add(tree_size as u64) + .ok_or_else(|| FsError::VpkCorrupt("переполнение размера дерева".to_string()))?; + if tree_end > data.len() as u64 { + return Err(FsError::VpkCorrupt(format!( + "дерево выходит за пределы файла: {} > {}", + tree_end, + data.len() + ))); + } + + // Суммарная длина секций не должна выходить за файл (мягкая проверка: + // подписанные/обрезанные файлы встречаются, поэтому только предупреждаем). + let sections_end = tree_end + .checked_add(file_data_section_size as u64) + .and_then(|v| v.checked_add(archive_md5_section_size as u64)) + .and_then(|v| v.checked_add(other_md5_section_size as u64)) + .and_then(|v| v.checked_add(signature_section_size as u64)); + if sections_end.is_none_or(|end| end > data.len() as u64) { + tracing::debug!(path = %dir_path.display(), "VPK: секции выходят за пределы файла (файл обрезан?)"); + } + + let tree = &data[header_end as usize..tree_end as usize]; + let entries = parse_tree(tree)?; + + let stem = archive_stem(dir_path); + Ok(Vpk { + dir_path: dir_path.to_path_buf(), + stem, + header_size, + tree_size, + version, + file_data_section_size, + archive_md5_section_size, + other_md5_section_size, + signature_section_size, + entries, + }) + } + + /// Путь к dir-файлу. + pub fn dir_path(&self) -> &Path { + &self.dir_path + } + + /// Базовое имя для внешних архивов (без `_dir.vpk`). + pub fn stem(&self) -> &str { + &self.stem + } + + /// Диагностическая информация. + pub fn info(&self) -> VpkInfo { + VpkInfo { + version: self.version, + entry_count: self.entries.len(), + header_size: self.header_size, + tree_size: self.tree_size, + file_data_section_size: self.file_data_section_size, + archive_md5_section_size: self.archive_md5_section_size, + other_md5_section_size: self.other_md5_section_size, + signature_section_size: self.signature_section_size, + } + } + + /// Число записей. + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + /// Поиск записи (регистронезависимо, `/`-разделители). + pub fn find(&self, path: &str) -> Option<&VpkEntry> { + let key = normalize_lookup(path)?; + self.entries.get(&key) + } + + /// Все записи. + pub fn entries(&self) -> impl Iterator { + self.entries.values() + } + + /// Путь к внешнему архиву `NNN`. + pub fn archive_path(&self, index: u16) -> PathBuf { + let parent = self.dir_path.parent().unwrap_or_else(|| Path::new(".")); + parent.join(format!("{}_{:03}.vpk", self.stem, index)) + } + + /// Читает файл по пути (с опциональной проверкой CRC). + pub fn read(&self, path: &str, verify_crc: bool) -> Result, FsError> { + let entry = self + .find(path) + .ok_or_else(|| FsError::NotFound(path.to_string()))?; + self.read_entry(entry, verify_crc) + } + + /// Читает запись (с опциональной проверкой CRC). + pub fn read_entry(&self, entry: &VpkEntry, verify_crc: bool) -> Result, FsError> { + let total = entry.total_length(); + let mut out = Vec::with_capacity(total); + out.extend_from_slice(&entry.preload); + + if entry.length > 0 { + let (source_path, base_offset) = if entry.is_embedded() { + // Данные в dir-файле сразу после дерева. + ( + self.dir_path.clone(), + self.header_size + self.tree_size as u64, + ) + } else { + (self.archive_path(entry.archive_index), 0) + }; + + if !source_path.is_file() { + return Err(FsError::MissingArchive(source_path)); + } + let offset = base_offset + .checked_add(entry.offset as u64) + .ok_or_else(|| FsError::VpkCorrupt("переполнение смещения".to_string()))?; + let chunk = read_range(&source_path, offset, entry.length as usize)?; + out.extend_from_slice(&chunk); + } + + if verify_crc { + let actual = crc32fast::hash(&out); + if actual != entry.crc32 { + return Err(FsError::CrcMismatch { + path: entry.path.clone(), + expected: entry.crc32, + actual, + }); + } + } + + Ok(out) + } +} + +// --------------------------------------------------------------------------- +// Разбор дерева +// --------------------------------------------------------------------------- + +struct TreeReader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> TreeReader<'a> { + fn remaining(&self) -> usize { + self.data.len() - self.pos + } + + fn read_cstr(&mut self) -> Result<&'a str, FsError> { + if self.pos >= self.data.len() { + return Err(FsError::VpkCorrupt("неожиданный конец дерева".to_string())); + } + let rest = &self.data[self.pos..]; + let end = rest + .iter() + .position(|&b| b == 0) + .ok_or_else(|| FsError::VpkCorrupt("нет нуль-терминатора строки".to_string()))?; + if end > MAX_STRING_LEN { + return Err(FsError::VpkCorrupt(format!( + "строка длиной {end} превышает лимит" + ))); + } + let s = std::str::from_utf8(&rest[..end]).map_err(|e| FsError::VpkUtf8(e.to_string()))?; + self.pos += end + 1; + Ok(s) + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], FsError> { + if self.remaining() < n { + return Err(FsError::VpkCorrupt( + "preload выходит за границы дерева".to_string(), + )); + } + let slice = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(slice) + } + + fn read_u16(&mut self) -> Result { + let bytes = self.take(2)?; + Ok(u16::from_le_bytes([bytes[0], bytes[1]])) + } + + fn read_u32(&mut self) -> Result { + let bytes = self.take(4)?; + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } +} + +fn parse_tree(tree: &[u8]) -> Result, FsError> { + let mut rd = TreeReader { data: tree, pos: 0 }; + let mut entries: FxHashMap = FxHashMap::default(); + + // Дерево может состоять из нескольких чанков; каждый оканчивается пустым расширением. + while rd.remaining() > 0 { + loop { + let ext = rd.read_cstr()?; + if ext.is_empty() { + break; + } + loop { + let dir = rd.read_cstr()?; + if dir.is_empty() { + break; + } + loop { + let name = rd.read_cstr()?; + if name.is_empty() { + break; + } + + let crc32 = rd.read_u32()?; + let preload_size = rd.read_u16()?; + let archive_index = rd.read_u16()?; + let offset = rd.read_u32()?; + let length = rd.read_u32()?; + let terminator = rd.read_u16()?; + if terminator != VPK_DIRECTORY_ENTRY_TERMINATOR { + return Err(FsError::VpkCorrupt(format!( + "неверный терминатор записи: 0x{terminator:04x}" + ))); + } + let preload = rd.take(preload_size as usize)?.to_vec(); + + if entries.len() >= MAX_ENTRIES { + return Err(FsError::VpkCorrupt(format!( + "записей больше лимита ({MAX_ENTRIES})" + ))); + } + + let path = build_path(dir, name, ext); + let key = path.to_ascii_lowercase(); + if entries.contains_key(&key) { + tracing::debug!(path = %path, "VPK: дубликат пути, оставляем последний"); + } + entries.insert( + key, + VpkEntry { + path, + crc32, + preload_size, + archive_index, + offset, + length, + preload, + }, + ); + } + } + } + } + + Ok(entries) +} + +fn build_path(dir: &str, name: &str, ext: &str) -> String { + // Пустое расширение-строка " " означает «без расширения» (соглашение VPK). + let dir = if dir == " " { "" } else { dir }; + let mut path = String::with_capacity(dir.len() + name.len() + ext.len() + 2); + if !dir.is_empty() { + path.push_str(dir); + path.push('/'); + } + path.push_str(name); + if !ext.is_empty() && ext != " " { + path.push('.'); + path.push_str(ext); + } + path +} + +/// Нормализует путь для поиска: `\` → `/`, отбрасывает ведущие `/`, +/// запрещает `.`/`..`/пустые компоненты и NUL. Возвращает ключ в нижнем регистре. +pub fn normalize_lookup(path: &str) -> Option { + let replaced = path.replace('\\', "/"); + let trimmed = replaced.trim_start_matches('/'); + if trimmed.is_empty() || trimmed.contains('\0') { + return None; + } + for component in trimmed.split('/') { + if component.is_empty() || component == "." || component == ".." { + return None; + } + } + Some(trimmed.to_ascii_lowercase()) +} + +fn archive_stem(dir_path: &Path) -> String { + let file_name = dir_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if let Some(stem) = file_name.strip_suffix("_dir.vpk") { + return stem.to_string(); + } + if let Some(stem) = file_name.strip_suffix(".vpk") { + return stem.to_string(); + } + file_name +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let slice = data + .get(offset..offset + 4) + .ok_or_else(|| FsError::VpkCorrupt("неожиданный конец файла".to_string()))?; + Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]])) +} + +fn read_range(path: &Path, offset: u64, len: usize) -> Result, FsError> { + let file = File::open(path)?; + let file_len = file.metadata()?.len(); + let end = offset + .checked_add(len as u64) + .ok_or_else(|| FsError::VpkCorrupt("переполнение диапазона чтения".to_string()))?; + if end > file_len { + return Err(FsError::VpkCorrupt(format!( + "{}: диапазон {offset}..{end} выходит за размер файла {file_len}", + path.display() + ))); + } + + let mut buf = vec![0u8; len]; + read_exact_at(&file, &mut buf, offset)?; + Ok(buf) +} + +#[cfg(unix)] +fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> { + use std::os::unix::fs::FileExt; + file.read_exact_at(buf, offset) +} + +#[cfg(windows)] +fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> { + use std::os::windows::fs::FileExt; + while !buf.is_empty() { + match file.seek_read(buf, offset) { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "неожиданный конец файла", + )); + } + Ok(n) => { + buf = &mut buf[n..]; + offset += n as u64; + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> { + use std::io::{Read, Seek, SeekFrom}; + let mut reader = std::io::BufReader::new(file); + reader.seek(SeekFrom::Start(offset))?; + reader.read_exact(buf) +} + +// --------------------------------------------------------------------------- +// Тесты +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Собирает минимальный VPK v2 с одной встроенной записью и одной пустой. + fn build_test_vpk() -> Vec { + let payload = b"hello vpk"; + let crc = crc32fast::hash(payload); + + // дерево: ext "txt", dir "test", name "greeting" -> запись; затем пустые строки. + let mut tree = Vec::new(); + tree.extend_from_slice(b"txt\0test\0greeting\0"); + tree.extend_from_slice(&crc.to_le_bytes()); + tree.extend_from_slice(&0u16.to_le_bytes()); // preload + tree.extend_from_slice(&VPK_EMBEDDED_ARCHIVE_INDEX.to_le_bytes()); + tree.extend_from_slice(&0u32.to_le_bytes()); // offset в data-секции + tree.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + tree.extend_from_slice(&VPK_DIRECTORY_ENTRY_TERMINATOR.to_le_bytes()); + tree.extend_from_slice(b"\0\0\0"); // конец файлов/каталогов/расширений + + let mut out = Vec::new(); + out.extend_from_slice(&VPK_MAGIC.to_le_bytes()); + out.extend_from_slice(&2u32.to_le_bytes()); + out.extend_from_slice(&(tree.len() as u32).to_le_bytes()); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); // file data section + out.extend_from_slice(&0u32.to_le_bytes()); // archive md5 section + out.extend_from_slice(&48u32.to_le_bytes()); // other md5 section + out.extend_from_slice(&0u32.to_le_bytes()); // signature section + out.extend_from_slice(&tree); + out.extend_from_slice(payload); + out.extend_from_slice(&[0u8; 48]); // other md5 + out + } + + fn temp_dir_vpk(name: &str, data: &[u8]) -> PathBuf { + let dir = std::env::temp_dir().join("source-rs-vpk-tests"); + std::fs::create_dir_all(&dir).expect("временный каталог"); + let path = dir.join(name); + std::fs::write(&path, data).expect("запись временного VPK"); + path + } + + #[test] + fn parses_embedded_entry() { + let data = build_test_vpk(); + let path = temp_dir_vpk("embedded_dir.vpk", &data); + let vpk = Vpk::parse(&path).expect("должен разобраться"); + assert_eq!(vpk.info().version, 2); + assert_eq!(vpk.entry_count(), 1); + let entry = vpk + .find("TEST/Greeting.TXT") + .expect("поиск регистронезависим"); + assert_eq!(entry.total_length(), 9); + let content = vpk.read_entry(entry, true).expect("чтение с CRC"); + assert_eq!(content, b"hello vpk"); + assert!(vpk.read("test/greeting.txt", true).is_ok()); + } + + #[test] + fn bad_magic_is_error() { + let data = [0u8; 64]; + let result = Vpk::parse_bytes(Path::new("x_dir.vpk"), &data); + assert!(matches!(result, Err(FsError::VpkBadMagic { .. }))); + } + + #[test] + fn truncated_tree_is_error() { + let data = build_test_vpk(); + for cut in [12usize, 20, 30, 40, 60] { + let part = &data[..cut.min(data.len())]; + let res = Vpk::parse_bytes(Path::new("x_dir.vpk"), part); + assert!(res.is_err(), "обрезка до {cut} байт должна давать ошибку"); + } + } + + #[test] + fn garbage_never_panics() { + // Детерминированный псевдослучайный мусор. + let mut state: u32 = 0x1234_5678; + for len in [0usize, 1, 4, 12, 28, 64, 256, 1024] { + let mut data = Vec::with_capacity(len); + for _ in 0..len { + state = state.wrapping_mul(1664525).wrapping_add(1013904223); + data.push((state >> 24) as u8); + } + // Иногда подставляем магию, чтобы пройти дальше заголовка. + if len >= 12 && len % 2 == 0 { + data[0..4].copy_from_slice(&VPK_MAGIC.to_le_bytes()); + data[4..8].copy_from_slice(&2u32.to_le_bytes()); + } + let _ = Vpk::parse_bytes(Path::new("fuzz_dir.vpk"), &data); + } + } + + #[test] + fn missing_archive_reported() { + // Запись во внешнем архиве, которого нет. + let mut tree = Vec::new(); + tree.extend_from_slice(b"bin\0model\0x\0"); + tree.extend_from_slice(&0u32.to_le_bytes()); // crc + tree.extend_from_slice(&0u16.to_le_bytes()); // preload + tree.extend_from_slice(&0u16.to_le_bytes()); // archive 0 + tree.extend_from_slice(&0u32.to_le_bytes()); // offset + tree.extend_from_slice(&16u32.to_le_bytes()); // length + tree.extend_from_slice(&VPK_DIRECTORY_ENTRY_TERMINATOR.to_le_bytes()); + tree.extend_from_slice(b"\0\0\0"); + + let mut out = Vec::new(); + out.extend_from_slice(&VPK_MAGIC.to_le_bytes()); + out.extend_from_slice(&2u32.to_le_bytes()); + out.extend_from_slice(&(tree.len() as u32).to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&tree); + + let vpk = Vpk::parse_bytes(Path::new("/tmp/nonexistent_dir.vpk"), &out).expect("разбор"); + let err = vpk.read("model/x.bin", true).unwrap_err(); + assert!(matches!(err, FsError::MissingArchive(_))); + } + + #[test] + fn normalize_rejects_traversal() { + assert!(normalize_lookup("../etc/passwd").is_none()); + assert!(normalize_lookup("a/../../b").is_none()); + assert!(normalize_lookup("/abs/path").is_some()); + assert!(normalize_lookup("").is_none()); + assert_eq!( + normalize_lookup("Models\\Props\\x.mdl").as_deref(), + Some("models/props/x.mdl") + ); + } +} diff --git a/crates/fs/tests/real_vpk.rs b/crates/fs/tests/real_vpk.rs new file mode 100644 index 0000000..6490efc --- /dev/null +++ b/crates/fs/tests/real_vpk.rs @@ -0,0 +1,139 @@ +//! Интеграционные тесты на реальных VPK из локальной установки Garry's Mod. +//! +//! Если ассетов нет (например, в CI) — тест пропускается с сообщением. +//! Путь можно задать переменной окружения `SOURCE_RS_ASSETS`. + +use fs::vpk::Vpk; +use std::path::{Path, PathBuf}; + +fn assets_root() -> Option { + if let Some(path) = std::env::var_os("SOURCE_RS_ASSETS") { + let path = PathBuf::from(path); + if path.is_dir() { + return Some(path); + } + } + let home = std::env::var_os("HOME")?; + let path = PathBuf::from(home).join("gmod_server/server"); + path.is_dir().then_some(path) +} + +fn collect_dir_vpks(root: &Path) -> Vec { + let mut out = Vec::new(); + for sub in [".", "garrysmod", "sourceengine", "platform"] { + let dir = root.join(sub); + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let is_dir_vpk = path + .file_name() + .map(|n| n.to_string_lossy().ends_with("_dir.vpk")) + .unwrap_or(false); + if is_dir_vpk { + out.push(path); + } + } + } + out.sort(); + out.dedup(); + out +} + +#[test] +fn real_vpks_parse_and_sample_reads() { + let Some(root) = assets_root() else { + eprintln!("пропуск: ассеты Garry's Mod не найдены (задайте SOURCE_RS_ASSETS)"); + return; + }; + + let vpks = collect_dir_vpks(&root); + assert!( + !vpks.is_empty(), + "в {root:?} не найдено ни одного *_dir.vpk" + ); + + let mut total_entries = 0usize; + let mut checked = 0usize; + let mut total_bytes = 0u64; + let mut skipped_archives = 0usize; + + for path in &vpks { + let vpk = Vpk::parse(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let info = vpk.info(); + assert!(info.entry_count > 0, "{}: VPK без записей", path.display()); + total_entries += info.entry_count; + + let with_data: Vec<_> = vpk.entries().filter(|e| e.length > 0).collect(); + let step = (with_data.len() / 32).max(1); + for entry in with_data.iter().step_by(step).take(32) { + let archive_available = + entry.is_embedded() || vpk.archive_path(entry.archive_index).is_file(); + if !archive_available { + skipped_archives += 1; + continue; + } + let data = vpk + .read_entry(entry, true) + .unwrap_or_else(|err| panic!("{} {:?}: {err}", path.display(), entry.path)); + assert_eq!(data.len(), entry.total_length()); + checked += 1; + total_bytes += data.len() as u64; + } + } + + eprintln!( + "VPK: {} шт, записей {}, проверено {} ({} МиБ), пропущено архивов {}", + vpks.len(), + total_entries, + checked, + total_bytes / (1024 * 1024), + skipped_archives + ); + assert!(checked > 0, "не удалось проверить ни одной записи"); +} + +#[test] +fn garrysmod_dir_vpk_is_v2_and_rich() { + let Some(root) = assets_root() else { + eprintln!("пропуск: ассеты Garry's Mod не найдены"); + return; + }; + let dir_vpk = root.join("garrysmod/garrysmod_dir.vpk"); + if !dir_vpk.is_file() { + eprintln!("пропуск: {} отсутствует", dir_vpk.display()); + return; + } + + let vpk = Vpk::parse(&dir_vpk).expect("garrysmod_dir.vpk должен разбираться"); + let info = vpk.info(); + assert_eq!(info.version, 2, "ожидается VPK v2"); + assert!( + info.entry_count > 10_000, + "ожидается много записей, получено {}", + info.entry_count + ); + + // В пакете заведомо есть модели (проверяем, что пути читаются и имеют расширения). + assert!( + vpk.entries().any(|e| e.path.ends_with(".mdl")), + "ожидались модели (.mdl) в garrysmod_dir.vpk" + ); + + // Все внешние архивы, на которые ссылается индекс, обязаны существовать. + let mut missing: Vec = Vec::new(); + for entry in vpk.entries() { + if entry.length > 0 + && !entry.is_embedded() + && !vpk.archive_path(entry.archive_index).is_file() + && !missing.contains(&entry.archive_index) + { + missing.push(entry.archive_index); + } + } + assert!( + missing.is_empty(), + "нет файлов архивов для индексов {missing:?}" + ); +} diff --git a/crates/math/Cargo.toml b/crates/math/Cargo.toml new file mode 100644 index 0000000..15df6ea --- /dev/null +++ b/crates/math/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "math" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] diff --git a/crates/math/src/lib.rs b/crates/math/src/lib.rs new file mode 100644 index 0000000..92241d5 --- /dev/null +++ b/crates/math/src/lib.rs @@ -0,0 +1,218 @@ +//! Source-совместимая математика. Углы — в градусах (как QAngle в Source). +//! +//! Пока минимум, необходимый клиенту: [`Vec3`], [`Angle`], [`Aabb`]. +//! Расширяется в M1–M3 (матрицы, кватернионы, преобразования Source). +#![forbid(unsafe_code)] + +use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}; + +/// Вектор из трёх `f32` (система Source: X — вперёд, Y — влево, Z — вверх). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Vec3 { + pub x: f32, + pub y: f32, + pub z: f32, +} + +impl Vec3 { + pub const ZERO: Vec3 = Vec3::new(0.0, 0.0, 0.0); + + pub const fn new(x: f32, y: f32, z: f32) -> Self { + Self { x, y, z } + } + + pub fn splat(v: f32) -> Self { + Self::new(v, v, v) + } + + pub fn dot(self, other: Self) -> f32 { + self.x * other.x + self.y * other.y + self.z * other.z + } + + pub fn cross(self, other: Self) -> Self { + Self::new( + self.y * other.z - self.z * other.y, + self.z * other.x - self.x * other.z, + self.x * other.y - self.y * other.x, + ) + } + + pub fn length_squared(self) -> f32 { + self.dot(self) + } + + pub fn length(self) -> f32 { + self.length_squared().sqrt() + } + + pub fn distance(self, other: Self) -> f32 { + (self - other).length() + } + + pub fn normalized(self) -> Self { + let len = self.length(); + if len > 0.0 { self / len } else { Self::ZERO } + } + + pub fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.z.is_finite() + } + + pub fn lerp(self, other: Self, t: f32) -> Self { + self + (other - self) * t + } +} + +impl Add for Vec3 { + type Output = Vec3; + fn add(self, rhs: Self) -> Self::Output { + Vec3::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z) + } +} + +impl Sub for Vec3 { + type Output = Vec3; + fn sub(self, rhs: Self) -> Self::Output { + Vec3::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z) + } +} + +impl Neg for Vec3 { + type Output = Vec3; + fn neg(self) -> Self::Output { + Vec3::new(-self.x, -self.y, -self.z) + } +} + +impl Mul for Vec3 { + type Output = Vec3; + fn mul(self, rhs: f32) -> Self::Output { + Vec3::new(self.x * rhs, self.y * rhs, self.z * rhs) + } +} + +impl Div for Vec3 { + type Output = Vec3; + fn div(self, rhs: f32) -> Self::Output { + Vec3::new(self.x / rhs, self.y / rhs, self.z / rhs) + } +} + +impl AddAssign for Vec3 { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl SubAssign for Vec3 { + fn sub_assign(&mut self, rhs: Self) { + *self = *self - rhs; + } +} + +/// Углы Эйлера в градусах (порядок Source: pitch — вниз+, yaw — против часовой, +/// roll — крен). Диапазоны нормализуются отдельными функциями при необходимости. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Angle { + pub pitch: f32, + pub yaw: f32, + pub roll: f32, +} + +impl Angle { + pub const ZERO: Angle = Angle::new(0.0, 0.0, 0.0); + + pub const fn new(pitch: f32, yaw: f32, roll: f32) -> Self { + Self { pitch, yaw, roll } + } + + pub fn is_finite(self) -> bool { + self.pitch.is_finite() && self.yaw.is_finite() && self.roll.is_finite() + } +} + +/// Ограничивающий параллелепипед. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Aabb { + pub min: Vec3, + pub max: Vec3, +} + +impl Aabb { + pub const fn new(min: Vec3, max: Vec3) -> Self { + Self { min, max } + } + + pub fn center(self) -> Vec3 { + (self.min + self.max) * 0.5 + } + + pub fn size(self) -> Vec3 { + self.max - self.min + } + + pub fn contains(self, point: Vec3) -> bool { + point.x >= self.min.x + && point.x <= self.max.x + && point.y >= self.min.y + && point.y <= self.max.y + && point.z >= self.min.z + && point.z <= self.max.z + } + + pub fn union(self, other: Self) -> Self { + Self::new( + Vec3::new( + self.min.x.min(other.min.x), + self.min.y.min(other.min.y), + self.min.z.min(other.min.z), + ), + Vec3::new( + self.max.x.max(other.max.x), + self.max.y.max(other.max.y), + self.max.z.max(other.max.z), + ), + ) + } + + pub fn is_finite(self) -> bool { + self.min.is_finite() && self.max.is_finite() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vec_ops() { + let a = Vec3::new(1.0, 0.0, 0.0); + let b = Vec3::new(0.0, 1.0, 0.0); + assert_eq!(a.dot(b), 0.0); + assert_eq!(a.cross(b), Vec3::new(0.0, 0.0, 1.0)); + assert_eq!(Vec3::new(3.0, 4.0, 0.0).length(), 5.0); + assert!( + (Vec3::new(0.0, 0.0, 2.0).normalized() - Vec3::new(0.0, 0.0, 1.0)).length() + < f32::EPSILON + ); + assert_eq!(Vec3::ZERO.normalized(), Vec3::ZERO); + assert!((a.lerp(b, 0.5) - Vec3::new(0.5, 0.5, 0.0)).length() < f32::EPSILON); + } + + #[test] + fn aabb_basics() { + let bb = Aabb::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::new(1.0, 1.0, 1.0)); + assert_eq!(bb.center(), Vec3::ZERO); + assert_eq!(bb.size(), Vec3::splat(2.0)); + assert!(bb.contains(Vec3::ZERO)); + assert!(!bb.contains(Vec3::splat(2.0))); + let bb2 = Aabb::new(Vec3::splat(1.0), Vec3::splat(3.0)); + assert_eq!(bb.union(bb2).max, Vec3::splat(3.0)); + } + + #[test] + fn angles_finite() { + assert!(Angle::new(45.0, -90.0, 0.0).is_finite()); + assert!(!Angle::new(f32::NAN, 0.0, 0.0).is_finite()); + } +} diff --git a/crates/tier/Cargo.toml b/crates/tier/Cargo.toml new file mode 100644 index 0000000..1b0d71f --- /dev/null +++ b/crates/tier/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tier" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +parking_lot.workspace = true +serde.workspace = true +thiserror.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/tier/src/config.rs b/crates/tier/src/config.rs new file mode 100644 index 0000000..b1eb5de --- /dev/null +++ b/crates/tier/src/config.rs @@ -0,0 +1,148 @@ +//! Конфигурация Source RS: пути к ассетам, кэшу, сервер по умолчанию. + +use std::path::{Path, PathBuf}; + +/// Содержимое `config.toml` (все поля опциональны). +#[derive(Debug, Default, serde::Deserialize)] +pub struct ConfigFile { + /// Корень установки Garry's Mod (папка, содержащая `garrysmod/` и `sourceengine/`). + pub assets: Option, + /// Каталог кэшей (индексы VPK, обработанные ассеты и т.п.). + pub cache_dir: Option, + /// Сервер по умолчанию (`host:port`). + pub server: Option, +} + +/// Итоговая конфигурация процесса. +#[derive(Debug, Clone)] +pub struct Config { + pub assets: Option, + pub cache_dir: PathBuf, + pub server: Option, + pub config_path: Option, +} + +/// Ошибка чтения конфига. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("не удалось прочитать {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("не удалось разобрать {path}: {source}")] + Parse { + path: PathBuf, + #[source] + source: toml::de::Error, + }, +} + +impl Config { + /// Загружает конфиг из `~/.config/source-rs/config.toml` (если есть), + /// затем применяет переопределения из CLI (они приоритетнее). + pub fn load(cli_assets: Option, cli_cache: Option) -> Config { + let config_path = default_config_path(); + let file = match &config_path { + Some(path) if path.is_file() => match load_file(path) { + Ok(file) => file, + Err(err) => { + tracing::warn!("{err}"); + ConfigFile::default() + } + }, + _ => ConfigFile::default(), + }; + + Config { + assets: cli_assets.or(file.assets), + cache_dir: cli_cache + .or(file.cache_dir) + .unwrap_or_else(default_cache_dir), + server: file.server, + config_path, + } + } + + /// Короткое описание для логов. + pub fn describe(&self) -> String { + let assets = self + .assets + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "<авто-определение>".to_string()); + let cfg = self + .config_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "<нет>".to_string()); + format!( + "assets={assets} cache={} config={cfg}", + self.cache_dir.display() + ) + } +} + +/// Читает и разбирает файл конфигурации. +pub fn load_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { + path: path.to_path_buf(), + source, + })?; + toml::from_str(&text).map_err(|source| ConfigError::Parse { + path: path.to_path_buf(), + source, + }) +} + +fn home_dir() -> Option { + std::env::var_os("HOME").map(PathBuf::from) +} + +fn default_config_path() -> Option { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(xdg).join("source-rs/config.toml")); + } + home_dir().map(|home| home.join(".config/source-rs/config.toml")) +} + +fn default_cache_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") { + return PathBuf::from(xdg).join("source-rs"); + } + home_dir() + .map(|home| home.join(".cache/source-rs")) + .unwrap_or_else(|| PathBuf::from("cache")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_config_file() { + let file: ConfigFile = + toml::from_str("assets = \"/games/gmod\"\nserver = \"127.0.0.1:27015\"\n") + .expect("toml должен разобраться"); + assert_eq!(file.assets.as_deref(), Some(Path::new("/games/gmod"))); + assert_eq!(file.server.as_deref(), Some("127.0.0.1:27015")); + assert!(file.cache_dir.is_none()); + } + + #[test] + fn cli_overrides_file() { + let file: ConfigFile = toml::from_str("assets = \"/from/file\"\n").expect("toml"); + let cli = Some(PathBuf::from("/from/cli")); + let assets = cli.or(file.assets); + assert_eq!(assets.as_deref(), Some(Path::new("/from/cli"))); + } + + #[test] + fn defaults_are_absolute_when_home_present() { + if std::env::var_os("HOME").is_some() { + let dir = default_cache_dir(); + assert!(dir.is_absolute(), "кэш должен быть абсолютным: {dir:?}"); + } + } +} diff --git a/crates/tier/src/convars.rs b/crates/tier/src/convars.rs new file mode 100644 index 0000000..219ed59 --- /dev/null +++ b/crates/tier/src/convars.rs @@ -0,0 +1,398 @@ +//! Конвары — именованные настройки с типизированным доступом (аналог ConVar из Source). +//! +//! Использование: +//! ```ignore +//! static R_FOO: ConVar = ConVar::new_float("r_foo", 1.0, "Описание"); +//! tier::convars::register(&R_FOO); +//! let v = R_FOO.get_float(); +//! ``` + +use parking_lot::RwLock; +use std::fmt; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; + +/// Значение конвара (динамическое представление). +#[derive(Debug, Clone, PartialEq)] +pub enum ConValue { + Bool(bool), + Int(i64), + Float(f64), + Str(Arc), +} + +impl ConValue { + /// Строковое представление (формат для консоли). + pub fn as_string(&self) -> String { + match self { + ConValue::Bool(v) => { + if *v { + "1".to_string() + } else { + "0".to_string() + } + } + ConValue::Int(v) => v.to_string(), + ConValue::Float(v) => format!("{v}"), + ConValue::Str(v) => v.to_string(), + } + } +} + +/// Ошибка установки конвара. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConVarError { + /// Не удалось разобрать значение. + Parse, + /// Тип значения не совпадает с типом конвара. + TypeMismatch, +} + +impl fmt::Display for ConVarError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConVarError::Parse => write!(f, "не удалось разобрать значение"), + ConVarError::TypeMismatch => write!(f, "несовпадение типа значения"), + } + } +} + +impl std::error::Error for ConVarError {} + +/// Type-erased интерфейс конвара для реестра/консоли. +pub trait ConVarAccessor: Send + Sync + 'static { + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; + /// "bool", "int", "float" или "string". + fn type_name(&self) -> &'static str; + fn value_string(&self) -> String; + fn default_string(&self) -> String; + fn set_string(&self, value: &str) -> Result<(), ConVarError>; +} + +enum Storage { + Bool(AtomicBool), + Int(AtomicI64), + Float(AtomicU64), + Str(RwLock>), +} + +/// Конвар. Создаётся в `static` и регистрируется через [`register`]. +pub struct ConVar { + name: &'static str, + description: &'static str, + default: ConValue, + storage: Storage, +} + +#[cold] +fn on_type_mismatch(name: &str, expected: &str) { + debug_assert!(false, "ConVar `{name}`: обращение как к `{expected}`"); + tracing::error!(name, expected, "convar: несовпадение типа при обращении"); +} + +impl ConVar { + pub const fn new_bool(name: &'static str, default: bool, description: &'static str) -> Self { + Self { + name, + description, + default: ConValue::Bool(default), + storage: Storage::Bool(AtomicBool::new(default)), + } + } + + pub const fn new_int(name: &'static str, default: i64, description: &'static str) -> Self { + Self { + name, + description, + default: ConValue::Int(default), + storage: Storage::Int(AtomicI64::new(default)), + } + } + + pub const fn new_float(name: &'static str, default: f64, description: &'static str) -> Self { + Self { + name, + description, + default: ConValue::Float(default), + storage: Storage::Float(AtomicU64::new(default.to_bits())), + } + } + + pub fn new_str(name: &'static str, default: &str, description: &'static str) -> Self { + Self { + name, + description, + default: ConValue::Str(Arc::from(default)), + storage: Storage::Str(RwLock::new(Arc::from(default))), + } + } + + pub fn get_bool(&self) -> bool { + match &self.storage { + Storage::Bool(a) => a.load(Ordering::Relaxed), + _ => { + on_type_mismatch(self.name, "bool"); + false + } + } + } + + pub fn get_int(&self) -> i64 { + match &self.storage { + Storage::Int(a) => a.load(Ordering::Relaxed), + _ => { + on_type_mismatch(self.name, "int"); + 0 + } + } + } + + pub fn get_float(&self) -> f64 { + match &self.storage { + Storage::Float(a) => f64::from_bits(a.load(Ordering::Relaxed)), + _ => { + on_type_mismatch(self.name, "float"); + 0.0 + } + } + } + + pub fn get_str(&self) -> Arc { + match &self.storage { + Storage::Str(l) => l.read().clone(), + _ => { + on_type_mismatch(self.name, "string"); + Arc::from("") + } + } + } + + pub fn set_bool(&self, value: bool) { + match &self.storage { + Storage::Bool(a) => a.store(value, Ordering::Relaxed), + _ => on_type_mismatch(self.name, "bool"), + } + } + + pub fn set_int(&self, value: i64) { + match &self.storage { + Storage::Int(a) => a.store(value, Ordering::Relaxed), + _ => on_type_mismatch(self.name, "int"), + } + } + + pub fn set_float(&self, value: f64) { + match &self.storage { + Storage::Float(a) => a.store(value.to_bits(), Ordering::Relaxed), + _ => on_type_mismatch(self.name, "float"), + } + } + + pub fn set_str(&self, value: &str) { + match &self.storage { + Storage::Str(l) => *l.write() = Arc::from(value), + _ => on_type_mismatch(self.name, "string"), + } + } +} + +impl ConVarAccessor for ConVar { + fn name(&self) -> &'static str { + self.name + } + + fn description(&self) -> &'static str { + self.description + } + + fn type_name(&self) -> &'static str { + match &self.storage { + Storage::Bool(_) => "bool", + Storage::Int(_) => "int", + Storage::Float(_) => "float", + Storage::Str(_) => "string", + } + } + + fn value_string(&self) -> String { + match &self.storage { + Storage::Bool(a) => (if a.load(Ordering::Relaxed) { "1" } else { "0" }).to_string(), + Storage::Int(a) => a.load(Ordering::Relaxed).to_string(), + Storage::Float(a) => format!("{}", f64::from_bits(a.load(Ordering::Relaxed))), + Storage::Str(l) => l.read().to_string(), + } + } + + fn default_string(&self) -> String { + self.default.as_string() + } + + fn set_string(&self, value: &str) -> Result<(), ConVarError> { + match &self.storage { + Storage::Bool(a) => { + a.store(parse_bool(value)?, Ordering::Relaxed); + Ok(()) + } + Storage::Int(a) => { + a.store( + value + .trim() + .parse::() + .map_err(|_| ConVarError::Parse)?, + Ordering::Relaxed, + ); + Ok(()) + } + Storage::Float(a) => { + a.store( + value + .trim() + .parse::() + .map_err(|_| ConVarError::Parse)? + .to_bits(), + Ordering::Relaxed, + ); + Ok(()) + } + Storage::Str(l) => { + *l.write() = Arc::from(value); + Ok(()) + } + } + } +} + +fn parse_bool(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => other + .parse::() + .map(|v| v != 0) + .map_err(|_| ConVarError::Parse), + } +} + +// --------------------------------------------------------------------------- +// Реестр +// --------------------------------------------------------------------------- + +static REGISTRY: OnceLock>> = OnceLock::new(); + +fn registry() -> &'static RwLock> { + REGISTRY.get_or_init(|| RwLock::new(Vec::new())) +} + +/// Регистрирует конвар в глобальном реестре (идемпотентно по имени). +pub fn register(cv: &'static dyn ConVarAccessor) { + let mut list = registry().write(); + if list.iter().any(|c| c.name() == cv.name()) { + return; + } + let pos = list.partition_point(|c| c.name() < cv.name()); + list.insert(pos, cv); +} + +/// Все зарегистрированные конвары (отсортированы по имени). +pub fn list() -> Vec<&'static dyn ConVarAccessor> { + registry().read().clone() +} + +/// Находит конвар по имени. +pub fn find(name: &str) -> Option<&'static dyn ConVarAccessor> { + registry().read().iter().copied().find(|c| c.name() == name) +} + +/// Ошибка установки конвара по имени. +#[derive(Debug, thiserror::Error)] +pub enum SetConVarError { + #[error("неизвестный конвар: {0}")] + Unknown(String), + #[error("неверное значение: {0}")] + Value(#[from] ConVarError), +} + +/// Устанавливает конвар по имени (интерфейс консоли). +pub fn set_by_name(name: &str, value: &str) -> Result<(), SetConVarError> { + let cv = find(name).ok_or_else(|| SetConVarError::Unknown(name.to_string()))?; + cv.set_string(value).map_err(SetConVarError::Value) +} + +#[cfg(test)] +mod tests { + use super::*; + + static TEST_BOOL: ConVar = ConVar::new_bool("test_convars_bool", true, "тест"); + static TEST_INT: ConVar = ConVar::new_int("test_convars_int", 42, "тест"); + static TEST_FLOAT: ConVar = ConVar::new_float("test_convars_float", 1.5, "тест"); + // Строковые конвары не могут быть const-инициализированы (Arc), поэтому LazyLock. + static TEST_STR: std::sync::LazyLock = + std::sync::LazyLock::new(|| ConVar::new_str("test_convars_str", "hello", "тест")); + + fn register_all() { + register(&TEST_BOOL); + register(&TEST_INT); + register(&TEST_FLOAT); + register(&*TEST_STR); + } + + #[test] + fn typed_access() { + register_all(); + assert!(TEST_BOOL.get_bool()); + TEST_BOOL.set_bool(false); + assert!(!TEST_BOOL.get_bool()); + TEST_BOOL.set_bool(true); + + assert_eq!(TEST_INT.get_int(), 42); + TEST_FLOAT.set_float(2.25); + assert!((TEST_FLOAT.get_float() - 2.25).abs() < f64::EPSILON); + TEST_FLOAT.set_float(1.5); + + TEST_STR.set_str("world"); + assert_eq!(&*TEST_STR.get_str(), "world"); + TEST_STR.set_str("hello"); + } + + #[test] + fn string_interface() { + register_all(); + assert!(TEST_BOOL.set_string("0").is_ok()); + assert!(!TEST_BOOL.get_bool()); + assert!(TEST_BOOL.set_string("true").is_ok()); + assert!(TEST_BOOL.get_bool()); + + assert!(TEST_INT.set_string("-7").is_ok()); + assert_eq!(TEST_INT.get_int(), -7); + assert!(TEST_INT.set_string("abc").is_err()); + TEST_INT.set_int(42); + + assert!(TEST_FLOAT.set_string("0.5").is_ok()); + assert!((TEST_FLOAT.get_float() - 0.5).abs() < f64::EPSILON); + TEST_FLOAT.set_float(1.5); + + assert_eq!(TEST_STR.value_string(), "hello"); + assert_eq!(TEST_STR.default_string(), "hello"); + assert_eq!(TEST_BOOL.type_name(), "bool"); + } + + #[test] + fn find_and_set_by_name() { + register_all(); + assert!(find("test_convars_int").is_some()); + assert!(list().iter().any(|c| c.name() == "test_convars_float")); + assert!(set_by_name("test_convars_int", "11").is_ok()); + assert_eq!(TEST_INT.get_int(), 11); + TEST_INT.set_int(42); + assert!(set_by_name("нет_такого", "1").is_err()); + } + + #[test] + fn parse_bool_forms() { + assert_eq!(parse_bool("1"), Ok(true)); + assert_eq!(parse_bool("off"), Ok(false)); + assert_eq!(parse_bool("2"), Ok(true)); + assert_eq!(parse_bool("nope"), Err(ConVarError::Parse)); + } +} diff --git a/crates/tier/src/lib.rs b/crates/tier/src/lib.rs new file mode 100644 index 0000000..c51f8fa --- /dev/null +++ b/crates/tier/src/lib.rs @@ -0,0 +1,12 @@ +//! `tier` — базовый слой движка Source RS (аналог tier0/tier1 в Source): +//! логирование, конвары, конфигурация, определение путей к ассетам. + +pub mod config; +pub mod convars; +pub mod logging; +pub mod paths; + +pub use config::Config; + +/// Версия Source RS. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/tier/src/logging.rs b/crates/tier/src/logging.rs new file mode 100644 index 0000000..a26a21b --- /dev/null +++ b/crates/tier/src/logging.rs @@ -0,0 +1,35 @@ +//! Инициализация логирования (tracing). + +use std::io::IsTerminal; +use std::sync::Once; + +use tracing_subscriber::EnvFilter; + +static INIT: Once = Once::new(); + +/// Инициализирует глобальный логгер ровно один раз. +/// +/// `verbosity`: 0 — info, 1 — debug, 2 и больше — trace. +/// Переменная окружения `SOURCE_RS_LOG` (или `RUST_LOG`) имеет приоритет, +/// например: `SOURCE_RS_LOG=wgpu=warn,fs=debug`. +pub fn init(verbosity: u8) { + INIT.call_once(|| { + let filter = EnvFilter::try_from_env("SOURCE_RS_LOG") + .or_else(|_| EnvFilter::try_from_env("RUST_LOG")) + .unwrap_or_else(|_| { + let level = match verbosity { + 0 => "info", + 1 => "debug", + _ => "trace", + }; + EnvFilter::new(level) + }); + + let ansi = std::io::stdout().is_terminal(); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .with_ansi(ansi) + .try_init(); + }); +} diff --git a/crates/tier/src/paths.rs b/crates/tier/src/paths.rs new file mode 100644 index 0000000..d422972 --- /dev/null +++ b/crates/tier/src/paths.rs @@ -0,0 +1,152 @@ +//! Поиск установок Garry's Mod и раскладки ассетов. + +use std::path::{Path, PathBuf}; + +/// Раскладка установки Garry's Mod. +#[derive(Debug, Clone)] +pub struct AssetLayout { + /// Корень установки (например, `~/gmod_server/server`). + pub root: PathBuf, + /// Каталог игры (`root/garrysmod`). + pub game: PathBuf, + /// `root/sourceengine` — базовый контент (hl2_* vpk). + pub source_engine: Option, + /// `root/platform`. + pub platform: Option, +} + +impl AssetLayout { + /// `garrysmod_dir.vpk`. + pub fn dir_vpk(&self) -> PathBuf { + self.game.join("garrysmod_dir.vpk") + } + + /// `gameinfo.txt`. + pub fn gameinfo(&self) -> PathBuf { + self.game.join("gameinfo.txt") + } + + /// Похоже ли на настоящую установку. + pub fn is_valid(&self) -> bool { + self.dir_vpk().is_file() || self.gameinfo().is_file() + } + + /// Описание для логов/CLI. + pub fn describe(&self) -> String { + let mut s = format!("root={} game={}", self.root.display(), self.game.display()); + if let Some(p) = &self.source_engine { + s.push_str(&format!(" sourceengine={}", p.display())); + } + if let Some(p) = &self.platform { + s.push_str(&format!(" platform={}", p.display())); + } + s + } +} + +/// Строит раскладку по любому пути внутри установки +/// (корень или сам каталог `garrysmod`). +pub fn build(root: &Path) -> AssetLayout { + let (root, game) = if root.join("garrysmod").is_dir() { + (root.to_path_buf(), root.join("garrysmod")) + } else { + let parent = root + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| root.to_path_buf()); + (parent, root.to_path_buf()) + }; + + let source_engine = { + let p = root.join("sourceengine"); + p.is_dir().then_some(p) + }; + let platform = { + let p = root.join("platform"); + p.is_dir().then_some(p) + }; + + AssetLayout { + root, + game, + source_engine, + platform, + } +} + +/// Ищет установку: явный путь из CLI/конфига, затем известные места +/// (локальный DS-сервер, установки Steam). +pub fn detect(explicit: Option<&Path>) -> Option { + let mut candidates: Vec = Vec::new(); + + if let Some(path) = explicit { + candidates.push(expand_home(path)); + } + + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + candidates.push(home.join("gmod_server/server")); + candidates.push(home.join(".steam/steam/steamapps/common/GarrysMod")); + candidates.push(home.join(".local/share/Steam/steamapps/common/GarrysMod")); + } + + candidates.into_iter().find_map(|candidate| { + let layout = build(&candidate); + layout.is_valid().then_some(layout) + }) +} + +/// Раскрывает `~` и `~/...` в пути. +pub fn expand_home(path: &Path) -> PathBuf { + let as_str = path.to_string_lossy(); + if as_str == "~" { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home); + } + } else if let Some(rest) = as_str.strip_prefix("~/") { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(rest); + } + } + path.to_path_buf() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_home_works() { + if let Some(home) = std::env::var_os("HOME") { + let expanded = expand_home(Path::new("~/example")); + assert_eq!(expanded, PathBuf::from(home).join("example")); + } + // без ~ путь не трогаем + assert_eq!( + expand_home(Path::new("/abs/path")), + PathBuf::from("/abs/path") + ); + } + + #[test] + fn detect_never_panics() { + if let Some(layout) = detect(None) { + assert!(layout.is_valid(), "detect вернул невалидную раскладку"); + } + } + + #[test] + fn build_from_game_dir() { + let home = match std::env::var_os("HOME") { + Some(h) => PathBuf::from(h), + None => return, + }; + let root = home.join("gmod_server/server"); + if !root.join("garrysmod").is_dir() { + return; // нет ассетов — тест неактуален + } + let from_root = build(&root); + let from_game = build(&root.join("garrysmod")); + assert_eq!(from_root.game, from_game.game); + assert_eq!(from_root.root, from_game.root); + } +} diff --git a/docs/PLAN.md b/docs/PLAN.md index b406e72..69d6edd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -193,11 +193,15 @@ Source RS — клиент Garry's Mod, написанный с нуля на Ru ## 7. Спринт 0 -- [ ] `git init` + `.gitignore` + README + docs (этот коммит) -- [ ] Cargo workspace `~/garrysmod-rs`: `crates/tier` (логи/конвары/конфиг/CLI), `crates/fs` (VPK v1/v2, листинг) -- [ ] `client`: окно SDL3 + wgpu clear + FPS-оверлей -- [ ] Тест: листинг `garrysmod_dir.vpk` + чтение произвольного файла из него -- [ ] CI-скелет (fmt/clippy/test), fuzz-заглушки +- [x] `git init` + `.gitignore` + README + docs (первый коммит) +- [x] Cargo workspace `~/garrysmod-rs`: `crates/tier` (логи/конвары/конфиг/CLI), `crates/fs` (VPK v1/v2, листинг, CRC-проверка) +- [x] `crates/math` (базовый Vec3/Angle/Aabb) +- [x] `client`: окно SDL3 + wgpu clear + FPS в заголовке и логе (проверено: NVIDIA RTX 3060, Vulkan, ~58–63 FPS, чистое завершение) +- [x] Тесты: реальные VPK из `~/gmod_server/server` парсятся, CRC-выборка проходит; мусор не роняет парсер +- [x] CI-скелет (fmt/clippy/test для tier/math/fs), fuzz-заглушка (`fuzz/fuzz_targets/vpk_parse.rs`) + +**Спринт 0 завершён (2026-10-11):** `fs doctor` — 9 VPK, 77 933 записи, 41 архив, 0 ошибок CRC; +`fs cat` читает MDL/текст из VPK; окно открывается на Vulkan. ## 8. Тестовая стратегия diff --git a/docs/RECON.md b/docs/RECON.md index 93788b0..5b1d98f 100644 --- a/docs/RECON.md +++ b/docs/RECON.md @@ -80,3 +80,16 @@ | Packet dump/replay | Воспроизведение прод-нагрузки без сервера | | ClassicBox (live) | Финальные проверки (36–46 игроков), аккуратно и по делу | | Fuzz-стенды | vpk/bsp/mdl/vtf/pcf/сеть — cargo-fuzz | + +## 10. Заметки спринта 0 (обновляется по ходу) + +- VPK в установке: 9 `*_dir.vpk` (77 933 записи, 41 архив) — garrysmod (v2, 15 324 записи), + fallbacks, hl2_{textures,misc,sound_misc,sound_vo_english}, content_hl2, content_cstrike, + platform_misc. CS:S-контент присутствует (важно для gm_mobenix). +- Формат VPK сверен с ValvePak (VRF): терминаторы, preload, встроенные данные, + внешние архивы `_NNN.vpk`; парсер bounded и fuzz-устойчив. +- `fs doctor`: 0 ошибок CRC на выборке; `fs cat` читает MDL (IDST) и текстовые файлы из VPK. +- Клиент: SDL3 (собран из исходников, feature `build-from-source`) + wgpu 30. + Запуск проверен: NVIDIA RTX 3060, Vulkan, ~58–63 FPS (vsync), чистое завершение по `--frames`. +- wgpu 30: API проверено по примеру sdl3-rs (`raw-window-handle-with-wgpu`); + заголовок окна обновляется через `sdl3::sys` (метод крейта требует `&mut Window`). diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..59e662b --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "source-rs-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +fs = { path = ".." } + +# Отдельный workspace: не тянется в обычные сборки/тесты. +[workspace] + +[[bin]] +name = "vpk_parse" +path = "fuzz_targets/vpk_parse.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..403b693 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,13 @@ +# Fuzzing Source RS + +Таргеты для `cargo-fuzz` (требуется nightly-тулчейн и его установка): + +```bash +cargo install cargo-fuzz +cd fuzz +cargo +nightly fuzz run vpk_parse +``` + +Таргеты: + +- `vpk_parse` — разбор VPK: никакие входные данные не должны приводить к панике. diff --git a/fuzz/fuzz_targets/vpk_parse.rs b/fuzz/fuzz_targets/vpk_parse.rs new file mode 100644 index 0000000..1aa8892 --- /dev/null +++ b/fuzz/fuzz_targets/vpk_parse.rs @@ -0,0 +1,9 @@ +#![no_main] +//! Fuzz-таргет: разбор VPK не должен паниковать ни на каких данных. + +use libfuzzer_sys::fuzz_target; +use std::path::Path; + +fuzz_target!(|data: &[u8]| { + let _ = fs::vpk::Vpk::parse_bytes(Path::new("fuzz_dir.vpk"), data); +});