From 08b4224efdc82fa13dcf3bcb08ab0ac30d1d5c63 Mon Sep 17 00:00:00 2001 From: vtroc7 Date: Sun, 11 Oct 2026 21:43:23 +0300 Subject: [PATCH] Add Theater editing and chat, improve controller UI, audio, combat and network recovery Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 306 +-- Cargo.toml | 2 + THIRD-PARTY-LICENSES.txt | 362 +++- crates/approved_tests/README.md | 2 + crates/approved_tests/src/main.rs | 13 + .../src/scenarios/heavy_gameplay_lifecycle.rs | 2 + crates/asset_audio/src/playback_policy.rs | 2 +- .../src/playback_policy/compiler/t5.rs | 2 +- .../src/playback_policy/compiler/t6.rs | 4 +- crates/asset_game/src/menu_catalog.rs | 7 + crates/asset_game/src/weapon_catalog.rs | 2 + .../src/weapon_catalog/capture_t5.rs | 11 +- .../src/weapon_catalog/publication.rs | 1 + .../asset_game/src/weapon_catalog/registry.rs | 24 +- crates/asset_model/src/model_skel.rs | 321 +-- crates/asset_model/src/model_skel_t6.rs | 41 +- crates/asset_transport/src/discover.rs | 3 - crates/asset_world/src/world_t6.rs | 21 +- crates/assets/src/lane/sink.rs | 2 +- crates/audio/Cargo.toml | 2 +- crates/audio/src/cue_execution.rs | 1 + crates/audio/src/device.rs | 27 +- crates/audio/src/entity_events.rs | 89 +- crates/audio/src/lib.rs | 1 + crates/audio/src/media.rs | 2 + crates/audio/src/mix_bus.rs | 155 ++ crates/audio/src/playback.rs | 16 +- crates/audio/src/render_core.rs | 19 +- crates/bootstrap/Cargo.toml | 1 + crates/bootstrap/src/launch.rs | 8 +- crates/bootstrap/src/plugins.rs | 1 + crates/console/assets/default_controls.cfg | 3 + crates/console/src/binds.rs | 55 +- crates/console/src/binds/key_names.rs | 2 +- crates/console/src/chat.rs | 406 ++++ crates/console/src/diagnostics.rs | 2 +- crates/console/src/feature_dispatch/mod.rs | 4 + crates/console/src/feature_dispatch/replay.rs | 47 +- crates/console/src/frontend.rs | 45 +- crates/console/src/gamepad.rs | 32 +- crates/console/src/lib.rs | 1 + crates/console/src/plugin/mod.rs | 48 +- crates/console/src/user_settings.rs | 7 +- crates/console/src/weapon_dispatch.rs | 56 +- crates/fastfile_t5/src/load/weapon.rs | 6 +- crates/fastfile_t5/src/zone.rs | 1 + crates/frame/src/lib.rs | 25 +- crates/frame/src/pad.rs | 66 +- crates/frame/src/scope.rs | 83 +- crates/frame/src/screen_effects.rs | 3 + crates/frame/src/session.rs | 15 + crates/frame/src/settings.rs | 4 +- crates/frame/src/ui/menu.rs | 4 + crates/frame/src/ui/mod.rs | 3 +- crates/hud/Cargo.toml | 1 + crates/hud/icons/xbox/a.svg | 1 + crates/hud/icons/xbox/b.svg | 1 + crates/hud/icons/xbox/dpad_down.svg | 1 + crates/hud/icons/xbox/dpad_left.svg | 1 + crates/hud/icons/xbox/dpad_right.svg | 1 + crates/hud/icons/xbox/dpad_up.svg | 1 + crates/hud/icons/xbox/x.svg | 1 + crates/hud/icons/xbox/y.svg | 1 + crates/hud/src/blood.rs | 8 + crates/hud/src/chrome.rs | 10 +- crates/hud/src/connection_notice.rs | 105 + crates/hud/src/controller_icons.rs | 37 + crates/hud/src/damage_direction.rs | 203 ++ crates/hud/src/draw2d.rs | 37 + crates/hud/src/gpu_list.rs | 2 + crates/hud/src/images.rs | 19 + crates/hud/src/killfeed.rs | 9 +- crates/hud/src/lib.rs | 5 + crates/hud/src/menus/host.rs | 22 + crates/hud/src/menus/mod.rs | 61 +- crates/hud/src/plugin.rs | 86 +- crates/hud/src/splash.rs | 47 +- crates/iw4l-master/src/main.rs | 56 +- crates/master_protocol/src/lib.rs | 2 +- crates/net/src/authority/inbox.rs | 79 +- .../net/src/client/presentation/entities.rs | 10 +- .../presentation/entity_event_dispatch.rs | 2 +- .../net/src/client/presentation/presented.rs | 18 + crates/net/src/client/runtime.rs | 87 +- crates/net/src/lib.rs | 4 +- crates/net/src/svc_gamenotify.rs | 15 +- crates/net/src/transport/delta.rs | 2 +- crates/net/src/transport/master.rs | 69 +- crates/net/src/transport/meta_wire.rs | 47 + crates/net/src/transport/udp_session.rs | 66 +- crates/net/src/transport/world_sync.rs | 2 +- crates/render/src/extract.rs | 5 - crates/render_anim/src/anim/fpv_prepared.rs | 4 +- crates/render_anim/src/anim/xmodel_pose.rs | 52 +- .../render_anim/src/occupancy/fpv_present.rs | 4 +- .../render_anim/src/occupancy/script_model.rs | 5 +- .../render_anim/src/occupancy/third_person.rs | 6 + crates/render_anim/src/occupancy/view_kick.rs | 21 + crates/render_anim/src/plugin.rs | 4 - crates/render_backend/src/material_exec.rs | 14 + crates/render_frame/src/lib.rs | 5 +- crates/render_frame/src/products.rs | 41 - crates/render_frame/src/sun_shadow.rs | 6 +- .../src/adapters/fx/entity_mark.rs | 8 +- .../render_frontend/src/adapters/fx/system.rs | 2 +- .../src/assemble/drawsurf/command_context.rs | 62 +- .../src/assemble/drawsurf/film_vision_view.rs | 26 +- .../src/assemble/drawsurf/sun_shadow.rs | 52 +- .../src/assemble/drawsurf/tess/mod.rs | 22 +- .../src/assemble/drawsurf/tess/smodel.rs | 5 - .../src/assemble/drawsurf/tess/xmodel.rs | 214 +- crates/render_frontend/src/prepare/mod.rs | 5 +- .../src/prepare/scene/cell_frustum_cmds.rs | 11 +- .../src/prepare/scene/spawn.rs | 19 - .../src/prepare/scene/world_plan.rs | 15 +- crates/render_fx/src/combat.rs | 15 +- crates/render_fx/src/model_draw.rs | 16 +- .../src/drawsurf/colour_submit/geometry.rs | 110 +- .../src/drawsurf/colour_submit/pipeline.rs | 9 +- .../drawsurf/colour_submit/prepare_camera.rs | 6 +- .../src/drawsurf/colour_submit/publication.rs | 1 - .../src/drawsurf/colour_submit/record.rs | 8 +- crates/render_scene/Cargo.toml | 1 + crates/render_scene/src/camera.rs | 7 + crates/render_scene/src/lib.rs | 6 +- crates/render_scene/src/plugin.rs | 4 +- crates/render_scene/src/xmodel_draw.rs | 17 +- crates/replay/src/file.rs | 47 +- crates/replay/src/lib.rs | 8 +- crates/replay/src/plugin.rs | 163 +- crates/replay/src/session.rs | 93 +- crates/session/src/lifecycle.rs | 10 +- crates/session/src/match_apply.rs | 10 + crates/session/src/scope_control.rs | 2 +- crates/session/src/weapon_content.rs | 1 + crates/sim/src/bullet_collision.rs | 12 + crates/sim/src/chat.rs | 104 + crates/sim/src/input.rs | 7 + crates/sim/src/lib.rs | 1 + crates/sim/src/script/host/hud.rs | 46 +- crates/sim/src/script/host/match_data.rs | 2 +- crates/sim/src/script/host/players.rs | 17 + crates/sim/src/script/host/presence.rs | 24 + crates/sim/src/script/host/turrets.rs | 52 + crates/sim/src/script_player.rs | 10 +- crates/sim/src/step.rs | 14 + crates/sim/src/world/events.rs | 1 + crates/theater/Cargo.toml | 25 + crates/theater/src/browser.rs | 364 ++++ crates/theater/src/editor.rs | 1737 +++++++++++++++++ crates/theater/src/lib.rs | 7 + crates/theater/src/navigation.rs | 223 +++ crates/theater/src/project.rs | 305 +++ crates/ui/menus/controller.json | 12 - crates/ui/menus/frontend.json | 71 +- crates/ui/menus/settings.json | 6 +- crates/ui/src/classes/icons.rs | 3 +- crates/ui/src/menu.rs | 12 + crates/xmodel_runtime/src/lib.rs | 2 +- crates/xmodel_runtime/src/retained.rs | 17 + crates/xmodel_runtime/src/semantic.rs | 4 + docs/INDEX.md | 2 + docs/KILLCAM.md | 2 +- docs/KILLSTREAKS.md | 37 + docs/RUN.md | 10 + docs/THEATER.md | 50 + 166 files changed, 6499 insertions(+), 1309 deletions(-) create mode 100644 crates/audio/src/mix_bus.rs create mode 100644 crates/console/src/chat.rs create mode 100644 crates/hud/icons/xbox/a.svg create mode 100644 crates/hud/icons/xbox/b.svg create mode 100644 crates/hud/icons/xbox/dpad_down.svg create mode 100644 crates/hud/icons/xbox/dpad_left.svg create mode 100644 crates/hud/icons/xbox/dpad_right.svg create mode 100644 crates/hud/icons/xbox/dpad_up.svg create mode 100644 crates/hud/icons/xbox/x.svg create mode 100644 crates/hud/icons/xbox/y.svg create mode 100644 crates/hud/src/connection_notice.rs create mode 100644 crates/hud/src/controller_icons.rs create mode 100644 crates/hud/src/damage_direction.rs create mode 100644 crates/sim/src/chat.rs create mode 100644 crates/theater/Cargo.toml create mode 100644 crates/theater/src/browser.rs create mode 100644 crates/theater/src/editor.rs create mode 100644 crates/theater/src/lib.rs create mode 100644 crates/theater/src/navigation.rs create mode 100644 crates/theater/src/project.rs create mode 100644 docs/KILLSTREAKS.md create mode 100644 docs/THEATER.md diff --git a/Cargo.lock b/Cargo.lock index 0c0c9e4..a16680e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d861f5d..3759267 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index f099635..98c4bef 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -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) -------------------------------------------------------------------------------- diff --git a/crates/approved_tests/README.md b/crates/approved_tests/README.md index f7974cb..a79144d 100644 --- a/crates/approved_tests/README.md +++ b/crates/approved_tests/README.md @@ -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 diff --git a/crates/approved_tests/src/main.rs b/crates/approved_tests/src/main.rs index a11aa57..10b7be0 100644 --- a/crates/approved_tests/src/main.rs +++ b/crates/approved_tests/src/main.rs @@ -328,6 +328,18 @@ fn run(root: &Path, args: &Args) -> Result { }); } + 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 { args: child_args, env: vec![ ("IW4L_PERF", "1".into()), + ("IW4L_PRESENT_MODE", "AutoNoVsync".into()), ("IW4L_GAMETYPE", SCENARIO.gametype.into()), ( "IW4L_SETTINGS_PATH", diff --git a/crates/approved_tests/src/scenarios/heavy_gameplay_lifecycle.rs b/crates/approved_tests/src/scenarios/heavy_gameplay_lifecycle.rs index e9bf1f3..9bfc703 100644 --- a/crates/approved_tests/src/scenarios/heavy_gameplay_lifecycle.rs +++ b/crates/approved_tests/src/scenarios/heavy_gameplay_lifecycle.rs @@ -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", diff --git a/crates/asset_audio/src/playback_policy.rs b/crates/asset_audio/src/playback_policy.rs index a09a0ab..6d0d5b6 100644 --- a/crates/asset_audio/src/playback_policy.rs +++ b/crates/asset_audio/src/playback_policy.rs @@ -95,7 +95,7 @@ pub enum SecondaryActivation { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SecondaryPolicySource { - T5IndependentCompatibility, + IndependentLayers, PrimaryPreparedCompatibility, } diff --git a/crates/asset_audio/src/playback_policy/compiler/t5.rs b/crates/asset_audio/src/playback_policy/compiler/t5.rs index 8c90f0a..997efa4 100644 --- a/crates/asset_audio/src/playback_policy/compiler/t5.rs +++ b/crates/asset_audio/src/playback_policy/compiler/t5.rs @@ -23,7 +23,7 @@ impl CueCompiler for T5CueCompiler { streamed_decode: crate::StreamedDecodePolicy::WmaContainerWithWaveCompatibility, secondary: ( SecondaryActivation::OnResolution, - SecondaryPolicySource::T5IndependentCompatibility, + SecondaryPolicySource::IndependentLayers, ), } } diff --git a/crates/asset_audio/src/playback_policy/compiler/t6.rs b/crates/asset_audio/src/playback_policy/compiler/t6.rs index d2cccda..07a16c0 100644 --- a/crates/asset_audio/src/playback_policy/compiler/t6.rs +++ b/crates/asset_audio/src/playback_policy/compiler/t6.rs @@ -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, ), } } diff --git a/crates/asset_game/src/menu_catalog.rs b/crates/asset_game/src/menu_catalog.rs index e964b31..6b513ef 100644 --- a/crates/asset_game/src/menu_catalog.rs +++ b/crates/asset_game/src/menu_catalog.rs @@ -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); diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index 4e20779..814f65a 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -1329,6 +1329,7 @@ pub struct T6AttachmentModel { struct WeaponRow { preparation: WeaponPreparationRecipe, name: String, + native_script_name: String, alternate_weapon: Option, impact_payload: Option, 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, diff --git a/crates/asset_game/src/weapon_catalog/capture_t5.rs b/crates/asset_game/src/weapon_catalog/capture_t5.rs index e098a4d..11bc413 100644 --- a/crates/asset_game/src/weapon_catalog/capture_t5.rs +++ b/crates/asset_game/src/weapon_catalog/capture_t5.rs @@ -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, diff --git a/crates/asset_game/src/weapon_catalog/publication.rs b/crates/asset_game/src/weapon_catalog/publication.rs index da6a765..ee7ebb6 100644 --- a/crates/asset_game/src/weapon_catalog/publication.rs +++ b/crates/asset_game/src/weapon_catalog/publication.rs @@ -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, diff --git a/crates/asset_game/src/weapon_catalog/registry.rs b/crates/asset_game/src/weapon_catalog/registry.rs index d443e7b..ec6dfcc 100644 --- a/crates/asset_game/src/weapon_catalog/registry.rs +++ b/crates/asset_game/src/weapon_catalog/registry.rs @@ -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 { @@ -920,6 +930,18 @@ impl WeaponRegistry { gsc_weapon_script_name(self.name_of(index)) } + pub fn native_script_aliases(&self) -> impl Iterator + '_ { + 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; diff --git a/crates/asset_model/src/model_skel.rs b/crates/asset_model/src/model_skel.rs index 0c74fdc..cf81fb9 100644 --- a/crates/asset_model/src/model_skel.rs +++ b/crates/asset_model/src/model_skel.rs @@ -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, pub pose: Option, - pub positions: Vec<[f32; 3]>, - pub normals: Vec<[f32; 3]>, - pub colors: Vec<[f32; 4]>, - pub uvs: Vec<[f32; 2]>, - pub indices: Vec, + pub positions: Arc<[[f32; 3]]>, + pub indices: Arc<[u32]>, pub surface_materials: Vec>, @@ -77,11 +80,11 @@ pub struct ModelSkel { pub surface_vert_list_count: Vec>, - pub vert_skin: Vec, + 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, @@ -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 + '_ { + self.packed_vertices.iter().map(vertex_normal) + } + + pub fn colors(&self) -> impl ExactSizeIterator + '_ { + self.packed_vertices + .iter() + .map(|v| unpack_color(vertex_word(v, 16))) + } + + pub fn uvs(&self) -> impl ExactSizeIterator + '_ { + self.packed_vertices + .iter() + .map(|v| unpack_packed_tex_coords(vertex_word(v, 20))) + } + pub fn retained_capability(&self) -> Option { 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 for TagViewBind { #[derive(Clone, Debug, Default)] pub struct SharedXModelSurfaces( pub std::collections::BTreeMap, usize)>, + HashMap>>, ); impl SharedXModelSurfaces { + pub fn share_geometry(&mut self, mut skel: ModelSkel) -> Arc { + 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> { let mut skins = vec![VertSkin::default(); vertex_count]; - let bone_at = |raw: u16| -> Option { + let bone_at = |raw: u16| -> Option { 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> { let mut skins = vec![VertSkin::default(); vertex_count]; - let bone_at = |raw: u16| -> Option { + let bone_at = |raw: u16| -> Option { 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 { + let bone_at = |raw: u16| -> Option { 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; } diff --git a/crates/asset_model/src/model_skel_t6.rs b/crates/asset_model/src/model_skel_t6.rs index 9aa7f89..351ef1c 100644 --- a/crates/asset_model/src/model_skel_t6.rs +++ b/crates/asset_model/src/model_skel_t6.rs @@ -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> { let mut skins = vec![VertSkin::default(); vertex_count]; - let bone_at = |raw: u16| -> Option { + let bone_at = |raw: u16| -> Option { 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; } diff --git a/crates/asset_transport/src/discover.rs b/crates/asset_transport/src/discover.rs index cf35515..78b87d3 100644 --- a/crates/asset_transport/src/discover.rs +++ b/crates/asset_transport/src/discover.rs @@ -250,9 +250,6 @@ fn game_files(root: &Path) -> impl Iterator> { 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", diff --git a/crates/asset_world/src/world_t6.rs b/crates/asset_world/src/world_t6.rs index 660600e..082e1a8 100644 --- a/crates/asset_world/src/world_t6.rs +++ b/crates/asset_world/src/world_t6.rs @@ -1022,9 +1022,24 @@ pub fn model_mesh(skel: asset_model::ModelSkel) -> Result>(), + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_COLOR, + skel.colors() + .skip(first) + .take(end - first) + .collect::>(), + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_UV_0, + skel.uvs().skip(first).take(end - first).collect::>(), + ); mesh.insert_indices(Indices::U32(indices)); surfaces.push(crate::model_mesh::ModelSurfaceDraw { mesh, diff --git a/crates/assets/src/lane/sink.rs b/crates/assets/src/lane/sink.rs index f2749cc..cf2199b 100644 --- a/crates/assets/src/lane/sink.rs +++ b/crates/assets/src/lane/sink.rs @@ -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); diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 0d11fa8..786fa4f 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -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 diff --git a/crates/audio/src/cue_execution.rs b/crates/audio/src/cue_execution.rs index 3e421f4..ddf46c7 100644 --- a/crates/audio/src/cue_execution.rs +++ b/crates/audio/src/cue_execution.rs @@ -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) diff --git a/crates/audio/src/device.rs b/crates/audio/src/device.rs index 34add74..2fc1d22 100644 --- a/crates/audio/src/device.rs +++ b/crates/audio/src/device.rs @@ -92,7 +92,7 @@ pub(crate) fn supervise(shared: Arc, shutdown: Arc) { pub(crate) fn open( shared: Arc, failed: Arc, - error_tx: SyncSender, + error_tx: SyncSender, ) -> Result { let device = cpal::default_host() .default_output_device() @@ -157,8 +157,8 @@ fn build>( config: &cpal::StreamConfig, shared: Arc, failed: Arc, - error_tx: SyncSender, -) -> Result { + error_tx: SyncSender, +) -> Result { 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>( 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>( } 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); diff --git a/crates/audio/src/entity_events.rs b/crates/audio/src/entity_events.rs index efc5c59..3e4fd79 100644 --- a/crates/audio/src/entity_events.rs +++ b/crates/audio/src/entity_events.rs @@ -19,7 +19,7 @@ fn selected_alias<'a>( event: EntityEventKind, player_view: bool, knife: bool, -) -> Option<(asset_core::AssetNamespace, &'a str)> { +) -> Result, 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( diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index 76ffb5a..aea9ae6 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -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}; diff --git a/crates/audio/src/media.rs b/crates/audio/src/media.rs index ed4f146..7869476 100644 --- a/crates/audio/src/media.rs +++ b/crates/audio/src/media.rs @@ -204,6 +204,7 @@ pub(crate) struct RenderMedia { pub gain: Option, pub channel_gain: Option, pub release: Option>, + 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, } } diff --git a/crates/audio/src/mix_bus.rs b/crates/audio/src/mix_bus.rs new file mode 100644 index 0000000..4f406f4 --- /dev/null +++ b/crates/audio/src/mix_bus.rs @@ -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, +} + +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; + } + } +} diff --git a/crates/audio/src/playback.rs b/crates/audio/src/playback.rs index 1f9d861..9af3d86 100644 --- a/crates/audio/src/playback.rs +++ b/crates/audio/src/playback.rs @@ -60,15 +60,15 @@ impl Plugin for PlayerSoundPlugin { .scoped::(frame::MatchScope::Loading) .scoped::(frame::MatchScope::Loading) .scoped::(frame::MatchScope::Live) - .scoped::(frame::MatchScope::Live) - .scoped::(frame::MatchScope::Live) - .scoped_message::(frame::MatchScope::Live) - .scoped_message::(frame::MatchScope::Live) - .scoped_message::(frame::MatchScope::Live) - .scoped_message::(frame::MatchScope::Live) - .scoped_message::(frame::MatchScope::Live) + .scoped::(frame::Timeline::Running) + .scoped::(frame::Timeline::Running) + .scoped_message::(frame::Timeline::Running) + .scoped_message::(frame::Timeline::Running) + .scoped_message::(frame::Timeline::Running) + .scoped_message::(frame::Timeline::Running) + .scoped_message::(frame::Timeline::Running) .scoped::(frame::MatchScope::Live) - .scoped_message::(frame::MatchScope::Live) + .scoped_message::(frame::Timeline::Running) .add_systems( Update, play_weapon_sound_messages diff --git a/crates/audio/src/render_core.rs b/crates/audio/src/render_core.rs index 5dbb4a4..f2bbb0d 100644 --- a/crates/audio/src/render_core.rs +++ b/crates/audio/src/render_core.rs @@ -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); diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 7622c17..ac26bbd 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -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" } diff --git a/crates/bootstrap/src/launch.rs b/crates/bootstrap/src/launch.rs index c9294a0..d9f682f 100644 --- a/crates/bootstrap/src/launch.rs +++ b/crates/bootstrap/src/launch.rs @@ -87,7 +87,7 @@ fn install_class_catalog( mut commands: Commands, shell: Option>, mut strings: ResMut, - menus: Res, + mut menus: ResMut, account: Option>, mut player_data: ResMut, ) { @@ -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::(); + commands.remove_resource::(); } 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) diff --git a/crates/bootstrap/src/plugins.rs b/crates/bootstrap/src/plugins.rs index b841a59..6f523e4 100644 --- a/crates/bootstrap/src/plugins.rs +++ b/crates/bootstrap/src/plugins.rs @@ -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); diff --git a/crates/console/assets/default_controls.cfg b/crates/console/assets/default_controls.cfg index 840b30f..c6710d5 100644 --- a/crates/console/assets/default_controls.cfg +++ b/crates/console/assets/default_controls.cfg @@ -26,3 +26,6 @@ bind TAB +scores bind 2 +left bind 3 +right bind 4 +actionslot 4 + +bind T chatmodepublic +bind Y chatmodeteam diff --git a/crates/console/src/binds.rs b/crates/console/src/binds.rs index 64cc9fa..f957007 100644 --- a/crates/console/src/binds.rs +++ b/crates/console/src/binds.rs @@ -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(), } } diff --git a/crates/console/src/binds/key_names.rs b/crates/console/src/binds/key_names.rs index 5d0dd65..c7b7fcc 100644 --- a/crates/console/src/binds/key_names.rs +++ b/crates/console/src/binds/key_names.rs @@ -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)]) } } diff --git a/crates/console/src/chat.rs b/crates/console/src/chat.rs new file mode 100644 index 0000000..0adf8d6 --- /dev/null +++ b/crates/console/src/chat.rs @@ -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, + 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::(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) { + 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, + state: Option>, + binds: Res, + (console, menus, screen, playback): ( + Res, + hud::MenuState<'_>, + Res, + Option>, + ), + (keys, mouse, pads, active, mut wheel): ( + Res>, + Res>, + Query<&bevy::input::gamepad::Gamepad>, + Res, + MessageReader, + ), + 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, + state: Option>, + (screen, playback): (Res, Option>), + mut outbox: ChatOutbox, + mut console: ResMut, + settings: Res, +) { + 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, + state: Option>, + time: Res>, + (screen, local, presented, strings): ( + Res, + Res, + Res, + Option>, + ), + mut names: Query<(&ChatText, &mut Text, &mut TextColor), Without>, + mut bodies: Query<(&ChatBody, &mut TextSpan)>, + mut prompt: Query<&mut Text, (With, Without)>, +) { + 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; + } +} diff --git a/crates/console/src/diagnostics.rs b/crates/console/src/diagnostics.rs index 0c8d8a1..5627304 100644 --- a/crates/console/src/diagnostics.rs +++ b/crates/console/src/diagnostics.rs @@ -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; diff --git a/crates/console/src/feature_dispatch/mod.rs b/crates/console/src/feature_dispatch/mod.rs index f6d85da..b0101e1 100644 --- a/crates/console/src/feature_dispatch/mod.rs +++ b/crates/console/src/feature_dispatch/mod.rs @@ -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 — public chat"), + ("say_team", "say_team — team chat"), + ("chatmodepublic", "chatmodepublic — open public chat"), + ("chatmodeteam", "chatmodeteam — open team chat"), ("clear", "clear — clear console scrollback"), ("screenshot", "screenshot [name] — capture the backbuffer"), ( diff --git a/crates/console/src/feature_dispatch/replay.rs b/crates/console/src/feature_dispatch/replay.rs index a0a6472..fcf60a2 100644 --- a/crates/console/src/feature_dispatch/replay.rs +++ b/crates/console/src/feature_dispatch/replay.rs @@ -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>, Option>, ), - replay_inputs: ( - Option>, - Res, - Res, - ), + authority: Option>, 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" diff --git a/crates/console/src/frontend.rs b/crates/console/src/frontend.rs index e8de9e4..33b0869 100644 --- a/crates/console/src/frontend.rs +++ b/crates/console/src/frontend.rs @@ -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, } #[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" }, diff --git a/crates/console/src/gamepad.rs b/crates/console/src/gamepad.rs index abf53b1..1853aa9 100644 --- a/crates/console/src/gamepad.rs +++ b/crates/console/src/gamepad.rs @@ -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, mut active: ResMut, mut devices: ResMut, @@ -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, + overlay: Res, script_menus: hud::MenuState<'_>, (devices, console, capture): ( Res, @@ -191,7 +167,7 @@ pub(crate) fn drive_menus_with_pad( mut requests: MessageWriter, mut repeat: Local>, ) { - if !devices.focused || console.open { + if !devices.focused || console.open || overlay.0 { *repeat = None; return; } diff --git a/crates/console/src/lib.rs b/crates/console/src/lib.rs index 5735ce5..b7ad9ee 100644 --- a/crates/console/src/lib.rs +++ b/crates/console/src/lib.rs @@ -1,5 +1,6 @@ mod barracks_menu; pub mod binds; +mod chat; mod class_dispatch; mod class_menu; mod command; diff --git a/crates/console/src/plugin/mod.rs b/crates/console/src/plugin/mod.rs index 4c8adb2..f38904e 100644 --- a/crates/console/src/plugin/mod.rs +++ b/crates/console/src/plugin/mod.rs @@ -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::() .init_resource::() .init_resource::() @@ -94,7 +94,11 @@ impl Plugin for ConsolePlugin { .init_resource::() .init_resource::() .init_resource::() - .published::(frame::MatchScope::Live) + .init_resource::() + .add_systems( + OnExit(frame::MatchScope::Live), + crate::weapon_dispatch::clear_weapon_arg_completions, + ) .init_resource::() .init_resource::() .init_resource::() @@ -118,6 +122,8 @@ impl Plugin for ConsolePlugin { .add_systems(PreUpdate, feed_console_keyboard.before(InputSystems)) .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .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, + chat: Option>, mut keys: ResMut>, mut mouse: MessageReader, ) { - 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, +} + +fn publish_ui_input_capture( + console: Res, + chat: Option>, + mut hud_input: ResMut, +) { + 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, mut scripted: ResMut, console: Res, + chat: Option>, script_menus: hud::MenuState<'_>, + overlay: Res, mut hud_input: ResMut, settings: Res, mut out: ResMut, @@ -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, console: Res, + chat: Option>, script_menus: hud::MenuState<'_>, screen: Option>, mut focused: MessageReader, @@ -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, mut registry: ResMut, mut binds: ResMut, - weapon_completions: Res>, + weapon_completions: Res, ) { binds.apply_defaults(); if registry.resolve("hold").is_none() { diff --git a/crates/console/src/user_settings.rs b/crates/console/src/user_settings.rs index 1f52a4f..c77cf3e 100644 --- a/crates/console/src/user_settings.rs +++ b/crates/console/src/user_settings.rs @@ -152,20 +152,17 @@ pub(crate) fn consume_menu_binding( pub(crate) fn sync_binding_view( binds: Res, - devices: Res, mut view: ResMut, mut dvars: ResMut, - mut style: Local>, ) { - if !binds.is_changed() && *style == Some(devices.style) { + if !binds.is_changed() { return; } - *style = Some(devices.style); let mut chords = std::collections::BTreeMap::, Vec)>::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)), } } diff --git a/crates/console/src/weapon_dispatch.rs b/crates/console/src/weapon_dispatch.rs index 19ae690..55ecb6f 100644 --- a/crates/console/src/weapon_dispatch.rs +++ b/crates/console/src/weapon_dispatch.rs @@ -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, } -type CompletionSlot = Published; +#[derive(Resource, Clone, Default)] +pub(crate) struct CompletionSlot(Arc>>); + +impl CompletionSlot { + fn read(&self, read: impl FnOnce(Option<&WeaponArgCompletions>) -> R) -> R { + read(self.0.read().expect("completion slot poisoned").as_ref()) + } + + fn publish(&self, value: Option) { + *self.0.write().expect("completion slot poisoned") = value; + } +} + +pub(crate) fn clear_weapon_arg_completions(completions: Res) { + completions.publish(None); +} struct LiveListCompleter(CompletionSlot, fn(&WeaponArgCompletions) -> &[String]); impl ArgCompleter for LiveListCompleter { fn complete(&self, prefix: &str) -> Vec { 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 { 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 { 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), diff --git a/crates/fastfile_t5/src/load/weapon.rs b/crates/fastfile_t5/src/load/weapon.rs index bfe8d16..8b8e416 100644 --- a/crates/fastfile_t5/src/load/weapon.rs +++ b/crates/fastfile_t5/src/load/weapon.rs @@ -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, gun: Option, hand: Option, rocket: Option, diff --git a/crates/fastfile_t5/src/zone.rs b/crates/fastfile_t5/src/zone.rs index c8cd0d9..5601047 100644 --- a/crates/fastfile_t5/src/zone.rs +++ b/crates/fastfile_t5/src/zone.rs @@ -384,6 +384,7 @@ pub struct FxEffectDefGeometry { #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct WeaponGeometry { + pub kill_icon_name: Option, pub overlay_material_name: Option, pub overlay_material_lowres_name: Option, pub reticle_center_name: Option, diff --git a/crates/frame/src/lib.rs b/crates/frame/src/lib.rs index ab4da3c..92d1670 100644 --- a/crates/frame/src/lib.rs +++ b/crates/frame/src/lib.rs @@ -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, }; diff --git a/crates/frame/src/pad.rs b/crates/frame/src/pad.rs index 481aa47..31c1395 100644 --- a/crates/frame/src/pad.rs +++ b/crates/frame/src/pad.rs @@ -3,21 +3,69 @@ use bevy::prelude::{Entity, Message, Resource}; #[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ActivePad(pub Option); -#[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::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", + } + } +} diff --git a/crates/frame/src/scope.rs b/crates/frame/src/scope.rs index f119b2b..56324f4 100644 --- a/crates/frame/src/scope.rs +++ b/crates/frame/src/scope.rs @@ -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 Staging { } } -#[derive(Resource)] -pub struct Published(Arc>>); - -impl Clone for Published { - fn clone(&self) -> Self { - Self(self.0.clone()) - } -} - -impl Default for Published { - fn default() -> Self { - Self(Arc::new(RwLock::new(None))) - } -} - -impl Published { - pub fn read(&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::() .add_sub_state::() + .add_sub_state::() .init_resource::() .init_resource::() .init_resource::>() .init_resource::>() + .init_resource::>() .add_systems( StateTransition, - (retire_state::, retire_state::) + ( + retire_state::, + retire_state::, + retire_state::, + ) .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( } world.resource_mut::>().0 = world.resource::>().0.wrapping_add(1); + // Match-epoch consumers (audio voices) must also drop what a restarted + // timeline played. + if TypeId::of::() == TypeId::of::() { + world.resource_mut::>().0 = + world.resource::>().0.wrapping_add(1); + } if !actions.is_empty() { world.resource_mut::().mark_teardown(); let mut registry = world.resource_mut::(); @@ -309,7 +301,6 @@ pub trait ScopeApp { fn init_app(&mut self) -> &mut Self; fn scoped(&mut self, scope: impl States) -> &mut Self; fn staged(&mut self, scope: impl States) -> &mut Self; - fn published(&mut self, scope: impl States) -> &mut Self; fn scoped_message(&mut self, scope: impl States) -> &mut Self; } @@ -363,22 +354,6 @@ fn staging_empty(world: &World) -> bool { .get_resource::>() .is_none_or(Staging::is_empty) } -fn published_present(world: &World) -> bool { - world - .get_resource::>() - .is_some_and(|slot| slot.read(|value| value.is_some())) -} -fn clear_published(world: &mut World) { - let value = world - .resource::>() - .0 - .write() - .expect("published slot poisoned") - .take(); - if let Some(value) = value { - world.resource_mut::().hand_over(value); - } -} fn messages_present(world: &World) -> bool { world .get_resource::>() @@ -406,7 +381,8 @@ fn register( assert!( (TypeId::of::() == TypeId::of::() && matches!(label.as_str(), "Loading" | "Live")) - || (TypeId::of::() == TypeId::of::() && label == "Playing"), + || (TypeId::of::() == TypeId::of::() && label == "Playing") + || (TypeId::of::() == TypeId::of::() && label == "Running"), "unsupported resource scope {label}" ); if !app.is_plugin_added::() { @@ -474,19 +450,6 @@ impl ScopeApp for App { ); self.init_resource::>() } - fn published(&mut self, scope: impl States) -> &mut Self { - register::, _>( - self, - scope, - ScopeKind::Published, - noop, - clear_published::, - published_present::, - empty, - noop, - ); - self.init_resource::>() - } fn scoped_message(&mut self, scope: impl States) -> &mut Self { register::, _>( self, diff --git a/crates/frame/src/screen_effects.rs b/crates/frame/src/screen_effects.rs index c4b115a..9e71257 100644 --- a/crates/frame/src/screen_effects.rs +++ b/crates/frame/src/screen_effects.rs @@ -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, diff --git a/crates/frame/src/session.rs b/crates/frame/src/session.rs index 960956c..6cfa2c0 100644 --- a/crates/frame/src/session.rs +++ b/crates/frame/src/session.rs @@ -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, diff --git a/crates/frame/src/settings.rs b/crates/frame/src/settings.rs index 6263978..79c2b51 100644 --- a/crates/frame/src/settings.rs +++ b/crates/frame/src/settings.rs @@ -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) diff --git a/crates/frame/src/ui/menu.rs b/crates/frame/src/ui/menu.rs index e0633b1..3f2c6ca 100644 --- a/crates/frame/src/ui/menu.rs +++ b/crates/frame/src/ui/menu.rs @@ -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 }, diff --git a/crates/frame/src/ui/mod.rs b/crates/frame/src/ui/mod.rs index 2db6e4e..592edb0 100644 --- a/crates/frame/src/ui/mod.rs +++ b/crates/frame/src/ui/mod.rs @@ -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) { diff --git a/crates/hud/Cargo.toml b/crates/hud/Cargo.toml index 81a12ce..cd50bc4 100644 --- a/crates/hud/Cargo.toml +++ b/crates/hud/Cargo.toml @@ -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 diff --git a/crates/hud/icons/xbox/a.svg b/crates/hud/icons/xbox/a.svg new file mode 100644 index 0000000..2abdea9 --- /dev/null +++ b/crates/hud/icons/xbox/a.svg @@ -0,0 +1 @@ + diff --git a/crates/hud/icons/xbox/b.svg b/crates/hud/icons/xbox/b.svg new file mode 100644 index 0000000..2d9d1ec --- /dev/null +++ b/crates/hud/icons/xbox/b.svg @@ -0,0 +1 @@ + diff --git a/crates/hud/icons/xbox/dpad_down.svg b/crates/hud/icons/xbox/dpad_down.svg new file mode 100644 index 0000000..dde8509 --- /dev/null +++ b/crates/hud/icons/xbox/dpad_down.svg @@ -0,0 +1 @@ + diff --git a/crates/hud/icons/xbox/dpad_left.svg b/crates/hud/icons/xbox/dpad_left.svg new file mode 100644 index 0000000..696e28b --- /dev/null +++ b/crates/hud/icons/xbox/dpad_left.svg @@ -0,0 +1 @@ + diff --git a/crates/hud/icons/xbox/dpad_right.svg b/crates/hud/icons/xbox/dpad_right.svg new file mode 100644 index 0000000..2dea050 --- /dev/null +++ b/crates/hud/icons/xbox/dpad_right.svg @@ -0,0 +1 @@ + diff --git a/crates/hud/icons/xbox/dpad_up.svg b/crates/hud/icons/xbox/dpad_up.svg new file mode 100644 index 0000000..9aa3616 --- /dev/null +++ b/crates/hud/icons/xbox/dpad_up.svg @@ -0,0 +1 @@ + diff --git a/crates/hud/icons/xbox/x.svg b/crates/hud/icons/xbox/x.svg new file mode 100644 index 0000000..2cbc415 --- /dev/null +++ b/crates/hud/icons/xbox/x.svg @@ -0,0 +1 @@ + diff --git a/crates/hud/icons/xbox/y.svg b/crates/hud/icons/xbox/y.svg new file mode 100644 index 0000000..fbc9c12 --- /dev/null +++ b/crates/hud/icons/xbox/y.svg @@ -0,0 +1 @@ + diff --git a/crates/hud/src/blood.rs b/crates/hud/src/blood.rs index 85b81c6..adab792 100644 --- a/crates/hud/src/blood.rs +++ b/crates/hud/src/blood.rs @@ -17,6 +17,8 @@ pub(crate) struct HudRootVisible(pub Option); #[derive(Resource, Default)] pub(crate) struct BloodOverlayLatch { intensity: f32, + subject: Option, + last_time: Option, pub(crate) packed: PackedList, last_gpu_intensity: Option, last_w: Option, @@ -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; diff --git a/crates/hud/src/chrome.rs b/crates/hud/src/chrome.rs index b95c6d2..9e8090e 100644 --- a/crates/hud/src/chrome.rs +++ b/crates/hud/src/chrome.rs @@ -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; diff --git a/crates/hud/src/connection_notice.rs b/crates/hud/src/connection_notice.rs new file mode 100644 index 0000000..0f252ef --- /dev/null +++ b/crates/hud/src/connection_notice.rs @@ -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, + catalog: Option>, + link: Option>, + adopted: Option>, + mut pass: ResMut, + mut hud_images: ResMut, + mut images: ResMut>, +) { + 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); + } +} diff --git a/crates/hud/src/controller_icons.rs b/crates/hud/src/controller_icons.rs new file mode 100644 index 0000000..8360410 --- /dev/null +++ b/crates/hud/src/controller_icons.rs @@ -0,0 +1,37 @@ +use frame::ControllerIcon; + +pub(crate) const SIZE: u32 = 64; + +pub(crate) fn rasterize(icon: ControllerIcon) -> Vec { + 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 +} diff --git a/crates/hud/src/damage_direction.rs b/crates/hud/src/damage_direction.rs new file mode 100644 index 0000000..7e12764 --- /dev/null +++ b/crates/hud/src/damage_direction.rs @@ -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, + subject: Option, + time: Option, + 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, + presented: Res, + local: Res, + view: Res, + clock: Res, + ui_draw: Res, + aim: Res, + weapons: Option>, + mut started: MessageReader, + mut directions: ResMut, + mut pass: ResMut, +) { + 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); +} diff --git a/crates/hud/src/draw2d.rs b/crates/hud/src/draw2d.rs index 92b8ba6..7155a8f 100644 --- a/crates/hud/src/draw2d.rs +++ b/crates/hud/src/draw2d.rs @@ -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; }; diff --git a/crates/hud/src/gpu_list.rs b/crates/hud/src/gpu_list.rs index e01c8e8..92333aa 100644 --- a/crates/hud/src/gpu_list.rs +++ b/crates/hud/src/gpu_list.rs @@ -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, diff --git a/crates/hud/src/images.rs b/crates/hud/src/images.rs index 4f5b257..30ce5e8 100644 --- a/crates/hud/src/images.rs +++ b/crates/hud/src/images.rs @@ -133,6 +133,7 @@ fn make_image( #[derive(Resource, Default)] pub struct HudImages { + controller_icons: HashMap>, ui_images: asset_material::UiImagePublication, games_root: PathBuf, @@ -385,6 +386,24 @@ impl HudImages { sampler: Option, images: &mut Assets, ) -> Option> { + 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; diff --git a/crates/hud/src/killfeed.rs b/crates/hud/src/killfeed.rs index 06dd7c5..4ba9b5c 100644 --- a/crates/hud/src/killfeed.rs +++ b/crates/hud/src/killfeed.rs @@ -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", )); } diff --git a/crates/hud/src/lib.rs b/crates/hud/src/lib.rs index fc1be4f..9dcc92c 100644 --- a/crates/hud/src/lib.rs +++ b/crates/hud/src/lib.rs @@ -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; diff --git a/crates/hud/src/menus/host.rs b/crates/hud/src/menus/host.rs index 177fe50..f4016e7 100644 --- a/crates/hud/src/menus/host.rs +++ b/crates/hud/src/menus/host.rs @@ -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), diff --git a/crates/hud/src/menus/mod.rs b/crates/hud/src/menus/mod.rs index 36553ea..debd700 100644 --- a/crates/hud/src/menus/mod.rs +++ b/crates/hud/src/menus/mod.rs @@ -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>, + overlay: Res<'w, frame::UiOverlayInput>, keyboard: MessageReader<'w, 's, bevy::input::keyboard::KeyboardInput>, mouse: Res<'w, ButtonInput>, windows: Query<'w, 's, &'static Window, With>, @@ -193,6 +198,7 @@ pub(crate) struct MenuInputs<'w, 's> { party: Res<'w, frame::UiPartyState>, frontend_strings: Option>, player_data: Option>, + content_loading: Option>, } #[derive(Default)] @@ -245,7 +251,19 @@ pub(crate) fn update_script_menus( mut images: ResMut>, mut pass: ResMut, ) { - let mut menus = menu_states.0.as_deref_mut().unwrap_or(&mut menu_states.1.0); + let requests: Vec = 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 = 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 = 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)); diff --git a/crates/hud/src/plugin.rs b/crates/hud/src/plugin.rs index e41f2b1..12d1ce8 100644 --- a/crates/hud/src/plugin.rs +++ b/crates/hud/src/plugin.rs @@ -41,27 +41,29 @@ impl Plugin for HudPlugin { ); crate::gpu_list::register(app); let _ = crate::scorebar::milliseconds(); - app.init_resource::() + app.init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() + .scoped::(frame::Timeline::Running) .init_resource::() .init_resource::() .init_resource::() - .scoped::(frame::MatchScope::Live) - .scoped::(frame::MatchScope::Live) - .scoped::(frame::MatchScope::Live) + .scoped::(frame::Timeline::Running) + .scoped::(frame::Timeline::Running) + .scoped::(frame::Timeline::Running) .scoped::(frame::MatchScope::Live) .scoped::(frame::MatchScope::Live) .init_resource::() .init_resource::() - .scoped::(frame::MatchScope::Live) + .scoped::(frame::Timeline::Running) .init_resource::() .init_resource::() .init_resource::() .init_resource::() - .scoped::(frame::MatchScope::Live) + .scoped::(frame::Timeline::Running) .scoped::(frame::MatchScope::Live) .init_resource::() .add_message::() @@ -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 .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 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, ui_draw: Option>, + hud_draw: Res, mut roots: Query<&mut Visibility, With>, mut visible: ResMut, ) { 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, + mut pass: ResMut, + mut hud_images: ResMut, + mut images: ResMut>, + mut frame: ResMut, + mut hint: Query< + (Entity, &mut Node, &mut crate::gpu_list::GpuListLatch), + With, + >, +) { + 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, mut pass: ResMut, @@ -897,6 +939,36 @@ fn flush_breath_hint_tess( } } +fn flush_connection_notice_tess( + surface: Res, + mut pass: ResMut, + mut hud_images: ResMut, + mut images: ResMut>, + mut frame: ResMut, + mut hint: Query< + (Entity, &mut Node, &mut crate::gpu_list::GpuListLatch), + With, + >, +) { + 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, diff --git a/crates/hud/src/splash.rs b/crates/hud/src/splash.rs index 897a3e8..a685f99 100644 --- a/crates/hud/src/splash.rs +++ b/crates/hud/src/splash.rs @@ -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 diff --git a/crates/iw4l-master/src/main.rs b/crates/iw4l-master/src/main.rs index b6b34e5..0480263 100644 --- a/crates/iw4l-master/src/main.rs +++ b/crates/iw4l-master/src/main.rs @@ -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>>, 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, +} + +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, 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(()) } diff --git a/crates/master_protocol/src/lib.rs b/crates/master_protocol/src/lib.rs index d25c763..3a3809f 100644 --- a/crates/master_protocol/src/lib.rs +++ b/crates/master_protocol/src/lib.rs @@ -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"; diff --git a/crates/net/src/authority/inbox.rs b/crates/net/src/authority/inbox.rs index 31c20e7..104390f 100644 --- a/crates/net/src/authority/inbox.rs +++ b/crates/net/src/authority/inbox.rs @@ -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 = 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 { @@ -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)); } diff --git a/crates/net/src/client/presentation/entities.rs b/crates/net/src/client/presentation/entities.rs index ae7c01a..66b194b 100644 --- a/crates/net/src/client/presentation/entities.rs +++ b/crates/net/src/client/presentation/entities.rs @@ -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 = bevy::prelude::DespawnOnExit(frame::MatchScope::Live))] +#[require(bevy::prelude::DespawnOnExit = bevy::prelude::DespawnOnExit(frame::Timeline::Running))] pub struct CEntity { number: u16, client: Option, @@ -397,9 +397,9 @@ fn sync_snapshot_entities( } pub(crate) fn register_authority_entities(app: &mut App) { - app.scoped::(frame::MatchScope::Live) + app.scoped::(frame::Timeline::Running) .init_resource::() - .scoped::(frame::MatchScope::Live) + .scoped::(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::(frame::MatchScope::Live) + app.scoped::(frame::Timeline::Running) .init_resource::() - .scoped::(frame::MatchScope::Live) + .scoped::(frame::Timeline::Running) .add_systems( Update, sync_client_entities diff --git a/crates/net/src/client/presentation/entity_event_dispatch.rs b/crates/net/src/client/presentation/entity_event_dispatch.rs index 10dd769..74c9fcf 100644 --- a/crates/net/src/client/presentation/entity_event_dispatch.rs +++ b/crates/net/src/client/presentation/entity_event_dispatch.rs @@ -731,7 +731,7 @@ fn set_ads_from_reset( } pub fn register_entity_event_dispatch(app: &mut App) { - app.scoped::(frame::MatchScope::Live) + app.scoped::(frame::Timeline::Running) .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/net/src/client/presentation/presented.rs b/crates/net/src/client/presentation/presented.rs index e0b3483..63ddb55 100644 --- a/crates/net/src/client/presentation/presented.rs +++ b/crates/net/src/client/presentation/presented.rs @@ -46,6 +46,7 @@ pub struct PresentLocalCensus { #[derive(Resource, Clone, Debug, Default)] pub struct PresentedSnapshot { + external_camera: Option, inner: Option>, previous_inner: Option>, 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 { 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; diff --git a/crates/net/src/client/runtime.rs b/crates/net/src/client/runtime.rs index 7302bb9..608e2fc 100644 --- a/crates/net/src/client/runtime.rs +++ b/crates/net/src/client/runtime.rs @@ -150,9 +150,15 @@ pub struct LastAdoptedSnapshot { pub next_snap: Option>, pub applied_this_frame: bool, + + pub adopted_at: Option, } impl LastAdoptedSnapshot { + pub fn since_adopted(&self) -> Option { + 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, time: Res, + client_clock: Res, mut prediction: ResMut, mut join: ResMut, time: Res