Add Theater editing and chat, improve controller UI, audio, combat and network recovery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vtroc7
2026-10-11 21:43:23 +03:00
parent 327f416ca8
commit 08b4224efd
166 changed files with 6499 additions and 1309 deletions
Generated
+195 -111
View File
@@ -158,7 +158,7 @@ dependencies = [
"android-properties",
"bitflags 2.13.1",
"cc",
"jni 0.22.4",
"jni",
"libc",
"log",
"ndk",
@@ -2206,6 +2206,7 @@ dependencies = [
"session",
"sha2",
"sim",
"theater",
"ui",
]
@@ -2312,12 +2313,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -2560,14 +2555,15 @@ dependencies = [
[[package]]
name = "cpal"
version = "0.17.3"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7"
checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
dependencies = [
"alsa",
"block2 0.6.2",
"coreaudio-rs",
"dasp_sample",
"jni 0.21.1",
"jni",
"js-sys",
"libc",
"mach2",
@@ -2582,10 +2578,9 @@ dependencies = [
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows",
"windows-core",
]
[[package]]
@@ -2700,6 +2695,12 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -3027,6 +3028,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "fnv"
version = "1.0.7"
@@ -3560,6 +3567,7 @@ dependencies = [
"net",
"playerstate_iw4",
"render_material",
"resvg",
"sim",
"structured_data_iw4",
"weapon_iw4",
@@ -3819,6 +3827,12 @@ dependencies = [
"png",
]
[[package]]
name = "imagesize"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -3921,22 +3935,6 @@ dependencies = [
"tower-http",
]
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
@@ -4020,6 +4018,18 @@ dependencies = [
name = "killcam_iw4"
version = "0.1.0"
[[package]]
name = "kurbo"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"polycool",
"smallvec",
]
[[package]]
name = "lalrpop-util"
version = "0.23.1"
@@ -4291,12 +4301,9 @@ dependencies = [
[[package]]
name = "mach2"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
[[package]]
name = "marks_iw4"
@@ -4684,6 +4691,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"bitflags 2.13.1",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
@@ -5157,6 +5165,12 @@ dependencies = [
"indexmap",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -5247,6 +5261,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]]
name = "portable-atomic"
version = "1.14.0"
@@ -5753,6 +5776,7 @@ dependencies = [
"hud_iw4",
"lighting_iw4",
"math_iw4",
"render_frame",
"render_material",
"sim",
"xmodel_runtime",
@@ -5817,6 +5841,21 @@ dependencies = [
"web-sys",
]
[[package]]
name = "resvg"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd"
dependencies = [
"bytemuck",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia 0.12.0",
"usvg",
]
[[package]]
name = "rfd"
version = "0.17.2"
@@ -5844,6 +5883,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -5872,6 +5920,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -5972,7 +6029,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"jni",
"log",
"once_cell",
"rustls",
@@ -6054,7 +6111,7 @@ dependencies = [
"log",
"memmap2",
"smithay-client-toolkit",
"tiny-skia",
"tiny-skia 0.11.4",
]
[[package]]
@@ -6269,6 +6326,21 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "siphasher"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "skrifa"
version = "0.44.0"
@@ -6388,6 +6460,9 @@ name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]]
name = "structured_data_iw4"
@@ -6408,6 +6483,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo",
"siphasher",
]
[[package]]
name = "swash"
version = "0.2.10"
@@ -6600,6 +6685,26 @@ dependencies = [
"smallvec",
]
[[package]]
name = "theater"
version = "0.1.0"
dependencies = [
"bevy",
"console",
"frame",
"net",
"render",
"render_anim",
"render_scene",
"replay",
"serde",
"serde_json",
"session",
"sha2",
"sim",
"ui",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -6660,7 +6765,22 @@ dependencies = [
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
"tiny-skia-path 0.11.4",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path 0.12.0",
]
[[package]]
@@ -6674,6 +6794,17 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -7124,6 +7255,25 @@ dependencies = [
"serde",
]
[[package]]
name = "usvg"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91"
dependencies = [
"data-url",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path 0.12.0",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -7783,22 +7933,13 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -7807,7 +7948,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -7819,35 +7960,20 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@@ -7859,36 +7985,18 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -7901,48 +8009,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
+2
View File
@@ -63,6 +63,7 @@ members = [
"crates/render_material",
"crates/render_scene",
"crates/replay",
"crates/theater",
"crates/session",
"crates/sim",
"crates/structured_data_iw4",
@@ -169,6 +170,7 @@ render_material = { path = "crates/render_material" }
render_scene = { path = "crates/render_scene" }
session = { path = "crates/session" }
replay = { path = "crates/replay" }
theater = { path = "crates/theater" }
sim = { path = "crates/sim" }
trace_iw4 = { path = "crates/trace_iw4" }
ui = { path = "crates/ui" }
+361 -1
View File
@@ -920,7 +920,7 @@ Apache License 2.0 (Apache-2.0)
--------------------------------------------------------------------------------
Used by:
codespan-reporting 0.13.1 — https://github.com/brendanzab/codespan
cpal 0.17.3 — https://github.com/RustAudio/cpal
cpal 0.18.2 — https://github.com/RustAudio/cpal
Apache License
Version 2.0, January 2004
@@ -1604,6 +1604,7 @@ Used by:
session 0.1.0
sim 0.1.0
structured_data_iw4 0.1.0
theater 0.1.0
trace_iw4 0.1.0
ui 0.1.0
updater 0.1.0
@@ -1787,6 +1788,45 @@ pub(crate) static GB18030_2022_OVERRIDE_BYTES: [[u8; 2]; 18] = [
];
================================================================================
BSD 3-Clause "New" or "Revised" License (BSD-3-Clause)
--------------------------------------------------------------------------------
Used by:
tiny-skia-path 0.12.0 — https://github.com/linebender/tiny-skia/tree/master/path
tiny-skia 0.12.0 — https://github.com/linebender/tiny-skia
Copyright (c) 2011 Google Inc. All rights reserved.
Copyright (c) 2020 Yevhenii Reizner All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
BSD 3-Clause "New" or "Revised" License (BSD-3-Clause)
--------------------------------------------------------------------------------
@@ -2713,6 +2753,7 @@ DEALINGS IN THE SOFTWARE.
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
data-url 0.3.2 — https://github.com/servo/rust-url
idna 1.1.0 — https://github.com/servo/rust-url/
percent-encoding 2.3.2 — https://github.com/servo/rust-url/
url 2.5.8 — https://github.com/servo/rust-url
@@ -2914,6 +2955,33 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
float-cmp 0.9.0 — https://github.com/mikedilger/float-cmp
Copyright (c) 2014-2020 Optimal Computing (NZ) Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -3898,6 +3966,40 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
kurbo 0.13.1 — https://github.com/linebender/kurbo
polycool 0.4.0 — https://github.com/linebender/kurbo
Copyright (c) 2018 Raph Levien
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -3948,6 +4050,34 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
svgtypes 0.16.1 — https://github.com/linebender/svgtypes
Copyright (c) 2018 Yevhenii Reizner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -4466,6 +4596,34 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
pico-args 0.5.0 — https://github.com/RazrFalcon/pico-args
Copyright (c) 2019 Yevhenii Reizner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -4957,6 +5115,33 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
strict-num 0.1.1 — https://github.com/RazrFalcon/strict-num
Copyright (c) 2022 Yevhenii Reizner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -5330,6 +5515,40 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
resvg 0.48.1 — https://github.com/linebender/resvg
usvg 0.48.1 — https://github.com/linebender/resvg
Copyright 2017 the Resvg Authors
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -5627,6 +5846,35 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
imagesize 0.15.0 — https://github.com/Roughsketch/imagesize
MIT License
Copyright (c) 2017 Maiddog
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -5763,6 +6011,35 @@ The above copyright notice and this permission notice (including the next paragr
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
rgb 0.8.53 — https://github.com/kornelski/rust-rgb
MIT License
Copyright (c) 2019 Kornel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -6764,6 +7041,31 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
siphasher 1.0.4 — https://github.com/jedisct1/rust-siphash
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
@@ -7015,6 +7317,64 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
simplecss 0.2.2 — https://github.com/linebender/simplecss
The MIT License (MIT)
Copyright (c) 2018 Reizner Evgeniy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
Used by:
roxmltree 0.21.1 — https://github.com/RazrFalcon/roxmltree
The MIT License (MIT)
Copyright (c) 2018 Yevhenii Reizner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
MIT License (MIT)
--------------------------------------------------------------------------------
+2
View File
@@ -24,6 +24,8 @@ nowhere else: maps, seed rule, scenes, positions, angles, weapons, durations
and input. To pin a scene to an owner-chosen position, replace its `Place`
there; the runner does not change.
The heavy scenario uses a 2560×1440 window with vsync off, recorded alongside the observed render target in `run.json`.
The heavy scenario also plants a claymore, drops a weapon through player death,
restarts the script round in place, and captures entity and resource ownership at gameplay and teardown boundaries.
Both scenarios use isolated accounts and wait for native progression content and run `unlock all` from the menu before
+13
View File
@@ -328,6 +328,18 @@ fn run(root: &Path, args: &Args) -> Result<bool, String> {
});
}
let (width, height) = hgl::RESOLUTION;
std::fs::write(
run_dir.join("settings.cfg"),
format!("resolution={width}x{height}\nfullscreen=false\nvsync=false\n"),
)
.map_err(|error| format!("write scenario settings: {error}"))?;
manifest["requested_render"] = json!({
"width": width,
"height": height,
"fullscreen": false,
"present_mode": "AutoNoVsync",
});
let cache = prepare_cache(root, &run_dir, args.cache)?;
manifest["cache"] = cache;
let bin = stage_binary(&args.bin, &run_dir)?;
@@ -353,6 +365,7 @@ fn run(root: &Path, args: &Args) -> Result<bool, String> {
args: child_args,
env: vec![
("IW4L_PERF", "1".into()),
("IW4L_PRESENT_MODE", "AutoNoVsync".into()),
("IW4L_GAMETYPE", SCENARIO.gametype.into()),
(
"IW4L_SETTINGS_PATH",
@@ -5,6 +5,8 @@ use crate::scenario::{
MapB, Phase, Place, ResolvedScene, Scenario, Scene, Weapon, capture_commands,
};
pub const RESOLUTION: (u32, u32) = (2560, 1440);
pub const SCENARIO: Scenario = Scenario {
name: "heavy_gameplay_lifecycle",
gametype: "dm",
+1 -1
View File
@@ -95,7 +95,7 @@ pub enum SecondaryActivation {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SecondaryPolicySource {
T5IndependentCompatibility,
IndependentLayers,
PrimaryPreparedCompatibility,
}
@@ -23,7 +23,7 @@ impl CueCompiler for T5CueCompiler {
streamed_decode: crate::StreamedDecodePolicy::WmaContainerWithWaveCompatibility,
secondary: (
SecondaryActivation::OnResolution,
SecondaryPolicySource::T5IndependentCompatibility,
SecondaryPolicySource::IndependentLayers,
),
}
}
@@ -27,8 +27,8 @@ impl CueCompiler for T6CueCompiler {
zero_volume_unity: false,
streamed_decode: crate::StreamedDecodePolicy::Detected,
secondary: (
SecondaryActivation::OnPrimaryPrepared,
SecondaryPolicySource::PrimaryPreparedCompatibility,
SecondaryActivation::OnResolution,
SecondaryPolicySource::IndependentLayers,
),
}
}
+7
View File
@@ -464,6 +464,13 @@ impl MenuCatalog {
seen.len()
}
pub fn insert_string_table(&mut self, table: CapturedStringTable) {
self.string_tables
.retain(|name, _| !name.eq_ignore_ascii_case(&table.name));
self.string_tables
.insert(table.name.to_ascii_lowercase(), table);
}
pub fn string_table(&self, name: &str) -> Option<&CapturedStringTable> {
if let Some(table) = self.string_tables.get(name) {
return Some(table);
+2
View File
@@ -1329,6 +1329,7 @@ pub struct T6AttachmentModel {
struct WeaponRow {
preparation: WeaponPreparationRecipe,
name: String,
native_script_name: String,
alternate_weapon: Option<String>,
impact_payload: Option<String>,
alternate_index: u32,
@@ -1469,6 +1470,7 @@ impl Default for WeaponRow {
Self {
preparation: WeaponPreparationRecipe::native(crate::AssetNamespace::Iw4),
name: String::new(),
native_script_name: String::new(),
alternate_weapon: None,
impact_payload: None,
alternate_index: 0,
@@ -115,11 +115,12 @@ impl WeaponCatalog {
dpad_icon_image: None,
dpad_icon_atlas: None,
dpad_icon_ratio: 0,
kill_icon: leftover_t5_material_name_opt(
stream,
geometry.weap_def,
fastfile_t5::size::WEAPON_DEF_KILL_ICON_OFF,
),
kill_icon: geometry
.kill_icon_name
.and_then(|ptr| stream.cstr(ptr).ok())
.map(asset_core::AssetRef::bare_name)
.filter(|name| !name.is_empty())
.map(str::to_owned),
kill_icon_slot: leftover_t5_asset_slot(
stream,
geometry.weap_def,
@@ -349,6 +349,7 @@ impl WeaponBuild {
rows.push(WeaponRow {
preparation: WeaponPreparationRecipe::for_capture(entry.namespace, &name),
name,
native_script_name: entry.name,
alternate_weapon: entry.alternate_weapon,
impact_payload: entry.impact_payload,
alternate_index: 0,
@@ -231,7 +231,17 @@ impl WeaponRegistry {
.describe_configuration(id)
.ok_or("unlock.missing_family")?;
let family = selection.family.as_ref().ok_or("unlock.missing_family")?;
self.item_unlock_requirement(family.namespace, &family.base)
match self.item_unlock_requirement(family.namespace, &family.base) {
Err("unlock.missing_item")
if matches!(
family.namespace,
crate::AssetNamespace::Iw4 | crate::AssetNamespace::Iw5
) =>
{
Ok(Default::default())
}
requirement => requirement,
}
}
pub fn vehicle_compass(&self) -> impl Iterator<Item = (&str, &[String; 2], [i32; 2])> {
@@ -920,6 +930,18 @@ impl WeaponRegistry {
gsc_weapon_script_name(self.name_of(index))
}
pub fn native_script_aliases(&self) -> impl Iterator<Item = (String, u32)> + '_ {
self.rows
.iter()
.enumerate()
.skip(1)
.filter_map(|(id, row)| {
(normalize_weapon_name(&row.native_script_name) == row.name
&& row.native_script_name != gsc_weapon_script_name(&row.name))
.then(|| (row.native_script_name.clone(), id as u32))
})
}
pub fn configuration_supported(&self, id: u32) -> bool {
let Some(row) = self.rows.get(id as usize) else {
return false;
+207 -114
View File
@@ -1,3 +1,9 @@
use std::{
collections::{HashMap, hash_map::DefaultHasher},
hash::{Hash, Hasher},
sync::{Arc, Weak},
};
use asset_iw4::size as sz;
use bevy::prelude::{Quat, Vec3};
use fastfile_iw4::{Ptr, ScriptStrings, XModelGeometry, ZonePtr, ZoneStream};
@@ -59,11 +65,8 @@ pub struct ModelSkel {
pub tag_weapon: Option<usize>,
pub pose: Option<ModelPoseSrc>,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub colors: Vec<[f32; 4]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
pub positions: Arc<[[f32; 3]]>,
pub indices: Arc<[u32]>,
pub surface_materials: Vec<Option<crate::WalkLocalMaterialIndex>>,
@@ -77,11 +80,11 @@ pub struct ModelSkel {
pub surface_vert_list_count: Vec<Option<u32>>,
pub vert_skin: Vec<VertSkin>,
pub vert_skin: Arc<[VertSkin]>,
pub rigid_verts: usize,
pub blend_verts: usize,
pub packed_vertices: Vec<[u8; sz::GFX_PACKED_VERTEX]>,
pub packed_vertices: Arc<[[u8; sz::GFX_PACKED_VERTEX]]>,
pub radius: Option<f32>,
@@ -106,7 +109,57 @@ pub struct ModelSkel {
pub lod_surf_span: [(u16, u16); 4],
}
fn vertex_word(vertex: &[u8; sz::GFX_PACKED_VERTEX], offset: usize) -> u32 {
u32::from_le_bytes(vertex[offset..offset + 4].try_into().unwrap())
}
fn vertex_normal(vertex: &[u8; sz::GFX_PACKED_VERTEX]) -> [f32; 3] {
normalize_or_up(unpack_unit_vec(vertex_word(vertex, 24)))
}
impl ModelSkel {
pub fn has_same_vertex_attributes(&self, other: &Self) -> bool {
self.packed_vertices.len() == other.packed_vertices.len()
&& self
.packed_vertices
.iter()
.zip(other.packed_vertices.iter())
.all(|(a, b)| {
let uv = vertex_word(a, 20);
let other_uv = vertex_word(b, 20);
let half_is_nan = |bits: u32| bits & 0x7c00 == 0x7c00 && bits & 0x03ff != 0;
vertex_word(a, 16) == vertex_word(b, 16)
&& (vertex_word(a, 24) == vertex_word(b, 24)
|| vertex_normal(a) == vertex_normal(b))
&& if uv == other_uv {
// Identical NaN encodings must still fail decoded float equality.
!half_is_nan(uv) && !half_is_nan(uv >> 16)
} else {
unpack_packed_tex_coords(uv) == unpack_packed_tex_coords(other_uv)
}
})
}
pub fn normal(&self, index: usize) -> Option<[f32; 3]> {
self.packed_vertices.get(index).map(vertex_normal)
}
pub fn normals(&self) -> impl ExactSizeIterator<Item = [f32; 3]> + '_ {
self.packed_vertices.iter().map(vertex_normal)
}
pub fn colors(&self) -> impl ExactSizeIterator<Item = [f32; 4]> + '_ {
self.packed_vertices
.iter()
.map(|v| unpack_color(vertex_word(v, 16)))
}
pub fn uvs(&self) -> impl ExactSizeIterator<Item = [f32; 2]> + '_ {
self.packed_vertices
.iter()
.map(|v| unpack_packed_tex_coords(vertex_word(v, 20)))
}
pub fn retained_capability(&self) -> Option<xmodel_runtime::RetainedModelCapability> {
Some(xmodel_runtime::RetainedModelCapability {
key: self.name.clone(),
@@ -160,12 +213,74 @@ impl ModelSkel {
pub type FpvSkel = ModelSkel;
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VertSkin {
pub bones: [u16; 4],
pub weights: [f32; 4],
pub bones: [u8; 4],
extra_weights: [u16; 3],
weight_flags: u8,
}
pub weight_u16: [u16; 4],
impl Default for VertSkin {
fn default() -> Self {
Self {
bones: [0; 4],
extra_weights: [0; 3],
weight_flags: 1 << 4,
}
}
}
impl VertSkin {
pub(crate) fn rigid(bone: u8) -> Self {
Self {
bones: [bone, 0, 0, 0],
weight_flags: 1 << 5,
..Self::default()
}
}
pub fn has_extra_weight(&self, index: usize) -> bool {
self.weight_flags & (1 << index) != 0
}
pub fn primary_is_zero(&self) -> bool {
self.weight_flags & (1 << 4) != 0
}
fn weight_is_nonzero(&self, index: usize) -> bool {
if index == 0 {
!self.primary_is_zero()
} else {
self.has_extra_weight(index)
}
}
pub fn has_only_primary_weight(&self) -> bool {
self.weight_flags & 0x2e == 0x20
}
pub(crate) fn is_rigid(&self) -> bool {
self.weight_flags & (1 << 5) != 0 && !self.has_extra_weight(1)
}
pub fn extra_weight(&self, index: usize) -> u16 {
self.extra_weights[index - 1]
}
pub(crate) fn set_extra_weight(&mut self, index: usize, raw: u16) {
self.extra_weights[index - 1] = raw;
}
pub(crate) fn set_weight(&mut self, index: usize, weight: f32) {
if index == 0 {
self.weight_flags = (self.weight_flags & 0x0f)
| (u8::from(weight == 0.0) << 4)
| (u8::from(weight == 1.0) << 5);
} else {
self.weight_flags =
(self.weight_flags & !(1 << index)) | (u8::from(weight > 0.0) << index);
}
}
}
#[derive(Clone, Copy, Debug)]
@@ -193,9 +308,38 @@ impl From<BoneBind> for TagViewBind {
#[derive(Clone, Debug, Default)]
pub struct SharedXModelSurfaces(
pub std::collections::BTreeMap<String, (std::sync::Arc<ModelSkel>, usize)>,
HashMap<u64, Vec<Weak<ModelSkel>>>,
);
impl SharedXModelSurfaces {
pub fn share_geometry(&mut self, mut skel: ModelSkel) -> Arc<ModelSkel> {
let mut hash = DefaultHasher::new();
skel.packed_vertices.as_ref().as_flattened().hash(&mut hash);
let candidates = self.1.entry(hash.finish()).or_default();
candidates.retain(|candidate| candidate.strong_count() != 0);
for candidate in candidates.iter().filter_map(Weak::upgrade) {
if skel.packed_vertices == candidate.packed_vertices
&& skel.indices == candidate.indices
&& skel.vert_skin == candidate.vert_skin
&& skel.positions.len() == candidate.positions.len()
&& skel
.positions
.iter()
.zip(candidate.positions.iter())
.all(|(a, b)| a.map(f32::to_bits) == b.map(f32::to_bits))
{
skel.packed_vertices = candidate.packed_vertices.clone();
skel.positions = candidate.positions.clone();
skel.indices = candidate.indices.clone();
skel.vert_skin = candidate.vert_skin.clone();
break;
}
}
let skel = Arc::new(skel);
candidates.push(Arc::downgrade(&skel));
skel
}
pub fn retain(
&mut self,
stream: &ZoneStream<'_>,
@@ -344,9 +488,6 @@ fn capture_model_skel_iw4(
let bone_collision = capture_bone_collision(stream, geometry, geometry.num_bones);
let mut positions = Vec::new();
let mut normals = Vec::new();
let mut colors = Vec::new();
let mut uvs = Vec::new();
let mut indices = Vec::new();
let mut surface_materials = Vec::new();
let mut surface_vertex_ranges = Vec::new();
@@ -382,8 +523,8 @@ fn capture_model_skel_iw4(
let (v, vn) = source.surface_vertex_ranges[n];
let (ix, ixn) = source.surface_index_ranges[n];
if source.vert_skin[v..v + vn].iter().any(|skin| {
skin.bones.iter().zip(skin.weights).any(|(bone, weight)| {
weight != 0.0 && usize::from(*bone) >= geometry.num_bones
skin.bones.iter().enumerate().any(|(index, bone)| {
skin.weight_is_nonzero(index) && usize::from(*bone) >= geometry.num_bones
})
}) {
return None;
@@ -391,12 +532,10 @@ fn capture_model_skel_iw4(
let base = positions.len();
let index_start = indices.len();
positions.extend_from_slice(&source.positions[v..v + vn]);
normals.extend_from_slice(&source.normals[v..v + vn]);
colors.extend_from_slice(&source.colors[v..v + vn]);
uvs.extend_from_slice(&source.uvs[v..v + vn]);
packed_vertices.extend_from_slice(&source.packed_vertices[v..v + vn]);
for skin in &source.vert_skin[v..v + vn] {
if skin.weights[1] == 0.0 && skin.weights[0] == 1.0 {
if skin.is_rigid() {
rigid_verts += 1;
} else {
blend_verts += 1;
@@ -465,13 +604,9 @@ fn capture_model_skel_iw4(
stream.f32_at(vertex, 4).ok()?,
stream.f32_at(vertex, 8).ok()?,
]);
colors.push(unpack_color(stream.u32_at(vertex, 16).ok()?));
uvs.push(unpack_packed_tex_coords(stream.u32_at(vertex, 20).ok()?));
normals.push(normalize_or_up(unpack_unit_vec(
stream.u32_at(vertex, 24).ok()?,
)));
let skin = surface_skins[vertex_index];
if skin.weights[1] == 0.0 && skin.weights[0] == 1.0 {
if skin.is_rigid() {
rigid_verts += 1;
} else {
blend_verts += 1;
@@ -515,21 +650,18 @@ fn capture_model_skel_iw4(
tag_view,
tag_weapon,
pose,
positions,
normals,
colors,
uvs,
indices,
positions: positions.into(),
indices: indices.into(),
surface_materials,
surface_vertex_ranges,
surface_index_ranges,
surface_part_bits,
surface_deformed,
surface_vert_list_count,
vert_skin,
vert_skin: vert_skin.into(),
rigid_verts,
blend_verts,
packed_vertices,
packed_vertices: packed_vertices.into(),
radius: geometry.radius,
bounds: match (geometry.bounds_mid, geometry.bounds_half) {
(Some(mid), Some(half))
@@ -772,12 +904,14 @@ fn decode_surface_skin(
num_bones: usize,
) -> Option<Vec<VertSkin>> {
let mut skins = vec![VertSkin::default(); vertex_count];
let bone_at = |raw: u16| -> Option<u16> {
let bone_at = |raw: u16| -> Option<u8> {
if raw % BONE_STRIDE != 0 {
return None;
}
let bone = raw / BONE_STRIDE;
((bone as usize) < num_bones).then_some(bone)
((bone as usize) < num_bones)
.then(|| u8::try_from(bone).ok())
.flatten()
};
if vert_list_count > 0 {
@@ -793,11 +927,7 @@ fn decode_surface_skin(
if vertex >= vertex_count {
break;
}
skins[vertex] = VertSkin {
bones: [bone, 0, 0, 0],
weights: [1.0, 0.0, 0.0, 0.0],
..Default::default()
};
skins[vertex] = VertSkin::rigid(bone);
vertex += 1;
}
}
@@ -825,11 +955,7 @@ fn decode_surface_skin(
);
}
for skin in &mut skins {
*skin = VertSkin {
bones: [0, 0, 0, 0],
weights: [1.0, 0.0, 0.0, 0.0],
..Default::default()
};
*skin = VertSkin::rigid(0);
}
return Some(skins);
}
@@ -858,11 +984,11 @@ fn decode_surface_skin(
let raw = stream.u16_at(blend_ptr, off * 2 + 2).ok()?;
let w = dpvs_iw4::skin_blend_weight(raw);
skin.bones[extra] = bone_at(b)?;
skin.weights[extra] = w;
skin.weight_u16[extra] = raw;
skin.set_weight(extra, w);
skin.set_extra_weight(extra, raw);
remaining -= w;
}
skin.weights[0] = remaining;
skin.set_weight(0, remaining);
if vertex >= vertex_count {
return None;
}
@@ -975,9 +1101,6 @@ fn capture_model_skel_t5(
let pose = capture_pose_src_t5(stream, &geometry, &bone_name_strs, &bones, num_child);
let mut positions = Vec::new();
let mut normals = Vec::new();
let mut colors = Vec::new();
let mut uvs = Vec::new();
let mut indices = Vec::new();
let mut surface_materials = Vec::with_capacity(geometry.surface_count);
let mut surface_vertex_ranges = Vec::with_capacity(geometry.surface_count);
@@ -1032,13 +1155,9 @@ fn capture_model_skel_t5(
stream.f32_at(vertex, 4).ok()?,
stream.f32_at(vertex, 8).ok()?,
]);
normals.push(normalize_or_up(unpack_unit_vec(
stream.u32_at(vertex, 24).ok()?,
)));
colors.push(unpack_color(stream.u32_at(vertex, 16).ok()?));
uvs.push(unpack_packed_tex_coords(stream.u32_at(vertex, 20).ok()?));
let skin = surface_skins[vertex_index];
if skin.weights[1] == 0.0 && skin.weights[0] == 1.0 {
if skin.is_rigid() {
rigid_verts += 1;
} else {
blend_verts += 1;
@@ -1075,21 +1194,18 @@ fn capture_model_skel_t5(
tag_view,
tag_weapon,
pose,
positions,
normals,
colors,
uvs,
indices,
positions: positions.into(),
indices: indices.into(),
surface_materials,
surface_vertex_ranges,
surface_index_ranges,
surface_part_bits,
surface_deformed,
surface_vert_list_count,
vert_skin,
vert_skin: vert_skin.into(),
rigid_verts,
blend_verts,
packed_vertices,
packed_vertices: packed_vertices.into(),
radius: geometry.radius,
bounds: None,
contents: Some(geometry.contents),
@@ -1355,12 +1471,14 @@ fn decode_surface_skin_t5(
rigid_stride: usize,
) -> Option<Vec<VertSkin>> {
let mut skins = vec![VertSkin::default(); vertex_count];
let bone_at = |raw: u16| -> Option<u16> {
let bone_at = |raw: u16| -> Option<u8> {
if raw % BONE_STRIDE != 0 {
return None;
}
let bone = raw / BONE_STRIDE;
((bone as usize) < num_bones).then_some(bone)
((bone as usize) < num_bones)
.then(|| u8::try_from(bone).ok())
.flatten()
};
if vert_list_count > 0 {
@@ -1376,11 +1494,7 @@ fn decode_surface_skin_t5(
if vertex >= vertex_count {
break;
}
skins[vertex] = VertSkin {
bones: [bone, 0, 0, 0],
weights: [1.0, 0.0, 0.0, 0.0],
..Default::default()
};
skins[vertex] = VertSkin::rigid(bone);
vertex += 1;
}
}
@@ -1401,11 +1515,7 @@ fn decode_surface_skin_t5(
fastfile_t5::ZonePtr::Offset(p) => stream.resolve_alias(p),
fastfile_t5::ZonePtr::Null if counts.iter().all(|&c| c == 0) => {
for skin in &mut skins {
*skin = VertSkin {
bones: [0, 0, 0, 0],
weights: [1.0, 0.0, 0.0, 0.0],
..Default::default()
};
*skin = VertSkin::rigid(0);
}
return Some(skins);
}
@@ -1434,11 +1544,11 @@ fn decode_surface_skin_t5(
let raw = stream.u16_at(blend_ptr, off * 2 + 2).ok()?;
let w = dpvs_iw4::skin_blend_weight(raw);
skin.bones[extra] = bone_at(b)?;
skin.weights[extra] = w;
skin.weight_u16[extra] = raw;
skin.set_weight(extra, w);
skin.set_extra_weight(extra, raw);
remaining -= w;
}
skin.weights[0] = remaining;
skin.set_weight(0, remaining);
if vertex >= vertex_count {
return None;
}
@@ -1582,10 +1692,9 @@ fn capture_model_skel_iw5(
let (source, _) = shared?.0.get(surfaces_name)?;
if source.surface_vertex_ranges.len() != geometry.surface_count
|| source.vert_skin.iter().any(|skin| {
skin.bones
.iter()
.zip(skin.weights)
.any(|(bone, weight)| weight != 0.0 && usize::from(*bone) >= geometry.num_bones)
skin.bones.iter().enumerate().any(|(index, bone)| {
skin.weight_is_nonzero(index) && usize::from(*bone) >= geometry.num_bones
})
})
{
return None;
@@ -1605,9 +1714,6 @@ fn capture_model_skel_iw5(
};
let mut positions = Vec::new();
let mut normals = Vec::new();
let mut colors = Vec::new();
let mut uvs = Vec::new();
let mut indices = Vec::new();
let mut packed_vertices = Vec::new();
let mut surface_materials = Vec::with_capacity(geometry.surface_count);
@@ -1673,13 +1779,9 @@ fn capture_model_skel_iw5(
stream.f32_at(vertex, 8).ok()?,
]);
packed_vertices.push(copy_packed_vertex_iw5(stream, vertex)?);
normals.push(normalize_or_up(unpack_unit_vec(
stream.u32_at(vertex, 24).ok()?,
)));
colors.push(unpack_color(stream.u32_at(vertex, 16).ok()?));
uvs.push(unpack_packed_tex_coords(stream.u32_at(vertex, 20).ok()?));
let skin = surface_skins[vertex_index];
if skin.weights[1] == 0.0 && skin.weights[0] == 1.0 {
if skin.is_rigid() {
rigid_verts += 1;
} else {
blend_verts += 1;
@@ -1706,21 +1808,18 @@ fn capture_model_skel_iw5(
tag_view,
tag_weapon,
pose,
positions,
normals,
colors,
uvs,
indices,
positions: positions.into(),
indices: indices.into(),
surface_materials,
surface_vertex_ranges,
surface_index_ranges,
surface_part_bits,
surface_deformed,
surface_vert_list_count,
vert_skin,
vert_skin: vert_skin.into(),
rigid_verts,
blend_verts,
packed_vertices,
packed_vertices: packed_vertices.into(),
radius: geometry.radius,
bounds: None,
contents: None,
@@ -1861,12 +1960,14 @@ fn decode_surface_skin_iw5(
use fastfile_iw5::size as iw5sz;
let mut skins = vec![VertSkin::default(); vertex_count];
let bone_at = |raw: u16| -> Option<u16> {
let bone_at = |raw: u16| -> Option<u8> {
if raw % BONE_STRIDE != 0 {
return None;
}
let bone = raw / BONE_STRIDE;
((bone as usize) < num_bones).then_some(bone)
((bone as usize) < num_bones)
.then(|| u8::try_from(bone).ok())
.flatten()
};
let mut rigid_assigned = 0usize;
@@ -1885,11 +1986,7 @@ fn decode_surface_skin_iw5(
if vertex >= vertex_count {
break;
}
skins[vertex] = VertSkin {
bones: [bone, 0, 0, 0],
weights: [1.0, 0.0, 0.0, 0.0],
..Default::default()
};
skins[vertex] = VertSkin::rigid(bone);
vertex += 1;
}
}
@@ -1921,11 +2018,7 @@ fn decode_surface_skin_iw5(
);
}
for skin in &mut skins {
*skin = VertSkin {
bones: [0, 0, 0, 0],
weights: [1.0, 0.0, 0.0, 0.0],
..Default::default()
};
*skin = VertSkin::rigid(0);
}
return Some(skins);
}
@@ -1954,11 +2047,11 @@ fn decode_surface_skin_iw5(
let raw = stream.u16_at(blend_ptr, off * 2 + 2).ok()?;
let w = dpvs_iw4::skin_blend_weight(raw);
skin.bones[extra] = bone_at(b)?;
skin.weights[extra] = w;
skin.weight_u16[extra] = raw;
skin.set_weight(extra, w);
skin.set_extra_weight(extra, raw);
remaining -= w;
}
skin.weights[0] = remaining;
skin.set_weight(0, remaining);
if vertex >= vertex_count {
return None;
}
+14 -27
View File
@@ -3,9 +3,7 @@ use fastfile_t6::{Ptr, ZoneLoad};
use crate::dobj::ModelPoseSrc;
use crate::model_skel::{BoneBind, ModelSkel, VertSkin};
use crate::packed_vertex::{
normalize_or_up, repack_vertex_t6, unpack_color, unpack_packed_tex_coords, unpack_unit_vec,
};
use crate::packed_vertex::repack_vertex_t6;
use asset_core::WalkLocalMaterialIndex;
pub use xmodel_runtime::BoneCollision;
@@ -174,9 +172,6 @@ pub fn capture_model_skel_t6(
let pose = capture_pose_src(&r, &model, &name, &bone_name_strs, &bones, num_child);
let mut positions = Vec::new();
let mut normals = Vec::new();
let mut colors = Vec::new();
let mut uvs = Vec::new();
let mut indices = Vec::new();
let mut surface_materials = Vec::with_capacity(surface_count);
let mut surface_vertex_ranges = Vec::with_capacity(surface_count);
@@ -217,13 +212,10 @@ pub fn capture_model_skel_t6(
.try_into()
.ok()?,
);
let word = |o: usize| u32::from_le_bytes(packed[o..o + 4].try_into().unwrap());
packed_vertices.push(packed);
positions.push([r.f32(vertex)?, r.f32(vertex.at(4))?, r.f32(vertex.at(8))?]);
normals.push(normalize_or_up(unpack_unit_vec(word(24))));
colors.push(unpack_color(word(16)));
uvs.push(unpack_packed_tex_coords(word(20)));
if skin.weights[1] == 0.0 && skin.weights[0] == 1.0 {
if skin.is_rigid() {
rigid_verts += 1;
} else {
blend_verts += 1;
@@ -260,21 +252,18 @@ pub fn capture_model_skel_t6(
tag_view,
tag_weapon,
pose,
positions,
normals,
colors,
uvs,
indices,
positions: positions.into(),
indices: indices.into(),
surface_materials,
surface_vertex_ranges,
surface_index_ranges,
surface_part_bits,
surface_deformed,
surface_vert_list_count,
vert_skin,
vert_skin: vert_skin.into(),
rigid_verts,
blend_verts,
packed_vertices,
packed_vertices: packed_vertices.into(),
radius: Some(model.h_f32(off::RADIUS)),
bounds: None,
contents: Some(model.h_u32(off::CONTENTS)),
@@ -355,18 +344,16 @@ fn decode_surface_skin(
num_bones: usize,
) -> Option<Vec<VertSkin>> {
let mut skins = vec![VertSkin::default(); vertex_count];
let bone_at = |raw: u16| -> Option<u16> {
let bone_at = |raw: u16| -> Option<u8> {
if !raw.is_multiple_of(BONE_STRIDE) {
return None;
}
let bone = raw / BONE_STRIDE;
(usize::from(bone) < num_bones).then_some(bone)
};
let rigid = |bone| VertSkin {
bones: [bone, 0, 0, 0],
weights: [1.0, 0.0, 0.0, 0.0],
..Default::default()
(usize::from(bone) < num_bones)
.then(|| u8::try_from(bone).ok())
.flatten()
};
let rigid = |bone| VertSkin::rigid(bone);
if vert_list_count > 0
&& let Some(Some(list)) = r.ptr(surface.at(40))
@@ -420,10 +407,10 @@ fn decode_surface_skin(
let o = start + 1 + (extra as u32 - 1) * 2;
skin.bones[extra] = bone_at(r.u16(blend.at(o * 2))?)?;
let w = f32::from(r.u16(blend.at(o * 2 + 2))?) * WEIGHT_SCALE;
skin.weights[extra] = w;
skin.set_weight(extra, w);
remaining -= w;
}
skin.weights[0] = remaining;
skin.set_weight(0, remaining);
if vertex >= vertex_count {
return None;
}
-3
View File
@@ -250,9 +250,6 @@ fn game_files(root: &Path) -> impl Iterator<Item = Result<PathBuf, String>> {
files_under(search_roots(root))
}
/// Folders the game never keeps zones in: cargo's output when the games root is the build's own
/// `target/play` (tens of thousands of files, walked on every discovery), and our own
/// artifacts.
const NOT_GAME_DIRS: [&str; 6] = [
"incremental",
"deps",
+18 -3
View File
@@ -1022,9 +1022,24 @@ pub fn model_mesh(skel: asset_model::ModelSkel) -> Result<crate::ModelMesh, Stri
Mesh::ATTRIBUTE_POSITION,
skel.positions[first..end].to_vec(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, skel.normals[first..end].to_vec());
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, skel.colors[first..end].to_vec());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, skel.uvs[first..end].to_vec());
mesh.insert_attribute(
Mesh::ATTRIBUTE_NORMAL,
skel.normals()
.skip(first)
.take(end - first)
.collect::<Vec<_>>(),
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_COLOR,
skel.colors()
.skip(first)
.take(end - first)
.collect::<Vec<_>>(),
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_UV_0,
skel.uvs().skip(first).take(end - first).collect::<Vec<_>>(),
);
mesh.insert_indices(Indices::U32(indices));
surfaces.push(crate::model_mesh::ModelSurfaceDraw {
mesh,
+1 -1
View File
@@ -1469,7 +1469,7 @@ impl AssetLinkSink for CommonWalkSink {
geometry,
Some(&self.materials),
)
.map(std::sync::Arc::new);
.map(|skel| self.shared_surfaces.share_geometry(skel));
if let Some(skel) = &skel {
self.shared_surfaces.retain(stream, geometry, skel.clone());
self.fx_models.capture_shared(skel.clone(), &self.materials);
+1 -1
View File
@@ -24,7 +24,7 @@ playerstate_iw4.workspace = true
gamemode_iw4.workspace = true
perf.workspace = true
symphonia = { version = "0.5", default-features = false, features = ["mp3", "pcm", "wav", "adpcm", "flac"] }
cpal = "=0.17.3"
cpal = "=0.18.2"
[lints]
workspace = true
+1
View File
@@ -366,6 +366,7 @@ impl CueWork {
}
_ => (None, volume),
};
media.bus = self.request.namespace;
let start = CueStart {
looping: self.source.is_some()
|| (matches!(intent.class, SoundClass::Music | SoundClass::Ambience)
+17 -10
View File
@@ -92,7 +92,7 @@ pub(crate) fn supervise(shared: Arc<RenderShared>, shutdown: Arc<AtomicBool>) {
pub(crate) fn open(
shared: Arc<RenderShared>,
failed: Arc<AtomicU8>,
error_tx: SyncSender<cpal::StreamError>,
error_tx: SyncSender<cpal::Error>,
) -> Result<cpal::Stream, String> {
let device = cpal::default_host()
.default_output_device()
@@ -157,8 +157,8 @@ fn build<T: SizedSample + FromSample<f32>>(
config: &cpal::StreamConfig,
shared: Arc<RenderShared>,
failed: Arc<AtomicU8>,
error_tx: SyncSender<cpal::StreamError>,
) -> Result<cpal::Stream, cpal::BuildStreamError> {
error_tx: SyncSender<cpal::Error>,
) -> Result<cpal::Stream, cpal::Error> {
let channels = usize::from(config.channels);
let step = f64::from(SAMPLE_RATE) / f64::from(config.sample_rate);
let mut block = [[0.0; 2]; QUANTUM];
@@ -169,7 +169,7 @@ fn build<T: SizedSample + FromSample<f32>>(
let mut primed = false;
let errors = shared.clone();
device.build_output_stream(
config,
config.clone(),
move |output: &mut [T], _: &cpal::OutputCallbackInfo| {
shared.device_callbacks.fetch_add(1, Ordering::Release);
let mut next = || {
@@ -211,14 +211,21 @@ fn build<T: SizedSample + FromSample<f32>>(
}
frames.into_remainder().fill(T::EQUILIBRIUM);
},
move |error| match error {
cpal::StreamError::BufferUnderrun => {
move |error| match error.kind() {
cpal::ErrorKind::Xrun => {
errors.device_underruns.fetch_add(1, Ordering::Relaxed);
}
error => {
let code = match &error {
cpal::StreamError::DeviceNotAvailable => 1,
cpal::StreamError::StreamInvalidated => 2,
cpal::ErrorKind::DeviceChanged => {
// The backend follows the default device and keeps this stream alive.
diag::info!(Audio, "audio: output device changed: {error}");
}
cpal::ErrorKind::RealtimeDenied => {
diag::warn!(Audio, "audio: realtime scheduling unavailable: {error}");
}
kind => {
let code = match kind {
cpal::ErrorKind::DeviceNotAvailable => 1,
cpal::ErrorKind::StreamInvalidated => 2,
_ => 3,
};
let _ = error_tx.try_send(error);
+43 -46
View File
@@ -19,7 +19,7 @@ fn selected_alias<'a>(
event: EntityEventKind,
player_view: bool,
knife: bool,
) -> Option<(asset_core::AssetNamespace, &'a str)> {
) -> Result<Option<(asset_core::AssetNamespace, &'a str)>, String> {
let weapon = if matches!(
event,
EntityEventKind::MELEE_SWIPE | EntityEventKind::MELEE_HIT | EntityEventKind::MELEE_MISS
@@ -74,25 +74,30 @@ fn selected_alias<'a>(
WeaponSoundSlot::MeleeSwipePlayer,
),
EntityEventKind::MELEE_HIT => {
return weapons.melee_impact_sound_key(
weapon,
knife,
asset_game::MeleeImpact::Hit,
bank,
);
return weapons
.melee_impact_sound_key(weapon, knife, asset_game::MeleeImpact::Hit, bank)
.map(Some)
.ok_or_else(|| "melee hit sound is not in the sound bank".to_owned());
}
EntityEventKind::MELEE_MISS => {
return weapons.melee_impact_sound_key(
weapon,
knife,
asset_game::MeleeImpact::Miss,
bank,
);
return weapons
.melee_impact_sound_key(weapon, knife, asset_game::MeleeImpact::Miss, bank)
.map(Some)
.ok_or_else(|| "melee miss sound is not in the sound bank".to_owned());
}
_ => return None,
_ => return Ok(None),
};
weapons.weapon_sound_key(weapon, if player_view { player } else { world }, bank)
let slot = if player_view { player } else { world };
// Weapon sound slots are optional. An empty slot is authored silence;
// sounds can also come from animation notetracks rather than this event.
let Some(alias) = weapons.authored_weapon_sound(weapon, slot) else {
return Ok(None);
};
weapons
.weapon_sound_key(weapon, slot, bank)
.map(Some)
.ok_or_else(|| format!("{slot:?} alias `{alias}` is not in the sound bank"))
}
fn entity_event_sound(
@@ -190,39 +195,31 @@ fn entity_event_sound(
);
return;
}
// Offhand view sounds may be authored entirely as animation notetracks.
// A missing player alias must not fall back to the world throw sound.
if event == EntityEventKind::USE_OFFHAND
&& weapons
.registry()
.authored_weapon_sound(
let selected = bank
.as_deref()
.ok_or_else(|| "sound bank is unavailable".to_owned())
.and_then(|bank| {
selected_alias(
&weapons.registry(),
&bank.0,
weapon,
if player_view {
WeaponSoundSlot::FirePlayer
} else {
WeaponSoundSlot::Fire
},
event,
player_view,
sound.event.payload.event_parm == 1,
)
.is_none()
{
return;
}
let Some((namespace, alias)) = bank.as_deref().and_then(|bank| {
selected_alias(
&weapons.registry(),
&bank.0,
weapon,
event,
player_view,
sound.event.payload.event_parm == 1,
)
}) else {
diag::warn!(
Audio,
"audio: entity sound event {} has no authored alias",
event.0
);
return;
});
let (namespace, alias) = match selected {
Ok(Some(selected)) => selected,
Ok(None) => return,
Err(error) => {
diag::warn!(
Audio,
"audio: entity sound event {} refused: weapon={} player_view={player_view}: {error}",
event.0,
weapons.registry().name_of(weapon)
);
return;
}
};
output.write(WeaponSound {
event: Some(crate::AudioEvent::from_entity(
+1
View File
@@ -67,6 +67,7 @@ pub use render_core::InstanceStatus;
pub use runtime::{AudioDiagnostics, AudioRuntime};
mod media;
mod mix_bus;
mod offline;
pub use decode_budget::{DecodeMemory, decode_memory};
+2
View File
@@ -204,6 +204,7 @@ pub(crate) struct RenderMedia {
pub gain: Option<LiveGain>,
pub channel_gain: Option<LiveGain>,
pub release: Option<Arc<CueRelease>>,
pub bus: asset_core::AssetNamespace,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -410,6 +411,7 @@ impl RenderMedia {
gain: None,
channel_gain: None,
release: None,
bus: asset_core::AssetNamespace::Iw4,
}
}
+155
View File
@@ -0,0 +1,155 @@
use asset_core::AssetNamespace;
use crate::render_core::{QUANTUM, SAMPLE_RATE};
const BUSES: usize = 4;
#[derive(Clone, Copy)]
struct Compressor {
threshold_db: f32,
ratio: f32,
makeup_db: f32,
attack_s: f32,
release_s: f32,
}
#[derive(Clone, Copy)]
struct BusStage {
trim_db: f32,
compressor: Option<Compressor>,
}
fn stage(namespace: AssetNamespace) -> BusStage {
match namespace {
AssetNamespace::Iw4 => BusStage {
trim_db: 0.0,
compressor: None,
},
AssetNamespace::Iw5 => BusStage {
trim_db: -2.5,
compressor: None,
},
AssetNamespace::T5 => BusStage {
trim_db: 7.0,
compressor: None,
},
AssetNamespace::T6 => BusStage {
trim_db: 0.0,
compressor: Some(Compressor {
threshold_db: -40.0,
ratio: 4.0,
makeup_db: 29.5,
attack_s: 0.002,
release_s: 0.212,
}),
},
}
}
fn coefficient(seconds: f32) -> f32 {
1.0 - (-1.0 / (seconds * SAMPLE_RATE as f32)).exp()
}
fn db_to_gain(db: f32) -> f32 {
10f32.powf(db / 20.0)
}
pub(crate) struct MixBuses {
buffers: [[[f32; 2]; QUANTUM]; BUSES],
used: [bool; BUSES],
envelope: [f32; BUSES],
}
impl Default for MixBuses {
fn default() -> Self {
Self {
buffers: [[[0.0; 2]; QUANTUM]; BUSES],
used: [false; BUSES],
envelope: [0.0; BUSES],
}
}
}
impl MixBuses {
pub(crate) fn bus(&mut self, namespace: AssetNamespace) -> &mut [[f32; 2]; QUANTUM] {
let index = namespace as usize;
if !self.used[index] {
self.used[index] = true;
self.buffers[index] = [[0.0; 2]; QUANTUM];
}
&mut self.buffers[index]
}
pub(crate) fn mix_into(&mut self, output: &mut [[f32; 2]; QUANTUM]) {
for namespace in AssetNamespace::ALL {
let index = namespace as usize;
let stage = stage(namespace);
let Some(compressor) = stage.compressor else {
if self.used[index] {
let trim = db_to_gain(stage.trim_db);
for (target, frame) in output.iter_mut().zip(&self.buffers[index]) {
target[0] += frame[0] * trim;
target[1] += frame[1] * trim;
}
}
continue;
};
if !self.used[index] {
if self.envelope[index] < 1e-12 {
continue;
}
self.buffers[index] = [[0.0; 2]; QUANTUM];
}
let attack = coefficient(compressor.attack_s);
let release = coefficient(compressor.release_s);
let slope = 1.0 - 1.0 / compressor.ratio;
let mut envelope = self.envelope[index];
for (target, frame) in output.iter_mut().zip(&self.buffers[index]) {
let power = (frame[0] * frame[0] + frame[1] * frame[1]) * 0.5;
let power = if power.is_finite() { power } else { 0.0 };
envelope += (power - envelope) * if power > envelope { attack } else { release };
let level_db = 10.0 * envelope.max(1e-12).log10();
let over = (level_db - compressor.threshold_db).max(0.0);
let gain = db_to_gain(stage.trim_db + compressor.makeup_db - over * slope);
target[0] += frame[0] * gain;
target[1] += frame[1] * gain;
}
self.envelope[index] = envelope;
}
self.used = [false; BUSES];
}
}
pub(crate) struct MasterLimiter {
gain: f32,
}
impl Default for MasterLimiter {
fn default() -> Self {
Self { gain: 1.0 }
}
}
const LIMITER_CEILING: f32 = 0.95;
const LIMITER_RELEASE_S: f32 = 0.08;
impl MasterLimiter {
pub(crate) fn apply(&mut self, output: &mut [[f32; 2]; QUANTUM]) {
let release = coefficient(LIMITER_RELEASE_S);
for frame in output {
let peak = frame[0].abs().max(frame[1].abs());
let target = if peak > LIMITER_CEILING {
LIMITER_CEILING / peak
} else {
1.0
};
self.gain = if target < self.gain {
target
} else {
self.gain + (target - self.gain) * release
};
frame[0] *= self.gain;
frame[1] *= self.gain;
}
}
}
+8 -8
View File
@@ -60,15 +60,15 @@ impl Plugin for PlayerSoundPlugin {
.scoped::<crate::ambient::SoundBankLoadAttempted>(frame::MatchScope::Loading)
.scoped::<crate::ambient::ResidentSoundBank>(frame::MatchScope::Loading)
.scoped::<crate::BobCycleTracker>(frame::MatchScope::Live)
.scoped::<crate::shellshock::ShellshockSources>(frame::MatchScope::Live)
.scoped::<crate::breath::BreathSources>(frame::MatchScope::Live)
.scoped_message::<AliasCommand>(frame::MatchScope::Live)
.scoped_message::<Footstep>(frame::MatchScope::Live)
.scoped_message::<WeaponSound>(frame::MatchScope::Live)
.scoped_message::<BoundWeaponSound>(frame::MatchScope::Live)
.scoped_message::<ViewmodelNotetracks>(frame::MatchScope::Live)
.scoped::<crate::shellshock::ShellshockSources>(frame::Timeline::Running)
.scoped::<crate::breath::BreathSources>(frame::Timeline::Running)
.scoped_message::<AliasCommand>(frame::Timeline::Running)
.scoped_message::<Footstep>(frame::Timeline::Running)
.scoped_message::<WeaponSound>(frame::Timeline::Running)
.scoped_message::<BoundWeaponSound>(frame::Timeline::Running)
.scoped_message::<ViewmodelNotetracks>(frame::Timeline::Running)
.scoped::<crate::entity_events::NotetrackSoundTable>(frame::MatchScope::Live)
.scoped_message::<LandSound>(frame::MatchScope::Live)
.scoped_message::<LandSound>(frame::Timeline::Running)
.add_systems(
Update,
play_weapon_sound_messages
+12 -7
View File
@@ -144,6 +144,8 @@ struct Cursor {
struct RenderState {
cursors: [Cursor; PHYSICAL_VOICES],
buses: crate::mix_bus::MixBuses,
limiter: crate::mix_bus::MasterLimiter,
}
pub(crate) struct RenderShared {
@@ -187,6 +189,8 @@ impl RenderShared {
position: 0.0,
gains: [crate::media::GainEnvelope::default(); 3],
}; PHYSICAL_VOICES],
buses: Default::default(),
limiter: Default::default(),
}),
frame: AtomicU64::new(0),
match_epoch: AtomicU64::new(0),
@@ -241,7 +245,7 @@ impl RenderShared {
epoch,
cancelled,
hold_one_shots,
output,
state.buses.bus(assignment.media.bus),
);
match device {
Some(true) => {
@@ -277,15 +281,16 @@ impl RenderShared {
slot.phase.store(RETIRED, Ordering::Release);
}
}
state.buses.mix_into(output);
let master = f32::from_bits(self.master.load(Ordering::Relaxed));
let mut peak = f32::from_bits(self.peak.load(Ordering::Relaxed));
for sample in output.iter_mut().flatten() {
let value = *sample * master;
*sample = if value.is_finite() {
value.clamp(-1.0, 1.0)
} else {
0.0
};
*sample = if value.is_finite() { value } else { 0.0 };
}
state.limiter.apply(output);
let mut peak = f32::from_bits(self.peak.load(Ordering::Relaxed));
for sample in output.iter_mut().flatten() {
*sample = sample.clamp(-1.0, 1.0);
peak = peak.max(sample.abs());
}
self.peak.store(peak.to_bits(), Ordering::Relaxed);
+1
View File
@@ -38,6 +38,7 @@ sim.workspace = true
net.workspace = true
ui.workspace = true
replay.workspace = true
theater.workspace = true
session.workspace = true
bots = { path = "../bots" }
hud = { path = "../hud" }
+7 -1
View File
@@ -87,7 +87,7 @@ fn install_class_catalog(
mut commands: Commands,
shell: Option<ResMut<ShellCommonTask>>,
mut strings: ResMut<asset_game::LocalizeCatalog>,
menus: Res<asset_game::MenuCatalog>,
mut menus: ResMut<asset_game::MenuCatalog>,
account: Option<Res<net::LocalAccount>>,
mut player_data: ResMut<sim::LocalPlayerData>,
) {
@@ -148,6 +148,7 @@ fn install_class_catalog(
("mp/rankicontable.csv", common.weapons.shared_rank_icons()),
] {
if let Ok(table) = table {
menus.insert_string_table(table.clone());
player_data.install_rank_table(
name,
sim::script::StringTable {
@@ -191,6 +192,7 @@ fn install_class_catalog(
strings.absorb(common.strings);
commands.insert_resource(class_catalog);
commands.remove_resource::<ShellCommonTask>();
commands.remove_resource::<frame::FrontendContentLoading>();
}
fn launch_identity(config: &LaunchConfig) -> LaunchIdentity {
@@ -405,6 +407,9 @@ fn run_menu(
artifacts,
};
let mut app = App::new();
if std::env::var_os("IW4L_PRESENT_MODE").is_some() {
app.insert_resource(ui::PresentModeOverride(launch_present_mode(false)));
}
let namespace_trees = NamespaceTrees::discover(&games);
let have = content_flags(&namespace_trees);
let master_intent = if have.0 & net::CONTENT_IW4 == 0 {
@@ -435,6 +440,7 @@ fn run_menu(
perk_table: menus.string_table("mp/perkTable.csv").cloned(),
started: std::time::Instant::now(),
})
.insert_resource(frame::FrontendContentLoading)
.add_systems(
Update,
(install_class_catalog, adopt_shell_ui_images)
+1
View File
@@ -44,6 +44,7 @@ pub fn add_runtime_plugins_with_role(app: &mut App, role: RuntimeRole) {
.add_plugins((BotsPlugin, HudPlugin))
.add_plugins(AudioPlugin)
.add_plugins(ReplayPlugin)
.add_plugins(theater::TheaterPlugin)
.add_plugins(RenderPlugin)
.add_plugins(SessionPlugin);
@@ -26,3 +26,6 @@ bind TAB +scores
bind 2 +left
bind 3 +right
bind 4 +actionslot 4
bind T chatmodepublic
bind Y chatmodeteam
+28 -27
View File
@@ -140,33 +140,34 @@ impl PadButton {
}
}
pub const fn prompt(self, style: frame::PromptStyle) -> &'static str {
use frame::PromptStyle::*;
match (style, self) {
(Xbox, _) => self.label(),
(PlayStation, Self::South) => "×",
(PlayStation, Self::East) => "○",
(PlayStation, Self::West) => "□",
(PlayStation, Self::North) => "△",
(PlayStation, Self::LeftBumper) => "L1",
(PlayStation, Self::RightBumper) => "R1",
(PlayStation, Self::LeftTrigger) => "L2",
(PlayStation, Self::RightTrigger) => "R2",
(PlayStation, Self::LeftStick) => "L3",
(PlayStation, Self::RightStick) => "R3",
(PlayStation, Self::Select) => "SHARE",
(PlayStation, Self::Start) => "OPTIONS",
(Generic, Self::South) => "SOUTH",
(Generic, Self::East) => "EAST",
(Generic, Self::West) => "WEST",
(Generic, Self::North) => "NORTH",
(Generic, Self::LeftBumper) => "L-BUMPER",
(Generic, Self::RightBumper) => "R-BUMPER",
(Generic, Self::LeftTrigger) => "L-TRIGGER",
(Generic, Self::RightTrigger) => "R-TRIGGER",
(Generic, Self::LeftStick) => "L-STICK",
(Generic, Self::RightStick) => "R-STICK",
(Generic, Self::Select) => "SELECT",
pub const fn icon_prompt(self) -> &'static str {
use frame::ControllerIcon;
match self {
Self::South => ControllerIcon::A.prompt(),
Self::East => ControllerIcon::B.prompt(),
Self::West => ControllerIcon::X.prompt(),
Self::North => ControllerIcon::Y.prompt(),
Self::DpadUp => ControllerIcon::DpadUp.prompt(),
Self::DpadDown => ControllerIcon::DpadDown.prompt(),
Self::DpadLeft => ControllerIcon::DpadLeft.prompt(),
Self::DpadRight => ControllerIcon::DpadRight.prompt(),
_ => self.label(),
}
}
pub const fn generic_label(self) -> &'static str {
match self {
Self::South => "SOUTH",
Self::East => "EAST",
Self::West => "WEST",
Self::North => "NORTH",
Self::LeftBumper => "L-BUMPER",
Self::RightBumper => "R-BUMPER",
Self::LeftTrigger => "L-TRIGGER",
Self::RightTrigger => "R-TRIGGER",
Self::LeftStick => "L-STICK",
Self::RightStick => "R-STICK",
Self::Select => "SELECT",
_ => self.label(),
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ macro_rules! key_catalog {
super::PadButton::ALL
.into_iter()
.find(|pad| pad.console_name() == upper || pad.label() == upper
|| format!("BUTTON_{}", pad.prompt(frame::PromptStyle::Generic)) == upper)
|| format!("BUTTON_{}", pad.generic_label()) == upper)
.map(|pad| vec![BindButton::Pad(pad)])
}
}
+406
View File
@@ -0,0 +1,406 @@
use crate::{BindButton, ConsoleCommand, ConsoleSettings, ConsoleState, KeyBinds};
use bevy::ecs::system::SystemParam;
use bevy::{
input::{ButtonState, keyboard::KeyboardInput},
prelude::*,
};
use frame::ScopeApp;
#[derive(Resource, Default)]
pub(crate) struct ChatState {
pub open: bool,
team_only: bool,
line: String,
caret: usize,
captured: bool,
error: Option<String>,
recent: std::collections::VecDeque<(f64, String, String, i32)>,
}
impl ChatState {
pub fn captures_input(&self) -> bool {
self.open || self.captured
}
fn begin(&mut self, team_only: bool) {
self.open = true;
self.team_only = team_only;
self.line.clear();
self.caret = 0;
self.captured = true;
self.error = None;
}
}
#[derive(Component)]
struct ChatText(usize);
#[derive(Component)]
struct ChatBody(usize);
#[derive(Component)]
struct ChatPrompt;
pub(crate) fn install(app: &mut App) {
app.scoped::<ChatState>(frame::MatchScope::Live)
.add_systems(Startup, setup)
.add_systems(
Update,
(commands, display).chain().in_set(net::ClientSet::Diag),
);
}
fn setup(mut commands: Commands, font: Res<ui::GameUiFont>) {
commands
.spawn((
ui::UiLayer::Hud,
Node {
position_type: PositionType::Absolute,
left: px(12),
top: percent(25),
width: px(300),
flex_direction: FlexDirection::Column,
..default()
},
GlobalZIndex(20),
))
.with_children(|parent| {
for index in 0..5 {
parent
.spawn((
ChatText(index),
Text::new(""),
ui::game_text_font(&font.0, 12.0),
TextColor(Color::WHITE),
TextShadow {
offset: Vec2::splat(1.0),
color: Color::BLACK,
},
))
.with_children(|parent| {
parent.spawn((
ChatBody(index),
TextSpan::new(""),
ui::game_text_font(&font.0, 12.0),
TextColor(Color::WHITE),
));
});
}
parent.spawn((
ChatPrompt,
Text::new(""),
ui::game_text_font(&font.0, 12.0),
TextColor(Color::WHITE),
TextShadow {
offset: Vec2::splat(1.0),
color: Color::BLACK,
},
));
});
}
#[derive(SystemParam)]
pub(crate) struct ChatOutbox<'w> {
local: Res<'w, net::LocalPresentClient>,
inbox: ResMut<'w, net::ClientActionInbox>,
ids: ResMut<'w, net::ActionRequestIds>,
}
fn submit(state: &mut ChatState, outbox: &mut ChatOutbox) {
if !state.line.trim().is_empty()
&& let Err(error) = outbox.inbox.push(
outbox.local.0,
sim::ClientAction::Chat {
request_id: outbox.ids.allocate(),
team_only: state.team_only,
text: sim::chat::payload(&state.line),
},
)
{
state.error = Some(error.to_string());
return;
}
state.open = false;
state.line.clear();
state.caret = 0;
}
pub(crate) fn keyboard(
mut events: MessageReader<KeyboardInput>,
state: Option<ResMut<ChatState>>,
binds: Res<KeyBinds>,
(console, menus, screen, playback): (
Res<ConsoleState>,
hud::MenuState<'_>,
Res<frame::AppScreen>,
Option<Res<replay::ReplayPlayback>>,
),
(keys, mouse, pads, active, mut wheel): (
Res<ButtonInput<KeyCode>>,
Res<ButtonInput<MouseButton>>,
Query<&bevy::input::gamepad::Gamepad>,
Res<frame::ActivePad>,
MessageReader<bevy::input::mouse::MouseWheel>,
),
mut outbox: ChatOutbox,
) {
let Some(mut state) = state else {
events.clear();
return;
};
state.captured = false;
let available = *screen == frame::AppScreen::InGame && playback.is_none();
let modal = console.open || menus.is_some_and(|menus| menus.captures_input());
if !available || modal {
state.open = false;
state.line.clear();
events.clear();
return;
}
let wheel_direction = wheel.read().find_map(|event| {
if event.y > 0.0 {
Some(BindButton::WheelUp)
} else if event.y < 0.0 {
Some(BindButton::WheelDown)
} else {
None
}
});
if !state.open {
let inputs = crate::BindInputs::new(&keys, &mouse)
.with_pad(active.0.and_then(|id| pads.get(id).ok()));
for (button, id) in binds
.iter()
.filter(|(button, _)| !matches!(button, BindButton::Key(_)))
{
let team_only = match input_iw4::command_name(id) {
Some("chatmodepublic") => false,
Some("chatmodeteam") => true,
_ => continue,
};
if inputs.just_pressed(button) || wheel_direction == Some(button) {
state.begin(team_only);
events.clear();
return;
}
}
}
for event in events.read().filter(|e| e.state == ButtonState::Pressed) {
if !state.open {
if !event.repeat {
match binds
.get(BindButton::Key(event.key_code))
.and_then(input_iw4::command_name)
{
Some("chatmodepublic") => state.begin(false),
Some("chatmodeteam") => state.begin(true),
_ => {}
}
}
continue;
}
state.captured = true;
match event.key_code {
KeyCode::Escape | KeyCode::Backquote => {
state.open = false;
state.line.clear();
}
KeyCode::Enter | KeyCode::NumpadEnter => submit(&mut state, &mut outbox),
KeyCode::ArrowLeft => {
state.caret = state.line[..state.caret]
.char_indices()
.last()
.map_or(0, |(i, _)| i);
}
KeyCode::ArrowRight => {
state.caret += state.line[state.caret..]
.chars()
.next()
.map_or(0, char::len_utf8);
}
KeyCode::Home => state.caret = 0,
KeyCode::End => state.caret = state.line.len(),
KeyCode::Backspace if state.caret > 0 => {
let end = state.caret;
let start = state.line[..end].char_indices().last().unwrap().0;
state.line.replace_range(start..end, "");
state.caret = start;
}
KeyCode::Delete if state.caret < state.line.len() => {
let start = state.caret;
let end = start + state.line[start..].chars().next().unwrap().len_utf8();
state.line.replace_range(start..end, "");
}
_ => {
if let Some(text) = &event.text {
for ch in text.chars().filter(|c| !c.is_control()) {
if state.line.len() + ch.len_utf8() < sim::chat::CHAT_BYTES {
let caret = state.caret;
state.line.insert(caret, ch);
state.caret += ch.len_utf8();
}
}
}
}
}
}
}
fn commands(
mut events: MessageReader<ConsoleCommand>,
state: Option<ResMut<ChatState>>,
(screen, playback): (Res<frame::AppScreen>, Option<Res<replay::ReplayPlayback>>),
mut outbox: ChatOutbox,
mut console: ResMut<ConsoleState>,
settings: Res<ConsoleSettings>,
) {
let Some(mut state) = state else {
for cmd in events.read().filter(|c| {
matches!(
c.name.as_str(),
"chatmodepublic" | "chatmodeteam" | "say" | "say_team"
)
}) {
console.echo(
format!("{}: join a match first", cmd.name),
settings.log_capacity,
);
}
return;
};
for cmd in events.read().filter(|c| {
matches!(
c.name.as_str(),
"chatmodepublic" | "chatmodeteam" | "say" | "say_team"
)
}) {
if *screen != frame::AppScreen::InGame || playback.is_some() {
console.echo("chat: requires a live match", settings.log_capacity);
continue;
}
let team = matches!(cmd.name.as_str(), "chatmodeteam" | "say_team");
state.begin(team);
if matches!(cmd.name.as_str(), "say" | "say_team") {
state.line = sim::chat::clean(&cmd.args.join(" "));
submit(&mut state, &mut outbox);
} else {
console.open = false;
}
}
}
fn display(
mut messages: MessageReader<net::SvcGameNotify>,
state: Option<ResMut<ChatState>>,
time: Res<Time<Real>>,
(screen, local, presented, strings): (
Res<frame::AppScreen>,
Res<net::LocalPresentClient>,
Res<net::PresentedSnapshot>,
Option<Res<assets::PreparedLocalizedStrings>>,
),
mut names: Query<(&ChatText, &mut Text, &mut TextColor), Without<ChatPrompt>>,
mut bodies: Query<(&ChatBody, &mut TextSpan)>,
mut prompt: Query<&mut Text, (With<ChatPrompt>, Without<ChatText>)>,
) {
let Some(mut state) = state else {
messages.clear();
for (_, mut text, _) in &mut names {
if !text.0.is_empty() {
text.0.clear();
}
}
for (_, mut body) in &mut bodies {
if !body.0.is_empty() {
body.0.clear();
}
}
if let Ok(mut text) = prompt.single_mut()
&& !text.0.is_empty()
{
text.0.clear();
}
return;
};
let now = time.elapsed_secs_f64();
for msg in messages.read().filter(|m| m.tag == net::SVC_CHAT) {
let (name, body) = msg
.name
.split_once(sim::HUD_PRINT_ARG_SEPARATOR)
.unwrap_or(("", &msg.name));
let (team, localized) = msg
.key
.split_once(':')
.map_or((msg.key.as_str(), false), |(team, kind)| {
(team, kind == "localized")
});
let body = if localized {
let key = body.strip_prefix(": ").unwrap_or(body);
format!(
": {}",
strings.as_ref().and_then(|s| s.0.text(key)).unwrap_or(key)
)
} else {
body.to_owned()
};
state
.recent
.push_back((now, name.to_owned(), body, team.parse().unwrap_or(0)));
while state.recent.len() > 5 {
state.recent.pop_front();
}
}
while state.recent.front().is_some_and(|(at, ..)| now - at > 8.0) {
state.recent.pop_front();
}
let in_game = *screen == frame::AppScreen::InGame;
let local_team = presented
.snapshot()
.and_then(|s| s.meta.for_client(local.0))
.map_or(0, |m| m.client_state_team);
for (index, mut text, mut color) in &mut names {
let row = state.recent.get(index.0).filter(|_| in_game);
let name = row.map_or("", |(_, name, ..)| name.as_str());
if text.0 != name {
text.0 = name.to_owned();
}
let rgba = row.map_or([1.0; 4], |(_, _, _, team)| {
presented.snapshot().map_or([1.0; 4], |s| {
hud::team_name_color(local_team, *team, s.meta.script_dvars(local.0))
})
});
let next = Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3]);
if color.0 != next {
color.0 = next;
}
}
for (index, mut body) in &mut bodies {
let next = state
.recent
.get(index.0)
.filter(|_| in_game)
.map_or("", |(_, _, body, _)| body.as_str());
if body.0 != next {
body.0 = next.to_owned();
}
}
let Ok(mut text) = prompt.single_mut() else {
return;
};
let next = if in_game && state.open {
let label = if state.team_only { "Team" } else { "Say" };
format!(
"{label}: {}|{}{}",
&state.line[..state.caret],
&state.line[state.caret..],
state
.error
.as_ref()
.map_or(String::new(), |e| format!("\n{e}"))
)
} else {
String::new()
};
if text.0 != next {
text.0 = next;
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ fn finish_replay_playback(
let Some(playback) = playback.as_mut() else {
return;
};
if !playback.ended || playback.exit_queued {
if !playback.ended || playback.exit_queued || playback.retain_at_end {
return;
}
playback.exit_queued = true;
@@ -214,6 +214,10 @@ pub fn register_feature_commands(registry: &mut crate::ConsoleRegistry, maps: &[
}
for (name, usage) in [
("help", "help — list registered commands"),
("say", "say <message> — public chat"),
("say_team", "say_team <message> — team chat"),
("chatmodepublic", "chatmodepublic — open public chat"),
("chatmodeteam", "chatmodeteam — open team chat"),
("clear", "clear — clear console scrollback"),
("screenshot", "screenshot [name] — capture the backbuffer"),
(
+25 -22
View File
@@ -8,8 +8,7 @@ use ::replay::{
};
use bevy::prelude::*;
use frame::{LaunchIdentity, RuntimeRole};
use net::{AuthorityClock, AuthorityInputGate, AuthorityWorld, PresentedSnapshot};
use render_frontend::prepare::scene::camera::SimCamera;
use net::{AuthorityClock, AuthorityWorld, PresentedSnapshot};
use crate::{ConsoleCommand, ConsoleDispatch, ConsoleLine, ConsoleSettings, ConsoleState};
@@ -31,11 +30,7 @@ pub(crate) fn route_replay_commands(
Option<Res<audio::ClipStore>>,
Option<Res<audio::AudioRuntime>>,
),
replay_inputs: (
Option<Res<AuthorityWorld>>,
Res<SimCamera>,
Res<AuthorityInputGate>,
),
authority: Option<Res<AuthorityWorld>>,
mut lifecycle: (
ResMut<::replay::PendingReplayArm>,
ResMut<::session::SessionSwapRequest>,
@@ -56,7 +51,6 @@ pub(crate) fn route_replay_commands(
line.0 = msg.clone();
console.echo(msg, capacity);
};
let (authority, sim_cam, input_gate) = replay_inputs;
let (theater, map_transition, dispatch) = &mut lifecycle;
let (ring, role, prediction, clip_clock, clip_presented) = clip;
@@ -104,12 +98,17 @@ pub(crate) fn route_replay_commands(
};
theater.playback = Some(playback);
theater.quit_on_end = false;
theater.in_place = false;
match map_transition.request_demo(name.clone(), zone.clone(), false) {
Ok(id) => echo(
format!("demo: `{name}` zone `{zone}` (swap #{id})"),
console,
line,
),
Ok(id) => {
theater.request_id = Some(id);
theater.control = None;
echo(
format!("demo: `{name}` zone `{zone}` (swap #{id})"),
console,
line,
);
}
Err(error) => {
theater.playback = None;
dispatch.release();
@@ -146,12 +145,10 @@ pub(crate) fn route_replay_commands(
);
continue;
}
if !sim_cam.enabled || !input_gate.local_cmds_enabled {
if *role == RuntimeRole::Replay || ring.as_ref().is_none_or(|ring| ring.is_empty())
{
echo(
"record: the simulation does not own the camera yet \
(no clip brushes, or the authored intermission view is active); \
there are no ticks to record"
.into(),
"record: no live match ticks to record".into(),
console,
line,
);
@@ -165,9 +162,13 @@ pub(crate) fn route_replay_commands(
);
continue;
};
let Some(authority) = authority.as_ref() else {
let Some(world) = authority
.as_ref()
.map(|authority| &authority.0)
.or_else(|| prediction.as_ref().map(|prediction| prediction.0.world()))
else {
echo(
"record: authority world missing (match identity unavailable)".into(),
"record: match world missing (match identity unavailable)".into(),
console,
line,
);
@@ -175,7 +176,7 @@ pub(crate) fn route_replay_commands(
};
let requested = cmd.args.first().map(String::as_str).unwrap_or("");
let record_identity =
MatchRecordIdentity::from_world_on_zone(&authority.0, &identity.zone);
MatchRecordIdentity::from_world_on_zone(world, &identity.zone);
match Recording::start_with_identity(
&identity.artifacts,
requested,
@@ -237,6 +238,7 @@ pub(crate) fn route_replay_commands(
);
match save_clip_package(
identity,
*role,
authority.as_deref().filter(|_| role.runs_authority()),
prediction.0.world(),
ring.as_ref(),
@@ -307,6 +309,7 @@ fn allocate_clip_dir(artifacts: &Path) -> Result<(String, PathBuf), String> {
fn save_clip_package(
identity: &LaunchIdentity,
role: RuntimeRole,
world: Option<&AuthorityWorld>,
prediction: &sim::SimWorld,
ring: &ClipRing,
@@ -348,7 +351,7 @@ fn save_clip_package(
&identity.zone,
captured_unix_ns,
);
let source = if world.is_some() {
let source = if role == RuntimeRole::Dedicated {
"authority"
} else {
"received"
+14 -31
View File
@@ -8,7 +8,6 @@ use crate::{CommandSpec, ConsoleCommand, ConsoleRegistry};
const PAGE_SIZE: usize = 10;
const PACK_SLOTS: usize = 16;
const LOBBY_REOPEN_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
#[derive(Default)]
pub(crate) struct FrontendState {
@@ -24,7 +23,6 @@ pub(crate) struct FrontendState {
password_joining: bool,
lobby_password: String,
rules_seeded: bool,
reopen_lobby_until: Option<std::time::Instant>,
}
#[derive(SystemParam)]
@@ -145,10 +143,9 @@ pub(crate) fn route(
&& !state.public
&& services.menus.is_some()
{
state.reopen_lobby_until = Some(std::time::Instant::now() + LOBBY_REOPEN_WAIT);
menus.write(UiMenuRequest::RestoreLobby);
dvars.set("ui_frontend_status", "");
} else {
state.reopen_lobby_until = None;
*party = UiPartyState::default();
state.public = false;
}
@@ -168,35 +165,11 @@ pub(crate) fn route(
dvars.set("ui_frontend_status", reason);
} else if returned_in_menu && party.in_lobby && party.is_host {
if !left_session && services.menus.is_some() {
state.reopen_lobby_until = Some(std::time::Instant::now() + LOBBY_REOPEN_WAIT);
menus.write(UiMenuRequest::RestoreLobby);
} else {
*party = UiPartyState::default();
}
}
// The return resets the menu stack: the lobby opened before the main menu would be dropped.
if let Some(deadline) = state.reopen_lobby_until {
let open = services
.menus
.as_ref()
.map(|script_menus| script_menus.open_names());
let is_open = |name: &str| {
open.iter()
.flatten()
.any(|menu| menu.eq_ignore_ascii_case(name))
};
if !party.in_lobby {
state.reopen_lobby_until = None;
} else if is_open("iw4l_main") {
if !is_open("game_lobby") {
menus.write(UiMenuRequest::Open("game_lobby".into()));
diag::info!(Ui, "frontend: private lobby reopened");
}
state.reopen_lobby_until = None;
} else if std::time::Instant::now() >= deadline {
state.reopen_lobby_until = None;
*party = UiPartyState::default();
}
}
if dvars.get("ui_mapname").is_none()
&& let Some(map) = maps.maps().find(|map| map.starts_with("iw4:"))
{
@@ -239,7 +212,6 @@ pub(crate) fn route(
}
"ui_create_lobby" => {
selected_game(&dvars, &maps)?;
state.reopen_lobby_until = None;
state.lobby_password.clear();
party.active = true;
party.in_lobby = true;
@@ -283,7 +255,6 @@ pub(crate) fn route(
if !party.in_lobby || !party.is_host {
return Err("Only the lobby host can start a match".into());
}
state.reopen_lobby_until = None;
let (map, mode) = selected_game(&dvars, &maps)?;
commands.insert_resource(host_rules(&dvars));
if state.public {
@@ -589,6 +560,7 @@ pub(crate) fn route(
}
}
let mut lobby_names = vec![settings.player_name.clone()];
let mut local_lobby_row = Some(0);
if state.public
&& let Some(bridge) = services.bridge.as_ref()
{
@@ -597,6 +569,7 @@ pub(crate) fn route(
map,
mode,
members,
identity,
member_names,
max_players,
..
@@ -605,10 +578,12 @@ pub(crate) fn route(
map,
mode,
members,
identity,
member_names,
max_players,
..
} => {
local_lobby_row = members.iter().position(|id| *id == identity.member_id);
lobby_names = members
.iter()
.map(|id| {
@@ -645,6 +620,14 @@ pub(crate) fn route(
&format!("ui_lobby_member_{row}"),
name.map(String::as_str).unwrap_or(""),
);
dvars.set(
&format!("ui_lobby_member_{row}_local"),
if local_lobby_row == Some(row) {
"1"
} else {
"0"
},
);
dvars.set(
&format!("ui_lobby_member_{row}_visible"),
if name.is_some() { "1" } else { "0" },
+4 -28
View File
@@ -74,7 +74,7 @@ pub(crate) struct PadActivity {
}
pub(crate) fn track_active_pad(
gamepads: Query<(Entity, &Gamepad, Option<&Name>)>,
gamepads: Query<&Gamepad>,
mut events: MessageReader<GamepadEvent>,
mut active: ResMut<frame::ActivePad>,
mut devices: ResMut<frame::InputDevices>,
@@ -104,7 +104,7 @@ pub(crate) fn track_active_pad(
}
}
if matches!(event.connection, GamepadConnection::Connected { .. })
&& let Ok((_, pad, _)) = gamepads.get(event.gamepad)
&& let Ok(pad) = gamepads.get(event.gamepad)
{
for axis in [
GamepadAxis::LeftStickX,
@@ -148,31 +148,6 @@ pub(crate) fn track_active_pad(
if active.0.is_none() {
devices.pad_prompts = false;
}
devices.style = match settings.pad_prompts {
1 => frame::PromptStyle::Xbox,
2 => frame::PromptStyle::PlayStation,
3 => frame::PromptStyle::Generic,
_ => active
.0
.and_then(|entity| gamepads.get(entity).ok())
.map_or(frame::PromptStyle::Generic, |(_, pad, name)| {
let name = name.map_or("", |name| name.as_str()).to_ascii_lowercase();
if pad.vendor_id() == Some(0x054c)
|| name.contains("dualshock")
|| name.contains("dualsense")
|| name == "wireless controller"
{
frame::PromptStyle::PlayStation
} else if pad.vendor_id() == Some(0x045e)
|| name.contains("xbox")
|| name.contains("xinput")
{
frame::PromptStyle::Xbox
} else {
frame::PromptStyle::Generic
}
}),
};
}
const REPEAT_DELAY: f32 = 0.4;
@@ -181,6 +156,7 @@ const REPEAT_EVERY: f32 = 0.12;
pub(crate) fn drive_menus_with_pad(
gamepads: Query<&Gamepad>,
active: Res<frame::ActivePad>,
overlay: Res<frame::UiOverlayInput>,
script_menus: hud::MenuState<'_>,
(devices, console, capture): (
Res<frame::InputDevices>,
@@ -191,7 +167,7 @@ pub(crate) fn drive_menus_with_pad(
mut requests: MessageWriter<UiMenuRequest>,
mut repeat: Local<Option<(UiMenuKey, f32)>>,
) {
if !devices.focused || console.open {
if !devices.focused || console.open || overlay.0 {
*repeat = None;
return;
}
+1
View File
@@ -1,5 +1,6 @@
mod barracks_menu;
pub mod binds;
mod chat;
mod class_dispatch;
mod class_menu;
mod command;
+37 -11
View File
@@ -17,7 +17,6 @@ use bevy::{
window::{CursorEntered, CursorGrabMode, CursorOptions, PrimaryWindow, WindowFocused},
};
use frame::AppScreen;
use frame::ScopeApp;
use input_iw4::{SCRIPT_KEYNUM, command_names, input_cmd, key_event, key_up_command_id};
use net::{ClientActionInput, ClientSet, PresentedSnapshot, frame_time_msec, key_frame_msec};
use render_frontend::prepare::scene::world::WorldScene;
@@ -84,6 +83,7 @@ pub struct ConsolePlugin;
impl Plugin for ConsolePlugin {
fn build(&self, app: &mut App) {
crate::startup::install_stdin(app);
crate::chat::install(app);
app.init_resource::<ConsoleSettings>()
.init_resource::<ConsoleState>()
.init_resource::<frame::HudInputView>()
@@ -94,7 +94,11 @@ impl Plugin for ConsolePlugin {
.init_resource::<ConsoleRegistry>()
.init_resource::<crate::ConsoleQueue>()
.init_resource::<crate::ConsoleLine>()
.published::<crate::weapon_dispatch::WeaponArgCompletions>(frame::MatchScope::Live)
.init_resource::<crate::weapon_dispatch::CompletionSlot>()
.add_systems(
OnExit(frame::MatchScope::Live),
crate::weapon_dispatch::clear_weapon_arg_completions,
)
.init_resource::<crate::user_settings::PendingMenuBinding>()
.init_resource::<crate::user_settings::UserSettingsPersistence>()
.init_resource::<crate::game_folders::FolderPicks>()
@@ -118,6 +122,8 @@ impl Plugin for ConsolePlugin {
.add_systems(PreUpdate, feed_console_keyboard.before(InputSystems))
.init_resource::<frame::ActivePad>()
.init_resource::<frame::InputDevices>()
.init_resource::<frame::UiPointerFree>()
.init_resource::<frame::UiOverlayInput>()
.add_systems(
PreUpdate,
(
@@ -138,10 +144,12 @@ impl Plugin for ConsolePlugin {
PreUpdate,
(
handle_console_input,
crate::chat::keyboard,
handle_scrollback_pointer,
copy_console_selection_on_release,
isolate_gameplay_input,
expire_pressed_inputs,
publish_ui_input_capture,
publish_client_action_input.in_set(frame::InMatch),
sync_cursor_grab,
)
@@ -298,10 +306,11 @@ fn dispatch_menu_commands(
fn isolate_gameplay_input(
console: Res<ConsoleState>,
chat: Option<Res<crate::chat::ChatState>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut mouse: MessageReader<MouseMotion>,
) {
if !console.open {
if !console.open && !chat.as_ref().is_some_and(|chat| chat.captures_input()) {
return;
}
keys.reset_all();
@@ -316,7 +325,16 @@ struct PhysicalInputState {
look_ready: bool,
mouse_activity: f32,
mouse_activity_start: f32,
prompts: Option<(bool, frame::PromptStyle)>,
prompts: Option<bool>,
}
fn publish_ui_input_capture(
console: Res<ConsoleState>,
chat: Option<Res<crate::chat::ChatState>>,
mut hud_input: ResMut<frame::HudInputView>,
) {
hud_input.console_open =
console.open || chat.as_ref().is_some_and(|chat| chat.captures_input());
}
fn publish_client_action_input(
@@ -331,7 +349,9 @@ fn publish_client_action_input(
binds: Res<KeyBinds>,
mut scripted: ResMut<ConsoleInputState>,
console: Res<ConsoleState>,
chat: Option<Res<crate::chat::ChatState>>,
script_menus: hud::MenuState<'_>,
overlay: Res<frame::UiOverlayInput>,
mut hud_input: ResMut<frame::HudInputView>,
settings: Res<frame::GameSettings>,
mut out: ResMut<ClientActionInput>,
@@ -384,12 +404,12 @@ fn publish_client_action_input(
if binds.is_changed() {
*wheel_carry = 0.0;
}
let modal_captured = console.open
let modal_captured = chat.as_ref().is_some_and(|chat| chat.captures_input())
|| console.open
|| script_menus.is_some_and(|menus| menus.captures_input())
|| keys.just_pressed(KeyCode::Escape)
|| pad.is_some_and(|pad| pad.just_pressed(bevy::input::gamepad::GamepadButton::Start));
let captured = !devices.focused || modal_captured;
hud_input.console_open = console.open;
let captured = !devices.focused || modal_captured || overlay.0;
physical.blocked.retain(|button| inputs.pressed(*button));
let akimbo = prediction
@@ -555,7 +575,7 @@ fn publish_client_action_input(
devices.pad_prompts = false;
physical.mouse_activity = 0.0;
}
let prompts = (devices.pad_prompts, devices.style);
let prompts = devices.pad_prompts;
if physical.prompts == Some(prompts) && !binds.is_changed() {
return;
}
@@ -570,7 +590,7 @@ fn publish_client_action_input(
hud_input.action_slot_keys = [None, None, None, None];
for (button, id) in labels {
let label = match button {
BindButton::Pad(button) => button.prompt(devices.style).to_owned(),
BindButton::Pad(button) => button.icon_prompt().to_owned(),
_ => crate::binds::display_button(button).to_uppercase(),
};
if let Some(command) = binds.binding_name(button) {
@@ -604,7 +624,9 @@ fn publish_client_action_input(
/// only reports relative motion to a held pointer the view stops turning while
/// every key, and the console, still work.
fn sync_cursor_grab(
free_pointer: Res<frame::UiPointerFree>,
console: Res<ConsoleState>,
chat: Option<Res<crate::chat::ChatState>>,
script_menus: hud::MenuState<'_>,
screen: Option<Res<AppScreen>>,
mut focused: MessageReader<WindowFocused>,
@@ -621,7 +643,11 @@ fn sync_cursor_grab(
let in_game = screen
.as_ref()
.is_some_and(|s| matches!(**s, AppScreen::InGame));
let grab = in_game && !console.open && !menu_open;
let grab = in_game
&& !console.open
&& !chat.as_ref().is_some_and(|chat| chat.open)
&& !menu_open
&& !free_pointer.0;
let Ok(mut cursor) = windows.single_mut() else {
return;
};
@@ -650,7 +676,7 @@ fn setup_console(
settings: Res<ConsoleSettings>,
mut registry: ResMut<ConsoleRegistry>,
mut binds: ResMut<KeyBinds>,
weapon_completions: Res<frame::Published<crate::weapon_dispatch::WeaponArgCompletions>>,
weapon_completions: Res<crate::weapon_dispatch::CompletionSlot>,
) {
binds.apply_defaults();
if registry.resolve("hold").is_none() {
+2 -5
View File
@@ -152,20 +152,17 @@ pub(crate) fn consume_menu_binding(
pub(crate) fn sync_binding_view(
binds: Res<KeyBinds>,
devices: Res<frame::InputDevices>,
mut view: ResMut<ui::BindingView>,
mut dvars: ResMut<frame::UiMenuDvars>,
mut style: Local<Option<frame::PromptStyle>>,
) {
if !binds.is_changed() && *style == Some(devices.style) {
if !binds.is_changed() {
return;
}
*style = Some(devices.style);
let mut chords = std::collections::BTreeMap::<u32, (Vec<String>, Vec<String>)>::new();
for (button, id) in binds.iter() {
let (keys, buttons) = chords.entry(id).or_default();
match button {
BindButton::Pad(pad) => buttons.push(pad.prompt(devices.style).to_owned()),
BindButton::Pad(pad) => buttons.push(pad.icon_prompt().to_owned()),
_ => keys.push(display_button(button)),
}
}
+34 -22
View File
@@ -1,4 +1,4 @@
use frame::Published;
use std::sync::{Arc, RwLock};
use asset_game::{LoadoutRules, WeaponSelection};
use assets::PreparedWeapons;
@@ -20,14 +20,29 @@ pub struct WeaponArgCompletions {
pub bots: Vec<String>,
}
type CompletionSlot = Published<WeaponArgCompletions>;
#[derive(Resource, Clone, Default)]
pub(crate) struct CompletionSlot(Arc<RwLock<Option<WeaponArgCompletions>>>);
impl CompletionSlot {
fn read<R>(&self, read: impl FnOnce(Option<&WeaponArgCompletions>) -> R) -> R {
read(self.0.read().expect("completion slot poisoned").as_ref())
}
fn publish(&self, value: Option<WeaponArgCompletions>) {
*self.0.write().expect("completion slot poisoned") = value;
}
}
pub(crate) fn clear_weapon_arg_completions(completions: Res<CompletionSlot>) {
completions.publish(None);
}
struct LiveListCompleter(CompletionSlot, fn(&WeaponArgCompletions) -> &[String]);
impl ArgCompleter for LiveListCompleter {
fn complete(&self, prefix: &str) -> Vec<String> {
self.0.read(|value| {
value.map_or_else(Vec::new, |(_, value)| {
value.map_or_else(Vec::new, |value| {
StaticCompleter::new((self.1)(value).iter().cloned()).complete(prefix)
})
})
@@ -110,20 +125,17 @@ pub(crate) fn refresh_weapon_arg_completions(
)
.collect()
};
completions.publish(
generation.0.expect("live match generation"),
WeaponArgCompletions {
give: weapon_completions(&weapons),
attach: if held == 0 {
Vec::new()
} else {
attach_completions(&weapons, held)
},
camos,
killstreaks: killstreaks.map_or_else(Vec::new, |value| value.0.clone()),
bots,
completions.publish(Some(WeaponArgCompletions {
give: weapon_completions(&weapons),
attach: if held == 0 {
Vec::new()
} else {
attach_completions(&weapons, held)
},
);
camos,
killstreaks: killstreaks.map_or_else(Vec::new, |value| value.0.clone()),
bots,
}));
}
pub(crate) fn route_weapon_commands(
@@ -448,8 +460,8 @@ fn grant_killstreak(
seq: &mut net::ActionRequestIds,
mut echo: impl FnMut(String),
) {
let names = completions
.read(|value| value.map_or_else(Vec::new, |(_, value)| value.killstreaks.clone()));
let names =
completions.read(|value| value.map_or_else(Vec::new, |value| value.killstreaks.clone()));
let requested = name.to_ascii_lowercase().replace(['-', ' '], "_");
let requested = KILLSTREAK_ALIASES
.iter()
@@ -518,7 +530,7 @@ impl ArgCompleter for KillstreakCompleter {
fn complete(&self, prefix: &str) -> Vec<String> {
let names = self
.0
.read(|value| value.map_or_else(Vec::new, |(_, value)| value.killstreaks.clone()));
.read(|value| value.map_or_else(Vec::new, |value| value.killstreaks.clone()));
let aliases = KILLSTREAK_ALIASES
.iter()
.filter(|(_, name)| names.iter().any(|value| value == name))
@@ -533,7 +545,7 @@ impl ArgCompleter for GiveCompleter {
fn complete(&self, prefix: &str) -> Vec<String> {
let mut items = vec!["ammo".to_owned(), "camo".to_owned()];
self.0.read(|value| {
if let Some((_, value)) = value {
if let Some(value) = value {
items.extend(value.give.iter().map(|name| format!("weapon/{name}")));
}
});
@@ -573,7 +585,7 @@ impl ArgCompleter for BotCompleter {
("give" | "fire" | "tp", 1) => {
let mut ids = self
.0
.read(|value| value.map_or_else(Vec::new, |(_, value)| value.bots.clone()));
.read(|value| value.map_or_else(Vec::new, |value| value.bots.clone()));
if *verb != "give" {
ids.insert(0, "all".into());
}
@@ -582,7 +594,7 @@ impl ArgCompleter for BotCompleter {
("give", 2) => {
let names = self
.0
.read(|value| value.map_or_else(Vec::new, |(_, value)| value.give.clone()));
.read(|value| value.map_or_else(Vec::new, |value| value.give.clone()));
StaticCompleter::new(names).complete(prefix)
}
("tp", 2) => StaticCompleter::new(["above"]).complete(prefix),
+5 -1
View File
@@ -16,6 +16,7 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink)
read_body_combat(s, body)?,
Some(body),
WeaponAssets {
kill_icon: material_name_at(s, links, body.at(sz::WEAPON_DEF_KILL_ICON_OFF)),
reticle_center: material_name_at(s, links, body.at(0x1a0)),
reticle_side: material_name_at(s, links, body.at(0x1a4)),
..WeaponAssets::default()
@@ -65,6 +66,7 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink)
_ => None,
};
s.record_weapon(WeaponGeometry {
kill_icon_name: assets.kill_icon,
overlay_material_name,
overlay_material_lowres_name,
reticle_center_name: assets.reticle_center,
@@ -155,7 +157,7 @@ fn load_weapon_def(
for off in [0x394, 0x398, 0x39c, 0x3a0, 0x3a4, 0x3a8, 0x48c] {
follow_name(s, p, off)?;
}
asset_ptr_at(s, links, AssetType::Material, p.at(0x578))?;
let kill_icon = follow_material_name(s, links, p.at(sz::WEAPON_DEF_KILL_ICON_OFF))?;
asset_ptr_at(s, links, AssetType::Material, p.at(0x328))?;
follow_name(s, p, 0x58c)?;
follow_name(s, p, 0x590)?;
@@ -220,6 +222,7 @@ fn load_weapon_def(
asset_ptr_at(s, links, AssetType::Fx, p.at(0x7f8))?;
asset_ptr_at(s, links, AssetType::Fx, p.at(0x7fc))?;
Ok(WeaponAssets {
kill_icon,
gun: gun0,
hand: hand0,
rocket,
@@ -397,6 +400,7 @@ fn follow_material_name(
#[derive(Default)]
struct WeaponAssets {
kill_icon: Option<Ptr>,
gun: Option<Ptr>,
hand: Option<Ptr>,
rocket: Option<Ptr>,
+1
View File
@@ -384,6 +384,7 @@ pub struct FxEffectDefGeometry {
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct WeaponGeometry {
pub kill_icon_name: Option<Ptr>,
pub overlay_material_name: Option<Ptr>,
pub overlay_material_lowres_name: Option<Ptr>,
pub reticle_center_name: Option<Ptr>,
+14 -11
View File
@@ -1,10 +1,12 @@
pub mod scope;
pub use scope::{
InMatch, InRound, MatchScope, Published, RoundPhase, ScopeApp, ScopeEpoch, ScopePlugin,
ScopeRegistry, ScopeSet, Staging,
InMatch, InRound, MatchScope, RoundPhase, ScopeApp, ScopeEpoch, ScopePlugin, ScopeRegistry,
ScopeSet, Staging, Timeline,
};
mod screen_effects;
pub use screen_effects::{ScreenEffectsDvars, ScreenEffectsPublished, ScreenEffectsView};
pub use screen_effects::{
ScreenBlurRequest, ScreenEffectsDvars, ScreenEffectsPublished, ScreenEffectsView,
};
pub mod class_presets;
pub mod pad;
pub mod retire;
@@ -16,7 +18,7 @@ pub mod settings;
pub mod ui;
pub use class_presets::{ClassPreset, pick_showcase, showcase_classes};
pub use pad::{ActivePad, InputDevices, PromptStyle, TestControllerRumble};
pub use pad::{ActivePad, ControllerIcon, InputDevices, TestControllerRumble};
pub use retire::Retiring;
pub use schedule::{
AUTHORITY_TOC, AuthorityBookkeeping, AuthorityEdge, AuthoritySet, CLIENT_TOC,
@@ -30,14 +32,15 @@ pub use script_entity_notify::{AbortKillcam, BeginKillcam, KillcamEnded, Spawned
pub use script_notify::{ExitLevelCalled, register_script_notify};
pub use session::{
AdmissionKey, AppScreen, BotNavigationReady, CacWeaponOffer, ClassSelectHandoff,
DisplayEncodedCamera, Headless, HostClassLoadouts, HostClassSlot, HudInputView, InstalledMatch,
LaunchIdentity, LaunchReport, LifeEndCause, LifeEnded, LifeStartReason, LifeStarted,
LocalLoadKey, LocalSpawnArmed, MapLoadApproved, MapLoadFailed, MatchKey, ReadinessState,
RuntimeRole, TeardownReason, UiCamera, UiDraw, ViewSubject, WorldGeneration, WorldProducts,
WorldReadiness, WorldStamp,
DisplayEncodedCamera, Headless, HostClassLoadouts, HostClassSlot, HudDraw, HudInputView,
InstalledMatch, LaunchIdentity, LaunchReport, LifeEndCause, LifeEnded, LifeStartReason,
LifeStarted, LocalLoadKey, LocalSpawnArmed, MapLoadApproved, MapLoadFailed, MatchKey,
ReadinessState, RuntimeRole, TeardownReason, UiCamera, UiDraw, UiOverlayInput, UiPointerFree,
ViewSubject, WorldGeneration, WorldProducts, WorldReadiness, WorldStamp,
};
pub use settings::{DisplayResolution, GameSettings, OtherGame};
pub use ui::{
HostMatchRules, UiBindRequest, UiBindingCapture, UiExecCommand, UiMenuDvars, UiMenuKey,
UiMenuRequest, UiPartyState, UiPlayMusic, UiPlaySound, UiStopMusic, register_ui_contracts,
FrontendContentLoading, HostMatchRules, UiBindRequest, UiBindingCapture, UiExecCommand,
UiMenuDvars, UiMenuKey, UiMenuRequest, UiPartyState, UiPlayMusic, UiPlaySound, UiStopMusic,
register_ui_contracts,
};
+57 -9
View File
@@ -3,21 +3,69 @@ use bevy::prelude::{Entity, Message, Resource};
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ActivePad(pub Option<Entity>);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PromptStyle {
Xbox,
PlayStation,
#[default]
Generic,
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct InputDevices {
pub pad_prompts: bool,
pub style: PromptStyle,
pub focused: bool,
pub aiming_with_pad: bool,
}
#[derive(Message)]
pub struct TestControllerRumble;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ControllerIcon {
A,
B,
X,
Y,
DpadUp,
DpadDown,
DpadLeft,
DpadRight,
}
impl ControllerIcon {
pub const ALL: [Self; 8] = [
Self::A,
Self::B,
Self::X,
Self::Y,
Self::DpadUp,
Self::DpadDown,
Self::DpadLeft,
Self::DpadRight,
];
pub const fn prompt(self) -> &'static str {
match self {
Self::A => "\u{e000}",
Self::B => "\u{e001}",
Self::X => "\u{e002}",
Self::Y => "\u{e003}",
Self::DpadUp => "\u{e004}",
Self::DpadDown => "\u{e005}",
Self::DpadLeft => "\u{e006}",
Self::DpadRight => "\u{e007}",
}
}
pub fn from_char(c: char) -> Option<Self> {
Self::ALL
.get((c as u32).checked_sub(0xe000)? as usize)
.copied()
}
pub const fn material(self) -> &'static str {
match self {
Self::A => "iw4l_controller_a",
Self::B => "iw4l_controller_b",
Self::X => "iw4l_controller_x",
Self::Y => "iw4l_controller_y",
Self::DpadUp => "iw4l_controller_dpad_up",
Self::DpadDown => "iw4l_controller_dpad_down",
Self::DpadLeft => "iw4l_controller_dpad_left",
Self::DpadRight => "iw4l_controller_dpad_right",
}
}
}
+23 -60
View File
@@ -4,7 +4,6 @@ use bevy::prelude::*;
use bevy::state::{app::StatesPlugin, state::StateTransitionSystems};
use std::any::{TypeId, type_name};
use std::marker::PhantomData;
use std::sync::{Arc, RwLock};
#[derive(States, Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
pub enum MatchScope {
@@ -22,6 +21,13 @@ pub enum RoundPhase {
Playing,
}
#[derive(SubStates, Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
#[source(MatchScope = MatchScope::Live)]
pub enum Timeline {
#[default]
Running,
}
#[derive(SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ScopeCommit;
@@ -71,37 +77,10 @@ impl<R: Resource> Staging<R> {
}
}
#[derive(Resource)]
pub struct Published<T: Send + Sync + 'static>(Arc<RwLock<Option<(u64, T)>>>);
impl<T: Send + Sync + 'static> Clone for Published<T> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<T: Send + Sync + 'static> Default for Published<T> {
fn default() -> Self {
Self(Arc::new(RwLock::new(None)))
}
}
impl<T: Send + Sync + 'static> Published<T> {
pub fn read<R>(&self, read: impl FnOnce(Option<(u64, &T)>) -> R) -> R {
let guard = self.0.read().expect("published slot poisoned");
read(guard.as_ref().map(|(epoch, value)| (*epoch, value)))
}
pub fn publish(&self, epoch: u64, value: T) {
*self.0.write().expect("published slot poisoned") = Some((epoch, value));
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScopeKind {
Resource,
Staged,
Published,
Message,
}
@@ -151,13 +130,19 @@ impl Plugin for ScopePlugin {
}
app.init_state::<MatchScope>()
.add_sub_state::<RoundPhase>()
.add_sub_state::<Timeline>()
.init_resource::<ScopeRegistry>()
.init_resource::<crate::Retiring>()
.init_resource::<ScopeEpoch<MatchScope>>()
.init_resource::<ScopeEpoch<RoundPhase>>()
.init_resource::<ScopeEpoch<Timeline>>()
.add_systems(
StateTransition,
(retire_state::<RoundPhase>, retire_state::<MatchScope>)
(
retire_state::<Timeline>,
retire_state::<RoundPhase>,
retire_state::<MatchScope>,
)
.chain()
.in_set(ScopeRetired)
.in_set(StateTransitionSystems::TransitionSchedules),
@@ -197,6 +182,7 @@ impl Plugin for ScopePlugin {
configure_scope(app, MatchScope::Loading);
configure_scope(app, MatchScope::Live);
configure_scope(app, RoundPhase::Playing);
configure_scope(app, Timeline::Running);
if cfg!(debug_assertions) {
app.add_systems(Last, verify_registry.after(ScopeCommit));
}
@@ -254,6 +240,12 @@ fn retire_state<S: States>(
}
world.resource_mut::<ScopeEpoch<S>>().0 =
world.resource::<ScopeEpoch<S>>().0.wrapping_add(1);
// Match-epoch consumers (audio voices) must also drop what a restarted
// timeline played.
if TypeId::of::<S>() == TypeId::of::<Timeline>() {
world.resource_mut::<ScopeEpoch<MatchScope>>().0 =
world.resource::<ScopeEpoch<MatchScope>>().0.wrapping_add(1);
}
if !actions.is_empty() {
world.resource_mut::<crate::Retiring>().mark_teardown();
let mut registry = world.resource_mut::<ScopeRegistry>();
@@ -309,7 +301,6 @@ pub trait ScopeApp {
fn init_app<R: Resource + FromWorld>(&mut self) -> &mut Self;
fn scoped<R: Resource + FromWorld>(&mut self, scope: impl States) -> &mut Self;
fn staged<R: Resource>(&mut self, scope: impl States) -> &mut Self;
fn published<T: Send + Sync + 'static>(&mut self, scope: impl States) -> &mut Self;
fn scoped_message<M: Message>(&mut self, scope: impl States) -> &mut Self;
}
@@ -363,22 +354,6 @@ fn staging_empty<R: Resource>(world: &World) -> bool {
.get_resource::<Staging<R>>()
.is_none_or(Staging::is_empty)
}
fn published_present<T: Send + Sync + 'static>(world: &World) -> bool {
world
.get_resource::<Published<T>>()
.is_some_and(|slot| slot.read(|value| value.is_some()))
}
fn clear_published<T: Send + Sync + 'static>(world: &mut World) {
let value = world
.resource::<Published<T>>()
.0
.write()
.expect("published slot poisoned")
.take();
if let Some(value) = value {
world.resource_mut::<crate::Retiring>().hand_over(value);
}
}
fn messages_present<M: Message>(world: &World) -> bool {
world
.get_resource::<Messages<M>>()
@@ -406,7 +381,8 @@ fn register<R: 'static, S: States>(
assert!(
(TypeId::of::<S>() == TypeId::of::<MatchScope>()
&& matches!(label.as_str(), "Loading" | "Live"))
|| (TypeId::of::<S>() == TypeId::of::<RoundPhase>() && label == "Playing"),
|| (TypeId::of::<S>() == TypeId::of::<RoundPhase>() && label == "Playing")
|| (TypeId::of::<S>() == TypeId::of::<Timeline>() && label == "Running"),
"unsupported resource scope {label}"
);
if !app.is_plugin_added::<ScopePlugin>() {
@@ -474,19 +450,6 @@ impl ScopeApp for App {
);
self.init_resource::<Staging<R>>()
}
fn published<T: Send + Sync + 'static>(&mut self, scope: impl States) -> &mut Self {
register::<Published<T>, _>(
self,
scope,
ScopeKind::Published,
noop,
clear_published::<T>,
published_present::<T>,
empty,
noop,
);
self.init_resource::<Published<T>>()
}
fn scoped_message<M: Message>(&mut self, scope: impl States) -> &mut Self {
register::<Messages<M>, _>(
self,
+3
View File
@@ -15,6 +15,9 @@ pub struct ScreenEffectsView {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ScreenEffectsPublished;
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq)]
pub struct ScreenBlurRequest(pub f32);
#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScreenEffectsDvars {
pub draw_shellshock: bool,
+15
View File
@@ -45,6 +45,21 @@ impl Default for UiDraw {
}
}
#[derive(Resource, Default, Debug, Clone, Copy)]
pub struct UiPointerFree(pub bool);
#[derive(Resource, Default, Debug, Clone, Copy)]
pub struct UiOverlayInput(pub bool);
#[derive(Resource, Debug, Clone, Copy)]
pub struct HudDraw(pub bool);
impl Default for HudDraw {
fn default() -> Self {
Self(true)
}
}
#[derive(Resource, Clone, Debug, Default)]
pub struct LaunchIdentity {
pub role_label: String,
+2 -2
View File
@@ -118,7 +118,7 @@ impl Default for GameSettings {
pad_curve: 0,
pad_acceleration: true,
pad_aim_assist: 0,
pad_prompts: 0,
pad_prompts: 1,
pad_vibration: true,
pad_deadzone_left: 0.12,
pad_deadzone_right: 0.12,
@@ -177,7 +177,7 @@ impl GameSettings {
self.pad_stick_layout = self.pad_stick_layout.min(3);
self.pad_curve = self.pad_curve.min(2);
self.pad_aim_assist = 0;
self.pad_prompts = self.pad_prompts.min(3);
self.pad_prompts = 1;
let finite = |v: f32, lo: f32, hi: f32, default: f32| {
if v.is_finite() {
v.clamp(lo, hi)
+4
View File
@@ -30,6 +30,9 @@ impl UiMenuDvars {
}
}
#[derive(Resource, Default, Debug)]
pub struct FrontendContentLoading;
#[derive(Resource, Clone, Default, Debug)]
pub struct HostMatchRules(pub Vec<(String, String)>);
@@ -41,6 +44,7 @@ pub struct UiExecCommand {
#[derive(Message, Clone, Debug, PartialEq, Eq)]
pub enum UiMenuRequest {
Toggle,
RestoreLobby,
Open(String),
Close(String),
Focus { menu: String, item: String },
+2 -1
View File
@@ -7,7 +7,8 @@ pub mod menu;
pub use audio::{UiPlayMusic, UiPlaySound, UiStopMusic};
pub use input::{UiBindRequest, UiBindingCapture};
pub use menu::{
HostMatchRules, UiExecCommand, UiMenuDvars, UiMenuKey, UiMenuRequest, UiPartyState,
FrontendContentLoading, HostMatchRules, UiExecCommand, UiMenuDvars, UiMenuKey, UiMenuRequest,
UiPartyState,
};
pub fn register_ui_contracts(app: &mut App) {
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
description = "Draws what hud_iw4 computes / Snapshot presents."
[dependencies]
resvg = { version = "0.48.1", default-features = false }
structured_data_iw4.workspace = true
diag.workspace = true
bevy.workspace = true
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="rgb(32,37,43)" stroke="rgb(112,207,104)" stroke-width="2"/><path d="M10 22 16 9 22 22M12 18h8" fill="none" stroke="rgb(112,207,104)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 304 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="rgb(32,37,43)" stroke="rgb(240,116,110)" stroke-width="2"/><path d="M12 10h5a3 3 0 0 1 0 6h-5m5 0a3 3 0 0 1 0 6h-5V10" fill="none" stroke="rgb(240,116,110)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 328 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M11 2h10v9h9v10h-9v9H11v-9H2V11h9Z" fill="rgb(32,37,43)" stroke="rgb(146,155,165)" stroke-width="1.5" stroke-linejoin="round"/><path d="M12 4h8v8h-8Z" fill="rgb(245,247,250)" transform="rotate(180 16 16)"/></svg>

After

Width:  |  Height:  |  Size: 282 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M11 2h10v9h9v10h-9v9H11v-9H2V11h9Z" fill="rgb(32,37,43)" stroke="rgb(146,155,165)" stroke-width="1.5" stroke-linejoin="round"/><path d="M12 4h8v8h-8Z" fill="rgb(245,247,250)" transform="rotate(270 16 16)"/></svg>

After

Width:  |  Height:  |  Size: 282 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M11 2h10v9h9v10h-9v9H11v-9H2V11h9Z" fill="rgb(32,37,43)" stroke="rgb(146,155,165)" stroke-width="1.5" stroke-linejoin="round"/><path d="M12 4h8v8h-8Z" fill="rgb(245,247,250)" transform="rotate(90 16 16)"/></svg>

After

Width:  |  Height:  |  Size: 281 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M11 2h10v9h9v10h-9v9H11v-9H2V11h9Z" fill="rgb(32,37,43)" stroke="rgb(146,155,165)" stroke-width="1.5" stroke-linejoin="round"/><path d="M12 4h8v8h-8Z" fill="rgb(245,247,250)" transform="rotate(0 16 16)"/></svg>

After

Width:  |  Height:  |  Size: 280 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="rgb(32,37,43)" stroke="rgb(117,185,246)" stroke-width="2"/><path d="m11 10 10 12m0-12L11 22" fill="none" stroke="rgb(117,185,246)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 302 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="rgb(32,37,43)" stroke="rgb(240,211,106)" stroke-width="2"/><path d="m11 10 5 7 5-7m-5 7v5" fill="none" stroke="rgb(240,211,106)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 300 B

+8
View File
@@ -17,6 +17,8 @@ pub(crate) struct HudRootVisible(pub Option<i32>);
#[derive(Resource, Default)]
pub(crate) struct BloodOverlayLatch {
intensity: f32,
subject: Option<ViewSubject>,
last_time: Option<i32>,
pub(crate) packed: PackedList,
last_gpu_intensity: Option<f32>,
last_w: Option<f32>,
@@ -72,6 +74,12 @@ pub(crate) fn update_blood_overlay(
latch.intensity = 0.0;
}
}
let now = cg_clock.time();
if latch.subject != Some(*view) || latch.last_time.is_some_and(|time| now < time) {
latch.intensity = 0.0;
}
latch.subject = Some(*view);
latch.last_time = Some(now);
let in_game = matches!(*screen, AppScreen::InGame);
let Some(ps) = presented.player(local.0) else {
latch.intensity = 0.0;
+8 -2
View File
@@ -739,10 +739,16 @@ pub(crate) fn text_width(font: &FontDef, text: &str) -> i32 {
width = 0;
continue;
}
let Some(glyph) = font.glyph(letter) else {
let dx = if char::from_u32(letter)
.and_then(frame::ControllerIcon::from_char)
.is_some()
{
font.pixel_height
} else if let Some(glyph) = font.glyph(letter) {
i32::from(glyph.dx)
} else {
continue;
};
let dx = i32::from(glyph.dx);
width += dx;
if max_width < width {
max_width = width;
+105
View File
@@ -0,0 +1,105 @@
use std::collections::HashMap;
use std::time::Duration;
use asset_game::MenuCatalog;
use bevy::prelude::*;
use net::LastAdoptedSnapshot;
use net::transport::udp_session::UdpClientLink;
use crate::draw2d::{Draw2dCmd, Draw2dList, Draw2dOp, Draw2dProvenance, tessellate_fonts};
use crate::gpu_list::{HudTessPass, TessJob};
use crate::images::HUD_CHROME_NAMESPACE;
use crate::images::HudImages;
const SHOW_AFTER: Duration = Duration::from_millis(1000);
#[derive(Component)]
pub(crate) struct ConnectionNoticeRaster;
pub(crate) fn update(
surface: Res<crate::surface::Hud2dSurface>,
catalog: Option<Res<MenuCatalog>>,
link: Option<Res<UdpClientLink>>,
adopted: Option<Res<LastAdoptedSnapshot>>,
mut pass: ResMut<HudTessPass>,
mut hud_images: ResMut<HudImages>,
mut images: ResMut<Assets<Image>>,
) {
pass.connection_notice = TessJob::Hide;
if !surface.is_ready() || !link.is_some_and(|link| link.has_entered_match()) {
return;
}
let Some(silent) = adopted
.and_then(|adopted| adopted.since_adopted())
.filter(|d| *d >= SHOW_AFTER)
else {
return;
};
let Some(catalog) = catalog.as_ref() else {
return;
};
let Some(item) = catalog
.get("hud_fullscreen")
.and_then(|m| m.items.iter().find(|i| i.owner_draw == 113))
else {
return;
};
let font_name = hud_iw4::ui_get_font_handle(
item.font_enum,
surface.scale_virtual_to_real()[1],
item.text_scale,
);
let Some(font) = catalog.font(font_name) else {
return;
};
let text = format!("Connection interrupted {:.1}s", silent.as_secs_f32());
let nscale = hud_iw4::normalized_text_scale(font.pixel_height, item.text_scale);
let material = asset_core::AssetRef::bare_name(&font.material).to_owned();
let width = crate::chrome::ui_text_width(font, &text, item.text_scale);
let rect = surface.apply_rect(
-width * 0.5,
-120.0,
nscale,
nscale,
hud_iw4::ALIGN_CENTER,
hud_iw4::ALIGN_CENTER,
);
let list = Draw2dList {
cmds: vec![Draw2dCmd {
material_namespace: HUD_CHROME_NAMESPACE,
x: rect.x,
y: rect.y,
w: rect.w,
h: rect.h,
s0: 0.0,
t0: 0.0,
s1: 1.0,
t1: 1.0,
color: [1.0, 0.3, 0.3, 1.0],
material: material.clone(),
op: Draw2dOp::TextRun {
font: font_name.to_owned(),
scale: nscale,
text,
loc_key: String::new(),
style: item.text_style,
fx: None,
glow: None,
},
provenance: Draw2dProvenance::CgDraw {
site: "connection_notice",
},
layer: 1,
}],
};
let mut fonts = HashMap::new();
fonts.insert(font_name.to_owned(), font);
let (quads, _) = tessellate_fonts(&list, &fonts);
if !quads.is_empty()
&& hud_images
.get(HUD_CHROME_NAMESPACE, &material, &mut images)
.is_some()
{
pass.connection_notice = TessJob::Quads(quads);
}
}
+37
View File
@@ -0,0 +1,37 @@
use frame::ControllerIcon;
pub(crate) const SIZE: u32 = 64;
pub(crate) fn rasterize(icon: ControllerIcon) -> Vec<u8> {
let svg = match icon {
ControllerIcon::A => include_str!("../icons/xbox/a.svg"),
ControllerIcon::B => include_str!("../icons/xbox/b.svg"),
ControllerIcon::X => include_str!("../icons/xbox/x.svg"),
ControllerIcon::Y => include_str!("../icons/xbox/y.svg"),
ControllerIcon::DpadUp => include_str!("../icons/xbox/dpad_up.svg"),
ControllerIcon::DpadDown => include_str!("../icons/xbox/dpad_down.svg"),
ControllerIcon::DpadLeft => include_str!("../icons/xbox/dpad_left.svg"),
ControllerIcon::DpadRight => include_str!("../icons/xbox/dpad_right.svg"),
};
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default())
.expect("embedded controller SVG");
let mut pixmap = resvg::tiny_skia::Pixmap::new(SIZE, SIZE).expect("controller icon pixmap");
resvg::render(
&tree,
resvg::tiny_skia::Transform::from_scale(
SIZE as f32 / tree.size().width(),
SIZE as f32 / tree.size().height(),
),
&mut pixmap.as_mut(),
);
let mut rgba = pixmap.take();
for pixel in rgba.chunks_exact_mut(4) {
let alpha = u32::from(pixel[3]);
if alpha != 0 {
for channel in &mut pixel[..3] {
*channel = ((u32::from(*channel) * 255 + alpha / 2) / alpha).min(255) as u8;
}
}
}
rgba
}
+203
View File
@@ -0,0 +1,203 @@
use bevy::prelude::*;
use frame::{LifeStarted, UiDraw, ViewSubject};
use net::{FrameClock, LocalPresentClient, PresentedSnapshot};
use crate::draw2d::{Draw2dProvenance, Draw2dQuad};
use crate::gpu_list::{HudTessPass, TessJob};
#[derive(Component)]
pub(crate) struct DamageDirectionRaster;
#[derive(Clone, Copy, Default)]
struct Hit {
time: i32,
duration: i32,
yaw: f32,
stun: bool,
}
#[derive(Resource, Default)]
pub(crate) struct DamageDirections {
hits: [Hit; 12],
event: Option<u32>,
subject: Option<ViewSubject>,
time: Option<i32>,
weapon_frac: f32,
ads_in: bool,
random: u32,
}
pub(crate) fn spawn(root: &mut ChildSpawnerCommands) {
crate::font_overlay::spawn_overlay(root, DamageDirectionRaster);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn update(
surface: Res<crate::surface::Hud2dSurface>,
presented: Res<PresentedSnapshot>,
local: Res<LocalPresentClient>,
view: Res<ViewSubject>,
clock: Res<FrameClock>,
ui_draw: Res<UiDraw>,
aim: Res<net::ViewweaponAim>,
weapons: Option<Res<assets::PreparedWeapons>>,
mut started: MessageReader<LifeStarted>,
mut directions: ResMut<DamageDirections>,
mut pass: ResMut<HudTessPass>,
) {
pass.damage_direction = TessJob::Hide;
let now = clock.time();
let reset = started.read().filter(|ev| ev.client == local.0.0).count() != 0
|| directions.subject != Some(*view)
|| directions.time.is_some_and(|time| now < time);
if reset {
*directions = DamageDirections::default();
}
directions.subject = Some(*view);
directions.time = Some(now);
let Some(ps) = presented.player(local.0) else {
*directions = DamageDirections::default();
return;
};
let dvars = presented
.snapshot()
.map(|snapshot| snapshot.meta.script_dvars(local.0));
if directions
.event
.is_some_and(|event| event != ps.damage_event)
&& ps.damage_count > 0
&& !(ps.damage_yaw == 255 && ps.damage_pitch == 255)
{
let duration = dvars
.and_then(|d| d.int("cg_hudDamageIconTime"))
.unwrap_or(6000)
.max(0);
let slot = directions
.hits
.iter()
.enumerate()
.min_by_key(|(_, hit)| hit.time)
.map(|(index, _)| index)
.unwrap_or(0);
directions.random = directions.random.wrapping_mul(214013).wrapping_add(2531011);
let jitter = (((directions.random >> 16) & 0x7fff) as f32 / 32768.0 - 0.5) * 20.0;
directions.hits[slot] = Hit {
time: now,
duration,
yaw: ps.damage_yaw as f32 * (360.0 / 256.0) + jitter,
stun: ps.damage_flags & 1 != 0,
};
}
directions.event = Some(ps.damage_event);
if ps.f_weapon_pos_frac != directions.weapon_frac {
directions.ads_in = ps.f_weapon_pos_frac > directions.weapon_frac;
}
directions.weapon_frac = ps.f_weapon_pos_frac;
let scoped = weapons.as_ref().is_some_and(|weapons| {
let index = playerstate_iw4::get_viewmodel_weapon_index(ps);
weapons
.snapshot_weapon(presented.weapon_epoch(), index)
.ok()
.and_then(|weapon| weapon.hud_facts())
.is_some_and(|facts| {
hud_iw4::get_weap_reticle_zoom(
ps.f_weapon_pos_frac,
directions.ads_in,
&hud_iw4::WeaponAdsOverlayFacts {
ads_zoom_in_frac: facts.ads_zoom_in_frac,
ads_zoom_out_frac: facts.ads_zoom_out_frac,
overlay_material: u32::from(
weapons.registry().overlay_material_of(index).is_some(),
),
overlay_reticle: facts.overlay_reticle,
..default()
},
)
.active
})
});
let flashed = hud_iw4::is_flashbanged(
now,
ps.shellshock_time,
ps.shellshock_duration,
presented
.shellshock(local.0)
.map_or(hud_iw4::SCREEN_BLEND_BLURRED, |shock| shock.screen_type),
) != 0;
if !surface.is_ready()
|| !ui_draw.0
|| flashed
|| ps.pm_flags & playerstate_iw4::pm_flags::MANTLE != 0
|| (scoped
&& dvars
.and_then(|d| d.int("cg_hudDamageIconInScope"))
.unwrap_or(0)
== 0)
|| dvars
.and_then(|d| d.int("cg_drawDamageDirection"))
.unwrap_or(1)
== 0
|| (view.in_killcam() && ps.kill_cam_entity != playerstate_iw4::ENTITYNUM_NONE)
{
return;
}
let value = |name, default| {
dvars
.and_then(|d| d.float(name))
.filter(|value| value.is_finite())
.unwrap_or(default)
.clamp(0.0, 512.0)
};
let scale = surface.scale_virtual_to_real();
let half_width = value("cg_hudDamageIconWidth", 128.0) * scale[0] * 0.5;
let offset = value("cg_hudDamageIconOffset", 128.0) * scale[1];
let outer = offset + value("cg_hudDamageIconHeight", 64.0) * scale[1];
let mut center = [surface.width() * 0.5, surface.height() * 0.5];
if scoped && aim.live {
let projection = crate::presentation_scale::PresentationScale::from_window(
surface.width(),
surface.height(),
);
let factor = projection.factor(crate::presentation_scale::ScaleClass::ProjectionBound);
center[0] += aim.xhair_x * factor;
center[1] += aim.xhair_y * factor;
}
let mut quads = Vec::new();
for hit in &directions.hits {
let age = now.saturating_sub(hit.time);
if age <= 0 || age >= hit.duration {
continue;
}
let (sin, cos) = (ps.viewangles[1] - hit.yaw).to_radians().sin_cos();
let xy = [
[half_width, outer],
[-half_width, outer],
[-half_width, offset],
[half_width, offset],
]
.map(|[x, y]| [center[0] + cos * x - sin * y, center[1] + sin * x + cos * y]);
quads.push(Draw2dQuad {
xy,
st: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
color: [
1.0,
1.0,
1.0,
(2.0 - 2.0 * age as f32 / hit.duration as f32).min(1.0),
],
material: if hit.stun {
"hit_direction_stun"
} else {
"hit_direction"
}
.into(),
material_namespace: crate::images::HUD_CHROME_NAMESPACE,
provenance: Draw2dProvenance::CgDraw {
site: "damage_direction",
},
layer: 0,
clip: None,
});
}
pass.damage_direction = TessJob::Quads(quads);
}
+37
View File
@@ -648,6 +648,43 @@ fn text_run_quads(
hud_iw4::rand_with_seed(&mut temp)
});
if let Some(icon) = frame::ControllerIcon::from_char(c) {
let size = font.pixel_height as f32;
let (xy, st) = hud_iw4::draw_stretch_pic_corners(
cursor_x,
y - size * y_scale * 0.8,
size * x_scale,
size * y_scale,
0.0,
0.0,
1.0,
1.0,
);
let alpha = if decaying || (draw_rand_char_at_end && max_length_remaining == 1) {
f32::from(hud_iw4::modulate_byte_colors(
(color[3] * 255.0) as u8,
fade_alpha,
)) / 255.0
} else {
color[3]
};
if !skip_drawing {
out.push(Draw2dQuad {
xy,
st,
color: [1.0, 1.0, 1.0, alpha],
material: icon.material().to_owned(),
material_namespace,
provenance: provenance.clone(),
layer,
clip,
});
}
cursor_x += size * x_scale;
max_length_remaining = max_length_remaining.saturating_sub(1);
continue;
}
let Some(glyph) = font.glyph(letter) else {
continue;
};
+2
View File
@@ -191,7 +191,9 @@ pub struct HudTessPass {
pub targetmap: TessJob,
pub emp_static: TessJob,
pub mantle_hint: TessJob,
pub damage_direction: TessJob,
pub breath_hint: TessJob,
pub connection_notice: TessJob,
pub use_hint: TessJob,
pub hud_elems: TessJob,
pub hud_elems_back: TessJob,
+19
View File
@@ -133,6 +133,7 @@ fn make_image(
#[derive(Resource, Default)]
pub struct HudImages {
controller_icons: HashMap<String, Handle<Image>>,
ui_images: asset_material::UiImagePublication,
games_root: PathBuf,
@@ -385,6 +386,24 @@ impl HudImages {
sampler: Option<u8>,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
if let Some(icon) = frame::ControllerIcon::ALL
.into_iter()
.find(|icon| icon.material() == name)
{
let handle = self
.controller_icons
.entry(name.to_owned())
.or_insert_with(|| {
images.add(make_image(
crate::controller_icons::SIZE,
crate::controller_icons::SIZE,
crate::controller_icons::rasterize(icon),
HudSampling::Color,
None,
))
});
return Some(handle.clone());
}
let key = iwd_key(ns, name, sampling, sampler);
if ns == HUD_CHROME_NAMESPACE && self.zone_states.get(&key.1) == Some(&None) {
return None;
+6 -3
View File
@@ -174,7 +174,7 @@ fn snapshot_client_team(presented: &net::PresentedSnapshot, client: i32) -> i32
.map_or(0, |meta| meta.client_state_team)
}
fn obituary_name_color(local_team: i32, team: i32, dvars: sim::ScriptDvars<'_>) -> [f32; 4] {
pub fn team_name_color(local_team: i32, team: i32, dvars: sim::ScriptDvars<'_>) -> [f32; 4] {
if !matches!(local_team, 1 | 2) || !matches!(team, 1 | 2) {
return [1.0, 1.0, 1.0, 1.0];
}
@@ -341,6 +341,9 @@ pub(crate) fn update_killfeed(
let now = clock.time();
for cmd in notifies.read() {
if cmd.tag == net::SVC_CHAT {
continue;
}
if !window.seen.insert(cmd.id) {
continue;
}
@@ -577,7 +580,7 @@ pub(crate) fn update_killfeed(
applied.h,
font_material.clone(),
attacker.clone(),
obituary_name_color(local_team, *attacker_team, dvars),
team_name_color(local_team, *attacker_team, dvars),
"killfeed_obituary",
));
x_virtual += attacker_w + icon_spacing;
@@ -641,7 +644,7 @@ pub(crate) fn update_killfeed(
applied.h,
font_material.clone(),
victim.clone(),
obituary_name_color(local_team, *victim_team, dvars),
team_name_color(local_team, *victim_team, dvars),
"killfeed_obituary",
));
}
+5
View File
@@ -3,6 +3,9 @@ mod blood;
mod breath_hint;
mod chrome;
mod compass;
mod connection_notice;
mod controller_icons;
mod damage_direction;
mod draw2d;
mod emp_static;
mod expr_cache;
@@ -55,3 +58,5 @@ pub use splash::PendingSplash;
pub use surface::Hud2dSurface;
mod use_hint;
pub use killfeed::team_name_color;
+22
View File
@@ -48,6 +48,28 @@ impl MenuHost<'_> {
let lower = name.to_ascii_lowercase();
let flag = |on: bool| String::from(if on { "1" } else { "0" });
match lower.as_str() {
"ui_profile_rank_icon" | "ui_profile_level" => self
.world
.player_data
.and_then(|data| {
let progression = data.progression.as_ref()?;
let rank = progression.rank.to_string();
if lower == "ui_profile_rank_icon" {
data.table_lookup(
"mp/rankicontable.csv",
0,
&rank,
progression
.prestige
.checked_add(1)
.filter(|column| *column > 0)?,
)
} else {
data.table_lookup("mp/ranktable.csv", 0, &rank, 14)
}
})
.unwrap_or_default()
.to_owned(),
"cl_ingame" => flag(self.world.in_game),
"gamemode" => "mp".to_owned(),
"ui_multiplayer" | "widescreen" | "hidef" => flag(true),
+55 -6
View File
@@ -8,7 +8,10 @@ use state::OpenMenu;
pub use state::ScriptMenus;
#[derive(bevy::prelude::Resource, Default)]
pub struct FrontendMenus(pub ScriptMenus);
pub struct FrontendMenus {
menus: ScriptMenus,
restore_lobby: bool,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct MenuState<'w> {
@@ -19,7 +22,7 @@ impl MenuState<'_> {
pub fn as_ref(&self) -> Option<&ScriptMenus> {
self.scoped
.as_deref()
.or_else(|| self.frontend.as_deref().map(|menus| &menus.0))
.or_else(|| self.frontend.as_deref().map(|menus| &menus.menus))
}
pub fn as_deref(&self) -> Option<&ScriptMenus> {
self.as_ref()
@@ -65,6 +68,7 @@ pub(super) const MAX_SCRIPT_DEPTH: u32 = 16;
const MAIN_MENU_DVAR: &str = "g_scriptMainMenu";
const FALLBACK_MAIN_MENU: &str = "class";
const SCOREBOARD_MENU: &str = "scoreboard";
const FRONTEND_LOADING_MENU: &str = "iw4l_frontend_loading";
pub(super) fn is_focusable(item: &asset_game::MenuItem) -> bool {
if item.static_flags & WINDOW_DECORATION != 0 {
@@ -180,6 +184,7 @@ pub(crate) fn spawn_script_menus(root: &mut ChildSpawnerCommands) {
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct MenuInputs<'w, 's> {
keys: Res<'w, ButtonInput<KeyCode>>,
overlay: Res<'w, frame::UiOverlayInput>,
keyboard: MessageReader<'w, 's, bevy::input::keyboard::KeyboardInput>,
mouse: Res<'w, ButtonInput<MouseButton>>,
windows: Query<'w, 's, &'static Window, With<bevy::window::PrimaryWindow>>,
@@ -193,6 +198,7 @@ pub(crate) struct MenuInputs<'w, 's> {
party: Res<'w, frame::UiPartyState>,
frontend_strings: Option<Res<'w, asset_game::LocalizeCatalog>>,
player_data: Option<Res<'w, sim::LocalPlayerData>>,
content_loading: Option<Res<'w, frame::FrontendContentLoading>>,
}
#[derive(Default)]
@@ -245,7 +251,19 @@ pub(crate) fn update_script_menus(
mut images: ResMut<Assets<Image>>,
mut pass: ResMut<HudTessPass>,
) {
let mut menus = menu_states.0.as_deref_mut().unwrap_or(&mut menu_states.1.0);
let requests: Vec<UiMenuRequest> = input.requests.read().cloned().collect();
let FrontendMenus {
menus: frontend_menus,
restore_lobby,
} = &mut *menu_states.1;
for request in &requests {
match request {
UiMenuRequest::RestoreLobby => *restore_lobby = true,
UiMenuRequest::Close(name) if name == "game_lobby" => *restore_lobby = false,
_ => {}
}
}
let mut menus = menu_states.0.as_deref_mut().unwrap_or(frontend_menus);
let mut locals = local_states
.0
.as_deref_mut()
@@ -258,7 +276,6 @@ pub(crate) fn update_script_menus(
out.binding.command = None;
*menus = ScriptMenus::default();
}
let requests: Vec<UiMenuRequest> = input.requests.read().cloned().collect();
let Some(catalog) = catalog.as_deref() else {
return;
};
@@ -342,8 +359,27 @@ pub(crate) fn update_script_menus(
binding: &mut out.binding,
};
let main_menu = if frontend && input.content_loading.is_some() {
FRONTEND_LOADING_MENU
} else {
"iw4l_main"
};
if frontend
&& main_menu == "iw4l_main"
&& let Some(at) = runner.menus.position(FRONTEND_LOADING_MENU)
{
let above: Vec<String> = runner.menus.stack[at + 1..]
.iter()
.map(|open| open.name.clone())
.collect();
runner.close(FRONTEND_LOADING_MENU);
runner.open(main_menu);
for name in above {
runner.open(&name);
}
}
if frontend && screen_changed {
runner.open("iw4l_main");
runner.open(main_menu);
if let Some(def) = catalog.get("main")
&& !def.sound_name.is_empty()
{
@@ -353,6 +389,14 @@ pub(crate) fn update_script_menus(
}
}
if frontend && main_menu == "iw4l_main" && *restore_lobby {
*restore_lobby = false;
if input.party.in_lobby && input.party.is_host {
runner.open("game_lobby");
diag::info!(Ui, "frontend: private lobby reopened");
}
}
for command in meta
.into_iter()
.filter(|_| in_game)
@@ -378,6 +422,7 @@ pub(crate) fn update_script_menus(
for request in requests {
match request {
UiMenuRequest::Toggle => pressed.escape = true,
UiMenuRequest::RestoreLobby => {}
UiMenuRequest::Open(name) => runner.open(&name),
UiMenuRequest::Close(name) => runner.close(&name),
UiMenuRequest::Focus { menu, item } => {
@@ -411,7 +456,7 @@ pub(crate) fn update_script_menus(
if frontend && runner.dvars.get("ui_map_error") == Some("1") {
runner.open("iw4l_map_error");
}
let pointer = !console_open;
let pointer = !console_open && !input.overlay.0;
if pointer {
let keys = &input.keys;
pressed.escape |= keys.just_pressed(KeyCode::Escape);
@@ -453,6 +498,10 @@ pub(crate) fn update_script_menus(
}
}
if input.overlay.0 && in_game {
return;
}
let scoreboard = snapshot
.filter(|s| in_game && crate::scoreboard::displayed(world.scores_open, s, local.0))
.and_then(|_| catalog.get(SCOREBOARD_MENU));
+79 -7
View File
@@ -41,27 +41,29 @@ impl Plugin for HudPlugin {
);
crate::gpu_list::register(app);
let _ = crate::scorebar::milliseconds();
app.init_resource::<HudImages>()
app.init_resource::<frame::HudDraw>()
.init_resource::<HudImages>()
.init_resource::<HudPresentationGaps>()
.init_resource::<ReticleAdsLatch>()
.init_resource::<IrisLetterboxFill>()
.init_resource::<BloodOverlayLatch>()
.scoped::<crate::damage_direction::DamageDirections>(frame::Timeline::Running)
.init_resource::<BloodGpuJob>()
.init_resource::<FlashWhiteoutLatch>()
.init_resource::<FlashGpuJob>()
.scoped::<PendingSplash>(frame::MatchScope::Live)
.scoped::<SplashSlots>(frame::MatchScope::Live)
.scoped::<KillfeedWindow>(frame::MatchScope::Live)
.scoped::<PendingSplash>(frame::Timeline::Running)
.scoped::<SplashSlots>(frame::Timeline::Running)
.scoped::<KillfeedWindow>(frame::Timeline::Running)
.scoped::<PlayerCardCache>(frame::MatchScope::Live)
.scoped::<UiLocalVars>(frame::MatchScope::Live)
.init_resource::<crate::playercard::FrontendLocalVars>()
.init_resource::<HudRootVisible>()
.scoped::<crate::compass::CompassPingLatch>(frame::MatchScope::Live)
.scoped::<crate::compass::CompassPingLatch>(frame::Timeline::Running)
.init_resource::<crate::surface::Hud2dSurface>()
.init_resource::<HudPresentStamp>()
.init_resource::<HudStageStamp>()
.init_resource::<crate::expr_cache::MenuExprCache>()
.scoped::<crate::hudelem::HudElemSoundLatch>(frame::MatchScope::Live)
.scoped::<crate::hudelem::HudElemSoundLatch>(frame::Timeline::Running)
.scoped::<crate::menus::ScriptMenus>(frame::MatchScope::Live)
.init_resource::<crate::menus::FrontendMenus>()
.add_message::<net::SvcCardSlotCmd>()
@@ -126,7 +128,9 @@ impl Plugin for HudPlugin {
crate::use_hint::update.in_set(frame::InMatch),
update_hud_elems.in_set(frame::InMatch),
update_targetmap,
crate::damage_direction::update.in_set(frame::InMatch),
crate::menus::update_script_menus,
crate::connection_notice::update.in_set(frame::InMatch),
hud_stage_close::<7>,
)
.chain(),
@@ -154,7 +158,9 @@ impl Plugin for HudPlugin {
flush_hud_elems_tess,
flush_targetmap_tess,
flush_blood_tess,
flush_damage_direction_tess,
flush_script_menus_tess,
flush_connection_notice_tess,
)
.chain()
.after(hud_stage_close::<7>)
@@ -340,6 +346,7 @@ fn ensure_hud_root(mut commands: Commands, existing: Query<Entity, With<HudRoot>
.with_children(|root| {
crate::font_overlay::spawn_overlay(root, crate::overhead_names::OverheadNamesRaster);
spawn_reticle(root);
crate::damage_direction::spawn(root);
spawn_iris(root);
spawn_hud_elems_back(root);
spawn_compass(root);
@@ -357,17 +364,22 @@ fn ensure_hud_root(mut commands: Commands, existing: Query<Entity, With<HudRoot>
spawn_hud_elems(root);
spawn_targetmap(root);
crate::menus::spawn_script_menus(root);
crate::font_overlay::spawn_overlay(
root,
crate::connection_notice::ConnectionNoticeRaster,
);
});
}
fn sync_hud_visibility(
screen: Res<AppScreen>,
ui_draw: Option<Res<UiDraw>>,
hud_draw: Res<frame::HudDraw>,
mut roots: Query<&mut Visibility, With<HudRoot>>,
mut visible: ResMut<HudRootVisible>,
) {
let ui_on = ui_draw.is_some_and(|d| d.0);
let show = hud_root_should_show(*screen, ui_on);
let show = hud_draw.0 && hud_root_should_show(*screen, ui_on);
visible.0 = Some(i32::from(show));
for mut vis in &mut roots {
let want = if show {
@@ -681,6 +693,36 @@ fn flush_mantle_hint_tess(
}
}
fn flush_damage_direction_tess(
surface: Res<crate::surface::Hud2dSurface>,
mut pass: ResMut<HudTessPass>,
mut hud_images: ResMut<HudImages>,
mut images: ResMut<Assets<Image>>,
mut frame: ResMut<crate::gpu_list::HudTessGpuFrame>,
mut hint: Query<
(Entity, &mut Node, &mut crate::gpu_list::GpuListLatch),
With<crate::damage_direction::DamageDirectionRaster>,
>,
) {
let _body = gpu_list::TessBody::open();
if !surface.is_ready() {
return;
}
let job = std::mem::take(&mut pass.damage_direction);
if let Ok((_, mut host, mut latch)) = hint.single_mut() {
gpu_list::apply_tess_job(
job,
&mut host,
&mut latch,
&mut hud_images,
&mut images,
&mut frame,
surface.width(),
surface.height(),
);
}
}
fn flush_scoreboard_tess(
surface: Res<crate::surface::Hud2dSurface>,
mut pass: ResMut<HudTessPass>,
@@ -897,6 +939,36 @@ fn flush_breath_hint_tess(
}
}
fn flush_connection_notice_tess(
surface: Res<crate::surface::Hud2dSurface>,
mut pass: ResMut<HudTessPass>,
mut hud_images: ResMut<HudImages>,
mut images: ResMut<Assets<Image>>,
mut frame: ResMut<crate::gpu_list::HudTessGpuFrame>,
mut hint: Query<
(Entity, &mut Node, &mut crate::gpu_list::GpuListLatch),
With<crate::connection_notice::ConnectionNoticeRaster>,
>,
) {
let _body = gpu_list::TessBody::open();
if !surface.is_ready() {
return;
}
let job = std::mem::take(&mut pass.connection_notice);
if let Ok((_, mut host, mut latch)) = hint.single_mut() {
gpu_list::apply_tess_job(
job,
&mut host,
&mut latch,
&mut hud_images,
&mut images,
&mut frame,
surface.width(),
surface.height(),
);
}
}
fn release_hud_publication(
mut latches: Query<&mut crate::gpu_list::GpuListLatch>,
mut frame: ResMut<crate::gpu_list::HudTessGpuFrame>,
+31 -16
View File
@@ -356,26 +356,41 @@ pub(crate) fn update_splash(
return;
};
// Promotion uses this match's authoritative rank. The authored expressions
// read a stored profile's experience/prestige, which is not the match state.
let mut promotion;
let menu = if menu_name.eq_ignore_ascii_case("promotion") {
promotion = menu.clone();
if let Some(meta) = presented
let rank = presented
.snapshot()
.and_then(|snap| snap.meta.for_client(local.0))
{
let key = meta.rank.to_string();
let icon = catalog
.as_ref()
.and_then(|c| c.string_table("mp/rankIconTable.csv"))
.map(|t| t.lookup_col(&key, meta.prestige.saturating_add(1)))
.unwrap_or("");
for item in &mut promotion.items {
if item.name.starts_with("promotion_rank_icon") {
item.material_exp.clear();
item.background = icon.to_owned();
}
.and_then(|snap| snap.meta.for_client(local.0));
let icon = rank
.and_then(|meta| {
let column = meta.prestige.checked_add(1).filter(|column| *column > 0)?;
catalog
.as_ref()?
.string_table("mp/rankIconTable.csv")
.map(|table| table.lookup_col(&meta.rank.to_string(), column))
})
.unwrap_or_default();
let title = rank
.and_then(|meta| {
catalog
.as_ref()?
.string_table("mp/rankTable.csv")
.map(|table| table.lookup_col(&meta.rank.to_string(), 5))
})
.unwrap_or_default();
for item in &mut promotion.items {
if item.name.starts_with("promotion_rank_icon") {
item.material_exp.clear();
item.background = icon.to_owned();
} else if item.name.starts_with("promotion_rank_text") {
item.text_exp.clear();
item.text_key = if title.is_empty() {
String::new()
} else {
format!("@{}", title.trim_start_matches('@'))
};
item.text_literal = false;
}
}
&promotion
+53 -3
View File
@@ -45,6 +45,9 @@ const CLOSE_SERVICE_DONE: u32 = 0;
const MAX_CLOSE_REASON_BYTES: usize = 120;
const DATAGRAM_SEND_BUFFER: usize = 128 * 1024;
const RELAY_BACKLOG_BYTES: usize = 16 * 1024;
struct AddressSlot {
counts: Arc<std::sync::Mutex<HashMap<IpAddr, usize>>>,
ip: IpAddr,
@@ -543,7 +546,7 @@ async fn serve(bind: SocketAddr, cert_path: &Path, key_path: &Path) -> Result<()
));
transport.keep_alive_interval(Some(SESSION_KEEP_ALIVE));
transport.datagram_receive_buffer_size(Some(128 * 1024));
transport.datagram_send_buffer_size(128 * 1024);
transport.datagram_send_buffer_size(DATAGRAM_SEND_BUFFER);
let endpoint = quinn::Endpoint::server(config, bind)?;
let state = Arc::new(Mutex::new(ServiceState::default()));
@@ -678,6 +681,7 @@ async fn run_connection(
connection.clone(),
control_tx,
);
let mut relay_gate = RelayGate::default();
let mut children = tokio::task::JoinSet::new();
children.spawn(async move {
let result = async {
@@ -739,7 +743,7 @@ async fn run_connection(
}
datagram = connection.read_datagram() => {
let bytes = datagram?;
if let Err(error) = relay_datagram(state, connection_id, &bytes).await {
if let Err(error) = relay_datagram(state, connection_id, &bytes, &mut relay_gate).await {
let _ = writeln!(std::io::stderr(), "relay datagram: {error}");
}
}
@@ -1344,17 +1348,63 @@ async fn bootstrap_forward(
ConnTask::Bootstrap
}
// Gameplay packets arrive split into fragments, and one lost fragment loses
// the packet. Quinn sheds the oldest datagrams of a full buffer one at a time,
// which on a slow target leaves almost no packet whole.
#[derive(Default)]
struct RelayGate {
shedding: Option<u32>,
}
impl RelayGate {
fn admit(&mut self, payload: &[u8], target: &quinn::Connection, wire_bytes: usize) -> bool {
let Some((index, count, packet_id)) = fragment_header(payload) else {
return true;
};
if index != 0 {
return self.shedding != Some(packet_id);
}
let queued = DATAGRAM_SEND_BUFFER.saturating_sub(target.datagram_send_buffer_space());
let admitted =
queued < wire_bytes || queued + wire_bytes * usize::from(count) <= RELAY_BACKLOG_BYTES;
self.shedding = (!admitted).then_some(packet_id);
admitted
}
}
fn fragment_header(payload: &[u8]) -> Option<(u16, u16, u32)> {
let header = payload.get(..12)?;
if header[..3] != *b"RF\x02" {
return None;
}
let index = u16::from_le_bytes([header[4], header[5]]);
let count = u16::from_le_bytes([header[6], header[7]]);
let packet_id = u32::from_le_bytes([header[8], header[9], header[10], header[11]]);
Some((index, count, packet_id))
}
async fn relay_datagram(
state: &Mutex<ServiceState>,
connection_id: u64,
bytes: &[u8],
gate: &mut RelayGate,
) -> Result<()> {
let relay = decode_relay(bytes)?;
let (target, outgoing) = {
let current = state.lock().await;
current.route_target(connection_id, &relay)?
};
target.send_datagram(outgoing.into())?;
let payload = match relay {
RelayDatagram::ClientToHost(payload) | RelayDatagram::HostToMember { payload, .. } => {
payload
}
RelayDatagram::ServiceToHost { payload, .. } | RelayDatagram::ServiceToMember(payload) => {
payload
}
};
if gate.admit(payload, &target, outgoing.len()) {
target.send_datagram(outgoing.into())?;
}
Ok(())
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub const MAX_BUILD_BYTES: usize = 64;
pub const MAX_PLAYER_NAME_BYTES: usize = 64;
pub const MAX_LIST_ADVERTS: usize = 128;
pub const SESSION_IDLE: core::time::Duration = core::time::Duration::from_secs(8);
pub const SESSION_IDLE: core::time::Duration = core::time::Duration::from_secs(40);
pub const SESSION_KEEP_ALIVE: core::time::Duration = core::time::Duration::from_secs(1);
const MAGIC: [u8; 4] = *b"IW4M";
+55 -24
View File
@@ -39,14 +39,16 @@ pub(crate) const COMMAND_TIME_STEP_MS: i32 = 200;
pub const MAX_COMMAND_STALL_MS: i32 = 1000;
pub const LOST_COMMAND_HOLD_MS: i32 = 250;
pub const MAX_COMMAND_LEAD_MS: i32 = 200;
pub const MAX_COMMANDS_PER_PEER_PER_FRAME: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputBacklogFault {
QueueCount { queued: usize },
QueueDuration { queued_ms: i32 },
NoProgress { stalled_ms: i32 },
}
@@ -58,11 +60,6 @@ impl core::fmt::Display for InputBacklogFault {
"InputBacklogExceeded: {queued} unique commands queued (max \
{MAX_QUEUED_COMMANDS_PER_PEER})"
),
Self::QueueDuration { queued_ms } => write!(
f,
"InputBacklogExceeded: {queued_ms} ms of command time queued (max \
{MAX_QUEUED_COMMAND_MS})"
),
Self::NoProgress { stalled_ms } => write!(
f,
"InputBacklogExceeded: command queue made no progress for {stalled_ms} ms (max \
@@ -186,6 +183,22 @@ impl ClientCommandInbox {
if sequenced > MAX_QUEUED_COMMANDS_PER_PEER {
return Some(InputBacklogFault::QueueCount { queued: sequenced });
}
let head = queue.iter().find_map(|(seq, _, _)| *seq)?;
let blocked = self.blocked_head.entry(id).or_insert((head, time_ms));
if blocked.0 != head {
*blocked = (head, time_ms);
}
let stalled_ms = time_ms.saturating_sub(blocked.1);
if stalled_ms > MAX_COMMAND_STALL_MS {
return Some(InputBacklogFault::NoProgress { stalled_ms });
}
None
}
fn queued_ms(&self, id: ClientId, time_ms: i32) -> i32 {
let Some(queue) = self.queued.get(&id) else {
return 0;
};
let mut queued_ms: i32 = 0;
let mut previous: Option<i32> = self
.last_consumed
@@ -207,19 +220,22 @@ impl ClientCommandInbox {
}
previous = Some(cmd.server_time);
}
if queued_ms > MAX_QUEUED_COMMAND_MS {
return Some(InputBacklogFault::QueueDuration { queued_ms });
queued_ms
}
// A burst after a stalled relay can carry more command time than the
// budget. Running it would let the peer outpace real time, so the oldest
// commands are dropped as lost instead of retiring the peer.
fn shed_excess(&mut self, id: ClientId, time_ms: i32) {
while self.queued_ms(id, time_ms) > MAX_QUEUED_COMMAND_MS {
let Some((seq, cmd, _)) = self.queued.get_mut(&id).and_then(VecDeque::pop_front) else {
return;
};
if let Some(seq) = seq {
self.last_acked_seq.insert(id, seq.0);
self.last_consumed.insert(id, cmd);
}
}
let head = queue.iter().find_map(|(seq, _, _)| *seq)?;
let blocked = self.blocked_head.entry(id).or_insert((head, time_ms));
if blocked.0 != head {
*blocked = (head, time_ms);
}
let stalled_ms = time_ms.saturating_sub(blocked.1);
if stalled_ms > MAX_COMMAND_STALL_MS {
return Some(InputBacklogFault::NoProgress { stalled_ms });
}
None
}
pub fn last_acked_seq(&self, id: ClientId) -> Option<CmdSeq> {
@@ -245,12 +261,19 @@ impl ClientCommandInbox {
pub fn take_for_tick(&mut self, time_ms: i32) -> GatheredCommands {
let mut out = GatheredCommands::default();
for id in self.known_clients() {
// After an authority hitch the client's monotonic clock stays
// ahead; holding its commands until their stamp starves the peer.
if let Some(queue) = self.queued.get_mut(&id) {
for (_, cmd, _) in queue.iter_mut() {
if cmd.server_time > time_ms.saturating_add(MAX_COMMAND_LEAD_MS) {
cmd.server_time = time_ms;
}
}
}
// A datagram burst can contain more than one frame of runnable work.
// Consume the bounded frame budget before measuring remaining debt.
// Count and blocked-head limits still apply before any simulation.
if let Some(fault) = self.backlog_fault(id, time_ms)
&& !matches!(fault, InputBacklogFault::QueueDuration { .. })
{
if let Some(fault) = self.backlog_fault(id, time_ms) {
out.backlog_faults.push((id, fault));
continue;
}
@@ -269,10 +292,16 @@ impl ClientCommandInbox {
.iter()
.take_while(|(seq, cmd, _)| {
if let Some(seq) = seq {
if seq.0 != expected || cmd.server_time > time_ms {
// The client resends only its oldest unacked
// command plus a small rotation, so on a lossy
// relay a hole may never fill. Once the command
// after it is overdue, the hole is lost.
let lost = seq.0 > expected
&& cmd.server_time <= time_ms - LOST_COMMAND_HOLD_MS;
if (seq.0 != expected && !lost) || cmd.server_time > time_ms {
return false;
}
expected = expected.wrapping_add(1);
expected = seq.0.wrapping_add(1);
}
true
})
@@ -290,6 +319,7 @@ impl ClientCommandInbox {
}
drained.sort_by_key(|(seq, _, _)| *seq);
if drained.is_empty() {
self.shed_excess(id, time_ms);
if let Some(fault) = self.backlog_fault(id, time_ms) {
out.backlog_faults.push((id, fault));
continue;
@@ -330,6 +360,7 @@ impl ClientCommandInbox {
if let Some(seq) = ack {
out.acks.push((id, seq));
}
self.shed_excess(id, time_ms);
if let Some(fault) = self.backlog_fault(id, time_ms) {
out.backlog_faults.push((id, fault));
}
@@ -14,7 +14,7 @@ pub const CLIENT_ENTITY_SLOT_COUNT: usize = ENTITYNUM_NONE as usize;
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
#[component(immutable)]
#[require(bevy::prelude::DespawnOnExit<frame::MatchScope> = bevy::prelude::DespawnOnExit(frame::MatchScope::Live))]
#[require(bevy::prelude::DespawnOnExit<frame::Timeline> = bevy::prelude::DespawnOnExit(frame::Timeline::Running))]
pub struct CEntity {
number: u16,
client: Option<ClientId>,
@@ -397,9 +397,9 @@ fn sync_snapshot_entities(
}
pub(crate) fn register_authority_entities(app: &mut App) {
app.scoped::<CEntitySlots>(frame::MatchScope::Live)
app.scoped::<CEntitySlots>(frame::Timeline::Running)
.init_resource::<NetIdentityGaps>()
.scoped::<CEntityBirthCensus>(frame::MatchScope::Live)
.scoped::<CEntityBirthCensus>(frame::Timeline::Running)
.add_systems(
FixedUpdate,
sync_authority_entities
@@ -409,9 +409,9 @@ pub(crate) fn register_authority_entities(app: &mut App) {
}
pub fn register_client_entities(app: &mut App) {
app.scoped::<CEntitySlots>(frame::MatchScope::Live)
app.scoped::<CEntitySlots>(frame::Timeline::Running)
.init_resource::<NetIdentityGaps>()
.scoped::<CEntityBirthCensus>(frame::MatchScope::Live)
.scoped::<CEntityBirthCensus>(frame::Timeline::Running)
.add_systems(
Update,
sync_client_entities
@@ -731,7 +731,7 @@ fn set_ads_from_reset(
}
pub fn register_entity_event_dispatch(app: &mut App) {
app.scoped::<EntityEventCursor>(frame::MatchScope::Live)
app.scoped::<EntityEventCursor>(frame::Timeline::Running)
.init_resource::<AppliedEntityEventWalk>()
.init_resource::<UnsupportedEntityEvents>()
.init_resource::<NetIdentityGaps>()
@@ -46,6 +46,7 @@ pub struct PresentLocalCensus {
#[derive(Resource, Clone, Debug, Default)]
pub struct PresentedSnapshot {
external_camera: Option<bool>,
inner: Option<Arc<Snapshot>>,
previous_inner: Option<Arc<Snapshot>>,
frame_interpolation: f32,
@@ -60,12 +61,29 @@ pub struct PresentedSnapshot {
}
impl PresentedSnapshot {
pub fn external_camera(&self) -> bool {
self.external_camera == Some(true)
}
pub fn first_person_camera(&self) -> bool {
self.external_camera == Some(false)
}
pub fn set_first_person_camera(&mut self) {
self.external_camera = Some(false);
}
pub fn set_external_camera(&mut self, external: bool) {
self.external_camera = external.then_some(true);
}
pub fn weapon_epoch(&self) -> Option<u32> {
self.snapshot()
.map(|snapshot| snapshot.meta.world_objects.map_round_epoch)
}
pub fn clear(&mut self) {
self.external_camera = None;
self.inner = None;
self.previous_inner = None;
self.frame_interpolation = 0.0;
+67 -20
View File
@@ -150,9 +150,15 @@ pub struct LastAdoptedSnapshot {
pub next_snap: Option<Arc<Snapshot>>,
pub applied_this_frame: bool,
pub adopted_at: Option<std::time::Instant>,
}
impl LastAdoptedSnapshot {
pub fn since_adopted(&self) -> Option<std::time::Duration> {
self.adopted_at.map(|at| at.elapsed())
}
pub fn next(&self) -> Option<&Snapshot> {
self.next_snap.as_deref()
}
@@ -408,6 +414,7 @@ pub fn advance_cls_realtime(mut cls: ResMut<ClientRealtime>, time: Res<Time<Real
pub fn advance_cg_frame_clock(
mut clock: ResMut<FrameClock>,
client_clock: Res<ClientClock>,
mut prediction: ResMut<ClientPredictionState>,
mut join: ResMut<GameJoinCensus>,
time: Res<Time>,
@@ -427,6 +434,17 @@ pub fn advance_cg_frame_clock(
if *role == RuntimeRole::Listen && cgame_active.0 && was_started {
let server = authority.as_ref().expect("listen authority clock").time_ms;
clock.assign_server_time(server.wrapping_add(fixed.overstep().as_millis() as i32));
} else if *role == RuntimeRole::Replay {
if cgame_active.0
&& let Some(server) = server_time
{
clock.assign_server_time(
server
.ms()
.wrapping_add(AUTHORITY_MS)
.wrapping_add(client_clock.accumulator_ms as i32),
);
}
} else if *role == RuntimeRole::Client {
clock.advance_remote(time.delta_secs(), cgame_active.0, server_time);
} else {
@@ -456,6 +474,7 @@ pub fn receive_ticks(
mut link: Option<ResMut<crate::transport::udp_session::UdpClientLink>>,
loopback: Option<ResMut<ListenLoopback>>,
mut received: ResMut<ReceivedTicks>,
adopted: Res<LastAdoptedSnapshot>,
mut local: ResMut<LocalPresentClient>,
mut prediction: ResMut<ClientPredictionState>,
trace: Option<ResMut<ClientPhaseTrace>>,
@@ -490,7 +509,9 @@ pub fn receive_ticks(
reliable.fail(&error);
}
for payload in link.take_controls() {
reliable.apply(link.assigned_client.unwrap_or(local.0), &payload);
reliable.apply(link.assigned_client.unwrap_or(local.0), &payload, |index| {
adopted.sound_alias_name(index).is_some()
});
}
note_join_link(&mut watch, link);
@@ -573,12 +594,15 @@ pub fn reconcile_prediction(
)
}));
}
reliable.apply(local.0, &tick.frame.reliable);
reliable.apply(local.0, &tick.frame.reliable, |index| {
sim::name_in_occupied(&tick.snapshot.meta.sound_aliases, index).is_some()
});
let ack = tick.ack_for(local.0);
if let Some(acked) = ack {
pending.clear_acknowledged(acked);
}
last_adopted.applied_this_frame = true;
last_adopted.adopted_at = Some(std::time::Instant::now());
if let Some(old_next) = last_adopted.next_snap.take() {
last_adopted.snap = Some(old_next);
}
@@ -675,7 +699,12 @@ impl ReliableInbound<'_> {
));
}
fn apply(&mut self, local: sim::ClientId, payload: &crate::ReliablePayload) {
fn apply(
&mut self,
local: sim::ClientId,
payload: &crate::ReliablePayload,
sound_ready: impl Fn(u8) -> bool,
) {
if payload.dropped_oldest != 0 {
self.fail("ReliableControlOverflow: outcome unknown");
return;
@@ -717,6 +746,12 @@ impl ReliableInbound<'_> {
self.svc.audio.write(crate::SvcScriptAudio(cmd.clone()));
}
crate::ReliableRow::Sound(cmd) => {
// Reliable controls can overtake the snapshot that publishes
// this configuration string. Keep the row unacknowledged so
// retransmission preserves it until its dependency is ready.
if !sound_ready(cmd.index) {
break;
}
self.svc.sound.write(crate::SvcLocalSound {
stop: cmd.stop,
index: cmd.index,
@@ -1783,7 +1818,7 @@ pub fn publish_presented(
.map(|(_, ps)| ps);
let predicted_ps = prediction.0.predicted_local().copied();
let previous = presented.player(local.0).copied();
let armed = prediction.0.is_armed();
let armed = *role != RuntimeRole::Replay && prediction.0.is_armed();
let frame_interpolation = clock.accumulator_ms / AUTHORITY_MS as f32;
let interpolation_previous = if *role == RuntimeRole::Replay {
last_adopted
@@ -1938,6 +1973,14 @@ pub fn publish_presented(
);
}
fn release_timeline(
mut presented: ResMut<PresentedSnapshot>,
mut events: ResMut<Messages<ReliableControlEvent>>,
) {
presented.clear();
events.clear();
}
fn release_client(
mut actions: ResMut<ClientActionInbox>,
mut presented: ResMut<PresentedSnapshot>,
@@ -1964,6 +2007,10 @@ pub fn register_client_runtime(app: &mut App) {
.in_set(frame::ScopeSet::Release)
.after(crate::signon::release_match_protocol),
);
app.add_systems(
OnExit(frame::Timeline::Running),
release_timeline.in_set(frame::ScopeSet::Release),
);
app.add_systems(
Update,
enforce_client_work_limits
@@ -1975,15 +2022,15 @@ pub fn register_client_runtime(app: &mut App) {
.init_resource::<crate::ClientAdmission>()
.scoped::<ClientPredictionState>(frame::MatchScope::Live)
.scoped::<ClientClock>(frame::MatchScope::Live)
.scoped::<FrameClock>(frame::MatchScope::Live)
.scoped::<GameActive>(frame::MatchScope::Live)
.scoped::<GameJoinCensus>(frame::MatchScope::Live)
.scoped::<FrameClock>(frame::Timeline::Running)
.scoped::<GameActive>(frame::Timeline::Running)
.scoped::<GameJoinCensus>(frame::Timeline::Running)
.init_resource::<ClientRealtime>()
.scoped::<ReceivedTicks>(frame::MatchScope::Live)
.scoped::<LastAdoptedSnapshot>(frame::MatchScope::Live)
.scoped::<PresentLocalCensus>(frame::MatchScope::Live)
.scoped::<PendingPresentedEntityEvents>(frame::MatchScope::Live)
.scoped::<PendingPelletFx>(frame::MatchScope::Live)
.scoped::<ReceivedTicks>(frame::Timeline::Running)
.scoped::<LastAdoptedSnapshot>(frame::Timeline::Running)
.scoped::<PresentLocalCensus>(frame::Timeline::Running)
.scoped::<PendingPresentedEntityEvents>(frame::Timeline::Running)
.scoped::<PendingPelletFx>(frame::Timeline::Running)
.scoped::<PendingClientSends>(frame::MatchScope::Live)
.init_resource::<ClientCmdTemplate>()
.scoped::<WeaponSelect>(frame::MatchScope::Live)
@@ -1993,15 +2040,15 @@ pub fn register_client_runtime(app: &mut App) {
.add_message::<ReliableControlEvent>()
.scoped_message::<FireCommandVerdicts>(frame::MatchScope::Live)
.init_resource::<crate::FireVerdictState>()
.scoped::<RemoteProxyState>(frame::MatchScope::Live)
.scoped::<RemoteProxyState>(frame::Timeline::Running)
.scoped::<ClientShotSamples>(frame::MatchScope::Live)
.scoped::<crate::Scoreboard>(frame::MatchScope::Live)
.scoped_message::<crate::SvcLocalSound>(frame::MatchScope::Live)
.scoped_message::<crate::SvcScriptAudio>(frame::MatchScope::Live)
.scoped_message::<crate::SvcCardSlotCmd>(frame::MatchScope::Live)
.scoped_message::<crate::SvcOpenMenuCmd>(frame::MatchScope::Live)
.scoped_message::<crate::SvcHudSplash>(frame::MatchScope::Live)
.scoped_message::<crate::SvcGameNotify>(frame::MatchScope::Live);
.scoped::<crate::Scoreboard>(frame::Timeline::Running)
.scoped_message::<crate::SvcLocalSound>(frame::Timeline::Running)
.scoped_message::<crate::SvcScriptAudio>(frame::Timeline::Running)
.scoped_message::<crate::SvcCardSlotCmd>(frame::Timeline::Running)
.scoped_message::<crate::SvcOpenMenuCmd>(frame::Timeline::Running)
.scoped_message::<crate::SvcHudSplash>(frame::Timeline::Running)
.scoped_message::<crate::SvcGameNotify>(frame::Timeline::Running);
crate::client::frame_census::register_update_phase_census(app);
app.configure_sets(
Update,
+2 -2
View File
@@ -117,7 +117,7 @@ pub use signon::{
phase_from_udp_link,
};
pub use svc_gamenotify::{
PendingGameNotify, SVC_DISCONNECT_NOTIFY, SVC_PRINT, SVC_PRINT_BOLD, SvcGameNotify,
PendingGameNotify, SVC_CHAT, SVC_DISCONNECT_NOTIFY, SVC_PRINT, SVC_PRINT_BOLD, SvcGameNotify,
client_name_string,
};
pub use svc_playercard::{
@@ -174,6 +174,6 @@ pub use transport::wire::{WireError, WireReader, WireWriter};
pub use svc_script_audio::SvcScriptAudio;
pub const PROTOCOL_VERSION: u32 = 114;
pub const PROTOCOL_VERSION: u32 = 116;
pub use client::fire_verdict::{FireVerdictState, PredictedFireStatus};
+10 -5
View File
@@ -5,6 +5,8 @@ use crate::transport::wire::{WireError, WireReader, WireWriter};
pub const SVC_DISCONNECT_NOTIFY: u8 = 0x65;
pub const SVC_CHAT: u8 = 0x68;
pub const SVC_PRINT: u8 = 0x66;
pub const SVC_PRINT_BOLD: u8 = 0x67;
@@ -49,7 +51,9 @@ impl PendingGameNotify {
pub fn adopt_prints(&mut self, effects: &[sim::PendingPrint]) {
for print in effects.iter().cloned() {
let tag = if print.bold {
let tag = if print.chat {
SVC_CHAT
} else if print.bold {
SVC_PRINT_BOLD
} else {
SVC_PRINT
@@ -116,10 +120,11 @@ pub fn decode_svc_game_notifies(
for _ in 0..count {
let id = input.get_u32()?;
let tag = input.get_u8()?;
if !matches!(tag, SVC_DISCONNECT_NOTIFY | SVC_PRINT | SVC_PRINT_BOLD) {
return Err(WireError::Malformed(
"gamenotify tag is not 'e', 'f' or 'g'",
));
if !matches!(
tag,
SVC_DISCONNECT_NOTIFY | SVC_PRINT | SVC_PRINT_BOLD | SVC_CHAT
) {
return Err(WireError::Malformed("unknown game notification tag"));
}
let mut name = vec![0; input.get_u8()? as usize];
input.get_bytes(&mut name)?;
+1 -1
View File
@@ -119,7 +119,7 @@ impl SnapshotEncoder {
}
}
#[derive(Debug, Default)]
#[derive(Clone, Debug, Default)]
pub struct SnapshotDecoder {
baseline: Vec<(ClientId, PlayerState)>,
projectile_baseline: HashMap<ProjectileId, ProjectileState>,
+55 -14
View File
@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::fmt;
use std::net::ToSocketAddrs;
use std::path::PathBuf;
@@ -2690,17 +2690,18 @@ fn apply_peer_event(
now_ms: now_ms(),
},
);
// A peer that reported its map and drew no effect is a peer
// that will wait out the match in the loading screen. Name the
// gate that owes it an offer.
if applied.effects.is_empty() {
// MapLoaded can arrive before AuthorityReady. The core retains
// that report and offers its bootstrap when the host is ready.
// Repeated reports from an admitted peer also need no effect.
if !applied.accepted {
diag::warn!(
Net,
"peer map loaded admits nothing: epoch={epoch} host_phase={:?} host_match={:?} host_world={:?} peer={:?} — no bootstrap offer will follow",
"peer map loaded rejected: epoch={epoch} host_phase={:?} host_match={:?} host_world={:?} peer={:?} transition={}",
host_match.phase(),
host_match.match_key(),
host_match.host_world(),
host_match.peer(member_id).map(|peer| peer.phase),
applied.transition,
);
}
execute_host_match_effects(
@@ -3051,6 +3052,9 @@ async fn gameplay_egress(
is_host: bool,
) -> std::result::Result<(), TransportFault> {
let mut fragmenter = relay_fragmenter();
// Quinn reserves bookkeeping space even for an empty datagram buffer.
let empty_buffer_space = connection.datagram_send_buffer_space();
let mut pending: VecDeque<(MemberId, Vec<u8>)> = VecDeque::new();
let mut tick = tokio::time::interval(QUEUE_POLL);
let mut sampled = tokio::time::Instant::now();
loop {
@@ -3066,29 +3070,65 @@ async fn gameplay_egress(
stats.path.cwnd,
stats.path.lost_packets,
stats.path.congestion_events,
(DATAGRAM_SEND_BUFFER - connection.datagram_send_buffer_space()) as u64,
empty_buffer_space.saturating_sub(connection.datagram_send_buffer_space()) as u64,
);
}
for (member, packet) in mailbox.take_outbound() {
let fragments = match fragmenter.split(&packet) {
// Gameplay packets carry the newest input/snapshot plus
// unacknowledged reliable rows. Replace unsent older state
// for this peer rather than building a latency backlog.
if let Some((_, previous)) = pending.iter_mut().find(|(id, _)| *id == member) {
*previous = packet;
} else {
pending.push_back((member, packet));
}
}
while let Some((member, packet)) = pending.front() {
let fragments = match fragmenter.split(packet) {
Ok(fragments) => fragments,
Err(error) => {
diag::warn!(Net, "master {role} packet not splittable: {error}");
pending.pop_front();
continue;
}
};
for fragment in fragments {
let mut datagrams = Vec::with_capacity(fragments.len());
for fragment in &fragments {
let datagram = if is_host {
RelayDatagram::HostToMember {
member_id: member,
payload: &fragment,
member_id: *member,
payload: fragment,
}
} else {
RelayDatagram::ClientToHost(&fragment)
RelayDatagram::ClientToHost(fragment)
};
let bytes = encode_relay(datagram).map_err(|error| {
datagrams.push(encode_relay(datagram).map_err(|error| {
TransportFault::with_connection("encode_relay", role, error, &connection)
})?;
})?);
}
let wire_bytes = datagrams.iter().map(Vec::len).sum::<usize>();
if wire_bytes > DATAGRAM_SEND_BUFFER {
return Err(TransportFault::with_connection(
"send_datagram",
role,
"fragmented gameplay packet exceeds the send buffer",
&connection,
));
}
let free = connection.datagram_send_buffer_space();
let queued = empty_buffer_space.saturating_sub(free);
let window = connection.stats().path.cwnd as usize;
// Queue complete packets together. Quinn discards oldest
// datagrams when its buffer fills, which can otherwise leave
// every fragmented snapshot incomplete. One packet may exceed
// the congestion window, but never enqueue a history behind it.
if wire_bytes > free
|| (queued != 0 && queued.saturating_add(wire_bytes) > window)
{
break;
}
pending.pop_front();
for bytes in datagrams {
if let Err(error) = connection.send_datagram(bytes.into()) {
return Err(TransportFault::with_connection(
"send_datagram",
@@ -3200,6 +3240,7 @@ async fn connect(
quinn::IdleTimeout::try_from(SESSION_IDLE).expect("session idle fits QUIC VarInt"),
));
transport.keep_alive_interval(Some(SESSION_KEEP_ALIVE));
transport.datagram_send_buffer_size(DATAGRAM_SEND_BUFFER);
transport.max_concurrent_uni_streams(MAX_RELAY_UNI_STREAMS.into());
client_config.transport_config(Arc::new(transport));
+47
View File
@@ -178,6 +178,16 @@ pub(crate) fn encode_action(out: &mut WireWriter, action: &ClientAction) {
out.put_u32(request_id);
out.put_bytes(&name);
}
ClientAction::Chat {
request_id,
team_only,
text,
} => {
out.put_u8(24);
out.put_u32(request_id);
out.put_u8(u8::from(team_only));
out.put_bytes(&text);
}
ClientAction::UseCopycat { request_id } => {
out.put_u8(13);
out.put_u32(request_id);
@@ -293,6 +303,21 @@ pub(crate) fn decode_action(input: &mut WireReader<'_>) -> Result<ClientAction,
killstreaks: [input.get_u32()?, input.get_u32()?, input.get_u32()?],
},
}),
24 => {
let request_id = input.get_u32()?;
let team_only = match input.get_u8()? {
0 => false,
1 => true,
_ => return Err(WireError::Malformed("invalid chat audience")),
};
let mut text = [0; sim::chat::CHAT_BYTES];
input.get_bytes(&mut text)?;
Ok(ClientAction::Chat {
request_id,
team_only,
text,
})
}
13 => Ok(ClientAction::UseCopycat {
request_id: input.get_u32()?,
}),
@@ -2623,6 +2648,14 @@ fn encode_entity_dobjs(
out.put_u8(u8::from(model.ignore_collision));
}
out.put_u32(state.pose_revision);
debug_assert!(state.tag_rotations.len() <= u16::MAX as usize);
out.put_u16(state.tag_rotations.len() as u16);
for rotation in &state.tag_rotations {
put_text(out, &rotation.tag);
for angle in rotation.angles {
out.put_f32(angle);
}
}
put_optional_part_bits(out, state.requested_parts);
for word in state.hide_part_bits.words() {
out.put_u32(*word);
@@ -2685,6 +2718,19 @@ fn decode_entity_dobjs(
});
}
let pose_revision = input.get_u32()?;
let rotation_count = input.get_u16()? as usize;
if rotation_count > anim_iw4::PartBits::CAPACITY {
return Err(WireError::Malformed("too many tag rotations"));
}
let mut tag_rotations = Vec::with_capacity(rotation_count);
for _ in 0..rotation_count {
let tag = get_text(input)?;
let angles = [input.get_f32()?, input.get_f32()?, input.get_f32()?];
if tag.is_empty() || !angles.iter().all(|angle| angle.is_finite()) {
return Err(WireError::Malformed("invalid tag rotation"));
}
tag_rotations.push(xmodel_runtime::TagRotation { tag, angles });
}
let requested_parts = get_optional_part_bits(input)?;
let mut hide_words = [0u32; anim_iw4::PartBits::WORDS];
for word in &mut hide_words {
@@ -2746,6 +2792,7 @@ fn decode_entity_dobjs(
models,
},
pose_revision,
tag_rotations,
tree,
requested_parts,
hide_part_bits: xmodel_runtime::HidePartBits::from_words(hide_words),
+61 -5
View File
@@ -208,6 +208,52 @@ struct PeerReplicationState {
last_control_seq: Option<u16>,
control_drops_sent: u32,
account: Option<PeerAccount>,
pace: SnapshotPace,
held_ack: Option<(ClientId, CmdSeq)>,
}
const SNAPSHOT_RATE_BYTES_PER_SEC: i64 = 25_000;
const SNAPSHOT_PACKET_OVERHEAD: i64 = 48;
const SNAPSHOT_CREDIT_PER_TICK: i64 =
SNAPSHOT_RATE_BYTES_PER_SEC * crate::authority::inbox::AUTHORITY_MS as i64 / 1000;
const STALE_BASELINE_SNAPSHOTS: u32 = 29;
const MAX_STALE_BACKOFF: u32 = 4;
#[derive(Debug, Default)]
struct SnapshotPace {
credit: i64,
refilled: Option<Tick>,
backoff: u32,
stale_until: Option<Tick>,
}
impl SnapshotPace {
fn ready(&mut self, tick: Tick, unacked: Option<u32>) -> bool {
let elapsed = self
.refilled
.map_or(1, |last| i64::from(tick.0.saturating_sub(last.0)));
self.refilled = Some(tick);
self.credit =
(self.credit + elapsed * SNAPSHOT_CREDIT_PER_TICK).min(SNAPSHOT_CREDIT_PER_TICK);
if self.credit <= 0 {
return false;
}
if unacked.is_none_or(|age| age < STALE_BASELINE_SNAPSHOTS) {
self.backoff = 0;
self.stale_until = None;
return true;
}
if self.stale_until.is_some_and(|until| tick.0 < until.0) {
return false;
}
self.stale_until = Some(Tick(tick.0.saturating_add(1 << self.backoff)));
self.backoff = (self.backoff + 1).min(MAX_STALE_BACKOFF);
true
}
fn charge(&mut self, bytes: usize) {
self.credit -= bytes as i64 + SNAPSHOT_PACKET_OVERHEAD;
}
}
#[derive(Debug)]
@@ -240,6 +286,8 @@ impl PeerReplicationState {
last_control_seq: None,
control_drops_sent: 0,
account: None,
pace: SnapshotPace::default(),
held_ack: None,
}
}
@@ -970,6 +1018,17 @@ impl UdpAuthorityHub {
{
continue;
}
if let Some(ack) = acks.iter().rev().find(|(id, _)| *id == client) {
peer.held_ack = Some(*ack);
}
let via_bootstrap = baseline_seq == 0
&& self.bootstrap.is_some()
&& !matches!(peer.admission, PeerAdmission::Committed { .. });
let unacked =
(baseline_seq != 0).then(|| peer.next_snap_seq.wrapping_sub(baseline_seq));
if !via_bootstrap && !peer.pace.ready(snapshot.tick, unacked) {
continue;
}
let mut encoder = std::mem::take(&mut peer.encoder);
if baseline_seq == 0 {
peer.last_full = Some(snapshot.tick);
@@ -977,11 +1036,7 @@ impl UdpAuthorityHub {
} else if let Some(baseline) = peer.baseline.baseline_for_encode() {
encoder.adopt_baseline(baseline);
}
let peer_acks: Vec<(ClientId, CmdSeq)> = acks
.iter()
.copied()
.filter(|(id, _)| *id == client)
.collect();
let peer_acks: Vec<(ClientId, CmdSeq)> = peer.held_ack.take().into_iter().collect();
let (peer_snap, meta) = match for_peer(client, snapshot) {
Some(own) => {
let meta = Arc::new(WireMeta::without_reliable_events(&own.meta));
@@ -1116,6 +1171,7 @@ impl UdpAuthorityHub {
if let Err(e) = self.send_outgoing(target, &bytes) {
last_err = Some(e);
} else if let Some(peer) = self.replication.get_mut(&conn) {
peer.pace.charge(bytes.len());
peer.sent_ticks.push_back(snapshot.tick);
while peer.sent_ticks.len() > 32 {
peer.sent_ticks.pop_front();
+1 -1
View File
@@ -39,7 +39,7 @@ impl WorldObjectSyncEncoder {
}
}
#[derive(Debug, Default)]
#[derive(Clone, Debug, Default)]
pub struct WorldObjectSyncDecoder {
state: WorldObjectSnapshot,
}
-5
View File
@@ -389,10 +389,6 @@ pub fn seal_render_frame(
1u32,
),
};
let xmodel_packed_segments = xmodel
.as_ref()
.map(|plan| plan.packed_segments)
.unwrap_or_default();
let xmodel_revision = xmodel.as_ref().map(|plan| plan.revision).unwrap_or(0);
let xmodel_topology_revision = xmodel
.as_ref()
@@ -615,7 +611,6 @@ pub fn seal_render_frame(
fx_revision,
xmodel_revision,
xmodel_topology_revision,
xmodel_packed_segments,
particle_cloud_vertices,
particle_cloud_indices,
particle_cloud_surface_ranges,
+1 -3
View File
@@ -499,9 +499,7 @@ impl FpvPreparationJob {
|| camo.skel.surface_vertex_ranges != base.skel.surface_vertex_ranges
|| camo.skel.surface_index_ranges != base.skel.surface_index_ranges
|| camo.skel.positions != base.skel.positions
|| camo.skel.normals != base.skel.normals
|| camo.skel.uvs != base.skel.uvs
|| camo.skel.colors != base.skel.colors
|| !camo.skel.has_same_vertex_attributes(&base.skel)
|| camo.skel.indices != base.skel.indices
{
return Err("appearance topology mismatch".to_owned());
+29 -23
View File
@@ -90,10 +90,12 @@ fn blend_skel_vertices_inner<const DECODED: bool>(
) -> BlendedSkel {
let (active_vert, rigid_vert) =
vertex_stream_masks(skel, lod, surface_is_visible, surface_rigid);
let mut positions = DECODED.then(|| skel.positions.clone()).unwrap_or_default();
let mut normals = DECODED.then(|| skel.normals.clone()).unwrap_or_default();
let mut positions = DECODED.then(|| skel.positions.to_vec()).unwrap_or_default();
let mut normals = DECODED
.then(|| skel.normals().collect::<Vec<_>>())
.unwrap_or_default();
let mut posed_packed =
(skel.packed_vertices.len() == skel.positions.len()).then(|| skel.packed_vertices.clone());
(skel.packed_vertices.len() == skel.positions.len()).then(|| skel.packed_vertices.to_vec());
let max_bone = skel
.vert_skin
.iter()
@@ -102,7 +104,7 @@ fn blend_skel_vertices_inner<const DECODED: bool>(
.flat_map(|(skin, _)| {
std::iter::once(skin.bones[0]).chain(
(1..4)
.filter(|&extra| skin.weights[extra] > 0.0)
.filter(|&extra| skin.has_extra_weight(extra))
.map(|extra| skin.bones[extra]),
)
})
@@ -120,7 +122,7 @@ fn blend_skel_vertices_inner<const DECODED: bool>(
continue;
}
let p = Vec3::from_array(skel.positions[i]);
let n = DECODED.then(|| Vec3::from_array(skel.normals[i]));
let n = DECODED.then(|| Vec3::from_array(normals[i]));
let (mut posed_position, mut posed_normal, primary_basis) =
if rigid_vert.get(i).copied().unwrap_or(false) {
@@ -135,12 +137,12 @@ fn blend_skel_vertices_inner<const DECODED: bool>(
let mut extras = [(primary_esi, 0u16); 3];
let mut extra_n = 0usize;
for extra in 1..4 {
if skin.weights[extra] <= 0.0 {
if !skin.has_extra_weight(extra) {
continue;
}
extras[extra_n] = (
&bone_cache[usize::from(skin.bones[extra])].1,
skin.weight_u16[extra],
skin.extra_weight(extra),
);
extra_n += 1;
}
@@ -154,7 +156,7 @@ fn blend_skel_vertices_inner<const DECODED: bool>(
*primary,
)
};
if posed_position == Vec3::ZERO && skin.weights[0] == 0.0 {
if posed_position == Vec3::ZERO && skin.primary_is_zero() {
posed_position = p;
posed_normal = n;
}
@@ -247,9 +249,9 @@ pub(crate) fn meshes_from_blended(
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, skel.uvs.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, skel.colors.clone());
mesh.insert_indices(Indices::U32(skel.indices.clone()));
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, skel.uvs().collect::<Vec<_>>());
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, skel.colors().collect::<Vec<_>>());
mesh.insert_indices(Indices::U32(skel.indices.to_vec()));
return Some(vec![PosedModelSurface {
surface_index: 0,
mesh,
@@ -290,8 +292,7 @@ pub(crate) fn meshes_from_blended(
let vertex_end = first_vertex.checked_add(vertex_count)?;
let index_end = index_start.checked_add(index_count)?;
if vertex_end > positions.len()
|| vertex_end > skel.uvs.len()
|| vertex_end > skel.colors.len()
|| vertex_end > skel.packed_vertices.len()
|| vertex_end > normals.len()
|| index_end > skel.indices.len()
{
@@ -328,11 +329,17 @@ pub(crate) fn meshes_from_blended(
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_UV_0,
skel.uvs[first_vertex..vertex_end].to_vec(),
skel.uvs()
.skip(first_vertex)
.take(vertex_count)
.collect::<Vec<_>>(),
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_COLOR,
skel.colors[first_vertex..vertex_end].to_vec(),
skel.colors()
.skip(first_vertex)
.take(vertex_count)
.collect::<Vec<_>>(),
);
mesh.insert_indices(Indices::U32(local_indices));
let packed_vertices = posed_packed
@@ -380,7 +387,7 @@ pub fn build_skin_layout(
return Some(SkinLayout {
surfaces: Vec::new(),
dest_vertex_n: skel.packed_vertices.len(),
indices: skel.indices.clone(),
indices: skel.indices.to_vec(),
});
}
if skel.surface_index_ranges.len() != surface_count {
@@ -459,7 +466,7 @@ pub fn skin_packed_into(
.flat_map(|(skin, _)| {
std::iter::once(skin.bones[0]).chain(
(1..4)
.filter(|&extra| skin.weights[extra] > 0.0)
.filter(|&extra| skin.has_extra_weight(extra))
.map(|extra| skin.bones[extra]),
)
})
@@ -495,12 +502,12 @@ pub fn skin_packed_into(
let mut extras = [(primary_esi, 0u16); 3];
let mut extra_n = 0usize;
for extra in 1..4 {
if skin.weights[extra] <= 0.0 {
if !skin.has_extra_weight(extra) {
continue;
}
extras[extra_n] = (
&bone_cache[usize::from(skin.bones[extra])].1,
skin.weight_u16[extra],
skin.extra_weight(extra),
);
extra_n += 1;
}
@@ -513,7 +520,7 @@ pub fn skin_packed_into(
*primary,
)
};
let posed_position = if posed_position == Vec3::ZERO && skin.weights[0] == 0.0 {
let posed_position = if posed_position == Vec3::ZERO && skin.primary_is_zero() {
p
} else {
posed_position
@@ -546,7 +553,7 @@ pub fn smodel_surfaces_from_blended(
return Some(vec![PosedSmodelSurface {
surface_index: 0,
vert_n: vertex_n,
indices: skel.indices.clone(),
indices: skel.indices.to_vec(),
packed_vertices: posed_packed.unwrap_or_default(),
}]);
}
@@ -567,8 +574,7 @@ pub fn smodel_surfaces_from_blended(
let vertex_end = first_vertex.checked_add(vertex_count)?;
let index_end = index_start.checked_add(index_count)?;
if vertex_end > vertex_n
|| vertex_end > skel.uvs.len()
|| vertex_end > skel.colors.len()
|| vertex_end > skel.packed_vertices.len()
|| index_end > skel.indices.len()
{
return None;
@@ -1273,10 +1273,10 @@ pub fn register_fpv_present_systems(app: &mut App) {
.scoped::<SessionViewmodel>(frame::MatchScope::Live)
.scoped::<PreparedFpv>(frame::MatchScope::Live)
.scoped::<crate::anim::model_materials::PreparedModelMaterials>(frame::MatchScope::Live)
.scoped::<SessionViewKick>(frame::MatchScope::Live)
.scoped::<SessionViewKick>(frame::Timeline::Running)
.scoped::<GunOffset>(frame::MatchScope::Live)
.scoped::<ViewweaponAim>(frame::MatchScope::Live)
.scoped::<PendingViewHurt>(frame::MatchScope::Live)
.scoped::<PendingViewHurt>(frame::Timeline::Running)
.scoped::<FpvStatusGap>(frame::MatchScope::Live)
.init_app::<RenderPresentationGaps>()
.add_systems(
@@ -76,7 +76,7 @@ struct ScriptModelPoseLocals<'w, 's> {
remap: Local<'s, Vec<Option<usize>>>,
lod_skinned: Res<'w, render_scene::LodRampSkinnedDvar>,
gfx_scene: Res<'w, HostGfxScene>,
sun_casters: Res<'w, render_scene::SunShadowCasterVolume>,
sun_casters: Res<'w, render_scene::PreparedSunShadowFrame>,
}
#[derive(SystemParam)]
@@ -636,9 +636,6 @@ fn pose_script_models(
let entnum = owner.gentity_number.map(u32::from);
let slot = entnum.map(|entnum| script_scene_slot(&gfx_scene.scene, entnum));
// Out of the camera's walk, a model can still shade what the camera sees (laundry
// above the view on the wall below it): it stays, for the sun shadow only, while it is
// inside a sun partition.
let walk_hidden = entnum.is_some_and(|entnum| gfx_scene.scene.scene_ent_hidden(entnum));
if let Some(why) = slot.as_ref().and_then(|slot| slot.skip)
&& !walk_hidden
@@ -17,6 +17,12 @@ pub fn presented_is_third_person(
in_killcam: bool,
third_person: bool,
) -> bool {
if presented.external_camera() {
return true;
}
if presented.first_person_camera() {
return false;
}
let Some(ps) = presented.player(local) else {
return false;
};
@@ -230,6 +230,27 @@ pub fn sync_camera_from_presented(
death_cam_clip: Res<crate::occupancy::dyn_ent::DynEntPhysClip>,
) {
kick.killcam_focus_distance = None;
if let Some(view) = sim_cam.view_override {
for mut transform in &mut q {
*transform = view.transform;
}
for mut projection in &mut lenses {
if let Projection::Perspective(perspective) = &mut *projection {
perspective.fov = horizontal_to_vertical_fov_deg(view.horizontal_fov).to_radians();
}
}
kick.horiz_fov_deg = view.horizontal_fov;
kick.refdef_vieworg = view.transform.translation.to_array();
let forward = *view.transform.forward();
let right = *view.transform.right();
let up = *view.transform.up();
kick.refdef_view_angles = [
-forward.z.clamp(-1.0, 1.0).asin().to_degrees(),
forward.y.atan2(forward.x).to_degrees(),
(-right.z).atan2(up.z).to_degrees(),
];
return;
}
if !sim_cam.enabled {
return;
}
-4
View File
@@ -6,9 +6,6 @@ pub struct RenderAnimPlugin;
impl Plugin for RenderAnimPlugin {
fn build(&self, app: &mut App) {
app.scoped::<crate::anim::scene_submission::AnimDObjSceneSkels>(frame::MatchScope::Live)
.scoped::<crate::anim::remote_body::RemoteBodyTrees>(frame::MatchScope::Live)
.scoped::<crate::anim::remote_body::RemoteSkinPoseHashes>(frame::MatchScope::Live)
.scoped::<crate::anim::remote_body::RemoteBodySkinnedQueue>(frame::MatchScope::Live)
.scoped::<crate::anim::fpv_host::FpvPresentCursor>(frame::MatchScope::Live)
.scoped::<crate::anim::fpv_host::PendingFpvSpawn>(frame::MatchScope::Live)
.scoped::<crate::anim::fpv_host::PendingFpvNotetracks>(frame::MatchScope::Live)
@@ -17,7 +14,6 @@ impl Plugin for RenderAnimPlugin {
.scoped::<crate::anim::fpv_host::FpvBoltTargets>(frame::MatchScope::Live)
.scoped::<crate::anim::fpv_host::FpvPoseProduct>(frame::MatchScope::Live)
.scoped::<crate::draw::FpvDrawPlan>(frame::MatchScope::Live)
.scoped::<crate::draw::RemoteBodyDrawPlan>(frame::MatchScope::Live)
.scoped::<crate::draw::ScriptModelDrawPlan>(frame::MatchScope::Live)
.scoped::<crate::draw::MissileDrawPlan>(frame::MatchScope::Live)
.scoped::<crate::draw::ItemDrawPlan>(frame::MatchScope::Live)
@@ -550,6 +550,20 @@ fn overlay_draw_obj_only(
);
}
}
RetainedDrawKind::Glass {
lighting_handle, ..
}
| RetainedDrawKind::MarkMesh {
lighting_handle, ..
} => {
overlay_smodel_tess_code_constants(
scratch,
lighting_handle,
inv_image_height,
None,
need,
);
}
_ => {}
}
}

Some files were not shown because too many files have changed in this diff Show More