Files
benilla-octo/crates/benilla-app/src/ui_script/death_tests.rs
T
SamandClaude Fable 5.1 e7ac8b94f3 the minimap and world map go stock, and the chat loses its white plate
- the minimap cluster and the battleground list are the reference's own `Minimap.xml` and `BattlefieldFrame.xml`, ours deleted, and the sixteen engine names the static probe found unbound are built, not stubbed: the battleground list and queue family (the as-group join refused client-side with no packet when the map's `MaxPlayers` is below the group), the bodyless `CMSG_BATTLEFIELD_STATUS` on world enter — benilla never sent it, so a queue joined before a reconnect was invisible — the 5.5556 yd `BATTLEFIELDS_CLOSED` leash, and the meeting-stone pair over the `0x295` five-way table with both asymmetries; the ping's lifetime is the stock Lua's now (held 5 s, faded 0.5 s), `GetPingPosition` answers two numbers always — our nil was a Lua error on every frame of the stock `Minimap_OnUpdate` — and the map-change drop the reference never had is gone (1974)
- the world map is the reference's own `WorldMapFrame.xml`, our transcription deleted; built for it: the arrow-frame family (an anonymous `Model` child at its 33.6 px footprint, drawn app-side as the minimap's own arrow sprite through one shared constant), the battleground position family over `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways (the reference's 5000 ms request throttle), and `GetPlayerMapPosition` resolving `raid1..raid40` where it knew only the party (B320); `SetupFullscreenScale` — the engine binding `0x48c270` the map's and the cinematic frame's OnShow call, which our map had stood in for with Lua — is bound to the carved law `min(0.75 · aspect, 1)` off the configured aspect, written through the frame's own `SetScale` (1.0 at 4:3 and wider, 0.9375 at 5:4); a hovered POI writes the map's own area label, not the tooltip; named gap: `WorldMapPing` draws nothing yet (1980, 1981)
- the tutorial frame is the reference's own `TutorialFrame.xml`, over a tutorial system benilla never had: two bit banks filled from `SMSG_TUTORIAL_FLAGS` — the fire-once bank `TriggerTutorial` reads and sets (never a send) and the acknowledged bank `FlagTutorial` writes and sends; there is no tutorial CVar in the binary (210 `CVar::Register` sites censused), so "disable tutorials" is every bit acknowledged; the sites this app can produce are wired (the world-enter Welcome cascade, the level-up arm, item pushes, the loot sparkle, the meeting-stone and battleground arms), the rest named; the loader learned `<TitleRegion>` — the stock frame's drag handle, a region that emits no quad; and the `TabardModel` widget kind arrives ahead of its window, five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}` and the constant 100 000 tabard cost (1976, 1977)
- the chat window drew as an opaque white plate: the chat-cache loader, gated on `InWorld` alone, restored into the boot VM 130 ms before the deferred entry load filled it, and its per-VM memo then blocked the restore that would have reached the real frames — the plate the stock Lua tints black stayed the XML's white; between the entry edge and the load there is now no VM in the world (the edge parks the boot VM, the load takes it back), so no in-world feed pushes into a VM with no interface; the perf pill reads `59 fps  7.0 ms  17.3 cpu` — the MAIN thread's milliseconds in full text and the process-wide sum dim, because the sum of every thread's CPU time had been read as a frame time (a raid read 17 on it at a solid 60 with the main thread at 7); and `WOW_PROBE_UI_ONE_TEX=1` is a pricing lever, never a look, for the draw-count ceiling a UI atlas would reach before one is built (1978, 1954)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:59:53 +07:00

