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)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 22:12:45 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6e869eea4e
commit a21fb2750a
23 changed files with 1095 additions and 60 deletions
+13 -4
View File
@@ -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<String> = 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
+51
View File
@@ -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<NonSendMut<UiScript>>, net: Option<Res<NetCommands>>) {
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::<UiScript>()
.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")
);
}
}
+30 -2
View File
@@ -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<Vec<String>>,
}
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::<ConsoleCommands>()
.init_resource::<ConsoleEcho>()
.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<String> {
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::<ConsoleEcho>() {
echo.capture = Some(Vec::new());
}
for event in events {
world.trigger(event);
}
match outcome {
let mut lines = world
.get_resource_mut::<ConsoleEcho>()
.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<String> {
SetOutcome::Staged => vec![format!(
"{name} staged as \"{value}\" — applies at the next RestartGx"
)],
}
});
lines
}
/// `set <name> <value>` (`0x63d500`). Deviation: the reference registers an unknown name as a new
+111 -3
View File
@@ -134,13 +134,13 @@ pub(crate) fn registered_pairs() -> impl Iterator<Item = (&'static str, &'static
REGISTERED.iter().map(|r| (r.name, r.default))
}
/// The host-backed CVars, one row per knob that has a reader.
/// The host-backed CVars, one row per knob that has a reader: a host knob, or a Lua consumer
/// such as the stock Video Options window's verbs.
///
/// Not registered for want of a reader, though pfUI's `hdgraphic` writes them: `lodDist`
/// (`0x688524`, "100.0", read at `0x6afb1d` for the doodad LOD swap), `footstepBias` (`0x6888b4`,
/// "0.125", read at `0x68fcb6`), `mapObjLightLOD` (`0x6886ec`, "0") and `SkyCloudLOD` (`0x6d1d33`,
/// "0"). `DistCull` (`0x688570`) and `texLodBias` (`0x6885e2`, whose sink `0x672640` is `ret 4`)
/// have no reader in the reference either. `maxLOD` is no 1.12 CVar.
/// "0"). `DistCull` (`0x688570`) has no reader in the reference either. `maxLOD` is no 1.12 CVar.
pub(crate) const REGISTERED: &[Registered] = &[
// `realmName` (`0x83f2d0`): registered `""` (`0x882748`), help "Last realm connected to"
// (`0x85d684`); the client builds its SavedVariables path from it (`0x5ab7d0`). Written from
@@ -329,6 +329,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
// also writes `SmallCull` {0.07, 0.04, 0.01}, and `GetWorldDetail` reads only `SmallCull`,
// whose registered 0.04 is stop 1: the reference's slider boots at Medium.
same("WorldDetail", "1"),
// `SmallCull` (`0x68854a`: name `0x8696f8`, default `0x869718` "0.04", flags 1, callback
// `0x688b10`, record `[0xc7f330]`). The callback refuses outside [0.001, 2.0] and stores into
// `[0x868620]`, which nothing reads: no mechanism here either. Its readers are Lua ones:
// `SetWorldDetail` writes it per stop, and `OptionsFrame_SetDefaults` reads its default
// (`OptionsFrame.lua:431`). `hwDetect` sets 0.04 on the fallback row a modern GPU reaches.
same(benilla_ui::script::CVAR_SMALL_CULL, "0.04"),
// `frillDensity` (`0x68862e`: name `0x8423d8`, default `0x864644` "16", flags 1, callback
// `0x688de0`, record `[0xc7f2f4]`): detail-doodad cells visited per chunk, clamped to [1, 256]
// and handed through `0x6725a0` to `[0xc7b494]`, the bound of the scatter loop at
@@ -576,6 +582,32 @@ pub(crate) const REGISTERED: &[Registered] = &[
// Not one of `hwDetect`'s sixteen (`[0x639a60, 0x639b80)` never reads `0xc7f2e4`), so the
// registered "1", off, stands.
same("anisotropic", "1"),
// The four video CVars the stock window's verbs read and write (`benilla_ui` `video_pairs`);
// benilla has no mechanism behind any of them. `shadowLevel` (`0x6885bc`, "1", flags 1,
// callback `0x688c10`, record `[0xc7f350]`) is behind `Get/SetTerrainMip` as `1 − level`: the
// callback refuses above 1 and prints "Shadow mip level changed upon restart.", and the
// terrain's shadow-map mip (`0x66f877`) reads it at load. `hwDetect` leaves it at 1.
same("shadowLevel", "1"),
// `doodadAnim` (`0x6884d8`, "1", flags 1, callback `0x688a10`, record `[0xc7f354]`), behind
// `Get/SetDoodadAnim`: the callback toggles bit `0x8000` of `[0xc7b2a4]` and calls `0x6953d0`,
// a bare `ret`, so no animation reads it. `hwDetect` sets 0 or 1 by CPU tier.
same("doodadAnim", "1"),
// `texLodBias` (`0x6885e2`, "0.0" at `0x84fad4`, flags 1, callback `0x688c90`, record
// `[0xc7f2f8]`), behind `Get/SetTexLodBias`: the callback refuses outside [-1.0, 1.0] and its
// sink `0x672640` is `ret 4`.
same("texLodBias", "0.0"),
// `baseMip` (`0x6887aa`, "0", flags 1, callback `0x689090`, record `[0xc7f2f0]`), behind
// `Get/SetBaseMip` as `1 − level`: the callback refuses outside [0, 1] ("BaseMip must be 0 or
// 1") and sets the device's first uploaded mip level (`0x589bf0`, read at `0x59f6ce`). benilla
// uploads every level; the write is kept and has no effect. `hwDetect` leaves it at 0.
same("baseMip", "0"),
// `spellEffectLevel` (`0x688900`: name `0x869324`, default `0x843068` "2", flags 1, callback
// `0x689510`, record `[0xc7f2a4]`), the Spell Detail slider (`OptionsFrame.lua:32`). The
// callback's two effects here: the particle emission scalar ([`crate::video::on_cvar`]) and
// the dynamic-object shard rate, read at each emitter's spawn (`crate::entities`). Its third,
// skipping one M2-scene ground decal below level 2 (`0x672b3a`), has no counterpart: benilla
// does not draw that projection.
same("spellEffectLevel", "2"),
// Weather Intensity (`OptionsFrame.lua:33`), registered "2" (`0x67b806`, flags 0, callback
// `0x67b870`, name `0x8685ac`). The reader is `benilla_world::weather::WeatherState`, which
// scales the precipitation spawn rate by `0x67b870`'s table {0.1, 0.33, 0.66, 1.0}; rendering
@@ -599,6 +631,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
// moves no pixel. Spelled "1.000000" because `SetGamma 0x4891f0` formats with `"%f"`, and
// Restore Defaults must compare equal to the default; [`sync_cvars`] seeds it the same way.
same("gamma", "1.000000"),
// `DesktopGamma` (`0x402d4d`: name `0x82e930`, default "0" `0x82e570`, flags 0, callback
// `0x403500`, record `[0x8826d4]`), the stock window's Use Desktop Gamma box, which it also
// sets whenever Windowed Mode is ticked (`OptionsFrame.lua:399-408`). On 1 the callback puts
// the desktop's own ramp back, and `gamma`'s callback (`0x4034d0`) uploads only while this
// reads 0. Here nothing reads it: [`crate::ui_gamma::DisplayGamma`] follows `gamma` alone.
same("DesktopGamma", "0"),
// benilla's own: the world renders at `window × this` while the UI stays native. The knob is
// [`crate::world_backdrop::RenderScale`], clamped to `RENDER_SCALE_RANGE`; at "1" nothing is
// resampled. `$WOW_RENDER_SCALE` overrides it for the session.
@@ -1949,6 +1987,17 @@ mod tests {
"{key}: RestoreVideoDefaults would restore it, and nothing registers it"
);
}
// The video pairs and `SetWorldDetail`'s `smallCull` write by name; a missing row would
// warn and store nothing.
for key in benilla_ui::script::VIDEO_PAIR_CVARS
.iter()
.chain([&benilla_ui::script::CVAR_SMALL_CULL])
{
assert!(
REGISTERED.iter().any(|r| r.name.eq_ignore_ascii_case(key)),
"{key}: a video verb writes it, and nothing registers it"
);
}
for (n, frill) in benilla_ui::script::WORLD_DETAIL_STOPS.iter().enumerate() {
assert_eq!(
*frill,
@@ -1970,6 +2019,37 @@ mod tests {
}
apply(&mut app, "WorldDetail", "1");
assert_eq!(res::<ClutterConfig>(&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::<Cvars>(&app).get(benilla_ui::script::CVAR_SMALL_CULL),
Some("0.070000")
);
apply(&mut app, "WorldDetail", "1");
assert_eq!(
res::<Cvars>(&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::<benilla_world::particles::ParticleTuning>(&app).density(),
scale,
"spellEffectLevel {level}"
);
assert_eq!(
res::<crate::video::SpellEffectLevel>(&app).0,
level.parse::<i32>().unwrap(),
"the record's integer, unclamped, for the shard emitter"
);
}
apply(&mut app, "minimapZoom", "5");
assert_eq!(res::<MinimapZoom>(&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::<crate::console::ConsoleEcho>();
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::<Cvars>(&app).get("spellEffectLevel"), Some("9"));
assert_eq!(
crate::console::execute(app.world_mut(), "farclip 400"),
Vec::<String>::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::<crate::combat_text::DamageTextGates>()
.init_resource::<crate::ui_chat::combat::LogPeriodicSpells>()
.init_resource::<benilla_world::weather::WeatherState>()
.init_resource::<benilla_world::particles::ParticleTuning>()
.init_resource::<crate::video::SpellEffectLevel>()
.init_resource::<crate::ui_gamma::DisplayGamma>()
.init_resource::<ClickConfig>()
.init_resource::<crate::target::AssistAttack>()
@@ -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
+23 -3
View File
@@ -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<i32>) -> 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<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
mut sounds: MessageWriter<SpellKitSound>,
spell_effect_level: Option<Res<crate::video::SpellEffectLevel>>,
) {
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]
+2
View File
@@ -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,
+1
View File
@@ -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 {
+2 -20
View File
@@ -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 {
+4
View File
@@ -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;
@@ -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",
@@ -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<String> {
s.eval::<Option<String>>(&format!("return GetCVar({name:?})"))
.unwrap()
}
fn slider(s: &UiScript, i: usize) -> f64 {
s.eval::<f64>(&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::<bool>("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::<f64>("return OptionsFrame.gamma").unwrap(), 0.0);
assert_eq!(
s.eval::<Option<String>>("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::<bool>("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::<f64>("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::<bool>("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::<bool>("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`)"
);
}
+48
View File
@@ -163,6 +163,9 @@ pub(crate) fn on_cvar(
mut tex_filter: ResMut<benilla_assets::TexFilterSetting>,
mut clutter: ResMut<benilla_world::clutter::ClutterConfig>,
mut weather: ResMut<benilla_world::weather::WeatherState>,
particles: Option<ResMut<benilla_world::particles::ParticleTuning>>,
mut spell_effect_level: ResMut<SpellEffectLevel>,
mut console: Option<ResMut<crate::console::ConsoleEcho>>,
mut cvars: ResMut<crate::cvars::Cvars>,
) {
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<String> {
impl Plugin for VideoPlugin {
fn build(&self, app: &mut App) {
use crate::console::ConsoleCommandApp;
app.init_resource::<SpellEffectLevel>();
app.add_observer(on_cvar);
app.console_command(
"detailDoodadAlpha",
@@ -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"),
@@ -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;
@@ -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.
+93
View File
@@ -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::<Model>()
.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::<f64>("return IsWindowsClient(0)").unwrap(), 1.0);
assert_eq!(s.arity("IsLinuxClient()").unwrap(), 1);
assert!(s.eval::<bool>("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})"
);
}
}
}
}
+64 -14
View File
@@ -332,6 +332,37 @@ fn cvar_arg(v: Option<&Value>) -> Option<String> {
}
}
/// 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::<Model>().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::<Model>().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())
+130
View File
@@ -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<u32> {
(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::<Model>().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::<Value>(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::<Model>().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::<Model>().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<bool>) = 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::<bool>("return EnumerateFrames(C) == nil").unwrap());
// A non-table argument starts over.
assert!(s
.eval::<bool>("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}"
);
}
}
+7 -2
View File
@@ -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)?;
+3
View File
@@ -900,6 +900,8 @@ pub(crate) struct Model {
pub(crate) addon_sends: Vec<super::addon_message::AddonSend>,
/// `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(),
@@ -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::<i64>("return GetWorldDetail()").unwrap(),
n,
+302
View File
@@ -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::<f32>().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::<Model>().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::<Model>().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::<String>(&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::<bool>("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::<f64>(&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::<f64>("return GetFarclip()").unwrap(), 350.0);
assert_eq!(bare.eval::<f64>("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::<f64>("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::<f64>("return GetTexLodBias()").unwrap(), -0.5);
// `SetTerrainMip`: truncate, then `1 − n`.
assert_eq!(set("SetTerrainMip(1)", "shadowLevel"), "0");
assert_eq!(s.eval::<f64>("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::<f64>("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::<f64>("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);
}
}
+16 -3
View File
@@ -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