- a cast refused for the wrong shapeshift form names it, "Must be in Cat Form", a two-form spell joined the reference's way, and the combat log reads its own buffer rather than the red line (2280, 2285) - three refusals that never had a combat-log line, totems, reagents and equipped item class, get one; a pet's form refusal names one form (2285) - the game's plugins are one group and the whole client builds headless in 0.12 s, so the schedule is a test subject: three one-shot consumers caught firing into the login drain are gated (2279) - the net drain's parameters are named bundles, not nested tuples (2279) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
283 lines
13 KiB
Rust
283 lines
13 KiB
Rust
//! **What the host remembers about the VM, and how it forgets.**
|
|
//!
|
|
//! The UI VM does not live for the process — it is built at world entry and destroyed at the
|
|
//! character screen, once per login (decision 1290, the reference's `0x48fbf0` ↔ `0x490bd0`). That
|
|
//! makes every host-side "I already told the VM about this" a claim with an expiry date, and there
|
|
//! are two kinds of them:
|
|
//!
|
|
//! - **seeds** — a registry, a catalog, a keybinding set: pushed once, because pushing it is
|
|
//! expensive and its content does not change. A `bool` latch, or a `PostStartup` system, which is
|
|
//! the same latch written in the scheduler.
|
|
//! - **change memos** — "these are the action slots I last pushed", so an unchanged frame costs
|
|
//! nothing. A `Local<FeedMemory>`, near-universally.
|
|
//!
|
|
//! Both are *correct* against one VM and *silently wrong* against the next, and the failure has no
|
|
//! error path: the push simply does not happen, and the window is empty. Session 2's action bar,
|
|
//! bags, spellbook, keybinds and macros all failed exactly this way, and one of them — the CVar
|
|
//! table — failed worse than empty: `save_config` composes `config.toml` from the VM's snapshot, so
|
|
//! an unseeded VM would have written the player's settings file back out **stripped**.
|
|
//!
|
|
//! [`VmMemo`] is the one mechanism for both. It keys the memory on
|
|
//! [`UiScript::session`] — a VM-side identity, not a host-side counter someone must remember to
|
|
//! bump — so a memory written against a dead VM cannot be read at all. The failure mode inverts:
|
|
//! forgetting to use it is visible (the memo is a plain `Local` again), while using it and being
|
|
//! wrong costs one redundant push per login.
|
|
|
|
use benilla_ui::script::UiScript;
|
|
|
|
/// A host-side memory about the UI VM — **valid only for the VM it was written against**.
|
|
///
|
|
/// Wrap the memo type and read it through [`VmMemo::get`]; against a VM other than the one that
|
|
/// last wrote it, the memo resets to `T::default()` first. The wrapped type is unchanged, so the
|
|
/// call sites keep their own shape:
|
|
///
|
|
/// ```ignore
|
|
/// mut memory: Local<VmMemo<FeedMemory>>,
|
|
/// // …
|
|
/// let memory = memory.get(&script);
|
|
/// if memory.pushed.get(&id) != Some(&slot) { /* push */ }
|
|
/// ```
|
|
pub(crate) struct VmMemo<T> {
|
|
/// The VM this memory is about. `0` is "no VM" — [`UiScript::session`] hands out from 1, so a
|
|
/// freshly defaulted memo matches nothing, and the first read of every session is a miss.
|
|
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 if this is a different VM** than the one that wrote it.
|
|
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** — the character screen, where the
|
|
/// session's Lua state does not exist (1290).
|
|
///
|
|
/// "No VM" is a session in its own right, and it is session `0`: the memory resets once on the
|
|
/// way into it and once on the way out, and holds in between. Reaching for a scratch default
|
|
/// each frame instead would quietly turn the memo off for the whole glue phase.
|
|
pub(crate) fn get_for(&mut self, script: Option<&UiScript>) -> &mut T {
|
|
self.get_reset_for(script).0
|
|
}
|
|
|
|
/// [`VmMemo::get`], also reporting whether the memory RESET on this read — i.e. this is the
|
|
/// first read against a new VM. A gated feed (decision 1439) keys its "must run" on exactly
|
|
/// this: with every input unchanged, a fresh VM still needs the full re-push, and the reset is
|
|
/// the only signal that says so. The flag is true at most once per session per memo, so a gate
|
|
/// that ORs it in costs nothing on the steady frames it exists to skip.
|
|
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** — the seed shape: `if seeded.claim(&script) { …push the
|
|
/// registry… }`.
|
|
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::*;
|
|
|
|
/// The property the whole mechanism rests on: a memo written against one VM is not readable
|
|
/// from the next, and a seed claimed in one session is claimable again in the one after.
|
|
#[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"
|
|
);
|
|
}
|
|
|
|
/// The gate half (1439): `get_reset` reports the reset exactly once per session flip — the
|
|
/// one frame a gated feed must run with every other input unchanged.
|
|
#[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));
|
|
}
|
|
|
|
/// **The rule, enforced instead of remembered.**
|
|
///
|
|
/// A feed that memoizes what it pushed into the VM and does *not* key that memo on the session
|
|
/// fails in the one way nothing catches: it pushes nothing into the new VM, the window is
|
|
/// empty, and no error is raised anywhere. There is no runtime signal to test for — which is
|
|
/// exactly why the check has to be structural.
|
|
///
|
|
/// So: a `Local<…>` in the parameter list of a system that also takes the VM must be a
|
|
/// [`VmMemo`], or be named in [`EXEMPT`] with the reason it is not host-memory-about-the-VM.
|
|
/// Adding a feed the ordinary way passes; adding one with a bare `Local` fails here, at the
|
|
/// line, before it can reach a login.
|
|
#[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 (decision 1290):\n {}",
|
|
offenders.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// `Local`s that live in a system holding the VM but are **not memory about the VM**, each with
|
|
/// the reason. Keyed `(path under src/, parameter name)`.
|
|
const EXEMPT: &[(&str, &str)] = &[
|
|
// A raster fact (the screen seam), not a VM push. The measurer beside it deliberately
|
|
// re-seats off `!script.has_text_measurer()` — it interrogates the VM instead of a memo,
|
|
// which is the same guarantee arrived at the other way.
|
|
("ui_script/extract/mod.rs", "last_seam"),
|
|
// The plate driver's anti-overlap scratch (decision 2148 put the VM in that system's
|
|
// hands). It is cleared at the top of every run and rebuilt from this frame's plates —
|
|
// a reused allocation, not memory: nothing in it survives the call it was filled in, so
|
|
// a new VM has nothing to stale.
|
|
("vplates.rs", "bucket"),
|
|
// The window's `scale_factor` beside it (decision 1342) — the other term a measure is
|
|
// only correct under, since a logical height becomes an integer DEVICE-pixel raster size.
|
|
// A fact about the window, not about the VM; it gates the same re-seat `last_seam` does,
|
|
// and a fresh VM re-seats on `!has_text_measurer()` regardless.
|
|
("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, not about the VM: whether the player has joined a
|
|
// zone-dependent defense channel (the world-state readout's `Type == 1` gate). It is
|
|
// recomputed whenever `ChannelState` changes and is the same answer either side of a
|
|
// login; the push it feeds is memoized beside it in a real `VmMemo`, which is what
|
|
// re-seats the new VM.
|
|
("world_state_ui.rs", "defense_channel"),
|
|
// The cursor systems, both platform arms: these track the OS cursor and an `NSCursor` raw
|
|
// pointer. The OS keeps that state across the VM's death and rebirth, so re-seating them at
|
|
// a login would re-assert a cursor nothing changed. (`last_set` is the key we last handed
|
|
// the window — the same fact under the same name on both arms.)
|
|
("cursor.rs", "was_looking"),
|
|
("cursor.rs", "rects_disabled"),
|
|
("cursor.rs", "decode_failed"),
|
|
("cursor.rs", "last_set"),
|
|
("cursor.rs", "last_ptr"),
|
|
];
|
|
|
|
use crate::test_support::{fn_parameter_lists, rust_files};
|
|
|
|
/// Every `name: Local<Ty>` in a parameter list, as `(name, Ty)` — angle-bracket matched, so a
|
|
/// nested generic comes back 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
|
|
}
|
|
}
|