the HUD becomes UIParent's child, and cinematics fade through black

- the transcription had dropped parent= on 72 declarations the reference carries (37 to UIParent, 28 to the action bar's art frame, 7 more), so the HUD was a field of top-level frames and UIParent:Hide() reached almost nothing — which is why a cinematic had to hide the UI at the draw level and then special-case the pointer; both workarounds are deleted and the HUD hides through the reference's own route; restoring only the UIParent half would have been the dangerous part (draw order is one flat sort, so a partial restore lifts frames over the neighbours they carry), and manifest order turns out to be load-bearing — a parent name that doesn't exist yet is silently ignored, so UIParent.xml moves to the reference's own slot with a test that fails if anything declares a child first; the chat still drew over a fly-by after all that, because the reference parents the chat windows on their TEMPLATE, invisible to a name-for-name diff when both sides rename — two declarations fix it, and the sweep that replaces the method loads the whole shipped manifest, fires CINEMATIC_START and asserts the survivors are exactly three: 91 frames visible before, 3 after, and the count comes back on stop
- every cinematic boundary cuts through the reference's 0.25 s fade to black, and the next step hangs on the fade's COMPLETION, not a delay — the shot boundary is exactly where the reference does a blocking terrain load, so a delay-driven cut shows through a hitch and a completion-driven one cannot; and a fly-by now ends into black instead of a ~0.3 s loading cover: the reference raises no cover on that path, it holds black while the ground under the player loads back, so ours holds black while the stream catches up — budgeted, never an unbounded wait
- the death arc keeps its promises across a relog: the body's own state machine now dies with the session — re-entering the world as a ghost used to match no arm of the world-edge machine, so the corpse query never went out and "Resurrect Now" stayed down until the next death; and a corpse wears its owner's guild crest — the corpse descriptor carries the guild field and resolves through the same crest cache a living tabard does, where ours dressed the body crestless
- the char-select ghost gets its body kit: the screen resolves the state kit of the very "Ghost" aura a released ghost rides in the world — tint (140,185,253), alpha 0.5, and the Ghost_state wisp model at the body's attach point, pinned by a golden that decodes the row out of the shipped DBC; the airborne model is corrected before its fix lands — the address six comments called the gravity integrator is really the fall-velocity query, and a knockback is unsteerable because its launch PLANTS the forward bit, not because the arc is airborne (our guard was a velocity proxy, so a near-zero-speed knockback steered from frame 2); and the hover inspect card now decodes a picked part's mesh tag — alpha, rig slot, and both readings of the lighting payload side by side — turning a rebuild-and-two-probes hunt into a hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:13:31 +07:00
co-authored by Claude Fable 5
parent a6e8929f18
commit d245535e68
58 changed files with 1908 additions and 213 deletions
@@ -171,3 +171,58 @@ fn the_screenshot_key_is_handed_back_to_its_binding() {
assert!(s.frame_key_input("ESCAPE"));
assert!(s.take_keybind_requests().is_empty());
}
/// **The HUD hide, end to end — and the frame that must survive it.**
///
/// This is the chain decision 1734 restored, and every link was broken until it did:
/// `CINEMATIC_START` → `CinematicFrame`'s arm calls `ShowUIPanel` → its `area = "full"` row routes
/// to `SetFullScreenFrame` → which hides `UIParent` → which cascades to every frame declaring
/// `parent="UIParent"`. Before, `SetFullScreenFrame` had that line dropped, and there was almost
/// nothing parented to cascade to; the engine hid the HUD with `UiHidden` instead, and paid for it
/// with a mouse that could not find the cinematic frame.
///
/// It replaces a log line. 1699 verified the takeover by watching `cinematic: HUD hidden for
/// playback` go past, because `UiHidden` hides at the *draw* and leaves every widget answering
/// `IsVisible() == true` — the predicate was useless. Hiding through the real cascade makes
/// `IsVisible` the honest question again, so the check is an assertion instead of a log.
#[test]
fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
let mut s = ui_with_the_cinematic_frame();
let visible = |s: &UiScript, f: &str| {
s.eval::<i64>(&format!("return {f}:IsVisible() and 1 or 0"))
.unwrap()
== 1
};
// A shown child of UIParent stands in for the HUD: `UiPanels.xml` gives us one without
// dragging the whole action bar in, and what is under test is the cascade, not which frame.
s.eval::<i64>("StaticPopup1:Show() return 0").unwrap();
s.resolve();
assert!(visible(&s, "UIParent"), "the HUD's parent starts visible");
assert!(visible(&s, "StaticPopup1"), "and so does its child");
start_cinematic(&mut s);
assert!(!visible(&s, "UIParent"), "SetFullScreenFrame hid UIParent");
assert!(
!visible(&s, "StaticPopup1"),
"and the hide cascaded to its children — this is the whole point of the 72 restored \
parent= declarations, and the assertion that fails if one is dropped again"
);
assert_eq!(
s.eval::<i64>("return StaticPopup1:IsShown() and 1 or 0")
.unwrap(),
1,
"cascaded, not shown=false: the frame keeps its own state and gets it back untouched"
);
assert!(
visible(&s, "CinematicFrame"),
"**and the fly-by itself survives** — the reference declares CinematicFrame with no \
parent precisely so the frame being shown escapes the hide that showing it performs"
);
s.set_in_cinematic(false);
s.fire_event("CINEMATIC_STOP", vec![]);
s.resolve();
assert!(visible(&s, "UIParent"), "and the world's UI comes back");
assert!(visible(&s, "StaticPopup1"), "with the child that was up");
}
@@ -25,6 +25,9 @@ fn harness_with(extra: &[&str]) -> UiScript {
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = [
"Fonts.xml",
// `GameMenuFrame` and the panels it opens declare `parent="UIParent"`, resolved at LOAD
// (decision 1734) — UIParent must already be there, as it is in the manifest.
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIPanelTemplates.xml",
@@ -172,4 +172,34 @@ mod tests {
shipped.sort();
assert_eq!(listed, shipped);
}
/// **Nothing may declare `parent="UIParent"` before `UIParent.xml` has loaded.**
///
/// A parent name is resolved at LOAD, and a name that does not exist yet is not an error: the
/// loader warns and silently falls back to the enclosing frame. So this ordering mistake does
/// not fail, it *half-works* — the frame keeps drawing, keeps answering `IsShown`, and simply
/// never joins the cascade `UIParent:Hide()` walks. That is precisely how it would be missed.
///
/// It nearly was: `UIParent.xml` sat below `UiPanels.xml` until decision 1734, so restoring
/// `StaticPopup1`/`StaticPopup2`'s parents there would have written two declarations that did
/// nothing at all. The reference's own order is the fix (FrameXML.toc: BasicControls.xml l.6,
/// UIParent.xml l.8), and this keeps it.
#[test]
fn nothing_declares_a_uiparent_child_before_uiparent_itself_loads() {
let files = manifest_files();
let at = files
.iter()
.position(|f| f == "UIParent.xml")
.expect("the manifest lists UIParent.xml");
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
for early in &files[..at] {
let text = std::fs::read_to_string(dir.join(early)).unwrap();
assert!(
!text.contains(r#"parent="UIParent""#),
"{early} loads before UIParent.xml (position {at}) but declares a \
UIParent child — the loader would warn and silently drop it. Move \
UIParent.xml up, or the declaration down."
);
}
}
}
+6 -18
View File
@@ -450,26 +450,14 @@ fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool
fn arbitrate_pointer_over_ui(
egui: Option<Res<EguiPointerOver>>,
hover: Res<PlayerUiHover>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
mut over: ResMut<PointerOverUi>,
) {
// **A cinematic covers the screen with a frame, so the pointer is over the UI wherever it
// is.** That is a fact about the reference's own layout, not a special case: `CinematicFrame`
// is `setAllPoints` + `enableMouse="true"`, and while it is up it is the only mouse target the
// hit test can reach — because `UIParent:Hide()` has taken every other frame out of it.
//
// benilla cannot yet reach that answer through the hit test. `crate::cinematic` substitutes
// `UiHidden` for the reference's `UIParent:Hide()` (decision 1699, for want of a HUD parented
// to `UIParent`), and `UiHidden` also switches the whole mouse feed off — ALT-Z's meaning,
// where the world *should* take the mouse. So `feed_ui_input` never hit-tested
// `CinematicFrame` at all and the world took every click of a fly-by: right-drag turned the
// character, left-click picked targets through the flying camera, the wheel zoomed a rig
// nobody could see. **This line states the layout fact directly, and it is the interim, not
// the fix** — restoring the 32 dropped `parent="UIParent"` declarations lets the hit test
// reach the same answer on its own, and this argument (and this parameter) is then dead code
// to delete.
let in_cinematic = cinematic.is_some_and(|c| c.is_playing());
over.0 = in_cinematic || egui.is_some_and(|e| e.0) || hover.0.is_some();
// A cinematic used to need a third term here, and no longer does — the deletion is the point
// of decision 1734. `CinematicFrame` is `setAllPoints` + `enableMouse="true"`, so while it is
// up it is the only mouse target the hit test can reach, because `UIParent:Hide()` has taken
// every other frame out of it. The hit test now arrives at that on its own, so
// [`PlayerUiHover`] carries it and the special case is gone.
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
}
/// The session lifecycle — the VM's birth, identity, death, and the reload. Every edge function
@@ -38,6 +38,9 @@ fn harness_on(mut s: UiScript) -> UiScript {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
// Every panel window declares `parent="UIParent"`, resolved at LOAD — so UIParent has to
// exist by the time they are read, exactly as it does in the manifest (decision 1734).
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
@@ -1089,3 +1089,111 @@ fn the_inspect_cursor_pair_takes_both_arms() {
s.errors()
);
}
/// **Nothing of the interface is left on screen during a cinematic** — swept over the whole
/// shipped manifest, not one file at a time.
///
/// This is the test the director's eye had to stand in for. Decision 1734 restored 72 dropped
/// `parent=` declarations and a per-window test proved the cascade worked; the chat still drew
/// over the fly-by, because benilla carries the reference's `FloatingChatFrameTemplate` and
/// `ChatTabTemplate` under its own names (`BenillaChatFrameTemplate`, `BenillaChatTabTemplate`)
/// and the gap analysis that found the 72 skipped `virtual="true"` templates entirely. A
/// name-for-name comparison against the reference could not see it. **Sweeping what is actually
/// visible can**, which is why this is written against the observable and not against a list.
///
/// The three survivors are each required to survive:
///
/// - `CinematicFrame` — the frame being *shown*. The reference declares it with no parent for
/// exactly this reason, and `SetFullScreenFrame` shows it in the same breath as hiding UIParent.
/// - `WorldFrame` — the 3D scene's frame. Ours renders nothing (Bevy draws the world), but it is
/// the reference's own bottom-of-strata frame and a cinematic is a thing you watch *in* it.
/// - `BenillaFadeDriver` — a 1x1 frame with no textures and no layers, whose only content is an
/// `OnUpdate` running `UIFrameFadeUpdate`. It draws nothing, and it must not be hidden: a hidden
/// frame's `OnUpdate` does not run, so parenting it would freeze every in-flight `UIFrameFade`
/// the instant a cinematic started and leave frames stranded mid-fade.
#[test]
fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.resolve();
let names = shipped_frame_names();
let visible = |s: &benilla_ui::script::UiScript, n: &str| -> bool {
s.eval::<i64>(&format!(
"local f = getglobal(\"{n}\") \
if not f or not f.IsVisible then return 0 end \
return f:IsVisible() and 1 or 0"
))
.unwrap_or(0)
== 1
};
// The sweep is only worth anything if there was something to hide in the first place.
let before = names.iter().filter(|n| visible(&s, n)).count();
assert!(
before > 50,
"only {before} frames visible before the cinematic — the sweep found no interface to \
hide, so it would pass no matter what the cascade did"
);
s.set_in_cinematic(true);
s.fire_event("CINEMATIC_START", vec![]);
s.resolve();
let mut after: Vec<&str> = names
.iter()
.map(String::as_str)
.filter(|n| visible(&s, n))
.collect();
after.sort_unstable();
assert_eq!(
after,
["BenillaFadeDriver", "CinematicFrame", "WorldFrame"],
"something is drawing over the fly-by (see this test's header for why exactly these \
three are allowed to survive)"
);
// …and the player gets it all back.
s.set_in_cinematic(false);
s.fire_event("CINEMATIC_STOP", vec![]);
s.resolve();
let restored = names.iter().filter(|n| visible(&s, n)).count();
assert_eq!(
restored, before,
"the interface comes back exactly as it was"
);
}
/// Every named, non-virtual frame the shipped tree declares — the sweep's population.
fn shipped_frame_names() -> Vec<String> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut names: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
let path = entry.path();
if !path.extension().is_some_and(|e| e == "xml") {
continue;
}
let text = std::fs::read_to_string(&path).expect("read");
for chunk in text.split('<').skip(1) {
let head = &chunk[..chunk.find('>').unwrap_or(chunk.len())];
if head.contains("virtual=\"true\"") {
continue;
}
let Some(i) = head.find("name=\"") else {
continue;
};
let rest = &head[i + 6..];
let Some(j) = rest.find('"') else { continue };
let n = &rest[..j];
// `$parent`-templated names are not globals; nothing can look them up by name.
if !n.is_empty() && !n.contains('$') {
names.push(n.to_string());
}
}
}
names.sort();
names.dedup();
names
}