From a21fb2750a29ec07d9e046ef470c162fe83eed12 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:12:45 +0700 Subject: [PATCH] The stock video options window opens, saves, restores defaults and cancels clean: its CVars are registered, spellEffectLevel drives the particle and shard rates, and the video pairs, frame enumeration and client queries are defined (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stock video options window (`OptionsFrame`, off the player's chain) now opens, saves, restores defaults and cancels with no Lua error, as on 1.12.1. - The CVars it reads are registered at the reference's defaults: `SmallCull` "0.04" (`0x68854a`), `shadowLevel` "1" (`0x6885bc`), `baseMip` "0" (`0x6887aa`), `DesktopGamma` "0" (`0x402d4d`), `spellEffectLevel` "2" (`0x688900`), plus `doodadAnim` and `texLodBias`, which the new getters read. `SetWorldDetail` keeps `SmallCull` in step (`0x488e56`), and the wrong comment that called the getter its only reader is gone. - `spellEffectLevel`'s handler (`0x689510`): the value is clamped to 0-2, "Spell effect level set to %d." is printed, the particle emission scalar is set to 0.33/0.66/1.0, and the shard emitter's rate follows the level (`0x6eb95e`). - The 21 functions #337 lists: the six video getter/setter pairs (`0x488ec0`-`0x489300`), `GetNumFrames`/`EnumerateFrames` in creation order (`0x705f00`, `0x705f60`), and the client queries, including `GetDebugStats` and `OpeningCinematic`'s empty `CMSG_OPENING_CINEMATIC` (0xf9, `0x48c8c9`). Verified: a headless test walks the stock window's 18 boxes and 9 sliders, then Okay, Defaults and Cancel, with no error (it fails on main at `OptionsFrame.lua:146`), plus unit tests for each binding and handler; 12 mutations each fail a test. A live run opened the window, saved, restored defaults and cancelled with no Lua error. `scripts/gates.sh` green. Built on macOS; no platform seam touched. Fixes #548 Fixes #337 Refs #329 (benilla's own options window has no Spell Detail row yet) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/bindings/commands.rs | 17 +- crates/benilla-app/src/cinematic.rs | 51 +++ crates/benilla-app/src/console.rs | 32 +- crates/benilla-app/src/cvars.rs | 114 ++++++- crates/benilla-app/src/entities/dest_fx.rs | 26 +- crates/benilla-app/src/net.rs | 2 + crates/benilla-app/src/net/io.rs | 1 + crates/benilla-app/src/ui_chat/edit.rs | 22 +- crates/benilla-app/src/ui_script/mod.rs | 4 + .../benilla-app/src/ui_script/reference_ui.rs | 7 - .../src/ui_script/video_options_tests.rs | 173 ++++++++++ crates/benilla-app/src/video.rs | 48 +++ crates/benilla-app/tests/world_api_wall.rs | 1 + .../benilla-protocol/src/messages/opcode.rs | 2 + .../src/world/writer/lifecycle.rs | 6 + crates/benilla-ui/src/script/client.rs | 93 ++++++ crates/benilla-ui/src/script/cvars.rs | 78 ++++- crates/benilla-ui/src/script/frame_enum.rs | 130 ++++++++ crates/benilla-ui/src/script/mod.rs | 9 +- crates/benilla-ui/src/script/model.rs | 3 + .../src/script/tests/reference_surface.rs | 15 +- crates/benilla-ui/src/script/video_pairs.rs | 302 ++++++++++++++++++ crates/benilla-world/src/particles.rs | 19 +- 23 files changed, 1095 insertions(+), 60 deletions(-) create mode 100644 crates/benilla-app/src/ui_script/video_options_tests.rs create mode 100644 crates/benilla-ui/src/script/frame_enum.rs create mode 100644 crates/benilla-ui/src/script/video_pairs.rs diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs index 37f4222e..afb88f91 100644 --- a/crates/benilla-app/src/bindings/commands.rs +++ b/crates/benilla-app/src/bindings/commands.rs @@ -1731,10 +1731,11 @@ pub(crate) static ABSENT: &[Absent] = &[ // ── Misc ──────────────────────────────────────────────────────────────────────────── // The nine `hidden="true" debug="true"` rows: benilla's instruments for these answer to the // dev plane's chords, not to a binding. - // Stock `StatsFrame.lua` defines `ToggleStats`; the readout it shows is `GetDebugStats`. + // Stock `StatsFrame.lua` defines `ToggleStats`; the readout it shows is `GetDebugStats`, + // which answers nothing in the release build (`0x488af0`). absent!( "TOGGLESTATS", - ["GetDebugStats"], + ["ToggleStats", "GetDebugStats"], "a dev-plane instrument on a dev chord" ), absent!( @@ -1981,12 +1982,20 @@ mod tests { } /// An [`ABSENT`] row whose `needs` are all defined belongs in [`SPECS`]. "Defined" is read - /// from source, not from a VM. + /// from source, not from a VM. A `hidden="true"` row can never move there (the Key Bindings + /// page would list it), so its landed needs are no signal: `TOGGLESTATS`, whose stock body and + /// readout (`ToggleStats`, `GetDebugStats`) are all defined, is one. #[test] fn the_absent_commands_are_still_absent() { let defined = lua_globals_defined(); + let hidden: std::collections::HashSet = install_bindings() + .unwrap_or_default() + .into_iter() + .filter(|b| b.hidden) + .map(|b| b.name) + .collect(); for a in ABSENT { - if a.needs.is_empty() { + if a.needs.is_empty() || hidden.contains(a.name) { continue; } let landed: Vec<&str> = a diff --git a/crates/benilla-app/src/cinematic.rs b/crates/benilla-app/src/cinematic.rs index c4f45e08..16b7ccd8 100644 --- a/crates/benilla-app/src/cinematic.rs +++ b/crates/benilla-app/src/cinematic.rs @@ -157,6 +157,7 @@ impl Plugin for CinematicPlugin { Update, drive_letterbox.in_set(WorldStage::Input).after(drive), ) + .add_systems(Update, send_opening_cinematic.in_set(WorldStage::Input)) // Leaving the world drops a cinematic with no ack, as the reference's teardown does // (`0x490a80` clears the flag and sends no `CMSG_COMPLETE_CINEMATIC`). .add_systems(OnExit(ClientState::InWorld), abandon_on_leaving_world); @@ -577,6 +578,20 @@ fn feed_ui( ); } +/// Each Lua `OpeningCinematic()` is one empty `CMSG_OPENING_CINEMATIC` (`0x48c8c0`); the server +/// answers, if at all, with the `SMSG_TRIGGER_CINEMATIC` [`take_trigger`] plays. +fn send_opening_cinematic(script: Option>, net: Option>) { + let Some(mut script) = script else { + return; + }; + let asks = script.take_opening_cinematic_asks(); + if let Some(net) = net { + for _ in 0..asks { + let _ = net.0.send(ClientCommand::OpeningCinematic); + } + } +} + fn ack(net: Option<&NetCommands>) { if let Some(net) = net { let _ = net.0.send(ClientCommand::CompleteCinematic); @@ -837,3 +852,39 @@ mod settling_under_black { ); } } + +/// Lua's `OpeningCinematic()` reaches the wire as one empty `CMSG_OPENING_CINEMATIC` per call. +#[cfg(test)] +mod opening_cinematic { + use super::*; + + #[test] + fn each_call_sends_one_opening_cinematic() { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.add_systems(Update, send_opening_cinematic); + app.world() + .non_send_resource::() + .run("OpeningCinematic() OpeningCinematic()") + .unwrap(); + app.update(); + let sent = rx + .try_iter() + .filter(|c| matches!(c, ClientCommand::OpeningCinematic)) + .count(); + assert_eq!(sent, 2); + app.update(); + assert_eq!(rx.try_iter().count(), 0, "drained once"); + // The opcode the reference puts at `0x48c8c9`. + assert_eq!( + benilla_protocol::messages::opcode::CMSG_OPENING_CINEMATIC, + 0xf9 + ); + assert_eq!( + benilla_protocol::messages::opcode_name(0xf9), + Some("CMSG_OPENING_CINEMATIC") + ); + } +} diff --git a/crates/benilla-app/src/console.rs b/crates/benilla-app/src/console.rs index 4a85d1da..05513184 100644 --- a/crates/benilla-app/src/console.rs +++ b/crates/benilla-app/src/console.rs @@ -61,12 +61,31 @@ impl ConsoleCommandApp for App { } } +/// Lines a CVar's change callback prints to the console (`0x63cb50`), such as `spellEffectLevel`'s +/// "Spell effect level set to %d.". A console command's own write collects them into its output; +/// any other write (a Lua `SetCVar`, the boot load) sends them to the log, as the reference's go +/// to a console screen nobody has open. +#[derive(Resource, Default)] +pub(crate) struct ConsoleEcho { + capture: Option>, +} + +impl ConsoleEcho { + pub(crate) fn print(&mut self, line: String) { + match &mut self.capture { + Some(lines) => lines.push(line), + None => info!("console: {line}"), + } + } +} + /// The built-ins: the reference's four `ConsoleVar.cpp` commands and `help`. pub(crate) struct ConsolePlugin; impl Plugin for ConsolePlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .console_command("set", "Set the value of a CVar.", set) .console_command("cvar_reset", "Reset a CVar to its default.", cvar_default) .console_command("cvar_default", "Reset a CVar to its default.", cvar_default) @@ -136,10 +155,18 @@ fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec { let outcome = cvars.set(name, value); (outcome, cvars.take_events()) }; + // The callbacks' own lines come first, as they print inside `CVar::Set`. + if let Some(mut echo) = world.get_resource_mut::() { + echo.capture = Some(Vec::new()); + } for event in events { world.trigger(event); } - match outcome { + let mut lines = world + .get_resource_mut::() + .and_then(|mut echo| echo.capture.take()) + .unwrap_or_default(); + lines.extend(match outcome { SetOutcome::Unknown => vec![format!("console: no CVar named '{name}'")], SetOutcome::Refused => vec![format!( "console: '{value}' is not a number, and {name} takes one" @@ -148,7 +175,8 @@ fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec { SetOutcome::Staged => vec![format!( "{name} staged as \"{value}\" β€” applies at the next RestartGx" )], - } + }); + lines } /// `set ` (`0x63d500`). Deviation: the reference registers an unknown name as a new diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 2cf17d1b..dd0595b0 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -134,13 +134,13 @@ pub(crate) fn registered_pairs() -> impl Iterator(&app).density, 2.0); + // A stop set as a CVar keeps `SmallCull` in step, as `SetWorldDetail` writes it. + apply(&mut app, "WorldDetail", "0"); + assert_eq!( + res::(&app).get(benilla_ui::script::CVAR_SMALL_CULL), + Some("0.070000") + ); + apply(&mut app, "WorldDetail", "1"); + assert_eq!( + res::(&app).get(benilla_ui::script::CVAR_SMALL_CULL), + Some("0.040000") + ); + // Spell Detail: `SStrToInt`, clamp, then the shared emission scalar (`0x689510`). + for (level, scale) in [ + ("0", 0.33), + ("1", 0.66), + ("2", 1.0), + ("-3", 0.33), + ("7", 1.0), + ] { + apply(&mut app, "spellEffectLevel", level); + assert_eq!( + res::(&app).density(), + scale, + "spellEffectLevel {level}" + ); + assert_eq!( + res::(&app).0, + level.parse::().unwrap(), + "the record's integer, unclamped, for the shard emitter" + ); + } apply(&mut app, "minimapZoom", "5"); assert_eq!(res::(&app).outdoor, 5); assert_eq!( @@ -1986,6 +2066,28 @@ mod tests { assert_eq!(apply(&mut app, "bogus", "1"), SetOutcome::Unknown); } + /// `/console spellEffectLevel 0` prints the handler's own line (`0x689537`-`0x689554`, format + /// `0x869f94`) with the level it clamped to; a Lua write logs it instead. + #[test] + fn the_spell_effect_level_handler_echoes_to_the_console() { + let mut app = cvar_app(); + app.init_resource::(); + assert_eq!( + crate::console::execute(app.world_mut(), "spellEffectLevel 0"), + ["Spell effect level set to 0."] + ); + assert_eq!( + crate::console::execute(app.world_mut(), "spellEffectLevel 9"), + ["Spell effect level set to 2."] + ); + assert_eq!(res::(&app).get("spellEffectLevel"), Some("9")); + assert_eq!( + crate::console::execute(app.world_mut(), "farclip 400"), + Vec::::new(), + "a callback that prints nothing adds nothing" + ); + } + #[test] fn compose_writes_the_diff_and_preserves_what_it_does_not_own() { let mut cvars = Cvars::default(); @@ -2086,6 +2188,8 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -2631,6 +2735,10 @@ mod tests { "useUiScale", "UIOptionsFrame.lua and OptionsFrame.lua branch on it to gate the uiScale slider", ), + ( + "DesktopGamma", + "OptionsFrame.lua's Use Desktop Gamma box, recorded at open and put back on close", + ), ]; let app_src = crate::test_support::src_dir(); let ui_src = app_src diff --git a/crates/benilla-app/src/entities/dest_fx.rs b/crates/benilla-app/src/entities/dest_fx.rs index d08c03fd..c8cad2ae 100644 --- a/crates/benilla-app/src/entities/dest_fx.rs +++ b/crates/benilla-app/src/entities/dest_fx.rs @@ -32,6 +32,13 @@ fn shard_model_index(param0: f32) -> usize { idx.min(SHARD_MODELS.len() - 1) } +/// The shard emitter's rate factor from the `spellEffectLevel` record's integer, read once at the +/// emitter's Initialize (`0x6eb95e`-`0x6eb989`), so a change reaches only emitters created after +/// it: 0.33 at 0, 0.66 at 1, and 1.0 for any other value, unclamped, or with no record. +fn shard_rate_scale(spell_effect_level: Option) -> f32 { + spell_effect_level.map_or(1.0, crate::video::spell_effect_scale) +} + /// A dest-anchored effect model: visual A, a shard or a GO burst. #[derive(Component)] pub(super) struct GroundFx { @@ -67,8 +74,8 @@ pub(super) struct ShardEmitter { path: String, /// `DYNAMICOBJECT_RADIUS`: the wire radius is the spread (`0x6ebad0`). radius: f32, - /// Shards per second: `CharParamOne` times the `spellEffectLevel` factor, 1.0 at its - /// default "2". + /// Shards per second: `CharParamOne` times the `spellEffectLevel` factor (`0x6eb95e`- + /// `0x6eb98f`), 1.0 at its default "2". rate: f32, /// Fractional emissions carried between frames. accum: f32, @@ -96,10 +103,12 @@ pub(super) fn arm_ground_effects( fx: Option>, asset_server: Res, mut sounds: MessageWriter, + spell_effect_level: Option>, ) { let (Some(visuals), Some(spells), Some(mut fx)) = (visuals, spells, fx) else { return; }; + let level_scale = shard_rate_scale(spell_effect_level.map(|l| l.0)); for (anchor, net, store) in &created { if net.kind != EntityKind::DynamicObject { continue; @@ -147,7 +156,7 @@ pub(super) fn arm_ground_effects( commands.entity(anchor).insert(ShardEmitter { path, radius: store.0.dynamicobject_radius().unwrap_or(0.0), - rate: proc.params[1], + rate: proc.params[1] * level_scale, accum: 0.0, rng: 0x9e3779b97f4a7c15 ^ anchor.to_bits(), }); @@ -293,6 +302,17 @@ pub(super) fn attach_ground_fx_models( mod tests { use super::*; + #[test] + fn the_shard_rate_follows_the_spell_effect_level_record() { + assert_eq!(shard_rate_scale(Some(0)), 0.33); + assert_eq!(shard_rate_scale(Some(1)), 0.66); + assert_eq!(shard_rate_scale(Some(2)), 1.0); + // The record's integer, not the handler's clamped copy: out of range is the Γ—1.0 arm. + assert_eq!(shard_rate_scale(Some(-1)), 1.0); + assert_eq!(shard_rate_scale(Some(5)), 1.0); + assert_eq!(shard_rate_scale(None), 1.0); + } + /// The `0x5d55c0` decode, `bits(f32(param0 + 512.0)) >> 14 & 0xff`; the real rows carry 0.0 /// (Blizzard) and 1.0 (Rain of Fire). #[test] diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index ff29a309..99457603 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -1622,6 +1622,8 @@ pub(crate) enum ClientCommand { }, /// `/played` (`CMSG_PLAYED_TIME`). PlayedTime, + /// `OpeningCinematic()`: `CMSG_OPENING_CINEMATIC`, empty. + OpeningCinematic, /// `CMSG_COMPLETE_CINEMATIC`, at the end or skip, or at once for an unresolvable trigger. /// Unacked, vmangos keeps visibility on the cinematic camera and nearby NPCs despawn. CompleteCinematic, diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 32cfd1b2..328ba575 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -1334,6 +1334,7 @@ fn writer_loop( ClientCommand::ClearTradeItem { trade_slot } => w.clear_trade_item(trade_slot), ClientCommand::Logout => w.logout_request(), ClientCommand::LogoutCancel => w.logout_cancel(), + ClientCommand::OpeningCinematic => w.opening_cinematic(), ClientCommand::CompleteCinematic => w.complete_cinematic(), ClientCommand::NextCinematicCamera => w.next_cinematic_camera(), ClientCommand::MoveModeAck { diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs index b65e81ac..f9cac67e 100644 --- a/crates/benilla-app/src/ui_chat/edit.rs +++ b/crates/benilla-app/src/ui_chat/edit.rs @@ -3,6 +3,8 @@ use bevy::prelude::*; +use benilla_ui::script::sstr_to_int; + use crate::net::ChatKind; /// The sendable chat types, as the wire kind an addon's `SendChatMessage` token maps to. @@ -142,26 +144,6 @@ impl ChannelSlot { } } -/// Storm's `SStrToInt` (`0x64ac60`): an optional `-`, then decimal digits up to the first other -/// byte, with no whitespace skip, no `+` and no overflow check (`10Β·n + d` wraps); no digit is 0. -fn sstr_to_int(s: &str) -> i32 { - let (neg, digits) = match s.strip_prefix('-') { - Some(rest) => (true, rest), - None => (false, s), - }; - let n = digits - .bytes() - .take_while(u8::is_ascii_digit) - .fold(0i32, |n, d| { - n.wrapping_mul(10).wrapping_add(i32::from(d - b'0')) - }); - if neg { - n.wrapping_neg() - } else { - n - } -} - /// `id`'s `ZONECHANNELS` bit, `1 << (ChannelID - 1)`; nothing outside `1..=32` has one. pub(crate) fn zone_bit(id: u32) -> u32 { if id == 0 || id > 32 { diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index f65c87f6..6892784b 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -953,6 +953,10 @@ mod errors_tests; #[cfg(test)] mod shipped_xml_tests; +/// The stock Video Options window off the chain: open, Okay, Defaults and Cancel. +#[cfg(test)] +mod video_options_tests; + #[cfg(test)] mod bottom_hud_tests; diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 62f99b86..0b1455bd 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -1185,13 +1185,6 @@ mod tests { (the producer gate carries the event). Gated as the addon is a reached \ LoadOnDemand row.", ), - ( - "StatsFrame.xml", - "GetDebugStats", - "the debug readout's one engine verb (`0x488af0`), not built. `StatsFrame` loads \ - hidden and only its OnUpdate calls it; nothing stock shows it, and its binding, \ - `TOGGLESTATS`, is a `debug=\"true\"` row the release loader skips (`0x4b70a4`).", - ), ( "StaticPopup.xml", "ReplaceTradeEnchant", diff --git a/crates/benilla-app/src/ui_script/video_options_tests.rs b/crates/benilla-app/src/ui_script/video_options_tests.rs new file mode 100644 index 00000000..abdaeba6 --- /dev/null +++ b/crates/benilla-app/src/ui_script/video_options_tests.rs @@ -0,0 +1,173 @@ +//! The stock Video Options window (`OptionsFrame.xml`/`.lua`, off the player's chain) standing on +//! its own: open, every box and slider, Okay, Defaults and Cancel, with no Lua error and the +//! values each verb stores. + +use benilla_ui::script::{ScreenResolution, UiScript, UnitState, VideoCaps}; + +/// A loaded default UI with the display facts the window's `OnLoad`s and `_Load` read. +fn video_ui() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.set_screen_resolutions( + vec![ + ScreenResolution { + width: 1280, + height: 720, + }, + ScreenResolution { + width: 1920, + height: 1080, + }, + ], + Some(ScreenResolution { + width: 1600, + height: 900, + }), + ); + s.set_video_caps(VideoCaps { + anisotropic: true, + pixel_shaders: true, + vertex_shaders: true, + trilinear: true, + triple_buffering: false, + max_anisotropy: 16, + hardware_cursor: true, + }); + s.set_unit( + "player", + Some(UnitState { + exists: true, + name: Some("Probefour".into()), + level: 60, + class: Some("Warrior".into()), + class_file: Some("WARRIOR".into()), + ..Default::default() + }), + ); + let failures = super::load_default_ui(&s); + assert!(failures.is_empty(), "manifest load errors: {failures:#?}"); + s +} + +fn cvar(s: &UiScript, name: &str) -> Option { + s.eval::>(&format!("return GetCVar({name:?})")) + .unwrap() +} + +fn slider(s: &UiScript, i: usize) -> f64 { + s.eval::(&format!("return OptionsFrameSlider{i}:GetValue()")) + .unwrap() +} + +fn assert_clean(s: &UiScript, step: &str) { + let errors = s.errors(); + assert!(errors.is_empty(), "{step} raised: {errors:#?}"); +} + +/// Open, walk every box and slider, Okay; reopen and Defaults; reopen, move the gamma and Cancel. +/// Each step runs the stock Lua to its end: an aborted `OptionsFrame_Load` leaves +/// `OptionsFrame.gamma` nil for `OnHide`'s `SetGamma` (`OptionsFrame.lua:260`). +#[test] +fn the_stock_video_window_opens_saves_restores_defaults_and_cancels_clean() { + let _data = benilla_formats::wow_data_or_skip!(); + let s = video_ui(); + + // ── Open: `OptionsFrame_Load` reads every slider through its verb or its CVar. ── + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + assert_clean(&s, "opening the window"); + assert!(s.eval::("return OptionsFrame:IsShown()").unwrap()); + // Environment Detail: `GetWorldDetail`, stop 1 at the registered `SmallCull` 0.04. + assert_eq!(slider(&s, 3), 1.0); + // Terrain Mip: `GetTerrainMip` is `1 - shadowLevel` (`0x488fbd`), `shadowLevel` "1". + assert_eq!(slider(&s, 4), 0.0); + // Texture Detail: `GetBaseMip` is `1 - baseMip` (`0x48928d`), `baseMip` "0". + assert_eq!(slider(&s, 5), 1.0); + // Spell Detail: the CVar path, `spellEffectLevel` "2". + assert_eq!(slider(&s, 8), 2.0); + // `OptionsFrame_Load:153-154` recorded both for `OnHide`. + assert_eq!(s.eval::("return OptionsFrame.gamma").unwrap(), 0.0); + assert_eq!( + s.eval::>("return OptionsFrame.desktopGamma") + .unwrap() + .as_deref(), + Some("0"), + "`DesktopGamma` registers \"0\" (`0x402d4d`)" + ); + + // ── Every box, then every slider to a position off its default. ── + for i in 1..=18 { + s.run(&format!("OptionsFrameCheckButton{i}:Click()")) + .unwrap(); + } + for (i, v) in [ + (1, "1.0"), + (2, "777"), + (3, "2"), + (4, "1"), + (5, "0"), + (6, "0.5"), + (7, "5"), + (8, "0"), + (9, "0"), + ] { + s.run(&format!("OptionsFrameSlider{i}:SetValue({v})")) + .unwrap(); + } + assert_clean(&s, "walking the boxes and sliders"); + + // ── Okay: `OptionsFrame_Save`, then `OnHide`'s `OptionsFrame_Cancel`. ── + s.run("OptionsFrameOkay:Click()").unwrap(); + assert_clean(&s, "Okay"); + assert!(!s.eval::("return OptionsFrame:IsShown()").unwrap()); + // `SetWorldDetail(2)`: `frillDensity` 48 and `smallCull` "%f" of 0.01 (`0x488e1e`, `0x488e56`). + assert_eq!(cvar(&s, "frillDensity").as_deref(), Some("48")); + assert_eq!(cvar(&s, "SmallCull").as_deref(), Some("0.010000")); + assert_eq!(s.eval::("return GetWorldDetail()").unwrap(), 2.0); + // `SetTerrainMip(1)` writes `1 - 1` as "%d" (`0x48901d`-`0x489025`). + assert_eq!(cvar(&s, "shadowLevel").as_deref(), Some("0")); + // `SetBaseMip(0)` writes `trunc(1 - 0)` as "%d" (`0x4892f5`-`0x489301`). + assert_eq!(cvar(&s, "baseMip").as_deref(), Some("1")); + assert_eq!(cvar(&s, "spellEffectLevel").as_deref(), Some("0")); + assert_eq!(cvar(&s, "farclip").as_deref(), Some("777")); + assert_eq!(cvar(&s, "gamma").as_deref(), Some("0.500000")); + // The Use Desktop Gamma box was ticked, `_Save` wrote "1" and the recorded value follows it. + assert_eq!(cvar(&s, "desktopGamma").as_deref(), Some("1")); + + // ── Defaults: `OptionsFrame_SetDefaults` reads `GetCVarDefault` for `smallCull`, + // `shadowLevel` and `baseMip` (`:431-443`) and ends in `RestoreVideoDefaults`. ── + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + assert_clean(&s, "reopening for Defaults"); + s.run("OptionsFrameDefaults:Click()").unwrap(); + assert_clean(&s, "Defaults"); + assert!(!s.eval::("return OptionsFrame:IsShown()").unwrap()); + // `RestoreVideoDefaults` restores the graphics CVars benilla registers among `0x639a60`'s. + for (name, default) in [ + ("farclip", "350"), + ("SmallCull", "0.04"), + ("shadowLevel", "1"), + ("baseMip", "0"), + ("doodadAnim", "1"), + ] { + assert_eq!(cvar(&s, name).as_deref(), Some(default), "{name} restored"); + } + + // ── Cancel: `OnHide` puts back the gamma and desktop gamma recorded at open. ── + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + let gamma_at_open = cvar(&s, "gamma"); + let desktop_at_open = cvar(&s, "desktopGamma"); + s.run("OptionsFrameCheckButton1:SetChecked(nil) OptionsFrameSlider6:SetValue(-0.5) SetGamma(-0.5)") + .unwrap(); + s.run("OptionsFrameCancel:Click()").unwrap(); + assert_clean(&s, "Cancel"); + assert!(!s.eval::("return OptionsFrame:IsShown()").unwrap()); + assert_eq!( + cvar(&s, "gamma"), + gamma_at_open, + "Cancel restores the gamma" + ); + assert_eq!( + cvar(&s, "desktopGamma"), + desktop_at_open, + "…and the desktop gamma (`OptionsFrame.lua:261`)" + ); +} diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 429a1538..78fd0e47 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -163,6 +163,9 @@ pub(crate) fn on_cvar( mut tex_filter: ResMut, mut clutter: ResMut, mut weather: ResMut, + particles: Option>, + mut spell_effect_level: ResMut, + mut console: Option>, mut cvars: ResMut, ) { use benilla_world::view::{FARCLIP_RANGE, MSAA_RANGE}; @@ -209,6 +212,12 @@ pub(crate) fn on_cvar( benilla_ui::script::CVAR_FRILL_DENSITY, &clutter.frill_density().to_string(), ); + // The stop's other half, as `SetWorldDetail` writes it, so a stop set as a CVar keeps + // `SmallCull` in step too. + cvars.mirror( + benilla_ui::script::CVAR_SMALL_CULL, + &benilla_ui::script::small_cull_text(v.clamp(0.0, 2.0) as usize), + ); } // The same knob in the reference's cells per chunk, clamped to `[1, 256]` // (`ClutterConfig::set_frill_density`); the loaded tiles re-scatter off the change. @@ -223,10 +232,48 @@ pub(crate) fn on_cvar( // quality cells {0.1, 0.33, 0.66, 1.0} at `[0x8680ec]`. Its off-grid handling is // untraced; this clamps. "weatherdensity" => weather.weather_density = v.trunc().clamp(0.0, 3.0) as u8, + // Spell Detail (`0x689510`): `SStrToInt`, clamped to [0, 2] in the handler's own copy, + // echoed, then the emission scalar 0.33, 0.66 or 1.0 through `0x7adfb0`, shared with + // `particleDensity`. The reference runs it on every write, an unchanged one included; an + // observer fires only on a change. + "spelleffectlevel" => { + spell_effect_level.0 = benilla_ui::script::sstr_to_int(&ev.new); + let level = spell_effect_level.0.clamp(0, 2); + if let Some(console) = console.as_mut() { + console.print(format!("Spell effect level set to {level}.")); + } + if let Some(mut particles) = particles { + particles.set_density(spell_effect_scale(level)); + } + } _ => {} } } +/// The `spellEffectLevel` record's integer (`rec+0x28`, `SStrToInt` of the value), unclamped: what +/// the dynamic-object shard emitter reads at spawn (`0x6eb967`), where the handler clamps only its +/// own copy. +#[derive(Resource)] +pub(crate) struct SpellEffectLevel(pub(crate) i32); + +impl Default for SpellEffectLevel { + /// The registered "2". + fn default() -> Self { + Self(2) + } +} + +/// The `spellEffectLevel` emission factor, 0.33, 0.66 or 1.0: the handler's f32 immediates +/// (`0x68956a` `0x3ea8f5c3`, `0x689588` `0x3f28f5c3`, `0x689561` `0x3f800000`) and the shard +/// emitter's `.rdata` pair (`0x808300`, `0x81199c`) are the same three. Any level but 0 or 1 is 1.0. +pub(crate) fn spell_effect_scale(level: i32) -> f32 { + match level { + 0 => 0.33, + 1 => 0.66, + _ => 1.0, + } +} + /// `/console detailDoodadAlpha [0..255]`, the reference's console command (`0x6739a0`, registered /// by `0x63f9e0` as a command, not a CVar, so it never persists): the ground-clutter cutout that /// `texel.a x distance_ramp` is tested against, so at the default 128 grass ends ~61 yd out of @@ -256,6 +303,7 @@ fn detail_doodad_alpha(world: &mut World, args: &str) -> Vec { impl Plugin for VideoPlugin { fn build(&self, app: &mut App) { use crate::console::ConsoleCommandApp; + app.init_resource::(); app.add_observer(on_cvar); app.console_command( "detailDoodadAlpha", diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index 93df715b..2f345e49 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -133,6 +133,7 @@ const PUBLISHED: &[(&str, &str)] = &[ ("model_render::ModelPart", "record"), ("model_render::lazy::realize", "record"), ("modkeys::SyntheticHold", "wall"), + ("particles::ParticleTuning", "record"), ("particles::ViewThrottled", "record"), ("particles::render::EFFECT_DRAW_STATS", "record"), ("particles::spawn_emitter", "record"), diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 70e42f78..e5245d52 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -40,6 +40,8 @@ pub const SMSG_UPDATE_OBJECT: u16 = 0x00A9; pub const SMSG_DESTROY_OBJECT: u16 = 0x00AA; // Cinematics: a first login's race intro, or a type-13 GameObject camera. Until the COMPLETE ack, // vmangos sees from the camera and despawns all around the body (`Player::UpdateCinematic`). +/// Lua's `OpeningCinematic()` (`0x48c8c0`); empty body. vmangos does not handle it. +pub const CMSG_OPENING_CINEMATIC: u16 = 0x00F9; pub const SMSG_TRIGGER_CINEMATIC: u16 = 0x00FA; /// The next camera of a multi-camera `CinematicSequences` row began; empty body (`0x48efe0`). pub const CMSG_NEXT_CINEMATIC_CAMERA: u16 = 0x00FB; diff --git a/crates/benilla-protocol/src/world/writer/lifecycle.rs b/crates/benilla-protocol/src/world/writer/lifecycle.rs index 94d38a2f..fbd72d98 100644 --- a/crates/benilla-protocol/src/world/writer/lifecycle.rs +++ b/crates/benilla-protocol/src/world/writer/lifecycle.rs @@ -37,6 +37,12 @@ impl WorldWriter { self.send(opcode::CMSG_QUERY_TIME, &messages::query_time()) } + /// `CMSG_OPENING_CINEMATIC`, empty: Lua's `OpeningCinematic()` (`0x48c8c0`, opcode `0xf9` put + /// at `0x48c8c9`, sent through `0x5ab630`). + pub fn opening_cinematic(&mut self) -> Result<()> { + self.send(opcode::CMSG_OPENING_CINEMATIC, &[]) + } + /// `CMSG_COMPLETE_CINEMATIC`, empty: sent when a cinematic ends or is escaped, and owed for /// every `SMSG_TRIGGER_CINEMATIC`; until then vmangos anchors visibility to the cinematic /// camera and the world around the body despawns. diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index 9d85cfcc..c242e4d0 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -22,6 +22,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, ()| lua.create_string("enUS"))?, )?; g.set("IsMacClient", lua.create_function(|_, ()| Ok(Value::Nil))?)?; + // The PC build's other two platform answers: `IsWindowsClient()` (`0x48c960`) pushes the + // number 1 and `IsLinuxClient()` (`0x48c990`) one nil. Arguments are ignored. + g.set( + "IsWindowsClient", + lua.create_function(|_, _: MultiValue| Ok(1.0))?, + )?; + g.set( + "IsLinuxClient", + lua.create_function(|_, _: MultiValue| Ok(Value::Nil))?, + )?; + + // `GetDebugStats()` (`0x488af0`, `xor eax,eax; ret`): zero values, which `StatsFrame.lua:20` + // hands straight to `SetText`. + g.set( + "GetDebugStats", + lua.create_function(|_, _: MultiValue| Ok(MultiValue::new()))?, + )?; + + // `OpeningCinematic()` (`0x48c8c0`): sends an empty `CMSG_OPENING_CINEMATIC` (`0xf9`, + // `0x48c8c9` into `0x5ab630`) and returns nothing; the server may answer with + // `SMSG_TRIGGER_CINEMATIC`. Arguments are ignored. + g.set( + "OpeningCinematic", + lua.create_function(|lua, _: MultiValue| { + lua.app_data_mut::() + .expect("model app_data") + .opening_cinematic_asks += 1; + Ok(()) + })?, + )?; + + // Three setters whose state has no reader here, so each takes its argument as the reference + // does, never raising, and stores nothing; all return zero values. + // - `SetEuropeanNumbers(flag)` (`0x48dec0`): `GetBoolOrDefault(1, true)` into `[0xcf4d24]`, + // which turns on `FontString:SetText`'s digit-group pass (`0x771daa` into `0x771c50`). + // benilla's `SetText` has no such pass. + // - `SetLayoutMode(mode)` (`0x488540`): a number truncates into the UI root's `+0xcf8`, anything + // else stores 1; read by the root's mouse handler (`0x766301`) for a frame-dragging mode + // benilla does not build. + // - `SetConsoleKey(key)` (`0x488640`): a one-character or named key becomes the console's + // toggle key (`0x63cb00`), a non-string the default `0x30e`. benilla has no console screen. + for name in ["SetEuropeanNumbers", "SetLayoutMode", "SetConsoleKey"] { + g.set( + name, + lua.create_function(|_, _: MultiValue| Ok(MultiValue::new()))?, + )?; + } // `FrameXML_Debug([v])` (`0x488440`): get-or-set of the XML loader's trace flag `[0xceea30]`, // which gates `crate::loader::LoadReport::traces`. A Lua-truthy argument sets it, truncated @@ -146,6 +193,11 @@ impl super::UiScript { std::mem::take(&mut self.model_mut().played_time_asks) } + /// Drain the `OpeningCinematic()` calls, each an empty `CMSG_OPENING_CINEMATIC`. + pub fn take_opening_cinematic_asks(&mut self) -> u32 { + std::mem::take(&mut self.model_mut().opening_cinematic_asks) + } + /// Push the bind location's name, resolved from `SMSG_BINDPOINTUPDATE`'s AreaTable id through /// the catalog the hearthstone's `$z` uses, so the two agree; empty until the packet lands. pub fn set_bind_location(&mut self, area_name: &str) { @@ -281,4 +333,45 @@ mod tests { "nil is the PC arm, which is the arm this client wants" ); } + + /// `IsWindowsClient` (`0x48c960`) pushes 1 and `IsLinuxClient` (`0x48c990`) nil, one value + /// each; `GetDebugStats` (`0x488af0`) pushes none. + #[test] + fn the_platform_queries_and_debug_stats_answer_the_pc_build() { + let s = UiScript::new().unwrap(); + assert_eq!(s.arity("IsWindowsClient()").unwrap(), 1); + assert_eq!(s.eval::("return IsWindowsClient(0)").unwrap(), 1.0); + assert_eq!(s.arity("IsLinuxClient()").unwrap(), 1); + assert!(s.eval::("return IsLinuxClient() == nil").unwrap()); + assert_eq!(s.arity("GetDebugStats()").unwrap(), 0); + } + + /// Each `OpeningCinematic()` queues one send and returns nothing. + #[test] + fn opening_cinematic_queues_one_send_per_call() { + let mut s = UiScript::new().unwrap(); + assert_eq!(s.arity("OpeningCinematic()").unwrap(), 0); + s.run("OpeningCinematic(1)").unwrap(); + assert_eq!(s.take_opening_cinematic_asks(), 2); + assert_eq!( + s.take_opening_cinematic_asks(), + 0, + "the drain empties the queue" + ); + } + + /// The three setters never raise, whatever they are handed, and return zero values. + #[test] + fn the_stateless_setters_take_any_argument() { + let s = UiScript::new().unwrap(); + for name in ["SetEuropeanNumbers", "SetLayoutMode", "SetConsoleKey"] { + for arg in ["", "nil", "1", "\"x\"", "{}", "\"ESCAPE\""] { + assert_eq!( + s.arity(&format!("{name}({arg})")).unwrap(), + 0, + "{name}({arg})" + ); + } + } + } } diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs index 143f1cb1..14ffe513 100644 --- a/crates/benilla-ui/src/script/cvars.rs +++ b/crates/benilla-ui/src/script/cvars.rs @@ -332,6 +332,37 @@ fn cvar_arg(v: Option<&Value>) -> Option { } } +/// A value as the video setters print it, `SStrPrintf(buf, 0x10, "%f", v)` (`0x64a7f0` with the +/// format at `0x835160`): six decimals in a 16-byte buffer, so the stored string is cut at 15 +/// characters. +pub(super) fn format_f(v: f64) -> String { + let mut written = format!("{v:.6}"); + written.truncate(15); + written +} + +/// Storm's `SStrToInt` (`0x64ac60`), how a CVar record's integer (`rec+0x28`) is parsed from its +/// value and how a change callback reads it: an optional `-`, then decimal digits up to the first +/// other byte, with no whitespace skip, no `+` and no overflow check (`10Β·n + d` wraps); no digit +/// is 0. +pub fn sstr_to_int(s: &str) -> i32 { + let (neg, digits) = match s.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, s), + }; + let n = digits + .bytes() + .take_while(u8::is_ascii_digit) + .fold(0i32, |n, d| { + n.wrapping_mul(10).wrapping_add(i32::from(d - b'0')) + }); + if neg { + n.wrapping_neg() + } else { + n + } +} + /// What `SetCVar` and `RegisterCVar` store for a value with no string form (`0x82e570`, loaded /// at `0x488c98` and `0x488b6f`). const NO_STRING_FORM: &str = "0"; @@ -526,7 +557,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; install_nameplate_verbs(lua)?; - install_world_detail_verbs(lua) + install_world_detail_verbs(lua)?; + super::video_pairs::install(lua) } /// What `RestoreVideoDefaults` puts back: the CVars among the reference verb's writes that benilla @@ -545,10 +577,15 @@ pub const VIDEO_DEFAULT_CVARS: &[&str] = &[ "gxColorBits", "gxDepthBits", "gxMultisample", - // Of the sixteen further graphics CVars (`0x639a60`), the three benilla registers. + // Of the sixteen further graphics CVars (`0x639a60`), the seven benilla registers, in the + // pass's order. "farclip", + "shadowLevel", "frillDensity", + "doodadAnim", "trilinear", + CVAR_SMALL_CULL, + "baseMip", // Ours, restored with `frillDensity` so the pair keeps describing one detail level. "WorldDetail", ]; @@ -560,7 +597,8 @@ pub const VIDEO_DEFAULT_CVARS: &[&str] = &[ /// Ten names must stay nil so their slider rows fall back to `GetCVar`/`SetCVar` /// (`OptionsFrame_Load:110` tries `getglobal("Get"..value.func)`): `Getuiscale`, `Getfarclip`, /// `Getanisotropic`, `GetspellEffectLevel`, `GetweatherDensity` and their setters. -/// `GetFarclip`/`SetFarclip` do exist (`0x488f00`/`0x488f30`); `getglobal` is case-sensitive. +/// `GetFarclip`/`SetFarclip` (`0x488f00`/`0x488f30`, in [`super::video_pairs`]) do exist; +/// `getglobal` is case-sensitive. fn install_video_verbs(lua: &Lua) -> mlua::Result<()> { // ── GetScreenResolutions ───────────────────────────────────────────────────────────────── // A vararg of `"WxH"` strings, the spelling `ScreenResolution` pins. @@ -744,12 +782,8 @@ fn install_video_verbs(lua: &Lua) -> mlua::Result<()> { } _ => return Err(mlua::Error::runtime(USAGE_SET_GAMMA)), }; - // `SStrPrintf(buf, 0x10, "%f", 1.0 - v)`: a 16-byte buffer, so the stored string, and - // what `GetCVar("gamma")` answers, is cut at 15 characters. - let mut written = format!("{:.6}", 1.0 - v); - written.truncate(15); let mut model = lua.app_data_mut::().expect("model app_data"); - write_cvar(&mut model, CVAR_GAMMA, written); + write_cvar(&mut model, CVAR_GAMMA, format_f(1.0 - v)); // Zero return values, not nil (`eax = 0` at every `ret`). Ok(mlua::MultiValue::new()) })?, @@ -771,18 +805,32 @@ pub const CVAR_WORLD_DETAIL: &str = "WorldDetail"; /// The reference's Environment Detail CVar: cells visited per chunk by the detail-doodad scatter. pub const CVAR_FRILL_DENSITY: &str = "frillDensity"; +/// The reference's object-size cull (`0x68854a`, name `0x8696f8`), the other half of each +/// Environment Detail stop. `SetWorldDetail` looks it up as `smallCull` (`0x8423ac`), the same +/// record under the table's case-folding hash. +pub const CVAR_SMALL_CULL: &str = "SmallCull"; + /// `SetWorldDetail`'s `frillDensity` per stop, the three dwords at `0x804518`. pub const WORLD_DETAIL_STOPS: [u32; 3] = [16, 32, 48]; +/// `SetWorldDetail`'s `smallCull` per stop, the three f32s at `0x804524`. +const SMALL_CULL_STOPS: [f32; 3] = [0.07, 0.04, 0.01]; + +/// The `SmallCull` text `SetWorldDetail` writes for a stop (clamped to 2): the f32 as "%f". +pub fn small_cull_text(stop: usize) -> String { + format_f(f64::from(SMALL_CULL_STOPS[stop.min(2)])) +} + /// The Environment Detail pair, `SetWorldDetail 0x488dd0` and `GetWorldDetail 0x488d70`, which /// `OptionsFrame.lua` row 3 (`func = "WorldDetail"`) drives in place of `SetCVar`/`GetCVar`. The /// setter truncates toward zero (`0x40a2b0`), raises outside 0..=2, and writes `frillDensity` from /// [`WORLD_DETAIL_STOPS`] and `smallCull` from `{0.07, 0.04, 0.01}` (`0x804524`). /// -/// Deviation: the getter reads [`CVAR_WORLD_DETAIL`] and `smallCull` is not registered, because -/// the reference's getter is `smallCull`'s only reader (`[0x868620]` is never read). A fresh -/// reference client reads stop 1 (`frillDensity 16` with `smallCull 0.04`), hence -/// `WorldDetail`'s `"1"`; only a bare `frillDensity` write moves ours and not the reference's. +/// Deviation: the getter reads [`CVAR_WORLD_DETAIL`], where the reference's reads only `smallCull` +/// (`0x488d77`), because benilla's stop follows the knob that draws: a console `frillDensity` +/// write moves the ground clutter and `WorldDetail` with it, and no renderer reads `smallCull` +/// (its callback's `[0x868620]` has no reader). A fresh reference client reads stop 1 +/// (`smallCull 0.04`), hence `WorldDetail`'s `"1"`. fn install_world_detail_verbs(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); g.set( @@ -811,13 +859,15 @@ fn install_world_detail_verbs(lua: &Lua) -> mlua::Result<()> { } let stop = stop as usize; let mut model = lua.app_data_mut::().expect("model app_data"); - // Two CVars per stop, as `0x488dd0` writes two: `frillDensity` and, here, the stop. - // Writing only one would leave `GetWorldDetail` stale until the host drains the queue. + // The reference's two writes, `frillDensity` as "%d" (`0x488e26`) and `smallCull` as + // "%f" of the f32 (`0x488e63`), then ours, the stop: writing it here keeps + // `GetWorldDetail` current before the host drains the queue. write_cvar( &mut model, CVAR_FRILL_DENSITY, WORLD_DETAIL_STOPS[stop].to_string(), ); + write_cvar(&mut model, CVAR_SMALL_CULL, small_cull_text(stop)); write_cvar(&mut model, CVAR_WORLD_DETAIL, stop.to_string()); // Zero return values, not nil (`eax = 0` at every `ret`). Ok(mlua::MultiValue::new()) diff --git a/crates/benilla-ui/src/script/frame_enum.rs b/crates/benilla-ui/src/script/frame_enum.rs new file mode 100644 index 00000000..3ce01bb5 --- /dev/null +++ b/crates/benilla-ui/src/script/frame_enum.rs @@ -0,0 +1,130 @@ +//! `GetNumFrames` (`0x705f00`) and `EnumerateFrames` (`0x705f60`), registered beside `CreateFrame` +//! from the frame-script table `0x872e74`: both walk the UI root's list of every live frame +//! (`root+0xcc4`), which a frame joins at construction and leaves at destruction, hidden or not. +//! Regions are not frames and are not on it. Here the list is the frame ids in ascending order, +//! since every creation path mints its frame's id at once ([`Model::frame_id`]). + +use mlua::{Lua, MultiValue, Value}; + +use super::object::{decode_id, frame_wrapper}; +use super::Model; + +/// `0x872f08`, verbatim. +const NO_THIS: &str = "EnumerateFrames: Couldn't find 'this' in current object"; +/// `0x872ecc`, verbatim. +const NOT_A_FRAME: &str = "EnumerateFrames: Wrong current object type, expected frame"; + +/// The first live frame with an id above `after`, in creation order. +fn next_frame(model: &Model, after: u32) -> Option { + (after.saturating_add(1)..model.next_id).find(|id| { + model + .id_to_frame + .get(id) + .is_some_and(|h| model.arena.frame(*h).is_some()) + }) +} + +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + let g = lua.globals(); + + // `GetNumFrames()`: the list's length as one number; any arguments are ignored. + g.set( + "GetNumFrames", + lua.create_function(|lua, _: MultiValue| { + let model = lua.app_data_ref::().expect("model app_data"); + Ok(model.arena.iter_frames().count() as f64) + })?, + )?; + + // `EnumerateFrames([frame])`: the frame after `frame` on the list, or the first when the + // argument is not a table (`lua_type(L, 1) == 5`, `0x705f6e`); nil past the last. A table is + // read as an object through its `[0]` (`lua_rawgeti`, `0x705f7c`): none raises `0x872f08`, + // and an object that is not a frame, a Texture or FontString, raises `0x872ecc`. + g.set( + "EnumerateFrames", + lua.create_function(|lua, args: MultiValue| { + let after = match args.front() { + Some(Value::Table(t)) => { + let id = match t.raw_get::(0)? { + Value::LightUserData(l) if !l.0.is_null() => decode_id(t)?, + _ => return Err(mlua::Error::runtime(NO_THIS)), + }; + let model = lua.app_data_ref::().expect("model app_data"); + if !model.id_to_frame.contains_key(&id) { + return Err(mlua::Error::runtime(NOT_A_FRAME)); + } + id + } + _ => 0, + }; + let next = { + let model = lua.app_data_ref::().expect("model app_data"); + next_frame(&model, after) + }; + match next { + Some(id) => Ok(Value::Table(frame_wrapper(lua, id)?)), + None => Ok(Value::Nil), + } + })?, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::script::UiScript; + + #[test] + fn enumerate_frames_walks_every_live_frame_in_creation_order() { + let s = UiScript::new().unwrap(); + s.run( + "A = CreateFrame('Frame', 'EnumA') \ + A:CreateTexture('EnumATex') \ + B = CreateFrame('Button', nil, A) B:Hide() \ + C = CreateFrame('Frame', 'EnumC')", + ) + .unwrap(); + let (count, walked, tail): (i64, i64, Vec) = s + .eval( + "local n, f, seen = 0, EnumerateFrames(), {} \ + while f do n = n + 1 table.insert(seen, f) f = EnumerateFrames(f) end \ + local k = table.getn(seen) \ + return GetNumFrames(), n, { seen[k - 2] == A, seen[k - 1] == B, seen[k] == C }", + ) + .unwrap(); + assert_eq!( + walked, count, + "the walk visits exactly GetNumFrames() frames" + ); + assert_eq!( + tail, + [true, true, true], + "the three newest frames close the walk in creation order, the hidden and nameless \ + one included, and the texture is not a frame" + ); + assert_eq!(s.arity("GetNumFrames()").unwrap(), 1); + assert_eq!(s.arity("EnumerateFrames(C)").unwrap(), 1); + assert!(s.eval::("return EnumerateFrames(C) == nil").unwrap()); + // A non-table argument starts over. + assert!(s + .eval::("return EnumerateFrames(5) == EnumerateFrames() and EnumerateFrames('x') == EnumerateFrames()") + .unwrap()); + } + + #[test] + fn enumerate_frames_refuses_what_is_not_a_frame() { + let s = UiScript::new().unwrap(); + s.run("F = CreateFrame('Frame', 'EnumF') T = F:CreateTexture('EnumT')") + .unwrap(); + let e = s.run("EnumerateFrames({})").unwrap_err().to_string(); + assert!( + e.contains("EnumerateFrames: Couldn't find 'this' in current object"), + "{e}" + ); + let e = s.run("EnumerateFrames(T)").unwrap_err().to_string(); + assert!( + e.contains("EnumerateFrames: Wrong current object type, expected frame"), + "{e}" + ); + } +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 3ff9dcfe..51980c73 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -60,6 +60,7 @@ mod extract; mod follow; pub(crate) mod font; mod font_block; +mod frame_enum; mod gm_ticket; mod gossip; mod guild; @@ -133,6 +134,7 @@ mod tradeskill; mod trainer; mod types; mod unit; +mod video_pairs; mod weapon_enchant; mod who_sort; mod worldmap; @@ -175,10 +177,11 @@ pub use cursor::{ CursorAction, CursorItem, CursorMacro, CursorMerchantItem, CursorMoney, CursorPayload, CursorPetAction, CursorSpell, CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG, }; +pub use cvars::{small_cull_text, sstr_to_int}; pub use cvars::{ MultisampleFormat, ScreenResolution, SeededCvar, VideoCaps, CVAR_FRILL_DENSITY, CVAR_GAMMA, - CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS, CVAR_WORLD_DETAIL, IN_WORLD_READ_ONLY_CVARS, - VIDEO_DEFAULT_CVARS, WORLD_DETAIL_STOPS, + CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS, CVAR_SMALL_CULL, CVAR_WORLD_DETAIL, + IN_WORLD_READ_ONLY_CVARS, VIDEO_DEFAULT_CVARS, WORLD_DETAIL_STOPS, }; pub use death::{DeathAction, DeathUiState}; pub use dressup::DressUpIntent; @@ -200,6 +203,7 @@ pub use tabard::{ emblem_mask_path, TabardHost, TabardIntent, EMBLEM_MASK_TOKEN, TABARD_COUNTS, TABARD_CREATION_COST, }; +pub use video_pairs::VIDEO_PAIR_CVARS; pub use worldmap_arrow::ARROW_MODEL; pub(crate) use button::{set_label_font_justify_h_lua, LabelFont}; @@ -496,6 +500,7 @@ impl UiScript { lua50::install(&lua)?; stdlib::install(&lua)?; object::install(&lua)?; + frame_enum::install(&lua)?; // After `object`, whose `publish_global` it reuses, and before any FrameXML loads: // `Loader::do_font` publishes into the tables this builds. font::install(&lua)?; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index b298c1db..939ea0cd 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -900,6 +900,8 @@ pub(crate) struct Model { pub(crate) addon_sends: Vec, /// `RequestTimePlayed()` calls, one empty `CMSG_PLAYED_TIME` each. pub(crate) played_time_asks: u32, + /// `OpeningCinematic()` calls, one empty `CMSG_OPENING_CINEMATIC` each. + pub(crate) opening_cinematic_asks: u32, /// `Screenshot()` calls, one capture each. pub(crate) screenshot_asks: u32, /// `GetRealmName()`: `""` until pushed, never nil, since addons index tables with it at load. @@ -1339,6 +1341,7 @@ impl Model { chat_sends: Vec::new(), addon_sends: Vec::new(), played_time_asks: 0, + opening_cinematic_asks: 0, screenshot_asks: 0, realm_name: String::new(), player_record: super::PlayerRecord::default(), diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 037183b5..1bf260e7 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -1325,18 +1325,29 @@ fn set_world_detail_writes_the_stop_table_and_validates_like_the_reference() { s.register_cvars([ (crate::script::CVAR_WORLD_DETAIL, "1"), (crate::script::CVAR_FRILL_DENSITY, "32"), + (crate::script::CVAR_SMALL_CULL, "0.04"), ]); let frill = |s: &crate::script::UiScript| s.cvar(crate::script::CVAR_FRILL_DENSITY); let stop = |s: &crate::script::UiScript| s.cvar(crate::script::CVAR_WORLD_DETAIL); - // The preset table at `0x804518`, verbatim: {16, 32, 48}. - for (n, want) in [(0, "16"), (1, "32"), (2, "48")] { + // The preset tables at `0x804518` and `0x804524`, verbatim: {16, 32, 48} and `smallCull`'s + // f32 {0.07, 0.04, 0.01} printed "%f". + for (n, want, cull) in [ + (0, "16", "0.070000"), + (1, "32", "0.040000"), + (2, "48", "0.010000"), + ] { s.run(&format!("SetWorldDetail({n})")).unwrap(); assert_eq!( frill(&s).as_deref(), Some(want), "stop {n} writes frillDensity" ); + assert_eq!( + s.cvar(crate::script::CVAR_SMALL_CULL).as_deref(), + Some(cull), + "stop {n} writes smallCull" + ); assert_eq!( s.eval::("return GetWorldDetail()").unwrap(), n, diff --git a/crates/benilla-ui/src/script/video_pairs.rs b/crates/benilla-ui/src/script/video_pairs.rs new file mode 100644 index 00000000..69dfc24c --- /dev/null +++ b/crates/benilla-ui/src/script/video_pairs.rs @@ -0,0 +1,302 @@ +//! The Video options getter/setter pairs beside `GetWorldDetail` and `GetGamma` in the main table +//! `0x83de68` (records `0x83df10`..`0x83df68`): water detail, far clip, terrain mip, doodad +//! animation, texture LOD bias and base mip. Each getter ignores its arguments and pushes one +//! number; each setter takes `lua_isnumber(1)` (`0x6f34d0`) or raises its `Usage:` string, writes +//! one CVar through `CVar::Set` (`0x63df50`) and returns zero values. +//! +//! The stock window reaches only `Get/SetTerrainMip` and `Get/SetBaseMip` (`OptionsFrame.lua:28-29`, +//! `func` spelled as the verb); its far-clip row composes `Getfarclip`, which must stay nil. + +use mlua::{Lua, MultiValue, Value}; + +use super::cvars::{format_f, sstr_to_int, write_cvar}; +use super::Model; + +/// The CVars these pairs read and write, which the host must register: a missing row would make a +/// setter warn and store nothing. +pub const VIDEO_PAIR_CVARS: [&str; 5] = [ + "farclip", + "shadowLevel", + "doodadAnim", + "texLodBias", + "baseMip", +]; + +/// How a getter answers, from the CVar record it looks up by name (`0x63de30`). +#[derive(Clone, Copy)] +enum Read { + /// A constant, `GetWaterDetail`'s `0.0` (`0x488ec0`). + Zero, + /// The record's f32 (`fld [rec+0x24]`), widened. + Float(&'static str), + /// `1.0 βˆ’ ` the record's integer (`fild [rec+0x28]`, `fsubr [0x8015b8]`). + OneMinusInt(&'static str), + /// `1.0 βˆ’ ` the record's f32 (`fld [rec+0x24]`, `fsubr [0x8015b8]`). + OneMinusFloat(&'static str), +} + +/// How a setter writes, after the `lua_isnumber` gate and `lua_tonumber` (`0x6f3620`). +#[derive(Clone, Copy)] +enum Write { + /// `SetWaterDetail` (`0x488ed0`): the gate, then nothing. + Nothing, + /// The number as "%f" (format `0x835160`). + Float(&'static str), + /// `1 βˆ’ trunc(v)` as "%d" (`__ftol`, then `mov ecx,1; sub ecx,eax`, format `0x835154`). + OneMinusTrunc(&'static str), + /// `trunc(1.0 βˆ’ v)` as "%d" (`fsubr [0x8015b8]`, then `__ftol`). + TruncOneMinus(&'static str), +} + +/// One pair: its two globals, the setter's usage string and the CVar's registered default, which a +/// bare VM without the host's rows reads as the record's value. +struct Pair { + get: &'static str, + set: &'static str, + usage: &'static str, + read: Read, + write: Write, + default: &'static str, +} + +const PAIRS: [Pair; 6] = [ + // `0x488ec0`/`0x488ed0`, usage `0x842408`: no CVar behind either. + Pair { + get: "GetWaterDetail", + set: "SetWaterDetail", + usage: "Usage: SetWaterDetail(value)", + read: Read::Zero, + write: Write::Nothing, + default: "0", + }, + // `0x488f00`/`0x488f30` over `farclip` (`0x842428`), usage `0x842430`. + Pair { + get: "GetFarclip", + set: "SetFarclip", + usage: "Usage: SetFarclip(value)", + read: Read::Float("farclip"), + write: Write::Float("farclip"), + default: "350", + }, + // `0x488fb0`/`0x488fe0` over `shadowLevel` (`0x84244c`), usage `0x842458`: the slider shows + // `1 βˆ’ shadowLevel`, so High (1) is shadow mip 0. + Pair { + get: "GetTerrainMip", + set: "SetTerrainMip", + usage: "Usage: SetTerrainMip(value)", + read: Read::OneMinusInt("shadowLevel"), + write: Write::OneMinusTrunc("shadowLevel"), + default: "1", + }, + // `0x489060`/`0x489090` over `doodadAnim` (`0x842474`), usage `0x842480`. + Pair { + get: "GetDoodadAnim", + set: "SetDoodadAnim", + usage: "Usage: SetDoodadAnim(value)", + read: Read::Float("doodadAnim"), + write: Write::Float("doodadAnim"), + default: "1", + }, + // `0x489110`/`0x489140` over `texLodBias` (`0x84249c`), usage `0x8424a8`. + Pair { + get: "GetTexLodBias", + set: "SetTexLodBias", + usage: "Usage: SetTexLodBias(value)", + read: Read::Float("texLodBias"), + write: Write::Float("texLodBias"), + default: "0.0", + }, + // `0x489280`/`0x4892b0` over `baseMip` (`0x8424e4`), usage `0x8424ec`: the slider shows + // `1 βˆ’ baseMip`, so High (1) is base mip 0. + Pair { + get: "GetBaseMip", + set: "SetBaseMip", + usage: "Usage: SetBaseMip(value)", + read: Read::OneMinusFloat("baseMip"), + write: Write::TruncOneMinus("baseMip"), + default: "0", + }, +]; + +/// `__ftol` (`0x40a2b0`) truncates toward zero into a qword, and the "%d" these setters format +/// reads its low dword. Out of range or NaN it stores the integer indefinite `0x8000000000000000`, +/// whose low dword is 0. +fn ftol_low(v: f64) -> i32 { + if v.is_finite() && v.abs() < 2f64.powi(63) { + v.trunc() as i64 as i32 + } else { + 0 + } +} + +/// The record's value, or `default` when this VM has no such row. +fn record_value(model: &Model, name: &str, default: &str) -> String { + model + .cvars + .get(&name.to_ascii_lowercase()) + .map_or_else(|| default.to_string(), |slot| slot.value.clone()) +} + +/// The record's `valueAsFloat` (`rec+0x24`), parsed from the value as an f32. A value that does +/// not parse reads 0.0; the setters and the host's registry store only numbers. +fn record_float(value: &str) -> f64 { + f64::from(value.parse::().unwrap_or(0.0)) +} + +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + let g = lua.globals(); + for pair in &PAIRS { + let (read, default) = (pair.read, pair.default); + g.set( + pair.get, + // Any arguments are ignored: the getter never calls `lua_gettop`. + lua.create_function(move |lua, _: MultiValue| { + let model = lua.app_data_ref::().expect("model app_data"); + Ok(match read { + Read::Zero => 0.0, + Read::Float(name) => record_float(&record_value(&model, name, default)), + Read::OneMinusInt(name) => { + 1.0 - f64::from(sstr_to_int(&record_value(&model, name, default))) + } + Read::OneMinusFloat(name) => { + 1.0 - record_float(&record_value(&model, name, default)) + } + }) + })?, + )?; + let (write, usage) = (pair.write, pair.usage); + g.set( + pair.set, + lua.create_function(move |lua, value: Value| { + // A number or a numeric string; anything else, or no argument, raises. + let Some(v) = lua.coerce_number(value)? else { + return Err(mlua::Error::runtime(usage)); + }; + let (name, text) = match write { + Write::Nothing => return Ok(MultiValue::new()), + Write::Float(name) => (name, format_f(v)), + Write::OneMinusTrunc(name) => { + (name, 1i32.wrapping_sub(ftol_low(v)).to_string()) + } + Write::TruncOneMinus(name) => (name, ftol_low(1.0 - v).to_string()), + }; + let mut model = lua.app_data_mut::().expect("model app_data"); + write_cvar(&mut model, name, text); + // Zero return values, not nil (`xor eax,eax` at every `ret`). + Ok(MultiValue::new()) + })?, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::script::UiScript; + + /// A VM with the host's five rows at their reference defaults. + fn script() -> UiScript { + let s = UiScript::new().unwrap(); + s.register_cvars([ + ("farclip", "350"), + ("shadowLevel", "1"), + ("doodadAnim", "1"), + ("texLodBias", "0.0"), + ("baseMip", "0"), + ]); + s + } + + #[test] + fn every_pair_has_the_reference_shape() { + let s = script(); + for get in [ + "GetWaterDetail", + "GetFarclip", + "GetTerrainMip", + "GetDoodadAnim", + "GetTexLodBias", + "GetBaseMip", + ] { + assert_eq!(s.arity(&format!("{get}()")).unwrap(), 1, "{get}"); + assert_eq!( + s.eval::(&format!("return type({get}(7, 'x'))")) + .unwrap(), + "number", + "{get} ignores its arguments and pushes a number" + ); + } + for (set, usage) in [ + ("SetWaterDetail", "Usage: SetWaterDetail(value)"), + ("SetFarclip", "Usage: SetFarclip(value)"), + ("SetTerrainMip", "Usage: SetTerrainMip(value)"), + ("SetDoodadAnim", "Usage: SetDoodadAnim(value)"), + ("SetTexLodBias", "Usage: SetTexLodBias(value)"), + ("SetBaseMip", "Usage: SetBaseMip(value)"), + ] { + assert_eq!(s.arity(&format!("{set}(1)")).unwrap(), 0, "{set}"); + assert_eq!(s.arity(&format!("{set}(\"1\")")).unwrap(), 0, "{set}"); + for bad in ["", "nil", "\"x\"", "{}", "true"] { + let e = s.run(&format!("{set}({bad})")).unwrap_err().to_string(); + assert!(e.contains(usage), "{set}({bad}) raised {e}"); + } + } + // The stock window's lower-case spellings are not bindings. + assert!(s + .eval::("return Getfarclip == nil and Setfarclip == nil") + .unwrap()); + } + + #[test] + fn the_getters_read_their_records_at_the_reference_defaults() { + let s = script(); + let get = |e: &str| s.eval::(&format!("return {e}()")).unwrap(); + assert_eq!(get("GetWaterDetail"), 0.0); + assert_eq!(get("GetFarclip"), 350.0); + // `shadowLevel` "1" β†’ `1 βˆ’ 1`; `baseMip` "0" β†’ `1 βˆ’ 0`: both sliders at High. + assert_eq!(get("GetTerrainMip"), 0.0); + assert_eq!(get("GetBaseMip"), 1.0); + assert_eq!(get("GetDoodadAnim"), 1.0); + assert_eq!(get("GetTexLodBias"), 0.0); + // A bare VM without the rows answers the registered defaults. + let bare = UiScript::new().unwrap(); + assert_eq!(bare.eval::("return GetFarclip()").unwrap(), 350.0); + assert_eq!(bare.eval::("return GetTerrainMip()").unwrap(), 0.0); + } + + #[test] + fn the_setters_write_the_reference_text() { + let s = script(); + let set = |call: &str, cvar: &str| { + s.run(call).unwrap(); + s.cvar(cvar).unwrap() + }; + // "%f" of the number, as `SetGamma` writes. + assert_eq!(set("SetFarclip(500)", "farclip"), "500.000000"); + assert_eq!(s.eval::("return GetFarclip()").unwrap(), 500.0); + assert_eq!(set("SetDoodadAnim(0)", "doodadAnim"), "0.000000"); + assert_eq!(set("SetTexLodBias(-0.5)", "texLodBias"), "-0.500000"); + assert_eq!(s.eval::("return GetTexLodBias()").unwrap(), -0.5); + // `SetTerrainMip`: truncate, then `1 βˆ’ n`. + assert_eq!(set("SetTerrainMip(1)", "shadowLevel"), "0"); + assert_eq!(s.eval::("return GetTerrainMip()").unwrap(), 1.0); + assert_eq!(set("SetTerrainMip(0.9)", "shadowLevel"), "1"); + // `SetBaseMip`: `1 βˆ’ v`, then truncate, so 0.5 is `trunc(0.5)` = 0, not `1 βˆ’ 0`. + assert_eq!(set("SetBaseMip(0)", "baseMip"), "1"); + assert_eq!(s.eval::("return GetBaseMip()").unwrap(), 0.0); + assert_eq!(set("SetBaseMip(0.5)", "baseMip"), "0"); + assert_eq!(set("SetBaseMip(\"0\")", "baseMip"), "1"); + // `SetWaterDetail` checks its argument and writes nothing. + s.run("SetWaterDetail(3)").unwrap(); + assert_eq!(s.eval::("return GetWaterDetail()").unwrap(), 0.0); + } + + #[test] + fn ftol_keeps_the_low_dword_of_the_truncation() { + assert_eq!(super::ftol_low(2.9), 2); + assert_eq!(super::ftol_low(-2.9), -2); + assert_eq!(super::ftol_low(4_294_967_297.0), 1); + assert_eq!(super::ftol_low(f64::NAN), 0); + assert_eq!(super::ftol_low(1e300), 0); + } +} diff --git a/crates/benilla-world/src/particles.rs b/crates/benilla-world/src/particles.rs index b47622c2..c2921988 100644 --- a/crates/benilla-world/src/particles.rs +++ b/crates/benilla-world/src/particles.rs @@ -33,11 +33,13 @@ pub struct ViewThrottled; /// A backstop cap on one emitter's live particles; a campfire's steady state is about 30 + 24. const MAX_PARTICLES: usize = 1024; -/// Particle settings: the reference's `particleDensity` CVar, set from the debug panel. +/// The emission scalar `[0x87d5fc]`, which the `particleDensity` handler (`0x688fb0`) and the +/// `spellEffectLevel` handler (`0x689510`) both write through `0x7adfb0`, the last write winning. +/// benilla registers only `spellEffectLevel`. #[derive(Resource)] pub struct ParticleTuning { - /// Clamped to [0.25, 1.0] by its handler (`0x688fb0`); it scales emission rate only (its - /// getter's two callers are the spawn-count `fmul`s), never size, alpha or draw distance. + /// It scales emission rate only (its getter's two callers are the spawn-count `fmul`s), never + /// size, alpha or draw distance. pub(crate) density: f32, } @@ -47,6 +49,17 @@ impl Default for ParticleTuning { } } +impl ParticleTuning { + /// `0x7adfb0`: clamp to [0.0, 1.0] and store. + pub fn set_density(&mut self, density: f32) { + self.density = density.clamp(0.0, 1.0); + } + + pub fn density(&self) -> f32 { + self.density + } +} + /// One live particle, in the frame file flag `0x10` picks. Clear (world mode): `pos`/`vel` are /// world coordinates, Bevy axes (a transport's deck while riding one); the birth bakes bone pose, /// rotation, scale and position through the live emitter matrix (`0x7b8acf`/`0x7b8b0f`) and the