Key input follows the client's walks: key-down (`0x765f10`), char (`0x765df0`), key-up to the recorded consumer (`0x765fd0`) and repeat (`0x766070`). - EditBoxes are in the walk from creation (`0x779ced`), so the stock login's boxes take typing; only a focused box consumes. A box declines PRINTSCREEN and unaltered arrows on an `ignoreArrows` box, so stack-split steps under a focused chat box. A binding's press records the world frame. - Repeats follow the pressed-key list, so a held key resumes after alt-tab. Chords reach a box only through the walk. Keys the client can't name walk nowhere. - The stock login reads the agreements before its first screen. Cinematics returns to login (no movie player; Deviation). The Realmlist box has its middle border. - Alt+F4 closes the window on Windows and Linux (Deviation: the reference relies on the OS). Fixes #179 Fixes #1003 Fixes #1004 Fixes #1005 Fixes #1019 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
8.4 KiB
Rust
220 lines
8.4 KiB
Rust
//! Host-side memory about the UI VM, which lives for one login: the reference builds it at world
|
|
//! entry and destroys it at the character screen (`0x48fbf0`, `0x490bd0`). A seed or change memo
|
|
//! kept past its VM silently skips the push into the next one; [`VmMemo`] keys it on
|
|
//! [`UiScript::session`], so against a new VM it reads as fresh.
|
|
|
|
use benilla_ui::script::UiScript;
|
|
|
|
/// A host-side memo valid only for the VM that wrote it: [`VmMemo::get`] resets it to
|
|
/// `T::default()` when read against another.
|
|
pub(crate) struct VmMemo<T> {
|
|
/// The VM this memory is about; 0 is no VM, as [`UiScript::session`] counts from 1.
|
|
session: u64,
|
|
inner: T,
|
|
}
|
|
|
|
impl<T: Default> Default for VmMemo<T> {
|
|
fn default() -> Self {
|
|
Self {
|
|
session: 0,
|
|
inner: T::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: Default> VmMemo<T> {
|
|
/// The memory, cleared first against a different VM.
|
|
pub(crate) fn get(&mut self, script: &UiScript) -> &mut T {
|
|
self.get_for(Some(script))
|
|
}
|
|
|
|
/// [`VmMemo::get`] for a system that also runs with no VM, at the character screen; no VM is
|
|
/// session 0, so the memo holds there too.
|
|
pub(crate) fn get_for(&mut self, script: Option<&UiScript>) -> &mut T {
|
|
self.get_reset_for(script).0
|
|
}
|
|
|
|
/// [`VmMemo::get`], plus whether this read reset it: a gated feed must re-push into a fresh VM
|
|
/// even when every input is unchanged.
|
|
pub(crate) fn get_reset(&mut self, script: &UiScript) -> (&mut T, bool) {
|
|
self.get_reset_for(Some(script))
|
|
}
|
|
|
|
fn get_reset_for(&mut self, script: Option<&UiScript>) -> (&mut T, bool) {
|
|
let now = script.map_or(0, UiScript::session);
|
|
let reset = self.session != now;
|
|
if reset {
|
|
self.session = now;
|
|
self.inner = T::default();
|
|
}
|
|
(&mut self.inner, reset)
|
|
}
|
|
}
|
|
|
|
impl VmMemo<bool> {
|
|
/// True exactly once per VM, for a seed pushed once per login.
|
|
pub(crate) fn claim(&mut self, script: &UiScript) -> bool {
|
|
let done = self.get(script);
|
|
if *done {
|
|
false
|
|
} else {
|
|
*done = true;
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_memo_does_not_survive_the_vm_it_was_written_against() {
|
|
let first = UiScript::new().expect("VM");
|
|
let second = UiScript::new().expect("VM");
|
|
assert_ne!(
|
|
first.session(),
|
|
second.session(),
|
|
"every VM gets its own identity"
|
|
);
|
|
|
|
let mut memo: VmMemo<Vec<u32>> = VmMemo::default();
|
|
memo.get(&first).push(7);
|
|
assert_eq!(memo.get(&first), &vec![7], "…and keeps it within one VM");
|
|
assert!(
|
|
memo.get(&second).is_empty(),
|
|
"a memo about the previous VM reads as empty against the next one"
|
|
);
|
|
assert!(
|
|
memo.get(&first).is_empty(),
|
|
"and moving back does not resurrect it — the memory is gone, not shelved"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_reset_reports_each_session_flip_once() {
|
|
let first = UiScript::new().expect("VM");
|
|
let second = UiScript::new().expect("VM");
|
|
let mut memo: VmMemo<u32> = VmMemo::default();
|
|
|
|
let (m, reset) = memo.get_reset(&first);
|
|
assert!(reset, "the first read of a session is the reset");
|
|
*m = 7;
|
|
let (m, reset) = memo.get_reset(&first);
|
|
assert!(!reset, "steady frames read quietly");
|
|
assert_eq!(*m, 7);
|
|
let (m, reset) = memo.get_reset(&second);
|
|
assert!(reset, "a new VM resets again");
|
|
assert_eq!(*m, 0, "…and the memory is gone with the old one");
|
|
}
|
|
|
|
#[test]
|
|
fn a_seed_is_claimed_once_per_vm() {
|
|
let first = UiScript::new().expect("VM");
|
|
let second = UiScript::new().expect("VM");
|
|
let mut seeded = VmMemo::<bool>::default();
|
|
|
|
assert!(seeded.claim(&first), "the first VM needs seeding");
|
|
assert!(!seeded.claim(&first), "…and only once");
|
|
assert!(seeded.claim(&second), "the next VM needs it again");
|
|
assert!(!seeded.claim(&second));
|
|
}
|
|
|
|
/// A `Local` in a system that takes the VM must be a [`VmMemo`] or be listed in [`EXEMPT`]: a
|
|
/// stale memo fails silently, so the check is structural.
|
|
#[test]
|
|
fn a_local_in_a_system_that_holds_the_vm_is_keyed_on_the_session() {
|
|
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
|
let mut offenders = Vec::new();
|
|
for file in rust_files(&src) {
|
|
let text = std::fs::read_to_string(&file).expect("readable source");
|
|
if !text.contains("UiScript") {
|
|
continue; // no VM in this file, so no memory about one
|
|
}
|
|
let rel = file
|
|
.strip_prefix(&src)
|
|
.unwrap_or(&file)
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
for params in fn_parameter_lists(&text) {
|
|
if !params.contains("UiScript") {
|
|
continue;
|
|
}
|
|
for (name, ty) in locals_in(params) {
|
|
if ty.contains("VmMemo") || EXEMPT.contains(&(rel.as_str(), name.as_str())) {
|
|
continue;
|
|
}
|
|
offenders.push(format!("{rel}: `{name}: Local<{ty}>`"));
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
offenders.is_empty(),
|
|
"these memos outlive the VM they are about — wrap them in \
|
|
`crate::ui_script::VmMemo<…>` and read them through `.get(&script)`, or add them to \
|
|
`EXEMPT` with the reason they are not memory about the VM:\n {}",
|
|
offenders.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// `Local`s in VM-holding systems that are not memory about the VM, keyed
|
|
/// `(path under src/, parameter name)`.
|
|
const EXEMPT: &[(&str, &str)] = &[
|
|
// A raster fact; a fresh VM re-seats its measurer on `!has_text_measurer()` anyway.
|
|
("ui_script/extract/mod.rs", "last_seam"),
|
|
// The plate driver's anti-overlap scratch, cleared at the top of every run.
|
|
("vplates.rs", "bucket"),
|
|
// The window's scale factor, which gates the same re-seat as `last_seam`.
|
|
("ui_script/extract/mod.rs", "last_dpi"),
|
|
// Pushed unconditionally every frame; there is nothing remembered to go stale.
|
|
("ui_script/mod.rs", "smoothed"),
|
|
// A fact about the chat roster (a defense channel joined); its push has its own `VmMemo`.
|
|
("world_state_ui.rs", "defense_channel"),
|
|
// The cursor systems on both platforms track OS cursor state, which outlives the VM.
|
|
("cursor.rs", "was_looking"),
|
|
("cursor.rs", "rects_disabled"),
|
|
("cursor.rs", "decode_failed"),
|
|
("cursor.rs", "last_set"),
|
|
("cursor.rs", "last_ptr"),
|
|
// The wheel's fraction toward its next notch, a fact about the device, not the VM.
|
|
("bindings.rs", "notches"),
|
|
// The keys believed down, a fact about the keyboard; an alt-tab wipes it by reconcile.
|
|
("bindings.rs", "down"),
|
|
("ui_script/input.rs", "pressed"),
|
|
];
|
|
|
|
use crate::test_support::{fn_parameter_lists, rust_files};
|
|
|
|
/// Every `name: Local<Ty>` in a parameter list as `(name, Ty)`, nested generics whole.
|
|
fn locals_in(params: &str) -> Vec<(String, String)> {
|
|
let mut out = Vec::new();
|
|
for (i, _) in params.match_indices("Local<") {
|
|
let open = i + "Local<".len();
|
|
let mut depth = 1usize;
|
|
let mut close = None;
|
|
for (j, c) in params[open..].char_indices() {
|
|
match c {
|
|
'<' => depth += 1,
|
|
'>' => {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
close = Some(open + j);
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let Some(close) = close else { continue };
|
|
// Walk back over `: ` and `mut ` to the parameter's own name.
|
|
let head = params[..i].trim_end().trim_end_matches(':').trim_end();
|
|
let name = head
|
|
.rsplit(|c: char| c == ',' || c == '(' || c.is_whitespace())
|
|
.find(|s| !s.is_empty() && *s != "mut")
|
|
.unwrap_or("<unnamed>");
|
|
out.push((name.to_string(), params[open..close].trim().to_string()));
|
|
}
|
|
out
|
|
}
|
|
}
|