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:
co-authored by
Claude Opus 5.5
parent
6e869eea4e
commit
a21fb2750a
@@ -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
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user