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:
Sam
2026-08-29 17:24:50 +07:00
co-authored by Claude Fable 5
parent 27bd2670aa
commit d7fb26713c
31 changed files with 1871 additions and 462 deletions
Generated
+2 -380
View File
@@ -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
View File
@@ -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>
+23 -3
View File
@@ -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)
+5
View File
@@ -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
+508
View File
@@ -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);
}
}
+8 -3
View File
@@ -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(_) => {
+6
View File
@@ -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
+17 -4
View File
@@ -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
+5 -1
View File
@@ -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,
+19 -13
View File
@@ -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,
+1
View File
@@ -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,
+9 -1
View File
@@ -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)
+110
View File
@@ -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:#}"),
}
}
+2
View File
@@ -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);
+7
View File
@@ -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));
}
}
}
}
+10 -1
View File
@@ -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" }
+555
View File
@@ -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");
}
}
}
+5
View File
@@ -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,
};
+17 -35
View File
@@ -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),
})
}
+81 -4
View File
@@ -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
+50 -1
View File
@@ -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
+133
View File
@@ -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(&times_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);
}
+141
View File
@@ -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, &[])
}
}
+8
View File
@@ -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)),
+26 -1
View File
@@ -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(())
}
+5
View File
@@ -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
+5 -1
View File
@@ -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),