- the chat dock is a list, not a hardcoded pair: DOCKED_CHAT_FRAMES, SELECTED_DOCK_FRAME and the FCF verbs — DockFrame, UnDockFrame, DockUpdate, SelectDockFrame, SaveDock, SetTabPosition, Close and the button-side trio — are transcribed from FloatingChatFrame.lua, and every "for i = 1, 2" moved to what it meant, the dock or every window; the dock seeds at load from the window record's own docked byte handed to FCF_DockFrame, whose first line IS the reference's early return — transcribing the function that answers the way the reference answers, where an empty stub "because we have no dock" would have been the bandaid; its unconditional re-lock cannot clobber an unlock you saved, because the stored look is applied last; pfUI's RefreshChat now runs end to end - ignoreArrows is alt-arrow mode, and the arrow is DECLINED, not swallowed: the reference's edit-box handler returns unconsumed on an arrow key, so the walk carries it past the box and it runs TURNLEFT — the whole feature is steering while the chat box holds focus, where the old reading left the arrows dead; holding Alt resumes the caret; SetAltArrowKeyMode and its getter land with the reference's own quirk that a missing argument enables while nil disables (the getter answers 1 or nil, never a boolean), SetIgnoreArrows turns out not to exist in 1.12 and is unpublished, and the binding pass gets a named four-key exemption rather than a hole, because the handler consumes every other path - pfUI's square minimap is actually square: Minimap:SetMaskTexture makes the disc's mask state instead of a load-time constant — write-only from Lua, as shipped, no invented getter — where an empty path restores the round default and a failed load keeps the current mask and warns, because either mistake would otherwise hand a mistyped path a silently square map that looks like a feature; and MiniMapBattlefieldFrame grows the Border and Shine regions beside its icon, on the reference's own layers — icon on BACKGROUND, ring on ARTWORK above it, the only order a ring around an icon can draw in — which pfUI hides unguarded to sit the icon flush Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
433 lines
19 KiB
Rust
433 lines
19 KiB
Rust
//! The chat windows' **saved state** — the background tint, the background alpha and the font size
|
|
//! the player picks off a chat tab's right-click menu (decision 1589, fixing **B246**: *"no chat
|
|
//! options at all — background transparency has no home, and chat can be hard to read"*), plus the
|
|
//! window's **lock**, which joined them when the windows became movable and resizable.
|
|
//!
|
|
//! The state itself lives in the VM ([`benilla_ui::script::ChatWindowLook`], written by the
|
|
//! reference's own `SetChatWindowColor`/`SetChatWindowAlpha`/`SetChatWindowSize` and read straight
|
|
//! back out of `GetChatWindowInfo`); this module is the two ends the VM cannot own — **where it
|
|
//! comes from at login and where it goes at logout**.
|
|
//!
|
|
//! ## Character-scoped, like the reference
|
|
//!
|
|
//! 1.12 keeps these in `WTF/Account/<ACC>/<REALM>/<CHAR>/chat-cache.txt`, whose per-window block a
|
|
//! stock client writes as
|
|
//!
|
|
//! ```text
|
|
//! WINDOW 1 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 1 SHOWN 1
|
|
//! ```
|
|
//!
|
|
//! `LOCKED` is an `i32` in the record (`CHATWINDOW+0x8c`) but the cache writer booleanises it
|
|
//! through `setne`, so only `{0,1}` round-trip there — which is why ours is a `bool` and writes the
|
|
//! same two digits.
|
|
//!
|
|
//! `COLOR` there is **R G B A**, written from the record's packed BGRA quad and parsed straight
|
|
//! back as bytes, so the reference's own file round-trip is bit-exact (§5,
|
|
//! wow-re `system/ui/scratch/chat-window-record.md`). Ours writes the same four in the same order.
|
|
//!
|
|
//! **Its loader's `WINDOW` bound is off by one** — `0x498d1c` uses `ja` where the array wants
|
|
//! `jae`, so a hand-edited `WINDOW 11` writes a full record past the end at `0xb50440` (the same
|
|
//! §5 found it, incidentally). Ours cannot: the index is bounds-checked at the seam that consumes
|
|
//! it (`set_chat_window_looks` uses `get_mut`), and an out-of-range window is simply dropped.
|
|
//!
|
|
//! (the real file from the pin's install, quoted in
|
|
//! [`benilla_ui::script`]'s `chat_window` module docs). Ours is
|
|
//! `benilla-config/chat/<realm>-<character>.txt` ([`crate::local_state::chat_character_path`]) —
|
|
//! the camera pose's shape one folder over (decision 1131/1138), with the reference's own key
|
|
//! spellings and its own byte-valued `COLOR r g b a`, so the file reads as a narrower relative of
|
|
//! its ancestor rather than as an invention.
|
|
//!
|
|
//! **It is deliberately a SUBSET and says so in its header.** The reference's cache also carries
|
|
//! `DOCKED`/`SHOWN`, the per-window `MESSAGES`/`CHANNELS` registration and the whole `COLORS`
|
|
//! table; benilla has no rename, no undock and no per-type colour editor (0288 §2), so writing
|
|
//! those keys would persist state nothing can move — the honest-tree rule (1134 §4) at the
|
|
//! persistence layer. Each joins the file the day something can change it, **which is exactly what
|
|
//! `LOCKED` just did**: the tab menu's *Lock/Unlock Window* row moves it, so a file that dropped it
|
|
//! would reset the player's unlocked window at every login.
|
|
//!
|
|
//! ## Why per character, and not a CVar
|
|
//!
|
|
//! Both halves matter. *Per character*, because it is where the reference puts it and because it
|
|
//! is what the setting means — a raid alt reading a 40-man combat log wants a solid box where a
|
|
//! questing alt wants glass. And *not a CVar*, because `SetChatWindowAlpha` is the API 1.12 addons
|
|
//! are written against: an addon that reads a window's alpha calls `GetChatWindowInfo`, and
|
|
//! routing benilla's store through `config.toml` instead would have given the same player setting
|
|
//! two different names depending on who asked.
|
|
//!
|
|
//! ## The write posture
|
|
//!
|
|
//! **Debounced by one quiet second, plus both session edges** — the colour picker's opacity slider
|
|
//! drives `FCF_SetChatWindowOpacity` on *every drag step*, so this is a slider, not a discrete
|
|
//! edit: [`crate::cvars`]'s `SAVE_QUIET` reasoning applies verbatim ("long enough to coalesce a
|
|
//! slider drag, short enough that a crash loses one gesture, not a session"). The edges are
|
|
//! `OnExit(InWorld)` and `AppExit`, the same two the camera pose and the saved variables use.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_ui::script::{ChatWindowLook, UiScript};
|
|
|
|
use crate::ui_script::VmMemo;
|
|
|
|
/// How long a dirty look sits before the save fires. [`crate::cvars`]'s own constant and its own
|
|
/// reasoning — an opacity drag is exactly the gesture it was sized for.
|
|
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
|
|
|
/// The file's header — where these values come from and where the law lives.
|
|
const HEADER: &str = "\
|
|
# benilla chat window state (decision 1589) — the tint, alpha, font size and lock a chat tab's
|
|
# right-click menu sets. A SUBSET of the reference's chat-cache.txt: benilla has no rename,
|
|
# undock or per-type colour editor yet, so those keys are absent rather than invented.
|
|
# COLOR is four bytes (0-255), the engine's own storage; SIZE is 0 for \"the font's own height\";
|
|
# LOCKED is 1 or 0, and 1 is the stock row (a fresh window cannot be dragged until you unlock it).
|
|
";
|
|
|
|
/// Which character's file we are on, where it lives, and whether it is owed a write.
|
|
#[derive(Resource, Default)]
|
|
pub(super) struct ChatWindowFile {
|
|
path: Option<PathBuf>,
|
|
/// The `(realm, character)` [`Self::path`] was built for. Session-keyed (1290) like the macro
|
|
/// and binding loads: the *same* character coming back still meets a fresh VM whose look table
|
|
/// is back at the stock row.
|
|
identity: VmMemo<Option<(String, String)>>,
|
|
/// Whether **this VM** has unsaved writes. Session-keyed for a reason that is one-way and
|
|
/// therefore worth the wrapper: the values live in the VM, so a plain `bool` surviving a VM
|
|
/// replacement would let a save compose the player's file out of a table that is back at the
|
|
/// stock row — the "refusing to compose the file from nothing" hazard `crate::cvars` guards
|
|
/// against, one store over. A fresh VM starts undirty and cannot write until Lua writes.
|
|
dirty: VmMemo<bool>,
|
|
last_change: Option<std::time::Instant>,
|
|
}
|
|
|
|
/// Render the table exactly as the loader reads it: one `WINDOW` line per window, in window order.
|
|
fn render(looks: &[ChatWindowLook]) -> String {
|
|
let mut out = String::from(HEADER);
|
|
for (i, l) in looks.iter().enumerate() {
|
|
out.push_str(&format!(
|
|
"WINDOW {} SIZE {} COLOR {} {} {} {} LOCKED {} DOCKED {}\n",
|
|
i + 1,
|
|
l.font_size,
|
|
l.r,
|
|
l.g,
|
|
l.b,
|
|
l.a,
|
|
i32::from(l.locked),
|
|
// The reference's own spelling: the dock POSITION, and `0` for a window that is not
|
|
// docked (the shipped rows for windows 3..10 are `DOCKED 0`).
|
|
l.docked.unwrap_or(0)
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parse `WINDOW <n> SIZE <s> COLOR <r> <g> <b> <a>` lines into `(index, look)` pairs.
|
|
///
|
|
/// Permissive in the three ways the reference's own cache reader is, and for the same reason — a
|
|
/// hand edit and a later build's extra key must both cost at most the line they are on:
|
|
/// keys match case-insensitively, an unknown key is skipped rather than failing the parse, and a
|
|
/// `WINDOW` line missing a field keeps whatever the field's default is. A window number this build
|
|
/// has no frame for is dropped by the seam that consumes this, not here.
|
|
fn parse(text: &str) -> Vec<(usize, ChatWindowLook)> {
|
|
let mut out = Vec::new();
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let mut it = line.split_whitespace();
|
|
let Some(head) = it.next() else { continue };
|
|
if !head.eq_ignore_ascii_case("WINDOW") {
|
|
continue;
|
|
}
|
|
let Some(index) = it.next().and_then(|n| n.parse::<usize>().ok()) else {
|
|
warn!("chat looks: WINDOW line with no number ignored: {line}");
|
|
continue;
|
|
};
|
|
if index == 0 {
|
|
continue;
|
|
}
|
|
// Seeded from the STOCK row for this window, not one flat default: `DOCKED` is the one
|
|
// field whose shipped value differs per window (1 and 2 are the dock), so a file written
|
|
// by a build that knew nothing about the key must leave the dock where it shipped rather
|
|
// than silently undocking both windows — the same leniency `LOCKED` gets below, applied
|
|
// where a flat default could not express it.
|
|
let mut look = ChatWindowLook::stock(index - 1);
|
|
let byte = |s: Option<&str>| -> u8 { s.and_then(|v| v.parse::<u8>().ok()).unwrap_or(0) };
|
|
while let Some(key) = it.next() {
|
|
if key.eq_ignore_ascii_case("SIZE") {
|
|
// `i32`, like the record's field (`+0x84`) — and never negative: the setter
|
|
// drops `<= 0`, so a hand-edited one reads as "no size stored".
|
|
look.font_size = it
|
|
.next()
|
|
.and_then(|v| v.parse::<i32>().ok())
|
|
.unwrap_or(0)
|
|
.max(0);
|
|
} else if key.eq_ignore_ascii_case("COLOR") {
|
|
look.r = byte(it.next());
|
|
look.g = byte(it.next());
|
|
look.b = byte(it.next());
|
|
look.a = byte(it.next());
|
|
} else if key.eq_ignore_ascii_case("LOCKED") {
|
|
// Anything that is not the literal `0` is locked — the reference's own `setne`
|
|
// read, and the lenient half of it: a file from a build that wrote nothing here
|
|
// keeps the stock `LOCKED 1` default rather than silently unlocking the window.
|
|
look.locked = it.next().is_none_or(|v| v.trim() != "0");
|
|
} else if key.eq_ignore_ascii_case("DOCKED") {
|
|
// The dock POSITION, 1-based; `0` (and anything unparseable) is "not docked".
|
|
look.docked = it
|
|
.next()
|
|
.and_then(|v| v.trim().parse::<u8>().ok())
|
|
.filter(|p| *p > 0);
|
|
}
|
|
// Anything else is a key from a build that knows more than this one — skip it and the
|
|
// value it would have consumed cannot be told from the next key, so skip only the key.
|
|
}
|
|
out.push((index - 1, look));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Seed the VM's look table from disk, once per character per VM. Absent file = the shipped stock
|
|
/// row (`COLOR 0 0 0 0`, `SIZE 0`), which is the normal first run.
|
|
fn load_chat_looks(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
roster: Res<crate::char_select::Roster>,
|
|
mut file: ResMut<ChatWindowFile>,
|
|
) {
|
|
let Some(mut script) = script else { return };
|
|
let Some(id) = crate::ui_macro::identity(&roster) else {
|
|
return;
|
|
};
|
|
if file.identity.get(&script).as_ref() == Some(&id) {
|
|
return; // already restored for this character, into the VM that is live now
|
|
}
|
|
file.path = crate::local_state::chat_character_path(&id.0, &id.1);
|
|
*file.identity.get(&script) = Some(id);
|
|
// A fresh VM starts at the stock row, so a character with no file needs nothing pushed — and
|
|
// the drain below must not read a change this load never made.
|
|
*file.dirty.get(&script) = false;
|
|
file.last_change = None;
|
|
|
|
let Some(path) = file.path.clone() else {
|
|
return; // hermetic capture, or no state folder — session-only, the stock row stands
|
|
};
|
|
let text = match std::fs::read_to_string(&path) {
|
|
Ok(t) => t,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
|
Err(e) => {
|
|
warn!("chat looks: cannot read {}: {e}", path.display());
|
|
return;
|
|
}
|
|
};
|
|
let looks = parse(&text);
|
|
if looks.is_empty() {
|
|
return;
|
|
}
|
|
info!(
|
|
"chat looks: {} windows restored from {}",
|
|
looks.len(),
|
|
path.display()
|
|
);
|
|
script.set_chat_window_looks(looks);
|
|
// The reference's own event for "a chat window's stored settings moved" — `FloatingChatFrame`
|
|
// is registered for it and re-reads `GetChatWindowInfo` on every window (its
|
|
// `FloatingChatFrame_OnEvent`), which is exactly what has to happen after this push.
|
|
script.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
|
|
}
|
|
|
|
/// Drain the VM's writes into the dirty flag. Cheap on a steady frame — the drain is a `take` of
|
|
/// an empty set.
|
|
fn watch_chat_looks(script: Option<NonSendMut<UiScript>>, mut file: ResMut<ChatWindowFile>) {
|
|
let Some(mut script) = script else { return };
|
|
if script.take_chat_window_changes().is_empty() {
|
|
return;
|
|
}
|
|
*file.dirty.get(&script) = true;
|
|
file.last_change = Some(std::time::Instant::now());
|
|
}
|
|
|
|
/// Dirty + one quiet second (or the app exiting) → rewrite the file atomically.
|
|
fn save_chat_looks(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut file: ResMut<ChatWindowFile>,
|
|
mut exits: MessageReader<AppExit>,
|
|
) {
|
|
let exiting = exits.read().next().is_some();
|
|
let Some(script) = script else { return };
|
|
if !*file.dirty.get(&script) {
|
|
return;
|
|
}
|
|
if !(exiting || file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET)) {
|
|
return;
|
|
}
|
|
let Some(path) = file.path.clone() else {
|
|
// hermetic/session-only: nothing to write, stop retrying
|
|
*file.dirty.get(&script) = false;
|
|
return;
|
|
};
|
|
let body = render(&script.chat_window_looks());
|
|
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
|
|
// …and don't retry every frame into the same error.
|
|
warn!("chat looks: cannot write {}: {e}", path.display());
|
|
}
|
|
*file.dirty.get(&script) = false;
|
|
}
|
|
|
|
/// `OnExit(InWorld)` — a `/logout` back to the glue, or a disconnect. The same edge the camera
|
|
/// pose and the saved variables flush on, and it must not wait for the quiet second.
|
|
fn save_on_session_end(script: Option<NonSendMut<UiScript>>, mut file: ResMut<ChatWindowFile>) {
|
|
let Some(script) = script else { return };
|
|
if !*file.dirty.get(&script) {
|
|
return;
|
|
}
|
|
if let Some(path) = file.path.clone() {
|
|
let body = render(&script.chat_window_looks());
|
|
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
|
|
warn!("chat looks: cannot write {}: {e}", path.display());
|
|
}
|
|
}
|
|
*file.dirty.get(&script) = false;
|
|
}
|
|
|
|
pub(super) fn plugin(app: &mut App) {
|
|
app.init_resource::<ChatWindowFile>()
|
|
.add_systems(
|
|
Update,
|
|
(load_chat_looks, watch_chat_looks)
|
|
.chain()
|
|
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
|
)
|
|
.add_systems(
|
|
OnExit(crate::char_select::ClientState::InWorld),
|
|
save_on_session_end,
|
|
);
|
|
// The quit flush rides the exit edge rather than `Update` for decision 1528's reason: the
|
|
// close button's `AppExit` is not written until `PostUpdate`, so a save chained beside the
|
|
// watcher would lose the last second of drags to the process ending.
|
|
crate::shutdown::on_app_exit(app, save_chat_looks.into_configs());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A look for WINDOW `n` (1-based). The window number is a parameter because `DOCKED` is the
|
|
/// one field whose stock value differs per window — the dock is windows 1 and 2 — so an
|
|
/// expectation that does not say which window it is cannot be right for all of them.
|
|
fn look(n: usize, r: u8, g: u8, b: u8, a: u8, font_size: i32) -> ChatWindowLook {
|
|
ChatWindowLook {
|
|
r,
|
|
g,
|
|
b,
|
|
a,
|
|
font_size,
|
|
locked: true,
|
|
docked: ChatWindowLook::stock(n - 1).docked,
|
|
}
|
|
}
|
|
|
|
/// The file round-trips — what `render` writes is exactly what `parse` reads back, indices
|
|
/// included.
|
|
#[test]
|
|
fn the_file_round_trips() {
|
|
let looks = vec![
|
|
look(1, 0, 0, 0, 64, 14),
|
|
look(2, 255, 128, 0, 255, 0),
|
|
ChatWindowLook::default(),
|
|
];
|
|
let parsed = parse(&render(&looks));
|
|
assert_eq!(
|
|
parsed,
|
|
vec![(0, looks[0]), (1, looks[1]), (2, looks[2])],
|
|
"0-based indices, values intact"
|
|
);
|
|
}
|
|
|
|
/// The header is a comment block and survives the round trip as one — a reader that choked on
|
|
/// its own header would lose the player's settings on the second launch.
|
|
#[test]
|
|
fn the_header_is_skipped_not_parsed() {
|
|
assert!(render(&[ChatWindowLook::default()]).starts_with('#'));
|
|
assert_eq!(parse(HEADER), vec![]);
|
|
}
|
|
|
|
/// Permissive in the three ways the reference's own reader is: case-insensitive keys, an
|
|
/// unknown key skipped rather than failing the line, and a missing field left at its default.
|
|
#[test]
|
|
fn the_parse_is_permissive_the_way_the_reference_cache_is() {
|
|
let got = parse(
|
|
"window 1 size 16 color 10 20 30 40\n\
|
|
WINDOW 2 SHOWN 1 COLOR 1 2 3 4 DOCKED 2\n\
|
|
WINDOW 3 SIZE 12\n",
|
|
);
|
|
assert_eq!(
|
|
got,
|
|
vec![
|
|
(0, look(1, 10, 20, 30, 40, 16)),
|
|
(1, look(2, 1, 2, 3, 4, 0)),
|
|
(2, look(3, 0, 0, 0, 0, 12)),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// `LOCKED` round-trips, and a file that never mentions it keeps the stock **locked** row —
|
|
/// the lenient default that matters, because reading it as "unlocked" would hand every
|
|
/// pre-existing player's chat window to a stray drag on their next login.
|
|
#[test]
|
|
fn the_lock_round_trips_and_an_absent_key_stays_locked() {
|
|
let unlocked = ChatWindowLook {
|
|
locked: false,
|
|
..look(1, 0, 0, 0, 64, 14)
|
|
};
|
|
assert!(render(&[unlocked]).contains("LOCKED 0"));
|
|
assert_eq!(parse(&render(&[unlocked])), vec![(0, unlocked)]);
|
|
assert_eq!(
|
|
parse("WINDOW 1 SIZE 14 COLOR 0 0 0 64\n"),
|
|
vec![(0, look(1, 0, 0, 0, 64, 14))],
|
|
"no LOCKED key = the stock LOCKED 1"
|
|
);
|
|
}
|
|
|
|
/// **`DOCKED` round-trips, and an absent key keeps the window's own stock position** — the
|
|
/// leniency `LOCKED` gets, in the one field where a single flat default could not express it.
|
|
/// A file written by a build that knew nothing about the key must leave the dock where it
|
|
/// shipped; seeding every window from one default would have undocked ChatFrame1 and
|
|
/// ChatFrame2 on the next login of every existing player (1714).
|
|
#[test]
|
|
fn dock_positions_round_trip_and_an_absent_key_keeps_the_stock() {
|
|
let undocked = ChatWindowLook {
|
|
docked: None,
|
|
..look(1, 0, 0, 0, 64, 14)
|
|
};
|
|
assert!(render(&[undocked]).contains("DOCKED 0"));
|
|
assert_eq!(parse(&render(&[undocked])), vec![(0, undocked)]);
|
|
|
|
// No DOCKED key at all: window 1 keeps position 1, window 2 keeps 2, window 3 stays out.
|
|
let got = parse(
|
|
"WINDOW 1 SIZE 0
|
|
WINDOW 2 SIZE 0
|
|
WINDOW 3 SIZE 0
|
|
",
|
|
);
|
|
assert_eq!(
|
|
got.iter().map(|(_, l)| l.docked).collect::<Vec<_>>(),
|
|
vec![Some(1), Some(2), None]
|
|
);
|
|
|
|
// And a moved window round-trips at its new position.
|
|
let moved = ChatWindowLook {
|
|
docked: Some(3),
|
|
..look(1, 0, 0, 0, 0, 0)
|
|
};
|
|
assert_eq!(parse(&render(&[moved]))[0].1.docked, Some(3));
|
|
}
|
|
|
|
/// Junk costs the line it is on and nothing more.
|
|
#[test]
|
|
fn junk_costs_only_its_own_line() {
|
|
let got = parse("WINDOW\nnot a window line\nWINDOW 0 SIZE 1\nWINDOW 2 SIZE 18\n");
|
|
assert_eq!(got, vec![(1, look(2, 0, 0, 0, 0, 18))]);
|
|
}
|
|
}
|