475 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The death-arc UI (decisions 0308/1746, DeathFrame.xml): the DEATH release popup's
//! show/countdown/release flow, its self-resurrect button, the resurrect-offer popup pick, and
//! the spirit-healer XP_LOSS two-step — the Lua wiring between the engine's death events and the
//! StaticPopup engine, driven exactly as `death.rs`'s feed does it (set_death → fire_event).
use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "UIParent.xml");
s
}
/// PLAYER_DEAD with a running release timer: the DEATH popup shows, its OnShow seeds the countdown
/// from GetReleaseTimeRemaining, the engine's per-tick DEATH text renders, Release Spirit queues
/// the Repop intent, and PLAYER_ALIVE (the release landing) hides it.
#[test]
fn death_popup_counts_down_and_release_queues_repop() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"PLAYER_DEAD shows the DEATH popup"
);
s.tick(0.05);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"5 Minutes until release",
"the countdown renders through the engine's DEATH per-tick text"
);
// ESC must NOT close it (no hideOnEscape on DEATH — the ref's law).
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the DEATH popup ignores ESC"
);
// Button2 (soulstone) is hidden — HasSoulstone() is nil.
assert!(
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"no soulstone ⇒ single-button release dialog"
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::Repop]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"Release Spirit hides the dialog"
);
// Re-shown (still dead), then the release lands: PLAYER_ALIVE hides it.
s.fire_event("PLAYER_DEAD", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
s.fire_event("PLAYER_ALIVE", vec![]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"PLAYER_ALIVE (the release) hides the DEATH popup"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The self-resurrect leg (decision 1746): with `PLAYER_SELF_RES_SPELL` naming a spell, the DEATH
/// popup grows its second button, the button wears the SPELL's name rather than the registry's
/// "Reincarnate" default, and clicking it queues `UseSoulstone` — while button1 still releases.
#[test]
fn death_popup_offers_the_self_resurrect_and_spends_it() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
self_res_label: Some("Use Soulstone".into()),
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"a self-res owed ⇒ DisplayButton2 shows the second button"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Button2:GetText()")
.unwrap(),
"Use Soulstone",
"OnShow stamps the Spell.dbc name over the registry's vestigial \"Reincarnate\""
);
// Button1 is still the release, and it is NOT the self-res.
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::Repop]);
// Button2 spends it. The dialog is raised again first, because clicking either button hides it.
s.fire_event("PLAYER_DEAD", vec![]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::UseSoulstone]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the self-res hides the dialog; the resurrection itself lands as descriptor deltas"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A shaman's Reincarnation is the same path with a different name — and the button is decided by
/// `HasSoulstone()` at SHOW time, so a self-res that arrives after the popup is up does not
/// retro-fit a button onto it (the reference's own StaticPopup engine re-evaluates neither
/// `DisplayButton2` nor `OnShow` per tick).
#[test]
fn death_popup_button2_is_decided_at_show_time() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(!s
.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap());
// The field lands late: the standing dialog does not grow a button…
s.set_death(DeathUiState {
release_remaining: Some(300.0),
self_res_label: Some("Reincarnation".into()),
..Default::default()
});
s.tick(0.05);
assert!(
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"no per-tick re-evaluation — the button set is fixed at StaticPopup_Show"
);
// …but the OnCancel arm re-asks, so a click on a button that is not there cannot mis-route,
// and the next raise picks it up.
s.run("StaticPopup_Hide(\"DEATH\")").unwrap();
s.fire_event("PLAYER_DEAD", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1Button2:GetText()")
.unwrap(),
"Reincarnation"
);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::UseSoulstone]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The no-timer variant: GetReleaseTimeRemaining() == −1 (an instanceable map) swaps in the
/// DEATH_RELEASE_NOTIMER text and never counts down.
#[test]
fn death_popup_no_timer_shows_the_static_text() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: None,
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
s.tick(0.05);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"You have died. Release to the nearest graveyard?",
"−1 picks the no-timer text, untouched by ticks"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// RESURRECT_REQUEST picks its popup by the offer bits (the ref's three-way), formats the offerer
/// name in, gates Accept behind the recovery delay, and Accept/Decline queue their intents.
#[test]
fn resurrect_request_picks_variant_and_answers() {
let mut s = setup();
// No sickness + timer, delay already elapsed: RESURRECT_NO_SICKNESS with an armed Accept.
s.set_death(DeathUiState {
resurrect_sickness: false,
resurrect_has_timer: true,
recovery_delay: 0.0,
..Default::default()
});
s.fire_event("RESURRECT_REQUEST", vec![ScriptValue::Str("Pone".into())]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RESURRECT_NO_SICKNESS"
);
s.tick(0.05); // the zero StartDelay expires: real text + enabled button
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Pone wants to resurrect you",
"the offerer name formats into the no-sickness text"
);
assert!(s
.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap());
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::AcceptResurrect]);
// Sickness variant declines: the intent queues and (still dead) DEATH re-shows.
s.set_death(DeathUiState {
resurrect_sickness: true,
resurrect_has_timer: true,
release_remaining: Some(200.0),
..Default::default()
});
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
max_health: 100,
health: 0,
dead: true,
..Default::default()
}),
);
s.fire_event("RESURRECT_REQUEST", vec![ScriptValue::Str("Pone".into())]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RESURRECT"
);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::DeclineResurrect]);
// The re-shown DEATH lands on the free SECOND instance (Show runs inside the decline's
// OnCancel, before the click's own hide frees instance 1) — the exact case that sized
// STATICPOPUP_NUMDIALOGS at 2.
assert_eq!(
s.eval::<String>("return StaticPopup_Visible(\"DEATH\") or \"none\"")
.unwrap(),
"StaticPopup2",
"declining while dead re-shows the DEATH popup (the ref's OnCancel)"
);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the answered RESURRECT dialog itself closed"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The spirit healer's CONFIRM_XP_LOSS two-step: first Accept rewrites to the "Are you sure" text
/// and keeps the dialog, the second queues AcceptXPLoss; walking out of range auto-hides.
#[test]
fn xp_loss_two_step_confirm_then_range_hide() {
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 Minutes".into()),
spirit_healer_in_range: true,
..Default::default()
});
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"XP_LOSS"
);
let text = s
.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap();
assert!(
text.contains("afflicted by 8 Minutes of Resurrection Sickness"),
"the sickness duration formats into CONFIRM_XP_LOSS: {text}"
);
// First Accept: the AGAIN text swaps in, the dialog stays, nothing queues. XP_LOSS's verbatim
// OnAccept reads `this:GetParent()` (the ref's button-handler context), so the click is driven
// as the XML OnClick does — with `this` bound to the button.
s.run("this = StaticPopup1Button1 StaticPopup_OnClick(StaticPopup1, 1) this = nil")
.unwrap();
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.take_death_actions().is_empty());
let text = s
.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap();
assert!(
text.starts_with("Remember, if you find your corpse"),
"the second-ask text: {text}"
);
// Second Accept: the activate intent queues and the dialog closes.
s.run("this = StaticPopup1Button1 StaticPopup_OnClick(StaticPopup1, 1) this = nil")
.unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::AcceptXpLoss]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
// Below the sickness level: the NO_SICKNESS variant. Out of range: the OnUpdate poll hides.
s.set_death(DeathUiState {
sickness_duration: None,
spirit_healer_in_range: true,
..Default::default()
});
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"XP_LOSS_NO_SICKNESS"
);
s.set_death(DeathUiState {
sickness_duration: None,
spirit_healer_in_range: false,
..Default::default()
});
s.tick(0.05);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"leaving the spirit healer's range auto-hides the confirm"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A cancelled confirm comes back on the next ask (decision 1068, B80): every CONFIRM_XP_LOSS
/// fire re-shows the dialog — the feed fires per SMSG, and this guards the Lua half of that
/// contract. And the XP_LOSS dialogs carry the ref's alert dress (StaticPopup.lua l.1579-1589):
/// width 420 with the AlertIcon shown, reset to 320/hidden for a non-alert entry on the same
/// instance.
#[test]
fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 Minutes".into()),
spirit_healer_in_range: true,
..Default::default()
});
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
420.0,
"showAlert widens the dialog to the ref's 420"
);
assert!(
s.eval::<bool>("return StaticPopup1AlertIcon:IsShown()")
.unwrap(),
"showAlert shows the DialogAlertIcon"
);
// Cancel: nothing queues, nothing on the wire — the dialog just hides.
s.run("this = StaticPopup1Button2 StaticPopup_OnClick(StaticPopup1, 2) this = nil")
.unwrap();
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.take_death_actions().is_empty());
// Asking the healer again = a fresh SMSG = the feed fires again: the confirm is back.
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a fresh confirm re-shows after a Cancel (B80's deadlock)"
);
s.run("StaticPopup_Hide(\"XP_LOSS\")").unwrap();
// A non-alert entry on the same instance resets the dress — Show's unconditional reset.
s.run(concat!(
"StaticPopupDialogs[\"TEST_PLAIN\"] = { text = \"x\", button1 = \"OK\", timeout = 0, ",
"whileDead = 1 } StaticPopup_Show(\"TEST_PLAIN\")"
))
.unwrap();
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
320.0,
"a non-alert Show resets the width"
);
assert!(
!s.eval::<bool>("return StaticPopup1AlertIcon:IsShown()")
.unwrap(),
"a non-alert Show hides the icon"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// UnitIsGhost/UnitIsDeadOrGhost — the trio's ghost legs (a ghost has health 1, so UnitIsDead is
/// false for it; decision 0308 §1).
#[test]
fn the_ghost_predicates() {
let mut s = setup();
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
max_health: 100,
health: 1,
dead: false,
ghost: true,
..Default::default()
}),
);
assert!(!s.eval::<bool>("return UnitIsDead(\"player\")").unwrap());
assert!(s.eval::<bool>("return UnitIsGhost(\"player\")").unwrap());
assert!(s
.eval::<bool>("return UnitIsDeadOrGhost(\"player\")")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The corpse-run range events (decision 0308 §5): CORPSE_IN_RANGE shows RECOVER_CORPSE with its
/// StartDelay countdown gating Accept, Accept queues the reclaim intent (and keeps the dialog —
/// the server's descriptor deltas close the loop), CORPSE_OUT_OF_RANGE hides it; the instance
/// variant is the buttonless notice.
#[test]
fn corpse_range_events_drive_recover_corpse() {
let mut s = setup();
s.set_death(DeathUiState {
recovery_delay: 2.0,
..Default::default()
});
s.fire_event("CORPSE_IN_RANGE", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RECOVER_CORPSE"
);
assert!(
!s.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap(),
"Accept is delay-gated"
);
s.tick(0.5);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"2 Seconds until resurrection"
);
s.tick(1.6);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Resurrect now?"
);
assert!(s
.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap());
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::RetrieveCorpse]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"RetrieveCorpse returns 1 — the dialog stays until the server answers"
);
s.fire_event("CORPSE_OUT_OF_RANGE", vec![]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"leaving range hides the recover dialog"
);
// The dungeon-corpse notice: buttonless (the ref entry has no button1), whileDead.
s.fire_event("CORPSE_IN_INSTANCE", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RECOVER_CORPSE_INSTANCE"
);
assert!(!s
.eval::<bool>("return StaticPopup1Button1:IsShown()")
.unwrap());
s.fire_event("CORPSE_OUT_OF_RANGE", vec![]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// GetCorpseMapPosition: the feed's corpse UV surfaces (0,0) = hidden when absent — the reference
/// WorldMapFrame.lua:443-452 law the map's update block branches on.
#[test]
fn corpse_map_position_binding() {
let mut s = setup();
let (x, y) = s
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
assert_eq!((x, y), (0.0, 0.0), "no corpse ⇒ the (0,0) hide sentinel");
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new(), Vec::new());
let (x, y) = s
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
assert!((x - 0.25).abs() < 1e-6 && (y - 0.75).abs() < 1e-6);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}