Files
benilla-octo/crates/benilla-app/tests/cover_input_wall.rs
T
SamandClaude Fable 5.1 bc1a2428dd the loading screen lets nothing through, and the micro buttons go stock
- units could be hovered through the loading screen — the sword cursor gave it away: the world pick chain has no state gate, one of fifty-one input sites, so a gate per consumer is a rule that regresses; the cut is at the source instead — one `PreUpdate` system empties the button planes, drains the raw queues, zeroes the accumulators and blanks the window's cursor position while the cover is up (the alt-tabbed-away state every consumer already handles); a press under the cover never happened, a held button is released once, a gesture in flight is cancelled, and a wall test counts the channels; the reference does the same — its raise registers a veto stub (`0x406a50`) on five event-bus categories — and one gap closed with it: the reference parks the plain arrow and drops any item overlay before the screen goes up, where benilla kept a held item's icon over the loading art (1986, 1990)
- the micro-button row is the reference's own `MainMenuBarMicroButtons.xml`, our transcription deleted — no engine verb was missing, and the whole engine-side cost is one alias, `SoundOptionsFrame = OptionsFrame`, because the stock file reads the reference's three options windows and ours is the one window for all three; what the stock file brings back: `UpdateTalentButton`'s own −2 re-seat (the five buttons after the talent button sit 1 px further right — the reference's own pixel), the raw binding token in the hover label (`(C)`, `(SHIFT-P)`), the sixty-second talent-button pulse on a ding while the sheet is closed (0304's scope-out retired), the keyring arm, and "Abilities" on the spellbook hover for a character with no spells; `BenillaActionBarArt_SeatAbove` goes with the file — a child is born one level above its parent (`SetParent` `0x76ab65`), the seat it set by hand (1987)
- hovering the player arrow on the stock world map showed an empty tooltip: narrower than 4:3 the map is scaled under 1, and our `MouseIsOver` had dropped the reference's division by `GetEffectiveScale` as "always 1" — the frame getters answer in the frame's own units, `GetCursorPosition` in the screen's; the division is back, and the region getters divide by their owner's scale too; and the ready check times out: the stock `UIParent.xml` ticks `CheckReadyCheckTime` every frame, the one unbound name in that file's way, so the mechanism behind it is built — a 30 s deadline and a "has not answered" flag per roster entry, the leader getting no popup while members print "%s has initiated a ready check" and get it, and on expiry `RAID_MEMBERS_AFK` with the unanswered names or `READY_CHECK_NO_AFK`, the last answer closing the check at once (1985, 1989)
- a review of the floor grind closed eight stale-input and bypassed-side-effect siblings the gates had left open: a never-placed card places on a still frame, the dormant-emitter skip sits below the emitter's lifetime blocks, PVS changes (not only spawns) un-still the gates, the appear ramp's retirement and a far-side unmark come due, a host born under a parked camera is judged, the projection counts as a still input, a fresh palette range is zeroed, the stable feed runs once more after a close, the unit publisher's viewer query is filtered, solo UI materials are reused by key, and the draw-list cache compares exactly; and three doc links the chat window's stock move (1948) left dangling point at what replaced them — the colour table and the tell ring are the stock Lua's now — which is what had the doc-links gate (1925) red on main

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:01:38 +07:00

265 lines
11 KiB
Rust

//! **The cover-input wall** — the loading screen's input rule, counted from source on every
//! `cargo test`, so the swallow list can never quietly fall behind what the client actually reads.
//!
//! `loading_screen/input.rs` takes the whole input plane while the cover is up, at the source, in
//! `PreUpdate`. That cut is what makes the rule survive a consumer nobody remembered — the world
//! pick chain, which hovered units and changed the hardware cursor *through* the loading screen
//! until the director noticed. But a source cut only covers the channels it names, and bevy has
//! more channels than we currently read: touch, IME, gamepads, the trackpad gestures. The day
//! someone reads one of those, the cover has to grow a line — and nothing in the compiler will say
//! so.
//!
//! This is that "nothing". It scans every workspace source for the input channels the client
//! reaches for and requires each to carry a **verdict** in [`VERDICTS`] below:
//!
//! - **`Swallowed`** — the cover empties it. Cross-checked: the name must literally appear in the
//! gate's own source, so the table cannot claim coverage the gate does not implement.
//! - **`Open`** — deliberately *not* swallowed, with the reason written down. Window lifecycle is
//! the whole of this class today: a close request, a focus change and an occlusion change must
//! still land while a load is running.
//! - **`Plumbing`** — a type, enum, plugin or system-set name that carries no player input at all.
//!
//! An unclassified name fails the test with the question it wants answered: *does the cover
//! swallow this?* Answering it is one line here and, when the answer is yes, one line in the gate.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Verdict {
/// The cover empties this channel — see `loading_screen/input.rs`.
Swallowed,
/// Deliberately left open under the cover. The `&str` is the reason.
Open(&'static str),
/// Not a player-input channel at all.
Plumbing,
}
use Verdict::{Open, Plumbing, Swallowed};
/// Every `bevy::input::…` / `bevy::window::…` leaf the workspace names, plus the prelude-sourced
/// input items, each with its verdict. **The wall itself.**
const VERDICTS: &[(&str, Verdict)] = &[
// ── The channels the cover takes ────────────────────────────────────────────────────────
("ButtonInput", Swallowed),
("KeyboardInput", Swallowed),
("MouseButtonInput", Swallowed),
("MouseMotion", Swallowed),
("MouseWheel", Swallowed),
("CursorMoved", Swallowed),
("AccumulatedMouseMotion", Swallowed),
("AccumulatedMouseScroll", Swallowed),
// The pointer position is a *field*, not a message; the cover blanks it, which is why every
// hit-test in the client goes quiet without knowing the cover exists.
("cursor_position", Swallowed),
("physical_cursor_position", Swallowed),
// ── Deliberately open under the cover ───────────────────────────────────────────────────
(
"WindowCloseRequested",
Open("closing the window during a load must work — it is the OS asking, not the player"),
),
(
"WindowFocused",
Open("alt-tab bookkeeping; `cursor.rs` re-asserts the hardware cursor on the focus edge"),
),
(
"WindowOccluded",
Open("the present-mode throttle: a load behind another window must still throttle"),
),
(
"CursorOptions",
Open("an OUTPUT (grab/visible), not a channel — the cover has nothing to take from it"),
),
// ── Not input at all ────────────────────────────────────────────────────────────────────
("ButtonState", Plumbing),
// The cursor OUTPUTS — the art and the grab mode benilla *writes*. Nothing arrives through
// them, so there is nothing for the cover to take.
("CursorGrabMode", Plumbing),
("CursorIcon", Plumbing),
("CustomCursor", Plumbing),
("CustomCursorImage", Plumbing),
("InputPlugin", Plumbing),
("InputSystems", Plumbing),
("Key", Plumbing),
("KeyCode", Plumbing),
("MouseButton", Plumbing),
("MouseScrollUnit", Plumbing),
("NativeKey", Plumbing),
("ExitCondition", Plumbing),
("Monitor", Plumbing),
("MonitorSelection", Plumbing),
("PresentMode", Plumbing),
("PrimaryWindow", Plumbing),
("RawHandleWrapper", Plumbing),
("Window", Plumbing),
("WindowLevel", Plumbing),
("WindowMode", Plumbing),
("WindowPlugin", Plumbing),
("WindowPosition", Plumbing),
("WindowResolution", Plumbing),
];
/// The gate's own source — the cross-check target for every `Swallowed` row.
const GATE: &str = "crates/benilla-app/src/loading_screen/input.rs";
/// The prelude re-exports these, so a source can name them with no `bevy::input::` path in sight.
/// Scanned for by bare identifier; anything matched still has to carry a verdict above.
const PRELUDE_INPUT_NAMES: &[&str] = &[
"ButtonInput",
"AccumulatedMouseMotion",
"AccumulatedMouseScroll",
"cursor_position",
"physical_cursor_position",
];
#[test]
fn every_input_channel_the_client_reads_has_a_verdict_under_the_cover() {
let root = workspace_root();
let mut found: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for file in workspace_sources(&root) {
// The wall names every channel in its own prose; scanning it would only find itself.
if file.ends_with("tests/cover_input_wall.rs") {
continue;
}
let Ok(text) = std::fs::read_to_string(&file) else {
continue;
};
let rel = file
.strip_prefix(&root)
.unwrap_or(&file)
.to_string_lossy()
.to_string();
for name in input_names(&text) {
found.entry(name).or_default().insert(rel.clone());
}
}
let table: BTreeMap<&str, Verdict> = VERDICTS.iter().copied().collect();
let unclassified: Vec<_> = found
.iter()
.filter(|(name, _)| !table.contains_key(name.as_str()))
.collect();
assert!(
unclassified.is_empty(),
"the client reads input channels the cover has never been asked about.\n\
For each, decide: does the loading cover swallow it? If yes, add the line to {GATE} \
and a `Swallowed` row to VERDICTS; if not, an `Open` row saying why.\n{}",
unclassified
.iter()
.map(|(name, files)| format!(
" {name} — {}",
files.iter().take(3).cloned().collect::<Vec<_>>().join(", ")
))
.collect::<Vec<_>>()
.join("\n")
);
// A `Swallowed` verdict is a claim about the gate's source; hold it to it. (The reverse is not
// asserted: the gate may name a channel nothing reads yet — that is the cover being ahead.)
let gate = std::fs::read_to_string(root.join(GATE)).expect("the gate's source");
let unbacked: Vec<&str> = VERDICTS
.iter()
.filter(|(name, v)| *v == Swallowed && !gate.contains(name))
.map(|(name, _)| *name)
.collect();
assert!(
unbacked.is_empty(),
"VERDICTS claims the cover swallows these, but {GATE} never names them: {unbacked:?}"
);
// And the table itself must not rot: a row nothing in the workspace names any more is a row to
// delete, not to keep as decoration. (`Plumbing` rows are exempt — they exist to absorb noise.)
let stale: Vec<&str> = VERDICTS
.iter()
.filter(|(name, v)| *v != Plumbing && !found.contains_key(*name))
.map(|(name, _)| *name)
.collect();
assert!(
stale.is_empty(),
"VERDICTS rows nothing reads any more — delete them: {stale:?}"
);
}
/// Every input-ish identifier `text` names: the leaf of any `bevy::input::…`/`bevy::window::…`
/// path (grouped `use` braces expanded), plus the prelude-sourced names.
fn input_names(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for prefix in ["bevy::input::", "bevy::window::"] {
let mut rest = text;
while let Some(at) = rest.find(prefix) {
rest = &rest[at + prefix.len()..];
// `use bevy::input::mouse::{A, B};` — take the braced group whole.
let path: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
.collect();
let leaf_source = if rest[path.len()..].starts_with('{') {
let end = rest[path.len()..].find('}').unwrap_or(0);
rest[path.len() + 1..path.len() + end].to_string()
} else {
path.clone()
};
for item in leaf_source.split(',') {
// `keyboard::KeyboardInput`, `KeyCode::Space`, `self` → the first segment that is
// a type-shaped name (upper-case initial) wins; a module segment is skipped.
if let Some(name) = item
.split("::")
.map(str::trim)
.find(|seg| seg.starts_with(|c: char| c.is_ascii_uppercase()))
{
out.insert(name.to_string());
}
}
rest = &rest[path.len()..];
}
}
for name in PRELUDE_INPUT_NAMES {
if text.contains(name) {
out.insert((*name).to_string());
}
}
out
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("crates/benilla-app → the workspace root")
.to_path_buf()
}
/// Every `.rs` under each crate's `src/` and `examples/` — the client's own code. Test files are
/// included on purpose: a test that reaches for a new input channel is still a signal that the
/// channel exists, and the verdict costs one line.
fn workspace_sources(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for crate_dir in std::fs::read_dir(root.join("crates"))
.into_iter()
.flatten()
.flatten()
{
for sub in ["src", "examples", "tests"] {
let dir = crate_dir.path().join(sub);
if dir.is_dir() {
collect_rs(&dir, &mut out);
}
}
}
out.sort();
out
}
fn collect_rs(dir: &Path, out: &mut Vec<PathBuf>) {
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
}