creatures keep their first corner, and characters never share files

- a creature walking a path no longer cuts its first corner: the path's first waypoint was dropped at decode (2342)
- two characters whose names differ only in accented or non-Latin letters no longer share macros, bindings and SavedVariables; a file that failed to load is not overwritten at logout (2342)
- custom chat channels survive a logout, a collapsed quest header keeps its quests watched, and a trackpad swipe scrolls as far as it adds up to (2342)
- a hearth off a zeppelin no longer lands back on its deck, and an opened lockbox with loot left in it unlocks (2342)
- every snapshot runs on Vulkan, in a container, before it ships (2344)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 12:19:22 +07:00
co-authored by Claude Opus 5.5
parent 9c49f10882
commit 293b8e57fb
60 changed files with 1876 additions and 425 deletions
+29 -9
View File
@@ -717,8 +717,18 @@ fn load_saved_variables(lua: &Lua, i: usize) {
}
let Some(dir) = dir else { continue };
let path = dir.join(format!("{name}.lua"));
let Ok(bytes) = std::fs::read(&path) else {
continue; // absent is the first-run case
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, // the first-run case
Err(e) => {
// Present but unreadable is not "absent": hold it, like a file that will not run.
log_error(lua, &format!("{}: {e}", path.display()));
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.held_saved_files.contains(&path) {
model.held_saved_files.push(path);
}
continue;
}
};
if let Err(e) = run_chunk(
lua,
@@ -726,8 +736,14 @@ fn load_saved_variables(lua: &Lua, i: usize) {
&crate::script::addon_chunk_name(&name.to_string(), &format!("{name}.lua")),
) {
// The reference fails this silently. We do not: a settings file that stopped parsing
// is exactly the thing a player needs told, and the file is left on disk untouched.
// is exactly the thing a player needs told, and the file is left on disk untouched —
// held, so the shutdown write does not replace it with the defaults this session is
// running on (`UiScript::hold_saved_file`).
log_error(lua, &format!("{}: {e}", path.display()));
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.held_saved_files.contains(&path) {
model.held_saved_files.push(path);
}
}
}
}
@@ -839,13 +855,17 @@ fn run_chunk(lua: &Lua, bytes: &[u8], name: &str) -> mlua::Result<()> {
.exec()
}
/// Record a load error where the host drains it — the same channel a handler error uses, so a
/// demand-load failure surfaces the way every other script error does rather than vanishing.
/// Record a demand-load failure the way the startup walk records its own
/// ([`super::UiScript::report_script_error`]): retained as a Load row, and dispatched to the
/// player's error handler — plus the host drain the instruments read. It went to that drain alone
/// until now, so a `LoadAddOn` whose file raised surfaced as a terminal line and nothing a player
/// could see.
fn log_error(lua: &Lua, msg: &str) {
lua.app_data_mut::<Model>()
.expect("model")
.errors
.push(format!("LoadAddOn: {msg}"));
let msg = format!("LoadAddOn: {msg}");
super::diagnostics::record_load_failure(lua, &msg);
let mut model = lua.app_data_mut::<Model>().expect("model");
model.pending_error_dispatch.push(msg.clone());
model.errors.push(msg);
}
/// The host's push: the discovered registry and the root its files live under.
+1 -2
View File
@@ -1353,8 +1353,7 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
{
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
// **A nameplate's click is the engine's too** (decision 2148): the reference's plate overrides
// the button click slot (`0x7cb910`) *and* chains the base, so its unit is selected whether the
@@ -450,11 +450,6 @@ impl super::UiScript {
.register_window_channel(window, name.to_string(), id)
}
/// Host-side [`Model::strip_window_channel`] — the cascade's leave arm.
pub fn strip_chat_window_channel(&mut self, key: &str) -> bool {
self.model_mut().strip_window_channel(key)
}
/// Seed the per-window looks from the host's persisted store — the load path, so it queues no
/// change (an echo would re-dirty the file it was just read from; [`Self::set_cvar_host`]'s
/// reason, one store over).
+1 -2
View File
@@ -325,8 +325,7 @@ fn fire_color_select(lua: &Lua, this: &Table, r: f64, g: f64, b: f64) -> mlua::R
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
Ok(())
}
+7 -3
View File
@@ -259,7 +259,7 @@ pub struct CursorMacro {
/// Also derives `ACTIONBAR_SHOWGRID`/`ACTIONBAR_HIDEGRID` (decision 0216 §7) off
/// [`Model::cursor_grid_shown`]'s mirror against the CURRENT `model.cursor` (already the
/// post-transition state at every call site — every caller mutates `model.cursor` before calling
/// this): a None→Some edge (any payload arm, any surface — bags/doll/actions alike) shows the
/// this): a None→Some edge (any bar-droppable arm, any surface — bags/doll/actions alike) shows the
/// bar's drop grid, Some→None hides it, Some→Some (the action hop) touches neither, so one
/// gesture never churns HIDE+SHOW. This is the one seam every pickup/place/clear already routes
/// through, so no call site needs to know about grid events at all.
@@ -271,13 +271,17 @@ pub(crate) fn queue_cursor_update(model: &mut Model) {
// different places and the payload spaces do not overlap (decision 1010):
//
// - the ACTION bar's grid follows "is anything held that could land there" — every arm except
// the pet one, which `PlaceAction` refuses outright;
// the pet one, which `PlaceAction` refuses outright, and the vendor row (mode 5): its grab
// goes through the shared setter `0x4950f0`, whose `ACTIONBAR_SHOWGRID` branch is taken for
// mode 7 alone (`0x495106`/`0x49513a`, wow-re `merchant-cursor-law.md` §5);
// - the PET bar's grid follows the pet payload alone. The reference fires `PET_BAR_SHOWGRID`
// from inside the pet-action pickup builder itself (`0x494f28`), not from a shared cursor
// transition — so a spell on the cursor lights the action bar's empty slots and leaves the
// pet bar alone, which is also the only honest answer: you cannot drop it there.
let pet_held = matches!(model.cursor, Some(CursorPayload::PetAction(_)));
let bar_held = model.cursor.is_some() && !pet_held;
let bar_held = model.cursor.is_some()
&& !pet_held
&& !matches!(model.cursor, Some(CursorPayload::Merchant(_)));
if bar_held != model.cursor_grid_shown {
model.cursor_grid_shown = bar_held;
let event = if bar_held {
+4 -7
View File
@@ -883,13 +883,10 @@ fn install_video_verbs(lua: &Lua) -> mlua::Result<()> {
// format record and can veto the write outright; the handler validates that record, applies it
// with `DeviceSetFormat`, and then COMMITS the fourteen latched CVars so `GetCVar` catches up.
//
// **benilla has no such latch and no such device rebuild.** Its video settings apply as they
// are written — the departure `benilla_app::video`'s module doc already states for `gxVSync`
// ("wgpu reconfigures the surface on the next frame") and for the display mode ("ours takes
// effect on the click"). So the verb's postcondition is already true when it is called, which
// is not the same as the verb having nothing to do: the caller is asking for the settings to be
// in effect *now*, and the honest answer is to make the host re-assert them against the window
// rather than wait for its change detection to notice something it may already have seen.
// **benilla's latch is the host's** (decision 2303): a gx write stages in the host's CVar
// registry, and this verb is the host's cue to commit the gx stages (the fourteen-record
// commit, `Cvars::commit_latched`) and re-assert them against the window. The one difference
// left is when a change callback runs — the reference's at `SetCVar`, ours at the commit.
//
// A counted request the host drains (`take_restart_gx_asks`), the shape `Screenshot()` uses and
// for the same reason: no payload, and two calls in a frame are two requests. **It is not a
+2 -4
View File
@@ -337,8 +337,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
if let Err(e) = on_char {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
mark_text_changed(lua, h);
if fire_space {
@@ -606,8 +605,7 @@ fn fire_script(lua: &Lua, id: u32, name: &str) {
if let Err(e) = event::fire_widget_handler(lua, id, name, Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
}
+8 -24
View File
@@ -27,27 +27,13 @@ use crate::widget::{ButtonState, FrameHandle};
/// Fire `event` at every frame registered for it (the engine-internal twin of
/// `UiScript::fire_event`, for engine code holding only the Lua context — the compare drive's
/// `SHOW_COMPARE_TOOLTIP`), in registration order with per-step live re-reads (the FIFO law —
/// see `fire_event`'s doc). Handler errors land in [`Model::errors`].
/// `SHOW_COMPARE_TOOLTIP`), in registration order. **The same walk, not a second one**: this was
/// an index walk over the live list, so a handler that unregistered *itself* (the hook-once idiom
/// on `ADDON_LOADED`) shifted its successor into the slot the walk had just left, and that
/// listener never heard the event — the 1324 bug, fixed in [`super::tick::fire_event_into`] and
/// left standing here.
pub(super) fn fire_global(lua: &Lua, event: &str, args: &[ScriptValue]) {
let mut i = 0;
loop {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(&h) = model.event_to_frames.get(event).and_then(|l| l.get(i)) else {
break;
};
model.frame_id(h)
};
if let Err(e) = fire_event_handler(lua, id, event, args) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
i += 1;
}
fire_all_event_listeners(lua, event, args);
super::tick::fire_event_into(lua, event, args.to_vec());
}
/// The `RegisterAllEvents()` half of one dispatch: every frame that asked for the whole stream,
@@ -81,8 +67,7 @@ pub(super) fn fire_all_event_listeners(lua: &Lua, event: &str, args: &[ScriptVal
if let Err(e) = fire_event_handler(lua, id, event, args) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
}
at = next;
@@ -161,8 +146,7 @@ pub(super) fn fire_size_changes(lua: &Lua) {
) {
lua.app_data_mut::<Model>()
.expect("model")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
}
}
+2 -4
View File
@@ -197,16 +197,14 @@ fn fire(lua: &Lua, id: u32, script: &str, arg: &str) {
Err(e) => {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
return;
}
});
if let Err(e) = event::fire_widget_handler(lua, id, script, vec![val]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
}
+98 -2
View File
@@ -382,12 +382,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
// From here every leg clears the cursor and answers nothing. Holding a mode-5 payload
// and calling this again is the toggle-off, which is the same clear.
// and calling this again is the toggle-off, which is the same clear. It is the REAL
// `ClearCursor(1,1)` (`0x4fb82d`/`0x4fb83f` on a refusal, `0x49510b` inside the grab
// setter — wow-re `merchant-cursor-law.md` §4/§5), not a bare payload drop: a spell
// or action dropped on the vendor window fires `CURSOR_UPDATE` and hides the bar's
// grid, and an armed gift wrap is cancelled (`0x5edf10`, `ClearCursor`'s opening act).
let held = match &model.cursor {
Some(CursorPayload::Merchant(m)) => Some(m.row),
_ => None,
};
model.cursor = None;
crate::script::cursor::clear_cursor(&mut model);
let Some(index) = index else { return Ok(()) };
// `_ftol` truncates toward zero; the bound is then applied signed, so a negative or
@@ -419,7 +423,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
row: row0,
texture: item.texture.clone(),
});
// The grab's own transition (`0x495159` `SignalEvent(CURSOR_UPDATE)`); mode 5 skips
// the mode-7 `ACTIONBAR_SHOWGRID` branch, which the shared seam derives from the arm.
model.cursor = Some(payload);
crate::script::cursor::queue_cursor_update(&mut model);
Ok(())
})?,
)?;
@@ -1153,4 +1160,93 @@ mod tests {
s.run("PickupContainerItem(0, 5)").unwrap();
assert_eq!(s.take_merchant_slot_buys(), vec![(0, 5, 159)]);
}
/// Count the cursor's three events from Lua, the way a stock handler would see them.
fn listen_cursor_events(s: &mut UiScript) {
s.run(
r#"
updates, shows, hides = 0, 0, 0
local f = CreateFrame("Frame", "MerchantCursorListener")
f:RegisterEvent("CURSOR_UPDATE")
f:RegisterEvent("ACTIONBAR_SHOWGRID")
f:RegisterEvent("ACTIONBAR_HIDEGRID")
f:SetScript("OnEvent", function()
if event == "CURSOR_UPDATE" then updates = updates + 1 end
if event == "ACTIONBAR_SHOWGRID" then shows = shows + 1 end
if event == "ACTIONBAR_HIDEGRID" then hides = hides + 1 end
end)
"#,
)
.unwrap();
}
/// `(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID)` delivered so far.
fn cursor_event_counts(s: &mut UiScript) -> (i64, i64, i64) {
s.tick(0.01);
let n = |g: &str| s.eval::<i64>(&format!("return {g}")).unwrap();
(n("updates"), n("shows"), n("hides"))
}
/// The grab writes through the real cursor setter (`0x4950f0`, wow-re `merchant-cursor-law.md`
/// §5): `CURSOR_UPDATE` at `0x495159`, and — mode 5 not being mode 7 — **no**
/// `ACTIONBAR_SHOWGRID`: a vendor row cannot land on the bar, so its empty slots stay dark.
#[test]
fn a_vendor_grab_fires_cursor_update_but_not_the_bar_grid() {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
listen_cursor_events(&mut s);
s.run("PickupMerchantItem(1)").unwrap();
assert_eq!(
cursor_event_counts(&mut s),
(1, 0, 0),
"(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID) after a vendor grab"
);
// …and its toggle-off is a plain clear: one more CURSOR_UPDATE, and no HIDEGRID either.
s.run("PickupMerchantItem(1)").unwrap();
assert_eq!(cursor_event_counts(&mut s), (2, 0, 0));
}
/// Every non-sell leg opens with `ClearCursor(1,1)` (`0x4fb82d`/`0x4fb83f` on a refusal,
/// `0x49510b` inside the grab — `merchant-cursor-law.md` §4/§5), so a spell held over the
/// vendor window and dropped there — stock `MerchantFrame.xml`'s `OnMouseUp`,
/// `PickupMerchantItem(0)` — is a real clear: the bar's grid hides and `CURSOR_UPDATE` fires.
#[test]
fn a_spell_dropped_on_the_vendor_is_a_real_clear() {
use crate::script::cursor::{self, CursorPayload, CursorSpell};
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
listen_cursor_events(&mut s);
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133,
texture: None,
passive: false,
}));
cursor::queue_cursor_update(&mut s.model_mut()); // the pickup's own transition
assert_eq!(cursor_event_counts(&mut s), (1, 1, 0), "the spell pickup");
s.run("PickupMerchantItem(0)").unwrap();
assert!(s.cursor_payload().is_none());
assert_eq!(
cursor_event_counts(&mut s),
(2, 1, 1),
"(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID) after the vendor drop"
);
}
/// `ClearCursor 0x495190` opens with the gift-wrap cancel (`0x5edf10`, decision 1934), and the
/// grab's first act is that clear (`0x49510b`) — so taking a vendor row disarms a wrap.
#[test]
fn a_vendor_grab_cancels_an_armed_gift_wrap() {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
s.arm_gift_wrap(0, 1);
s.run("PickupMerchantItem(1)").unwrap();
assert_eq!(
s.gift_wrap_armed(),
None,
"the grab's ClearCursor cancels the wrap"
);
}
}
+8 -1
View File
@@ -839,6 +839,10 @@ pub(crate) struct Model {
/// The globals `RegisterForSave` declared, in registration order — the saved-variables set the
/// host writes out at logout/exit and re-executes at load (decision 1128, [`super::saved`]).
pub(crate) saved_names: Vec<String>,
/// The saved-variables files that failed to load this session ([`super::saved`]'s
/// `hold_saved_file`) — the shutdown write leaves each one on disk rather than replacing a
/// settings file it never read with the defaults it ran on instead.
pub(crate) held_saved_files: Vec<std::path::PathBuf>,
/// The key-binding table (decision 0997, [`super::keybind`]) — the chord→command store the
/// Key Bindings window edits, plus its stored account/character sets. The CVar table's twin:
@@ -1077,7 +1081,9 @@ pub(crate) struct Model {
///
/// The **pet** payload arm is excluded (decision 1010): `PlaceAction` refuses it, so lighting
/// the action bar's empty slots for a payload that cannot land there would be an invitation to
/// a no-op. It drives [`Self::pet_grid_shown`] instead.
/// a no-op. It drives [`Self::pet_grid_shown`] instead. So is the **vendor row** (mode 5): its
/// grab setter `0x4950f0` fires `ACTIONBAR_SHOWGRID` for mode 7 alone (wow-re
/// `merchant-cursor-law.md` §5).
pub(crate) cursor_grid_shown: bool,
/// The same mirror for the PET bar's grid — `PET_BAR_SHOWGRID`/`PET_BAR_HIDEGRID`, which the
/// reference fires from inside the pet-action pickup builder itself (`0x494f28`) rather than
@@ -2038,6 +2044,7 @@ impl Model {
video_caps: super::cvars::VideoCaps::default(),
restart_gx_asks: 0,
saved_names: Vec::new(),
held_saved_files: Vec::new(),
keybinds: super::keybind::KeybindState::default(),
actions: HashMap::new(),
action_states: HashMap::new(),
+67 -5
View File
@@ -163,22 +163,36 @@ pub type QuestLogQuestItem = QuestItemView;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct QuestLogState {
/// The list rows, in quest-log (descriptor slot) order. 1-based indexing on the Lua side.
/// A collapsed header's quests are OMITTED here (the app filters) — indexes are visible rows.
/// A collapsed header's quests are OMITTED here (the app filters) — indexes are visible rows;
/// their ids ride [`Self::hidden_quest_ids`].
pub entries: Vec<QuestLogEntryView>,
/// The total quest count INCLUDING quests hidden under collapsed headers — the "Quests: N/20"
/// pill must not shrink when a header collapses (`GetNumQuestLogEntries` return 2).
pub num_quests: u32,
/// The ids of the quests folded under a collapsed header — in the log, absent from
/// [`Self::entries`]. No getter indexes them; they exist for the **watch prune** alone, which
/// in the reference scans the whole row array, hidden rows included (`0x4de7a7`–`0x4de80f`;
/// a collapsed-group quest is still a row there, sorted past the visible window — wow-re
/// `system/ui/scratch/questlog-list-rebuild.md` §7). Without them a collapse would read as the
/// quest leaving the log and silently drop its watch.
pub hidden_quest_ids: Vec<u32>,
}
impl super::UiScript {
/// Push the quest-log snapshot (the app calls this whenever slots/templates/selection change).
/// Also prunes the watch set: a watched quest that left the log (abandon/turn-in) drops its
/// watch, exactly as the real client's RemoveQuestWatch-on-removal does.
/// watch — the rebuild's prune `0x4de7a7`–`0x4de80f`, which keeps a watch while ANY non-header
/// row carries its id, visible or [hidden](QuestLogState::hidden_quest_ids). A collapse is
/// not a removal: the reference's `0x4ded30` re-sorts and recounts and never prunes.
pub fn set_quest_log(&mut self, state: QuestLogState) {
let mut model = self.model_mut();
model
.quest_log_watched
.retain(|id| state.entries.iter().any(|e| e.quest_id == *id));
model.quest_log_watched.retain(|id| {
state
.entries
.iter()
.any(|e| !e.is_header && e.quest_id == *id)
|| state.hidden_quest_ids.contains(id)
});
model.quest_log = state;
}
@@ -850,6 +864,7 @@ mod tests {
fn two_quests() -> QuestLogState {
QuestLogState {
num_quests: 2,
hidden_quest_ids: Vec::new(),
entries: vec![
QuestLogEntryView {
quest_id: 783,
@@ -1266,6 +1281,53 @@ mod tests {
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 0);
}
/// A header's collapse folds its quests out of the visible list but NOT out of the log, and
/// the watch prune counts them — the reference's prune (`0x4de7a7`–`0x4de80f`) scans the whole
/// row array for a non-header row with the watched id, and a collapsed-group quest is still a
/// row there, just sorted past the visible window; the collapse itself (`0x4ded30`) re-sorts
/// and recounts only (wow-re `system/ui/scratch/questlog-list-rebuild.md` §7/§8). So watch →
/// collapse → expand keeps the watch, and a quest that genuinely LEFT the log still drops it.
#[test]
fn a_collapsed_header_keeps_its_quests_watched() {
let header = |collapsed| QuestLogEntryView {
title: "Elwynn Forest".into(),
is_header: true,
collapsed,
..Default::default()
};
let expanded = || {
let mut state = two_quests();
state.entries.insert(0, header(false));
state
};
let mut s = UiScript::new().unwrap();
s.set_quest_log(expanded());
s.run("AddQuestWatch(3)").unwrap(); // quest 7
assert_eq!(s.quest_log_watched(), vec![7]);
// The app's collapsed push: the header alone is visible, both quests are hidden under it.
s.set_quest_log(QuestLogState {
entries: vec![header(true)],
num_quests: 2,
hidden_quest_ids: vec![783, 7],
});
assert_eq!(
s.eval::<i64>("return GetNumQuestWatches()").unwrap(),
1,
"a collapse must not prune the watch of a quest still in the log"
);
s.set_quest_log(expanded());
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetQuestIndexForWatch(1)").unwrap(), 3);
// Quest 7 leaves the log (turn-in) — neither visible nor hidden — and its watch goes.
let mut gone = expanded();
gone.entries.retain(|e| e.quest_id != 7);
gone.num_quests = 1;
s.set_quest_log(gone);
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 0);
}
/// The countdown trio (decisions 1150/1154). One deadline stamp in the snapshot, one clock
/// pushed beside it, and every read is a live subtraction — so advancing the clock alone (no
/// re-push of the log) makes the number fall, which is precisely what the reference's
+112 -42
View File
@@ -30,7 +30,6 @@
use std::collections::HashSet;
use std::ffi::c_void;
use std::fmt::Write as _;
use mlua::{Lua, Table, Value};
@@ -42,6 +41,27 @@ use super::Model;
const MAX_DEPTH: usize = 32;
impl super::UiScript {
/// **Hold a saved-variables file that did not load** — the shutdown write must leave it alone.
///
/// A file that fails as a chunk (a hand edit with a typo; a database past Lua's 262,143
/// constants per chunk) leaves its globals at the defaults the code assigned, and the write at
/// logout would then replace the player's whole file with those defaults. The reference does
/// exactly that — it discards an unparseable file and writes whole from live values (wow-re
/// `system/ui/scratch/savedvariables-protocol.md`) — and both load sites here already promised
/// otherwise ("left on disk untouched"); this is what makes the promise true. The cost is this
/// session's changes to that one file, which is the trade the promise named.
pub fn hold_saved_file(&self, path: &std::path::Path) {
let mut model = self.model_mut();
if !model.held_saved_files.iter().any(|p| p == path) {
model.held_saved_files.push(path.to_path_buf());
}
}
/// Did this session's load fail on `path`? ([`Self::hold_saved_file`].)
pub fn saved_file_held(&self, path: &std::path::Path) -> bool {
self.model_ref().held_saved_files.iter().any(|p| p == path)
}
/// The registered names, in registration order — what the host writes out (the reference's
/// own emission order, and stable across runs because the load order is).
pub fn saved_variable_names(&self) -> Vec<String> {
@@ -55,16 +75,22 @@ impl super::UiScript {
/// rather than written as something that would fail to load; the reference loses the same set,
/// silently. `nil` IS written (`NAME = nil`), which is how the reference records a toggle that
/// has never been touched.
pub fn saved_variables_text(&self) -> String {
self.saved_variables_text_for(&self.saved_variable_names())
///
/// **Bytes, not text**: a Lua string is a byte string, and the file is executed as a chunk
/// that reads bytes (1193), so the writer is the one place that could change a value — and it
/// did. Values and keys went through `to_string_lossy`, so a string an addon cut mid-codepoint
/// or packed with high bytes (a compression library's output) came back from the next login as
/// U+FFFD, and two such keys could fold into one entry. The reference writes the bytes raw.
pub fn saved_variables_bytes(&self) -> Vec<u8> {
self.saved_variables_bytes_for(&self.saved_variable_names())
}
/// [`UiScript::saved_variables_text`] over an explicit name list — an addon's own
/// [`UiScript::saved_variables_bytes`] over an explicit name list — an addon's own
/// `## SavedVariables` set (1188 phase 3), which is declared in its manifest rather than
/// through `RegisterForSave`. Same grammar, same skip rules; only the source of the names
/// differs, which is exactly the difference between the reference's two mechanisms.
pub fn saved_variables_text_for(&self, names: &[String]) -> String {
let mut out = String::new();
pub fn saved_variables_bytes_for(&self, names: &[String]) -> Vec<u8> {
let mut out = Vec::new();
let mut unwritable = Vec::new();
for name in names {
let value: Value = match self.lua().globals().get(name.as_str()) {
@@ -77,7 +103,10 @@ impl super::UiScript {
let mut seen = HashSet::new();
match serialize(&value, 1, &mut seen) {
Some(text) => {
let _ = writeln!(out, "{name} = {text}");
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(b" = ");
out.extend_from_slice(&text);
out.push(b'\n');
}
None => unwritable.push(name.clone()),
}
@@ -98,13 +127,13 @@ impl super::UiScript {
/// starts at 1); `seen` carries the table identities on the current path — a repeat is a cycle and
/// drops that entry. Note it is the *path*, not every table ever visited: a shared subtable is
/// legitimately written twice (as it must be, since the file has no way to express aliasing).
fn serialize(v: &Value, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
fn serialize(v: &Value, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<Vec<u8>> {
match v {
Value::Nil => Some("nil".to_string()),
Value::Boolean(b) => Some(b.to_string()),
Value::Integer(i) => Some(i.to_string()),
Value::Number(n) => number(*n),
Value::String(s) => Some(quote(&s.to_string_lossy())),
Value::Nil => Some(b"nil".to_vec()),
Value::Boolean(b) => Some(b.to_string().into_bytes()),
Value::Integer(i) => Some(i.to_string().into_bytes()),
Value::Number(n) => number(*n).map(String::into_bytes),
Value::String(s) => Some(quote(&s.as_bytes())),
Value::Table(t) => table(t, depth, seen),
// Functions, threads, userdata (every widget reference is one — a frame's Lua value is a
// table whose `[0]` is a lightuserdata handle, RF-0023) cannot be written as a literal.
@@ -127,21 +156,22 @@ fn number(n: f64) -> Option<String> {
/// A quoted Lua string. The reference escapes exactly four characters (`\000`, `\n`, `\"`, `\\`)
/// and writes CR and every high byte raw; we add `\r` (raw CR in a quoted string is not something
/// its own loader could read back) and keep high bytes raw, so localized text stays legible.
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\0' => out.push_str("\\000"),
c => out.push(c),
/// its own loader could read back) and keep every other byte raw, so localized text stays legible
/// and a byte string that is not UTF-8 round-trips unchanged.
fn quote(s: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len() + 2);
out.push(b'"');
for &b in s {
match b {
b'\\' => out.extend_from_slice(b"\\\\"),
b'"' => out.extend_from_slice(b"\\\""),
b'\n' => out.extend_from_slice(b"\\n"),
b'\r' => out.extend_from_slice(b"\\r"),
0 => out.extend_from_slice(b"\\000"),
b => out.push(b),
}
}
out.push('"');
out.push(b'"');
out
}
@@ -158,12 +188,12 @@ fn quote(s: &str) -> String {
/// part that `next` walks ascending. Our vendored parser was restored to that placement in decision
/// 2111 — before it, this exact file shape came back keyed 1..n in the *hash* part and Bagnon's
/// keyring drew first.
fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<Vec<u8>> {
if depth > MAX_DEPTH || !seen.insert(t.to_pointer()) {
return None;
}
let mut ints: Vec<(i64, String)> = Vec::new();
let mut strs: Vec<(String, String)> = Vec::new();
let mut ints: Vec<(i64, Vec<u8>)> = Vec::new();
let mut strs: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for pair in t.pairs::<Value, Value>() {
let Ok((k, v)) = pair else { continue };
let Some(value) = serialize(&v, depth + 1, seen) else {
@@ -173,25 +203,34 @@ fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<S
Value::Integer(i) => ints.push((i, value)),
// A float key that is integral is the same slot as the integer in Lua 5.1.
Value::Number(n) if n.fract() == 0.0 => ints.push((n as i64, value)),
Value::String(s) => strs.push((s.to_string_lossy(), value)),
Value::String(s) => strs.push((s.as_bytes().to_vec(), value)),
_ => continue, // a table/bool/function key cannot be written as a literal
}
}
seen.remove(&t.to_pointer());
ints.sort_by_key(|(k, _)| *k);
// Byte order — the same order `str`'s `Ord` gave every UTF-8 key before.
strs.sort_by(|a, b| a.0.cmp(&b.0));
let indent = "\t".repeat(depth);
let close = "\t".repeat(depth.saturating_sub(1));
let mut out = String::from("{\n");
let mut out = b"{\n".to_vec();
let mut entry = |key: &[u8], value: &[u8]| {
out.extend_from_slice(indent.as_bytes());
out.push(b'[');
out.extend_from_slice(key);
out.extend_from_slice(b"] = ");
out.extend_from_slice(value);
out.extend_from_slice(b",\n");
};
for (k, v) in ints {
let _ = writeln!(out, "{indent}[{k}] = {v},");
entry(k.to_string().as_bytes(), &v);
}
for (k, v) in strs {
let _ = writeln!(out, "{indent}[{}] = {v},", quote(&k));
entry(&quote(&k), &v);
}
out.push_str(&close);
out.push('}');
out.extend_from_slice(close.as_bytes());
out.push(b'}');
Some(out)
}
@@ -254,13 +293,14 @@ mod tests {
};
assert_eq!(walk(&s), "0,1,2,3,4,-2,", "the live table, before any save");
let text = s.saved_variables_text();
let text = s.saved_variables_bytes();
let fresh = UiScript::new().unwrap();
fresh.run(&text).unwrap();
fresh.run_chunk(&text).unwrap();
assert_eq!(
walk(&fresh),
"0,1,2,3,4,-2,",
"the restart must walk the same order the live session did; wrote:\n{text}"
"the restart must walk the same order the live session did; wrote:\n{}",
String::from_utf8_lossy(&text)
);
}
@@ -299,7 +339,8 @@ mod tests {
],
"registration order, and a re-register is not a second entry"
);
let text = s.saved_variables_text();
let bytes = s.saved_variables_bytes();
let text = String::from_utf8(bytes).expect("every value here is UTF-8");
// The grammar: one statement per line, `nil` written out, integral numbers bare, keys
// bracketed and SORTED (integers ascending, then strings), tab-indented, trailing comma.
assert_eq!(
@@ -365,13 +406,42 @@ mod tests {
"#,
)
.unwrap();
let text = s.saved_variables_text();
let text = s.saved_variables_bytes();
// The cycle's own entry drops; the table itself still writes (empty here).
assert_eq!(text, "KEPT = 7\nCYCLE = {\n}\n", "got:\n{text}");
assert_eq!(text, b"KEPT = 7\nCYCLE = {\n}\n");
let warns = s.take_warnings();
assert_eq!(warns.len(), 1, "one line, not one per name: {warns:?}");
assert!(warns[0].contains("A_FUNCTION") && warns[0].contains("NOT_A_NUMBER"));
// And what it wrote is loadable.
UiScript::new().unwrap().run(&text).unwrap();
UiScript::new().unwrap().run_chunk(&text).unwrap();
}
/// **A byte string comes back as the same bytes.** A Lua string is bytes; an addon that cuts
/// a UTF-8 name mid-codepoint with `string.sub`, or packs data with `string.char(≥128)` (a
/// compression library's output), stores something that is not UTF-8. The writer used to
/// decode lossily, so the next login read U+FFFD — and two such keys folded into one entry.
#[test]
fn a_byte_string_that_is_not_utf8_round_trips_unchanged() {
let s = UiScript::new().unwrap();
s.run(
r#"
PACKED = string.char(65, 200, 255, 0, 66)
KEYS = { [string.char(200)] = 1, [string.char(201)] = 2 }
RegisterForSave("PACKED")
RegisterForSave("KEYS")
"#,
)
.unwrap();
let fresh = UiScript::new().unwrap();
fresh.run_chunk(&s.saved_variables_bytes()).unwrap();
assert!(fresh
.eval::<bool>(
"return string.len(PACKED) == 5 and string.byte(PACKED, 2) == 200 \
and string.byte(PACKED, 3) == 255 and string.byte(PACKED, 4) == 0"
)
.unwrap());
assert!(fresh
.eval::<bool>("return KEYS[string.char(200)] == 1 and KEYS[string.char(201)] == 2")
.unwrap());
}
}
+2 -4
View File
@@ -403,8 +403,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
Ok(())
})?,
@@ -428,8 +427,7 @@ fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> mlua::Resul
{
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
Ok(())
}
+1 -2
View File
@@ -267,8 +267,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
Ok(())
}
+2 -4
View File
@@ -269,8 +269,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
Ok(())
}
@@ -296,8 +295,7 @@ pub(super) fn fire_engine_value_changed(lua: &Lua, bar: crate::widget::FrameHand
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
}
+48
View File
@@ -226,6 +226,54 @@ fn a_chosen_error_handler_hears_engine_caught_errors_and_the_default_does_not_du
);
}
/// **Every handler path reaches the chosen handler, not only `OnEvent`.** The widget scripts —
/// `OnClick`, `OnValueChanged`, the edit box's and keyboard's, the tooltip's — pushed their errors
/// onto the host channel alone, so an addon's broken click handler was a terminal line and never
/// the dialog, while the same error from `OnEnter` did pop it (decision 1305's "every
/// engine-caught script error", which these paths had never joined).
#[test]
fn a_widget_handler_error_reaches_the_chosen_error_handler() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
"caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \
local b = CreateFrame('Button') \
b:SetScript('OnClick', function() error('click boom') end) \
b:Click() \
local sl = CreateFrame('Slider') \
sl:SetMinMaxValues(0, 10) \
sl:SetScript('OnValueChanged', function() error('slide boom') end) \
sl:SetValue(5)",
)
.unwrap();
s.dispatch_script_errors_to_handler();
let caught = s
.eval::<String>("return table.concat(caught, ' | ')")
.unwrap();
assert!(caught.contains("click boom"), "OnClick: {caught}");
assert!(caught.contains("slide boom"), "OnValueChanged: {caught}");
}
/// **A listener that unregisters itself does not rob the next one** — on the engine-internal
/// dispatch too (`fire_global`: `ADDON_LOADED` for a `LoadAddOn`, `UPDATE_FACTION` from the
/// faction-header verbs). That walk stepped an index over the live list, so the hook-once idiom
/// (`this:UnregisterEvent(event)` inside the handler) shifted the successor into the slot just
/// visited and it never heard the event — the 1324 bug, fixed in `fire_event` and not here.
#[test]
fn a_self_unregistering_listener_does_not_rob_its_successor_on_the_internal_dispatch() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"log = ""
local a = CreateFrame("Frame") a:RegisterEvent("UPDATE_FACTION")
a:SetScript("OnEvent", function() log = log .. "a" this:UnregisterEvent("UPDATE_FACTION") end)
local b = CreateFrame("Frame") b:RegisterEvent("UPDATE_FACTION")
b:SetScript("OnEvent", function() log = log .. "b" end)
ExpandFactionHeader(0)"#,
)
.unwrap();
assert_eq!(s.eval::<String>("return log").unwrap(), "ab");
}
/// **A handler that itself raises cannot recurse the error path** — its failure is recorded on
/// the host channel only, never re-queued, and the batch stops on the first handler failure.
#[test]
+1 -2
View File
@@ -350,8 +350,7 @@ pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) {
if let Err(e) = event::fire_widget_handler(lua, id, "OnTooltipCleared", Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
}
@@ -168,8 +168,7 @@ fn fire_add_money(lua: &Lua, h: crate::widget::FrameHandle, copper: u64) {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
}
+1 -2
View File
@@ -284,8 +284,7 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
.record_script_error(e.to_string());
}
}
}