Files
benilla-octo/crates/benilla-ui/src/script/diagnostics.rs
T
Sam SungandClaude Opus 5.5 459e0e90c1 The stock video window's boxes are registered and ffx, ffxGlow, ffxDeath and gxMaximize work; benilla's options gain Spell Detail; load failures go to FrameXML.log (#585)
**#549: the stock video window's CVars.** Its check boxes, `ffx` and `gxRefresh` are registered at the reference's defaults. `pixelShaders` and `specular` ship as `overridden` "1" (`hwDetect`, `0x639a60`), and `gxFixLag` ships "1".
- `ffx`, `ffxGlow` and `ffxDeath` gate the full-screen effect passes every frame, as the reference's gate does (`0x6cd890`; `CFFXGlow` reads `ffxGlow` at `0x6cc5a8`, `CFFXDeath` reads `ffxDeath` at `0x6cdf10`).
- `gxMaximize` is latched. At the `RestartGx` commit it makes a windowed client borderless over the monitor, as the reference's window rebuild does (`0x58cf10`).
- The rest are registered with no effect, and each row says why: `useWeatherShaders` (snow's fallback leg is a separate CPU draw), `lod`, `M2UseShaders`, `M2UsePixelShaders`, `movieSubtitle`, `gxTripleBuffer`, `gxCursor`, `gxRefresh`, `DesktopGamma`.
- `cvars.rs` is split into the runtime and the table.

**#329: Spell Detail.** benilla's options window gains 1.12's Spell Detail Level slider on the Graphics page, over `spellEffectLevel` on the stock 0-2 grid.

**#583: `FrameXML.log`.** Load failures now go to `benilla-config/Logs/FrameXML.log`, as the reference's go to `Logs\FrameXML.log`, and no longer to chat.
- The log uses the reference's lines ("Couldn't open", "Error loading", "Couldn't parse XML in") and its banners.
- A UI load that reports something rewrites the file, and a runtime `LoadAddOn` appends to it (`0x490187`, `0x46aac0`).

Verified:
- New tests for each switch, the maximize latch, every box's stored value with no unknown-CVar warning, the Spell Detail row and the log file's rewrite and append, each failing on main.
- A region dump of the options window shows the new row, and the rows below it moved 35 px, and nothing else.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run unticked Full Screen Glow in the stock window, which turned the glow pass off and saved `ffxGlow = "0"`, and a missing addon file produced its four log lines.

Built on macOS; no platform seam touched.

Fixes #549
Fixes #329
Fixes #583

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:09:02 +07:00

280 lines
9.9 KiB
Rust

//! The engine's record of this session's script errors, addon load failures and warnings, for the
//! host (the world-entry load-failure lines, the addon harness) and dev instruments; no Lua global
//! reads it. The layer's `/errors` window keeps its own log off the Lua error handler, as a 1.12
//! addon does. Dispatch and `_ERRORMESSAGE` are unchanged by it.
//!
//! A repeat bumps its row's count and keeps its place, rows are oldest first, and past
//! [`DIAGNOSTIC_LOG_CAP`] distinct messages the oldest is evicted; `seq` is never reused, so an
//! eviction shows as a gap. The log lives on the `Model`, and every world entry and `ReloadUI`
//! builds a fresh VM, so each starts an empty log.
use std::collections::VecDeque;
use mlua::Lua;
use super::Model;
/// Distinct messages retained before the oldest is evicted, sized for the whole 218-addon corpus
/// loaded at once.
pub const DIAGNOSTIC_LOG_CAP: usize = 256;
/// What a row reports, split by its consequence to the player.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticKind {
/// Code ran and raised; the addon is otherwise loaded.
Error,
/// An addon did not load, whether its file scope raised or a file, document or dependency
/// was missing or broken.
Load,
/// A call was accepted and did not do what it said (a dropped `inherits=`, an unresolved
/// `SetPoint` target, a `SetCVar` on an unregistered name, a refused `CreateMacro`).
Warning,
}
impl DiagnosticKind {
/// The one-word tag a surface prints; stable, as a surface may key colour off it.
pub fn tag(self) -> &'static str {
match self {
Self::Error => "error",
Self::Load => "load",
Self::Warning => "warn",
}
}
}
/// One retained row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
/// The "#N" a surface shows: 1-based, monotonic and never reused.
pub seq: u64,
pub kind: DiagnosticKind,
/// The message exactly as the host logged it.
pub message: String,
/// Occurrences of this `(kind, message)`, from 1.
pub count: u32,
}
/// The log, held on the model.
#[derive(Debug, Default)]
pub(crate) struct DiagnosticLog {
rows: VecDeque<Diagnostic>,
/// The last `seq` handed out; kept apart from `rows.len()` so eviction never renumbers.
seq: u64,
}
impl DiagnosticLog {
/// Record one failure, or bump the count of its identical row.
pub(crate) fn record(&mut self, kind: DiagnosticKind, message: &str) {
if let Some(row) = self
.rows
.iter_mut()
.find(|r| r.kind == kind && r.message == message)
{
row.count = row.count.saturating_add(1);
return;
}
self.seq += 1;
if self.rows.len() == DIAGNOSTIC_LOG_CAP {
self.rows.pop_front();
}
self.rows.push_back(Diagnostic {
seq: self.seq,
kind,
message: message.to_string(),
count: 1,
});
}
/// Every retained row, oldest first.
pub(crate) fn rows(&self) -> impl Iterator<Item = &Diagnostic> {
self.rows.iter()
}
/// Retained rows.
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.rows.len()
}
/// Distinct failures recorded this session, evicted rows included.
#[cfg(test)]
pub(crate) fn total(&self) -> u64 {
self.seq
}
}
impl super::UiScript {
/// Every retained row, oldest first, cloned out of the model.
pub fn diagnostics(&self) -> Vec<Diagnostic> {
self.model_ref().diagnostics.rows().cloned().collect()
}
/// Retain an addon load failure that never raised (a missing file, an unparseable document, a
/// dependency cycle or missing dependency, a broken `Bindings.xml`). It is not dispatched to
/// `geterrorhandler()`: the reference logs such a failure and shows nothing. The caller still
/// writes its own log line.
pub fn report_load_failure(&self, msg: &str) {
record_load_failure(&self.lua, msg);
}
/// Whether `FrameXML_Debug` is above 0, which puts every banner in the load log.
pub fn framexml_debug(&self) -> bool {
self.model_ref().framexml_debug.get() > 0
}
/// Merge one closed level of a UI load (the core's `.toc`, an add-on) into the load's record.
pub fn report_load_status(&self, status: crate::status::Status) {
self.model_mut().load_log.ui.merge(status);
}
/// The UI load is over: its record is drained (`0x490187`), after any `LoadAddOn` block it
/// ran.
pub fn finish_ui_load_log(&self) {
let mut model = self.model_mut();
let lines = model.load_log.ui.take_lines();
model
.load_log
.writes
.push(crate::status::LogWrite::Rewrite(lines));
}
/// The drains made since the last call, in order, for the host to write.
pub fn take_load_log_writes(&mut self) -> Vec<crate::status::LogWrite> {
std::mem::take(&mut self.model_mut().load_log.writes)
}
/// Retain a warning the host has already logged (a loader warning with its `<Addon>/<file>`
/// prefix). Queuing it on `Model::warnings` too would log it twice; an engine-raised warning
/// goes through `Model::record_warning`, which does both.
pub fn report_warning(&self, msg: &str) {
record_warning(&self.lua, msg);
}
}
/// Retain an addon file that did not load: the one rule for the startup walk and for `LoadAddOn`,
/// which holds only `&Lua`. The reference's failed open is non-fatal: `0x6edaa0` logs
/// `Couldn't open %s` (`0x846ff4`) at severity 2 and loading goes on, never reaching the Lua
/// error handler.
pub(crate) fn record_load_failure(lua: &Lua, msg: &str) {
lua.app_data_mut::<Model>()
.expect("model app_data set")
.diagnostics
.record(DiagnosticKind::Load, msg);
}
/// Retain a warning its caller, holding `&Lua`, has already logged; `Model::record_warning` also
/// queues it for the host log.
pub(crate) fn record_warning(lua: &Lua, msg: &str) {
lua.app_data_mut::<Model>()
.expect("model app_data set")
.diagnostics
.record(DiagnosticKind::Warning, msg);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repeats_collapse_onto_the_first_row_and_count() {
let mut log = DiagnosticLog::default();
for _ in 0..1113 {
log.record(DiagnosticKind::Error, "OnUpdate: boom");
}
assert_eq!(log.len(), 1, "1,113 raises are ONE row — the whole point");
let row = log.rows().next().unwrap();
assert_eq!(row.count, 1113);
assert_eq!(row.seq, 1);
assert_eq!(log.total(), 1);
}
#[test]
fn a_warning_reaches_the_retained_log_and_the_host_drain() {
let mut s = crate::script::UiScript::new().expect("vm");
s.run(r#"SetCVar("thisCVarDoesNotExist", "1")"#).unwrap();
let host = s.take_warnings();
assert!(
host.iter().any(|w| w.contains("thisCVarDoesNotExist")),
"the host's per-frame drain still gets it: {host:?}"
);
let kept = s.diagnostics();
let row = kept
.iter()
.find(|d| d.message.contains("thisCVarDoesNotExist"))
.unwrap_or_else(|| panic!("…and it is RETAINED: {kept:#?}"));
assert_eq!(row.kind, DiagnosticKind::Warning, "under its own kind");
assert!(s.take_warnings().is_empty());
assert!(s
.diagnostics()
.iter()
.any(|d| d.message.contains("thisCVarDoesNotExist")));
}
#[test]
fn a_text_sink_takes_bytes_rather_than_raising_on_them() {
// `strsub` is byte-indexed, so an addon can hand `SetText` half a UTF-8 character; that
// costs a glyph, never a raise.
let s = crate::script::UiScript::new().expect("vm");
s.run(
r#"
f = CreateFrame("Frame", "BytesFrame")
t = f:CreateFontString("BytesText")
-- "a—b" is 5 bytes; cutting at 2 leaves the em dash's lead byte alone.
local sliced = strsub("a\226\128\148b", 1, 2)
t:SetText(sliced)
e = CreateFrame("EditBox", "BytesBox")
e:SetText(sliced)
"#,
)
.expect("a sliced multi-byte string must not raise");
assert!(s.errors().is_empty(), "{:?}", s.errors());
let got: Option<String> = s.eval("return BytesText:GetText()").unwrap();
assert_eq!(
got.as_deref(),
Some("a\u{fffd}"),
"the broken byte became one replacement glyph, and the call completed"
);
}
#[test]
fn kind_is_part_of_identity() {
let mut log = DiagnosticLog::default();
log.record(DiagnosticKind::Error, "same text");
log.record(DiagnosticKind::Load, "same text");
assert_eq!(
log.len(),
2,
"the same string means different things per kind"
);
}
#[test]
fn order_is_first_occurrence_and_a_repeat_does_not_move_its_row() {
let mut log = DiagnosticLog::default();
log.record(DiagnosticKind::Load, "first");
log.record(DiagnosticKind::Error, "second");
log.record(DiagnosticKind::Load, "first");
let seen: Vec<_> = log.rows().map(|r| r.message.as_str()).collect();
assert_eq!(seen, ["first", "second"]);
assert_eq!(log.rows().next().unwrap().count, 2);
}
#[test]
fn eviction_is_oldest_first_and_seq_never_rewinds() {
let mut log = DiagnosticLog::default();
for i in 0..DIAGNOSTIC_LOG_CAP + 10 {
log.record(DiagnosticKind::Error, &format!("e{i}"));
}
assert_eq!(log.len(), DIAGNOSTIC_LOG_CAP);
let first = log.rows().next().unwrap();
assert_eq!(first.message, "e10", "the oldest ten were evicted");
assert_eq!(
first.seq, 11,
"seq is monotonic: the gap IS the honest report that rows were dropped"
);
assert_eq!(log.total() as usize, DIAGNOSTIC_LOG_CAP + 10);
}
}