cinematics play, and a lit particle stops brightening as you turn
- cinematics play: `SMSG_TRIGGER_CINEMATIC` resolves through `CinematicSequences.dbc` → `CinematicCamera.dbc` → a `Cameras\*.m2` whose camera record is an eye path, a look-at path and a roll — cubic tracks with the full step / linear / Bézier / Hermite dispatch the bone loops collapse to step-or-linear (every shipped fly-by is Bézier), planted at the row's origin and facing (`Rz(+facing)`, checked against vmangos's own waypoint table: 8.5 yd mean on the human path, 154 with the other sign); FOV is per shot (the Undead intro authors 90°, the rest 45°), roll is authored around whole turns, the flight ends at the sequence band's end, a trigger arriving before the loading cover lifts is latched and started when it does, ESC skips through `CinematicFrame`'s own `OnKeyDown` (`StopCinematic` has zero native callers), and every path acks — natural end, ESC, or an unresolvable trigger immediately, as before; `.debug play cinematic <id>` replays any of them - a cinematic hides the HUD — `UIParent:Hide()` reaches nothing here because the HUD frames are not its children, so the ALT-Z draw-level hide is used and the letterbox moves out of FrameXML onto Bevy UI nodes where that hide cannot reach it, recomputed per frame (bars only wider than 4:3, the picture cropped to 2:1) — and the restore only un-hides a UI this module hid - a lit particle's normal is world up, one constant per draw, not camera-facing: Onyxia's always-on floor haze read about four times brighter from a level camera than from above because the shader dotted a to-eye normal against a fixed light; the reference uploads row 2 of the view matrix — world +Z carried into eye space — on every vertex and carries the light into the same frame, so the rotation cancels; 400 lit emitters (the waterfalls, chimney and campfire smoke, Westfall dust, Dun Morogh snow) stop brightening and dimming as you turn - the `play` profile drops ThinLTO — rustc #86049 was intermittently poisoning the incremental link with `_anon.<hash>.llvm.<id>` symbols promoted by an earlier compile (48 unresolved across 7 codegen units; 1065 such symbols → 0 after) — and `benilla-formats` stops taking `image` with default features, which had been compiling a complete AV1 encoder into a crate that writes two PNGs: 499 → 466 workspace crates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+2
-380
@@ -103,24 +103,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
|
||||
dependencies = [
|
||||
"as-slice",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned-vec"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||
dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -273,12 +255,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary-chunks"
|
||||
version = "0.4.1"
|
||||
@@ -305,17 +281,6 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -334,15 +299,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "as-slice"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
version = "0.38.0+1.3.281"
|
||||
@@ -481,40 +437,6 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "av-scenechange"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
|
||||
dependencies = [
|
||||
"aligned",
|
||||
"anyhow",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"pastey 0.1.1",
|
||||
"rayon",
|
||||
"thiserror 2.0.18",
|
||||
"v_frame",
|
||||
"y4m",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "av1-grain"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"nom 8.0.0",
|
||||
"num-rational",
|
||||
"v_frame",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "avian3d"
|
||||
version = "0.6.1"
|
||||
@@ -552,15 +474,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "avif-serialize"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -2173,12 +2086,6 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -2195,15 +2102,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "4.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
|
||||
dependencies = [
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
@@ -2284,12 +2182,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -2415,7 +2307,7 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2537,12 +2429,6 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -3181,26 +3067,6 @@ version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||
dependencies = [
|
||||
"equator-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator-macro"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -3264,21 +3130,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.74.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
|
||||
dependencies = [
|
||||
"bit_field",
|
||||
"half",
|
||||
"lebe",
|
||||
"miniz_oxide",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
@@ -3560,16 +3411,6 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gilrs"
|
||||
version = "0.11.1"
|
||||
@@ -4028,38 +3869,12 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"exr",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"qoi",
|
||||
"ravif",
|
||||
"rayon",
|
||||
"rgb",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2"
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
@@ -4117,17 +3932,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.24"
|
||||
@@ -4310,7 +4114,7 @@ dependencies = [
|
||||
"cpal 0.17.1",
|
||||
"glam 0.33.2",
|
||||
"mint",
|
||||
"pastey 0.2.3",
|
||||
"pastey",
|
||||
"rtrb",
|
||||
"send_wrapper",
|
||||
"symphonia",
|
||||
@@ -4338,12 +4142,6 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "lewton"
|
||||
version = "0.10.2"
|
||||
@@ -4361,16 +4159,6 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
@@ -4467,15 +4255,6 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loop9"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
|
||||
dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lua-src"
|
||||
version = "550.0.0"
|
||||
@@ -4529,16 +4308,6 @@ dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -4734,12 +4503,6 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@@ -4764,15 +4527,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -4789,27 +4543,12 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonmax"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
|
||||
|
||||
[[package]]
|
||||
name = "noop_proc_macro"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
@@ -4867,17 +4606,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -5452,12 +5180,6 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
@@ -5711,15 +5433,6 @@ version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -5843,56 +5556,6 @@ version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"arbitrary",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"av-scenechange",
|
||||
"av1-grain",
|
||||
"bitstream-io",
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
"maybe-rayon",
|
||||
"new_debug_unreachable",
|
||||
"noop_proc_macro",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"profiling",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
"simd_helpers",
|
||||
"thiserror 2.0.18",
|
||||
"v_frame",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ravif"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"imgref",
|
||||
"loop9",
|
||||
"quick-error",
|
||||
"rav1e",
|
||||
"rayon",
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -6022,12 +5685,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||
|
||||
[[package]]
|
||||
name = "robust"
|
||||
version = "1.2.0"
|
||||
@@ -6311,15 +5968,6 @@ dependencies = [
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd_helpers"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
@@ -7171,17 +6819,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "v_frame"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -8414,12 +8051,6 @@ version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
|
||||
[[package]]
|
||||
name = "y4m"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yazi"
|
||||
version = "0.2.1"
|
||||
@@ -8541,15 +8172,6 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-inflate"
|
||||
version = "0.2.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
|
||||
+11
@@ -83,3 +83,14 @@ codegen-units = 1
|
||||
[profile.play]
|
||||
inherits = "release"
|
||||
incremental = true
|
||||
# ThinLTO OFF, and it is not a performance choice -- it is the fix for rustc #86049. `release`
|
||||
# implies thin-LOCAL LTO (ThinLTO across a crate's own codegen units), which promotes each CGU's
|
||||
# internal anonymous constants to external symbols named `anon.<hash>.N.llvm.<moduleid>`. Under
|
||||
# `incremental` some CGUs come back from the cache carrying names minted in an earlier compile,
|
||||
# so a promoted reference finds no definition and `cc` fails with "symbol(s) not found for
|
||||
# architecture arm64" -- an intermittent link failure with no source-level cause, cleared only by
|
||||
# a full rebuild. Measured on the 2026-08-29 occurrence: 7 poisoned CGUs in `benilla-formats`, 48
|
||||
# unresolved symbols. `lto = "off"` disables the promotion step entirely (verified: the play rlib
|
||||
# goes from 381 undefined `anon.*.llvm.*` symbols to 0). `release` and `ship` are UNTOUCHED -- they
|
||||
# are non-incremental, cannot hit this, and stay the graded profiles (0736). Decision 1698.
|
||||
lto = "off"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<Ui>
|
||||
<!--
|
||||
The cinematic frame — what makes InCinematic() true, and the ESC that skips a fly-by.
|
||||
Transcribed from the reference's Interface\FrameXML\CinematicFrame.xml + .lua (decision
|
||||
1602 permits it); the engine half is crate::cinematic.
|
||||
|
||||
This frame is load-bearing for exactly one thing, and it is not a picture: it is the WHOLE
|
||||
skip path. In the reference, StopCinematic (0x48b970) has zero native callers and no
|
||||
Bindings.xml row — the only thing that ever calls it is the OnKeyDown below, reached
|
||||
because this fullscreen frame is keyboard-enabled and showing. No frame on screen means no
|
||||
way out of a 102-second Night Elf intro.
|
||||
|
||||
THE BLACK BARS ARE NOT HERE, deliberately. The reference draws them as two textures on this
|
||||
frame; we draw them as Bevy UI nodes in crate::cinematic instead, because a cinematic hides
|
||||
the HUD through UiHidden — which kills both of ui_pass's quad lanes wholesale, and the
|
||||
FrameXML layer is one of them. Bars declared here would go dark along with the action bars
|
||||
they exist to replace. The bar LAW is unchanged and still the reference's (only a screen
|
||||
wider than 4:3 gets bars; the picture is cropped to 2:1, the remainder split evenly) — it
|
||||
just lives where it can be drawn. See `drive_letterbox`.
|
||||
|
||||
So this frame draws nothing at all. It is kept rather than replaced with an engine-side key
|
||||
handler because the skip path IS a frame in the reference: keeping it means ESC works for
|
||||
the same reason it works there, and an addon that hooks CinematicFrame still finds it.
|
||||
|
||||
RunBinding("SCREENSHOT") is kept as the reference has it: while this frame eats the
|
||||
keyboard, the screenshot key is the one binding that still has to get through.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
function CinematicFrame_OnLoad()
|
||||
this:RegisterEvent("CINEMATIC_START");
|
||||
this:RegisterEvent("CINEMATIC_STOP");
|
||||
end
|
||||
|
||||
function CinematicFrame_OnEvent()
|
||||
if ( event == "CINEMATIC_START" ) then
|
||||
ShowUIPanel(this, 1);
|
||||
return;
|
||||
end
|
||||
if ( event == "CINEMATIC_STOP" ) then
|
||||
HideUIPanel(this);
|
||||
return;
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
<Frame name="CinematicFrame" setAllPoints="true" hidden="true" enableKeyboard="true" enableMouse="true">
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
CinematicFrame_OnLoad();
|
||||
</OnLoad>
|
||||
<OnEvent>
|
||||
CinematicFrame_OnEvent();
|
||||
</OnEvent>
|
||||
<OnKeyDown>
|
||||
local screenshotKey = GetBindingKey("SCREENSHOT");
|
||||
if ( arg1 == "ESCAPE" ) then
|
||||
StopCinematic();
|
||||
elseif ( screenshotKey and (arg1 == screenshotKey) ) then
|
||||
RunBinding("SCREENSHOT");
|
||||
end
|
||||
</OnKeyDown>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -26,7 +26,7 @@
|
||||
<Script><![CDATA[
|
||||
-- UIParent.lua l.14-42: UIPanelWindows["FrameName"] = { area, pushable, whileDead }. Only
|
||||
-- the rows benilla ships today; the ref rows with no benilla window yet (Tabard,
|
||||
-- Battlefield, Cinematic, WorldStateScore, Minigame) join as their windows land (audited
|
||||
-- Battlefield, WorldStateScore, Minigame) join as their windows land (audited
|
||||
-- row-by-row against the bytes, decision 1507). Four have since left that list: the
|
||||
-- doublewide AuctionFrame with decision 1511, PetStableFrame with 1676, HelpFrame with
|
||||
-- 1673, and Petition + GuildRegistrar with 1672 — all of them are rows below.
|
||||
@@ -184,6 +184,13 @@
|
||||
-- was the ONE window that still opened (director-reported). Routing its toggle through
|
||||
-- ShowUIPanel is what subjects it to the same rules as everything else.
|
||||
UIPanelWindows["WorldMapFrame"] = { area = "full", pushable = 0, whileDead = 1 }
|
||||
-- The cinematic letterbox (CinematicFrame.xml) — the ref's own line verbatim
|
||||
-- (UIParent.lua l.31). The row is what makes CINEMATIC_START's `ShowUIPanel(this, 1)`
|
||||
-- actually seat the frame: unregistered, ShowUIPanel takes its bare-`Show()` branch and
|
||||
-- the bars would appear without the panel manager ever closing what they cover. `full`
|
||||
-- because a fly-by owns the screen, and no `whileDead` — the ref gives it none, and a
|
||||
-- cinematic is not something a corpse needs.
|
||||
UIPanelWindows["CinematicFrame"] = { area = "full", pushable = 0 }
|
||||
|
||||
-- UIChildWindows — the ref's own table verbatim (UIParent.lua l.44-50; its comment:
|
||||
-- windows that "rely on a parent frame to be open. If the parent closes or a pushable
|
||||
@@ -939,8 +946,10 @@
|
||||
-- child under the text, shown per entry, with the buttons dropping to -24 to clear it
|
||||
-- and the frame growing 16px. The AMOUNT is not the engine's — the entry's own
|
||||
-- OnShow/the event arm calls MoneyFrame_Update, exactly as the reference does.
|
||||
-- · Accepted but v1-inert: exclusive, interruptCinematic (cinematics are acked
|
||||
-- instantly — 0196), notClosableByLogout, preferredIndex.
|
||||
-- · interruptCinematic is LIVE as of the cinematic arc: a dialog without it is
|
||||
-- refused while one plays, the ref's own l.1454 gate (it was inert only while
|
||||
-- 0196 acked every cinematic instantly, so nothing could be playing).
|
||||
-- · Accepted but v1-inert: exclusive, notClosableByLogout, preferredIndex.
|
||||
-- · text_arg1/2 stash on the DIALOG frame (the ref stashes them on the Text region;
|
||||
-- our regions don't carry Lua fields).
|
||||
-- · GetText("SECONDS"/"MINUTES") localization collapses to English singular/plural.
|
||||
@@ -1030,6 +1039,17 @@
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The cinematic gate (ref l.1454-1459), the whileDead gate's twin: a fly-by owns the
|
||||
-- screen, so a dialog that has not declared itself worth interrupting one is cancelled
|
||||
-- rather than raised over it. `InCinematic` is guarded like the predicate above so a
|
||||
-- VM without the binding behaves as it did before.
|
||||
if InCinematic and InCinematic() and not info.interruptCinematic then
|
||||
if info.OnCancel then
|
||||
info.OnCancel()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Showing X hides a visible X.cancels with an "override" cancel (ref l.1470-1485).
|
||||
if info.cancels then
|
||||
local frame = StaticPopup_FindVisible(info.cancels)
|
||||
|
||||
@@ -455,6 +455,11 @@ ScriptLogFrame.xml
|
||||
# requirement rather than legibility is **after BuffFrame.xml**: the toast re-anchors
|
||||
# TemporaryEnchantFrame out of its own way, and that frame is declared there.
|
||||
HelpFrame.xml
|
||||
# The cinematic letterbox (the race-intro fly-by's two black bars, and the ESC that skips it).
|
||||
# Needs UiPanels.xml far above for ShowUIPanel/HideUIPanel and its own UIPanelWindows row; needs
|
||||
# nothing else, and nothing needs it — the engine reaches it purely by firing CINEMATIC_START.
|
||||
# Seated late because it is an outermost, screen-owning layer, like the two windows below it.
|
||||
CinematicFrame.xml
|
||||
# The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
|
||||
# the templates + script of the Options window's Keybindings category, over the
|
||||
# engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
//! **Cinematic playback** — the race-intro fly-by, and every other `SMSG_TRIGGER_CINEMATIC`.
|
||||
//!
|
||||
//! A cinematic is an in-engine camera flight, not a movie: the trigger carries a
|
||||
//! `CinematicSequences.dbc` id, the row names its `CinematicCamera.dbc` shots, and each shot is an
|
||||
//! authored eye/target/roll path in a `Cameras\*.m2` planted at a world origin and facing. The
|
||||
//! parsing, the world transform and the Bézier evaluation all live in
|
||||
//! [`benilla_formats::CinematicPath`]; this module is the *playback* — when it starts, what it
|
||||
//! takes over while it runs, and how it ends.
|
||||
//!
|
||||
//! # What the reference does, and what we match
|
||||
//!
|
||||
//! Byte-verified in wow-re (the cinematic dispatch, 2026-08-29), and the reason each piece here is
|
||||
//! shaped the way it is:
|
||||
//!
|
||||
//! - **A trigger that arrives before the world is up is deferred, not dropped.** The reference
|
||||
//! stashes the sequence id in a single-slot latch (`0xc4d75c`) whenever its world-load gate is
|
||||
//! still closed, and starts it the instant the gate opens (`0x5deb78`). Last write wins; there
|
||||
//! is no queue. [`Cinematic::pending`] is that latch, and the gate here is the loading screen —
|
||||
//! a first login's intro would otherwise play its opening seconds behind the cover, with no UI
|
||||
//! to ESC out of.
|
||||
//! - **The camera path is armed as an ordinary M2 animation** (`0x7121a0`, sequence 0, rate 1.0)
|
||||
//! and the shot ends when the scene clock reaches the sequence band's end — `M2Sequence.end −
|
||||
//! .start`, which is [`CinematicPath::duration_ms`]. Every shipped fly-by is `flags` bit 0 =
|
||||
//! clamp, i.e. plays **once and freezes**; it does not loop. (`Scry_cam`, which is not a race
|
||||
//! intro, is the one file authored to loop — a difference we inherit for free by ending on the
|
||||
//! band rather than on the flag.)
|
||||
//! - **Between the shots of a multi-camera row the client sends `CMSG_NEXT_CINEMATIC_CAMERA`**
|
||||
//! (`0x48efe0`), and a camera id of `0` **ends** the cinematic rather than being skipped. No
|
||||
//! shipped row has a second camera, so this path is exercised only by a server with its own
|
||||
//! DBCs — which is exactly why it is written rather than assumed away.
|
||||
//! - **ESC is not an engine binding.** `StopCinematic` has zero native callers in the reference:
|
||||
//! the only skip path is `CinematicFrame.xml`'s own `OnKeyDown`, which is why benilla's copy of
|
||||
//! that frame (`assets/ui/CinematicFrame.xml`) carries the same handler and why the Lua binding
|
||||
//! queues [`SessionRequest::StopCinematic`](benilla_ui::script::SessionRequest) rather than
|
||||
//! reaching in here.
|
||||
//! - **The ack ends the run, once.** `CMSG_COMPLETE_CINEMATIC` goes out on a natural end and on an
|
||||
//! ESC skip alike (`0x48f080`). Decision 0196 is why it can never be dropped: unacked, vmangos
|
||||
//! re-anchors object visibility to its own copy of the flying camera and everything around the
|
||||
//! body despawns until relog.
|
||||
//!
|
||||
//! # What we deliberately do differently
|
||||
//!
|
||||
//! The reference defers both the start and the stop by **0.25 s** through a scheduled fade
|
||||
//! (`0x4c0d10`, the constant at `[0x804550]`). benilla plays the shot immediately and acks
|
||||
//! immediately: the fade is a transition effect we have not built, and faking its *delay* without
|
||||
//! its *picture* would only add latency. Noted rather than silently dropped — it is the one timing
|
||||
//! difference from the reference on this path.
|
||||
//!
|
||||
//! # What playback takes over
|
||||
//!
|
||||
//! Three things, all released on the way out. The **camera** (this module writes the
|
||||
//! [`WorldCamera`] pose and FOV after `control` has seated it, the same slot
|
||||
//! `apply_camera_shake` uses); the **streaming focus**, which has to follow the camera rather than
|
||||
//! the body, because a Tauren's shot opens 1741 yards from where the body stands and would
|
||||
//! otherwise fly over unstreamed terrain; and the **UI's cinematic flag**, which drives
|
||||
//! `CinematicFrame`'s letterbox and makes `InCinematic()` answer truthfully so `StaticPopup`
|
||||
//! suppresses dialogs the way the reference's does.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
use benilla_formats::{CinematicCatalog, CinematicPath};
|
||||
use benilla_ui::script::UiScript;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
use benilla_world::view::{WorldCamera, CAM_FOVY};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::ClientState;
|
||||
use crate::loading_screen::LoadingScreen;
|
||||
use crate::net::{CinematicTriggeredMessage, ClientCommand, NetCommands};
|
||||
use crate::player::PlayerControlSet;
|
||||
|
||||
/// Both cinematic DBCs, read once at startup.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Cinematics(pub(crate) CinematicCatalog);
|
||||
|
||||
/// The shot being played, plus the deferred-start latch.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Cinematic {
|
||||
/// The sequence a trigger asked for but the world was not ready to show — the reference's
|
||||
/// single-slot latch (`0xc4d75c`), last write wins, no queue.
|
||||
pending: Option<u32>,
|
||||
playing: Option<Playing>,
|
||||
}
|
||||
|
||||
/// One cinematic in flight.
|
||||
struct Playing {
|
||||
/// The `CinematicSequences.dbc` id, for logging.
|
||||
sequence_id: u32,
|
||||
/// The row's shots, in order. Non-empty (a sequence that resolves to nothing is acked at the
|
||||
/// trigger and never becomes a `Playing`).
|
||||
shots: Vec<CinematicPath>,
|
||||
/// Which shot is on screen.
|
||||
index: usize,
|
||||
/// Time inside the current shot.
|
||||
elapsed: Duration,
|
||||
}
|
||||
|
||||
impl Playing {
|
||||
fn shot(&self) -> &CinematicPath {
|
||||
&self.shots[self.index]
|
||||
}
|
||||
}
|
||||
|
||||
impl Cinematic {
|
||||
/// Is a cinematic on screen right now? The engine half of `InCinematic()`.
|
||||
pub(crate) fn is_playing(&self) -> bool {
|
||||
self.playing.is_some()
|
||||
}
|
||||
|
||||
/// The shot on screen: `(sequence id, shot index, narration sound id)`. The identity pair is
|
||||
/// what lets a follower tell "still the same shot" from "the next one", which is the question
|
||||
/// the narration channel actually asks.
|
||||
pub(crate) fn playing_shot(&self) -> Option<(u32, usize, u32)> {
|
||||
let play = self.playing.as_ref()?;
|
||||
Some((play.sequence_id, play.index, play.shot().sound_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the two letterbox bars — full-width, black, top or bottom.
|
||||
///
|
||||
/// **Bevy UI nodes, not FrameXML quads, and that is the whole point.** The HUD is hidden during a
|
||||
/// cinematic through [`UiHidden`], which kills both of `ui_pass`'s quad lanes wholesale — the
|
||||
/// FrameXML layer, the minimap, chat bubbles, combat text, all of it together. Bars drawn as
|
||||
/// FrameXML textures would go dark with everything else. The glue and loading screens already sit
|
||||
/// on the other side of that line (`ui_hide`'s own list: "the glue/loading screens (Bevy UI nodes,
|
||||
/// not quads)"), so the letterbox joins them there.
|
||||
///
|
||||
/// `CinematicFrame` still exists and still shows — it is what makes `InCinematic()` true, fires
|
||||
/// the events, and owns the ESC handler. It just no longer paints the bars, because while it is
|
||||
/// up the lane it paints into is dark.
|
||||
#[derive(Component)]
|
||||
struct LetterboxBar;
|
||||
|
||||
pub(crate) struct CinematicPlugin;
|
||||
|
||||
impl Plugin for CinematicPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Cinematic>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
(load_catalog.after(AssetSet::Open), spawn_letterbox),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
// One chain, all three steps in the same frame: the net drain has already run
|
||||
// (`WorldStage::Net` precedes `Input`), so a trigger can arrive, start and be
|
||||
// driven without ever showing a frame of the follow camera in between. And they
|
||||
// run *after* `control` has seated that camera, so the pose written here is the
|
||||
// last word on it — the same slot `apply_camera_shake` takes.
|
||||
(take_trigger, start_pending, drive)
|
||||
.chain()
|
||||
.in_set(WorldStage::Input)
|
||||
.after(PlayerControlSet),
|
||||
)
|
||||
// The UI edge runs after the driver settled this frame's state, and only once there
|
||||
// IS a UI: firing CINEMATIC_START into a VM with no `CinematicFrame` yet would raise
|
||||
// no letterbox and leave nothing listening for the ESC that ends the shot.
|
||||
.add_systems(
|
||||
Update,
|
||||
feed_ui
|
||||
.in_set(WorldStage::Input)
|
||||
.after(drive)
|
||||
.run_if(not(crate::ui_script::ingame_ui_pending)),
|
||||
)
|
||||
// A cinematic cannot outlive the world it was flying over: leaving drops it silently,
|
||||
// with no ack, exactly as the reference's own leave-world teardown does (`0x490a80`
|
||||
// clears the in-cinematic flag and sends no `CMSG_COMPLETE_CINEMATIC` — the socket is
|
||||
// going away anyway).
|
||||
// The screen's two takeovers — the HUD going dark and the bars coming in — ride the
|
||||
// same edge as everything else, after the driver has settled this frame's state.
|
||||
.add_systems(
|
||||
Update,
|
||||
drive_letterbox.in_set(WorldStage::Input).after(drive),
|
||||
)
|
||||
.add_systems(OnExit(ClientState::InWorld), abandon_on_leaving_world);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two bars, spawned once and parked hidden — the loading cover's own shape.
|
||||
fn spawn_letterbox(mut commands: Commands) {
|
||||
for top in [true, false] {
|
||||
commands.spawn((
|
||||
LetterboxBar,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: if top { Val::Px(0.0) } else { Val::Auto },
|
||||
bottom: if top { Val::Auto } else { Val::Px(0.0) },
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(0.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::BLACK),
|
||||
// Above the world and the UI quads, below the loading cover (1000): a cinematic that
|
||||
// runs into a zone load should be covered by the load, not paint over it.
|
||||
GlobalZIndex(900),
|
||||
Visibility::Hidden,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Raise the letterbox and darken the HUD while a shot is on screen; put both back after.
|
||||
///
|
||||
/// The bar height is the reference's own law, the one `CinematicFrame` carries in Lua: only a
|
||||
/// screen wider than 4:3 gets bars, and there the picture is cropped to **2:1** — `width/2`,
|
||||
/// capped at the screen height, with the remainder split evenly. Computed here per frame so a
|
||||
/// window resized mid-cinematic stays letterboxed correctly.
|
||||
fn drive_letterbox(
|
||||
cine: Res<Cinematic>,
|
||||
mut hidden: ResMut<crate::ui_hide::UiHidden>,
|
||||
mut bars: Query<(&mut Node, &mut Visibility), With<LetterboxBar>>,
|
||||
windows: Query<&Window>,
|
||||
mut ours: Local<bool>,
|
||||
) {
|
||||
let playing = cine.is_playing();
|
||||
// Only ever un-hide a UI *we* hid: a player who pressed ALT-Z before the cinematic keeps
|
||||
// their choice when it ends.
|
||||
if playing && !hidden.0 {
|
||||
hidden.0 = true;
|
||||
*ours = true;
|
||||
info!("cinematic: HUD hidden for playback");
|
||||
} else if !playing && *ours {
|
||||
hidden.0 = false;
|
||||
*ours = false;
|
||||
info!("cinematic: HUD restored");
|
||||
}
|
||||
|
||||
let height = playing
|
||||
.then(|| windows.iter().next())
|
||||
.flatten()
|
||||
.map_or(0.0, |w| {
|
||||
let (width, screen) = (w.width(), w.height().max(1.0));
|
||||
if width / screen <= 4.0 / 3.0 {
|
||||
return 0.0;
|
||||
}
|
||||
((screen - (width / 2.0).min(screen)) / 2.0).max(0.0)
|
||||
});
|
||||
if *ours && height > 0.0 {
|
||||
// Once per cinematic, not per frame: the measured crop, so the letterbox is a number in
|
||||
// the log rather than something only an eye can confirm.
|
||||
debug!("cinematic: letterbox bar {height:.1} px");
|
||||
}
|
||||
for (mut node, mut vis) in &mut bars {
|
||||
let want = if height > 0.0 {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
if node.height != Val::Px(height) {
|
||||
node.height = Val::Px(height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_catalog(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
match benilla_formats::load_cinematics(&mut chain) {
|
||||
Ok(cat) => {
|
||||
info!(
|
||||
"cinematic: {} sequences, {} cameras",
|
||||
cat.sequence_count(),
|
||||
cat.camera_count()
|
||||
);
|
||||
commands.insert_resource(Cinematics(cat));
|
||||
}
|
||||
// Graceful absence, the standing posture: with no catalog every trigger falls through to
|
||||
// the immediate ack, which is exactly the pre-playback behaviour decision 0196 shipped.
|
||||
Err(e) => warn!("cinematic: tables failed to load: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Latch a triggered cinematic (or ack it immediately, if it names nothing we can play).
|
||||
fn take_trigger(
|
||||
mut triggered: MessageReader<CinematicTriggeredMessage>,
|
||||
mut cine: ResMut<Cinematic>,
|
||||
catalog: Option<Res<Cinematics>>,
|
||||
net: Option<Res<NetCommands>>,
|
||||
) {
|
||||
for msg in triggered.read() {
|
||||
let id = msg.cinematic_id;
|
||||
let playable = catalog
|
||||
.as_deref()
|
||||
.is_some_and(|c| !c.0.shots(id).is_empty());
|
||||
if !playable {
|
||||
// Nothing to play — ack on the spot rather than leaving the server flying a path
|
||||
// nobody is watching (decision 0196).
|
||||
warn!("cinematic: {id} names no shot we can play — acking it");
|
||||
ack(net.as_deref());
|
||||
continue;
|
||||
}
|
||||
cine.pending = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a latched cinematic once the world is actually up.
|
||||
fn start_pending(
|
||||
mut cine: ResMut<Cinematic>,
|
||||
catalog: Option<Res<Cinematics>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
screen: Option<Res<LoadingScreen>>,
|
||||
net: Option<Res<NetCommands>>,
|
||||
state: Res<State<ClientState>>,
|
||||
) {
|
||||
let Some(id) = cine.pending else { return };
|
||||
// The reference's world-load gate. Ours is the loading cover plus being in the world at all:
|
||||
// starting under the cover would burn the opening seconds behind black, and `CinematicFrame`
|
||||
// (the letterbox, and the only ESC route out) does not exist until the in-game UI has loaded.
|
||||
if *state.get() != ClientState::InWorld || screen.is_some_and(|s| s.covering()) {
|
||||
return;
|
||||
}
|
||||
let (Some(catalog), Some(assets)) = (catalog, assets) else {
|
||||
return;
|
||||
};
|
||||
cine.pending = None;
|
||||
|
||||
let rows: Vec<_> = catalog.0.shots(id).into_iter().cloned().collect();
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
let mut shots = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
match CinematicPath::load(&mut chain, row) {
|
||||
Ok(p) => shots.push(p),
|
||||
// One unreadable shot does not have to sink the whole cinematic: play what parses.
|
||||
Err(e) => warn!("cinematic: camera {} failed to load: {e:#}", row.id),
|
||||
}
|
||||
}
|
||||
if shots.is_empty() {
|
||||
warn!("cinematic: {id} had no loadable shot — acking it");
|
||||
ack(net.as_deref());
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
"cinematic: playing {id} — {} shot(s), {} ms",
|
||||
shots.len(),
|
||||
shots.iter().map(|s| s.duration_ms).sum::<u32>()
|
||||
);
|
||||
cine.playing = Some(Playing {
|
||||
sequence_id: id,
|
||||
shots,
|
||||
index: 0,
|
||||
elapsed: Duration::ZERO,
|
||||
});
|
||||
}
|
||||
|
||||
/// Advance the shot and seat the camera on it; hand over to the next shot, or end the cinematic.
|
||||
///
|
||||
/// Runs **after** `control` has seated the follow camera, the same slot `apply_camera_shake` uses:
|
||||
/// the pose written here is this frame's, over a base the controller rewrote from scratch, so
|
||||
/// nothing accumulates and the *pose* needs no restore — the next frame's `control` simply seats
|
||||
/// it again.
|
||||
///
|
||||
/// The **FOV does** need one, and it is taken here rather than left to a neighbour. `scoped_view`
|
||||
/// happens to rewrite the projection unconditionally every frame ahead of `control`, so today the
|
||||
/// narrow cinematic FOV would be undone anyway — but that is its ordering, not our release, and a
|
||||
/// reorder would leave the world permanently telephoto with nothing pointing at why. Ending a
|
||||
/// cinematic puts [`CAM_FOVY`] back itself; the spyglass re-asserts its own value next frame if
|
||||
/// one is up.
|
||||
fn drive(
|
||||
mut cine: ResMut<Cinematic>,
|
||||
time: Res<Time>,
|
||||
net: Option<Res<NetCommands>>,
|
||||
mut camera: Query<(&mut Transform, &mut Projection), With<WorldCamera>>,
|
||||
windows: Query<&Window>,
|
||||
mut was_playing: Local<bool>,
|
||||
) {
|
||||
let release_fov = |camera: &mut Query<(&mut Transform, &mut Projection), With<WorldCamera>>| {
|
||||
if let Ok((_, mut projection)) = camera.single_mut() {
|
||||
if let Projection::Perspective(p) = projection.as_mut() {
|
||||
p.fov = CAM_FOVY;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Every exit lands here: the natural end below releases immediately, and the two that happen
|
||||
// outside this system — an ESC skip, and leaving the world — are caught on the next frame by
|
||||
// this edge. One release path, so no exit can be the one that forgets.
|
||||
let Some(play) = cine.playing.as_mut() else {
|
||||
if std::mem::take(&mut *was_playing) {
|
||||
release_fov(&mut camera);
|
||||
}
|
||||
return;
|
||||
};
|
||||
*was_playing = true;
|
||||
play.elapsed += time.delta();
|
||||
|
||||
// Walk past any shot this frame's delta ran clean through (a long stall, a debugger pause),
|
||||
// so a hitch cannot leave a finished shot on screen or skip the packet between two of them.
|
||||
while play.elapsed.as_millis() as u32 >= play.shot().duration_ms {
|
||||
let over = play.elapsed - Duration::from_millis(u64::from(play.shot().duration_ms));
|
||||
if play.index + 1 >= play.shots.len() {
|
||||
let id = play.sequence_id;
|
||||
cine.playing = None;
|
||||
info!("cinematic: {id} finished");
|
||||
ack(net.as_deref());
|
||||
*was_playing = false;
|
||||
release_fov(&mut camera);
|
||||
return;
|
||||
}
|
||||
play.index += 1;
|
||||
play.elapsed = over;
|
||||
if let Some(net) = net.as_deref() {
|
||||
let _ = net.0.send(ClientCommand::NextCinematicCamera);
|
||||
}
|
||||
}
|
||||
|
||||
let Ok((mut cam, mut projection)) = camera.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let shot = play.shot();
|
||||
let view = shot.sample(play.elapsed.as_millis() as u32);
|
||||
let eye = benilla_assets::coords::wow_to_bevy(view.eye);
|
||||
let target = benilla_assets::coords::wow_to_bevy(view.target);
|
||||
|
||||
// The roll is authored about the view axis, and around whole turns rather than around zero —
|
||||
// `FlyByDwarf` holds a constant 2π — so it is applied as an angle and never tested for zero.
|
||||
// The WoW→Bevy basis is a proper rotation (determinant +1), so the sign carries across
|
||||
// unchanged.
|
||||
let forward = (target - eye).normalize_or_zero();
|
||||
let up = if forward == Vec3::ZERO {
|
||||
Vec3::Y
|
||||
} else {
|
||||
Quat::from_axis_angle(forward, view.roll) * Vec3::Y
|
||||
};
|
||||
cam.translation = eye;
|
||||
if forward != Vec3::ZERO {
|
||||
cam.look_at(target, up);
|
||||
}
|
||||
|
||||
// The authored FOV is a **diagonal** opening angle in the reference's convention, and it is
|
||||
// not uniform across the corpus — the Undead intro is 90° where the other nine are 45° — so
|
||||
// it is read per shot and converted against the live viewport, never assumed.
|
||||
let aspect = windows
|
||||
.iter()
|
||||
.next()
|
||||
.map_or(4.0 / 3.0, |w| w.width() / w.height().max(1.0));
|
||||
if let Projection::Perspective(p) = projection.as_mut() {
|
||||
p.fov = shot.vertical_fov(aspect);
|
||||
}
|
||||
}
|
||||
|
||||
/// ESC, or anything else that asks for the skip: end the cinematic and ack it.
|
||||
///
|
||||
/// The reference sends the same `CMSG_COMPLETE_CINEMATIC` here as it does on a natural end — a
|
||||
/// skip is indistinguishable from a completion on the wire, which is what made decision 0196's
|
||||
/// instant-ack legitimate in the first place.
|
||||
pub(crate) fn stop(cine: &mut Cinematic, net: Option<&NetCommands>) {
|
||||
if let Some(play) = cine.playing.take() {
|
||||
info!("cinematic: {} stopped", play.sequence_id);
|
||||
ack(net);
|
||||
}
|
||||
// A trigger still waiting on the world is dropped too: the player has said "not this".
|
||||
if cine.pending.take().is_some() {
|
||||
ack(net);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving the world drops a cinematic without acking — the socket is going away with it.
|
||||
fn abandon_on_leaving_world(mut cine: ResMut<Cinematic>) {
|
||||
if let Some(play) = cine.playing.take() {
|
||||
info!("cinematic: {} abandoned (left the world)", play.sequence_id);
|
||||
}
|
||||
cine.pending = None;
|
||||
}
|
||||
|
||||
/// Fire the `CINEMATIC_START`/`CINEMATIC_STOP` edges and keep `InCinematic()` honest.
|
||||
///
|
||||
/// Edge-fired off what **this VM** has heard, the `death.rs` posture: a fresh VM's memo is empty,
|
||||
/// so a `/reload` mid-cinematic re-fires `CINEMATIC_START` into the rebuilt frame tree and the
|
||||
/// letterbox comes back up rather than staying lost for the rest of the shot.
|
||||
fn feed_ui(
|
||||
cine: Res<Cinematic>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut published: Local<crate::ui_script::VmMemo<Option<bool>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let playing = cine.is_playing();
|
||||
// VM-scoped (decision 1290): the memo is memory *about a VM*, so a `/reload` mid-cinematic
|
||||
// resets it and the edge re-fires into the rebuilt frame tree — which is the whole point,
|
||||
// because the new tree's `CinematicFrame` is hidden and knows nothing about the shot still on
|
||||
// screen. A plain `Local` here would leave the letterbox down and ESC dead for the rest of a
|
||||
// 102-second intro.
|
||||
let published = published.get(&script);
|
||||
if *published == Some(playing) {
|
||||
return;
|
||||
}
|
||||
*published = Some(playing);
|
||||
script.set_in_cinematic(playing);
|
||||
script.fire_event(
|
||||
if playing {
|
||||
"CINEMATIC_START"
|
||||
} else {
|
||||
"CINEMATIC_STOP"
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
|
||||
fn ack(net: Option<&NetCommands>) {
|
||||
if let Some(net) = net {
|
||||
let _ = net.0.send(ClientCommand::CompleteCinematic);
|
||||
}
|
||||
}
|
||||
@@ -749,9 +749,14 @@ fn drive_go_anim(
|
||||
// The arm is therefore re-issued every ~`span` ms with a fresh
|
||||
// `variationIdx = -1` roll, for ever. For a single-variation id that
|
||||
// is the loop it always was, restarted at its own band start; for a
|
||||
// chain it is the whole point — Onyxia's traps re-roll every 867 ms,
|
||||
// so ~10 % of ~200 of them light a 5 s lava plume at any moment
|
||||
// instead of one flurry at stream-in.
|
||||
// chain it is the whole point — Onyxia's traps re-roll every 867 ms at
|
||||
// a 10 % weight, and with a 5 s ember life that leaves **47 % of them
|
||||
// holding a live plume at any instant** (measured, `WOW_PARTICLE_CENSUS`
|
||||
// in the lair: 49 of 104), instead of one flurry at stream-in. The
|
||||
// period, the re-roll and `R = 1` from a `(0,0)` replay pair are all
|
||||
// byte-converged across two independent §5 rounds; what is *not*
|
||||
// settled is which per-frame advance list a GameObject's model rides
|
||||
// (wow-re `gameobject-anim-arm.md` §6's named residual).
|
||||
active.set_repeat(RepeatAnimation::Never);
|
||||
match play {
|
||||
Play::Motion(_) => {
|
||||
|
||||
@@ -48,6 +48,7 @@ mod capture;
|
||||
mod char_create;
|
||||
mod char_select;
|
||||
mod chat_bubble;
|
||||
mod cinematic;
|
||||
mod combat_text;
|
||||
mod cooldowns;
|
||||
mod creature_anim;
|
||||
@@ -171,6 +172,7 @@ use bevy::prelude::*;
|
||||
use blob_shadow::BlobShadowPlugin;
|
||||
use bowstring::BowstringPlugin;
|
||||
use camera_shake::CameraShakePlugin;
|
||||
use cinematic::CinematicPlugin;
|
||||
use creature_anim::CreatureAnimPlugin;
|
||||
use cursor::CursorPlugin;
|
||||
use entities::EntitiesPlugin;
|
||||
@@ -515,6 +517,10 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
.add_plugins(go_anim::plugin)
|
||||
// Avatar + camera + input.
|
||||
.add_plugins(PlayerPlugin)
|
||||
// Cinematic fly-bys (`SMSG_TRIGGER_CINEMATIC`): the race intro a first login plays, and the
|
||||
// GameObject cameras. Takes the world camera for the duration — hence after PlayerPlugin,
|
||||
// whose `control` it overrides within the same stage (decision 0196's deferred arc).
|
||||
.add_plugins(CinematicPlugin)
|
||||
// The real client's hardware mouse cursor (native NSCursor on macOS).
|
||||
.add_plugins(CursorPlugin)
|
||||
// Net↔ECS bridge: spawns the world thread, exposes the snapshot + writer resources. In capture
|
||||
|
||||
@@ -118,6 +118,7 @@ impl Plugin for NetPlugin {
|
||||
.add_message::<CharListMessage>()
|
||||
.add_message::<CharActionResultMessage>()
|
||||
.add_message::<EnteredWorldMessage>()
|
||||
.add_message::<CinematicTriggeredMessage>()
|
||||
.add_message::<ServerSaidMessage>()
|
||||
.add_message::<LoggedOutMessage>()
|
||||
.add_message::<LoginStageMessage>()
|
||||
@@ -1686,12 +1687,16 @@ pub(crate) enum ClientCommand {
|
||||
},
|
||||
/// `/played` (`CMSG_PLAYED_TIME`).
|
||||
PlayedTime,
|
||||
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`). benilla doesn't
|
||||
/// play cinematics yet, so the Net drain answers every `SMSG_TRIGGER_CINEMATIC` immediately —
|
||||
/// the skip a real player's ESC sends. Without the ack, vmangos anchors object visibility to
|
||||
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`) — sent by
|
||||
/// [`crate::cinematic`] when playback ends or the player ESCs out of it, and immediately for a
|
||||
/// trigger it cannot resolve to a shot. Without the ack, vmangos anchors object visibility to
|
||||
/// the flying cinematic camera (a first login's race intro) and every NPC around the body
|
||||
/// despawns until relog. A future cinematic arc moves this send to the playback's end.
|
||||
/// despawns until relog (decision 0196).
|
||||
CompleteCinematic,
|
||||
/// Advance a multi-camera cinematic to its next shot (`CMSG_NEXT_CINEMATIC_CAMERA`) — see
|
||||
/// [`benilla_protocol::WorldWriter::next_cinematic_camera`]. No shipped 1.12 sequence has a
|
||||
/// second camera, so this rides only a server with its own DBCs.
|
||||
NextCinematicCamera,
|
||||
/// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (decisions
|
||||
/// 0308, 0866): the echoed `counter` + our live pose, on the opcode
|
||||
/// [`MoveMode::ack_opcode`] picks. Sent by the controller the frame the
|
||||
@@ -2090,6 +2095,14 @@ pub(crate) struct CharActionResultMessage {
|
||||
#[derive(Message)]
|
||||
pub(crate) struct EnteredWorldMessage;
|
||||
|
||||
/// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`) — a `CinematicSequences.dbc`
|
||||
/// id. Read by [`crate::cinematic`], which owns the playback *and* the ack (decision 0196: the ack
|
||||
/// is not optional, only late).
|
||||
#[derive(Message)]
|
||||
pub(crate) struct CinematicTriggeredMessage {
|
||||
pub(crate) cinematic_id: u32,
|
||||
}
|
||||
|
||||
/// One `CHAT_MSG_SYSTEM` line — the server's own answer to a GM dot-command ("You set god mode to
|
||||
/// on for …", "There is no such command", "Player not found!"). It has always been *logged*
|
||||
/// (`net: server says — …`, decision 0651); this makes it *readable*, which is what lets a sender
|
||||
|
||||
@@ -134,6 +134,9 @@ pub(super) fn apply_net_updates(
|
||||
// The possession handoff (`SMSG_CLIENT_CONTROL_UPDATE`): control of a unit granted or
|
||||
// revoked. Forwarded verbatim — only the controller knows the pose it would have to park.
|
||||
MessageWriter<super::ClientControlMessage>,
|
||||
// A cinematic to play (`SMSG_TRIGGER_CINEMATIC`) — `crate::cinematic` owns both the
|
||||
// playback and the ack that has to answer it (decision 0196).
|
||||
MessageWriter<super::CinematicTriggeredMessage>,
|
||||
),
|
||||
// One tuple param (the 16-SystemParam ceiling): the ask-once query caches + the gossip/merchant
|
||||
// state the net drain fills for the NPC-interaction windows (decision 0081).
|
||||
@@ -453,6 +456,7 @@ pub(super) fn apply_net_updates(
|
||||
mut server_said,
|
||||
mut self_moves,
|
||||
mut client_control,
|
||||
mut cinematics,
|
||||
) = session_msgs;
|
||||
// Descriptor seeds/deltas for objects created *earlier in this same drain* can't land on their
|
||||
// entities yet (the spawn `Command` hasn't run), so they accumulate here and flush once at the end.
|
||||
@@ -499,7 +503,7 @@ pub(super) fn apply_net_updates(
|
||||
session::char_action_result(action, code, &mut char_actions)
|
||||
}
|
||||
SessionEvent::CinematicTriggered { cinematic_id } => {
|
||||
session::cinematic_triggered(cinematic_id, &net_commands)
|
||||
session::cinematic_triggered(cinematic_id, &mut cinematics)
|
||||
}
|
||||
SessionEvent::Connected {
|
||||
self_guid: guid,
|
||||
|
||||
@@ -20,10 +20,10 @@ use crate::ui_taxi::TaxiState;
|
||||
use crate::ui_trainer::TrainerOpen;
|
||||
|
||||
use super::super::{
|
||||
CharActionResultMessage, CharListMessage, ClientCommand, DisconnectedMessage, DroppedOpcodes,
|
||||
EnteredWorldMessage, GameTime, GuidIndex, LoggedOutMessage, LoginFailedMessage,
|
||||
LoginStageMessage, NetCommands, NetStatus, PendingTransfer, PingShared, Reputations, SelfGuid,
|
||||
ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
|
||||
CharActionResultMessage, CharListMessage, CinematicTriggeredMessage, DisconnectedMessage,
|
||||
DroppedOpcodes, EnteredWorldMessage, GameTime, GuidIndex, LoggedOutMessage, LoginFailedMessage,
|
||||
LoginStageMessage, NetStatus, PendingTransfer, PingShared, Reputations, SelfGuid, ServerTime,
|
||||
ServerWallClock, TeleportMessage, WorldportMessage,
|
||||
};
|
||||
|
||||
/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it.
|
||||
@@ -81,15 +81,21 @@ pub(super) fn character_list(
|
||||
char_lists.write(CharListMessage { characters, realm });
|
||||
}
|
||||
|
||||
/// A cinematic sequence was triggered (`SMSG_TRIGGER_CINEMATIC`).
|
||||
pub(super) fn cinematic_triggered(cinematic_id: u32, net_commands: &NetCommands) {
|
||||
// benilla doesn't play cinematics yet — ack immediately (a real player's ESC
|
||||
// skip). Load-bearing, not cosmetic: while a cinematic runs unacked, vmangos
|
||||
// re-anchors object visibility to the flying camera (`Player::UpdateCinematic`)
|
||||
// and every NPC around the body despawns until relog. A first login's race intro
|
||||
// is the common trigger. The future cinematic arc hooks this event for playback.
|
||||
info!("net: cinematic {cinematic_id} triggered — skipping (unimplemented)");
|
||||
let _ = net_commands.0.send(ClientCommand::CompleteCinematic);
|
||||
/// A cinematic sequence was triggered (`SMSG_TRIGGER_CINEMATIC`) — hand it to
|
||||
/// [`crate::cinematic`], which plays it and owns the ack.
|
||||
///
|
||||
/// **The ack no longer goes out from here, and that is the load-bearing part.** While a cinematic
|
||||
/// runs unacked, vmangos re-anchors object visibility to the flying camera
|
||||
/// (`Player::UpdateCinematic`) and everything around the body despawns until relog (decision 0196)
|
||||
/// — so the ack must still happen, at the *end* of playback rather than instantly. The cinematic
|
||||
/// plugin sends it on a natural end, on an ESC skip, and immediately for a trigger it cannot
|
||||
/// resolve to a shot, so no path drops it.
|
||||
pub(super) fn cinematic_triggered(
|
||||
cinematic_id: u32,
|
||||
triggered: &mut MessageWriter<CinematicTriggeredMessage>,
|
||||
) {
|
||||
info!("net: cinematic {cinematic_id} triggered");
|
||||
triggered.write(CinematicTriggeredMessage { cinematic_id });
|
||||
}
|
||||
|
||||
/// We are in the world (the IO thread's first in-world event): record our guid, flip the status,
|
||||
|
||||
@@ -1029,6 +1029,7 @@ fn writer_loop(
|
||||
ClientCommand::Logout => w.logout_request(),
|
||||
ClientCommand::LogoutCancel => w.logout_cancel(),
|
||||
ClientCommand::CompleteCinematic => w.complete_cinematic(),
|
||||
ClientCommand::NextCinematicCamera => w.next_cinematic_camera(),
|
||||
ClientCommand::MoveModeAck {
|
||||
guid,
|
||||
counter,
|
||||
|
||||
@@ -70,10 +70,18 @@ pub(super) fn publish_view_focus(
|
||||
mut focus: ResMut<ViewFocus>,
|
||||
player: Option<Res<Player>>,
|
||||
roster: Option<Res<crate::char_select::Roster>>,
|
||||
cinematic: Option<Res<crate::cinematic::Cinematic>>,
|
||||
) {
|
||||
let entry = roster
|
||||
.as_deref()
|
||||
.and_then(crate::char_select::Roster::pending_entry);
|
||||
// A cinematic flies the eye away from the body — a Tauren's intro opens 1741 yards out — so
|
||||
// the stream has to follow the *camera* for its duration or the shot crosses unstreamed
|
||||
// terrain. That is what `detached` already means here (the eye is off the body; the zone
|
||||
// authority stays on it), so a fly-by borrows free-fly's answer rather than inventing a third
|
||||
// mode. The server does the mirror-image thing on its side: while a cinematic runs it
|
||||
// re-anchors object visibility to its own copy of the flying camera (decision 0196).
|
||||
let flying = cinematic.as_deref().is_some_and(|c| c.is_playing());
|
||||
// The pacing bit: spawn caps apply only to a live avatar standing in a settled world. Through
|
||||
// entry, a teleport and a world swap the loading cover is absorbing the burst, and a cap there
|
||||
// would only lengthen the reveal.
|
||||
@@ -81,7 +89,7 @@ pub(super) fn publish_view_focus(
|
||||
Some(p) if p.active => {
|
||||
let wow = benilla_assets::coords::bevy_to_wow(p.pos);
|
||||
let paced = !p.settling && !p.world_stale;
|
||||
if p.detached {
|
||||
if p.detached || flying {
|
||||
ViewFocus::detached(wow, paced)
|
||||
} else {
|
||||
ViewFocus::body(wow, paced)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! The cinematic **narration** — the race intro's voice-over, and the handle that lets an ESC cut
|
||||
//! it off mid-sentence.
|
||||
//!
|
||||
//! Every race fly-by names a `SoundEntries.dbc` id on its `CinematicCamera` row, and all eight are
|
||||
//! the same thing: a single streamed `Sound\CinematicVoices\<Race>Narration.mp3` (kit type 31,
|
||||
//! "DwarfFlyByNarration" and its siblings). The two non-intro rows — `PalantirOfAzora` and
|
||||
//! `Scry_cam` — name id 0 and play nothing.
|
||||
//!
|
||||
//! **Why this lives in the sound module rather than in [`crate::cinematic`].** Starting a stream
|
||||
//! and holding its handle is sound-internal (`pick_stream`, the mixer, the category amp are all
|
||||
//! `pub(super)` here), and the handle is the whole point: a dropped kira handle keeps playing, so
|
||||
//! a skipped cinematic whose narration was merely forgotten would keep narrating over the game for
|
||||
//! another minute. The reference has exactly the same problem and the same answer — it keeps the
|
||||
//! cinematic's sound in a dedicated global (`[0xb4e274]`) and *releases* it on every stop path
|
||||
//! (`0x48f050`, and again in the local-abort teardown at `0x490b88`).
|
||||
//!
|
||||
//! So this module watches [`Cinematic`]'s published shot and follows it: a new shot starts its
|
||||
//! narration, and the shot going away stops it. That keeps the coupling one-way — the cinematic
|
||||
//! plugin never learns what a mixer is.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::{LockRecover, WorldAssets};
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
use super::mixer::{self, StreamingSoundHandle};
|
||||
use super::{kit::SoundCategory, kit::SoundKits, SoundConfig, SoundOutput};
|
||||
use crate::cinematic::Cinematic;
|
||||
|
||||
/// How long a cut-off narration takes to fade. Short: an ESC skip should feel like a cut, not a
|
||||
/// dissolve, but a hard stop mid-waveform clicks.
|
||||
const CUT_FADE_MS: u64 = 250;
|
||||
|
||||
/// The narration channel: which shot it belongs to, and its live handle.
|
||||
#[derive(Default)]
|
||||
pub(super) struct CinematicVoice {
|
||||
/// `(sequence id, shot index)` of the shot whose narration is playing — the identity that
|
||||
/// decides whether the current shot's audio is already running.
|
||||
shot: Option<(u32, usize)>,
|
||||
handle: Option<StreamingSoundHandle<kira::sound::FromFileError>>,
|
||||
}
|
||||
|
||||
impl CinematicVoice {
|
||||
fn stop(&mut self) {
|
||||
self.shot = None;
|
||||
if let Some(mut h) = self.handle.take() {
|
||||
h.stop(mixer::fade(CUT_FADE_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
// In `Present`, after the cinematic driver in `Input` has settled this frame's shot: the
|
||||
// narration follows the picture rather than racing it.
|
||||
app.add_systems(Update, drive_narration.in_set(WorldStage::Present));
|
||||
}
|
||||
|
||||
fn drive_narration(
|
||||
cine: Option<Res<Cinematic>>,
|
||||
mut voice: Local<CinematicVoice>,
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
mut kits: ResMut<SoundKits>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
config: Res<SoundConfig>,
|
||||
) {
|
||||
let playing = cine.as_deref().and_then(Cinematic::playing_shot);
|
||||
let Some((sequence, index, sound_id)) = playing else {
|
||||
// No cinematic (or it just ended/was skipped) — cut whatever was narrating.
|
||||
voice.stop();
|
||||
return;
|
||||
};
|
||||
if voice.shot == Some((sequence, index)) {
|
||||
return;
|
||||
}
|
||||
// A new shot: the previous one's narration, if any, does not carry over.
|
||||
voice.stop();
|
||||
voice.shot = Some((sequence, index));
|
||||
if sound_id == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(assets) = assets else { return };
|
||||
let Some(mixer_ref) = out.mixer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some((path, kit_vol)) = kits.pick_stream(sound_id) else {
|
||||
warn!("cinematic voice: sound {sound_id} has no file");
|
||||
return;
|
||||
};
|
||||
let bytes = {
|
||||
let chain = assets.chain.lock_recover();
|
||||
chain.read(&path)
|
||||
};
|
||||
let data = match bytes.and_then(mixer::stream_from_bytes) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("cinematic voice: {path} — {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Speech rides the effects category, the same one every NPC voice line takes — 1.12 ships no
|
||||
// separate dialog slider, so this is where a player's expectation already points.
|
||||
let amp = config.category_amp(SoundCategory::Sfx) * kit_vol;
|
||||
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
|
||||
Ok(h) => {
|
||||
info!("cinematic voice: {path}");
|
||||
voice.handle = Some(h);
|
||||
}
|
||||
Err(e) => warn!("cinematic voice: {path} — {e:#}"),
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use benilla_world::schedule::WorldStage;
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
mod anim_events;
|
||||
mod cinematic;
|
||||
mod combat;
|
||||
mod creature;
|
||||
mod emote;
|
||||
@@ -389,6 +390,7 @@ impl Plugin for SoundPlugin {
|
||||
kit::plugin(app);
|
||||
liquid_loop::plugin(app);
|
||||
zone::plugin(app);
|
||||
cinematic::plugin(app);
|
||||
gameobject::plugin(app);
|
||||
anim_events::plugin(app);
|
||||
spell::plugin(app);
|
||||
|
||||
@@ -131,6 +131,7 @@ fn drain_logout(
|
||||
commands: Res<NetCommands>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
mut reload: ResMut<crate::ui_script::ReloadUiPending>,
|
||||
mut cinematic: ResMut<crate::cinematic::Cinematic>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -174,6 +175,12 @@ fn drain_logout(
|
||||
// Not this module's business beyond routing: the rebuild itself is
|
||||
// [`crate::ui_script::run_pending_reload`]'s, at the top of the next frame.
|
||||
SessionRequest::ReloadUi => reload.0 = true,
|
||||
// ESC out of a cinematic. It rides this queue rather than the binding table because
|
||||
// that is where the reference puts it: `StopCinematic` has no native callers and no
|
||||
// `Bindings.xml` row — `CinematicFrame`'s own `OnKeyDown` is the whole skip path.
|
||||
SessionRequest::StopCinematic => {
|
||||
crate::cinematic::stop(&mut cinematic, Some(&commands));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,16 @@ benilla-dbc = { path = "../benilla-dbc" }
|
||||
benilla-m2 = { path = "../benilla-m2" }
|
||||
benilla-mpq = { path = "../benilla-mpq" }
|
||||
benilla-blp = { path = "../benilla-blp" }
|
||||
image = "0.25"
|
||||
# PNG only, matching every sibling crate. `image`'s DEFAULT features enable `avif`, which drags
|
||||
# in ravif -> rav1e -> av-scenechange: a complete AV1 video ENCODER, monomorphized into this
|
||||
# crate, for the two calls below that both write a PNG. Cutting it takes this crate's graph from
|
||||
# 112 to 48 dependencies and the workspace's from 499 to 466.
|
||||
#
|
||||
# It is NOT why `cargo play` used to fail to link -- that was rustc #86049, fixed by `lto = "off"`
|
||||
# in `[profile.play]` (decision 1698). The tempting story is that rav1e's monomorphized bulk is
|
||||
# what tripped it; the evidence says otherwise, since 2 of the 7 poisoned codegen units held only
|
||||
# our own DBC catalog loaders. Two independent wins, and this is the smaller one. Decision 1698.
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
benilla-wdt = { path = "../benilla-wdt" }
|
||||
benilla-wmo = { path = "../benilla-wmo" }
|
||||
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
//! `CinematicSequences.dbc` + `CinematicCamera.dbc` — the in-engine cinematic fly-bys, and the
|
||||
//! world-space camera path each one resolves to.
|
||||
//!
|
||||
//! A cinematic in 1.12 is **not** a movie file. `SMSG_TRIGGER_CINEMATIC` carries a
|
||||
//! `CinematicSequences.dbc` id; the row names up to eight `CinematicCamera.dbc` cameras; each of
|
||||
//! those names a `Cameras\*.m2` whose single [`M2Camera`](benilla_m2::M2Camera) record *is* the
|
||||
//! shot — an eye path, a look-at path and a roll, keyed over a timeline in the model's own local
|
||||
//! frame — plus the world origin and facing to plant that frame at. The client flies its view
|
||||
//! along it and the world renders normally underneath. (The pre-rendered `.avi` under
|
||||
//! `Data\<locale>\Interface\Cinematics` is the *other* thing, the opening movie; it is not this.)
|
||||
//!
|
||||
//! **The shipped tables, dumped 2026-08-29 (VERIFIED — the bytes, not a wiki):**
|
||||
//! `CinematicSequences.dbc` is `10 × 10 fields × 40 B`: `ID · soundId · camera[8]`. Every shipped
|
||||
//! row uses exactly **one** camera and authors `soundId = 0`. `CinematicCamera.dbc` is
|
||||
//! `10 × 7 × 28 B`: `ID · model (string) · soundId · originX · originY · originZ · originFacing`.
|
||||
//! The model column ships `.mdx`; the archive file is `.m2` ([`camera_model_path`]). The ten rows
|
||||
//! are the eight race intros plus `PalantirOfAzora` and `Scry_cam`. Facing is **radians** — the
|
||||
//! shipped values include `3.14159` and `4.71239` (π and 3π/2) to five decimals, which no degree
|
||||
//! table would.
|
||||
//!
|
||||
//! **The world transform** — `world = origin + Rz(facing)·local`, `z` straight through — is the
|
||||
//! one non-obvious step, and it is checked against an independent oracle rather than assumed:
|
||||
//! vmangos walks its own server-side copy of these paths (`Player::UpdateCinematic`) off a
|
||||
//! hand-built `cinematic_waypoints` table of sampled world positions, and our evaluated path lands
|
||||
//! on those samples (tens of yards over ~600-yard offsets, on a table whose z column is
|
||||
//! ground-level, not camera-level). The three sign/axis alternatives miss by 700–1800 yards. See
|
||||
//! [`CinematicPath::sample`].
|
||||
//!
|
||||
//! **The shipped corpus, read out of the ten `Cameras\*.m2` files** (`fov` radians, `d₀` = how far
|
||||
//! the shot's first eye position sits from its own origin, horizontally):
|
||||
//!
|
||||
//! | cam | model | fov | length | pos keys | roll | d₀ |
|
||||
//! |---|---|---|---|---|---|---|
|
||||
//! | 1 | PalantirOfAzora | 0.7854 | 14.9 s | 10 | const 0 | 205 yd |
|
||||
//! | 2 | FlybyUndead | **1.5708** | 102.0 s | 22 | 18 keys | 523 yd |
|
||||
//! | 122 | FlybyNightElf | 0.7854 | 102.3 s | 32 | 19 keys | 813 yd |
|
||||
//! | 142 | FlyByHuman | 0.7854 | 87.5 s | 20 | const 2π | 663 yd |
|
||||
//! | 162 | FlyByGnome | 0.7854 | 76.7 s | 27 | const 0 | 705 yd |
|
||||
//! | 182 | FlyByTroll | 0.7854 | 57.8 s | 21 | 12 keys | 39 yd |
|
||||
//! | 202 | FlyByTauren | 0.7854 | 70.3 s | 19 | 7 keys | 1741 yd |
|
||||
//! | 224 | Scry_cam | 0.7854 | 3.3 s | 1 | const 3π | 0 yd |
|
||||
//! | 234 | FlyByDwarf | 0.7854 | 59.6 s | 28 | const 2π | 652 yd |
|
||||
//! | 235 | FlybyOrc | 0.7854 | 70.2 s | 22 | 14 keys | 1100 yd |
|
||||
//!
|
||||
//! Three things there are load-bearing and none of them are guessable. **The Undead intro's FOV is
|
||||
//! 90°, not 45°** — a client that hard-coded the other nine's value would render that one shot at
|
||||
//! half the intended width. **Roll is authored around multiples of 2π rather than around 0**
|
||||
//! (`FlyByDwarf` holds a constant `6.2832`; `Scry_cam` holds `3π`), so it is an *angle to apply*,
|
||||
//! and a "roll is zero, skip it" shortcut happens to be right only by the identity `2π ≡ 0`; five
|
||||
//! of the ten genuinely animate it. And the shots **start far from their own origin** — a Tauren
|
||||
//! 1741 yards out — which is why the server re-anchors object visibility to the flying camera
|
||||
//! while one runs (decision 0196), and why the client has to stream the world from the *camera*
|
||||
//! and not the avatar for the duration.
|
||||
//!
|
||||
//! **What this module deliberately does not decide.** The authored `near`/`far` clip (`0.22222` /
|
||||
//! `27.7778` = 8/36 and 1000/36, identical on all ten) are carried, not applied: a 27.8-yard far
|
||||
//! plane would render the dwarf intro's mountains as empty sky, so whatever the reference does
|
||||
//! with those two floats, it is not "install them as the world clips". Which camera of a
|
||||
//! multi-camera row plays, what ends a shot, and how `fov` reaches the world projection are the
|
||||
//! reference's to state (the wow-re cinematic dispatch, 2026-08-29).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
use benilla_m2::{M2Camera, M2SplineKey, M2Track};
|
||||
|
||||
use crate::dbc::{f32_at, parse, str_at, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const SEQUENCES: &str = "DBFilesClient\\CinematicSequences.dbc";
|
||||
const CAMERAS: &str = "DBFilesClient\\CinematicCamera.dbc";
|
||||
|
||||
/// How many camera slots a `CinematicSequences.dbc` row carries (fields 2..=9).
|
||||
pub const SEQUENCE_CAMERAS: usize = 8;
|
||||
|
||||
/// One `CinematicSequences.dbc` row — what a `SMSG_TRIGGER_CINEMATIC` id resolves to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CinematicSequence {
|
||||
pub id: u32,
|
||||
/// `soundId` (field 1). **Zero on every shipped row** — the sound a fly-by plays comes from
|
||||
/// its *camera* row instead ([`CinematicCameraRow::sound_id`]).
|
||||
pub sound_id: u32,
|
||||
/// The camera ids to play, in order, trailing zeros dropped. Every shipped row holds one.
|
||||
pub cameras: Vec<u32>,
|
||||
}
|
||||
|
||||
/// One `CinematicCamera.dbc` row — a shot: which path model, where to plant it, and the sound.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CinematicCameraRow {
|
||||
pub id: u32,
|
||||
/// The path model as the table ships it (`Cameras\FlyByDwarf.mdx`). Use
|
||||
/// [`camera_model_path`] for the archive path.
|
||||
pub model: String,
|
||||
/// `SoundEntries.dbc` id for the shot's audio, `0` for none (`PalantirOfAzora` and `Scry_cam`
|
||||
/// are the two silent rows).
|
||||
pub sound_id: u32,
|
||||
/// Where the path's local frame is planted, raw WoW world coordinates.
|
||||
pub origin: [f32; 3],
|
||||
/// The local frame's yaw about `+Z`, **radians**.
|
||||
pub origin_facing: f32,
|
||||
}
|
||||
|
||||
/// Both cinematic tables, keyed by row id.
|
||||
///
|
||||
/// `Default` is the **empty** catalog — what "the DBCs failed to load" already means to a caller
|
||||
/// (every lookup misses, and a trigger it cannot resolve is a trigger it skips).
|
||||
#[derive(Default)]
|
||||
pub struct CinematicCatalog {
|
||||
sequences: HashMap<u32, CinematicSequence>,
|
||||
cameras: HashMap<u32, CinematicCameraRow>,
|
||||
}
|
||||
|
||||
impl CinematicCatalog {
|
||||
/// The sequence a `SMSG_TRIGGER_CINEMATIC` id names.
|
||||
pub fn sequence(&self, id: u32) -> Option<&CinematicSequence> {
|
||||
self.sequences.get(&id)
|
||||
}
|
||||
|
||||
/// One camera row by id.
|
||||
pub fn camera(&self, id: u32) -> Option<&CinematicCameraRow> {
|
||||
self.cameras.get(&id)
|
||||
}
|
||||
|
||||
/// The camera rows a sequence plays, in order, skipping any id the camera table doesn't carry.
|
||||
pub fn shots(&self, sequence_id: u32) -> Vec<&CinematicCameraRow> {
|
||||
self.sequence(sequence_id)
|
||||
.into_iter()
|
||||
.flat_map(|s| s.cameras.iter())
|
||||
.filter_map(|id| self.camera(*id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sequence_count(&self) -> usize {
|
||||
self.sequences.len()
|
||||
}
|
||||
|
||||
pub fn camera_count(&self) -> usize {
|
||||
self.cameras.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The archive path for a camera row's model: the table's `.mdx` reference mapped to the `.m2` the
|
||||
/// MPQ actually holds (the same normalisation every other model reference in the client takes).
|
||||
pub fn camera_model_path(model: &str) -> String {
|
||||
let stem = model.rsplit_once('.').map_or(model, |(stem, ext)| {
|
||||
match ext.to_ascii_lowercase().as_str() {
|
||||
"mdx" | "mdl" | "m2" => stem,
|
||||
_ => model,
|
||||
}
|
||||
});
|
||||
format!("{stem}.m2")
|
||||
}
|
||||
|
||||
fn sequences_schema() -> Schema {
|
||||
let mut s = Schema::new("CinematicSequences");
|
||||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new("SoundID", FieldType::UInt32));
|
||||
for i in 0..SEQUENCE_CAMERAS {
|
||||
s.add_field(SchemaField::new(format!("Camera{i}"), FieldType::UInt32));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn cameras_schema() -> Schema {
|
||||
let mut s = Schema::new("CinematicCamera");
|
||||
for (name, ty) in [
|
||||
("ID", FieldType::UInt32),
|
||||
("Model", FieldType::String),
|
||||
("SoundID", FieldType::UInt32),
|
||||
("OriginX", FieldType::Float32),
|
||||
("OriginY", FieldType::Float32),
|
||||
("OriginZ", FieldType::Float32),
|
||||
("OriginFacing", FieldType::Float32),
|
||||
] {
|
||||
s.add_field(SchemaField::new(name, ty));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Read both cinematic tables off the patch chain.
|
||||
pub fn load_cinematics(chain: &mut Chain) -> Result<CinematicCatalog> {
|
||||
let bytes = chain
|
||||
.read_file(SEQUENCES)
|
||||
.with_context(|| format!("reading {SEQUENCES}"))?;
|
||||
let rs = parse(&bytes, sequences_schema(), "CinematicSequences")?;
|
||||
let mut sequences = HashMap::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
// Trailing zeros are "no camera", not camera 0 — the shipped rows are one camera then
|
||||
// seven zeros. Stop at the first, so a hypothetical gap can't smuggle a zero in.
|
||||
let cameras = (0..SEQUENCE_CAMERAS)
|
||||
.map_while(|i| u32_at(r, 2 + i).filter(|&c| c != 0))
|
||||
.collect();
|
||||
sequences.insert(
|
||||
id,
|
||||
CinematicSequence {
|
||||
id,
|
||||
sound_id: u32_at(r, 1).unwrap_or(0),
|
||||
cameras,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = chain
|
||||
.read_file(CAMERAS)
|
||||
.with_context(|| format!("reading {CAMERAS}"))?;
|
||||
let rs = parse(&bytes, cameras_schema(), "CinematicCamera")?;
|
||||
let mut cameras = HashMap::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
cameras.insert(
|
||||
id,
|
||||
CinematicCameraRow {
|
||||
id,
|
||||
model: str_at(&rs, r, 1).unwrap_or_default(),
|
||||
sound_id: u32_at(r, 2).unwrap_or(0),
|
||||
origin: [
|
||||
f32_at(r, 3).unwrap_or(0.0),
|
||||
f32_at(r, 4).unwrap_or(0.0),
|
||||
f32_at(r, 5).unwrap_or(0.0),
|
||||
],
|
||||
origin_facing: f32_at(r, 6).unwrap_or(0.0),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(CinematicCatalog { sequences, cameras })
|
||||
}
|
||||
|
||||
/// One instant of a cinematic: where the view is, what it looks at, and how it is banked — all in
|
||||
/// **raw WoW world coordinates** (X north, Y west, Z up), the convention this crate keeps.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CinematicView {
|
||||
pub eye: [f32; 3],
|
||||
pub target: [f32; 3],
|
||||
/// Roll about the view axis, radians. The authored sign, unconverted.
|
||||
pub roll: f32,
|
||||
}
|
||||
|
||||
/// A resolved shot: one camera row's `Cameras\*.m2` path, planted in the world and ready to sample.
|
||||
pub struct CinematicPath {
|
||||
/// The camera row this was built from.
|
||||
pub camera_id: u32,
|
||||
/// The row's `SoundEntries.dbc` narration id, `0` for a silent shot — carried here so a
|
||||
/// consumer that has the shot has everything the shot plays.
|
||||
pub sound_id: u32,
|
||||
/// The authored **diagonal** field of view, radians — `0.7854` (45°) on nine of the ten shots
|
||||
/// and `1.5708` (90°) on the Undead intro, so it is read per shot and never assumed. The
|
||||
/// reference's perspective builder takes the half-angle as `θ = (fov/2)/√(aspect²+1)`, so a
|
||||
/// vertical FOV for a modern projection is `fov / √(aspect²+1)` — see [`Self::vertical_fov`].
|
||||
pub fov: f32,
|
||||
/// The authored near clip, carried unapplied — see the module doc.
|
||||
pub near_clip: f32,
|
||||
/// The authored far clip, carried unapplied — see the module doc.
|
||||
pub far_clip: f32,
|
||||
/// How long the shot runs, milliseconds: the sequence band the tracks are keyed inside.
|
||||
pub duration_ms: u32,
|
||||
origin: [f32; 3],
|
||||
facing_sin_cos: (f32, f32),
|
||||
camera: M2Camera,
|
||||
}
|
||||
|
||||
impl CinematicPath {
|
||||
/// Build a shot from its camera row: read the `.m2` off the chain, take its camera record, and
|
||||
/// plant it at the row's origin/facing.
|
||||
pub fn load(chain: &mut Chain, row: &CinematicCameraRow) -> Result<Self> {
|
||||
let path = camera_model_path(&row.model);
|
||||
let bytes = chain
|
||||
.read_file(&path)
|
||||
.with_context(|| format!("reading cinematic camera model {path}"))?;
|
||||
Self::from_m2_bytes(&bytes, row).with_context(|| format!("parsing {path}"))
|
||||
}
|
||||
|
||||
/// The same, from bytes already in hand (the test/tooling seam).
|
||||
pub fn from_m2_bytes(bytes: &[u8], row: &CinematicCameraRow) -> Result<Self> {
|
||||
// The camera array alone, not a whole model parse: a `Cameras\*.m2` carries no geometry to
|
||||
// parse and the shot is entirely in this one record.
|
||||
let camera = benilla_m2::parse_cameras(bytes)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("model carries no camera record"))?;
|
||||
// The shot's length is the model's own sequence band (stride 0x44 records at header
|
||||
// 0x1c/0x20, band `[start, end]` at +0x04/+0x08 — the same walk the emitter-timing bake
|
||||
// uses). Every shipped fly-by carries exactly one sequence, `[0, end]`, whose end equals
|
||||
// the last key timestamp on its position and target tracks alike; the band is what the
|
||||
// file *says*, so that is what we read, with the last key as the fallback for a file that
|
||||
// authors no sequence at all.
|
||||
let duration_ms = sequence_band_end(bytes)
|
||||
.or_else(|| {
|
||||
[
|
||||
camera.positions.keys.last().map(|k| k.0),
|
||||
camera.target.keys.last().map(|k| k.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.max()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(Self {
|
||||
camera_id: row.id,
|
||||
sound_id: row.sound_id,
|
||||
fov: camera.fov,
|
||||
near_clip: camera.near_clip,
|
||||
far_clip: camera.far_clip,
|
||||
duration_ms,
|
||||
origin: row.origin,
|
||||
facing_sin_cos: row.origin_facing.sin_cos(),
|
||||
camera,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sample the shot at `ms` from its start, **end-clamped** at both ends.
|
||||
///
|
||||
/// Two steps, in this order. The reference's publish pass composes each track against its base
|
||||
/// (`eye = position_base + positions(t)`, `target = target_position_base + target(t)`); then
|
||||
/// the local frame is planted in the world by the camera row's origin and facing:
|
||||
///
|
||||
/// ```text
|
||||
/// world.x = origin.x + local.x·cos(facing) − local.y·sin(facing)
|
||||
/// world.y = origin.y + local.x·sin(facing) + local.y·cos(facing)
|
||||
/// world.z = origin.z + local.z
|
||||
/// ```
|
||||
///
|
||||
/// i.e. a plain yaw about `+Z` by the row's facing, then a translation — no scale, no
|
||||
/// handedness flip. Checked against vmangos's independently sampled `cinematic_waypoints` for
|
||||
/// the dwarf (41) and human (81) intros; the sign-flipped and axis-swapped alternatives are
|
||||
/// off by an order of magnitude (module doc).
|
||||
pub fn sample(&self, ms: u32) -> CinematicView {
|
||||
let eye = self.to_world(sample_against(
|
||||
&self.camera.positions,
|
||||
self.camera.position_base,
|
||||
ms,
|
||||
));
|
||||
let target = self.to_world(sample_against(
|
||||
&self.camera.target,
|
||||
self.camera.target_base,
|
||||
ms,
|
||||
));
|
||||
CinematicView {
|
||||
eye,
|
||||
target,
|
||||
roll: self.camera.roll.sample_ms(ms).unwrap_or(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertical FOV a modern projection wants, for a viewport of aspect `width/height`:
|
||||
/// `fov / √(aspect² + 1)`, inverting the reference's diagonal-FOV perspective builder
|
||||
/// (`m11 = 1/tan(θ)`, `θ = (fov/2)/√(aspect²+1)`, wow-re `portrait-projection-aspect.md`).
|
||||
pub fn vertical_fov(&self, aspect: f32) -> f32 {
|
||||
self.fov / (aspect * aspect + 1.0).sqrt()
|
||||
}
|
||||
|
||||
fn to_world(&self, local: [f32; 3]) -> [f32; 3] {
|
||||
let (s, c) = self.facing_sin_cos;
|
||||
[
|
||||
self.origin[0] + local[0] * c - local[1] * s,
|
||||
self.origin[1] + local[0] * s + local[1] * c,
|
||||
self.origin[2] + local[2],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// A camera track sampled and composed against its base — the reference's publish form.
|
||||
fn sample_against(track: &M2Track<M2SplineKey<[f32; 3]>>, base: [f32; 3], ms: u32) -> [f32; 3] {
|
||||
let d = track.sample_ms(ms).unwrap_or([0.0; 3]);
|
||||
std::array::from_fn(|i| base[i] + d[i])
|
||||
}
|
||||
|
||||
/// The end of the model's **first** sequence band (header `0x1c` count / `0x20` offset, entry
|
||||
/// stride `0x44`, band `[start, end]` at `+0x04`/`+0x08`). `None` for a model with no sequences.
|
||||
fn sequence_band_end(b: &[u8]) -> Option<u32> {
|
||||
let le_u32 = |o: usize| -> Option<u32> {
|
||||
b.get(o..o + 4)
|
||||
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
};
|
||||
let (n, o) = (le_u32(0x1c)?, le_u32(0x20)? as usize);
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
le_u32(o + 0x08)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The eight race intros, by `ChrRaces.dbc` `CinematicSequence` (field 16) — the ids a first
|
||||
/// login actually sends (VERIFIED in the shipped `ChrRaces.dbc`, and matching vmangos's
|
||||
/// `SendCinematicStart(rEntry->CinematicSequence)`).
|
||||
const RACE_INTROS: [u32; 8] = [2, 21, 41, 61, 81, 101, 121, 141];
|
||||
|
||||
#[test]
|
||||
fn model_paths_map_mdx_to_the_archive_m2() {
|
||||
assert_eq!(
|
||||
camera_model_path("Cameras\\FlyByDwarf.mdx"),
|
||||
"Cameras\\FlyByDwarf.m2"
|
||||
);
|
||||
assert_eq!(camera_model_path("Cameras\\X.MDX"), "Cameras\\X.m2");
|
||||
assert_eq!(camera_model_path("Cameras\\X.m2"), "Cameras\\X.m2");
|
||||
// A path with no extension at all still names an .m2, and a dot inside a directory name
|
||||
// is not an extension.
|
||||
assert_eq!(camera_model_path("Cameras\\X"), "Cameras\\X.m2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_cinematic_tables_are_the_shipped_shape() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_cinematics(&mut chain).expect("load cinematic tables");
|
||||
assert_eq!(cat.sequence_count(), 10);
|
||||
assert_eq!(cat.camera_count(), 10);
|
||||
|
||||
// Every shipped sequence names exactly one camera and carries no sound of its own.
|
||||
for id in RACE_INTROS {
|
||||
let seq = cat.sequence(id).unwrap_or_else(|| panic!("sequence {id}"));
|
||||
assert_eq!(seq.cameras.len(), 1, "sequence {id} camera count");
|
||||
assert_eq!(seq.sound_id, 0, "sequence {id} sound");
|
||||
}
|
||||
|
||||
// The dwarf intro, end to end — the row decision 0196 captured live.
|
||||
let dwarf = cat.shots(41);
|
||||
assert_eq!(dwarf.len(), 1);
|
||||
let cam = dwarf[0];
|
||||
assert_eq!(cam.id, 234);
|
||||
assert_eq!(cam.model, "Cameras\\FlyByDwarf.mdx");
|
||||
assert_eq!(cam.sound_id, 3740);
|
||||
assert!((cam.origin[0] - -5579.16).abs() < 0.01);
|
||||
assert!((cam.origin[1] - -455.776).abs() < 0.01);
|
||||
assert!((cam.origin[2] - 406.476).abs() < 0.01);
|
||||
// Radians, not degrees: 4.71239 = 3π/2 to five decimals.
|
||||
assert!((cam.origin_facing - std::f32::consts::FRAC_PI_2 * 3.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_flyby_shots_are_bezier_and_end_on_their_sequence_band() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_cinematics(&mut chain).expect("load cinematic tables");
|
||||
for id in RACE_INTROS {
|
||||
let row = cat.shots(id)[0].clone();
|
||||
let path = CinematicPath::load(&mut chain, &row)
|
||||
.unwrap_or_else(|e| panic!("sequence {id} path: {e:#}"));
|
||||
// The shot is a real flight, not a single parked key.
|
||||
assert!(path.duration_ms > 30_000, "sequence {id} duration");
|
||||
assert!(
|
||||
path.camera.positions.keys.len() >= 10,
|
||||
"sequence {id} is richly keyed"
|
||||
);
|
||||
// Cubic Bézier on both vector tracks — the case the four-way interp dispatch exists
|
||||
// for, and the one a step/linear-only sampler would silently mangle.
|
||||
assert_eq!(path.camera.positions.interp, 2, "sequence {id} position");
|
||||
assert_eq!(path.camera.target.interp, 2, "sequence {id} target");
|
||||
// **The end condition**: the authored sequence band ends exactly on the last key of
|
||||
// both vector tracks, on every shipped fly-by — so "the band" and "the last key" are
|
||||
// the same number, and reading either gives the same playback length.
|
||||
for (what, track) in [
|
||||
("position", &path.camera.positions),
|
||||
("target", &path.camera.target),
|
||||
] {
|
||||
assert_eq!(
|
||||
track.keys.last().map(|k| k.0),
|
||||
Some(path.duration_ms),
|
||||
"sequence {id} {what} track ends on the band"
|
||||
);
|
||||
}
|
||||
// The clips are uniform across the corpus; the FOV is NOT — the Undead intro is 90°
|
||||
// where the rest are 45°, which is exactly why it is read per shot.
|
||||
assert!((path.near_clip - 8.0 / 36.0).abs() < 1e-6);
|
||||
assert!((path.far_clip - 1000.0 / 36.0).abs() < 1e-4);
|
||||
let want_fov = if id == 2 {
|
||||
std::f32::consts::FRAC_PI_2
|
||||
} else {
|
||||
std::f32::consts::FRAC_PI_4
|
||||
};
|
||||
assert!(
|
||||
(path.fov - want_fov).abs() < 1e-4,
|
||||
"sequence {id} fov: {} vs {want_fov}",
|
||||
path.fov
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dwarf intro's world path, pinned at three instants.
|
||||
///
|
||||
/// These numbers are the **transform's** golden: they were derived independently (a
|
||||
/// hand-written evaluator over the raw bytes) and then cross-checked, time-independently,
|
||||
/// against vmangos's `cinematic_waypoints` samples for this cinematic — our arc passes within
|
||||
/// 59.6 yd horizontally of every one of the server's six samples (mean 35.5). The three
|
||||
/// alternative conventions do far worse on the same measure: `Rz(−facing)` 436.8 yd,
|
||||
/// axis-swapped 334.0, no rotation at all 859.9. So a sign flip here fails this test loudly.
|
||||
#[test]
|
||||
fn real_dwarf_intro_flies_the_authored_arc() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_cinematics(&mut chain).expect("load cinematic tables");
|
||||
let row = cat.shots(41)[0].clone();
|
||||
let path = CinematicPath::load(&mut chain, &row).expect("dwarf path");
|
||||
assert_eq!(path.duration_ms, 59_600);
|
||||
|
||||
let near = |got: [f32; 3], want: [f32; 3], what: &str| {
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(got[i] - want[i]).abs() < 0.05,
|
||||
"{what}[{i}]: got {}, want {}",
|
||||
got[i],
|
||||
want[i]
|
||||
);
|
||||
}
|
||||
};
|
||||
let start = path.sample(0);
|
||||
near(start.eye, [-5041.888, -824.646, 541.267], "start eye");
|
||||
near(start.target, [-5021.802, -836.789, 539.912], "start target");
|
||||
let mid = path.sample(30_000);
|
||||
near(mid.eye, [-5666.181, -425.222, 473.426], "mid eye");
|
||||
near(mid.target, [-5715.481, -427.434, 450.394], "mid target");
|
||||
let end = path.sample(path.duration_ms);
|
||||
near(end.eye, [-6246.921, 333.773, 384.187], "end eye");
|
||||
// Past the end the path holds its last key — it does not wrap to the start.
|
||||
assert_eq!(path.sample(u32::MAX).eye, end.eye);
|
||||
// Roll here is a single key holding **2π**, not 0 — the authored angles sit around whole
|
||||
// turns (`Scry_cam` holds 3π), so roll is applied as an angle and only *happens* to be an
|
||||
// identity on this shot.
|
||||
assert!(
|
||||
(start.roll - std::f32::consts::TAU).abs() < 1e-4,
|
||||
"{}",
|
||||
start.roll
|
||||
);
|
||||
assert_eq!(mid.roll, start.roll, "a single-key roll track is constant");
|
||||
}
|
||||
|
||||
/// The shots range far from their own origin — the reason the server re-anchors object
|
||||
/// visibility to the flying camera while a cinematic runs (decision 0196), and the reason
|
||||
/// benilla has to stream the world from the camera rather than the avatar for the duration.
|
||||
#[test]
|
||||
fn real_flyby_shots_range_far_from_their_origin() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_cinematics(&mut chain).expect("load cinematic tables");
|
||||
for id in RACE_INTROS {
|
||||
let row = cat.shots(id)[0].clone();
|
||||
let path = CinematicPath::load(&mut chain, &row).expect("path");
|
||||
// The furthest the eye gets from the body's neighbourhood over the whole shot. The
|
||||
// troll intro *starts* only 39 yd out, so this is the reach, not the first frame.
|
||||
let reach = (0..=path.duration_ms)
|
||||
.step_by(500)
|
||||
.map(|ms| {
|
||||
let e = path.sample(ms).eye;
|
||||
((e[0] - row.origin[0]).powi(2) + (e[1] - row.origin[1]).powi(2)).sqrt()
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(reach > 300.0, "sequence {id} reaches only {reach:.0} yd");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,15 @@ pub use characters::{
|
||||
StartOutfitItem,
|
||||
};
|
||||
mod camera_shakes;
|
||||
mod cinematics;
|
||||
mod creatures;
|
||||
mod dbc;
|
||||
mod unit_blood;
|
||||
pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog};
|
||||
pub use cinematics::{
|
||||
camera_model_path, load_cinematics, CinematicCameraRow, CinematicCatalog, CinematicPath,
|
||||
CinematicSequence, CinematicView, SEQUENCE_CAMERAS,
|
||||
};
|
||||
pub use creatures::{
|
||||
load_creature_catalog, CreatureCatalog, CreatureModel, FootprintParams, NpcAppearance,
|
||||
};
|
||||
|
||||
@@ -277,19 +277,10 @@ pub struct M2PortraitCamera {
|
||||
/// (stride `0x7c`), selected via `cameraLookup[0]` (`count@0x12c`/`ofs@0x130`, u16 entries) — exactly
|
||||
/// the real client's selection (see [`M2PortraitCamera`]). `None` when the model carries no camera
|
||||
/// table / no lookup slot 0 (some props and a few creatures — the caller falls back to heuristic
|
||||
/// framing). Track key 0 reads are layout-safe for both linear and hermite tracks (a spline key's
|
||||
/// value triple *starts* with the value).
|
||||
/// framing). The `0xffff` "none" sentinel and a malformed lookup both fall out as an out-of-range
|
||||
/// index, which [`parse_m2_camera`] answers with `None`.
|
||||
pub fn parse_m2_portrait_camera(bytes: &[u8]) -> Option<M2PortraitCamera> {
|
||||
let cam_count = bytes.u32_at(0x124)? as usize;
|
||||
let look_count = bytes.u32_at(0x12c)? as usize;
|
||||
let look_ofs = bytes.u32_at(0x130)? as usize;
|
||||
if look_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let idx = bytes.u16_at(look_ofs)? as usize;
|
||||
if idx >= cam_count {
|
||||
return None; // 0xffff "none" sentinel or a malformed lookup
|
||||
}
|
||||
let idx = *benilla_m2::parse_camera_lookup(bytes).first()? as usize;
|
||||
parse_m2_camera(bytes, idx)
|
||||
}
|
||||
|
||||
@@ -297,32 +288,23 @@ pub fn parse_m2_portrait_camera(bytes: &[u8]) -> Option<M2PortraitCamera> {
|
||||
/// create/select background scenes carry exactly one camera whose *lookup* slot holds the 0xffff
|
||||
/// none sentinel, so the portrait selection above sees nothing; the client indexes the table
|
||||
/// directly there). Same record read as the portrait camera.
|
||||
/// This is the **key-0 reduction** of [`benilla_m2::parse_cameras`], which is the one place the
|
||||
/// record's offsets are written down. A pane/portrait camera is static in practice — one key or
|
||||
/// none — so key 0 *is* its evaluated value; a camera whose tracks actually move over time (the
|
||||
/// `Cameras\*.m2` cinematic fly-bys) is sampled through the parsed record instead, never here.
|
||||
pub fn parse_m2_camera(bytes: &[u8], index: usize) -> Option<M2PortraitCamera> {
|
||||
let cam_count = bytes.u32_at(0x124)? as usize;
|
||||
let cam_ofs = bytes.u32_at(0x128)? as usize;
|
||||
if index >= cam_count {
|
||||
return None;
|
||||
}
|
||||
let rec = cam_ofs.checked_add(index.checked_mul(0x7c)?)?;
|
||||
if rec.checked_add(0x7c)? > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let base_plus_key = |base_ofs: usize, track_ofs: usize| -> Option<[f32; 3]> {
|
||||
let b = [
|
||||
bytes.f32_at(base_ofs)?,
|
||||
bytes.f32_at(base_ofs + 4)?,
|
||||
bytes.f32_at(base_ofs + 8)?,
|
||||
];
|
||||
let k = track_first_vec3(bytes, track_ofs).unwrap_or([0.0; 3]);
|
||||
Some([b[0] + k[0], b[1] + k[1], b[2] + k[2]])
|
||||
let cam = benilla_m2::parse_cameras(bytes).into_iter().nth(index)?;
|
||||
let base_plus_key = |base: [f32; 3], track: &benilla_m2::M2Vec3SplineTrack| {
|
||||
let k = track.keys.first().map_or([0.0; 3], |(_, k)| k.value);
|
||||
[base[0] + k[0], base[1] + k[1], base[2] + k[2]]
|
||||
};
|
||||
Some(M2PortraitCamera {
|
||||
fov: bytes.f32_at(rec + 0x04)?,
|
||||
far_clip: bytes.f32_at(rec + 0x08)?,
|
||||
near_clip: bytes.f32_at(rec + 0x0c)?,
|
||||
position: base_plus_key(rec + 0x2c, rec + 0x10)?,
|
||||
target: base_plus_key(rec + 0x54, rec + 0x38)?,
|
||||
roll: track_first_f32(bytes, rec + 0x60).unwrap_or(0.0),
|
||||
fov: cam.fov,
|
||||
far_clip: cam.far_clip,
|
||||
near_clip: cam.near_clip,
|
||||
position: base_plus_key(cam.position_base, &cam.positions),
|
||||
target: base_plus_key(cam.target_base, &cam.target),
|
||||
roll: cam.roll.keys.first().map_or(0.0, |(_, k)| k.value),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,12 +30,15 @@ mod track;
|
||||
|
||||
pub use error::Error;
|
||||
pub use model::{
|
||||
M2ArrayString, M2Attachment, M2BlendMode, M2Bone, M2BoneFlags, M2EventMarker, M2Format,
|
||||
M2Header, M2Material, M2Model, M2PlayableAnim, M2RawData, M2RenderFlags, M2Texture,
|
||||
M2ArrayString, M2Attachment, M2BlendMode, M2Bone, M2BoneFlags, M2Camera, M2EventMarker,
|
||||
M2Format, M2Header, M2Material, M2Model, M2PlayableAnim, M2RawData, M2RenderFlags, M2Texture,
|
||||
M2TextureTransform, M2TextureType, M2Vertex, C2, C3,
|
||||
};
|
||||
pub use skin::{Skin, SkinBatch, SkinSection};
|
||||
pub use track::{M2QuatTrack, M2ScalarTrack, M2Track, M2Vec3Track};
|
||||
pub use track::{
|
||||
CubicValue, M2QuatTrack, M2ScalarSplineTrack, M2ScalarTrack, M2SplineKey, M2Track,
|
||||
M2Vec3SplineTrack, M2Vec3Track,
|
||||
};
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::io::Cursor;
|
||||
@@ -43,7 +46,7 @@ use std::io::Cursor;
|
||||
use benilla_bytes::{capped, ByteExt};
|
||||
|
||||
use error::Result;
|
||||
use track::{track_fix16, track_quat, track_vec3_timed};
|
||||
use track::{track_fix16, track_quat, track_spline_f32, track_spline_vec3, track_vec3_timed};
|
||||
|
||||
/// Read a `C3Vector` (3×f32) at `o`, bounds-checked. `None` iff any of the three reads would run
|
||||
/// past the end — callers map that to their own [`Error::Truncated`].
|
||||
@@ -503,6 +506,8 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
|
||||
texture_transform_lookup,
|
||||
global_sequences,
|
||||
bones: bone_list,
|
||||
cameras: parse_cameras(b),
|
||||
camera_lookup: parse_camera_lookup(b),
|
||||
raw_data: M2RawData {
|
||||
texture_lookup_table: tlt,
|
||||
bounding_triangles: bt_bytes,
|
||||
@@ -528,6 +533,78 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the MD20 **camera** array — header `count@0x124` / `offset@0x128`, stride `0x7c` (see
|
||||
/// [`M2Camera`] for the byte-verified field map).
|
||||
///
|
||||
/// **The one reader for those offsets.** [`parse_m2`] fills [`M2Model::cameras`] with it, and
|
||||
/// callers that want a camera *without* paying for a whole model parse (the portrait/pane framing
|
||||
/// on the asset-load path, the cinematic fly-bys) call it directly — so the record layout is
|
||||
/// written down once. The header offsets are absolute rather than walked because every version
|
||||
/// [`parse_m2`] accepts (256–263) carries the identical array order up to this point.
|
||||
///
|
||||
/// Bounds-tolerant throughout: a truncated table yields the records that fit, and a model with no
|
||||
/// camera array yields an empty `Vec` (the overwhelmingly common case).
|
||||
pub fn parse_cameras(b: &[u8]) -> Vec<M2Camera> {
|
||||
let (Some(count), Some(ofs)) = (b.u32_at(0x124), b.u32_at(0x128)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let avail = b.len().saturating_sub(ofs as usize);
|
||||
let mut out = Vec::with_capacity(capped(count as usize, 0x7c, avail));
|
||||
for i in 0..count as usize {
|
||||
let rec = ofs as usize + i * 0x7c;
|
||||
// A record is read whole or not at all — a file whose camera array is cut short yields the
|
||||
// records that fit, never a half-read one with default tracks standing in for the tail.
|
||||
if rec.checked_add(0x7c).is_none_or(|end| end > b.len()) {
|
||||
break;
|
||||
}
|
||||
let (Some(camera_type), Some(fov), Some(far_clip), Some(near_clip)) = (
|
||||
b.u32_at(rec).map(|v| v as i32),
|
||||
b.f32_at(rec + 0x04),
|
||||
b.f32_at(rec + 0x08),
|
||||
b.f32_at(rec + 0x0c),
|
||||
) else {
|
||||
break;
|
||||
};
|
||||
// The bases are plain `[f32; 3]` (not `C3`) so they add to their track's sampled value
|
||||
// componentwise, the way the reference's publish pass composes them.
|
||||
let (Some(position_base), Some(target_base)) = (
|
||||
rd_c3(b, rec + 0x2c).map(|c| [c.x, c.y, c.z]),
|
||||
rd_c3(b, rec + 0x54).map(|c| [c.x, c.y, c.z]),
|
||||
) else {
|
||||
break;
|
||||
};
|
||||
out.push(M2Camera {
|
||||
camera_type,
|
||||
fov,
|
||||
far_clip,
|
||||
near_clip,
|
||||
positions: track_spline_vec3(b, rec + 0x10),
|
||||
position_base,
|
||||
target: track_spline_vec3(b, rec + 0x38),
|
||||
target_base,
|
||||
roll: track_spline_f32(b, rec + 0x60),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the MD20 **CameraLookup** table — header `count@0x12c` / `offset@0x130`, `u16` entries.
|
||||
/// See [`M2Model::camera_lookup`] for what it selects.
|
||||
pub fn parse_camera_lookup(b: &[u8]) -> Vec<u16> {
|
||||
let (Some(count), Some(ofs)) = (b.u32_at(0x12c), b.u32_at(0x130)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let avail = b.len().saturating_sub(ofs as usize);
|
||||
let mut out = Vec::with_capacity(capped(count as usize, 2, avail));
|
||||
for i in 0..count as usize {
|
||||
let Some(v) = b.u16_at(ofs as usize + i * 2) else {
|
||||
break;
|
||||
};
|
||||
out.push(v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The model-space **Z** of an M2 attachment selected by **attachment id** (not array index) — the
|
||||
/// follow-camera pivot reads id 17 this way. `attachments`/`lookup` are the header `(count, offset)`
|
||||
/// pairs; the id → array-index map (`lookup`, `u16` each) picks the record, whose `position` C3 sits at
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
use std::ffi::CString;
|
||||
|
||||
use crate::track::{M2QuatTrack, M2ScalarTrack, M2Vec3Track};
|
||||
use crate::track::{
|
||||
M2QuatTrack, M2ScalarSplineTrack, M2ScalarTrack, M2Vec3SplineTrack, M2Vec3Track,
|
||||
};
|
||||
|
||||
/// A 3-float vector (position / normal / pivot).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -220,6 +222,44 @@ pub struct M2TextureTransform {
|
||||
pub scaling: M2Vec3Track,
|
||||
}
|
||||
|
||||
/// One M2 **camera** record (header `cameras` @`0x124`, file stride `0x7c`) — the authored
|
||||
/// eye/target path a `Cameras\*.m2` cinematic fly-by *is*, and the same record the portrait bake
|
||||
/// and the `<Model>` panes frame themselves with.
|
||||
///
|
||||
/// Field map VERIFIED at the reference's M2 load `0x70ebd0` + the publish pass `0x718960` (wow-re
|
||||
/// `ui.md` §"The `Model` / `PlayerModel` pane camera", `models.md` §182):
|
||||
/// `type@+0x00 · fov@+0x04 · farClip@+0x08 · nearClip@+0x0c · positions@+0x10 ·
|
||||
/// position_base@+0x2c · target@+0x38 · target_position_base@+0x54 · roll@+0x60`. The load copies
|
||||
/// only fov/far/near into the runtime `CCamera`; the tracks are evaluated by the animate kernel and
|
||||
/// **published against the bases** — `eye = position_base + positions(t)`,
|
||||
/// `target = target_position_base + target(t)`, `roll = roll(t)` — so a keyless track leaves the
|
||||
/// base as the value. Character and creature models author a single `(0,0,0)` key on all three
|
||||
/// (their pane camera is a frozen constant); the fly-bys are the case the tracks exist for.
|
||||
#[derive(Clone)]
|
||||
pub struct M2Camera {
|
||||
/// The record's `type` word. `0` = portrait, `1` = "characterinfo" (the `<PlayerModel>` pane's
|
||||
/// raw index 1); every shipped `Cameras\*.m2` fly-by authors `-1`.
|
||||
pub camera_type: i32,
|
||||
/// **Diagonal** field of view, radians — the reference's `camera_view_lookat` `0x7ac640` takes
|
||||
/// the half-angle as `θ = (fov/2)/√(aspect²+1)`, *not* `fov/2` (wow-re
|
||||
/// `ui/scratch/portrait-projection-aspect.md`). All ten fly-bys author `0.7854` (45°).
|
||||
pub fov: f32,
|
||||
/// Far clip as authored (`+0x08`). Every fly-by carries `27.777779` (= 1000/36).
|
||||
pub far_clip: f32,
|
||||
/// Near clip as authored (`+0x0c`). Every fly-by carries `0.22222222` (= 8/36).
|
||||
pub near_clip: f32,
|
||||
/// The eye path, relative to [`Self::position_base`].
|
||||
pub positions: M2Vec3SplineTrack,
|
||||
/// The eye's base position, model space (`+0x2c`).
|
||||
pub position_base: [f32; 3],
|
||||
/// The look-at path, relative to [`Self::target_base`].
|
||||
pub target: M2Vec3SplineTrack,
|
||||
/// The look-at base position, model space (`+0x54`).
|
||||
pub target_base: [f32; 3],
|
||||
/// Roll about the view axis, radians (`+0x60`).
|
||||
pub roll: M2ScalarSplineTrack,
|
||||
}
|
||||
|
||||
/// The parsed model (render subset).
|
||||
pub struct M2Model {
|
||||
pub vertices: Vec<M2Vertex>,
|
||||
@@ -262,6 +302,15 @@ pub struct M2Model {
|
||||
/// `gseq`-tagged tracks wrap on.
|
||||
pub global_sequences: Vec<u32>,
|
||||
pub bones: Vec<M2Bone>,
|
||||
/// The authored camera records (header `0x124`) — see [`M2Camera`]. Empty for the overwhelming
|
||||
/// majority of models; a `Cameras\*.m2` fly-by carries exactly one.
|
||||
pub cameras: Vec<M2Camera>,
|
||||
/// The **CameraLookup** table (header `0x12c`, u16): a camera *purpose* index → the slot in
|
||||
/// [`Self::cameras`] that serves it. The portrait bake selects through it (`cameraLookup[0]`,
|
||||
/// `0x525266`); the `<Model>` pane indexes [`Self::cameras`] raw instead (wow-re
|
||||
/// `ui/scratch/modelframe-camera-law.md`). Carried so a selection can be written the way the
|
||||
/// reference writes it rather than assumed 1:1.
|
||||
pub camera_lookup: Vec<u16>,
|
||||
pub raw_data: M2RawData,
|
||||
pub header: M2Header,
|
||||
/// Model-space **Z** of attachment id 17 — the reference's follow-camera pivot height (wow-re
|
||||
|
||||
@@ -412,3 +412,136 @@ fn hostile_submesh_count_errs_cleanly_not_oom() {
|
||||
Err(Error::Truncated)
|
||||
));
|
||||
}
|
||||
|
||||
// --- cameras (header 0x124/0x128) + the cubic samplers ---------------------------------------
|
||||
|
||||
/// The camera array sits past [`HEADER_LEN`], so camera fixtures carry a header long enough to
|
||||
/// hold `cameras`@`0x124` and `cameraLookup`@`0x12c`.
|
||||
const CAM_HEADER_LEN: usize = 0x134;
|
||||
const OFS_CAMERAS: usize = 0x124;
|
||||
const OFS_CAMERA_LOOKUP: usize = 0x12c;
|
||||
|
||||
fn cam_header() -> Vec<u8> {
|
||||
let mut b = header();
|
||||
b.resize(CAM_HEADER_LEN, 0);
|
||||
b
|
||||
}
|
||||
|
||||
/// One fixture key: `(ms, value, in_tan, out_tan)`.
|
||||
type SplineKeyFixture = (u32, [f32; 3], [f32; 3], [f32; 3]);
|
||||
|
||||
/// Append a cubic `C3Vector` track's payload and return the 28 track bytes pointing at it, laid
|
||||
/// out `{value, inTan, outTan}` per the 0x24 key.
|
||||
fn spline_vec3_track(b: &mut Vec<u8>, interp: u16, keys: &[SplineKeyFixture]) -> Vec<u8> {
|
||||
let times_ofs = b.len() as u32;
|
||||
for (ms, ..) in keys {
|
||||
b.extend_from_slice(&ms.to_le_bytes());
|
||||
}
|
||||
let vals_ofs = b.len() as u32;
|
||||
for (_, v, i, o) in keys {
|
||||
for triple in [v, i, o] {
|
||||
for c in triple {
|
||||
b.extend_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut t = Vec::new();
|
||||
t.extend_from_slice(&interp.to_le_bytes());
|
||||
t.extend_from_slice(&0xffffu16.to_le_bytes()); // gseq: an ordinary sequence-timeline track
|
||||
t.extend_from_slice(&0u32.to_le_bytes()); // ranges count
|
||||
t.extend_from_slice(&0u32.to_le_bytes()); // ranges offset
|
||||
t.extend_from_slice(&(keys.len() as u32).to_le_bytes());
|
||||
t.extend_from_slice(×_ofs.to_le_bytes());
|
||||
t.extend_from_slice(&(keys.len() as u32).to_le_bytes());
|
||||
t.extend_from_slice(&vals_ofs.to_le_bytes());
|
||||
t
|
||||
}
|
||||
|
||||
/// A one-camera model whose position track carries the two keys `interp` will be sampled between:
|
||||
/// `value 0 → 3` on X with tangents `out[k0] = 1`, `in[k1] = 2` — four numbers chosen so the four
|
||||
/// interpolation legs land on four *different* answers.
|
||||
fn model_with_one_camera(interp: u16) -> Vec<u8> {
|
||||
let mut b = cam_header();
|
||||
let rec = b.len() as u32;
|
||||
b.resize(rec as usize + 0x7c, 0);
|
||||
// fov / far / near, then the two bases.
|
||||
b[rec as usize + 0x04..rec as usize + 0x08]
|
||||
.copy_from_slice(&std::f32::consts::FRAC_PI_4.to_le_bytes());
|
||||
b[rec as usize + 0x08..rec as usize + 0x0c].copy_from_slice(&27.777779f32.to_le_bytes());
|
||||
b[rec as usize + 0x0c..rec as usize + 0x10].copy_from_slice(&0.22222222f32.to_le_bytes());
|
||||
for (ofs, base) in [(0x2c, [10.0f32, 20.0, 30.0]), (0x54, [40.0f32, 50.0, 60.0])] {
|
||||
for (i, c) in base.iter().enumerate() {
|
||||
let at = rec as usize + ofs + i * 4;
|
||||
b[at..at + 4].copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let track = spline_vec3_track(
|
||||
&mut b,
|
||||
interp,
|
||||
&[
|
||||
(0, [0.0; 3], [0.0; 3], [1.0, 0.0, 0.0]),
|
||||
(1000, [3.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0; 3]),
|
||||
],
|
||||
);
|
||||
let at = rec as usize + 0x10;
|
||||
b[at..at + 0x1c].copy_from_slice(&track);
|
||||
// The lookup: one entry, pointing at camera 0.
|
||||
let lk = b.len() as u32;
|
||||
b.extend_from_slice(&0u16.to_le_bytes());
|
||||
set_arr(&mut b, OFS_CAMERAS, 1, rec);
|
||||
set_arr(&mut b, OFS_CAMERA_LOOKUP, 1, lk);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_record_fields_and_lookup_parse() {
|
||||
let b = model_with_one_camera(2);
|
||||
let cams = parse_cameras(&b);
|
||||
assert_eq!(cams.len(), 1);
|
||||
let c = &cams[0];
|
||||
assert!((c.fov - std::f32::consts::FRAC_PI_4).abs() < 1e-6);
|
||||
assert!((c.far_clip - 27.777779).abs() < 1e-4);
|
||||
assert!((c.near_clip - 0.22222222).abs() < 1e-7);
|
||||
assert_eq!(c.position_base, [10.0, 20.0, 30.0]);
|
||||
assert_eq!(c.target_base, [40.0, 50.0, 60.0]);
|
||||
assert_eq!(c.positions.keys.len(), 2);
|
||||
assert_eq!(parse_camera_lookup(&b), vec![0]);
|
||||
// …and the whole-model parse carries the same records.
|
||||
let fmt = parse(&b).expect("fixture parses");
|
||||
assert_eq!(fmt.model().cameras.len(), 1);
|
||||
assert_eq!(fmt.model().camera_lookup, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_sampler_takes_all_four_interp_legs() {
|
||||
// Between `value 0` and `value 3`, with `outTan[k0] = 1` and `inTan[k1] = 2`, at the exact
|
||||
// midpoint. Hand-computed from the bases in `M2Track::sample_ms`:
|
||||
// step → value[k0] = 0
|
||||
// linear → 0 + (3−0)·0.5 = 1.5
|
||||
// Bézier → 0.125·0 + 0.375·1 + 0.375·2 + 0.125·3 = 1.5
|
||||
// Hermite→ 0.5·0 + 0.125·1 + 0.5·3 + (−0.125)·2 = 1.375
|
||||
for (interp, want) in [(0u16, 0.0f32), (1, 1.5), (2, 1.5), (3, 1.375)] {
|
||||
let b = model_with_one_camera(interp);
|
||||
let cam = parse_cameras(&b).remove(0);
|
||||
let got = cam.positions.sample_ms(500).expect("keyed track samples")[0];
|
||||
assert!(
|
||||
(got - want).abs() < 1e-5,
|
||||
"interp {interp}: got {got}, want {want}"
|
||||
);
|
||||
// End-clamped at both ends, on every leg.
|
||||
assert_eq!(cam.positions.sample_ms(0).unwrap()[0], 0.0);
|
||||
assert_eq!(cam.positions.sample_ms(9_999).unwrap()[0], 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_camera_record_is_dropped_not_half_read() {
|
||||
let mut b = model_with_one_camera(2);
|
||||
// Claim two records where only one fits: the second must not appear with default tracks.
|
||||
let (rec, _) = (
|
||||
u32::from_le_bytes(b[OFS_CAMERAS + 4..OFS_CAMERAS + 8].try_into().unwrap()),
|
||||
0,
|
||||
);
|
||||
set_arr(&mut b, OFS_CAMERAS, 2, rec);
|
||||
assert_eq!(parse_cameras(&b).len(), 1);
|
||||
}
|
||||
|
||||
@@ -146,3 +146,144 @@ pub(crate) fn track_quat(b: &[u8], track_ofs: usize) -> M2QuatTrack {
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/// One key of a **cubic** M2 track — the reference's `M2SplineKey<T>`: the value plus its in/out
|
||||
/// tangents, `{value@+0, in_tan@+1·sizeof(T), out_tan@+2·sizeof(T)}` (VERIFIED wow-re
|
||||
/// `animation/scratch/kern-inner.md` §2a: the vec3 key is stride `0x24`
|
||||
/// `{value@+0, inTan@+0xc, outTan@+0x18}`, the scalar-float key stride `0xc`
|
||||
/// `{value@+0, inTan@+4, outTan@+8}`).
|
||||
///
|
||||
/// **The wide key is the stride whatever `interp` says.** The reference's cubic element loops
|
||||
/// address every key as `payload + k*0x24` (`0x716b51`) / `payload + k*0xc` (`0x7173cc`) *before*
|
||||
/// the four-way interp dispatch, and the STEP/LINEAR legs then read the `value` sub-field of that
|
||||
/// same wide key. Real art relies on it: `Cameras\FlyByDwarf.m2`'s roll track is authored
|
||||
/// `interp = 0` and still carries a 12-byte key.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct M2SplineKey<V> {
|
||||
pub value: V,
|
||||
pub in_tan: V,
|
||||
pub out_tan: V,
|
||||
}
|
||||
|
||||
/// A cubic `C3Vector` track — the M2Camera **position**/**target** tracks.
|
||||
pub type M2Vec3SplineTrack = M2Track<M2SplineKey<[f32; 3]>>;
|
||||
/// A cubic scalar-float track — the M2Camera **roll** track.
|
||||
pub type M2ScalarSplineTrack = M2Track<M2SplineKey<f32>>;
|
||||
|
||||
/// A value a cubic track can hold: the four basis weights are scalars, so the leaf only has to
|
||||
/// know how to build `Σ wᵢ·Pᵢ` componentwise.
|
||||
pub trait CubicValue: Copy {
|
||||
/// `w0·p0 + w1·p1 + w2·p2 + w3·p3`.
|
||||
fn combine(w: [f32; 4], p: [Self; 4]) -> Self;
|
||||
/// The LINEAR leg, in the reference's own form `a + (b − a)·t` (`0x716cf1`) — not the
|
||||
/// algebraically equal `(1−t)·a + t·b`, which rounds differently in f32.
|
||||
fn lerp(a: Self, b: Self, t: f32) -> Self;
|
||||
}
|
||||
|
||||
impl CubicValue for f32 {
|
||||
fn combine(w: [f32; 4], p: [Self; 4]) -> Self {
|
||||
w[0] * p[0] + w[1] * p[1] + w[2] * p[2] + w[3] * p[3]
|
||||
}
|
||||
fn lerp(a: Self, b: Self, t: f32) -> Self {
|
||||
a + (b - a) * t
|
||||
}
|
||||
}
|
||||
|
||||
impl CubicValue for [f32; 3] {
|
||||
fn combine(w: [f32; 4], p: [Self; 4]) -> Self {
|
||||
std::array::from_fn(|j| w[0] * p[0][j] + w[1] * p[1][j] + w[2] * p[2][j] + w[3] * p[3][j])
|
||||
}
|
||||
fn lerp(a: Self, b: Self, t: f32) -> Self {
|
||||
std::array::from_fn(|j| a[j] + (b[j] - a[j]) * t)
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: CubicValue> M2Track<M2SplineKey<V>> {
|
||||
/// Sample this cubic track at absolute global-timeline `ms`, **end-clamped** at both ends.
|
||||
///
|
||||
/// The four-way `interp` dispatch is the reference's own, and it is the *cubic element loops'*
|
||||
/// dispatch — not the two-way `cmp word[track],0; jne <linear>` the bone/TRS loops collapse to
|
||||
/// (VERIFIED wow-re `animation/scratch/tracks.md` deviation #2: the switch is per-loop, by
|
||||
/// track type). Byte-verified bases, `kern-inner.md` §2a(i)/(ii) with `t` the key-interval
|
||||
/// fraction:
|
||||
///
|
||||
/// - `0` **STEP** — `value[k0]`, no tangent read (`0x716b5f`).
|
||||
/// - `1` **LINEAR** — `value[k0] + (value[k1] − value[k0])·t` (`0x716cf1`).
|
||||
/// - `2` **BÉZIER** — the cubic Bernstein basis over control points
|
||||
/// `{value[k0], outTan[k0], inTan[k1], value[k1]}`: `B0 = (1−t)³`, `B1 = 3t(1−t)²`,
|
||||
/// `B2 = 3t²(1−t)`, `B3 = t³` (`0x716c41`). This is what every shipped `Cameras\*.m2`
|
||||
/// fly-by authors on its position and target tracks.
|
||||
/// - `3` **HERMITE** — `h00·value[k0] + h10·outTan[k0] + h01·value[k1] + h11·inTan[k1]` with
|
||||
/// `h00 = 2t³−3t²+1`, `h10 = t³−2t²+t`, `h01 = 3t²−2t³`, `h11 = t³−t²` (`0x716b9e`).
|
||||
///
|
||||
/// Note which tangent each control point comes from: the **outgoing** tangent of the key
|
||||
/// being left and the **incoming** tangent of the key being entered. Swapping them looks
|
||||
/// almost right and drifts wrong exactly where the path curves hardest.
|
||||
pub fn sample_ms(&self, ms: u32) -> Option<V> {
|
||||
let first = self.keys.first()?;
|
||||
let last = self.keys.last()?;
|
||||
if ms <= first.0 {
|
||||
return Some(first.1.value);
|
||||
}
|
||||
if ms >= last.0 {
|
||||
return Some(last.1.value);
|
||||
}
|
||||
// The key pair bracketing `ms` (keys are time-ascending within a band).
|
||||
let k1 = self.keys.partition_point(|&(t, _)| t <= ms);
|
||||
let (t0, a) = self.keys[k1 - 1];
|
||||
let (t1, b) = self.keys[k1];
|
||||
let t = if t1 > t0 {
|
||||
(ms - t0) as f32 / (t1 - t0) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
Some(match self.interp {
|
||||
0 => a.value,
|
||||
1 => V::lerp(a.value, b.value, t),
|
||||
2 => V::combine(
|
||||
[
|
||||
(1.0 - t) * (1.0 - t) * (1.0 - t),
|
||||
3.0 * t * (1.0 - t) * (1.0 - t),
|
||||
3.0 * t2 * (1.0 - t),
|
||||
t3,
|
||||
],
|
||||
[a.value, a.out_tan, b.in_tan, b.value],
|
||||
),
|
||||
_ => V::combine(
|
||||
[
|
||||
2.0 * t3 - 3.0 * t2 + 1.0,
|
||||
t3 - 2.0 * t2 + t,
|
||||
3.0 * t2 - 2.0 * t3,
|
||||
t3 - t2,
|
||||
],
|
||||
[a.value, a.out_tan, b.value, b.in_tan],
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn rd_spline<V>(
|
||||
b: &[u8],
|
||||
o: usize,
|
||||
step: usize,
|
||||
rd: impl Fn(&[u8], usize) -> Option<V>,
|
||||
) -> Option<M2SplineKey<V>> {
|
||||
Some(M2SplineKey {
|
||||
value: rd(b, o)?,
|
||||
in_tan: rd(b, o + step)?,
|
||||
out_tan: rd(b, o + 2 * step)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a cubic `C3Vector` track (key stride `0x24`) — the M2Camera position/target tracks.
|
||||
pub(crate) fn track_spline_vec3(b: &[u8], track_ofs: usize) -> M2Vec3SplineTrack {
|
||||
track_read(b, track_ofs, 0x24, |b, o| rd_spline(b, o, 12, rd_vec3))
|
||||
}
|
||||
|
||||
/// Read a cubic scalar-float track (key stride `0xc`) — the M2Camera roll track.
|
||||
pub(crate) fn track_spline_f32(b: &[u8], track_ofs: usize) -> M2ScalarSplineTrack {
|
||||
track_read(b, track_ofs, 0xc, |b, o| {
|
||||
rd_spline(b, o, 4, |b, o| b.f32_at(o))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ pub const SMSG_DESTROY_OBJECT: u16 = 0x00AA;
|
||||
// runs UNACKED, vmangos re-anchors object visibility to the flying cinematic camera
|
||||
// (`Player::UpdateCinematic`), despawning everything around the body — so the client must answer.
|
||||
pub const SMSG_TRIGGER_CINEMATIC: u16 = 0x00FA;
|
||||
/// Sent when playback advances from one camera of a multi-camera `CinematicSequences` row to the
|
||||
/// next — empty body, exactly like the completion ack. VERIFIED in the reference at `0x48efe0`
|
||||
/// (`NextCamera`): it bumps the camera index, and the send is `push 0xfb; call 0x418190` with
|
||||
/// nothing written between the CDataStore open and `call 0x5ab630`.
|
||||
pub const CMSG_NEXT_CINEMATIC_CAMERA: u16 = 0x00FB;
|
||||
pub const CMSG_COMPLETE_CINEMATIC: u16 = 0x00FC;
|
||||
pub const SMSG_MONSTER_MOVE: u16 = 0x00DD;
|
||||
pub const SMSG_INITIALIZE_FACTIONS: u16 = 0x0122;
|
||||
|
||||
@@ -59,4 +59,17 @@ impl WorldWriter {
|
||||
pub fn complete_cinematic(&mut self) -> Result<()> {
|
||||
self.send(opcode::CMSG_COMPLETE_CINEMATIC, &[])
|
||||
}
|
||||
|
||||
/// Advance a multi-camera cinematic to its next shot (`CMSG_NEXT_CINEMATIC_CAMERA`, empty
|
||||
/// body) — see the opcode's own note. Sent *between* cameras; the run still ends with exactly
|
||||
/// one [`complete_cinematic`](Self::complete_cinematic).
|
||||
///
|
||||
/// No shipped 1.12 `CinematicSequences` row carries a second camera, so this never fires
|
||||
/// against a stock server today. It exists because the sequencing is the *client's*, not the
|
||||
/// data's: a row with two cameras is one the client must play as a single continuous
|
||||
/// cinematic, and playing it silently — without the packet the reference sends at `0x48efe0`
|
||||
/// — is the kind of drift that only surfaces on a server shipping its own DBCs.
|
||||
pub fn next_cinematic_camera(&mut self) -> Result<()> {
|
||||
self.send(opcode::CMSG_NEXT_CINEMATIC_CAMERA, &[])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,13 @@ pub(crate) struct Model {
|
||||
pub(crate) rest_state: u8,
|
||||
pub(crate) rest_pool: u32,
|
||||
pub(crate) resting: bool,
|
||||
/// Is a cinematic playing? What `InCinematic()` reports, pushed by the cinematic plugin.
|
||||
///
|
||||
/// Read by more than the letterbox: `StaticPopup_Show` refuses any dialog whose entry lacks
|
||||
/// `interruptCinematic` while this is set, exactly as the reference's `StaticPopup.lua:1454`
|
||||
/// does — which is what stops a "your character will be logged out" box from landing on top
|
||||
/// of a first login's race intro.
|
||||
pub(crate) in_cinematic: bool,
|
||||
/// Exhaustion.dbc as the rest bindings consume it — rest-state byte → (localized name,
|
||||
/// factor), the table `GetRestState` indexes directly and whose row 1 scales
|
||||
/// `GetXPExhaustion` (wow-re rested-xp-bindings.md; decision 1087). Seeded with the shipped
|
||||
@@ -1700,6 +1707,7 @@ impl Model {
|
||||
rest_state: 2,
|
||||
rest_pool: 0,
|
||||
resting: false,
|
||||
in_cinematic: false,
|
||||
exhaustion: [
|
||||
(1, ("Rested".to_string(), 2.0)),
|
||||
(2, ("Normal".to_string(), 1.0)),
|
||||
|
||||
@@ -47,6 +47,15 @@ pub enum SessionRequest {
|
||||
/// Queuing an intent the app runs outside any VM call is that same guard in this engine's
|
||||
/// shape (wow-re `system/ui/scratch/savedvariables-protocol.md` §2, byte-verified).
|
||||
ReloadUi,
|
||||
/// `StopCinematic()` — skip the cinematic that is playing.
|
||||
///
|
||||
/// **The only skip path there is.** In the reference this binding (`0x48b970`) has *zero*
|
||||
/// native callers: nothing in the engine calls it, and it is absent from `Bindings.xml`, so
|
||||
/// ESC reaches it purely through `CinematicFrame`'s own `OnKeyDown` handler while that
|
||||
/// fullscreen frame holds keyboard focus. A cinematic with no `CinematicFrame` on screen is
|
||||
/// therefore un-skippable — which is why benilla starts playback only once the in-game UI
|
||||
/// exists.
|
||||
StopCinematic,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
@@ -63,7 +72,7 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the five session globals — the four exit verbs, and `ReloadUI`.
|
||||
/// Register the session globals — the four exit verbs, `ReloadUI`, and the cinematic skip.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
@@ -73,6 +82,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
("CancelLogout", SessionRequest::CancelLogout),
|
||||
("ForceQuit", SessionRequest::ForceQuit),
|
||||
("ReloadUI", SessionRequest::ReloadUi),
|
||||
("StopCinematic", SessionRequest::StopCinematic),
|
||||
] {
|
||||
g.set(
|
||||
name,
|
||||
@@ -84,5 +94,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
}
|
||||
|
||||
// `InCinematic()` — the reference's `0x48c930`: reads the "a cinematic is playing" global and
|
||||
// answers the **number 1** while one is, `nil` otherwise (both legs push exactly one result).
|
||||
// Era convention, not `true`/`false`.
|
||||
g.set(
|
||||
"InCinematic",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(if model.in_cinematic {
|
||||
mlua::Value::Integer(1)
|
||||
} else {
|
||||
mlua::Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -359,6 +359,11 @@ impl super::UiScript {
|
||||
model.resting = resting;
|
||||
}
|
||||
|
||||
/// Push whether a cinematic is playing — what `InCinematic()` answers.
|
||||
pub fn set_in_cinematic(&mut self, playing: bool) {
|
||||
self.model_mut().in_cinematic = playing;
|
||||
}
|
||||
|
||||
/// Push Exhaustion.dbc — `(rest-state byte, localized name, factor)` rows for the
|
||||
/// `GetRestState`/`GetXPExhaustion` bindings (they read the table exactly as `0x48d350` /
|
||||
/// `0x48d3f0` read the client's own copy; decision 1087). Called once at startup off the
|
||||
|
||||
@@ -277,7 +277,11 @@ pub struct EffectDrawSpec {
|
||||
pub blend: EffectBlend,
|
||||
pub fog: EffectFog,
|
||||
/// Multiply this draw's RGB by the scene's matte light — `clamp(ambient + diffuse·max(N·L,0))`
|
||||
/// against a camera-facing normal, the same term `wow_model.wgsl` applies to a mesh.
|
||||
/// against the **world up axis**, the same term `wow_model.wgsl` applies to a mesh. The
|
||||
/// reference's quad writer uploads one constant normal for the whole draw — world +Z carried
|
||||
/// into eye space, against a light carried into the same frame, so the product is the
|
||||
/// view-invariant `worldUp · worldLightDir` (wow-re `part-lit-normal-space.md`; decision 1696
|
||||
/// supersedes 0975's camera-facing reading).
|
||||
///
|
||||
/// The lane is unlit by default and every family but one passes `false`: ribbons, decals, the
|
||||
/// rings/reticle and precipitation are all authored to burn at their own colour. **M2 particle
|
||||
|
||||
@@ -77,10 +77,6 @@ struct VertexOutput {
|
||||
// View-space depth (+ in front of the camera) — the fog/farclip planar eye-Z (exact on
|
||||
// the cam-relative path, where the view transform is pure rotation).
|
||||
@location(2) view_z: f32,
|
||||
// World-space offset from the eye — what `EFFECT_LIT` builds its camera-facing normal from.
|
||||
// Already exactly `v.position` on the free-floating path (0733 §2's rebase); the decal path
|
||||
// carries absolute verts, so it subtracts the eye back out.
|
||||
@location(3) world_offset: vec3<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
@@ -97,7 +93,6 @@ fn vertex(v: Vertex) -> VertexOutput {
|
||||
out.clip_position = view.clip_from_world * vec4<f32>(v.position, 1.0);
|
||||
// Fog/farclip eye-Z via the full affine transform (yards-scale — rounding is irrelevant).
|
||||
out.view_z = -(view.view_from_world * vec4<f32>(v.position, 1.0)).z;
|
||||
out.world_offset = v.position - view.world_position;
|
||||
#else
|
||||
// Free-floating families: cam-relative verts (0733 §2). view_from_world = [R | −R·cam];
|
||||
// for a cam-relative point the translation column is exactly the subtraction prepare
|
||||
@@ -109,7 +104,6 @@ fn vertex(v: Vertex) -> VertexOutput {
|
||||
) * v.position;
|
||||
out.clip_position = view.clip_from_view * vec4<f32>(view_pos, 1.0);
|
||||
out.view_z = -view_pos.z;
|
||||
out.world_offset = v.position;
|
||||
#endif
|
||||
out.uv = v.uv;
|
||||
out.color = v.color;
|
||||
@@ -163,15 +157,28 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// unlit is a full-white cutout against shaded terrain (the Zul'Gurub waterfall foam).
|
||||
//
|
||||
// The term is the fixed-function matte `wow_model.wgsl` applies to a mesh —
|
||||
// `clamp(ambient + diffuse·max(N·L, 0))` — against the CAMERA-FACING normal the reference's
|
||||
// quad writer uploads for these emitters. On the cam-relative path a vertex position IS its
|
||||
// offset from the eye in world axes, so the direction back to the camera is its negation;
|
||||
// the degenerate at-the-eye case falls back to the sun-facing normal (full light), which is
|
||||
// the limit the billboard approaches anyway.
|
||||
let to_eye = -in.world_offset;
|
||||
let len2 = dot(to_eye, to_eye);
|
||||
// `clamp(ambient + diffuse·max(N·L, 0))` — and **N is the WORLD UP axis, one constant for the
|
||||
// whole draw** (wow-re `part-lit-normal-space.md`, §5-converged). The quad writer stores the
|
||||
// same triple into all four vertices of every quad from a single per-draw global
|
||||
// (`0xcf5878..80`, nine identical stores across the head/tail legs), and that global is set
|
||||
// once per draw by `0x7b3fd0` from **row 2 of the VIEW matrix** (`0x58b0b0` = GetView, not
|
||||
// GetWorld) — i.e. world +Z carried into eye space. The light is carried into the same frame
|
||||
// (`0x71bce0` pre-multiplies the M2 light direction by `CM2Scene+0x9c`, and `glLightfv` is
|
||||
// issued under the VIEW slot alone), so the rotation cancels and the product the hardware
|
||||
// computes is exactly `worldUp · worldLightDir` — **invariant as the camera orbits**.
|
||||
//
|
||||
// We had a camera-facing normal here, read off a parenthetical in `part-scene-multipliers.md`
|
||||
// §1 that was written about an emitter that is UNLIT and has since been corrected at the bytes
|
||||
// (its polarity was inverted too: the LIT leg is the one that binds a real normal stream —
|
||||
// vertex format 4, normal at +0x0c — while an UNLIT emitter's format has no normal element at
|
||||
// all). It made this multiplier swing with the viewer: in Onyxia's Lair, where the trap dust is
|
||||
// lit and additive, `ambient (0.22,0.26,0.29)` → `ambient + diffuse (1.00,0.93,0.74)` — a ~4×
|
||||
// brightness change from nothing but turning the camera (decision 1696).
|
||||
//
|
||||
// N and L must be in the SAME frame; that is where the invariance comes from. Both are world
|
||||
// here (Bevy axes: WoW's +Z up is Bevy +Y).
|
||||
let L = -normalize(wow_light.light_sun.xyz);
|
||||
let N = select(L, to_eye * inverseSqrt(len2), len2 > 1e-12);
|
||||
let N = vec3<f32>(0.0, 1.0, 0.0);
|
||||
let lit = clamp(
|
||||
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * max(dot(N, L), 0.0),
|
||||
vec3<f32>(0.0),
|
||||
|
||||
Reference in New Issue
Block a user