merge: absorb samwhosung/main on top of the upstream merge

Same fork policy: upstream structure wins, local-unique re-instated.

Cargo.toml unions both measured dev-profile philosophies (codegen-units=1 +
lto=off from samwhosung, debug=line-tables-only + split-debuginfo from the
fork's disk measurements).

glue_booth.rs takes samwhosung's typed GlueScene (token/of_race/from_model_path)
and re-adds the fork's TurtleWoW races (Goblin=9, BloodElf=10) as variants;
their glue fog stays on Human's row, exactly as the fork's scene_fog fallback did.

ui script takes samwhosung's mouseover/hyperlink rework (world_mouseover,
minimap_blip, hyperlink.rs) over the fork's inline hover_link/span_at/pointer
path — the rework covers the same OnHyperlinkEnter/Leave behaviour.

game_plugins.rs keeps the fork's re-measured ceilings (466 / 5,509); mail.rs
keeps the explicit 13-tuple with a float daysLeft; MAP.md takes the superset.
This commit is contained in:
chairmanpow
2026-10-12 17:24:31 +01:00
403 changed files with 52938 additions and 9951 deletions
Generated
+10
View File
@@ -696,6 +696,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e"
dependencies = [
"bevy_dylib",
"bevy_internal",
"tracing",
]
@@ -1015,6 +1016,15 @@ dependencies = [
"sysinfo",
]
[[package]]
name = "bevy_dylib"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f142f72bbbc1a61338bf6da5dc7cf6dcf223683b9d53bcd635ebff4bfeb5308f"
dependencies = [
"bevy_internal",
]
[[package]]
name = "bevy_ecs"
version = "0.18.1"
+14 -1
View File
@@ -49,9 +49,13 @@ kira = { path = "third_party/kira" }
opt-level = 3
debug = "line-tables-only"
split-debuginfo = "unpacked"
# One codegen unit each, since `lto = "off"` below drops the ThinLTO that inlines across a crate's
# units: at 16 units the dependencies cost +1.2 cpu_ms at the leg pin, at one nothing, and a cold
# build is no slower.
codegen-units = 1
# Our crates at opt-level 1: Bevy's generic hot paths monomorphize into them, and at opt-level 0
# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0.
# Stormwind drops from a pinned 60 fps to ~45.
[profile.dev]
opt-level = 1
# Line tables only, not `debug = 2`. DWARF is ~97% of what rustc writes for this tree: the largest
@@ -66,6 +70,10 @@ opt-level = 1
# with sccache and no disk pressure. Re-measure on macOS before assuming it holds there.
debug = "line-tables-only"
split-debuginfo = "unpacked"
# No ThinLTO: above opt-level 0 rustc runs it over a crate's own codegen units by default, and an
# incremental rebuild redoes it whole: 19 s of a one-line `benilla-app` edit's 25 s, and half its
# 5.3 GB. Off, our crates cost +0.7 cpu_ms (+6 %) at the leg pin.
lto = "off"
# The shipping build (`--profile ship`): fat LTO measures -1.0 cpu_ms (-5 %) against `release`,
# which stays the measured profile because fat LTO's link time would tax every iteration.
@@ -84,3 +92,8 @@ incremental = true
# Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols
# unresolved and the link fails intermittently (rustc #86049).
lto = "off"
# One codegen unit each, as in dev: at 16 the dependencies cost +1.2 cpu_ms against `release` at
# the leg pin, at one the profile is +0.5, and a cold build is no slower.
[profile.play.package."*"]
codegen-units = 1
+3
View File
@@ -21,6 +21,9 @@ dev = [
tracy = ["bevy/trace_tracy"]
# Writes every system's per-run span to `trace-<ts>.json` in the cwd.
trace_chrome = ["bevy/trace_chrome"]
# Links Bevy as a shared library so an incremental rebuild relinks faster. The binary then starts
# only through `cargo run`, which puts the toolchain's libstd on the loader path.
bevy_dynamic_linking = ["bevy/dynamic_linking"]
[dependencies]
anyhow.workspace = true
@@ -0,0 +1,127 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!-- benilla's realmlist control on the stock login screen: a button over the Cinematics one,
the address the next login dials beside it, and the stock GlueDialog with its edit box to
change it. The address is the realmList CVar, written through ConsoleExec; the host keeps
BENILLA_REALMLIST and BENILLA_REALMLIST_PINNED (set while $WOW_HOST fixes the address),
and reopens the dialog with the typed text when it is not an address. -->
<Script>
GlueDialogTypes["BENILLA_REALMLIST"] = {
-- The realmList CVar's registered help text.
text = "Address of realm list server",
button1 = TEXT(OKAY),
button2 = TEXT(CANCEL),
hasEditBox = 1,
maxLetters = 64,
OnShow = function()
BenillaRealmlist_TakeEditBox();
GlueDialogEditBox:SetText(GlueDialog.data or BENILLA_REALMLIST or "");
GlueDialogEditBox:HighlightText();
GlueDialogEditBox:SetFocus();
end,
OnAccept = function()
local typed = GlueDialogEditBox:GetText();
BenillaRealmlist_ReleaseEditBox();
if ( typed and typed ~= "" ) then
ConsoleExec("realmList "..typed);
end
end,
OnCancel = function()
BenillaRealmlist_ReleaseEditBox();
end,
};
GlueDialogTypes["BENILLA_REALMLIST_PINNED"] = {
text = "",
button1 = TEXT(OKAY),
button2 = nil,
};
-- The stock edit box's Enter and Escape call the in-game StaticPopup functions, which the
-- glue does not have: while this dialog owns it, they press its buttons, and it is wide
-- enough for a host name. The stock border is two 75-pixel caps that meet at its own 130
-- pixels, so the widened box also takes the chat box's stretched middle piece
-- (ChatFrame.xml:41-55) between them.
function BenillaRealmlist_TakeEditBox()
if ( not BenillaRealmlist_Saved ) then
BenillaRealmlist_Saved = {
width = GlueDialogEditBox:GetWidth(),
enter = GlueDialogEditBox:GetScript("OnEnterPressed"),
escape = GlueDialogEditBox:GetScript("OnEscapePressed"),
};
end
GlueDialogEditBox:SetWidth(300);
if ( not BenillaRealmlist_Middle ) then
BenillaRealmlist_Middle = GlueDialogEditBox:CreateTexture(nil, "BACKGROUND");
BenillaRealmlist_Middle:SetTexture("Interface\\ChatFrame\\UI-ChatInputBorder-Right");
BenillaRealmlist_Middle:SetTexCoord(0, 0.9375, 0, 1.0);
BenillaRealmlist_Middle:SetHeight(32);
BenillaRealmlist_Middle:SetPoint("LEFT", GlueDialogEditBox, "LEFT", 65, 0);
BenillaRealmlist_Middle:SetPoint("RIGHT", GlueDialogEditBox, "RIGHT", -65, 0);
end
BenillaRealmlist_Middle:Show();
GlueDialogEditBox:SetScript("OnEnterPressed", function() GlueDialogButton1:Click(); end);
GlueDialogEditBox:SetScript("OnEscapePressed", function() GlueDialogButton2:Click(); end);
end
function BenillaRealmlist_ReleaseEditBox()
local saved = BenillaRealmlist_Saved;
if ( saved ) then
BenillaRealmlist_Middle:Hide();
GlueDialogEditBox:SetWidth(saved.width);
GlueDialogEditBox:SetScript("OnEnterPressed", saved.enter);
GlueDialogEditBox:SetScript("OnEscapePressed", saved.escape);
BenillaRealmlist_Saved = nil;
end
GlueDialogEditBox:ClearFocus();
end
function BenillaRealmlist_OnClick()
if ( GlueDialog:IsVisible() ) then
return;
end
PlaySound("gsLoginNewAccount");
if ( BENILLA_REALMLIST_PINNED ) then
GlueDialog_Show("BENILLA_REALMLIST_PINNED", format(
"$WOW_HOST is set for this session, so the realmlist is fixed at %s.",
BENILLA_REALMLIST or ""));
else
GlueDialog_Show("BENILLA_REALMLIST", nil, BENILLA_REALMLIST);
end
end
</Script>
<Button name="BenillaRealmlistButton" inherits="GlueButtonSmallTemplate" parent="AccountLoginUI" text="Realmlist">
<Anchors>
<Anchor point="BOTTOM" relativeTo="AccountLoginCinematicsButton" relativePoint="TOP">
<Offset>
<AbsDimension x="0" y="-2"/>
</Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString name="BenillaRealmlistText" inherits="GlueFontDisableSmall" justifyH="RIGHT">
<Size>
<AbsDimension x="256" y="0"/>
</Size>
<Anchors>
<Anchor point="RIGHT" relativePoint="LEFT">
<Offset>
<AbsDimension x="-4" y="3"/>
</Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnClick>
BenillaRealmlist_OnClick();
</OnClick>
<OnUpdate>
if ( BenillaRealmlistText:GetText() ~= BENILLA_REALMLIST ) then
BenillaRealmlistText:SetText(BENILLA_REALMLIST);
end
</OnUpdate>
</Scripts>
</Button>
</Ui>
+26 -2
View File
@@ -278,11 +278,29 @@
return bottom
end
-- The scroll frame's size in its own units, from its edges: its anchors size it, and
-- GetWidth answers such a frame's span before the window's scale divides it.
local function BenillaOptionsScroll_Size(sf)
local l, r, t, b = sf:GetLeft(), sf:GetRight(), sf:GetTop(), sf:GetBottom()
if not l or not r or not t or not b then return nil end
return r - l, t - b
end
-- The page's range is the body's overhang, the content Fit measures: the engine's range
-- also counts the Keybindings list's faux scroll child, which is sized to every row.
function BenillaOptionsScroll_Range(sf)
local _, fh = BenillaOptionsScroll_Size(sf)
if not fh then return 0 end
local range = BenillaOptionsFrameContainerBody:GetHeight() - fh
if range > 0 then return range end
return 0
end
-- From OnUpdate, off the previous frame's rects; SetWidth and SetHeight invalidate layout.
function BenillaOptionsScroll_Fit()
local sf = BenillaOptionsFrameContainerScroll
local body = BenillaOptionsFrameContainerBody
local fw, fh = sf:GetWidth(), sf:GetHeight()
local fw, fh = BenillaOptionsScroll_Size(sf)
if not fw or fw <= 0 or not fh or fh <= 0 then return end
if math.abs(body:GetWidth() - fw) >= 0.5 then body:SetWidth(fw) end
local h = fh
@@ -294,6 +312,9 @@
if math.abs(body:GetHeight() - h) >= 0.5 then
body:SetHeight(h)
sf:UpdateScrollChildRect()
-- The bar follows the body, which the range notify misses when the engine's range,
-- a whole-subtree union, does not move.
BenillaScroll_SyncBar(sf)
end
end
@@ -2331,7 +2352,10 @@
</Anchors>
<Scripts>
<!-- The loader fires OnLoad bottom-up, so the body already exists. -->
<OnLoad>this:SetScrollChild(BenillaOptionsFrameContainerBody)</OnLoad>
<OnLoad>
this:SetScrollChild(BenillaOptionsFrameContainerBody)
this.contentRange = BenillaOptionsScroll_Range
</OnLoad>
<OnUpdate>BenillaOptionsScroll_Fit()</OnUpdate>
<!-- An armed Keybindings capsule binds the wheel, search rows included. -->
<OnMouseWheel>
@@ -96,11 +96,18 @@
BenillaScrollBar_UpdateArrows(bar)
end
-- The range the bar follows: the engine's, the reference's union of the child's whole
-- subtree, unless the frame measures its own content (`sf.contentRange`).
function BenillaScroll_Range(sf)
if sf.contentRange then return sf.contentRange(sf) end
return sf:GetVerticalScrollRange()
end
-- Hides the bar when the content fits (reference: ScrollFrame_OnScrollRangeChanged).
function BenillaScroll_SyncBar(sf)
local bar = getglobal(sf:GetName() .. "Bar")
if not bar then return end
local range = sf:GetVerticalScrollRange()
local range = BenillaScroll_Range(sf)
bar:SetValueStep(BENILLA_SCROLL_STEP)
bar:SetMinMaxValues(0, range)
bar:SetValue(sf:GetVerticalScroll())
@@ -111,7 +118,7 @@
-- Clamped here: SetVerticalScroll stores any value, as the reference's does.
function BenillaScroll_Step(sf, delta)
local target = sf:GetVerticalScroll() + delta
local range = sf:GetVerticalScrollRange()
local range = BenillaScroll_Range(sf)
if target > range then target = range end
if target < 0 then target = 0 end
sf:SetVerticalScroll(target)
+214 -50
View File
@@ -162,7 +162,7 @@ fn corpus(
let toc = Toc::parse(&benilla_ui::source::decode(
&std::fs::read(manifest_path(root, n)?).unwrap_or_default(),
));
Some(crate::ui_script::addons::info_from_toc(n, &toc))
Some(crate::ui_script::addons::info_from_toc(n, &toc, false))
})
.collect();
@@ -264,15 +264,10 @@ fn survey_one(
// The addon's dependencies, first and recursively, as `AddOn_Load` (`0x51f240`) does in its
// first two steps. Loaded before `globals_of` below, so a name a dependency provides is not
// missing.
let mut dep_loads: Vec<LoadedDep> = Vec::new();
load_dependencies(
&mut script,
root,
&toc,
installed,
&mut BTreeSet::new(),
&mut dep_loads,
);
// Marked first, as `AddOn_Load` marks an addon before its dependency loops (`0x51f313`): a
// dependency that requires the surveyed addon finds it loaded and does not load it here.
script.mark_addon_loaded(name);
let dep_loads = load_dependencies(&script, root, registry, &toc);
let dep_order: Vec<String> = dep_loads.into_iter().map(|d| d.name).collect();
let known = globals_of(&script);
// The methods the dependency chain defines, read from its source (a method has no VM read).
@@ -289,15 +284,11 @@ fn survey_one(
let warn_mark = script.diagnostics().last().map_or(0, |d| d.seq);
let files = load_addon_files(&script, root, name, &toc);
// The live walk stamps each addon as loaded just before its `ADDON_LOADED` (`0x51f5ad`);
// without it `IsAddOnLoaded` answers nil and a LoadOnDemand dependent gets
// `DEP_NOT_DEMAND_LOADED`. The dependencies stamped themselves in `load_dependencies`.
script.mark_addon_loaded(name);
let wants = missing_calls(root, name, &toc, &known, &dep_methods);
// After the addon's files, so a template its own XML declares is registered.
let missing_templates = missing_templates(&script, root, name, &toc);
let missing_inherits = missing_inherits(&script, root, name, &toc);
let session_errors = drive_session_start(&mut script, name, installed);
let session_errors = drive_session_start(&mut script, name, installed, root, registry);
let probe_errors = drive_ui_probe(&mut script);
// Warnings only; errors have their own columns.
let warnings: Vec<String> = script
@@ -576,6 +567,8 @@ fn drive_session_start(
script: &mut UiScript,
name: &str,
installed: &BTreeSet<String>,
root: &Path,
registry: &[benilla_ui::script::AddOnInfo],
) -> Vec<String> {
// Errors are attributed by the raising chunk, not the event window: AceAddon runs every queued
// consumer's `OnInitialize` on any `ADDON_LOADED` it sees (`AceAddon-2.0.lua:104-105`, `:230`),
@@ -587,6 +580,15 @@ fn drive_session_start(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.to_string())],
);
// `AddOn_Load` goes on to the addons whose `## LoadWith` names this one (`0x51f5b2`-
// `0x51f5e1`), their files and events inside this window; a raise in their own files is
// exempted below by its chunk name, like a dependency's.
if let Some(row) = script.addon_row(name) {
let mut host = HarnessLoad::new(script, root, registry);
for dependent in script.addon_dependents(row) {
script.load_addon_row(&mut host, dependent, false, false);
}
}
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
script.fire_event(event, Vec::new());
}
@@ -726,54 +728,75 @@ fn drive_ui_probe(script: &mut UiScript) -> Vec<String> {
out
}
/// Load an addon's declared dependencies into the VM, depth-first, each at most once.
///
/// The 1.12 client's order (`AddOn_Load`, `0x51f240`): optional dependencies first, failures
/// ignored; required ones next, a failure aborting the dependent; then its own files. The abort is
/// not reproduced, only the state the dependent's file scope meets; a missing required dependency
/// is reported ([`AddonReport::missing_deps`]), an absent optional one is silent. Errors inside a
/// dependency are its own row, never the dependent's.
/// Load an addon's declared dependencies into the VM through the shared `AddOn_Load` driver
/// (`0x51f240`): optional ones first, failures ignored, then required ones, each through the gate,
/// its own dependencies first, its files, then `ADDON_LOADED` with its folder name, then the
/// addons whose `## LoadWith` names it. The surveyed addon's own gate and abort are not
/// reproduced, so a survey still measures it when a required dependency is missing; that is
/// reported ([`AddonReport::missing_deps`]). Errors inside a dependency are its own row, never the
/// dependent's. The caller marks the surveyed addon loaded first (`0x51f313`).
fn load_dependencies(
script: &mut UiScript,
script: &UiScript,
root: &Path,
registry: &[benilla_ui::script::AddOnInfo],
toc: &Toc,
installed: &BTreeSet<String>,
seen: &mut BTreeSet<String>,
loaded: &mut Vec<LoadedDep>,
) {
) -> Vec<LoadedDep> {
// Optional first, then required: `FuBar_BagFu` lists `FuBarPlugin-2.0.lua` before
// `AceLibrary.lua`, and FuBarPlugin raises unless the optional `Ace2` loaded first.
let deps: Vec<&str> = toc
let mut host = HarnessLoad::new(script, root, registry);
for dep in toc
.optional_dependencies()
.into_iter()
.chain(toc.dependencies())
.collect();
for dep in deps {
let key = dep.to_ascii_lowercase();
if !installed.contains(&key) || !seen.insert(key) {
continue; // not installed (already reported), or already in this VM
{
if let Some(row) = script.addon_row(dep) {
// Every addon loads: the harness forces past the version check, as the AddOns screen's
// Load out of date AddOns does.
script.load_addon_row(&mut host, row, false, false);
}
let Some(dep_toc) = manifest_path(root, dep)
}
host.loaded
}
/// The harness's side of `AddOn_Load`: run a registry row's `.toc` files out of the corpus folder
/// and keep what each did. Rows are the corpus's, in [`corpus`]'s order.
struct HarnessLoad<'a> {
script: &'a UiScript,
root: &'a Path,
registry: &'a [benilla_ui::script::AddOnInfo],
/// Every addon the driver ran, in load order.
loaded: Vec<LoadedDep>,
}
impl<'a> HarnessLoad<'a> {
fn new(
script: &'a UiScript,
root: &'a Path,
registry: &'a [benilla_ui::script::AddOnInfo],
) -> Self {
HarnessLoad {
script,
root,
registry,
loaded: Vec::new(),
}
}
}
impl benilla_ui::script::AddonLoad for HarnessLoad<'_> {
fn run(&mut self, i: usize) {
let name = &self.registry[i].name;
let Some(toc) = manifest_path(self.root, name)
.and_then(|p| std::fs::read(p).ok())
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
else {
continue;
return;
};
load_dependencies(script, root, &dep_toc, installed, seen, loaded);
let files = load_addon_files(script, root, dep, &dep_toc);
// `ADDON_LOADED` fires here, at the 1.12 client's position: `AddOn_Load` (`0x51f240`) runs
// an addon's dependencies, its `.toc` files (`0x51f3fa`), `Bindings.xml`, the two
// SavedVariables chunks, then `ADDON_LOADED` (event 429, `0x51f5ad`), before the next
// addon. The loaded flag is set first (`[rec+0x18]=1` at `0x51f313`), so a handler's
// `IsAddOnLoaded` is true. Fired outside the surveyed addon's error mark: a library raising
// in its initialiser is its own row.
script.mark_addon_loaded(dep);
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(dep.to_string())],
);
loaded.push(LoadedDep {
name: dep.to_string(),
// `ADDON_LOADED` follows from the driver, after the files (`0x51f5ad`); a raise in a
// library's initialiser is its own row, outside the surveyed addon's error mark.
let files = load_addon_files(self.script, self.root, name, &toc);
self.loaded.push(LoadedDep {
name: name.clone(),
files,
});
}
@@ -1397,6 +1420,19 @@ struct FileLoad {
/// FrameXML, in the install-relative path space (`Interface/AddOns/<Folder>`, as
/// `ui_script::addons::Addon::prefix`), so chunk names match a live session's.
fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad {
// Under the addon's own context, as the live walk loads it.
let outer = script.swap_exec_context(benilla_ui::script::addon_load_context(
name,
// The corpus is folder addons, never the chain.
crate::ui_script::addons::secure(false, toc),
));
let load = load_addon_files_in_context(script, root, name, toc);
script.swap_exec_context(outer);
load
}
/// [`load_addon_files`]' body.
fn load_addon_files_in_context(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad {
let provider = |req: &str| -> Option<Vec<u8>> { read_under(root, req) };
let base = addon_base(name);
let mut errors = Vec::new();
@@ -2636,6 +2672,134 @@ mod dependency_tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// A folder of addons under a temp root, `(name, toc, lua body)` each with one file `main.lua`;
/// every body starts by appending its folder to `Order`.
fn corpus_folder(tag: &str, addons: &[(&str, &str, &str)]) -> PathBuf {
let tmp =
std::env::temp_dir().join(format!("benilla-harness-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
for (name, toc, body) in addons {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toc")),
format!("## Interface: 11200\n{toc}main.lua\n"),
)
.unwrap();
std::fs::write(
dir.join("main.lua"),
format!("Order = (Order or '') .. '{name},'\n{body}"),
)
.unwrap();
}
tmp
}
/// What one probe step answers, `= <value>`.
fn answer(root: &Path, name: &str, chunk: &str) -> String {
let out = probe::probe(root, name, &[probe::Step::Eval(chunk.into())]).unwrap();
out.answers[0].1.clone()
}
/// `AddOn_Load` fires `ADDON_LOADED` with the record's folder name (`[rec+0x14]`, `0x51f573`,
/// `0x51f5a2`), whatever case the dependent's `.toc` spelled it in.
#[test]
fn a_dependencys_addon_loaded_carries_its_folder_name() {
let tmp = corpus_folder(
"deps-spelling",
&[
(
"Lib",
"",
"Seen = {} Watch = CreateFrame('Frame') Watch:RegisterEvent('ADDON_LOADED') \
Watch:SetScript('OnEvent', function() table.insert(Seen, arg1) end)",
),
("User", "## Dependencies: LIB\n", ""),
],
);
assert_eq!(
answer(&tmp, "User", "return table.concat(Seen, ',')"),
"= Lib,User"
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// The gate runs before anything loads (`0x51f2fa`): a dependency that requires a missing
/// addon does not load, its files do not run, and its own dependencies are left alone.
#[test]
fn a_dependency_the_gate_refuses_does_not_load() {
let tmp = corpus_folder(
"deps-gate",
&[
("Blocked", "## Dependencies: Ghost\n", ""),
("User", "## Dependencies: Blocked\n", ""),
],
);
assert_eq!(answer(&tmp, "User", "return Order"), "= User,");
assert_eq!(
answer(&tmp, "User", "return IsAddOnLoaded('Blocked')"),
"= nil"
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// `AddOn_Load` loads the addons whose `## LoadWith` names the loaded one, right after its
/// `ADDON_LOADED` (`0x51f5b2`).
#[test]
fn a_dependencys_load_with_dependents_load_after_it() {
let tmp = corpus_folder(
"deps-load-with",
&[
("Lib", "", ""),
("Tag", "## LoadWith: Lib\n", ""),
("User", "## Dependencies: Lib\n", ""),
],
);
assert_eq!(answer(&tmp, "User", "return Order"), "= Lib,Tag,User,");
let _ = std::fs::remove_dir_all(&tmp);
}
/// The surveyed addon's own `AddOn_Load` ends with the addons that name it in `## LoadWith`
/// (`0x51f5b2`-`0x51f5e1`): they load right after its `ADDON_LOADED`, before the session events.
#[test]
fn the_surveyed_addons_load_with_dependents_load_after_its_addon_loaded() {
let tmp = corpus_folder(
"deps-own-load-with",
&[
("Lib", "", ""),
(
"Tag",
"## LoadWith: User\n",
"TagSawUser = IsAddOnLoaded('User')",
),
("User", "## Dependencies: Lib\n", ""),
],
);
assert_eq!(answer(&tmp, "User", "return Order"), "= Lib,User,Tag,");
assert_eq!(answer(&tmp, "User", "return TagSawUser"), "= 1");
let _ = std::fs::remove_dir_all(&tmp);
}
/// The surveyed addon is loaded (`0x51f313`) before its dependencies, so one that requires it
/// back finds it loaded and neither runs twice.
#[test]
fn a_dependency_cycle_through_the_surveyed_addon_loads_each_once() {
let tmp = corpus_folder(
"deps-cycle",
&[
("Ping", "## Dependencies: Pong\n", ""),
(
"Pong",
"## Dependencies: Ping\n",
"PingLoadedForPong = IsAddOnLoaded('Ping')\n",
),
],
);
assert_eq!(answer(&tmp, "Ping", "return Order"), "= Pong,Ping,");
assert_eq!(answer(&tmp, "Ping", "return PingLoadedForPong"), "= 1");
let _ = std::fs::remove_dir_all(&tmp);
}
/// An indexed frame (`ColorPickerFrame.func`, `ColorPickerFrame:SetColorRGB`) is a missing
/// surface, not a missing function.
#[test]
+5 -15
View File
@@ -6,7 +6,6 @@
//! after `PLAYER_ENTERING_WORLD` and the ticks, before the render and use probes touch the VM.
//! An eval can mutate the VM, so a probe is a debugger; the survey is the measurement.
use std::collections::BTreeSet;
use std::path::Path;
use benilla_ui::script::UiScript;
@@ -71,25 +70,16 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
script.set_screen_size(1024.0, 768.0);
// No saved-variable roots, so a probe never touches a player's saved variables; the AddOns
// root is passed as the survey passes it, so `LoadAddOn` resolves.
script.register_addons(registry, Some(root.to_path_buf()), None, None);
script.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
let mut dep_order: Vec<super::LoadedDep> = Vec::new();
super::load_dependencies(
&mut script,
root,
&toc,
&installed,
&mut BTreeSet::new(),
&mut dep_order,
);
// Marked before its dependencies load (`0x51f313`), as the survey does.
script.mark_addon_loaded(name);
let _ = super::load_dependencies(&script, root, &registry, &toc);
let load_errors = super::load_addon_files(&script, root, name, &toc).errors;
// The registry must agree with the VM, or `IsAddOnLoaded` answers for another session; the
// dependency chain stamped itself as it loaded.
script.mark_addon_loaded(name);
let session_errors = super::drive_session_start(&mut script, name, &installed);
let session_errors = super::drive_session_start(&mut script, name, &installed, root, &registry);
let answers = steps
.iter()
@@ -16,15 +16,11 @@
//! `LoadLoadOnDemandPlugins`, Auctioneer's stub), so honouring the flag would model a shorter
//! session than anyone plays.
use std::collections::BTreeSet;
use std::path::Path;
use benilla_ui::script::UiScript;
use super::{
corpus, load_addon_files, load_dependencies, manifest_path, LoadedDep, ADDON_INSTRUCTION_BUDGET,
};
use benilla_ui::toc::Toc;
use super::{corpus, HarnessLoad, ADDON_INSTRUCTION_BUDGET};
/// How many fresh-VM runs [`survey_together`] makes: three is the fewest that tell "always" from
/// "sometimes".
@@ -88,47 +84,33 @@ pub fn survey_together(root: &Path) -> Vec<TogetherRow> {
/// One walk in one VM.
fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
let (names, installed, registry) = corpus(root);
let (names, _, registry) = corpus(root);
let Ok(mut script) = UiScript::new() else {
return Vec::new();
};
script.set_screen_size(1024.0, 768.0);
script.register_addons(registry, Some(root.to_path_buf()), None, None);
script.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// `seen` spans the whole walk: a library declared by many dependents loads once.
let mut seen: BTreeSet<String> = BTreeSet::new();
// `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256
// distinct rows and evicts.
let mut load_errors: Vec<(String, Vec<String>)> = Vec::new();
for name in &names {
let Some(toc) = manifest_path(root, name)
.and_then(|p| std::fs::read(p).ok())
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
else {
continue;
};
if !seen.insert(name.to_ascii_lowercase()) {
continue; // already loaded as a dependency
}
// The live walk: `AddOn_Load` on every addon in order, through the same driver, so a
// library loads once under whichever dependent reaches it first and an addon that names
// another in `## LoadWith` loads right after it. The corpus is walked whole, LoadOnDemand
// included, and past the version check.
let mut host = HarnessLoad::new(&script, root, &registry);
for row in 0..registry.len() {
// Re-armed per addon, as the live walk does, so one runaway does not starve the rest.
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
let mut pulled: Vec<LoadedDep> = Vec::new();
load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled);
// A dependency's load failures are collected only here: it loads once, under the first
// dependent to reach it, and never gets a turn of its own.
for dep in pulled {
load_errors.push((dep.name, dep.files.errors));
}
let files = load_addon_files(&script, root, name, &toc);
load_errors.push((name.clone(), files.errors));
script.mark_addon_loaded(name);
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.clone())],
);
script.load_addon_row(&mut host, row, false, false);
}
// `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256
// distinct rows and evicts. A dependency's failures are collected here too: it loads once and
// never gets a turn of its own.
let load_errors: Vec<(String, Vec<String>)> = host
.loaded
.into_iter()
.map(|l| (l.name, l.files.errors))
.collect();
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
script.fire_event(event, Vec::new());
@@ -24,9 +24,9 @@ use super::render::{Painted, RenderBaseline};
/// it, in painter order, are dropped, so a broken ninth widget reads clean.
pub const MAX_USE_TARGETS: usize = 8;
/// How far the drag travels: past `cursor::DRAG_START_THRESHOLD` (4 px, strict `>`) so
/// `OnDragStart` fires, and inside a 16 px item slot so the release lands on the same frame.
const DRAG_PX: f32 = 5.0;
/// How far the drag travels: past the drag-start distance (0.01 of the screen diagonal, 12.8 root
/// units at the 1024×768 these runs use) so `OnDragStart` fires.
const DRAG_PX: f32 = 14.0;
/// Where the cursor is parked at the end, off-screen so the last `OnLeave` always fires.
const PARKED: (f32, f32) = (-10_000.0, -10_000.0);
@@ -90,7 +90,7 @@ fn the_use_column_can_fail() {
"#,
),
);
// `OnDragStart` fires only past the 4 px threshold, so the drag must actually move.
// `OnDragStart` fires only past the drag-start distance, so the drag must actually move.
fx.addon(
"RaisesOnDrag",
&painted_button(
+24 -4
View File
@@ -219,10 +219,7 @@ fn feed_zone_events(
subzone: signal.subzone_text.clone(),
minimap: minimap_text.clone(),
pvp_type: pvp.map(|(ty, _)| ty.to_string()),
pvp_faction: pvp
.map(|(_, faction)| faction)
.filter(|f| !f.is_empty())
.map(str::to_string),
pvp_faction: pvp_faction_text(pvp),
is_arena,
});
@@ -245,6 +242,14 @@ fn feed_zone_events(
cache.wmo_group = wmo_group;
}
/// `GetZonePVPInfo`'s second value: with a type answered, the faction name is always a string,
/// starting as the empty string (`0x48d633`, `0x882748`) and pushed by `lua_pushstring`
/// (`0x48d68f`) whether or not a FactionGroup row supplied one; only the bail (`0x48d6b5`) pushes
/// nil.
fn pvp_faction_text(pvp: Option<(&str, &str)>) -> Option<String> {
pvp.map(|(_, faction)| faction.to_string())
}
/// `GetZonePVPInfo` (`0x48d540`): `(pvpType, isArena)`. isArena is the leaf's Flags `0x80`, read
/// before any bail. The type is nil with no zone row, no faction template, or the display gate
/// closed: it opens on a PvP realm ([`RealmPvp`]) or in a zone whose Flags carry `0x10`, the six
@@ -433,6 +438,21 @@ mod tests {
assert_eq!(pvp(true, &orgrimmar), Some("hostile"));
}
/// An answered type always carries a faction string, `""` in an ownerless zone such as
/// Stranglethorn Vale (`0x48d633`); no type, no string (`0x48d6b5`).
#[test]
fn an_answered_type_carries_an_empty_faction_name_not_nil() {
assert_eq!(
pvp_faction_text(Some(("contested", ""))),
Some(String::new())
);
assert_eq!(
pvp_faction_text(Some(("friendly", "Alliance"))),
Some("Alliance".to_string())
);
assert_eq!(pvp_faction_text(None), None);
}
/// isArena is the leaf's `0x80`, read before any bail, so a closed gate keeps it.
#[test]
fn is_arena_survives_a_closed_gate() {
+303 -15
View File
@@ -29,7 +29,7 @@ use bevy::input::ButtonState;
use bevy::prelude::*;
use benilla_ui::script::keybind::KeybindRequest;
use benilla_ui::script::UiScript;
use benilla_ui::script::{KeyUp, KeyWalk, UiScript};
use crate::char_select::InWorldGated;
use crate::ui_script::{PlayerUiHover, PointerOverUiPanel, UiKeyboardCapture};
@@ -93,7 +93,46 @@ pub(crate) struct BindingsState {
/// Normalized like [`latched`](Self::latched) and reconciled against `ButtonInput` every
/// pass, so a window deactivate empties it and a key held across an alt-tab resumes, as in
/// the reference. Keyboard only: mouse edges cannot repeat.
down: Vec<KeyCode>,
down: KeyDownList,
}
/// The keys believed down, the reference's own list (`0x4248b3`): a press of a key on it is an
/// auto-repeat, whatever the OS flagged. One instance is the bindings', another the UI key feed's
/// ([`crate::ui_script`]), which decides repeat for the frames by the same rule and reconcile.
#[derive(Default)]
pub(crate) struct KeyDownList(Vec<KeyCode>);
impl KeyDownList {
/// Drop what is no longer physically down, per bevy's button plane: a window deactivate
/// empties it (the reference's `0x424790` wipe), so the first repeat after an alt-tab is a
/// fresh press and re-latches.
pub(crate) fn reconcile(&mut self, keys: &ButtonInput<KeyCode>) {
self.0.retain(|&kc| key_physically_down(kc, keys));
}
/// Record a press; whether the key was already down, which makes it a repeat.
pub(crate) fn press(&mut self, key: KeyCode) -> bool {
let key = chord::normalize_key(key);
let repeat = self.0.contains(&key);
if !repeat {
self.0.push(key);
}
repeat
}
pub(crate) fn release(&mut self, key: KeyCode) {
let key = chord::normalize_key(key);
self.0.retain(|&kc| kc != key);
}
}
/// `ENTER` is down while either physical key is, since [`chord::normalize_key`] folds
/// `NUMPADENTER` into it.
fn key_physically_down(kc: KeyCode, keys: &ButtonInput<KeyCode>) -> bool {
match kc {
KeyCode::Enter => keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter),
kc => keys.pressed(kc),
}
}
/// The physical input a latch waits on: a keyboard key by its code, not its name, so its release
@@ -392,9 +431,7 @@ fn latch_and_dispatch(
// ── The pressed-key reconcile ── against bevy's button planes, before this frame's messages:
// a release the window never saw drops off the list, and a window deactivate empties it, the
// reference's `0x424790` wipe, so a key held across an alt-tab re-latches on its next repeat.
state
.down
.retain(|&kc| physically_down(Held::Key(kc), &keys, &buttons));
state.down.reconcile(&keys);
// ── Keyboard ── press edges run their command, gated on ownership and the capture arm;
// release edges unlatch and run the `runOnUp` up-half.
@@ -413,15 +450,17 @@ fn latch_and_dispatch(
| KeyCode::ArrowUp
| KeyCode::ArrowDown
);
// The box declines PRINTSCREEN before it looks at focus (`0x77b17f`), so the
// walk goes on and the world frame's binding runs while typing.
let arrow_exempt = arrow_exempt
|| chord::key_token(ev.key_code, &layout)
== Some(chord::KeyName::Word("PRINTSCREEN"));
// A keyboard frame ate this one key (the world map's `OnKeyDown`, a cinematic);
// per key, so every other binding is untouched.
let eaten = capture.consumed.contains(&ev.key_code);
// A repeat is a key we already believe is down, the reference's test. Recorded
// before the gates below, so a key held through a focused box stays down.
let repeat = state.down.contains(&key);
if !repeat {
state.down.push(key);
}
let repeat = state.down.press(key);
// `sup`: the Super deviation (module doc).
if (typing && !arrow_exempt) || eaten || sup || repeat {
continue;
@@ -441,12 +480,44 @@ fn latch_and_dispatch(
key: BindKey::Key(name),
};
if let Some(command) = dispatch.resolve(chord, dev_plane) {
// The world frame's key-down ran it and returned that (`0x483b70`), so its
// key-up and repeats are the world frame's, not a stale earlier consumer's.
if let Some(s) = script.as_mut() {
s.note_world_key(&name.to_string());
}
press(&mut state, &mut script, command, Held::Key(key));
}
}
ButtonState::Released => {
state.down.retain(|&kc| kc != key);
state.down.release(key);
release(&mut state, &mut script, Held::Key(key));
// The release, to the frame that took the press (`0x765fd0`), here and not in the
// key feed so it follows this key's press in event order: a binding's press has
// just made the world frame its consumer. PRINTSCREEN arrives as an up alone on
// Windows (`VK_SNAPSHOT` sends no `WM_KEYDOWN`), and with no consumer on record the
// client runs the key-down walk on it (`0x76603c`-`0x76604d`), the world frame's
// binding last: the up then runs the chord as a press, with no latch since no
// release follows.
let Some(name) = chord::walk_name(ev.key_code, &layout) else {
continue;
};
let Some(s) = script.as_mut() else {
continue;
};
if s.key_up(&name) == KeyUp::Walked(KeyWalk::Declined) && !sup {
if let Some(token) = chord::key_token(ev.key_code, &layout) {
let chord = Chord {
alt,
ctrl,
shift,
key: BindKey::Key(token),
};
if let Some(command) = dispatch.resolve(chord, dev_plane) {
press(&mut state, &mut script, command, Held::Key(key));
state.latched.retain(|(k, _)| *k != Held::Key(key));
}
}
}
}
}
}
@@ -539,10 +610,7 @@ fn physically_down(
buttons: &ButtonInput<MouseButton>,
) -> bool {
match key {
Held::Key(KeyCode::Enter) => {
keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter)
}
Held::Key(kc) => keys.pressed(kc),
Held::Key(kc) => key_physically_down(kc, keys),
Held::Mouse(b) => buttons.pressed(b),
// A notch is a press and a release in one frame; it is never "held".
Held::Wheel => false,
@@ -733,7 +801,7 @@ mod tests {
}
fn with_addon(bindings: &str) -> UiScript {
let mut script = core_script();
let script = core_script();
script.register_addon_bindings(
"ProbeAddon",
&benilla_ui::bindings_xml::parse(bindings).expect("well-formed"),
@@ -839,6 +907,67 @@ mod tests {
);
}
/// The box declines PRINTSCREEN before it looks at focus (`0x77b17f`), so the walk goes on and
/// the binding runs while typing, with every other key still eaten.
#[test]
fn printscreen_runs_its_binding_while_a_box_has_focus() {
let script = with_addon(PROBE_BINDINGS);
script
.run(r#"SetBinding("PRINTSCREEN", "PROBEEDGE"); SetBinding("G", "PROBEHOLD")"#)
.expect("bind");
let mut app = vm_harness(script);
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = true;
press_key(&mut app, KeyCode::KeyG);
app.update();
assert_eq!(lua_count(&app, "PROBE_DOWN"), 0, "G is eaten by the box");
press_key(&mut app, KeyCode::PrintScreen);
app.update();
assert_eq!(
lua_count(&app, "PROBE_EDGE"),
1,
"PRINTSCREEN is declined, so its binding runs"
);
}
/// A press that resolved to a binding is the world frame's (`0x483b70`): the key's up goes to
/// it, not to the hidden frame that took the key's last press.
#[test]
fn a_press_that_runs_a_binding_makes_the_world_frame_its_consumer() {
let script = with_addon(PROBE_BINDINGS);
script
.run(
r#"
SetBinding("G", "PROBEEDGE")
CreateFrame("WorldFrame", "WorldFrame")
ups = 0
stale = CreateFrame("Frame", "StaleG")
stale:EnableKeyboard(true)
stale:SetScript("OnKeyDown", function() end)
stale:SetScript("OnKeyUp", function() ups = ups + 1 end)
"#,
)
.expect("bind");
let mut app = vm_harness(script);
{
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
assert!(s.key_input("G"), "the frame takes G first");
s.run("stale:Hide()").unwrap();
}
press_key(&mut app, KeyCode::KeyG);
app.update();
assert_eq!(
lua_count(&app, "PROBE_EDGE"),
1,
"the binding took the press"
);
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.run("stale:Show()").unwrap();
assert!(s.key_up("G").taken());
assert_eq!(s.eval::<i64>("return ups").unwrap(), 0);
}
/// A box taking focus releases nothing already held: the reference's focused box freezes the
/// direction bits rather than clearing them (`0x514490` is the OS window-deactivate clear).
#[test]
@@ -1002,6 +1131,165 @@ mod tests {
);
}
/// The UI key feed beside the dispatch, as the world runs them: the feed first, on the same
/// events, with the world frame in the walk.
fn feed_and_dispatch_harness() -> App {
fn feed(
mut script: NonSendMut<UiScript>,
mut capture: ResMut<UiKeyboardCapture>,
mut events: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
layout: Res<LayoutNames>,
mut clipboard: NonSendMut<crate::textinput::HostClipboard>,
mut down: Local<KeyDownList>,
) {
capture.consumed.clear();
crate::ui_script::feed_keys(
&mut script,
&mut capture,
events.read(),
&keys,
&mut down,
false,
crate::textinput::Mods {
shift: false,
ctrl: false,
alt: false,
sup: false,
},
&layout,
&mut clipboard,
None,
);
}
let script = core_script();
script
.run(r#"CreateFrame("WorldFrame", "WorldFrame")"#)
.expect("world frame");
let mut app = vm_harness(script);
app.insert_non_send_resource(crate::textinput::HostClipboard::holding(""))
.add_systems(Update, feed.before(latch_and_dispatch));
app
}
/// A key held across an alt-tab resumes at its next OS repeat even when its binding made the
/// world frame its consumer: the feed decides repeat from the same wiped pressed-key list
/// (`0x424790`), not from the OS flag, so the repeat is a fresh press for both and W runs
/// again (`0x4248b3`).
#[test]
fn a_held_movement_key_resumes_after_an_alt_tab_through_the_feed_too() {
let mut app = feed_and_dispatch_harness();
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(Input::MoveForward));
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release_all();
app.update();
assert!(
!state(&app).pressed(Input::MoveForward),
"the deactivate releases the direction bits"
);
repeat_key(&mut app, KeyCode::KeyW);
app.update();
assert!(
state(&app).pressed(Input::MoveForward),
"the first repeat after re-activation re-latches"
);
assert!(
!app.world()
.resource::<UiKeyboardCapture>()
.consumed
.contains(&KeyCode::KeyW),
"…the feed did not hand the repeat to the world frame's stub"
);
// A real repeat of the key now held is the stub's, and eaten by nobody's binding.
repeat_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(Input::MoveForward));
}
/// A press and its release in one frame's batch reach the dispatch in event order: the release
/// follows the press that made the world frame the consumer, so it is not sent on to the frame
/// that took the key's last press.
#[test]
fn a_press_and_a_release_in_one_batch_go_to_the_world_frame_not_a_stale_consumer() {
let mut app = feed_and_dispatch_harness();
{
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.run(
r#"
ups = 0
stale = CreateFrame("Frame", "StaleBatch")
stale:EnableKeyboard(true)
stale:SetScript("OnKeyDown", function() end)
stale:SetScript("OnKeyUp", function() ups = ups + 1 end)
"#,
)
.unwrap();
assert!(s.key_input("W"), "the frame takes W once");
s.run("stale:EnableKeyboard(false)").unwrap();
}
press_key(&mut app, KeyCode::KeyW);
release_key(&mut app, KeyCode::KeyW);
app.update();
let s = app.world().non_send_resource::<UiScript>();
assert_eq!(s.eval::<i64>("return ups").unwrap(), 0);
}
/// PRINTSCREEN reaches Windows as an up alone (`VK_SNAPSHOT` sends no `WM_KEYDOWN`). With no
/// consumer on record the client runs the key-down walk on the up (`0x76603c`-`0x76604d`), the
/// world frame's binding last, so the binding runs on the release; a frame that takes the walk
/// keeps it from the binding; and a platform that sends both halves runs it once.
#[test]
fn printscreen_as_an_up_alone_runs_its_binding_through_the_key_down_walk() {
let script = with_addon(PROBE_BINDINGS);
script
.run(
r#"SetBinding("PRINTSCREEN", "PROBEEDGE") CreateFrame("WorldFrame", "WorldFrame")"#,
)
.expect("bind");
let mut app = vm_harness(script);
release_key(&mut app, KeyCode::PrintScreen);
app.update();
assert_eq!(lua_count(&app, "PROBE_EDGE"), 1, "the up alone runs it");
// A frame that takes the key-down walk keeps it from the binding.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(
r#"
got = 0
cover = CreateFrame("Frame", "PrintCover")
cover:EnableKeyboard(true)
cover:SetScript("OnKeyDown", function() got = got + 1 end)
"#,
)
.unwrap();
release_key(&mut app, KeyCode::PrintScreen);
app.update();
assert_eq!(lua_count(&app, "got"), 1, "the frame took the walk");
assert_eq!(
lua_count(&app, "PROBE_EDGE"),
1,
"…so the binding did not run"
);
// A platform that sends both halves: the press ran it and made the world frame the
// consumer, so the up is delivered, not walked again.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("cover:Hide()")
.unwrap();
press_key(&mut app, KeyCode::PrintScreen);
release_key(&mut app, KeyCode::PrintScreen);
app.update();
assert_eq!(lua_count(&app, "PROBE_EDGE"), 2, "…once, not twice");
}
/// An addon's bindings that call the movement functions: a Start and a Stop alone.
const MOVE_BINDINGS: &str = r#"<Bindings>
<Binding name="PROBEGO">MoveForwardStart()</Binding>
+26
View File
@@ -113,6 +113,32 @@ pub(crate) fn key_token(k: KeyCode, layout: &LayoutNames) -> Option<KeyName> {
}
}
/// The name the key-down walk's `arg1` carries for a press (`0x76b7d0`), `None` for a key the
/// client drops before any walk: the modifiers go by their own names (`0`, `1`, `2` of the table,
/// `SHIFT`, `CTRL`, `ALT` at `0x846e80`, `0x846e88`, `0x846e90`), and `UNKNOWN` is the name of the
/// codes the table cannot name, `0x210` and `0x211` (ScrollLock and Pause, and F14 and F15 on a
/// Mac). Every other key without a name never reaches the walk: the Windows key table drops it
/// where `MapVirtualKeyA` answers 0 (`0x42da39`: the Windows and Menu keys, media keys, a key the
/// layout drops), the Mac table `0x5bf320` has -1 for Command and fn. Unlike [`key_token`], which
/// is a binding's, a modifier still gets a down and an up.
pub(crate) fn walk_name(k: KeyCode, layout: &LayoutNames) -> Option<String> {
if let Some(name) = key_token(k, layout) {
return Some(name.to_string());
}
Some(
match k {
KeyCode::ShiftLeft | KeyCode::ShiftRight => "SHIFT",
KeyCode::ControlLeft | KeyCode::ControlRight => "CTRL",
KeyCode::AltLeft | KeyCode::AltRight => "ALT",
KeyCode::ScrollLock | KeyCode::Pause => "UNKNOWN",
#[cfg(target_os = "macos")]
KeyCode::F14 | KeyCode::F15 => "UNKNOWN",
_ => return None,
}
.to_string(),
)
}
/// The fixed half of `0x42d800` and the Mac table: every key no layout names
/// ([`super::layout::position`]).
fn fixed_name(k: KeyCode) -> Option<KeyName> {
@@ -948,6 +948,8 @@ pub(super) fn seed_ui_fixture(
"mouseover",
Some(benilla_ui::script::UnitState {
exists: true,
// A unit is found by its guid; the builder hides for guid 0 (`0x52a04e`).
guid: 0xF130_0000_0044_0001,
name: Some("Stormwind Guard".into()),
health: 38,
max_health: 50,
@@ -975,6 +977,7 @@ pub(super) fn seed_ui_fixture(
"mouseover",
Some(benilla_ui::script::UnitState {
exists: true,
guid: 0x0000_0000_0000_0B0B,
is_player: true,
name: Some("Bob".into()),
level: 60,
@@ -1708,6 +1711,7 @@ fn seed_cooldown_filmstrip(
texture: texture.clone(),
count: 1,
quality: Some(1),
inventory_type: 0,
item_id: 6948,
link: Some("|cffffffff|Hitem:6948|h[Hearthstone]|h|r".into()),
locked: false,
@@ -1760,6 +1764,7 @@ fn seed_bag_window(
texture: icon(disp),
count,
quality: Some(quality),
inventory_type: 0,
item_id: id,
link: Some(format!("|cffffffff|Hitem:{id}|h[{name}]|h|r")),
locked: false,
@@ -1846,6 +1851,7 @@ fn seed_equipped_bags(
texture: icon(disp),
count,
quality: Some(quality),
inventory_type: 0,
item_id: 0,
link: Some(format!("|cffffffff|Hitem:0|h[{name}]|h|r")),
locked: false,
+40 -2
View File
@@ -113,9 +113,34 @@ pub(crate) use probes::{
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
JitterMeterPlugin, LiftCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin,
ProbeChatPlugin, ProbeClock, ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin,
ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin,
SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin,
ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
RevealAuditPlugin, SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin,
};
/// A probe that reads the native screens' rosters (the park's own, and the one re-sent after a
/// create or delete) cannot run under `WOW_GLUE_STOCK=1`, where the screen asks for its own and the
/// probe would sit until its timeout. Refuse it where it is requested: an error at plugin build,
/// and the exit of a driverless run ([`crate::run_mode::fatal_when_driverless`]), at the first
/// frame. `true` means the probe must not install.
pub(crate) fn refuse_under_stock(app: &mut App, probe: &'static str) -> bool {
refuse_if_stock(app, probe, crate::ui_script::glue::GlueStock::from_env().0)
}
fn refuse_if_stock(app: &mut App, probe: &'static str, stock: bool) -> bool {
if !stock {
return false;
}
error!(
"probe: {probe} reads the native screens' rosters and cannot run under WOW_GLUE_STOCK=1; \
unset one of them"
);
if crate::run_mode::fatal_when_driverless(&format!("{probe} under WOW_GLUE_STOCK=1")) {
app.add_systems(Startup, |mut exit: MessageWriter<AppExit>| {
exit.write(AppExit::error());
});
}
true
}
pub(crate) use scenarios::ui_opted_in;
use scenarios::GlueScreen;
use scenarios::{
@@ -1083,3 +1108,16 @@ fn drive_capture(
}
}
}
#[cfg(test)]
mod refuse_tests {
use super::*;
/// A native-only probe installs under the native screens and is refused under the stock ones.
#[test]
fn a_native_only_probe_is_refused_under_the_stock_screens() {
let mut app = App::new();
assert!(!refuse_if_stock(&mut app, "WOW_RIG", false));
assert!(refuse_if_stock(&mut app, "WOW_RIG", true));
}
}
@@ -21,6 +21,9 @@ impl Plugin for ProbeCharCreatePlugin {
else {
return; // inert without a valid env spec
};
if super::refuse_under_stock(app, "WOW_PROBE_CHARCREATE") {
return;
}
let keep = std::env::var("WOW_PROBE_CHARCREATE_KEEP").as_deref() == Ok("1");
app.insert_resource(CharCreateProbe {
req,
@@ -72,6 +72,26 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
// First fire at `_AT` seconds, re-armed `_AGAIN` seconds into every later world entry.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_GLUE_LUA",
purpose: "\"<chunk>\" — under WOW_GLUE_STOCK=1, run a Lua chunk once in the stock glue VM (press the login screen's buttons headlessly)",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_GLUE_LUA_AT",
purpose: "<secs> — when the glue chunk runs (default 10)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_GLUE_SHOT",
purpose: "<path> — one PNG after the glue chunk runs",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_GLUE_SHOT_AFTER",
purpose: "<secs> — how long after the glue chunk the shot is taken (default 1)",
wall_clock: false,
},
ProbeVar {
name: "WOW_PROBE_LUA_AT",
purpose: "<secs> — when the chunk runs after the first world entry (default 10)",
@@ -73,6 +73,9 @@ impl Plugin for ProbeRigPlugin {
else {
return; // inert without a parseable spec (parse() has already said why)
};
if super::refuse_under_stock(app, "WOW_RIG") {
return;
}
info!("rig: {}", spec.describe());
// Publish the claim on the character pick, so the roster knows the pick is spoken for.
if let Some(name) = rig_char_name(&spec) {
@@ -365,6 +365,97 @@ fn fire_probe_lua(
}
}
/// `WOW_PROBE_GLUE_LUA="<chunk>"`: under `WOW_GLUE_STOCK=1`, runs the chunk once in the stock
/// glue VM `WOW_PROBE_GLUE_LUA_AT` seconds (default 10) into the run, to press the login screen's
/// buttons headlessly; `WOW_PROBE_GLUE_SHOT=<path>` then saves one PNG
/// `WOW_PROBE_GLUE_SHOT_AFTER` seconds (default 1) later. The chunk is never logged: it may carry
/// a typed password.
pub(crate) struct ProbeGlueLuaPlugin;
impl Plugin for ProbeGlueLuaPlugin {
fn build(&self, app: &mut App) {
let secs = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
app.insert_resource(ProbeGlueLua {
chunk: std::env::var("WOW_PROBE_GLUE_LUA").unwrap_or_default(),
at: secs("WOW_PROBE_GLUE_LUA_AT", 10.0),
shot: std::env::var("WOW_PROBE_GLUE_SHOT").ok(),
shot_after: secs("WOW_PROBE_GLUE_SHOT_AFTER", 1.0),
ran_at: None,
shot_done: false,
})
.add_systems(Update, fire_probe_glue_lua.after(crate::ui_script::UiInput));
}
}
#[derive(Resource)]
struct ProbeGlueLua {
chunk: String,
at: f32,
shot: Option<String>,
shot_after: f32,
ran_at: Option<f32>,
shot_done: bool,
}
/// The log line for a failed glue chunk: its kind alone. The error's text can quote the chunk (a
/// syntax error's `near '<token>'`, a chunk named by its own source), and the chunk may carry a
/// typed password.
fn glue_chunk_failure(e: &dyn std::fmt::Display) -> String {
let text = e.to_string();
let kind = if text.starts_with("syntax error") {
"a syntax error"
} else if text.starts_with("runtime error") {
"a runtime error"
} else if text.starts_with("callback error") {
"an error in a callback"
} else {
"an error"
};
format!("probe-glue-lua: the chunk failed with {kind}")
}
/// Runs the chunk once the glue VM stands and its time has come, then the one shot.
fn fire_probe_glue_lua(
mut probe: ResMut<ProbeGlueLua>,
time: ProbeClock,
script: Option<NonSend<benilla_ui::script::UiScript>>,
mut commands: Commands,
) {
let now = time.elapsed_secs();
match probe.ran_at {
None => {
let Some(script) = script.filter(|s| s.vm_kind() == benilla_ui::script::VmKind::Glue)
else {
return;
};
if now < probe.at {
return;
}
probe.ran_at = Some(now);
info!("probe-glue-lua: running the chunk at {now:.1}s");
if let Err(e) = script.run(&probe.chunk) {
error!("{}", glue_chunk_failure(&e));
}
}
Some(ran) if !probe.shot_done && now >= ran + probe.shot_after => {
probe.shot_done = true;
if let Some(out) = probe.shot.clone() {
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(out.clone()));
info!("probe-glue-lua: shot writing {out}");
}
}
_ => {}
}
}
/// `WOW_PROBE_HOVER="Frame1;Frame2;…"`: moves the pointer across each named frame's centre
/// through the real pointer path, pressing nothing, from `WOW_PROBE_HOVER_AT` (default 14 s),
/// one step per `WOW_PROBE_HOVER_STEP` (default 0.25 s; ~0.016 moves every frame), until exit.
@@ -725,3 +816,28 @@ mod tests {
);
}
}
#[cfg(test)]
mod glue_chunk_tests {
/// A failing chunk is logged by its kind, never its text: a syntax error quotes its token.
#[test]
fn a_failed_glue_chunk_never_echoes_its_text() {
let s = benilla_ui::script::UiScript::new_glue().unwrap();
for (chunk, kind) in [
("x = 1 'SECRET'", "a syntax error"),
("error('SECRET')", "a runtime error"),
("SECRET()", "a runtime error"),
] {
let e = s.run(chunk).unwrap_err();
assert!(
e.to_string().contains("SECRET"),
"the raw error quotes the chunk: {e}"
);
let line = super::glue_chunk_failure(&e);
assert_eq!(
line,
format!("probe-glue-lua: the chunk failed with {kind}")
);
}
}
}
+2 -1
View File
@@ -9,7 +9,8 @@ mod fx_draw_census;
pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin;
mod act;
pub(crate) use act::{
ProbeChatPlugin, ProbeDragPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
ProbeChatPlugin, ProbeDragPlugin, ProbeGlueLuaPlugin, ProbeHoverPlugin, ProbeKeyPlugin,
ProbeLuaPlugin,
};
/// The probe run's shell: its bounded lifetime and its window's level, parking and size.
+4 -1
View File
@@ -47,7 +47,10 @@ pub(super) fn seed_realm_list(
}
match *step {
0 => {
lists.write(crate::net::RealmListMessage { realms: fixture() });
lists.write(crate::net::RealmListMessage {
realms: fixture(),
failed: false,
});
}
2 => crate::realm_select::open_over_char_select(&mut realms, &choice),
3.. => return,
+7 -7
View File
@@ -20,7 +20,7 @@ use crate::char_select::{ClientState, Roster};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use crate::net::{CharActionResultMessage, CharPick, CharRequest};
use crate::portrait::{CreateLook, GlueLook, GluePreview};
use crate::portrait::{CreateLook, GluePreview};
use crate::sound::GlueSound;
/// Alliance and Horde race columns, top to bottom: `CharacterCreateEnumerateRaces` fills buttons
@@ -71,11 +71,11 @@ impl Plugin for CharCreatePlugin {
Update,
(
screen::rescale_screen,
create_input,
create_input.run_if(crate::ui_script::glue::native_glue),
debug_auto_create,
debug_pick,
debug_shot,
rotate_model,
rotate_model.run_if(crate::ui_script::glue::native_glue),
refresh::refresh_dynamic,
refresh_name_box,
refresh::refresh_hover,
@@ -86,6 +86,8 @@ impl Plugin for CharCreatePlugin {
)
.chain()
.before(crate::glue::GlueVisuals)
// The create screen writes the glue booth's preview.
.in_set(crate::portrait::GluePreviewFeed)
.run_if(in_state(ClientState::CharCreate))
.after(benilla_world::schedule::WorldStage::Net),
);
@@ -188,8 +190,7 @@ fn debug_pick(
sel.dials[i] = want.min(n - 1);
}
}
preview.scene = Some(crate::portrait::GlueScene::Race(sel.race));
preview.look = Some(GlueLook::Create(sel.look()));
preview.show_create(sel.race, sel.look());
info!(
"char create: pick instrument set race {} sex {} class {} dials {:?}",
sel.race, sel.sex, sel.class, sel.dials
@@ -468,8 +469,7 @@ fn create_input(
let _ = pick.0.send(CharRequest::Create(sel.request()));
}
if changed_look {
preview.scene = Some(crate::portrait::GlueScene::Race(sel.race));
preview.look = Some(GlueLook::Create(sel.look()));
preview.show_create(sel.race, sel.look());
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ use bevy::window::PrimaryWindow;
use crate::char_select::{race_name, wow_font};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
use crate::portrait::{GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
use benilla_assets::WorldAssets;
use super::parts::{CharCreateUi, DialRow, DynIcon, DynText, DynTint, StatusLine};
@@ -41,8 +41,7 @@ pub(super) fn enter_create(
window: Query<&Window, With<PrimaryWindow>>,
) {
sel.reset(catalog.as_deref());
preview.scene = Some(crate::portrait::GlueScene::Race(sel.race));
preview.look = Some(GlueLook::Create(sel.look()));
preview.show_create(sel.race, sel.look());
preview.yaw = INITIAL_FACING;
if let Some(mut wa) = world_assets {
art.ensure_loaded(&mut wa, &mut images, &mut add_mats);
@@ -110,6 +109,7 @@ fn spawn_screen(
let root = commands
.spawn((
crate::glue::NativeGlue,
CharCreateUi { s },
DynTint::Backdrop,
GlobalZIndex(SCREEN_Z),
@@ -720,6 +720,7 @@ fn spawn_panel(
commands
.spawn((
AddonsUi,
crate::glue::NativeGlue,
GlobalZIndex(1200), // over the select screen's 1100, like the delete dialog
// The reference's full-screen BACKGROUND layer, black at 0.75; it also keeps a stray
// click off the screen behind.
@@ -223,6 +223,7 @@ fn spawn_dialog(
commands
.spawn((
DialogUi,
crate::glue::NativeGlue,
GlobalZIndex(1200), // over the select screen's 1100
Node {
width: Val::Percent(100.0),
+15 -4
View File
@@ -195,9 +195,10 @@ mod tests {
.init_resource::<super::super::addons::AddonsPanel>()
.init_resource::<crate::login::LoginIntent>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.insert_resource(CharPick(pick_tx))
.insert_resource(RealmChoice(realm_tx))
.insert_resource(CharPick(pick_tx.into()))
.insert_resource(RealmChoice(realm_tx.into()))
.add_message::<GlueSound>()
.add_message::<crate::login::GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, take_input);
if list_first {
@@ -226,11 +227,21 @@ mod tests {
} else {
"screen first"
};
assert!(
!app.world().resource::<Realms>().shown,
"{order}: the list's Cancel ran"
);
assert!(
realm_rx
.try_iter()
.any(|r| matches!(r, RealmRequest::Abandon)),
"{order}: the list's Cancel ran"
.all(|r| matches!(r, RealmRequest::Refresh)),
"{order}: and over character select it closes nothing"
);
assert!(
app.world()
.resource::<bevy::ecs::message::Messages<crate::login::GlueLogin>>()
.is_empty(),
"{order}: nor ends the login"
);
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
+148 -42
View File
@@ -73,48 +73,51 @@ impl Plugin for CharSelectPlugin {
)
.init_resource::<dialog::DeleteDialog>()
.init_resource::<addons::AddonsPanel>()
.init_resource::<refresh::SelectBackground>()
.add_systems(
OnEnter(ClientState::CharSelect),
(screen::enter_select, stamp_select_entry),
(
screen::enter_select.run_if(crate::ui_script::glue::native_glue),
stamp_select_entry,
),
)
.add_systems(
OnExit(ClientState::CharSelect),
(screen::exit_select, clear_select_entry),
(
screen::exit_select,
screen::clear_select_scene.run_if(crate::ui_script::glue::native_glue),
clear_select_entry,
),
)
.add_systems(
OnEnter(ClientState::InWorld),
refresh::reset_select_background,
)
.add_systems(Update, (debug_glue_roundtrip, debug_logout_smoke))
.add_systems(
Update,
(
// Runs in every state: the reconnect auto-answer and the logout edge arrive
// `InWorld`. `back_on_disconnect` runs after the entry: a session that died
// during entry queues both edges into one frame, and the dead one must win.
(
apply_roster_policy,
enter_on_connected,
back_on_logout,
back_on_login_refused,
back_on_disconnect,
// Last and ungated: it mirrors the pick the frame ended with, and world
// entry is reached from the create screen too.
persist_last_character,
)
.chain(),
session_edges(),
(
screen::materialize_screen,
input::select_input,
input::rotate_model,
input::select_input.run_if(crate::ui_script::glue::native_glue),
input::rotate_model.run_if(crate::ui_script::glue::native_glue),
debug_select_dialog,
dialog::drive_delete_dialog,
dialog::drive_delete_dialog.run_if(crate::ui_script::glue::native_glue),
// Ahead of the dialog driver, so a refused delete shows the frame it lands.
delete_result,
// The one `GlueDialog`: here, the refused login and the refused delete.
crate::glue::dialog::drive_glue_dialog,
crate::glue::dialog::drive_glue_dialog
.run_if(crate::ui_script::glue::native_glue),
debug_select_addons,
debug_select_walk,
addons::drive_addons_panel,
addons::drive_addons_panel.run_if(crate::ui_script::glue::native_glue),
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
refresh::feed_glue_preview,
// The stock screens drive the booth off the glue VM instead.
refresh::feed_glue_preview
.run_if(crate::ui_script::glue::native_glue)
.in_set(crate::portrait::GluePreviewFeed),
debug_select_shot,
)
.chain()
@@ -137,6 +140,26 @@ impl Plugin for CharSelectPlugin {
}
}
/// The session's edges, in every state: the reconnect auto-answer and the logout edge arrive
/// `InWorld`. A logout or a refused login clears the pick before the roster policy reads it: the
/// roster comes right behind them on the kept link, often in the same drain, and must be shown,
/// not answered with the pick just left. `back_on_disconnect` runs after the entry: a session that
/// died during entry queues both edges into one frame, and the dead one must win.
fn session_edges() -> bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::ScheduleSystem> {
(
back_on_logout,
back_on_login_refused,
apply_roster_policy,
enter_on_connected,
back_on_disconnect,
// Last and ungated: it mirrors the pick the frame ended with, and world entry is reached
// from the create screen too.
persist_last_character,
)
.chain()
.into_configs()
}
// ── The roster and pick policy ──────────────────────────────────────────────────────────────────
/// The account roster and the pick policy's memory.
@@ -441,7 +464,7 @@ fn back_on_disconnect(
/// `SMSG_CHARACTER_LOGIN_FAILED`: back to select with the refusal in the glue dialog. The IO
/// thread announces the entry with the pick, so this undoes a half-built one. The pick must be
/// cleared, or the relist behind the refusal is auto-answered and the refused login loops.
/// cleared, or the roster behind the refusal is auto-answered and the refused login loops.
fn back_on_login_refused(
mut msgs: MessageReader<CharacterLoginFailedMessage>,
mut roster: ResMut<Roster>,
@@ -588,7 +611,8 @@ fn debug_glue_roundtrip(
}
/// The logout smoke (`WOW_LOGOUT_SMOKE=1`, with `WOW_CHAR`): once in the world, `/logout`, return
/// to CharSelect, enter the world again and close the window. The second entry streams a map
/// to CharSelect, wait for the roster the logout brings on the link the world was entered by,
/// enter the world again and close the window. The second entry streams a map
/// `terrain_stream::release_world` tore down. It ends by a window close, not an `AppExit`, since
/// only the close reaches the shutdown tail that saves variables and addon files.
fn debug_logout_smoke(
@@ -602,12 +626,35 @@ fn debug_logout_smoke(
mut exit: MessageWriter<AppExit>,
mut close: MessageWriter<bevy::window::WindowCloseRequested>,
windows: Query<Entity, With<bevy::window::PrimaryWindow>>,
mut lists: MessageReader<CharListMessage>,
// The world session's number at the entry, and whether a roster has come since the logout.
mut link: Local<Option<u64>>,
mut relisted: Local<bool>,
mut phase: Local<u8>,
mut mark: Local<f32>,
) {
if std::env::var("WOW_LOGOUT_SMOKE").is_err() {
return;
}
for list in lists.read() {
if *phase < 2 {
*link = Some(list.epoch);
} else if !*relisted {
*relisted = true;
if Some(list.epoch) == *link {
info!(
"logout-smoke: roster after the logout on the same link — {} char(s)",
list.characters.len()
);
} else {
error!(
"logout-smoke: FAILED — the roster after the logout came on world session {} \
where the world was entered on {:?}",
list.epoch, *link
);
}
}
}
let now = time.elapsed_secs();
match *phase {
0 if *state.get() == ClientState::InWorld && player.active => {
@@ -625,21 +672,24 @@ fn debug_logout_smoke(
}
// Residency is read here, not on arrival: `release_world` runs about 150 ms after the
// state edge, on the world-live falling edge.
3 if now - *mark > 4.0 => match roster.chars.first().map(|c| (c.guid, c.name.clone())) {
Some((guid, name)) => {
info!(
"logout-smoke: {} tiles resident after the release (must be 0)",
streamer.residency().1
);
info!("logout-smoke: re-entering the world as {name}");
send_pick(&mut roster, &pick, guid);
(*phase, *mark) = (4, now);
// The roster the logout brings, not the one that survived it, decides the re-entry.
3 if now - *mark > 4.0 && *relisted => {
match roster.chars.first().map(|c| (c.guid, c.name.clone())) {
Some((guid, name)) => {
info!(
"logout-smoke: {} tiles resident after the release (must be 0)",
streamer.residency().1
);
info!("logout-smoke: re-entering the world as {name}");
send_pick(&mut roster, &pick, guid);
(*phase, *mark) = (4, now);
}
None => {
warn!("logout-smoke: empty roster — cannot test re-entry");
*phase = 5;
}
}
None => {
warn!("logout-smoke: empty roster — cannot test re-entry");
*phase = 5;
}
},
}
4 if *state.get() == ClientState::InWorld && player.active && now - *mark > 3.0 => {
// The suppressor reading catches a character that re-entered unable to move;
// `scripts/smoke.sh` fails on anything but `none`. A GM account's logout is instant,
@@ -923,7 +973,7 @@ pub(crate) fn test_character(guid: u64, name: &str) -> Character {
pub(crate) fn add_test_systems(app: &mut App, pick: crossbeam_channel::Sender<CharRequest>) {
app.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.insert_resource(CharPick(pick))
.insert_resource(CharPick(pick.into()))
.add_message::<CharListMessage>()
.add_systems(
Update,
@@ -1058,6 +1108,56 @@ mod tests {
);
}
/// A logout, or a refused login, and the roster the kept link sends right behind it can land
/// in one drain: the pick the world was entered with goes first, so the roster is shown and
/// not answered with that pick again.
#[test]
fn a_return_and_its_roster_in_one_drain_show_the_roster() {
for refused in [false, true] {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::InWorld)
.insert_resource(Roster::with_pending_pick(vec![character(7, "Seven")], 7))
.insert_resource(CharPick(tx.into()))
.init_resource::<crate::cvars::Cvars>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<EnteredWorldMessage>()
.add_message::<LoggedOutMessage>()
.add_message::<CharacterLoginFailedMessage>()
.add_message::<crate::net::DisconnectedMessage>()
.add_systems(Update, session_edges());
app.world_mut().resource_mut::<Roster>().env_read = true;
if refused {
app.world_mut()
.write_message(CharacterLoginFailedMessage { result: 0x01 });
} else {
app.world_mut().write_message(LoggedOutMessage);
}
app.world_mut().write_message(CharListMessage {
characters: vec![character(7, "Seven")],
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 1,
generation: 0,
});
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
"refused: {refused}"
);
assert!(
rx.try_recv().is_err(),
"refused: {refused} — the roster was answered with the pick just left"
);
assert_eq!(app.world().resource::<Roster>().pending_pick, None);
}
}
/// Every refusal byte resolves to the sentence 1.12 ships, off the player's chain. `4` is
/// `CHAR_LOGIN_DISABLED`: the reference's table skips `CHAR_LOGIN_FAILED` (0x41).
#[test]
@@ -1199,7 +1299,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(CharPick(tx.into()))
.insert_resource(match stored {
Some(v) => crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, v),
None => crate::cvars::Cvars::default(),
@@ -1217,6 +1317,9 @@ mod tests {
.map(|(i, n)| character(i as u64 + 1, n))
.collect(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
app.world().resource::<Roster>().selected()
@@ -1311,7 +1414,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(CharPick(tx.into()))
.insert_resource(crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, "0"))
.add_message::<CharListMessage>()
.add_message::<AppExit>()
@@ -1327,6 +1430,9 @@ mod tests {
character(3, "Zzbullone"),
],
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
assert_eq!(
@@ -1363,7 +1469,7 @@ mod tests {
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.init_resource::<crate::cvars::Cvars>()
.insert_resource(CharPick(tx))
.insert_resource(CharPick(tx.into()))
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
+136 -9
View File
@@ -7,7 +7,7 @@ use crate::area::AreaTableRes;
use crate::glue::widgets::{GlueDisabled, Hilight, LockHighlight};
use crate::glue_strings::GlueStrings;
use crate::net::NetStatus;
use crate::portrait::{GlueLook, GluePreview, SelectLook};
use crate::portrait::{GlueLook, GluePreview, GlueScene, SelectLook};
use super::screen::{RealmBanner, RowText, SelectAction, SelectedName, MAX_ROWS};
use super::{class_name, Roster};
@@ -170,10 +170,32 @@ pub(super) fn refresh_banner_and_buttons(
}
}
/// Feed the glue booth: the scene is the selected character's race (`SetBackgroundModel`; Orc
/// with no character, the reference's OnLoad default), the look its geared enum record.
/// The select scene's background as `CharacterSelect` holds it for the glue session: `race` is
/// `None` while only its OnLoad has set the scene (`Model:SetModel(UI_Orc)` under Orc's fog,
/// `CharacterSelect.lua:26-31`), else the race whose file string `SetBackgroundModel` last set
/// (`CharacterSelect.lua:341-344`), drawn unfogged (`0x472110` replaces the fog callback). The
/// call is keyed on the race's file string, not its stage, so a gnome after a dwarf loads the
/// same stage again, a new instance whose scene starts over (`0x76cce0`): `stage` counts them. An
/// emptied roster selects nothing and calls neither, so the scene stays. The glue rebuild at world
/// entry restores OnLoad's.
#[derive(Resource, Default)]
pub(crate) struct SelectBackground {
pub(super) race: Option<u8>,
pub(super) stage: u64,
}
/// The glue rebuild (`0x46a7b0`) runs `CharacterSelect`'s OnLoad again, a new instance.
pub(super) fn reset_select_background(mut background: ResMut<SelectBackground>) {
background.race = None;
background.stage = background.stage.wrapping_add(1);
}
/// Feed the glue booth: the scene is the selected character's stage (`SetBackgroundModel`),
/// unfogged; with no character the last stage stays, or the OnLoad Orc scene under its fog stands
/// ([`SelectBackground`]); the look is the selected character's geared enum record.
pub(super) fn feed_glue_preview(
roster: Res<Roster>,
mut background: ResMut<SelectBackground>,
mut preview: ResMut<GluePreview>,
mut showing: Local<Option<u64>>,
) {
@@ -184,13 +206,25 @@ pub(super) fn feed_glue_preview(
*showing = Some(roster.select_seq);
preview.yaw = 0.0;
}
let (race, look) = match roster.selected_char() {
Some(c) => (c.race, Some(GlueLook::Select(SelectLook::from(c)))),
None => (2, None), // UI_Orc, the reference's empty-account scene
let look = roster.selected_char().map(|c| {
if background.race != Some(c.race) {
background.race = Some(c.race);
background.stage = background.stage.wrapping_add(1);
}
GlueLook::Select(SelectLook::from(c))
});
let (scene, fog) = match background.race {
Some(race) => (GlueScene::of_race(race), None),
None => (GlueScene::Orc, Some(GlueScene::Orc.stock_fog())),
};
let scene = Some(crate::portrait::GlueScene::Race(race));
if preview.scene != scene {
preview.scene = scene;
if preview.scene != Some(scene) {
preview.scene = Some(scene);
}
if preview.stage != background.stage {
preview.stage = background.stage;
}
if preview.fog != fog {
preview.fog = fog;
}
if preview.look != look {
preview.look = look;
@@ -201,6 +235,99 @@ pub(super) fn feed_glue_preview(
mod tests {
use super::*;
/// #978: an empty roster's select scene is OnLoad's Orc under Orc's fog; a selected character
/// sets its stage, unfogged, and that stage stays when the roster empties; the glue rebuild at
/// world entry restores OnLoad's.
fn tauren_again() -> benilla_protocol::Character {
let mut c = crate::char_select::test_character(1, "Moo");
c.race = 6;
c
}
#[test]
fn the_select_scene_fogs_until_a_background_is_set_and_then_stays() {
let mut app = App::new();
app.init_resource::<Roster>()
.init_resource::<SelectBackground>()
.init_resource::<GluePreview>()
.add_systems(Update, feed_glue_preview);
let shown = |app: &App| {
let p = app.world().resource::<GluePreview>();
(p.scene, p.fog.map(|f| f.far), p.look.is_some())
};
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Orc), Some(270.0), false));
let mut tauren = crate::char_select::test_character(1, "Moo");
tauren.race = 6;
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars = vec![tauren];
roster.select(Some(0));
}
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, true));
// A gnome after a dwarf: the same stage, set again, starts over (`0x76cce0`).
let stage = |app: &App| app.world().resource::<GluePreview>().stage;
let mut dwarf = crate::char_select::test_character(2, "Stone");
dwarf.race = 3;
let mut gnome = crate::char_select::test_character(3, "Cog");
gnome.race = 7;
let first = stage(&app);
for (c, row) in [(dwarf, 0), (gnome, 1)] {
let before = stage(&app);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars.insert(row, c);
roster.select(Some(row));
}
app.update();
assert_eq!(shown(&app).0, Some(GlueScene::Dwarf));
assert_ne!(
stage(&app),
before,
"a new race's background is a new instance"
);
}
// Back to the dwarf, then the same row again: only a race change makes one.
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.select(Some(0));
}
app.update();
let dwarf_again = stage(&app);
assert!(dwarf_again != first);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.select(Some(0));
}
app.update();
assert_eq!(stage(&app), dwarf_again);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars = vec![tauren_again()];
roster.select(Some(0));
}
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, true));
// The last character deleted: nothing is selected, and no background call replaces it.
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars.clear();
roster.select(None);
}
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, false));
app.world_mut()
.run_system_cached(reset_select_background)
.unwrap();
app.update();
assert_eq!(shown(&app), (Some(GlueScene::Orc), Some(270.0), false));
}
/// The composition order of `CharacterSelect.lua:48-64`.
#[test]
fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() {
+11 -4
View File
@@ -70,10 +70,13 @@ const ROW_HIT_H: f32 = 55.0;
const ROW_PITCH: f32 = 57.0;
/// Entry only resets state: the initial state's `OnEnter` fires at startup, before the MPQ chain
/// and booth slots exist, so the tree spawns in [`materialize_screen`].
/// and booth slots exist, so the tree spawns in [`materialize_screen`]. The native screen's alone:
/// the stock screens' facing is the glue VM's.
pub(super) fn enter_select(mut preview: ResMut<GluePreview>) {
// The model faces the camera on entry: the reference's facing global defaults to zero.
preview.yaw = 0.0;
// `CharacterSelect`'s OnShow (`0x46fa60`).
preview.shown = preview.shown.wrapping_add(1);
}
/// Spawn the screen tree once the art is loaded, respawn it when art lands after an artless spawn
@@ -145,6 +148,7 @@ fn spawn_screen(
let root = commands
.spawn((
crate::glue::NativeGlue,
CharSelectUi {
with_art: art.button_up.is_some(),
s,
@@ -601,18 +605,21 @@ pub(super) fn hide_under_world_cover(
}
}
/// Leaving the native select screen drops its scene; the next screen sets its own.
pub(super) fn clear_select_scene(mut preview: ResMut<GluePreview>) {
preview.look = None;
preview.scene = None;
}
pub(super) fn exit_select(
mut commands: Commands,
roots: Query<Entity, With<CharSelectUi>>,
mut preview: ResMut<GluePreview>,
mut dialog: ResMut<super::dialog::DeleteDialog>,
mut glue_dialog: ResMut<crate::glue::dialog::GlueDialog>,
) {
for e in &roots {
commands.entity(e).despawn();
}
preview.look = None;
preview.scene = None;
dialog.close();
// The glue dialog must not follow into the world or the create screen. Its tree is despawned
// here, since its driver runs on the glue screens only.
File diff suppressed because it is too large Load Diff
+57 -5
View File
@@ -1139,8 +1139,11 @@ pub(super) fn environmental_damage_log(
/// `SMSG_SET_FACTION_STANDING`: "Your %s reputation has increased by %d.". The wire carries the
/// new total and the sentence the delta, so this runs before the store is overwritten, and an
/// unchanged value prints nothing (`0x62c5f0`). No range gate or classifier: the chat type is the
/// literal `0x55` `COMBAT_FACTION_CHANGE`. The wire's list id is `Faction.dbc`'s `rep_index`.
/// unchanged value prints nothing (`0x4d6344`). Only the packet's first entry prints, and only if
/// its faction is not `HIDDEN`: the handler passes `0x4d6330` its print argument as
/// `index == 0 && !(flags & 4)` (`0x4d57dd`-`0x4d57ea`), so the spill-over entries of one kill
/// update silently. No range gate or classifier: the chat type is the literal `0x55`
/// `COMBAT_FACTION_CHANGE`. The wire's list id is `Faction.dbc`'s `rep_index`.
pub(crate) fn faction_standing(
deltas: &[(u32, i32)],
reputations: &Reputations,
@@ -1150,11 +1153,15 @@ pub(crate) fn faction_standing(
let Some(catalog) = factions.map(|f| f.catalog()) else {
return;
};
for &(list_id, standing) in deltas {
let old = reputations
for &(list_id, standing) in deltas.iter().take(1) {
let (flags, old) = reputations
.0
.get(list_id as usize)
.map_or(0, |(_, standing)| *standing);
.copied()
.unwrap_or((0, 0));
if flags & benilla_formats::faction_flags::HIDDEN != 0 {
continue;
}
let delta = standing - old;
if delta == 0 {
continue;
@@ -1392,4 +1399,49 @@ mod tests {
// The direct packet takes the same wire number through one divide only.
assert_eq!(leech_figures(1, 100, 1.0), Some((10, 10)));
}
/// A standing packet prints the "increased by" line for its first entry alone (`0x4d57dd`), and
/// not at all when that faction is `HIDDEN`; the factions a kill spills over to update
/// silently. On the real DBC, Booty Bay moving with Gadgetzan and Ratchet.
#[test]
fn a_standing_packet_prints_its_first_entry_only_and_never_a_hidden_one() {
use crate::net::Reputations;
use crate::target::ring::Factions;
use crate::ui_chat::ChatLog;
use benilla_formats::faction_flags::HIDDEN;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("factions");
let slot =
|id: u32| u32::try_from(catalog.reputation_faction(id).unwrap().rep_index).unwrap();
let (booty, gadget, ratchet) = (slot(21), slot(369), slot(470));
let factions = Factions::from_catalog(catalog);
let packet = [(booty, 100), (gadget, 50), (ratchet, 50)];
let kinds = |reps: &Reputations, deltas: &[(u32, i32)]| {
let mut log = ChatLog::default();
super::faction_standing(deltas, reps, Some(&factions), &mut log);
log.pending_kinds()
};
let quiet = Reputations::default();
assert_eq!(
kinds(&quiet, &packet),
["combat"],
"one line for the packet"
);
// Flag 4 on the first entry's slot silences the packet; on a later entry it changes nothing.
let mut v = vec![(0u8, 0i32); 64];
v[booty as usize].0 = HIDDEN;
assert!(kinds(&Reputations(v), &packet).is_empty());
let mut v = vec![(0u8, 0i32); 64];
v[gadget as usize].0 = HIDDEN;
assert_eq!(kinds(&Reputations(v), &packet), ["combat"]);
// An unchanged first entry prints nothing, and the entries behind it still stay silent.
let mut v = vec![(0u8, 0i32); 64];
v[booty as usize].1 = 100;
assert!(kinds(&Reputations(v), &packet).is_empty());
}
}
@@ -259,6 +259,8 @@ pub(crate) fn float_combat_text(
path: font.as_ref().and_then(|f| f.0.as_deref()),
height: Some(target_px),
outline: Outline::None,
spacing: 0.0, // one line
shadow_x: 0.0,
alpha_gradient: None,
},
// A world overlay with its own seat; the UI grid does not apply.
+6
View File
@@ -3038,6 +3038,9 @@ mod tests {
app.world_mut().write_message(crate::net::CharListMessage {
characters: roster(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
assert_eq!(
@@ -3065,6 +3068,9 @@ mod tests {
app.world_mut().write_message(crate::net::CharListMessage {
characters: roster(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
+9
View File
@@ -1110,4 +1110,13 @@ pub(crate) const REGISTERED: &[Registered] = &[
// "Last character selected": a 0-based row (the selection cell `[0x83856c]` under `"%d"`), so
// "0" is the first character. It mirrors [`crate::char_select::Roster::pending_index`].
same(crate::char_select::CVAR_LAST_CHARACTER, "0"),
// The four user agreements the login screen walks (`0x402db6`, `0x402dd9`, `0x402dfc`,
// `0x402e1f`; "0" `0x82e570`, category 4): "Status of the TOS" (`0x82e8e4`), "... the EULA"
// (`0x82e8c8`), "... the Scanning notice" (`0x82e89c`) and "... the Contest notice"
// (`0x82e86c`). The glue manager reads them once at startup (`0x46a400`) as accepted (1) and
// changed (negative), and its `Accept*` writes "1" (`0x46aef0` and its siblings).
same("readTOS", "0"),
same("readEULA", "0"),
same("readScanning", "0"),
same("readContest", "0"),
];
+4
View File
@@ -101,6 +101,10 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_LUA").is_ok() {
app.add_plugins(crate::capture::ProbeLuaPlugin);
}
// `WOW_PROBE_GLUE_LUA`: runs a chunk once in the stock glue VM, then one shot.
if std::env::var("WOW_PROBE_GLUE_LUA").is_ok() {
app.add_plugins(crate::capture::ProbeGlueLuaPlugin);
}
// `WOW_PROBE_DRAG="A>B"`: drags frame A onto B through the real pointer path.
if std::env::var("WOW_PROBE_DRAG").is_ok() {
app.add_plugins(crate::capture::ProbeDragPlugin);
+15
View File
@@ -250,6 +250,21 @@ pub(crate) struct Creatures {
}
impl Creatures {
/// Whether a display id has a model row behind it (`CreatureDisplayInfo` → `CreatureModelData`),
/// the chain `0x5ec480` walks after the race row's id.
pub(crate) fn resolves_model(&self, display_id: u32) -> bool {
self.catalog.model(display_id).is_some()
}
/// A catalog with no cached models, for tests that only ask whether a display resolves.
#[cfg(test)]
pub(crate) fn from_catalog(catalog: CreatureCatalog) -> Self {
Creatures {
catalog,
models: HashMap::new(),
}
}
/// A display's foley material (`Material.dbc` id), the creature half of the footfall rustle.
pub(crate) fn foley_material(&self, display_id: u32) -> Option<u32> {
self.catalog.foley_material(display_id)
+122 -2
View File
@@ -537,6 +537,125 @@ pub(crate) mod schedule_tests {
c.systems.values().any(|s| s.name.contains("benilla_app::"))
}
/// The systems of one schedule whose declared access writes the resource `T`, each with every
/// run condition on it or on a set it is under, by name.
fn resource_writers<T: Resource>(
app: &mut App,
label: impl ScheduleLabel,
) -> Vec<(String, Vec<String>)> {
app.world_mut()
.schedule_scope(label.intern(), |world, schedule| {
schedule.graph_mut().systems.initialize(world);
let Some(id) = world.components().get_resource_id(TypeId::of::<T>()) else {
return Vec::new();
};
let graph = schedule.graph();
let mut out = Vec::new();
for (key, system, conds) in graph.systems.iter() {
let writes = graph
.systems
.get(key)
.is_some_and(|s| s.access.combined_access().has_resource_write(id));
if !writes {
continue;
}
let mut conditions: Vec<String> = conds
.iter()
.map(|c| c.condition.name().to_string())
.collect();
let mut stack = vec![NodeId::System(key)];
while let Some(node) = stack.pop() {
for parent in graph
.hierarchy()
.graph()
.neighbors_directed(node, Direction::Incoming)
{
if let NodeId::Set(set) = parent {
if let Some((_, set_conds)) = graph
.system_sets
.iter()
.find(|(k, _, _)| *k == set)
.map(|(_, s, c)| (s, c))
{
conditions.extend(
set_conds.iter().map(|c| c.condition.name().to_string()),
);
}
stack.push(parent);
}
}
}
out.push((system.name().to_string(), conditions));
}
out
})
}
/// The glue booth has one feeder per switch: under `WOW_GLUE_STOCK=1` only the glue VM's
/// writes `GluePreview`, and off it only the native screens' do. A writer of neither kind is
/// named here with why it cannot run against the other.
#[test]
fn the_glue_preview_has_one_feeder_per_switch() {
use crate::char_select::ClientState;
// The create screen's state, which only the native select screen enters, and its
// instrument.
const EITHER: [&str; 2] = ["benilla_app::char_create::", "drive_create_test"];
let mut app = headless_client();
let mut writers = Vec::new();
writers.extend(resource_writers::<crate::portrait::GluePreview>(
&mut app, Update,
));
for state in [ClientState::Login, ClientState::CharSelect] {
writers.extend(resource_writers::<crate::portrait::GluePreview>(
&mut app,
OnEnter(state),
));
writers.extend(resource_writers::<crate::portrait::GluePreview>(
&mut app,
OnExit(state),
));
}
if !writers.iter().any(|(n, _)| n.contains("benilla_app::")) {
eprintln!(
"skipped: this build carries no type names (bevy/debug rides with the dev feature)"
);
return;
}
let has = |conds: &[String], c: &str| conds.iter().any(|n| n.ends_with(c));
let mut stock = Vec::new();
let mut native = Vec::new();
let mut neither = Vec::new();
for (name, conds) in &writers {
match (has(conds, "::stock_glue"), has(conds, "::native_glue")) {
(true, false) => stock.push(name.as_str()),
(false, true) => native.push(name.as_str()),
_ if EITHER.iter().any(|e| name.contains(e)) => {}
_ => neither.push(name.as_str()),
}
}
assert!(
neither.is_empty(),
"GluePreview writers on neither switch: {neither:?}"
);
assert_eq!(
stock,
["benilla_app::ui_script::glue_scene::feed_stock_glue_preview"],
"the stock screens' one feeder"
);
for want in [
"benilla_app::char_select::refresh::feed_glue_preview",
"benilla_app::login::show_main_menu",
"benilla_app::login::screen::clear_login_scene",
"benilla_app::char_select::screen::enter_select",
"benilla_app::char_select::screen::clear_select_scene",
] {
assert!(
native.contains(&want),
"{want} is not gated to the native screens: {native:?}"
);
}
}
/// One row of the class table: the type's name for the message, and its `TypeId`.
type ClassRow = (&'static str, fn() -> TypeId);
@@ -691,8 +810,9 @@ pub(crate) mod schedule_tests {
/// would, off the same clock;
/// - the area-spirit-healer poll over `Transform` and `NetEntity`: it is level-triggered, so a
/// unit seen a frame early or late is decided again next frame;
/// - `ui_models::forget_dead_vm_tiles` against `portrait::glue_booth::sync_glue_scene` over
/// `MatAnimTable`, a slot allocator whose owners touch disjoint slots;
/// - `ui_script::glue_scene::feed_stock_glue_preview` against the native glue screens'
/// writers of `GluePreview`: the switch runs one side or the other (`stock_glue` against
/// `native_glue`), never both;
/// - the cursor classifier against `world_focus`'s focus publish and settle release over
/// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`;
/// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and
+1
View File
@@ -196,6 +196,7 @@ pub(crate) fn spawn_dialog(
let (caption, caption2) = kind.buttons(strings);
commands
.spawn((
super::NativeGlue,
GlobalZIndex(1200), // over the screen's 1100
Node {
width: Val::Percent(100.0),
+22
View File
@@ -15,6 +15,22 @@ use bevy::ui::FocusPolicy;
use art::{tc_rect, GlueArt, BTN_BG, BTN_HOVER, BUTTON_TC, GOLD};
use widgets::{ArtSwap, GlueBtn, GlueCaption, GlueDisabled, OutlineCopy};
/// The root of a native glue screen or dialog, which [`hide_native_glue`] keeps hidden while the
/// stock glue screens stand in its place.
#[derive(Component)]
pub(crate) struct NativeGlue;
/// Under `WOW_GLUE_STOCK=1`, keep every native glue tree hidden, after any screen system shows one
/// and before visibility propagates: their state machine still drives the network, unseen, and a
/// hidden node takes no click.
pub(crate) fn hide_native_glue(mut roots: Query<&mut Visibility, With<NativeGlue>>) {
for mut v in &mut roots {
if *v != Visibility::Hidden {
*v = Visibility::Hidden;
}
}
}
/// The glue widgets' look passes, registered once for every glue screen.
///
/// A screen orders its own refresh `.before(GlueVisuals)` so the flags and captions it writes
@@ -50,6 +66,12 @@ impl Plugin for GluePlugin {
PostUpdate,
fit_glue_canvas.before(bevy::ui::UiSystems::Layout),
)
.add_systems(
PostUpdate,
hide_native_glue
.before(bevy::camera::visibility::VisibilitySystems::VisibilityPropagate)
.run_if(not(crate::ui_script::glue::native_glue)),
)
.add_systems(
Update,
(art_swaps, glue_button_visuals, glue_hilights, sync_outlines).in_set(GlueVisuals),
+11
View File
@@ -273,6 +273,15 @@ fn launch(build: BuildId, extend: Option<Extension<'_>>) -> AppExit {
// A dev build's install, state folder and probe identity resolve from the launcher's folder,
// so a crate on top of benilla keeps them in its own and not in cargo's checkout of benilla.
benilla_formats::set_project_folder(build.project_dir);
if run_mode::unattended() && std::env::var_os("BENILLA_HOME").is_none() {
// Before the logger exists; whoever reads what the run wrote needs the folder.
if let Some(home) = local_state::home() {
println!(
"state: an unattended run keeps its own state in {} (BENILLA_HOME pins one)",
home.display()
);
}
}
// From here a panic leaves `benilla-config/Diagnostics/crash-<unix>.txt`; armed before the
// `App` exists, so a panic while plugins build is reported too.
@@ -423,6 +432,8 @@ fn launch(build: BuildId, extend: Option<Extension<'_>>) -> AppExit {
// macOS `Cmd+Q` goes straight to `terminate:`, which never runs another frame, so the session
// was never written; re-pointed at the window close, which [`shutdown`] sees.
.add_plugins(benilla_world::mac_quit::MacQuitPlugin)
// Alt+F4 on Windows and Linux, where a tiling window manager binds nothing to it.
.add_plugins(shutdown::AltF4Plugin)
// The engine as one group; `world_plugins.rs` has its load-bearing ordering edges and what it
// leaves out (`pipe_warm`).
.add_plugins(benilla_world::world_plugins::WorldPlugins)
+113 -30
View File
@@ -52,10 +52,6 @@ const CLEAR_AFTER_READY_FRAMES: u32 = 3;
/// [`WAIT_LOG_EVERY`] seconds.
const WAIT_LOG_AFTER: f32 = 3.0;
const WAIT_LOG_EVERY: f32 = 2.0;
/// How far (yd) a same-map snap must move the body to count as a load. It clears every combat
/// relocation (charge and intercept 25 yd, blink 20, knockbacks under 30), which also ends in a
/// server teleport and must never black out the screen.
const SNAP_LOAD_MIN_YD: f32 = 100.0;
/// Consecutive covered, in-world frames before the cover is on the glass: renders are serial, so
/// at 3 the two frames before have presented.
@@ -162,8 +158,6 @@ pub(crate) struct LoadingScreen {
/// `Time::elapsed_secs` at the last raise and at the last wait log line.
active_since: f32,
last_wait_log: f32,
/// The avatar's position last frame, to measure a snap (applied in `Input`, a stage earlier).
last_pos: Option<Vec3>,
}
impl LoadingScreen {
@@ -270,7 +264,7 @@ impl Plugin for LoadingScreenPlugin {
// In `First`, ahead of every consumer in `PreUpdate` and `Update`.
.add_systems(First, count_entry_cover)
.add_systems(Startup, setup_loading_screen.after(AssetSet::Open))
// In `WorldStage::Present`, after Input and Stream: a teleport snaps in Input and the
// In `WorldStage::Present`, after Input and Stream: a worldport snaps in Input and the
// streamer refocuses in Stream, so the cover lands on the same frame, never a flash.
.add_systems(Update, drive_loading_screen.in_set(WorldStage::Present));
// The cover's input rule, registered here so the two cannot be registered apart.
@@ -485,9 +479,9 @@ fn drive_loading_screen(
screen.raise("worldport", false, Some(w.map_id), now);
}
}
// A same-map teleport ends any awaited snap; whether it needs a screen is decided below.
let teleported = teleports.read().next().is_some();
if teleported {
// A same-map teleport ends any awaited snap and raises nothing: the reference's
// `MSG_MOVE_TELEPORT_ACK` handler (`0x603f90`) reaches none of the screen's raises.
if teleports.read().next().is_some() {
screen.snap_landed(None);
}
// Logout, a lost session or a refused login: `CharSelect` applies next frame, so cover the torn
@@ -516,35 +510,19 @@ fn drive_loading_screen(
}
// --- Backstop: the ground under the focus is not resident and nothing raised us. In world
// only, since behind the glue nothing loads; and only while the focus is the body, since in a
// cinematic or free-fly residency describes the camera's tile. ---
// only, since behind the glue nothing loads; only while the focus is the body, since in a
// cinematic or free-fly residency describes the camera's tile; and never while a same-map
// teleport's settle hold is up. ---
if !screen.active
&& !progress.focus_resident
&& focus.follows_body()
&& !player_settling
&& *state.get() == crate::char_select::ClientState::InWorld
{
// Same map by construction: the ground under the body.
screen.raise("focus not resident", false, map_id, now);
}
// --- At a snap, the same test against the clear's own predicate: a teleport inside the keep
// band lands on resident terrain while the destination's buildings may still be arriving. ---
let body = player.as_ref().map(|p| p.pos);
let relocated = match (teleported, screen.last_pos, body) {
(true, Some(was), Some(now_pos)) => was.distance(now_pos) >= SNAP_LOAD_MIN_YD,
// No previous position (the entry frame): treat it as a relocation.
(true, _, _) => true,
_ => false,
};
screen.last_pos = body;
if relocated
&& !screen.active
&& *state.get() == crate::char_select::ClientState::InWorld
&& !(progress.is_ready() && progress.presentable())
{
screen.raise("teleport, destination not presentable", false, map_id, now);
}
// --- Clear, sustained a few frames. ---
if screen.active && !screen.held {
if progress.is_ready()
@@ -788,4 +766,109 @@ mod tests {
screen.dismiss();
assert!(!screen.art_resolved);
}
/// `drive_loading_screen` alone, in world, standing on resident ground at `at` (wow coords).
fn in_world(at: [f32; 3]) -> App {
let mut player = crate::player::Player::default();
player.active = true;
player.pos = benilla_assets::coords::wow_to_bevy(at);
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(crate::char_select::ClientState::InWorld)
.add_message::<crate::net::EnteredWorldMessage>()
.add_message::<crate::net::WorldportMessage>()
.add_message::<crate::net::TeleportMessage>()
.add_message::<crate::net::LoggedOutMessage>()
.add_message::<crate::net::DisconnectedMessage>()
.add_message::<crate::net::CharacterLoginFailedMessage>()
.init_resource::<LoadingScreen>()
.init_resource::<crate::pipe_warm::WarmPass>()
.init_resource::<Assets<Image>>()
.insert_resource(CurrentMap(1))
.insert_resource(benilla_world::terrain_stream::ViewFocus::body(at, true))
.insert_resource(WorldLoadProgress {
ready: 9,
total: 9,
focus_resident: true,
scene_ready: true,
..Default::default()
})
.insert_resource(player)
.add_systems(Update, drive_loading_screen);
app.update();
app
}
/// The snap's frame as the stream sees it: the body held over ground not yet loaded.
fn land_unloaded(app: &mut App, at: [f32; 3]) {
let pos = benilla_assets::coords::wow_to_bevy(at);
let mut player = app.world_mut().resource_mut::<crate::player::Player>();
player.pos = pos;
player.settling = true;
app.insert_resource(benilla_world::terrain_stream::ViewFocus::body(at, false));
app.insert_resource(WorldLoadProgress {
ready: 0,
total: 9,
..Default::default()
});
}
fn covering(app: &App) -> bool {
app.world().resource::<LoadingScreen>().covering()
}
const GM_ISLAND: [f32; 3] = [16222.0, 16252.0, 12.5];
const SOUTH_SEAS: [f32; 3] = [-11880.0, -4700.0, 2.0];
const NORTHSHIRE: [f32; 3] = [-8949.95, -132.493, 83.5312];
/// GM Island to the South Seas, both on Kalimdor.
#[test]
fn a_same_map_teleport_across_the_continent_shows_no_loading_screen() {
let mut app = in_world(GM_ISLAND);
assert!(!covering(&app), "standing on resident ground");
land_unloaded(&mut app, SOUTH_SEAS);
app.world_mut().write_message(crate::net::TeleportMessage {
guid: 1,
counter: 0,
position: SOUTH_SEAS,
orientation: 0.0,
});
app.update();
assert!(!covering(&app), "the snap frame raises no screen");
for _ in 0..30 {
app.update();
assert!(
!covering(&app),
"nor does the destination streaming in under the held body"
);
}
}
#[test]
fn unloaded_ground_under_a_body_no_snap_holds_is_covered() {
let mut app = in_world(GM_ISLAND);
land_unloaded(&mut app, SOUTH_SEAS);
app.world_mut()
.resource_mut::<crate::player::Player>()
.settling = false;
app.update();
assert!(covering(&app));
}
#[test]
fn a_worldport_covers_its_load() {
let mut app = in_world(GM_ISLAND);
land_unloaded(&mut app, NORTHSHIRE);
app.world_mut().write_message(crate::net::WorldportMessage {
map_id: 0,
position: NORTHSHIRE,
orientation: 0.0,
needs_ack: true,
transport_entry: None,
});
app.update();
assert!(covering(&app));
}
}
+69 -6
View File
@@ -4,9 +4,12 @@
//!
//! Resolution, in order, the same shape as [`benilla_formats::wow_data`]:
//! 1. `$BENILLA_HOME`.
//! 2. `<project folder>/benilla-config/`, dev builds only ([`benilla_formats::project_folder`], the
//! 2. An unattended run ([`crate::run_mode::unattended`]): a fresh folder of its own for the run,
//! under the system temp folder, so a probe neither reads the player's settings nor writes back
//! over them.
//! 3. `<project folder>/benilla-config/`, dev builds only ([`benilla_formats::project_folder`], the
//! launcher's): a shipped binary must not carry the build machine's source tree.
//! 3. `<exe dir>/benilla-config/`.
//! 4. `<exe dir>/benilla-config/`.
//!
//! With `$WOW_CAPTURE` set every path resolves to `None`: a capture neither reads nor writes
//! player state.
@@ -22,25 +25,45 @@ const STATE_DIR: &str = "benilla-config";
/// It may not exist yet: [`write_atomic`] creates it, and a reader treats a missing file as
/// defaults.
pub(crate) fn home() -> Option<PathBuf> {
home_in(cfg!(test))
}
/// [`home`], with the unit-test stop as an argument so a test can walk the steps behind it.
fn home_in(unit_test: bool) -> Option<PathBuf> {
if std::env::var_os("WOW_CAPTURE").is_some() {
return None; // hermetic: captures neither read nor write player state
}
if let Some(over) = std::env::var_os("BENILLA_HOME") {
return Some(PathBuf::from(over));
}
if cfg!(test) {
if unit_test {
return None; // a unit test reaches no real state folder unless it pins `$BENILLA_HOME`
}
if crate::run_mode::unattended() {
return Some(run_home().to_path_buf());
}
resident_home()
}
/// Steps 2 and 3 of [`home`]: the folder the build itself lives in.
/// Step 2 of [`home`]: the unattended run's own folder, one per process, created on first write.
/// Left behind for whoever reads what the run wrote; the system cleans its temp folder.
pub(crate) fn run_home() -> &'static Path {
static RUN_HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
RUN_HOME.get_or_init(|| run_home_in(&std::env::temp_dir(), std::process::id()))
}
/// [`run_home`]'s path under `temp` for process `pid`.
fn run_home_in(temp: &Path, pid: u32) -> PathBuf {
temp.join(format!("benilla-run-{pid}")).join(STATE_DIR)
}
/// Steps 3 and 4 of [`home`]: the folder the build itself lives in.
fn resident_home() -> Option<PathBuf> {
// 2 · the project folder, dev builds only.
// 3 · the project folder, dev builds only.
if let Some(root) = dev_project_root() {
return Some(root.join(STATE_DIR));
}
// 3 · beside the binary; a dev build never reaches here.
// 4 · beside the binary; a dev build never reaches here.
std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(Path::to_path_buf))
@@ -384,6 +407,46 @@ mod tests {
std::fs::remove_dir_all(&tmp).ok();
}
/// An unattended run keeps its state in a folder of its own, never the player's, unless
/// `$BENILLA_HOME` pins one; a capture still resolves none.
#[test]
fn an_unattended_run_keeps_its_own_state_folder() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = EnvGuard::unset("WOW_CAPTURE");
let _r = EnvGuard::unset("WOW_RIG");
let _h = EnvGuard::unset("BENILLA_HOME");
let _u = EnvGuard::set("WOW_UNATTENDED", "1");
let run = home_in(false).expect("an unattended run has a state folder");
assert_eq!(run, run_home());
assert_ne!(
Some(&run),
resident_home().as_ref(),
"never the player's folder"
);
assert_eq!(
home_in(false),
Some(run.clone()),
"one folder for the whole run"
);
assert_eq!(
run_home_in(Path::new("/tmp"), 42),
Path::new("/tmp/benilla-run-42/benilla-config")
);
// A rig is unattended too.
let _u2 = EnvGuard::unset("WOW_UNATTENDED");
let _r2 = EnvGuard::set("WOW_RIG", "tauren druid 60");
assert_eq!(home_in(false), Some(run));
// The override still wins, and a capture still resolves nothing.
let _h2 = EnvGuard::set("BENILLA_HOME", "/tmp/benilla-pinned");
assert_eq!(home_in(false), Some(PathBuf::from("/tmp/benilla-pinned")));
let _c2 = EnvGuard::set("WOW_CAPTURE", "ui-options");
assert_eq!(home_in(false), None);
}
/// A unit test that does not pin `$BENILLA_HOME` resolves no state folder, so no test can write
/// the real one.
#[test]
+471 -52
View File
@@ -32,7 +32,7 @@ use crate::glue::dialog::{DialogKind, GlueDialog};
use crate::glue_strings::GlueStrings;
use crate::net::{
CharListMessage, DisconnectedMessage, LoginAbandon, LoginFailedMessage, LoginQueuedMessage,
LoginRequest, LoginStageMessage, LoginSubmit,
LoginRequest, LoginStageMessage, LoginSubmit, WorldAuthMessage,
};
use crate::portrait::{GluePreview, GlueScene};
use crate::sound::GlueSound;
@@ -53,24 +53,60 @@ impl Plugin for LoginPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LoginIntent>()
.init_resource::<LoginForm>()
.add_systems(OnEnter(ClientState::Login), enter_login)
.add_systems(OnExit(ClientState::Login), screen::exit_login)
.add_message::<GlueLogin>()
.add_message::<crate::ui_script::server_alert::LaunchUrl>()
.add_systems(
OnEnter(ClientState::Login),
(
enter_login,
// Under the stock glue screens their own feeder drives the booth.
show_main_menu.run_if(crate::ui_script::glue::native_glue),
),
)
.add_systems(
OnExit(ClientState::Login),
(
screen::exit_login,
screen::clear_login_scene.run_if(crate::ui_script::glue::native_glue),
),
)
.add_systems(
Update,
(
// Every state: the reconnect resubmit fires while `InWorld`, and the roster
// edge lands wherever it lands.
(drive_policy, to_select_on_roster, drive_quit).chain(),
// The stock glue screens' manager first, after the tick whose Lua called its
// verbs, so its asks reach the policy in the same frame.
(
crate::ui_script::glue_mgr::drive_glue_mgr
.after(crate::ui_script::UiInput)
.after(crate::realm_select::RealmSelectSet)
.run_if(crate::ui_script::glue::stock_glue),
// Off the roster and the screen the manager and the tick just moved.
crate::ui_script::glue_scene::feed_stock_glue_preview
.run_if(crate::ui_script::glue::stock_glue)
.in_set(crate::portrait::GluePreviewFeed),
crate::ui_script::server_alert::open_launched
// Reads only its message, so no state orders it but the sets.
.after(benilla_world::schedule::WorldStage::Stream)
.run_if(crate::ui_script::glue::stock_glue),
answer_glue,
drive_policy,
to_select_on_roster,
drive_quit,
)
.chain(),
(
screen::materialize_screen,
login_input,
login_input.run_if(crate::ui_script::glue::native_glue),
tick_login_caret,
screen::refresh_boxes,
screen::refresh_checkbox,
// The shared glue dialog stays inside this chain: ordering it from outside
// would order a shared painter against its own `SystemTypeSet`, which
// panics at schedule build, at boot, where no unit test sees it.
crate::glue::dialog::drive_glue_dialog,
crate::glue::dialog::drive_glue_dialog
.run_if(crate::ui_script::glue::native_glue),
answer_dialog,
// After the driver: it spawns the dialog's edit box, and a realmlist Okay
// changes the address repainted here.
@@ -110,8 +146,8 @@ enum IoPark {
/// the resubmit timer.
#[derive(Resource, Default)]
pub(crate) struct LoginIntent {
/// Kept in-world so the logout relist and an unattended reconnect re-authenticate silently;
/// cleared by select's Back, a refusal, a Cancel and a lost session.
/// Kept in-world so an unattended reconnect re-authenticates silently; cleared by select's
/// Back, a refusal, a Cancel and a lost session.
creds: Option<(String, String)>,
/// Between our send and its LoginFailed or CharacterList answer.
in_flight: bool,
@@ -166,6 +202,87 @@ impl Attempt<'_> {
}
}
/// What the glue screens ask of the login engine: the stock ones past the glue manager's own
/// guards ([`crate::ui_script::glue_mgr`]), and the native realm list's Cancel over login.
#[derive(Message)]
pub(crate) enum GlueLogin {
/// `CGlueMgr::Login` (`0x46afb0`) reaching `ClientServices`' logon (`0x5ab4b0`): one attempt,
/// typed, with the screen's credentials. It supersedes a logon still parked at the realm list,
/// as the reference starts the next logon whatever link the last one left.
Submit { account: String, password: String },
/// `CLoginMgr::Cancel` (`0x5b3320`): the status dialog's Cancel during the logon, and
/// `RealmListDialogCancelled` on the login screen. The attempt in flight is dropped, the
/// realmd link closes, and nothing resubmits.
Abandon,
/// The status dialog's Cancel during the world connect, which completes it
/// `RESPONSE_CANCELLED` (`0x46b754`); the failure arm then drops the world socket alone
/// (`0x46befd`, `0x5ab1a0`), so the attempt in flight ends and the realmd link stays up.
CancelWorld,
/// `DisconnectFromServer` (`0x46d340`): the world session dropped if there is one, then the
/// logon cancelled, as select's Back drops the parked session.
Disconnect,
/// `QuitGame` (`0x46cea0` → `0x401560(0)`): the process ends, with no grace for a sound.
Quit,
}
/// The glue manager's asks: a submit as the screen's own Login press makes it (the account saving
/// is the stock Lua's `SetSavedAccountName`), a cancel as the native Cancel, a disconnect as
/// select's Back, a quit as the native quit with no grace. The screen moves and the quit go
/// through `Commands`, applied before [`drive_quit`] in this chain.
pub(crate) fn answer_glue(
mut asks: MessageReader<GlueLogin>,
mut attempt: Attempt,
pick: Option<Res<crate::net::CharPick>>,
realm: Option<Res<crate::net::RealmChoice>>,
state: Res<State<ClientState>>,
time: Res<Time>,
mut commands: Commands,
) {
for ask in asks.read() {
match ask {
GlueLogin::Submit { account, password } => attempt.send(account, password, true),
GlueLogin::Abandon => {
close_logon(&attempt.abandon, &mut attempt.intent, realm.as_deref());
}
GlueLogin::CancelWorld => {
attempt.abandon.0.fetch_add(1, Ordering::SeqCst);
}
GlueLogin::Disconnect => {
close_logon(&attempt.abandon, &mut attempt.intent, realm.as_deref());
if matches!(
state.get(),
ClientState::CharSelect | ClientState::CharCreate
) {
if let Some(pick) = pick.as_deref() {
let _ = pick.0.send(crate::net::CharRequest::Abandon);
}
commands.set_state(ClientState::Login);
}
}
GlueLogin::Quit => {
info!("login: QuitGame");
commands.insert_resource(QuitArm(Some(time.elapsed_secs())));
}
}
}
}
/// `CLoginMgr::Cancel` (`0x5b3320`): the attempt in flight is dropped at its next stage boundary
/// (a dial asked for under the old generation included), the realmd link closes, and nothing
/// resubmits.
fn close_logon(
abandon: &LoginAbandon,
intent: &mut LoginIntent,
realm: Option<&crate::net::RealmChoice>,
) {
abandon.0.fetch_add(1, Ordering::SeqCst);
intent.in_flight = false;
intent.clear();
if let Some(realm) = realm {
let _ = realm.0.send(crate::net::RealmRequest::Close);
}
}
/// The queue ring's clock, standing in for the reference's `GetTickCount`: only differences
/// matter, and `u32` keeps the reference's wrapping width.
fn queue_now_ms(time: &Time) -> u32 {
@@ -196,7 +313,10 @@ fn stage_text(strings: &GlueStrings, stage: LoginStage) -> &str {
match stage {
LoginStage::Connecting => strings.text("LOGIN_STATE_CONNECTING", "Connecting"),
LoginStage::Authenticating => strings.text("LOGIN_STATE_AUTHENTICATING", "Authenticating"),
LoginStage::Handshaking => strings.text("LOGIN_STATE_HANDSHAKING", "Handshaking"),
// The native dialog keeps the handshake's text through the world socket's connect.
LoginStage::Handshaking | LoginStage::WorldConnected => {
strings.text("LOGIN_STATE_HANDSHAKING", "Handshaking")
}
}
}
@@ -220,7 +340,9 @@ fn fail_text(
}
let authored = match refusal {
Some(LoginRefusal::World(code)) => world_refusal_text(strings, code),
Some(LoginRefusal::Logon(code)) => logon_refusal_text(strings, Some(code)),
Some(LoginRefusal::Logon(code) | LoginRefusal::LogonProof(code)) => {
logon_refusal_text(strings, Some(code))
}
None => logon_refusal_text(strings, None),
};
without_dead_url(authored).into_owned()
@@ -249,9 +371,8 @@ fn world_refusal_text(strings: &GlueStrings, code: u8) -> &str {
m::AUTH_ALREADY_LOGGING_IN => ("AUTH_ALREADY_LOGGING_IN", "Already Logging In"),
m::AUTH_LOGIN_SERVER_NOT_FOUND => ("AUTH_LOGIN_SERVER_NOT_FOUND", "Invalid Login Server"),
// All but `AUTH_ALREADY_ONLINE` below are the rows of the client's `OKAY_WITH_URL` table
// (`0x803740`). Deviation: no URL button, because `LaunchURL` is a shell-out benilla does
// not make and its whitelist (`0x85cc34`) names only Blizzard domains that no longer
// serve these pages.
// (`0x803740`). Deviation: no URL button on the native screen, because `LaunchURL`'s
// whitelist (`0x85cc34`) names only Blizzard domains that no longer serve these pages.
m::AUTH_BANNED => (
"AUTH_BANNED",
"This account has been banned for violating the Terms of Use Agreement",
@@ -502,8 +623,13 @@ fn drive_policy(
}
}
for msg in disconnects.read() {
attempt.intent.park = IoPark::AtLogin;
attempt.intent.in_flight = false;
// A logout or a refused character login keeps the world link: the IO thread is back at
// character select on it (`0x5aaeb0`, `0x5aad70`), with nothing to log on again.
if msg.end == benilla_protocol::SessionEnd::LoggedOut {
continue;
}
attempt.intent.park = IoPark::AtLogin;
if msg.session_over {
// `DISCONNECTED_FROM_SERVER`: `GlueParent.lua` shows the login screen and the
// `DISCONNECTED` dialog. The credentials go, so nothing takes the account back.
@@ -515,14 +641,9 @@ fn drive_policy(
dialog.open_error(strings.text("DISCONNECTED", "Disconnected from server"));
continue;
}
// Otherwise a silent re-auth: at once after a logout, paced after a stream death.
// Otherwise an unattended run's silent re-auth, paced after the stream death.
if attempt.intent.creds.is_some() {
let delay = if msg.end == benilla_protocol::SessionEnd::LoggedOut {
0.0
} else {
RETRY_DELAY_SECS
};
attempt.intent.retry_at = Some(now + delay);
attempt.intent.retry_at = Some(now + RETRY_DELAY_SECS);
}
}
@@ -538,23 +659,54 @@ fn drive_policy(
}
}
/// The roster is the login's success edge: go to CharSelect, but only from `Login`, since a
/// reconnect's roster lands while `InWorld`.
/// The login's success edge: go to CharSelect, but only from `Login`, since an unattended
/// reconnect's roster lands while `InWorld`, and a logout's at character select. The native
/// screens take the roster for it, which the IO thread fetches on reaching the park; the stock
/// screens take the world server's authentication (`SMSG_AUTH_RESPONSE`, `0x5ab2b8`), since the
/// roster there is the screen's own ask and comes only once the screen is up.
fn to_select_on_roster(
mut msgs: MessageReader<CharListMessage>,
mut auths: MessageReader<WorldAuthMessage>,
stock: Option<Res<crate::ui_script::glue::GlueStock>>,
mut intent: ResMut<LoginIntent>,
mut dialog: ResMut<GlueDialog>,
state: Res<State<ClientState>>,
mut next: ResMut<NextState<ClientState>>,
abandon: Option<Res<LoginAbandon>>,
pick: Option<Res<crate::net::CharPick>>,
) {
if msgs.read().next().is_none() {
// A message of an attempt a Cancel or a disconnect has passed since is not an edge: the
// reference drops the socket there (`0x46befd`, `0x46d360`), so the session that parked
// behind the screen's back is abandoned.
let now = LoginAbandon::now(abandon.as_deref());
let (mut edge, mut stale) = (false, false);
let generations: Vec<u64> = if stock.is_some_and(|s| s.0) {
msgs.read().for_each(drop);
auths.read().map(|m| m.generation).collect()
} else {
auths.read().for_each(drop);
msgs.read().map(|m| m.generation).collect()
};
for generation in generations {
if generation == now {
edge = true;
} else {
stale = true;
}
}
if stale && *state.get() == ClientState::Login {
if let Some(pick) = pick.as_deref() {
let _ = pick.0.send(crate::net::CharRequest::Abandon);
}
}
if !edge {
return;
}
intent.in_flight = false;
intent.park = IoPark::Active;
intent.retry_at = None;
// Only the dialogs the roster answers: a refused character login's relist brings a roster,
// and its `Error` is dismissed by its Okay, never by a packet.
// Only the dialogs the roster answers: a refused character login's roster follows it on the
// kept link, and its `Error` is dismissed by its Okay, never by a packet.
if matches!(dialog.kind, Some(DialogKind::Status | DialogKind::Queued)) {
dialog.close();
}
@@ -622,8 +774,8 @@ impl LoginForm {
struct QuitArm(Option<f32>);
/// `AccountLogin_OnShow`: prefill the saved account, clear the password, focus the first empty
/// box, check Remember when a name is saved; then show the `UI_MainMenu` scene.
fn enter_login(mut form: ResMut<LoginForm>, mut preview: ResMut<GluePreview>) {
/// box, check Remember when a name is saved.
fn enter_login(mut form: ResMut<LoginForm>) {
let saved = load_saved_account();
form.save = !saved.is_empty();
form.focus = if saved.is_empty() {
@@ -637,9 +789,16 @@ fn enter_login(mut form: ResMut<LoginForm>, mut preview: ResMut<GluePreview>) {
// remembered name is typed over.
let focus = form.focus;
form.focus(focus);
}
/// The native login's scene, `AccountLogin.xml`'s `ModelFFX`: `UI_MainMenu` under its fog.
fn show_main_menu(mut preview: ResMut<GluePreview>) {
preview.scene = Some(GlueScene::MainMenu);
preview.look = None;
preview.yaw = 0.0;
preview.fog = Some(GlueScene::MainMenu.stock_fog());
// `AccountLogin`'s OnShow (`0x46fa60`).
preview.shown = preview.shown.wrapping_add(1);
}
/// The screen's input: typing, Tab, Enter submits, Esc quits (an open dialog takes it first),
@@ -861,7 +1020,7 @@ fn drive_quit(arm: Option<Res<QuitArm>>, time: Res<Time>, mut exit: MessageWrite
/// The realmlist dialog's prompt for a value that is not an address; the typed text stays for
/// editing.
const REALMLIST_BAD: &str =
pub(crate) const REALMLIST_BAD: &str =
"That is not a server address.\nTry logon.example.org or 127.0.0.1:3724";
/// What a glue-dialog press means on this screen; the widget
@@ -874,6 +1033,7 @@ fn answer_dialog(
mut realmlist: ResMut<crate::realmlist::Realmlist>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
abandon: Res<LoginAbandon>,
realm: Option<Res<crate::net::RealmChoice>>,
) {
for answer in answers.read() {
match answer.kind {
@@ -893,11 +1053,9 @@ fn answer_dialog(
dialog.dismiss(&mut commands);
}
}
// Cancel, as the reference's state-1 Cancel (`0x46b73d` → `CLoginMgr::Cancel`).
DialogKind::Status | DialogKind::Queued => {
// Cancel: the next stage boundary discards the attempt, and nothing resubmits.
abandon.0.fetch_add(1, Ordering::SeqCst);
intent.in_flight = false;
intent.clear();
close_logon(&abandon, &mut intent, realm.as_deref());
dialog.dismiss(&mut commands);
}
}
@@ -962,14 +1120,14 @@ fn save_account_to(path: &std::path::Path, name: &str) {
}
/// `GetSavedAccountName`, at [`crate::local_state`]'s path.
fn load_saved_account() -> String {
pub(crate) fn load_saved_account() -> String {
crate::local_state::saved_account_path()
.map(|p| load_saved_account_from(&p))
.unwrap_or_default()
}
/// `SetSavedAccountName`; empty clears.
fn save_account(name: &str) {
pub(crate) fn save_account(name: &str) {
if let Some(path) = crate::local_state::saved_account_path() {
save_account_to(&path, name);
}
@@ -1032,9 +1190,10 @@ mod tests {
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.insert_resource(crate::net::RealmChoice(realm_tx))
.insert_resource(crate::net::RealmChoice(realm_tx.into()))
.add_message::<KeyboardInput>()
.add_message::<GlueSound>()
.add_message::<GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, crate::realm_select::take_input);
let list = crate::realm_select::list_keys;
@@ -1061,11 +1220,17 @@ mod tests {
!app.world().resource::<crate::realm_select::Realms>().shown,
"{order}: the list's Cancel closed it"
);
let asks = app
.world()
.resource::<bevy::ecs::message::Messages<GlueLogin>>();
assert!(
realm_rx
.try_iter()
.any(|r| matches!(r, crate::net::RealmRequest::Abandon)),
"{order}: and answered the park"
asks.iter_current_update_messages()
.any(|a| matches!(a, GlueLogin::Abandon)),
"{order}: and ended the attempt, which closes the logon"
);
assert!(
realm_rx.try_recv().is_err(),
"{order}: the login engine sends the close"
);
assert!(
!app.world().contains_resource::<QuitArm>(),
@@ -1074,6 +1239,103 @@ mod tests {
}
}
/// The status dialog's Cancel is `CLoginMgr::Cancel` (`0x46b73d` → `0x5b3320`): the attempt
/// ends at its next boundary and the logon park closes, so a logon that already finished is
/// not left parked.
#[test]
fn the_status_cancel_closes_the_logon() {
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
let abandon = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let mut app = App::new();
app.init_resource::<GlueDialog>()
.init_resource::<LoginIntent>()
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.insert_resource(LoginAbandon(abandon.clone()))
.insert_resource(crate::net::RealmChoice(realm_tx.into()))
.add_message::<crate::glue::dialog::GlueDialogAnswer>()
.add_systems(Update, answer_dialog);
app.world_mut().resource_mut::<LoginIntent>().in_flight = true;
app.world_mut()
.write_message(crate::glue::dialog::GlueDialogAnswer {
kind: DialogKind::Status,
button1: true,
button2: false,
});
app.update();
assert_eq!(abandon.load(Ordering::SeqCst), 1);
assert!(matches!(
realm_rx.try_recv(),
Ok(crate::net::RealmRequest::Close)
));
assert!(!app.world().resource::<LoginIntent>().in_flight);
}
/// The realm list's Cancel over the login screen ends the attempt (#833): the logon closes
/// and the next Login is sent, where the attempt left in flight refused it.
#[test]
fn the_realm_lists_cancel_lets_the_next_login_through() {
let (tx, requests) = crossbeam_channel::unbounded();
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<crate::glue::GlueClicks>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<crate::bindings::LayoutNames>()
.insert_non_send_resource(HostClipboard::default())
.init_resource::<LoginForm>()
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.init_resource::<crate::realm_select::Realms>()
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.insert_resource(LoginSubmit(tx))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.insert_resource(crate::net::RealmChoice(realm_tx.into()))
.insert_resource(State::new(ClientState::Login))
.add_message::<KeyboardInput>()
.add_message::<GlueSound>()
.add_message::<GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, crate::realm_select::take_input)
.add_systems(
Update,
(crate::realm_select::list_keys, answer_glue, login_input).chain(),
);
{
let w = app.world_mut();
let mut form = w.resource_mut::<LoginForm>();
form.account.text = "probe".into();
form.password.text = "secret".into();
// The attempt reached the realm park, which raised the list over this screen.
w.resource_mut::<LoginIntent>().in_flight = true;
w.resource_mut::<crate::realm_select::Realms>().shown = true;
}
let press = |app: &mut App, key: KeyCode| {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(key);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
};
press(&mut app, KeyCode::Escape);
assert!(
matches!(realm_rx.try_recv(), Ok(crate::net::RealmRequest::Close)),
"Cancel closes the logon"
);
assert!(!app.world().resource::<LoginIntent>().in_flight);
app.update(); // the list's input ownership settles
press(&mut app, KeyCode::Enter);
let req = requests.try_recv().expect("the next Login is sent");
assert_eq!(req.user, "probe");
}
/// A lost session does not log itself back in: a displaced client's resubmit would kick the
/// client that displaced it. `GlueParent.lua` retries nothing.
#[test]
@@ -1101,19 +1363,23 @@ mod tests {
assert_eq!(dialog.text, "Disconnected from server");
}
/// A logout rides the same message and keeps its silent relist; its roster is the character
/// select the player asked for.
/// A logout rides the same message but keeps the world link, so nothing logs on again: the
/// IO thread is at character select on that link, and the roster comes on it.
#[test]
fn a_logout_teardown_still_relists_at_once() {
let (mut app, _requests) = policy_app();
app.world_mut().resource_mut::<LoginIntent>().creds =
Some(("player".into(), "secret".into()));
fn a_logout_teardown_logs_on_no_more() {
let (mut app, requests) = policy_app();
{
let mut intent = app.world_mut().resource_mut::<LoginIntent>();
intent.creds = Some(("player".into(), "secret".into()));
intent.park = IoPark::Active;
}
app.world_mut().write_message(DisconnectedMessage {
reason: "logged out".into(),
end: benilla_protocol::SessionEnd::LoggedOut,
session_over: false,
});
app.update();
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(
@@ -1121,9 +1387,12 @@ mod tests {
"a logout keeps the account signed in"
);
assert!(
intent.in_flight,
"and the same tick resubmits — the delay for a logout is 0, so the roster comes \
straight back",
intent.park == IoPark::Active && !intent.in_flight && intent.retry_at.is_none(),
"and submits nothing: the link it entered by is still up",
);
assert!(
requests.try_recv().is_err(),
"no login request reached the IO thread"
);
assert!(
app.world().resource::<GlueDialog>().kind.is_none(),
@@ -1131,8 +1400,8 @@ mod tests {
);
}
/// A refused character login's relist brings a roster a second later; it must not close
/// the refusal's `Error`.
/// A refused character login's roster comes on the kept link right behind the refusal; it must
/// not close the refusal's `Error`.
#[test]
fn an_arriving_roster_closes_the_connecting_dialog_but_not_an_error() {
let mut app = App::new();
@@ -1141,15 +1410,19 @@ mod tests {
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.add_systems(Update, to_select_on_roster);
// The refusal's dialog survives its own relist.
// The refusal's dialog survives the roster behind it.
app.world_mut()
.resource_mut::<GlueDialog>()
.open_error("World server is down");
app.world_mut().write_message(CharListMessage {
characters: Vec::new(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
let dialog = app.world().resource::<GlueDialog>();
@@ -1163,11 +1436,157 @@ mod tests {
app.world_mut().write_message(CharListMessage {
characters: Vec::new(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
assert!(app.world().resource::<GlueDialog>().kind.is_none());
}
/// The login's success edge is the authentication under the stock screens, whose roster comes
/// only after the screen is up and asks, and the roster under the native ones. Until the stock
/// edge the state stays `Login`, so the screen's Exit still abandons the attempt.
#[test]
fn the_stock_login_ends_at_the_authentication_and_the_native_at_the_roster() {
use crate::net::WorldAuthMessage;
let roster = || CharListMessage {
characters: Vec::new(),
realm: None,
cause: benilla_protocol::RosterCause::Asked,
epoch: 1,
generation: 0,
};
let auth = || WorldAuthMessage {
realm: benilla_protocol::RealmInfo {
name: "Fake".into(),
address: "127.0.0.1:8085".into(),
realm_type: 0,
flags: 0,
population: 0.0,
characters: 0,
category: 1,
id: 1,
},
epoch: 1,
generation: 0,
};
for stock in [true, false] {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::Login)
.insert_resource(crate::ui_script::glue::GlueStock(stock))
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.add_systems(Update, to_select_on_roster);
let (first, second) = (
|app: &mut App| {
if stock {
app.world_mut().write_message(roster());
} else {
app.world_mut().write_message(auth());
}
},
|app: &mut App| {
if stock {
app.world_mut().write_message(auth());
} else {
app.world_mut().write_message(roster());
}
},
);
// The message of the other policy's edge leaves the screen where it is.
first(&mut app);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::Login,
"stock {stock}"
);
second(&mut app);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
"stock {stock}"
);
}
}
/// An attempt a Cancel or a disconnect has passed since is no edge: the screen stays on login
/// and the session that parked behind its back is abandoned, under either policy.
#[test]
fn a_login_edge_behind_the_abandon_generation_abandons_the_session() {
use crate::net::WorldAuthMessage;
for stock in [true, false] {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::Login)
.insert_resource(crate::ui_script::glue::GlueStock(stock))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(1),
)))
.insert_resource(crate::net::CharPick(tx.into()))
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.add_systems(Update, to_select_on_roster);
let send = |app: &mut App, generation: u64| {
if stock {
app.world_mut().write_message(WorldAuthMessage {
realm: benilla_protocol::RealmInfo {
name: "Fake".into(),
address: "127.0.0.1:8085".into(),
realm_type: 0,
flags: 0,
population: 0.0,
characters: 0,
category: 1,
id: 1,
},
epoch: 1,
generation,
});
} else {
app.world_mut().write_message(CharListMessage {
characters: Vec::new(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 1,
generation,
});
}
};
send(&mut app, 0);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::Login,
"stock {stock}: a stale edge"
);
assert!(
matches!(rx.try_recv(), Ok(crate::net::CharRequest::Abandon)),
"stock {stock}: the parked session is abandoned"
);
send(&mut app, 1);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
"stock {stock}: the current attempt's edge"
);
assert!(rx.try_recv().is_err());
}
}
/// An unattended run still reconnects on a lost session; the verdict rides the message.
#[test]
fn an_unattended_run_still_reconnects_on_its_own() {
+48 -35
View File
@@ -72,47 +72,60 @@ impl QueueEstimate {
realm: Option<&str>,
strings: &crate::glue_strings::GlueStrings,
) -> String {
let position = self.position().unwrap_or(0);
let named = realm.filter(|r| !r.is_empty());
let (key, remaining) = self.key(now_ms, realm);
let fallback = match key {
"QUEUE_NAME_TIME_LEFT_UNKNOWN" => {
"%s is Full\nPosition in queue: %d\nEstimated time: Calculating..."
}
"QUEUE_NAME_TIME_LEFT_SECONDS" => {
"%s is Full\nPosition in queue: %d\nEstimated time: < 1 minute"
}
"QUEUE_NAME_TIME_LEFT" => "%s is Full\nPosition in queue: %d\nEstimated time: %d min",
"QUEUE_TIME_LEFT_UNKNOWN" => {
"Realm is Full\nPosition in queue: %d\nEstimated time: Calculating..."
}
"QUEUE_TIME_LEFT_SECONDS" => {
"Realm is Full\nPosition in queue: %d\nEstimated time: < 1 minute"
}
_ => "Realm is Full\nPosition in queue: %d\nEstimated time: %d min",
};
self.fill(strings.text(key, fallback), realm, remaining)
}
/// [`Self::text`] with the `QUEUE_*` template looked up by `lookup(key)`: the stock glue
/// screens read it off the glue VM's own `GlueStrings.lua`.
pub(crate) fn text_by(
&self,
now_ms: u32,
realm: Option<&str>,
lookup: impl Fn(&str) -> String,
) -> String {
let (key, remaining) = self.key(now_ms, realm);
self.fill(&lookup(key), realm, remaining)
}
/// The `QUEUE_*` key for this state, and the wait left in ms once there is an estimate.
fn key(&self, now_ms: u32, realm: Option<&str>) -> (&'static str, Option<i64>) {
let named = realm.is_some_and(|r| !r.is_empty());
let remaining = self.estimate_ms(now_ms).and_then(|est| {
(est > 0).then(|| i64::from(self.tick_ms[0]) + est - i64::from(now_ms))
});
let (key, fallback) = match remaining {
None => (
"QUEUE_NAME_TIME_LEFT_UNKNOWN",
"%s is Full\nPosition in queue: %d\nEstimated time: Calculating...",
),
Some(ms) if ms < MINUTE_MS => (
"QUEUE_NAME_TIME_LEFT_SECONDS",
"%s is Full\nPosition in queue: %d\nEstimated time: < 1 minute",
),
Some(_) => (
"QUEUE_NAME_TIME_LEFT",
"%s is Full\nPosition in queue: %d\nEstimated time: %d min",
),
};
let (key, fallback) = match named {
Some(_) => (key, fallback),
None => match remaining {
None => (
"QUEUE_TIME_LEFT_UNKNOWN",
"Realm is Full\nPosition in queue: %d\nEstimated time: Calculating...",
),
Some(ms) if ms < MINUTE_MS => (
"QUEUE_TIME_LEFT_SECONDS",
"Realm is Full\nPosition in queue: %d\nEstimated time: < 1 minute",
),
Some(_) => (
"QUEUE_TIME_LEFT",
"Realm is Full\nPosition in queue: %d\nEstimated time: %d min",
),
},
let key = match (named, remaining) {
(true, None) => "QUEUE_NAME_TIME_LEFT_UNKNOWN",
(true, Some(ms)) if ms < MINUTE_MS => "QUEUE_NAME_TIME_LEFT_SECONDS",
(true, Some(_)) => "QUEUE_NAME_TIME_LEFT",
(false, None) => "QUEUE_TIME_LEFT_UNKNOWN",
(false, Some(ms)) if ms < MINUTE_MS => "QUEUE_TIME_LEFT_SECONDS",
(false, Some(_)) => "QUEUE_TIME_LEFT",
};
(key, remaining)
}
let mut out = strings.text(key, fallback).to_string();
if let Some(realm) = named {
/// The template's holes: the realm's name, the position, then the minutes.
fn fill(&self, template: &str, realm: Option<&str>, remaining: Option<i64>) -> String {
let position = self.position().unwrap_or(0);
let mut out = template.to_string();
if let Some(realm) = realm.filter(|r| !r.is_empty()) {
out = out.replacen("%s", realm, 1);
}
out = out.replacen("%d", &position.to_string(), 1);
+8 -5
View File
@@ -141,6 +141,7 @@ fn spawn_screen(
let root = commands
.spawn((
crate::glue::NativeGlue,
LoginUi {
with_art: art.button_up.is_some(),
s,
@@ -603,25 +604,27 @@ pub(super) fn refresh_checkbox(
}
}
/// Leaving the login screen: drop the tree, the scene, and any open dialog.
/// Leaving the login screen: drop the tree and any open dialog.
pub(super) fn exit_login(
mut commands: Commands,
roots: Query<Entity, With<LoginUi>>,
mut preview: ResMut<crate::portrait::GluePreview>,
mut dialog: ResMut<crate::glue::dialog::GlueDialog>,
) {
for e in &roots {
commands.entity(e).despawn();
}
// The next screen sets its own scene the same frame.
preview.scene = None;
preview.look = None;
if let Some(root) = dialog.root.take() {
commands.entity(root).despawn();
}
dialog.close();
}
/// Leaving the native login screen drops its scene; the next screen sets its own the same frame.
pub(super) fn clear_login_scene(mut preview: ResMut<crate::portrait::GluePreview>) {
preview.scene = None;
preview.look = None;
}
/// `WOW_LOGIN_SHOT_OUT=<path>`: one PNG of the login screen once it has settled.
pub(super) fn debug_login_shot(
mut commands: Commands,
+5 -5
View File
@@ -107,9 +107,9 @@ pub(in crate::minimap) fn emit_quest_dots(
color: [tint, tint, tint, ctx.alpha],
..default()
});
if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) {
if let Some(c) = ctx.cursor {
if rect.contains(c) {
*hover = MinimapBlipHover::Npc(npc, ui, grey);
*hover = MinimapBlipHover::Npc(npc, grey);
}
}
}
@@ -200,11 +200,11 @@ pub(in crate::minimap) fn emit_tracking_dots(
color: [tint, tint, tint, ctx.alpha],
..default()
});
if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) {
if let Some(c) = ctx.cursor {
if rect.contains(c) {
*hover = match name {
DotName::Guid => MinimapBlipHover::Npc(guid, ui, grey),
DotName::Known(n) => MinimapBlipHover::TrackedGo(n, ui, grey),
DotName::Guid => MinimapBlipHover::Npc(guid, grey),
DotName::Known(n) => MinimapBlipHover::TrackedGo(n, grey),
};
}
}
+32 -38
View File
@@ -144,13 +144,13 @@ const POI_ICON_PX: f32 = 16.0;
pub(crate) enum MinimapBlipHover {
#[default]
None,
/// A landmark's name and the cursor's UI-space point, the tooltip's seat.
Landmark(String, Vec2),
/// A unit dot's guid, resolved through the name cache, the cursor point, and whether the dot
/// drew grey; the tooltip line greys the same way (`|cffb0b0b0`).
Npc(u64, Vec2, bool),
/// A tracked GameObject dot: its template name, the cursor point and the grey flag.
TrackedGo(String, Vec2, bool),
/// A landmark's name.
Landmark(String),
/// A unit dot's guid, resolved through the name cache, and whether the dot drew grey; the
/// tooltip line greys the same way (`|cffb0b0b0`).
Npc(u64, bool),
/// A tracked GameObject dot: its template name and the grey flag.
TrackedGo(String, bool),
}
/// The blip layer's system inputs, tupled to keep `emit_minimap` under Bevy's 16-parameter cap.
@@ -199,9 +199,6 @@ pub(super) struct BlipCtx {
pub(super) wz: f32,
/// The cursor in the quads' y-down logical px.
pub(super) cursor: Option<Vec2>,
/// The cursor in the y-up root units; the reference seats the blip tooltip at the cursor, its
/// exact offset untraced.
pub(super) cursor_ui: Option<Vec2>,
/// Window px per root unit: everything else here is window px, while Lua
/// ([`Minimap:PingLocation`](super::ping)) hands root units.
pub(super) seam: f32,
@@ -306,9 +303,9 @@ pub(super) fn emit_landmarks(
color: [1.0, 1.0, 1.0, ctx.alpha],
..default()
});
if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) {
if let Some(c) = ctx.cursor {
if rect.contains(c) {
*hover = MinimapBlipHover::Landmark(poi.name.clone(), ui);
*hover = MinimapBlipHover::Landmark(poi.name.clone());
}
}
}
@@ -318,9 +315,9 @@ pub(super) fn emit_landmarks(
continue;
};
let rect = push_rim_arrow(ctx, poi.pos, tex, quads);
if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) {
if let Some(c) = ctx.cursor {
if rect.contains(c) {
*hover = MinimapBlipHover::Landmark(poi.name.clone(), ui);
*hover = MinimapBlipHover::Landmark(poi.name.clone());
}
}
}
@@ -402,48 +399,45 @@ pub(super) fn emit_party_arrows(
}
}
/// Show the hovered blip's name; a unit's resolves through the name cache. Hover loss fades the
/// tooltip, never hides it at once. Runs after `UnitFeed`, so a same-frame move from a world
/// hover to a blip ends on the blip's tooltip.
/// Show the hovered blip's name; a unit's resolves through the name cache. This is the in-rect
/// half of the Minimap's probe `0x4eb0c0`, which the hover walk runs on the Minimap while it holds
/// the hover or the capture ([`UiScript::minimap_probed`]). A new blip shows; no blip where one
/// was held hides `GameTooltip`, whoever holds it (`0x4eb55f`). The cursor off the Minimap is the
/// walk's: the probe's miss and the Minimap's leave.
pub(super) fn drive_blip_tooltip(
script: Option<NonSendMut<UiScript>>,
hover: Res<MinimapBlipHover>,
names: Res<NameCache>,
commands: Res<NetCommands>,
mut last: Local<crate::ui_script::VmMemo<Option<(String, Vec2)>>>,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
if !script.minimap_probed() {
return;
}
let show = match &*hover {
MinimapBlipHover::None => None,
MinimapBlipHover::Landmark(name, at) => Some((name.clone(), *at, false)),
MinimapBlipHover::Npc(npc, at, grey) => names
MinimapBlipHover::Landmark(name) => Some((name.clone(), false)),
MinimapBlipHover::Npc(npc, grey) => names
.resolve(*npc, &commands)
.map(|n| (n.to_string(), *at, *grey)),
MinimapBlipHover::TrackedGo(name, at, grey) => Some((name.clone(), *at, *grey)),
.map(|n| (n.to_string(), *grey)),
MinimapBlipHover::TrackedGo(name, grey) => Some((name.clone(), *grey)),
};
match (&show, &*last) {
// Same blip: the reference's blip tooltip follows the cursor.
(Some((t, at, _)), Some((lt, lat))) if t == lt => {
if at != lat {
script.world_tooltip_move(at.x, at.y);
*last = Some((t.clone(), *at));
}
}
(Some((t, at, grey)), _) => {
script.minimap_tooltip(t, at.x, at.y, *grey);
*last = Some((t.clone(), *at));
}
(None, Some(_)) => {
script.world_tooltip_fade();
*last = None;
match (show, script.minimap_blip()) {
// Same blip: the plate, owned in mode 6, follows the cursor in its own update.
(Some((t, _)), Some(held)) if t == held => {}
(Some((t, grey)), _) => {
script.minimap_tooltip(&t, grey);
}
(None, Some(_)) => script.minimap_blip_lost(),
(None, None) => {}
}
}
#[cfg(test)]
mod tooltip_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,68 @@
//! The blip tooltip driver against the UI's own hover: the Minimap's probe and leave.
use benilla_ui::script::UiScript;
use bevy::prelude::*;
use super::{drive_blip_tooltip, MinimapBlipHover};
use crate::names::NameCache;
use crate::net::NetCommands;
/// A full-screen `UIParent`, a mouse-enabled `Minimap` at (600, 400)–(740, 540) with the cursor on
/// it, and a `GameTooltip`; the driver runs each update.
fn minimap_app() -> App {
let mut app = App::new();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx))
.init_resource::<NameCache>()
.init_resource::<MinimapBlipHover>()
.add_systems(Update, drive_blip_tooltip);
let mut script = UiScript::new().unwrap();
script.set_screen_size(800.0, 600.0);
script
.run(
r#"
UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints()
local map = CreateFrame("Minimap", "Minimap", UIParent)
map:SetPoint("BOTTOMLEFT", 600, 400); map:SetWidth(140); map:SetHeight(140)
map:EnableMouse(true)
CreateFrame("GameTooltip", "GameTooltip", UIParent):Hide()
"#,
)
.unwrap();
script.resolve();
script.mouse_move(670.0, 470.0);
app.insert_non_send_resource(script);
app
}
fn blip(app: &mut App, hover: MinimapBlipHover) {
*app.world_mut().resource_mut::<MinimapBlipHover>() = hover;
app.update();
}
fn shown(app: &mut App) -> bool {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<bool>("return GameTooltip:IsShown() == 1")
.unwrap()
}
/// Off every blip, the probe `0x4eb0c0` hides `GameTooltip` directly (`0x4eb55f`–`0x4eb567`),
/// whoever holds it: an addon tooltip taken over the blip's goes too.
#[test]
fn a_blip_hover_loss_hides_whoever_holds_the_tooltip() {
let mut app = minimap_app();
blip(&mut app, MinimapBlipHover::Landmark("Stormwind".into()));
assert!(shown(&mut app), "the blip tooltip is up");
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE"); GameTooltip:SetText("Addon")"#)
.unwrap();
blip(&mut app, MinimapBlipHover::None);
assert!(!shown(&mut app), "the addon's tooltip is hidden");
assert!(app
.world_mut()
.non_send_resource_mut::<UiScript>()
.take_errors()
.is_empty());
}
+2 -7
View File
@@ -636,11 +636,6 @@ fn emit_minimap(
wy: (wy / q).round() * q,
wz: wow[2],
cursor,
// The cursor in the root's units (y-up, ÷ the seam scale): the tooltip anchor resolves
// in them, not window px.
cursor_ui: cursor
.zip(win)
.map(|(c, w)| crate::ui_script::window_to_ui(w.height(), c)),
seam,
}
});
@@ -906,8 +901,8 @@ impl Plugin for MinimapPlugin {
.run_if(crate::ui_script::ingame_ui_up),
// Before the script tick, so GameTimeFrame reads this frame's minute.
feed_game_time.in_set(crate::ui_script::UiFeed),
// After the world-mouseover drive (UnitFeed): a same-frame world-hover→blip
// transition must end with the blip tooltip shown, not the fade.
// In the feed, off the hover the last walk left; after the world drive only so
// the two VM writers keep one order.
blips::drive_blip_tooltip
.after(crate::ui_unit::UnitFeed)
.in_set(crate::ui_script::UiFeed),
-1
View File
@@ -189,7 +189,6 @@ mod tests {
wy: 0.0,
wz: 0.0,
cursor: None,
cursor_ui: None,
seam: 1.0,
}
}
+2
View File
@@ -309,6 +309,8 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh {
path: None, // UNIT_NAME_FONT = Friz Quadrata, the default face
height: Some(BAKE_PX),
outline: Outline::None,
spacing: 0.0, // one line
shadow_x: 0.0,
alpha_gradient: None,
},
// A world billboard: the glyphs are re-seated in pitch units below, off the UI grid.
+158 -16
View File
@@ -84,6 +84,7 @@ impl Plugin for NetPlugin {
.insert_resource(NetCommands(handles.commands))
.insert_resource(CharPick(handles.pick))
.insert_resource(RealmChoice(handles.realm))
.insert_resource(RosterPolicy(handles.screen_asks_roster))
.insert_resource(LoginSubmit(handles.login))
.insert_resource(LoginAbandon(handles.login_abandon))
.insert_resource(PingShared(handles.ping))
@@ -118,6 +119,8 @@ impl Plugin for NetPlugin {
.add_message::<WorldportMessage>()
.add_message::<RealmListMessage>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.add_message::<RosterFailedMessage>()
.add_message::<CharActionResultMessage>()
.add_message::<CharacterLoginFailedMessage>()
.add_message::<EnteredWorldMessage>()
@@ -379,9 +382,89 @@ pub(crate) struct NetEvents(Receiver<SessionEvent>);
pub(crate) struct NetCommands(pub(crate) Sender<ClientCommand>);
/// The character-select channel to the IO thread parked at select: create and delete are served
/// in place, re-emitting the roster, until an [`CharRequest::Enter`] moves into the world.
/// in place, re-emitting the roster (under the native policy), until an [`CharRequest::Enter`]
/// moves into the world.
#[derive(Resource)]
pub(crate) struct CharPick(pub(crate) Sender<CharRequest>);
pub(crate) struct CharPick(pub(crate) ParkSender<CharRequest>);
/// A request to the IO thread at a park. The character screen's and the realm list's requests
/// travel on one channel, in the order the app made them: the reference's verbs act at the call
/// (`GetCharacterListUpdate` sends its `CMSG_CHAR_ENUM`, `ChangeRealm` then closes the link), so
/// the IO thread must meet them in that order, which two channels cannot give.
pub(crate) enum ParkRequest {
Char(CharRequest),
Realm(RealmRequest),
}
impl From<CharRequest> for ParkRequest {
fn from(r: CharRequest) -> Self {
ParkRequest::Char(r)
}
}
impl From<RealmRequest> for ParkRequest {
fn from(r: RealmRequest) -> Self {
ParkRequest::Realm(r)
}
}
/// The IO thread is gone.
#[derive(Debug)]
pub(crate) struct ParkGone;
/// The app's end of the park channel for one kind of request.
pub(crate) enum ParkSender<T> {
/// The shared, ordered channel the IO thread reads.
Park(Sender<ParkRequest>, std::marker::PhantomData<fn(T)>),
/// A channel of that kind alone, for a test that reads the requests itself.
#[cfg(test)]
Direct(Sender<T>),
}
impl<T> Clone for ParkSender<T> {
fn clone(&self) -> Self {
match self {
ParkSender::Park(tx, kind) => ParkSender::Park(tx.clone(), *kind),
#[cfg(test)]
ParkSender::Direct(tx) => ParkSender::Direct(tx.clone()),
}
}
}
impl<T> ParkSender<T> {
/// The app's end of the IO thread's park channel.
pub(super) fn park(tx: Sender<ParkRequest>) -> Self {
ParkSender::Park(tx, std::marker::PhantomData)
}
}
impl<T> ParkSender<T> {
/// Send a request of either kind, in the order it is made against the rest.
pub(crate) fn send_request(&self, request: ParkRequest) -> Result<(), ParkGone> {
match self {
ParkSender::Park(tx, _) => tx.send(request).map_err(|_| ParkGone),
#[cfg(test)]
ParkSender::Direct(_) => Err(ParkGone),
}
}
}
impl<T: Into<ParkRequest>> ParkSender<T> {
pub(crate) fn send(&self, request: T) -> Result<(), ParkGone> {
match self {
ParkSender::Park(tx, _) => tx.send(request.into()).map_err(|_| ParkGone),
#[cfg(test)]
ParkSender::Direct(tx) => tx.send(request).map_err(|_| ParkGone),
}
}
}
#[cfg(test)]
impl<T> From<Sender<T>> for ParkSender<T> {
fn from(tx: Sender<T>) -> Self {
ParkSender::Direct(tx)
}
}
/// One request to the IO thread parked at character select.
pub(crate) enum CharRequest {
@@ -393,24 +476,48 @@ pub(crate) enum CharRequest {
Delete(u64),
/// Select's Back: drop the session and return to the pre-logon park.
Abandon,
/// Fetch the roster again (`CMSG_CHAR_ENUM`) on the parked session, then stay parked with
/// the fresh roster: `GetCharacterListUpdate`, which the stock screen calls whenever it shows.
Enumerate,
}
/// Whether the screen asks for its roster (the stock glue screens' `GetCharacterListUpdate`)
/// instead of the IO thread fetching it on reaching the character park (the native screens'). Set
/// by the UI session as it installs the VM that runs: true with the stock glue VM up, false
/// otherwise, so a run whose stock VM did not build still gets its roster. The IO thread reads it
/// as each park is entered.
#[derive(Resource, Clone)]
pub(crate) struct RosterPolicy(pub(crate) std::sync::Arc<std::sync::atomic::AtomicBool>);
/// The realm channel: the answer to each [`RealmListMessage`]. Two parks listen, the login-side
/// one and the character one, since the reference's realm list is a dialog over either screen
/// (`RealmList.xml` is `frameStrata="DIALOG"`).
#[derive(Resource)]
pub(crate) struct RealmChoice(pub(crate) Sender<RealmRequest>);
pub(crate) struct RealmChoice(pub(crate) ParkSender<RealmRequest>);
/// One request to the IO thread parked at the realm list.
/// One request to the IO thread about the realm list, served at the logon park and at character
/// select; at the login park a query fails and the rest are dropped.
#[derive(Debug)]
pub(crate) enum RealmRequest {
/// Enter this realm, by name: the list refreshes while open, so an index could shift.
Enter(String),
/// Re-request the realm list (`RequestRealmList`, every 5 s from `RealmList_OnUpdate`).
/// Dial this realm (`ConnectToRealm`, `0x46b210`), the record as the list held it, as the
/// reference dials its copy (`0xc27fb8`) whatever a later list says. `generation` is the
/// abandon generation the app held when it asked: a later Cancel ends the dial however early
/// it lands.
Enter {
realm: benilla_protocol::RealmInfo,
generation: u64,
},
/// Query the realm list (`RequestRealmList`, every 5 s from `RealmList_OnUpdate`); the answer
/// is a [`RealmListMessage`], failed when the link gave none.
Refresh,
/// Cancel (`RealmList_OnCancel` only hides the frame): the screen underneath stays, login or
/// character select.
Abandon,
/// `CLoginMgr::Cancel` (`0x5b3320`, from `RealmListDialogCancelled` on the login screen, the
/// logon's Cancel and `DisconnectFromServer`): the realmd link closes and the thread parks for
/// credentials. At character select, where `RealmListDialogCancelled` is a no-op, nothing.
Close,
/// `ConnectToRealm` with the world up (`0x46b22b` → `0x5ab1a0`): the session at character
/// select drops and the thread returns to the logon park, the realmd link as it was (closed
/// since a world entry). Elsewhere, nothing.
Leave,
}
/// The credentials channel to the IO thread's pre-logon park, sent by [`crate::login`].
@@ -418,10 +525,17 @@ pub(crate) enum RealmRequest {
pub(crate) struct LoginSubmit(pub(crate) Sender<io::LoginRequest>);
/// The login abandon generation: Cancel bumps it, and the IO thread drops an
/// [`io::LoginRequest`] submitted under an older value.
/// [`io::LoginRequest`] or a [`RealmRequest::Enter`] asked under an older value.
#[derive(Resource)]
pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc<std::sync::atomic::AtomicU64>);
impl LoginAbandon {
/// The generation an ask is stamped with now.
pub(crate) fn now(abandon: Option<&LoginAbandon>) -> u64 {
abandon.map_or(0, |a| a.0.load(std::sync::atomic::Ordering::SeqCst))
}
}
/// Guid to entity for every kind, items included, like the reference's one `ClntObjMgr` index.
/// Written only by `net::objects`.
#[derive(Resource, Default)]
@@ -1978,10 +2092,12 @@ pub(crate) enum ClientCommand {
},
}
/// The realm list (`CMD_REALM_LIST`); the IO thread waits for [`crate::realm_select`] to pick.
/// The realm list (`CMD_REALM_LIST`); the IO thread waits for a [`RealmRequest`]. `failed` is a
/// query that got no answer (`REALM_LIST_FAILED`), `realms` then the list last held.
#[derive(Message)]
pub(crate) struct RealmListMessage {
pub(crate) realms: Vec<benilla_protocol::RealmInfo>,
pub(crate) failed: bool,
}
/// The character roster (`SMSG_CHAR_ENUM`); the IO thread waits for [`crate::char_select`] to
@@ -1990,6 +2106,30 @@ pub(crate) struct RealmListMessage {
pub(crate) struct CharListMessage {
pub(crate) characters: Vec<benilla_protocol::Character>,
pub(crate) realm: Option<benilla_protocol::RealmInfo>,
/// What the roster was fetched for.
pub(crate) cause: benilla_protocol::RosterCause,
/// The world session that sent it ([`WorldAuthMessage::epoch`]).
pub(crate) epoch: u64,
/// The abandon generation of the attempt that dialed that session.
pub(crate) generation: u64,
}
/// The world server accepted the authentication: the world connect to `realm` is done and the IO
/// thread is at the character park (`SMSG_AUTH_RESPONSE` success, `0x5ab2b8`).
#[derive(Message)]
pub(crate) struct WorldAuthMessage {
pub(crate) realm: benilla_protocol::RealmInfo,
/// This world session's number, counted per process: what its roster replies carry.
pub(crate) epoch: u64,
/// The abandon generation of the attempt that dialed it: behind [`LoginAbandon`], the attempt
/// was cancelled or disconnected since, and the session is not the screen's.
pub(crate) generation: u64,
}
/// A roster ask on the world session `epoch` failed on the stream.
#[derive(Message)]
pub(crate) struct RosterFailedMessage {
pub(crate) epoch: u64,
}
/// A character create or delete result; a refreshed [`CharListMessage`] precedes a success.
@@ -2024,7 +2164,7 @@ pub(crate) struct WorldEnterCascadeMessage;
/// `SMSG_ADDON_INFO`: the addons the server hid from the Lua index space, or `None` if it did
/// not answer. A resource, since it must exist before the world-entry UI load runs any addon;
/// rewritten on every login.
/// rewritten on every login from the reply its link got.
#[derive(Resource, Default)]
pub(crate) struct AddonInfoReply(pub(crate) Option<Vec<String>>);
@@ -2058,7 +2198,9 @@ pub(crate) struct LoginQueuedMessage {
pub(crate) realm: Option<String>,
}
/// The session ended; the IO thread is back at its pre-logon park.
/// The world session ended. After a logout or a refused character login ([`SessionEnd::LoggedOut`](
/// benilla_protocol::SessionEnd::LoggedOut)) the IO thread is back at character select on the
/// same link; after a loss, at its pre-logon park.
#[derive(Message, Clone)]
pub(crate) struct DisconnectedMessage {
pub(crate) reason: String,
@@ -2070,8 +2212,8 @@ pub(crate) struct DisconnectedMessage {
}
impl DisconnectedMessage {
/// `session_over` is false after a logout (the relist is the select the player asked for) and
/// in a run declared unattended ([`crate::run_mode::unattended`]), which reconnects. Not the
/// `session_over` is false after a logout (the link is kept and character select is what the
/// player asked for) and in a run declared unattended ([`crate::run_mode::unattended`]), which reconnects. Not the
/// login env vars: a person's launch line sets them too, and a reconnect would take the
/// account back from whoever displaced us.
pub(crate) fn new(reason: String, end: benilla_protocol::SessionEnd) -> Self {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+655
View File
@@ -0,0 +1,655 @@
//! A fake realmd and a fake world server on loopback, for the login cycle's tests: the real SRP6
//! logon, the realm list as often as it is asked for, the world handshake and the roster. Both take
//! any number of connections, so a test can log in again.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use benilla_protocol::messages::{self, opcode};
use benilla_protocol::{SessionEnd, SessionEvent};
use benilla_srp::vanilla_header::HeaderCrypto;
use benilla_srp::{
password_verifier, NormalizedString, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN,
SESSION_KEY_LENGTH,
};
use crossbeam_channel::{Receiver, Sender};
use num_bigint::BigUint;
use sha1::{Digest, Sha1};
use super::{run, writer_loop, Cycle, LoginRequest, Parks, PingClock, WriterSwap};
use crate::net::{CharRequest, ClientCommand, ParkRequest, ParkSender, RealmRequest};
pub(crate) const USER: &str = "empty";
pub(crate) const PASS: &str = "secret";
const SALT: [u8; 32] = [0x5a; 32];
/// Every wait in the tests: long enough for a loaded machine, short of a hung gate.
pub(crate) const WAIT: Duration = Duration::from_secs(20);
/// A session key, realmd to the world server.
type Key = [u8; SESSION_KEY_LENGTH];
fn sha1(parts: &[&[u8]]) -> [u8; 20] {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
fn le32(v: &BigUint) -> [u8; 32] {
let raw = v.to_bytes_le();
let mut out = [0u8; 32];
out[..raw.len()].copy_from_slice(&raw);
out
}
/// `SHA1_Interleave` over all 32 bytes of `S`, as vmangos's `SRP6::HashSessionKey` does.
fn interleave(s: &[u8; 32]) -> Key {
let even: Vec<u8> = s.iter().step_by(2).copied().collect();
let odd: Vec<u8> = s.iter().skip(1).step_by(2).copied().collect();
let (g, h) = (sha1(&[&even]), sha1(&[&odd]));
let mut out = [0u8; SESSION_KEY_LENGTH];
for (i, (gi, hi)) in g.iter().zip(&h).enumerate() {
out[i * 2] = *gi;
out[i * 2 + 1] = *hi;
}
out
}
/// One realm the fake realmd lists.
#[derive(Clone)]
pub(crate) struct FakeRealm {
pub(crate) name: String,
/// `host:port` the realm's world server answers on.
pub(crate) address: String,
pub(crate) realm_type: u32,
pub(crate) flags: u8,
pub(crate) characters: u8,
pub(crate) category: u8,
}
impl FakeRealm {
/// A Normal realm in category 1 at `address`.
pub(crate) fn new(name: &str, address: &str) -> Self {
FakeRealm {
name: name.into(),
address: address.into(),
realm_type: 0,
flags: 0,
characters: 0,
category: 1,
}
}
}
/// A realmd that logs [`USER`] in with the server half of SRP6 and answers every
/// `CMD_REALM_LIST` with its realms, on every connection, until the client closes it.
pub(crate) struct Realmd {
pub(crate) addr: String,
logons: Arc<AtomicUsize>,
lists: Arc<AtomicUsize>,
open: Arc<AtomicUsize>,
links: Arc<Mutex<Vec<TcpStream>>>,
}
impl Realmd {
/// Listen on loopback; each session key goes to `keys`, for the world server's header cipher.
pub(crate) fn start(realms: Vec<FakeRealm>, keys: Sender<Key>) -> Realmd {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let logons = Arc::new(AtomicUsize::new(0));
let lists = Arc::new(AtomicUsize::new(0));
let open = Arc::new(AtomicUsize::new(0));
let links = Arc::new(Mutex::new(Vec::new()));
let (l, n, o) = (logons.clone(), lists.clone(), open.clone());
let held = links.clone();
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(s) = stream else { return };
if let Ok(c) = s.try_clone() {
held.lock().unwrap().push(c);
}
let (realms, keys) = (realms.clone(), keys.clone());
let (l, n, o) = (l.clone(), n.clone(), o.clone());
thread::spawn(move || {
o.fetch_add(1, Ordering::SeqCst);
serve_realmd(s, &realms, &keys, &l, &n);
o.fetch_sub(1, Ordering::SeqCst);
});
}
});
Realmd {
addr,
logons,
lists,
open,
links,
}
}
/// Close every link from the server's side, as vmangos does at `MaxSessionDuration`.
pub(crate) fn drop_links(&self) {
for s in self.links.lock().unwrap().drain(..) {
let _ = s.shutdown(std::net::Shutdown::Both);
}
}
/// Logons completed so far.
pub(crate) fn logons(&self) -> usize {
self.logons.load(Ordering::SeqCst)
}
/// Realm lists sent so far.
pub(crate) fn lists(&self) -> usize {
self.lists.load(Ordering::SeqCst)
}
/// Connections the client still holds.
pub(crate) fn open(&self) -> usize {
self.open.load(Ordering::SeqCst)
}
}
/// One realmd connection: the logon, then the realm list for every request until EOF.
fn serve_realmd(
mut s: TcpStream,
realms: &[FakeRealm],
keys: &Sender<Key>,
logons: &AtomicUsize,
lists: &AtomicUsize,
) {
let n = BigUint::from_bytes_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
let g = BigUint::from(GENERATOR);
let user = NormalizedString::new(USER).unwrap();
let pass = NormalizedString::new(PASS).unwrap();
let v = BigUint::from_bytes_le(&password_verifier(&user, &pass, &SALT));
// CMD_AUTH_LOGON_CHALLENGE_Client: opcode, protocol, u16 size, then the body.
let mut head = [0u8; 4];
if s.read_exact(&mut head).is_err() {
return;
}
let mut body = vec![0u8; usize::from(u16::from_le_bytes([head[2], head[3]]))];
if s.read_exact(&mut body).is_err() {
return;
}
// B = k·v + g^b, with k = 3; `b` walks until `B`'s top byte is non-zero, since the client
// redials on a zero one.
let (b, b_pub) = (1u32..)
.map(|i| {
let b = BigUint::from_bytes_le(&[0x3c; 32]) + i;
let b_pub = le32(&((BigUint::from(3u8) * &v + g.modpow(&b, &n)) % &n));
(b, b_pub)
})
.find(|(_, b_pub)| b_pub[31] != 0)
.unwrap();
let mut reply = vec![0x00, 0x00, 0x00];
reply.extend_from_slice(&b_pub);
reply.extend_from_slice(&[1, GENERATOR, 32]);
reply.extend_from_slice(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
reply.extend_from_slice(&SALT);
reply.extend_from_slice(&[0u8; 16]); // crc_salt
reply.push(0); // security_flag
if s.write_all(&reply).is_err() {
return;
}
// CMD_AUTH_LOGON_PROOF_Client: opcode, A, M1, crc_hash, key count, security flag.
let mut proof = [0u8; 75];
if s.read_exact(&mut proof).is_err() {
return;
}
let (a_pub, m1) = (&proof[1..33], &proof[33..53]);
let u = BigUint::from_bytes_le(&sha1(&[a_pub, &b_pub]));
let a = BigUint::from_bytes_le(a_pub);
let big_s = (a * v.modpow(&u, &n) % &n).modpow(&b, &n);
let key = interleave(&le32(&big_s));
let mut reply = vec![0x01, 0x00];
reply.extend_from_slice(&sha1(&[a_pub, m1, &key]));
reply.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id
if s.write_all(&reply).is_err() {
return;
}
let _ = keys.send(key);
logons.fetch_add(1, Ordering::SeqCst);
// CMD_REALM_LIST_Client (0x10 and a u32), answered every time, until the client closes the
// link.
let mut request = [0u8; 5];
while s.read_exact(&mut request).is_ok() {
assert_eq!(request[0], 0x10, "a realm list request");
let mut records = Vec::new();
for r in realms {
records.extend_from_slice(&r.realm_type.to_le_bytes());
records.push(r.flags);
records.extend_from_slice(format!("{}\0{}\0", r.name, r.address).as_bytes());
records.extend_from_slice(&1f32.to_le_bytes()); // population
records.extend_from_slice(&[r.characters, r.category, 1]); // characters, category, id
}
let mut list = vec![0x10];
list.extend_from_slice(&((4 + 1 + records.len() + 2) as u16).to_le_bytes());
list.extend_from_slice(&0u32.to_le_bytes());
list.push(realms.len() as u8);
list.extend_from_slice(&records);
// vmangos's trailer (`AuthSocket.cpp:1088`).
list.extend_from_slice(&0x0002u16.to_le_bytes());
if s.write_all(&list).is_err() {
return;
}
lists.fetch_add(1, Ordering::SeqCst);
}
}
/// A server packet: `u16` BE size counting the opcode, `u16` LE opcode, the body.
fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u8]) {
let size = ((body.len() + 2) as u16).to_be_bytes();
let op = op.to_le_bytes();
let mut header = [size[0], size[1], op[0], op[1]];
if let Some(c) = crypto {
c.encrypter().encrypt(&mut header);
}
let _ = s.write_all(&header);
let _ = s.write_all(body);
}
/// A client packet as it left the client, header decrypted: `u16` BE size counting the 4-byte
/// opcode, `u32` LE opcode, the body.
fn recv_packet(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option<Vec<u8>> {
let mut header = [0u8; 6];
s.read_exact(&mut header).ok()?;
if let Some(c) = crypto {
c.decrypter().decrypt(&mut header);
}
let size = usize::from(u16::from_be_bytes([header[0], header[1]]));
let mut packet = header.to_vec();
packet.resize(2 + size, 0);
s.read_exact(&mut packet[6..]).ok()?;
Some(packet)
}
/// A client packet's opcode, reading past its body.
fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option<u16> {
let packet = recv_packet(s, crypto)?;
Some(u16::from_le_bytes([packet[2], packet[3]]))
}
/// An `SMSG_CHAR_ENUM` body for `characters`, in the order the client parses it
/// (`Character::read`, the reference's `0x5b42a0`).
pub(crate) fn char_enum_body(characters: &[benilla_protocol::Character]) -> Vec<u8> {
let mut b = vec![characters.len() as u8];
for c in characters {
b.extend_from_slice(&c.guid.to_le_bytes());
b.extend_from_slice(c.name.as_bytes());
b.push(0);
b.extend_from_slice(&[
c.race,
c.class,
c.gender,
c.skin,
c.face,
c.hair_style,
c.hair_color,
c.facial_hair,
c.level,
]);
b.extend_from_slice(&c.zone.to_le_bytes());
b.extend_from_slice(&c.map.to_le_bytes());
for v in [c.position.x, c.position.y, c.position.z] {
b.extend_from_slice(&v.to_le_bytes());
}
b.extend_from_slice(&0u32.to_le_bytes()); // guild
b.extend_from_slice(&c.flags.to_le_bytes());
b.push(0); // first login
for v in [c.pet_display_id, c.pet_level, c.pet_family] {
b.extend_from_slice(&v.to_le_bytes());
}
for item in &c.equipment {
b.extend_from_slice(&item.display_id.to_le_bytes());
b.push(item.inventory_type);
}
b.extend_from_slice(&0u32.to_le_bytes()); // first bag
b.push(0);
}
b
}
/// Packets, by opcode and body, the fake world sends behind `SMSG_LOGOUT_COMPLETE`.
type Trail = Arc<Mutex<Vec<(u16, Vec<u8>)>>>;
/// The fake world server's record: every opcode the client sent, and the sessions still open.
pub(crate) struct World {
pub(crate) sent: Arc<Mutex<Vec<u16>>>,
/// Every packet the client sent, header decrypted, as [`recv_packet`] reads it.
pub(crate) packets: Arc<Mutex<Vec<Vec<u8>>>>,
/// Every session's packets in the order it sent them, by the order the sessions began.
pub(crate) session_packets: Arc<Mutex<Vec<Vec<Vec<u8>>>>>,
/// The `SMSG_CHAR_ENUM` body each session answers its `CMSG_CHAR_ENUM` with, by the order the
/// sessions began (the last repeats): no characters until set.
rosters: Arc<Mutex<Vec<Vec<u8>>>>,
/// How long a `CMSG_CHAR_ENUM` waits before its answer.
enum_delay: Arc<Mutex<Duration>>,
/// Answer `CMSG_CHAR_ENUM` with `SMSG_WARDEN_DATA`, as a server does that wants Warden.
warden: Arc<AtomicBool>,
/// Close the socket on `CMSG_CHAR_ENUM` instead of answering.
cut: Arc<AtomicBool>,
/// Answer `CMSG_PLAYER_LOGIN` with `SMSG_CHARACTER_LOGIN_FAILED`.
refuse: Arc<AtomicBool>,
/// Packets sent right behind `SMSG_LOGOUT_COMPLETE`, as the world session leaves them in flight.
trail: Trail,
open: Arc<AtomicUsize>,
}
impl World {
/// The characters the account holds from now on.
pub(crate) fn set_roster(&self, characters: &[benilla_protocol::Character]) {
*self.rosters.lock().unwrap() = vec![char_enum_body(characters)];
}
/// The characters each session sees, by the order the sessions begin.
pub(crate) fn set_rosters(&self, per_session: &[&[benilla_protocol::Character]]) {
*self.rosters.lock().unwrap() = per_session.iter().map(|c| char_enum_body(c)).collect();
}
/// Have the server demand Warden in place of the roster from now on.
pub(crate) fn demand_warden(&self) {
self.warden.store(true, Ordering::SeqCst);
}
/// Have the server cut the connection on the next roster ask.
pub(crate) fn cut_on_roster_ask(&self) {
self.cut.store(true, Ordering::SeqCst);
}
/// Have the server refuse every character login from now on (`SMSG_CHARACTER_LOGIN_FAILED`,
/// vmangos's result 1).
pub(crate) fn refuse_logins(&self) {
self.refuse.store(true, Ordering::SeqCst);
}
/// Send these packets right behind every `SMSG_LOGOUT_COMPLETE`.
pub(crate) fn trail_logouts_with(&self, packets: Vec<(u16, Vec<u8>)>) {
*self.trail.lock().unwrap() = packets;
}
/// The `CMSG_AUTH_SESSION`s the server admitted: one per world session.
pub(crate) fn authentications(&self) -> usize {
self.session_packets.lock().unwrap().len()
}
/// Hold every roster answer back this long.
pub(crate) fn delay_rosters(&self, by: Duration) {
*self.enum_delay.lock().unwrap() = by;
}
/// How many `CMSG_CHAR_ENUM` session `index` has sent.
pub(crate) fn enumerations_in(&self, index: usize) -> usize {
self.session_packets
.lock()
.unwrap()
.get(index)
.map_or(0, |packets| {
packets
.iter()
.filter(|p| p[2..4] == opcode::CMSG_CHAR_ENUM.to_le_bytes())
.count()
})
}
/// How many `CMSG_CHAR_ENUM` the client has sent.
pub(crate) fn enumerations(&self) -> usize {
self.sent
.lock()
.unwrap()
.iter()
.filter(|&&op| op == opcode::CMSG_CHAR_ENUM)
.count()
}
/// Sessions the client still holds.
pub(crate) fn open(&self) -> usize {
self.open.load(Ordering::SeqCst)
}
}
/// A world server whose account has no characters: it admits each session under the latest key
/// realmd made, answers every `CMSG_CHAR_ENUM` with its roster (empty until [`World::set_roster`]),
/// a `CMSG_CHAR_CREATE` with success, a `CMSG_LOGOUT_REQUEST` with `SMSG_LOGOUT_COMPLETE` and a
/// `CMSG_PLAYER_LOGIN` with nothing (or a refusal, [`World::refuse_logins`]), and logs every opcode
/// the client sends.
pub(crate) fn world(listener: TcpListener, keys: Receiver<Key>) -> World {
let sent = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&sent);
let packets = Arc::new(Mutex::new(Vec::new()));
let packet_log = Arc::clone(&packets);
let rosters = Arc::new(Mutex::new(vec![vec![0u8]]));
let roster_bodies = Arc::clone(&rosters);
let session_packets = Arc::new(Mutex::new(Vec::<Vec<Vec<u8>>>::new()));
let session_log = Arc::clone(&session_packets);
let enum_delay = Arc::new(Mutex::new(Duration::ZERO));
let enum_wait = Arc::clone(&enum_delay);
let warden = Arc::new(AtomicBool::new(false));
let warden_flag = Arc::clone(&warden);
let cut = Arc::new(AtomicBool::new(false));
let cut_flag = Arc::clone(&cut);
let refuse = Arc::new(AtomicBool::new(false));
let refuse_flag = Arc::clone(&refuse);
let trail: Trail = Arc::default();
let trailing = Arc::clone(&trail);
let open = Arc::new(AtomicUsize::new(0));
let sessions = Arc::clone(&open);
thread::spawn(move || {
let mut last: Option<Key> = None;
for stream in listener.incoming() {
let Ok(mut s) = stream else { return };
send(
&mut s,
None,
opcode::SMSG_AUTH_CHALLENGE,
&0xDEAD_BEEFu32.to_le_bytes(),
);
if recv(&mut s, None) != Some(opcode::CMSG_AUTH_SESSION) {
continue;
}
// The account's latest session key, as realmd stores it for the world server: a newer
// logon's replaces it, and a second dial on the same logon reuses it.
while let Ok(k) = keys.try_recv() {
last = Some(k);
}
if last.is_none() {
last = keys.recv_timeout(WAIT).ok();
}
let Some(key) = last else {
return;
};
let mut crypto = HeaderCrypto::from_session_key(key);
send(
&mut s,
Some(&mut crypto),
opcode::SMSG_AUTH_RESPONSE,
&[messages::AUTH_OK],
);
let log = Arc::clone(&log);
let packet_log = Arc::clone(&packet_log);
let roster_bodies = Arc::clone(&roster_bodies);
let session_log = Arc::clone(&session_log);
let enum_wait = Arc::clone(&enum_wait);
let warden_flag = Arc::clone(&warden_flag);
let cut_flag = Arc::clone(&cut_flag);
let refuse_flag = Arc::clone(&refuse_flag);
let trailing = Arc::clone(&trailing);
let index = {
let mut all = session_log.lock().unwrap();
all.push(Vec::new());
all.len() - 1
};
let sessions = Arc::clone(&sessions);
sessions.fetch_add(1, Ordering::SeqCst);
thread::spawn(move || {
while let Some(packet) = recv_packet(&mut s, Some(&mut crypto)) {
let op = u16::from_le_bytes([packet[2], packet[3]]);
log.lock().unwrap().push(op);
session_log.lock().unwrap()[index].push(packet.clone());
packet_log.lock().unwrap().push(packet);
match op {
opcode::CMSG_CHAR_ENUM => {
let body = {
let all = roster_bodies.lock().unwrap();
all[index.min(all.len() - 1)].clone()
};
let wait = *enum_wait.lock().unwrap();
thread::sleep(wait);
if cut_flag.load(Ordering::SeqCst) {
break;
}
if warden_flag.load(Ordering::SeqCst) {
send(&mut s, Some(&mut crypto), opcode::SMSG_WARDEN_DATA, &[]);
continue;
}
send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &body);
}
opcode::CMSG_CHAR_CREATE => send(
&mut s,
Some(&mut crypto),
opcode::SMSG_CHAR_CREATE,
&[messages::CHAR_CREATE_SUCCESS],
),
opcode::CMSG_PLAYER_LOGIN if refuse_flag.load(Ordering::SeqCst) => send(
&mut s,
Some(&mut crypto),
opcode::SMSG_CHARACTER_LOGIN_FAILED,
&[1],
),
opcode::CMSG_LOGOUT_REQUEST => {
send(&mut s, Some(&mut crypto), opcode::SMSG_LOGOUT_COMPLETE, &[]);
for (op, body) in trailing.lock().unwrap().clone() {
send(&mut s, Some(&mut crypto), op, &body);
}
}
_ => {}
}
}
sessions.fetch_sub(1, Ordering::SeqCst);
});
}
});
World {
sent,
packets,
session_packets,
rosters,
enum_delay,
warden,
cut,
refuse,
trail,
open,
}
}
/// A fake realmd listing `realms` and the world server they point at. `realms` takes the world's
/// address.
pub(crate) fn servers(realms: impl FnOnce(&str) -> Vec<FakeRealm>) -> (Realmd, World) {
let world_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = world_listener.local_addr().unwrap().to_string();
let (keys_tx, keys_rx) = crossbeam_channel::unbounded();
let sent = world(world_listener, keys_rx);
(Realmd::start(realms(&addr), keys_tx), sent)
}
/// An address nothing answers on: a port bound and released.
pub(crate) fn dead_address() -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().to_string()
}
/// The app's ends of the IO thread's channels.
pub(crate) struct Cycles {
pub(crate) events: Receiver<SessionEvent>,
/// The app's commands, to the write thread and on to the world socket while in the world.
pub(crate) commands: Sender<ClientCommand>,
pub(crate) login: Sender<LoginRequest>,
pub(crate) realm: ParkSender<RealmRequest>,
pub(crate) pick: ParkSender<CharRequest>,
pub(crate) abandon: Arc<AtomicU64>,
}
/// The read thread's cycle loop and the write thread, as [`super::spawn_net`] runs them: the native
/// screens' policy, the IO thread enumerating on reaching the character park.
pub(crate) fn spawn_cycles() -> Cycles {
spawn_cycles_for(false)
}
/// [`spawn_cycles`] under the stock screen's policy: the roster comes only when it is asked for.
pub(crate) fn spawn_cycles_asking() -> Cycles {
spawn_cycles_for(true)
}
fn spawn_cycles_for(screen_asks_roster: bool) -> Cycles {
let (events_tx, events) = crossbeam_channel::unbounded();
let (commands, cmd_rx) = crossbeam_channel::unbounded::<ClientCommand>();
let (writer_tx, writer_rx) = crossbeam_channel::unbounded::<WriterSwap>();
thread::spawn(move || writer_loop(&cmd_rx, writer_rx, &Mutex::new(PingClock::default())));
let (login, login_rx) = crossbeam_channel::unbounded();
let (park_tx, park_rx) = crossbeam_channel::unbounded::<ParkRequest>();
let abandon = Arc::new(AtomicU64::new(0));
let thread_abandon = Arc::clone(&abandon);
thread::spawn(move || {
let parks = Parks {
login_rx,
park_rx,
screen_asks_roster: Arc::new(std::sync::atomic::AtomicBool::new(screen_asks_roster)),
};
let clock = Mutex::new(PingClock::default());
let mut tails = Default::default();
loop {
match run(
&events_tx,
&writer_tx,
&parks,
&thread_abandon,
&clock,
&mut tails,
) {
Ok(Cycle::Exit) => return,
Ok(Cycle::Repark) => {}
Err(e) => {
if events_tx
.send(SessionEvent::Disconnected {
reason: format!("disconnected: {e:#}"),
end: SessionEnd::Lost,
})
.is_err()
{
return;
}
}
}
}
});
Cycles {
events,
commands,
login,
realm: ParkSender::park(park_tx.clone()),
pick: ParkSender::park(park_tx),
abandon,
}
}
/// A login attempt for [`USER`] at `host` under the current abandon generation.
pub(crate) fn login(cycles: &Cycles, host: &str) {
cycles
.login
.send(LoginRequest {
user: USER.into(),
pass: PASS.into(),
host: host.into(),
generation: cycles.abandon.load(Ordering::SeqCst),
})
.unwrap();
}
+338
View File
@@ -0,0 +1,338 @@
//! The packet trace's lines: every world packet by opcode name with its length and its body as
//! hex, under the `in` and `out` tags of `WOW_MOVE_TRACE` (`benilla_assets::trace`).
//!
//! The body is the bytes after the 6-byte header, as they cross the socket (the header alone is
//! encrypted), so a line is a packet sniffer's row: paste the hex into a decoder. A field is
//! appended to each line and no earlier one moves, so a reader that matches the opcode, the name,
//! `ev=` or `len=` keeps working.
//!
//! Traces get attached to public issues, so two kinds of body never reach the file by default.
//! A packet that carries a credential is `hex=<redacted>` always ([`secret`]). Private
//! conversation and bulk lists of other players' names are `hex=<withheld>` unless
//! `WOW_MOVE_TRACE_CHAT=1` asks for them ([`private`]). Both keep `len=`.
use benilla_protocol::messages::{self, opcode};
/// What stands in for the hex of a packet that carries a secret, whatever the switches.
const REDACTED: &str = "<redacted>";
/// What stands in for the hex of private text or a list of names, until `WOW_MOVE_TRACE_CHAT=1`.
const WITHHELD: &str = "<withheld>";
/// Whether `WOW_MOVE_TRACE_CHAT` asks for the private bodies; read once.
fn reveal_private() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| {
std::env::var_os("WOW_MOVE_TRACE_CHAT").is_some_and(|v| !v.is_empty() && v != "0")
})
}
/// A body that is private in either direction: every chat message, sent (`CMSG_MESSAGECHAT`) or
/// received (`SMSG_MESSAGECHAT`, whispers and addon messages included), and the bulk lists of
/// other players' names, `SMSG_WHO`, `SMSG_FRIEND_LIST`, `SMSG_IGNORE_LIST`, `SMSG_GUILD_ROSTER`
/// and `SMSG_CHANNEL_LIST`. A single name (a name query, a friend's status) stays traced: it is
/// everywhere and says little alone.
fn private(opcode: u16) -> bool {
matches!(
opcode,
opcode::CMSG_MESSAGECHAT
| opcode::SMSG_MESSAGECHAT
| opcode::SMSG_WHO
| opcode::SMSG_FRIEND_LIST
| opcode::SMSG_IGNORE_LIST
| opcode::SMSG_GUILD_ROSTER
| opcode::SMSG_CHANNEL_LIST
)
}
/// A client packet whose body is never traced: `CMSG_AUTH_SESSION` carries the account name and
/// the proof built from the session key; `CMSG_JOIN_CHANNEL` and `CMSG_CHANNEL_PASSWORD` carry a
/// channel's password; and a `CMSG_MESSAGECHAT` that is a server command, which vmangos reads when
/// the text starts with `.` or `!` (`Chat.cpp:2031`, `:2047`) and matches by prefix
/// (`.acc pass <old> <new>`), may carry the account's. Server packets carry none.
fn secret(opcode: u16, body: &[u8]) -> bool {
match opcode {
opcode::CMSG_AUTH_SESSION | opcode::CMSG_JOIN_CHANNEL | opcode::CMSG_CHANNEL_PASSWORD => {
true
}
opcode::CMSG_MESSAGECHAT => is_command(body),
_ => false,
}
}
/// Whether a `CMSG_MESSAGECHAT` body (type u32, language u32, then for a whisper or channel
/// message the target's name, then the text) has a message that starts with `.` or `!`. Both
/// layouts are tried, so a type this does not know still gets the check.
fn is_command(body: &[u8]) -> bool {
let Some(rest) = body.get(8..) else {
return false;
};
let after_target = rest
.iter()
.position(|&b| b == 0)
.map(|nul| &rest[nul + 1..]);
[Some(rest), after_target]
.into_iter()
.flatten()
.any(|text| matches!(text.first(), Some(b'.' | b'!')))
}
/// `body` as lowercase hex with no separators; empty for an empty body.
fn hex(body: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(body.len() * 2);
for &b in body {
out.push(char::from(DIGITS[usize::from(b >> 4)]));
out.push(char::from(DIGITS[usize::from(b & 0xf)]));
}
out
}
fn name(opcode: u16) -> &'static str {
messages::opcode_name(opcode).unwrap_or("?")
}
/// The `hex=` field for a body: redacted, withheld, or the bytes. `outbound` is the client's own
/// packets, the only ones that can carry a credential; `reveal` is `WOW_MOVE_TRACE_CHAT`.
fn payload(opcode: u16, body: &[u8], outbound: bool, reveal: bool) -> String {
if outbound && secret(opcode, body) {
REDACTED.to_string()
} else if private(opcode) && !reveal {
WITHHELD.to_string()
} else {
hex(body)
}
}
/// The `out` line of a client packet: `0x01dc CMSG_PING len=4 hex=…`.
fn out_line_with(opcode: u16, body: &[u8], reveal: bool) -> String {
format!(
"{opcode:#06x} {} len={} hex={}",
name(opcode),
body.len(),
payload(opcode, body, true, reveal)
)
}
/// The `in` line of a server packet, decoded into `events` events: `0x01dd SMSG_PONG ev=1 len=4
/// hex=…`.
fn in_line_with(opcode: u16, events: usize, body: &[u8], reveal: bool) -> String {
format!(
"{opcode:#06x} {} ev={events} len={} hex={}",
name(opcode),
body.len(),
payload(opcode, body, false, reveal)
)
}
pub(super) fn out_line(opcode: u16, body: &[u8]) -> String {
out_line_with(opcode, body, reveal_private())
}
pub(super) fn in_line(opcode: u16, events: usize, body: &[u8]) -> String {
in_line_with(opcode, events, body, reveal_private())
}
/// The `skip` line of a server packet that did not parse: its `reason` and then its whole body,
/// which the `in` line never shows.
pub(super) fn skip_line(opcode: u16, reason: &str, body: &[u8]) -> String {
skip_line_with(opcode, reason, body, reveal_private())
}
fn skip_line_with(opcode: u16, reason: &str, body: &[u8], reveal: bool) -> String {
let payload = payload(opcode, body, false, reveal);
// The reader's reason ends in a ` [<n>B: <hex preview>]`, which would leak what the field hides.
let reason = match reason.rfind(" [") {
Some(at) if payload == WITHHELD => &reason[..at],
_ => reason,
};
format!("opcode={opcode:#06x} {reason} hex={payload}")
}
/// A command the writer dropped for want of a session, as the trace's `wire` line and the log name
/// it. The `Debug` form would print a channel's password and the text of a chat message, a mail or
/// a ticket, which these lines keep out as the packet lines do.
pub(super) fn dropped(cmd: &crate::net::ClientCommand) -> String {
use crate::net::ClientCommand as C;
match cmd {
C::JoinChannel { name, .. } => {
format!("JoinChannel {{ name: {name:?}, password: {REDACTED} }}")
}
C::ChannelPassword { name, .. } => {
format!("ChannelPassword {{ name: {name:?}, password: {REDACTED} }}")
}
C::Chat { .. } => format!("Chat {{ text: {WITHHELD} }}"),
C::AddonMessage { .. } => format!("AddonMessage {{ text: {WITHHELD} }}"),
C::SendMail { .. } => format!("SendMail {{ text: {WITHHELD} }}"),
C::GmTicketCreate { .. } => format!("GmTicketCreate {{ text: {WITHHELD} }}"),
C::GmTicketUpdate { .. } => format!("GmTicketUpdate {{ text: {WITHHELD} }}"),
other => format!("{other:?}"),
}
}
/// The hook [`super::spawn_net`] installs for the `out` tag: one line per client packet written
/// to the world socket, whichever session or writer sent it, right after the write.
pub(super) fn trace_out(opcode: u16, body: &[u8]) {
benilla_assets::trace::line("out", &out_line(opcode, body));
}
#[cfg(test)]
mod tests {
use super::*;
/// A traced packet's line carries its name, its length and its bytes, after the fields every
/// earlier trace had.
#[test]
fn a_traced_packet_line_carries_its_bytes() {
let body = [0x00, 0x0a, 0xff, 0x7e];
assert_eq!(
out_line_with(opcode::CMSG_PING, &body, false),
"0x01dc CMSG_PING len=4 hex=000aff7e"
);
assert_eq!(
in_line_with(opcode::SMSG_PONG, 1, &body, false),
"0x01dd SMSG_PONG ev=1 len=4 hex=000aff7e"
);
assert_eq!(
in_line_with(opcode::SMSG_PONG, 0, &[], false),
"0x01dd SMSG_PONG ev=0 len=0 hex=",
"an empty body is an empty field"
);
assert_eq!(
skip_line_with(
opcode::SMSG_PONG,
"short read [4B: 00 0a ff 7e]",
&body,
false
),
"opcode=0x01dd short read [4B: 00 0a ff 7e] hex=000aff7e"
);
}
/// The bytes of a login or a password never reach the file, switch or no switch.
#[test]
fn a_packet_that_carries_a_secret_is_redacted_always() {
let secret = b"SECRETWORD";
let needle = hex(secret);
for reveal in [false, true] {
for op in [
opcode::CMSG_AUTH_SESSION,
opcode::CMSG_JOIN_CHANNEL,
opcode::CMSG_CHANNEL_PASSWORD,
] {
let line = out_line_with(op, secret, reveal);
assert!(line.ends_with("hex=<redacted>"), "{line}");
assert!(!line.contains(&needle), "{line}");
}
// The length stays: it says the packet went out.
assert!(out_line_with(opcode::CMSG_AUTH_SESSION, secret, reveal).contains("len=10"));
// vmangos reads `.` and `!` commands and matches them by prefix.
for command in [
".account password old new new",
".acc pass old new new",
"!acc pass old new new",
".ACCOUNT create a b",
] {
let line = out_line_with(opcode::CMSG_MESSAGECHAT, &chat(command), reveal);
assert!(line.ends_with("hex=<redacted>"), "{command}: {line}");
// The same text whispered or sent to a channel follows the target's name.
let mut targeted = vec![7, 0, 0, 0, 0, 0, 0, 0];
targeted.extend_from_slice(b"Alice\0");
targeted.extend_from_slice(command.as_bytes());
targeted.push(0);
let line = out_line_with(opcode::CMSG_MESSAGECHAT, &targeted, reveal);
assert!(
line.ends_with("hex=<redacted>"),
"{command} to Alice: {line}"
);
}
}
// Inbound traffic carries no credential of ours.
assert!(in_line_with(opcode::SMSG_AUTH_RESPONSE, 1, secret, false).contains(&needle));
}
/// Chat body: type, language, then the text.
fn chat(text: &str) -> Vec<u8> {
let mut b = vec![0, 0, 0, 0, 0, 0, 0, 0];
b.extend_from_slice(text.as_bytes());
b.push(0);
b
}
/// An inbound whisper and an outgoing say are withheld by default and shown with the switch;
/// the length stays either way, and the lists of names go the same way.
#[test]
fn private_conversation_is_withheld_unless_asked_for() {
let say = chat("meet me at the bank");
let whisper = chat("psst, the password is hunter2 for the guild bank");
let len = format!("len={}", say.len());
let line = out_line_with(opcode::CMSG_MESSAGECHAT, &say, false);
assert!(line.ends_with("hex=<withheld>"), "{line}");
assert!(line.contains(&len), "{line}");
assert!(!line.contains(&hex(&say)), "{line}");
let line = out_line_with(opcode::CMSG_MESSAGECHAT, &say, true);
assert!(line.ends_with(&format!("hex={}", hex(&say))), "{line}");
let line = in_line_with(opcode::SMSG_MESSAGECHAT, 1, &whisper, false);
assert!(line.ends_with("hex=<withheld>"), "{line}");
assert!(line.contains(&format!("len={}", whisper.len())), "{line}");
assert!(!line.contains(&hex(&whisper)), "{line}");
let line = in_line_with(opcode::SMSG_MESSAGECHAT, 1, &whisper, true);
assert!(line.ends_with(&format!("hex={}", hex(&whisper))), "{line}");
for op in [
opcode::SMSG_WHO,
opcode::SMSG_FRIEND_LIST,
opcode::SMSG_IGNORE_LIST,
opcode::SMSG_GUILD_ROSTER,
opcode::SMSG_CHANNEL_LIST,
] {
let line = in_line_with(op, 1, b"Alice", false);
assert!(line.ends_with("hex=<withheld>"), "{line}");
assert!(in_line_with(op, 1, b"Alice", true).contains(&hex(b"Alice")));
}
// A single name stays traced.
assert!(
in_line_with(opcode::SMSG_FRIEND_STATUS, 1, b"Alice", false).contains(&hex(b"Alice"))
);
}
/// An unparseable chat packet leaks nothing through the skip line's preview either.
#[test]
fn a_skipped_chat_packet_keeps_neither_its_bytes_nor_its_preview() {
let body = chat("hello");
let reason = format!(
"opcode 0x0096 (SMSG_MESSAGECHAT): short read [{}B: 00 00 68 65]",
body.len()
);
let line = skip_line_with(opcode::SMSG_MESSAGECHAT, &reason, &body, false);
assert_eq!(
line,
"opcode=0x0096 opcode 0x0096 (SMSG_MESSAGECHAT): short read hex=<withheld>"
);
assert!(
skip_line_with(opcode::SMSG_MESSAGECHAT, &reason, &body, true).contains(&hex(&body))
);
}
/// A command the writer drops for want of a session is named without its secrets or its text.
#[test]
fn a_dropped_command_is_named_without_its_password_or_text() {
use crate::net::ClientCommand as C;
let join = dropped(&C::JoinChannel {
name: "Trade".into(),
password: "hunter2".into(),
});
assert_eq!(
join,
"JoinChannel { name: \"Trade\", password: <redacted> }"
);
let pw = dropped(&C::ChannelPassword {
name: "Trade".into(),
password: "hunter2".into(),
});
assert!(pw.contains("<redacted>") && !pw.contains("hunter2"), "{pw}");
// Anything else keeps its `Debug` form.
assert_eq!(dropped(&C::Logout), "Logout");
}
}
+202 -16
View File
@@ -19,13 +19,16 @@ use super::{
EnteredWorldMessage, GameTime, GuidIndex, HomeBind, KnockBackMessage, LoggedOutMessage,
LoginFailedMessage, LoginQueuedMessage, LoginStageMessage, NetCommands, NetHandlerApp,
NetStatus, ObjectStore, PendingTransfer, Proficiencies, RealmListMessage, Reputations,
SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
RosterFailedMessage, SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldAuthMessage,
WorldportMessage,
};
/// Registers the session handlers.
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::LoginStage, on_login_edge)
.net_handler(K::WorldAuthenticated, on_login_edge)
.net_handler(K::RosterFailed, on_login_edge)
.net_handler(K::LoginQueued, on_login_edge)
.net_handler(K::LoginFailed, on_login_edge)
.net_handler(K::RealmList, on_login_edge)
@@ -68,6 +71,8 @@ pub(crate) struct Edges<'w> {
knockbacks: MessageWriter<'w, KnockBackMessage>,
login_stages: MessageWriter<'w, LoginStageMessage>,
login_queued: MessageWriter<'w, LoginQueuedMessage>,
world_auth: MessageWriter<'w, WorldAuthMessage>,
roster_failed: MessageWriter<'w, RosterFailedMessage>,
login_failures: MessageWriter<'w, LoginFailedMessage>,
disconnects: MessageWriter<'w, DisconnectedMessage>,
client_control: MessageWriter<'w, ClientControlMessage>,
@@ -99,6 +104,20 @@ pub(crate) struct Bridge<'w, 's> {
fn on_login_edge(In(ev): In<SessionEvent>, mut e: Edges) {
match ev {
SessionEvent::LoginStage { stage } => login_stage(stage, &mut e.login_stages),
SessionEvent::WorldAuthenticated {
realm,
epoch,
generation,
} => {
e.world_auth.write(WorldAuthMessage {
realm,
epoch,
generation,
});
}
SessionEvent::RosterFailed { epoch } => {
e.roster_failed.write(RosterFailedMessage { epoch });
}
SessionEvent::LoginQueued { position, realm } => {
e.login_queued.write(LoginQueuedMessage { position, realm });
}
@@ -108,8 +127,8 @@ fn on_login_edge(In(ev): In<SessionEvent>, mut e: Edges) {
terminal,
dial,
} => login_failed(refusal, reason, terminal, dial, &mut e.login_failures),
SessionEvent::RealmList { realms } => {
e.realm_lists.write(RealmListMessage { realms });
SessionEvent::RealmList { realms, failed } => {
e.realm_lists.write(RealmListMessage { realms, failed });
}
SessionEvent::CharActionResult { action, code } => {
char_action_result(action, code, &mut e.char_actions)
@@ -125,8 +144,23 @@ fn on_login_edge(In(ev): In<SessionEvent>, mut e: Edges) {
}
fn on_character_list(In(ev): In<SessionEvent>, mut e: Edges, mut b: Bridge) {
if let SessionEvent::CharacterList { characters, realm } = ev {
character_list(characters, realm, &mut b.status, &mut e.char_lists);
if let SessionEvent::CharacterList {
characters,
realm,
cause,
epoch,
generation,
} = ev
{
character_list(
characters,
realm,
cause,
epoch,
generation,
&mut b.status,
&mut e.char_lists,
);
}
}
@@ -269,17 +303,56 @@ fn on_clock(In(ev): In<SessionEvent>, mut b: Bridge) {
}
}
fn on_reputations(In(ev): In<SessionEvent>, mut b: Bridge) {
fn on_reputations(
In(ev): In<SessionEvent>,
mut b: Bridge,
factions: Option<Res<crate::target::ring::Factions>>,
mut chat: ResMut<crate::ui_chat::ChatLog>,
) {
match ev {
SessionEvent::Reputations { standings } => reputations(standings, &mut b.reputations),
SessionEvent::ReputationVisible { list_id } => {
// The chat line names the rank, so it reads the store before the flag lands.
if let (Some(f), Some(me)) = (factions.as_deref(), self_facts(&b)) {
let line = crate::ui_reputation::visible_line(
list_id,
&b.reputations,
f.catalog(),
me.race,
me.class,
);
crate::ui_reputation::queue_standing_lines(line, f.catalog(), me.female, &mut chat);
}
reputation_visible(list_id, &mut b.reputations)
}
_ => {}
}
}
/// The chat line reads the deltas against the store, so it must run before the overwrite.
/// What the reputation chat lines read of the local player.
struct SelfFacts {
race: u8,
class: u8,
female: bool,
}
/// The local player's race, class and gender, `None` before the self object streams in; the
/// reference's `0x62c670` prints nothing without it (`0x62c6bf`). The gender is the real one
/// (`UNIT_FIELD_BYTES_0` byte 2), where `0x5ed5b0` reads `PLAYER_BYTES_3` byte 0 whole and so
/// takes a drunk female on vmangos for a male: that byte carries the drunk bits.
fn self_facts(b: &Bridge) -> Option<SelfFacts> {
let entity = *b.index.0.get(&b.self_guid.0?)?;
let store = b.stores.get(entity).ok()?;
Some(SelfFacts {
race: store.0.unit_race()?,
class: store.0.unit_class()?,
female: store.0.unit_gender() == Some(1),
})
}
/// The chat lines read the deltas against the store, so they must run before the overwrite. The
/// "increased by" line comes first, then the rank lines, as the reference's entry loop orders them
/// (`0x4d57f5`, `0x4d5896`).
fn on_reputation_delta(
In(ev): In<SessionEvent>,
mut b: Bridge,
@@ -294,6 +367,21 @@ fn on_reputation_delta(
factions.as_deref(),
&mut chat_log,
);
if let (Some(f), Some(me)) = (factions.as_deref(), self_facts(&b)) {
let lines = crate::ui_reputation::delta_lines(
&standings,
&b.reputations,
f.catalog(),
me.race,
me.class,
);
crate::ui_reputation::queue_standing_lines(
lines,
f.catalog(),
me.female,
&mut chat_log,
);
}
reputation_delta(standings, &mut b.reputations, &mut quest);
}
}
@@ -366,6 +454,9 @@ fn char_action_result(
fn character_list(
characters: Vec<Character>,
realm: Option<benilla_protocol::RealmInfo>,
cause: benilla_protocol::RosterCause,
epoch: u64,
generation: u64,
status: &mut NetStatus,
char_lists: &mut MessageWriter<CharListMessage>,
) {
@@ -373,10 +464,16 @@ fn character_list(
"net: character select — {} character(s) on the account",
characters.len()
);
// A roster means a live link: clear the failure the logout's synthesized `Disconnected`
// left, or the select screen keeps its "Server down" note until the next world entry.
// A roster means a live link: clear the reason a lost link left, or the select screen keeps
// its "Server down" note until the next world entry.
status.last_reason = None;
char_lists.write(CharListMessage { characters, realm });
char_lists.write(CharListMessage {
characters,
realm,
cause,
epoch,
generation,
});
}
/// `SMSG_CHARACTER_LOGIN_FAILED`: the announced entry is void; `crate::char_select` says why.
@@ -445,7 +542,8 @@ fn logged_out(
logged_out.write(LoggedOutMessage);
}
/// The session ended: tears down the streamed world and clears every session-scoped cache.
/// The world session ended, the link kept or not: tears down the streamed world and clears every
/// session-scoped cache.
fn disconnected(
reason: String,
end: benilla_protocol::SessionEnd,
@@ -467,12 +565,14 @@ fn disconnected(
// An unfinished far teleport dies with the socket.
pending_transfer.0 = None;
status.connected = false;
// A logout or a refused login ends the character, the link kept: nothing reconnects it.
let character_over = over || end == benilla_protocol::SessionEnd::LoggedOut;
// The RTT ring is not cleared here: the read thread wipes it as it re-enters its cycle loop.
// Every streamed entity despawns at once, not faded, so none races the reconnect's creates.
// The self avatar stays as the local puppet for the reconnect to refresh, unless the session
// is over, and then it goes as on logout.
let keep = if over { None } else { self_guid.0 };
if over {
// The self avatar stays as the local puppet for the reconnect to refresh, unless the
// character is over, and then it goes as on logout.
let keep = if character_over { None } else { self_guid.0 };
if character_over {
self_guid.0 = None;
}
index.0.retain(|guid, e| {
@@ -482,7 +582,10 @@ fn disconnected(
commands.entity(*e).despawn();
false
});
status.last_reason = Some(reason);
// A failure to show at character select; a kept link has none.
if end != benilla_protocol::SessionEnd::LoggedOut {
status.last_reason = Some(reason);
}
// In-flight name queries died with the socket.
names.clear_pending();
items.clear_session();
@@ -699,3 +802,86 @@ fn packet_dropped(opcode: u16, unparseable: bool, dropped: &mut DroppedOpcodes)
tally.unknown += 1;
}
}
#[cfg(test)]
mod reputation_tests;
#[cfg(test)]
mod teardown_tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
/// One teardown of `end` over a session in the world as guid 7, whose avatar is spawned:
/// whether the self guid survived and the reason the select screen would show.
fn teardown(end: benilla_protocol::SessionEnd) -> (Option<u64>, Option<String>, bool) {
let mut app = App::new();
app.init_resource::<GuidIndex>()
.insert_resource(SelfGuid(Some(7)))
.init_resource::<NetStatus>()
.init_resource::<NameCache>()
.init_resource::<Items>()
.init_resource::<crate::spell::Cooldowns>()
.init_resource::<PendingTransfer>()
.add_message::<DisconnectedMessage>();
let avatar = app.world_mut().spawn_empty().id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(7, avatar);
app.world_mut()
.run_system_once(
move |mut commands: Commands,
mut index: ResMut<GuidIndex>,
mut self_guid: ResMut<SelfGuid>,
mut status: ResMut<NetStatus>,
mut names: ResMut<NameCache>,
mut items: ResMut<Items>,
mut cooldowns: ResMut<crate::spell::Cooldowns>,
mut transfer: ResMut<PendingTransfer>,
mut out: MessageWriter<DisconnectedMessage>| {
disconnected(
"character login refused".into(),
end,
&mut commands,
&mut index,
&mut self_guid,
&mut status,
&mut names,
&mut items,
&mut cooldowns,
&mut transfer,
&mut out,
);
},
)
.unwrap();
(
app.world().resource::<SelfGuid>().0,
app.world().resource::<NetStatus>().last_reason.clone(),
app.world().get_entity(avatar).is_ok(),
)
}
/// A refused login (or a logout) ends the character with the link kept: the self guid goes as
/// on a logout, and no failure is left for character select to show as "Server down".
#[test]
fn a_teardown_on_a_kept_link_ends_the_character_and_reports_no_failure() {
let (guid, reason, avatar) = teardown(benilla_protocol::SessionEnd::LoggedOut);
assert_eq!(guid, None, "the refused character's guid outlived it");
assert_eq!(reason, None, "a kept link is no failure");
assert!(!avatar);
}
/// An unattended run's lost link keeps the avatar for the reconnect, and its reason.
#[test]
fn a_reconnectable_loss_keeps_the_avatar_and_its_reason() {
let _lock = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _decl = crate::local_state::test_env::EnvGuard::set("WOW_UNATTENDED", "1");
let (guid, reason, avatar) = teardown(benilla_protocol::SessionEnd::Lost);
assert_eq!(guid, Some(7));
assert!(reason.is_some());
assert!(avatar);
}
}
@@ -0,0 +1,200 @@
//! The chat line "You are now <rank> with <faction>." end to end: the wire handlers queue it
//! against the store as it stood, and the feed prints it as `CHAT_MSG_SYSTEM` from the VM's own
//! `GlobalStrings.lua` (`0x62c670`). A first contact is `SMSG_SET_FACTION_VISIBLE` (vmangos
//! `ReputationMgr.cpp:298`); a rank change rides `SMSG_SET_FACTION_STANDING`.
use benilla_protocol::SessionEvent;
use bevy::prelude::*;
use super::{on_reputation_delta, on_reputations};
use crate::net::{ObjectStore, Reputations, SelfGuid, SelfPlayer};
use benilla_protocol::ObjectFields;
use benilla_ui::script::UiScript;
const ME: u64 = 0x42;
/// `UNIT_FIELD_BYTES_0` of a male human warrior.
const HUMAN_WARRIOR: u32 = 1 | (1 << 8);
/// The handlers and the feed over a logged-in human warrior who has met nothing, on the real
/// DBC; `None` without client data. The second value is Stormwind's wire slot.
fn app() -> Option<(App, u32)> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("factions");
let slot = u32::try_from(catalog.reputation_faction(72)?.rep_index).ok()?;
let script = UiScript::new().expect("VM");
script
.run(
r#"FACTION_STANDING_CHANGED = "You are now %s with %s."
FACTION_STANDING_LABEL4 = "Neutral"
FACTION_STANDING_LABEL5 = "Friendly"
FACTION_STANDING_LABEL6 = "Honored""#,
)
.unwrap();
let mut app = App::new();
app.init_resource::<crate::net::GuidIndex>()
.insert_resource(SelfGuid(Some(ME)))
.init_resource::<crate::net::NetStatus>()
.init_resource::<crate::names::NameCache>()
.init_resource::<crate::items::Items>()
.init_resource::<crate::spell::Cooldowns>()
.init_resource::<Reputations>()
.init_resource::<crate::net::ServerTime>()
.init_resource::<crate::net::ServerWallClock>()
.init_resource::<crate::net::HomeBind>()
.init_resource::<crate::net::Proficiencies>()
.init_resource::<crate::net::DroppedOpcodes>()
.init_resource::<crate::ui_quest::QuestGiver>()
.init_resource::<crate::ui_chat::ChatLog>()
.insert_resource(crate::target::Factions::from_catalog(catalog))
.insert_resource(crate::net::NetCommands(crossbeam_channel::unbounded().0))
.insert_non_send_resource(script);
let me = app
.world_mut()
.spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[(36, HUMAN_WARRIOR)])),
))
.id();
app.world_mut()
.resource_mut::<crate::net::GuidIndex>()
.0
.insert(ME, me);
Some((app, slot))
}
/// Run one packet's handler as the bridge runs it, in wire order; nothing drains the chat log.
fn send(app: &mut App, ev: SessionEvent) {
// The handler `register` binds to the event's kind.
match ev {
SessionEvent::ReputationVisible { .. } => {
app.world_mut()
.run_system_cached_with(on_reputations, ev)
.unwrap();
}
SessionEvent::ReputationDelta { .. } => {
app.world_mut()
.run_system_cached_with(on_reputation_delta, ev)
.unwrap();
}
_ => panic!("not a reputation event"),
}
}
fn chat(app: &App) -> &crate::ui_chat::ChatLog {
app.world().resource::<crate::ui_chat::ChatLog>()
}
fn visible(app: &mut App, slot: u32) {
send(app, SessionEvent::ReputationVisible { list_id: slot });
}
fn delta(app: &mut App, slot: u32, standing: i32) {
send(
app,
SessionEvent::ReputationDelta {
standings: vec![(slot, standing)],
},
);
}
fn xp_gain(app: &mut App) {
let xp = benilla_protocol::messages::XpGain {
victim: 0,
total: 100,
base: 100,
kill: false,
};
app.world_mut()
.resource_mut::<crate::ui_chat::ChatLog>()
.push_xp_gain(&xp);
}
fn rank_lines(app: &App) -> Vec<(String, u8, bool)> {
chat(app).pending_standing()
}
#[test]
fn first_contact_a_rank_change_and_a_repeat_through_the_wire_handlers() {
let Some((mut app, slot)) = app() else {
return;
};
// First contact (what vmangos sends on meeting a faction): the human's base 4000 reads
// Friendly, and the line names it.
visible(&mut app, slot);
assert_eq!(rank_lines(&app), [("Stormwind".to_string(), 4, false)]);
// The same packet again is the dedup return: the slot is already listed.
visible(&mut app, slot);
assert_eq!(rank_lines(&app).len(), 1, "already listed prints nothing");
// 4000 → 4100: still Friendly.
delta(&mut app, slot, 100);
assert_eq!(rank_lines(&app).len(), 1, "no rank change, no line");
// 4100 → 9100: Honored, a rank change.
delta(&mut app, slot, 5100);
assert_eq!(
rank_lines(&app).last(),
Some(&("Stormwind".to_string(), 5, false))
);
assert_eq!(rank_lines(&app).len(), 2);
}
/// A faction a standing packet reveals prints once, whatever else the rank did.
#[test]
fn the_standing_packet_that_lists_a_faction_prints_it_once() {
let Some((mut app, slot)) = app() else {
return;
};
delta(&mut app, slot, 5100);
assert_eq!(
rank_lines(&app),
[("Stormwind".to_string(), 5, false)],
"one line, the rank after the gain, though the rank also moved"
);
}
/// The lines keep wire order against the rest of the chat: a standing packet prints "increased
/// by" (`0x4d6330`), then the rank (`0x4d5896`), and an XP award that arrives after it in the same
/// batch prints behind both. On first contact the VISIBLE packet's rank line leads the state's
/// "increased by" (vmangos `ReputationMgr.cpp:264-266`).
#[test]
fn the_rank_line_keeps_its_place_in_the_packet_batch() {
let Some((mut app, slot)) = app() else {
return;
};
delta(&mut app, slot, 5100);
xp_gain(&mut app);
// A rank-moving standing packet for a faction already listed.
assert_eq!(chat(&app).pending_kinds(), ["combat", "standing", "xp"]);
let Some((mut app, slot)) = self::app() else {
return;
};
visible(&mut app, slot);
delta(&mut app, slot, 100);
assert_eq!(chat(&app).pending_kinds(), ["standing", "combat"]);
}
/// The wire handlers carry the player's real gender to the line.
#[test]
fn a_female_player_asks_for_the_female_label() {
let Some((mut app, slot)) = app() else {
return;
};
// `UNIT_FIELD_BYTES_0` byte 2 is the gender; a drunk female is still female.
let me = app
.world_mut()
.query_filtered::<Entity, With<SelfPlayer>>()
.single(app.world())
.unwrap();
app.world_mut()
.entity_mut(me)
.insert(ObjectStore(ObjectFields::from_pairs(&[(
36,
HUMAN_WARRIOR | (1 << 16),
)])));
visible(&mut app, slot);
assert_eq!(rank_lines(&app), [("Stormwind".to_string(), 4, true)]);
}
+2
View File
@@ -200,6 +200,8 @@ pub(super) fn pill_quads(
path: None,
height: Some(PILL_QUAD_PX),
outline: Outline::None,
spacing: 0.0,
shadow_x: 0.0,
alpha_gradient: None,
},
// Our own dev overlay: measured off a degenerate rect, then shifted into the pill.
+4 -1
View File
@@ -708,6 +708,9 @@ pub(super) fn latch_world_mouse(
// ground-targeting veto (`+0x3c`, `0x7cba30`) arrives through `PointerOverUi`
// (`UiScript::set_nameplate_hit_test_veto`). The wheel still zooms over a plate.
pointer_over_ui: Res<crate::ui_script::PointerOverUi>,
// A press the UI consumed with nothing hovered, the layout drag's: no frame saw it, the
// world's included (`0x766353`).
press_consumed: Option<Res<crate::ui_script::PlayerUiPressConsumed>>,
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
@@ -721,7 +724,7 @@ pub(super) fn latch_world_mouse(
let Ok(camera) = cameras.single() else {
return;
};
let over_ui = pointer_over_ui.0;
let over_ui = pointer_over_ui.0 || press_consumed.is_some_and(|c| c.0);
let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui);
let wm = &mut rig.world_mouse;
wm.update(&buttons, world_press);
@@ -178,6 +178,25 @@ fn mouselook_start_turns_the_camera_and_the_character_with_no_button_held() {
assert!(w.resource::<CameraControl>().is_looking());
}
/// A right press over bare world that the UI consumed, as a layout-mode Alt press is
/// (`0x766353`, before any frame sees it), turns nothing; the same press unconsumed turns.
#[test]
fn a_press_the_ui_consumed_over_the_world_turns_nothing() {
let mut w = world(false);
w.insert_resource(crate::ui_script::PlayerUiPressConsumed(true));
press(&mut w, MouseButton::Right);
frame(&mut w, 40.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert_eq!(yaw(&mut w), 0.0, "the camera did not turn");
let mut w = world(false);
w.insert_resource(crate::ui_script::PlayerUiPressConsumed(false));
press(&mut w, MouseButton::Right);
frame(&mut w, 40.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Right));
assert!(yaw(&mut w) < 0.0, "an unconsumed press turns it");
}
/// `MouselookStop` (`0x514240`) ends the session: the cursor comes back and the mouse turns
/// nothing, and `IsMouselooking` answers nil.
#[test]
+2 -2
View File
@@ -76,8 +76,8 @@ pub(super) fn publish_view_focus(
}
}
*was_flying = flying;
// Spawn caps pace only a live avatar in a settled world; under the loading cover (entry, a
// teleport, a world swap) a cap would only lengthen the reveal.
// Spawn caps pace only a live avatar in a settled world; through entry, a teleport or a world
// swap a cap would only lengthen the load.
*focus = match player.as_deref() {
Some(p) if p.active => {
let wow = benilla_assets::coords::bevy_to_wow(p.pos);
@@ -43,6 +43,10 @@ pub(super) use benilla_formats::{ArtExtent, GLUE_AUTHORED_ASPECT};
/// about 3:2. Below 4/3 the authored horizontal extent holds and the view opens upward, up to the
/// art's `half_h` but never inside the authored box. Continuous at 4/3, where both legs give
/// `0.6·fov`. A `<PlayerModel>` pane is not the window and keeps its verbatim crop.
///
/// The Deviation is the native glue screens' alone. Under the stock screens `GlueParent` caps
/// every glue screen at 16:9 (`GlueParent.lua:78-84`), so the crop it answers cannot arise, and
/// the scene is framed as the reference frames it ([`stock_pane_view`]).
pub(super) fn glue_scene_framing(fov: f32, window_aspect: f32, art: Option<ArtExtent>) -> f32 {
let authored = diag_to_vert(fov, GLUE_AUTHORED_ASPECT);
if window_aspect <= 0.0 {
@@ -64,6 +68,36 @@ pub(super) fn glue_scene_framing(fov: f32, window_aspect: f32, art: Option<ArtEx
}
}
/// A stock glue `ModelFFX` pane's view of its scene, in the booth's window-sized target.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) struct PaneView {
/// The viewport, the pane's rect in physical pixels (`0x76d5d7`).
pub(super) position: UVec2,
pub(super) size: UVec2,
/// The projection's aspect, the viewport's `w/h` (`0x7ada40`), which also turns the camera
/// record's diagonal fov into the vertical opening (`0x5c3cc0`, [`diag_to_vert`]).
pub(super) aspect: f32,
}
/// The view of a stock pane whose rect is `pane`, `[left, right, top, bottom]` as fractions of a
/// `full_w`×`full_h` window from its top-left: the scene fills the pane at the pane's own aspect,
/// with no box of its own. `None` for an empty rect.
pub(super) fn stock_pane_view(full_w: u32, full_h: u32, pane: [f32; 4]) -> Option<PaneView> {
let [l, r, t, b] = pane;
let px = |f: f32, full: u32| (f.clamp(0.0, 1.0) * full as f32).round() as u32;
let (x0, x1) = (px(l, full_w), px(r, full_w));
let (y0, y1) = (px(t, full_h), px(b, full_h));
if x1 <= x0 || y1 <= y0 {
return None;
}
let size = UVec2::new(x1 - x0, y1 - y0);
Some(PaneView {
position: UVec2::new(x0, y0),
size,
aspect: size.x as f32 / size.y as f32,
})
}
/// The pillarbox aspect for a window: `Some(GLUE_BOX_ASPECT)` when the window is wider, else
/// `None`. It depends on the window alone, so the booth viewport and the chrome's canvas
/// ([`crate::glue::GlueCanvas`]) hold still when the selected race changes the stage.
+423 -121
View File
@@ -17,7 +17,7 @@ use benilla_assets::materials::WowModelMaterial;
use super::framing::{
attachment_point, diag_to_vert, glue_box_aspect, glue_box_physical, glue_scene_framing,
ArtExtent, GLUE_AUTHORED_ASPECT, PORTRAIT_ASPECT,
stock_pane_view, ArtExtent, GLUE_AUTHORED_ASPECT, PORTRAIT_ASPECT,
};
use super::{
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
@@ -123,25 +123,131 @@ impl GlueLook {
}
}
/// Which glue background scene shows: the login screen's main menu, or a race's `UI_<Race>` stage.
/// The main menu always fogs; a race stage fogs at create only.
#[derive(Clone, Copy, PartialEq, Eq)]
/// Which glue background scene shows, by its model's token, `UI_<token>`: the login screen's main
/// menu (`AccountLogin.xml`'s `ModelFFX`) or a race's stage.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlueScene {
/// `UI_MainMenu`, the login screen's burning gate (`AccountLogin.xml`'s ModelFFX).
MainMenu,
/// A race's `UI_<Race>` stage (Gnome uses Dwarf's, Troll uses Orc's; TurtleWoW's Goblin and
/// BloodElf ship their own).
Race(u8),
Human,
Orc,
Dwarf,
NightElf,
Scourge,
Tauren,
Goblin,
BloodElf,
}
/// The glue screens' input to the booth: the scene (`None` tears the booth down; select shows Orc
/// before a list arrives), the standing look (`None` for an empty stage), and the yaw in radians,
/// which create resets to the reference's −15° and select to 0; a yaw change never re-bakes.
impl GlueScene {
const ALL: [GlueScene; 9] = [
GlueScene::MainMenu,
GlueScene::Human,
GlueScene::Orc,
GlueScene::Dwarf,
GlueScene::NightElf,
GlueScene::Scourge,
GlueScene::Tauren,
GlueScene::Goblin,
GlueScene::BloodElf,
];
/// The model-name token, `Interface\Glues\Models\UI_<token>\UI_<token>.mdx`.
pub(crate) fn token(self) -> &'static str {
match self {
GlueScene::MainMenu => "MainMenu",
GlueScene::Human => "Human",
GlueScene::Orc => "Orc",
GlueScene::Dwarf => "Dwarf",
GlueScene::NightElf => "NightElf",
GlueScene::Scourge => "Scourge",
GlueScene::Tauren => "Tauren",
GlueScene::Goblin => "Goblin",
GlueScene::BloodElf => "BloodElf",
}
}
/// A race's stage, `GlueParent.lua`'s `SetBackgroundModel` remap: Gnome stands in Dwarf's,
/// Troll in Orc's; TurtleWoW's Goblin and BloodElf carry their own.
pub(crate) fn of_race(race: u8) -> Self {
match race {
2 | 8 => GlueScene::Orc,
3 | 7 => GlueScene::Dwarf,
4 => GlueScene::NightElf,
5 => GlueScene::Scourge,
6 => GlueScene::Tauren,
9 => GlueScene::Goblin,
10 => GlueScene::BloodElf,
_ => GlueScene::Human,
}
}
/// The scene a model path names, by its file stem (`UI_<token>`, any case, any extension);
/// `None` for a file that is none of the shipped scenes.
pub(crate) fn from_model_path(path: &str) -> Option<Self> {
let file = path.rsplit(['\\', '/']).next()?;
let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem);
let token = stem.get(..3).filter(|p| p.eq_ignore_ascii_case("UI_"))?;
let token = &stem[token.len()..];
Self::ALL
.into_iter()
.find(|s| s.token().eq_ignore_ascii_case(token))
}
/// The fog the stock files give the scene, near 0: the race rows are `GlueParent.lua`'s
/// `CharModelFogInfo`; the main menu's is `AccountLogin.xml`'s `<FogColor>` and `fogFar`.
pub(crate) fn stock_fog(self) -> SceneFog {
let (rgb, far) = match self {
GlueScene::MainMenu => ([0.25, 0.06, 0.015], 1200.0),
GlueScene::Orc => ([0.5, 0.5, 0.5], 270.0),
GlueScene::Dwarf => ([0.85, 0.88, 1.0], 500.0),
GlueScene::NightElf => ([0.25, 0.22, 0.55], 611.0),
GlueScene::Scourge => ([0.0, 0.22, 0.22], 26.0),
GlueScene::Tauren => ([1.0, 0.61, 0.42], 153.0),
// TurtleWoW's scenes camp in Human's fog until their own `CharModelFogInfo` rows are
// measured: the fork's `scene_fog` fell through to the Human row for any token it did
// not name, so a Turtle race creates no differently than it did before this merge.
GlueScene::Human | GlueScene::Goblin | GlueScene::BloodElf => {
([0.8, 0.65, 0.73], 222.0)
}
};
SceneFog {
rgb,
near: 0.0,
far,
}
}
}
/// A scene's fog: the colour and the span the fog fill callback (`0x76d680`) reads off the pane.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct SceneFog {
pub(crate) rgb: [f32; 3],
pub(crate) near: f32,
pub(crate) far: f32,
}
/// The glue screens' input to the booth: the scene (`None` tears the booth down), the standing
/// look (`None` for an empty stage), the yaw in radians, which create resets to the reference's
/// −15° and select to 0 (a yaw change never re-bakes), and the scene's fog. The fog is the
/// pane's while the scene holds the fog fill callback `Model:SetModel` installs (`0x76d680`), and
/// none once `SetCharSelectBackground` put the select screen's in its place (`0x472150`).
#[derive(Resource)]
pub(crate) struct GluePreview {
pub(crate) scene: Option<GlueScene>,
pub(crate) look: Option<GlueLook>,
pub(crate) yaw: f32,
pub(crate) fog: Option<SceneFog>,
/// The stock screens' pane the scene fills, `[left, right, top, bottom]` as window fractions
/// from the top-left ([`super::framing::stock_pane_view`]); `None` frames the native way.
pub(crate) pane: Option<[f32; 4]>,
/// The stage's instance: a change starts the scene over, as each `SetModel` makes a new
/// instance (`0x76cce0`), the same file included.
pub(crate) stage: u64,
/// Bumped as a glue screen's scene shows: the `ModelFFX`'s OnShow pins the glow back to
/// 0.30 (`0x46fa60`), which nothing else does ([`sync_glue_ffx`]).
pub(crate) shown: u64,
}
impl Default for GluePreview {
@@ -150,10 +256,25 @@ impl Default for GluePreview {
scene: None,
look: None,
yaw: 0.0,
fog: None,
pane: None,
stage: 0,
shown: 0,
}
}
}
impl GluePreview {
/// The create screen's stage: the race's scene, fogged as the create screen fogs it, with the
/// dialled look standing in it.
pub(crate) fn show_create(&mut self, race: u8, look: CreateLook) {
let scene = GlueScene::of_race(race);
self.scene = Some(scene);
self.look = Some(GlueLook::Create(look));
self.fog = Some(scene.stock_fog());
}
}
/// The glue background scene, the reference's fullscreen `ModelFFX`: `SetBackgroundModel` loads
/// `UI_<Race>.mdx`, plays sequence 0 and shows camera 0. Its root never yaws; the character
/// stands on its attachment 0, and its camera drives the booth camera while it shows.
@@ -168,9 +289,10 @@ pub(crate) struct CreateScene {
/// The shipped scene's art extent ([`benilla_formats::shipped_glue_art_extent`]): caps how far
/// [`glue_scene_framing`] opens upward on a narrow window. `None` with no scene.
art: Option<ArtExtent>,
/// `Some(aspect)` while the window is wider than [`super::framing::GLUE_BOX_ASPECT`]: the
/// camera renders into a centred viewport of this aspect ([`pillarbox_glue_scene`]). Set from
/// the window, not the scene, so the chrome's canvas never moves on a race change.
/// `Some(aspect)` while the window is wider than [`super::framing::GLUE_BOX_ASPECT`] under the
/// native screens: the camera renders into a centred viewport of this aspect
/// ([`pillarbox_glue_scene`]); a stock pane is its own viewport. Set from the window, not the
/// scene, so the chrome's canvas never moves on a race change.
viewport_aspect: Option<f32>,
/// The character's stage spot, scene attachment 0 in Bevy model space (`ZERO` with no scene);
/// the body seats on attachment 0 at select too (`0x473039`).
@@ -189,13 +311,14 @@ pub(crate) struct CreateScene {
pet_stage_scale: f32,
/// The revision of the pet bake standing at [`Self::pet_spot`], so a re-bake happens once.
pet_baked: Option<u64>,
/// Whether the spawn wrote fog enabled (create); the scene rebuilds when it flips.
fog: bool,
/// The fog the spawn wrote; the scene rebuilds when it changes.
fog: Option<SceneFog>,
/// The [`GluePreview::stage`] the spawn stood; the scene rebuilds when it changes.
stage: u64,
/// Whether the buffer holds the ghost rig ([`ghost_rig`]); a flip rewrites the buffer in
/// place, like the reference's per-frame fill callback, so the stage's emitters keep running.
ghost: bool,
/// The scene's light buffer: its authored rig ([`SceneRig`]) plus the per-race fog
/// (`CharModelFogInfo`) at create only; select renders unfogged (`0x472110`).
/// The scene's light buffer: its authored rig ([`SceneRig`]) plus [`GluePreview::fog`].
light: Option<bevy::render::render_resource::Buffer>,
/// Booth twins of the character's materials against the scene buffer (`BoothLight::variants`).
variants: std::collections::HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
@@ -205,6 +328,36 @@ pub(crate) struct CreateScene {
pending: Option<PendingSwap>,
}
impl CreateScene {
/// No scene up, over its two roots.
fn empty(root: Entity, pet_root: Entity) -> Self {
CreateScene {
root,
token: None,
handle: None,
spawned: false,
cam: None,
art: None,
viewport_aspect: None,
char_spot: Vec3::ZERO,
char_facing: Quat::IDENTITY,
char_scale: 1.0,
pet_root,
pet_spot: Vec3::ZERO,
pet_facing: Quat::IDENTITY,
pet_stage_scale: 1.0,
pet_baked: None,
fog: None,
stage: 0,
ghost: false,
light: None,
variants: default(),
rev: 0,
pending: None,
}
}
}
/// A requested stage swap, held until both halves are ready. The scene M2 and the character's
/// assembly land frames apart, and whichever came first would stand the wrong character in the
/// wrong stage; the reference's `SelectCharacter` loads both in one blocking call. The loads start
@@ -221,26 +374,6 @@ struct PendingSwap {
/// The bound on that hold, so a look whose assembly never completes cannot freeze the screen.
const GLUE_SWAP_HOLD_SECS: f32 = 2.0;
/// The scene's model-name token, `Interface\Glues\Models\UI_<token>\UI_<token>.mdx`. The race
/// mapping is `GlueParent.lua`'s `SetBackgroundModel`, which builds the token from the race's
/// `ChrRaces` fileString; the main menu is `AccountLogin.xml`'s. TurtleWoW's own races carry
/// theirs (`UI_Goblin`, `UI_BloodElf`), so their stages load as the shipped eight do.
fn scene_token(scene: GlueScene) -> &'static str {
match scene {
GlueScene::MainMenu => "MainMenu",
GlueScene::Race(race) => match race {
2 | 8 => "Orc", // Troll shares Orc's scene
3 | 7 => "Dwarf", // Gnome shares Dwarf's
4 => "NightElf",
5 => "Scourge",
6 => "Tauren",
9 => "Goblin",
10 => "BloodElf",
_ => "Human",
},
}
}
/// Whether the stage may swap this frame: nothing coherent is `standing`, both the requested art
/// and body are ready, or the hold has reached [`GLUE_SWAP_HOLD_SECS`].
fn may_swap(standing: bool, art_ready: bool, body_ready: bool, waited: f32) -> bool {
@@ -316,15 +449,15 @@ fn ghost_rig() -> SceneRig {
/// Pack a [`SceneRig`] into the glue scene's light buffer. The core rows carry the plain ambient
/// with a zero sun for any non-rig lane; the directional fold lands in probe slot 0 (`MeshTag` 0).
fn scene_light_blob(
rig: &SceneRig,
fog_rgb: [f32; 3],
fog_far: f32,
fog: bool,
) -> benilla_world::lighting::LightBlob {
fn scene_light_blob(rig: &SceneRig, fog: Option<SceneFog>) -> benilla_world::lighting::LightBlob {
let span = fog.unwrap_or(SceneFog {
rgb: [0.0; 3],
near: 0.0,
far: 1.0,
});
let mut blob = benilla_world::lighting::LightBlob::model(rig.ambient, [0.0; 3], Vec3::NEG_Y)
.probe(rig.ambient, &rig.lobes)
.fog(fog_rgb, fog_far, fog)
.fog_span(span.rgb, span.near, span.far, fog.is_some())
.dial(1.0);
for (pos, color) in &rig.points {
blob = blob.point(*pos, SCENE_POINT_RANGE, *color);
@@ -332,20 +465,6 @@ fn scene_light_blob(
blob
}
/// The per-scene fog, colour and far (near is 0): the race rows are `GlueParent.lua`'s
/// `CharModelFogInfo`; the main menu's is `AccountLogin.xml`'s `<FogColor>` and `fogFar`.
fn scene_fog(token: &str) -> ([f32; 3], f32) {
match token {
"MainMenu" => ([0.25, 0.06, 0.015], 1200.0),
"Orc" => ([0.5, 0.5, 0.5], 270.0),
"Dwarf" => ([0.85, 0.88, 1.0], 500.0),
"NightElf" => ([0.25, 0.22, 0.55], 611.0),
"Scourge" => ([0.0, 0.22, 0.22], 26.0),
"Tauren" => ([1.0, 0.61, 0.42], 153.0),
_ => ([0.8, 0.65, 0.73], 222.0), // Human
}
}
/// One assembled preview mesh, the tuple-driven counterpart of [`super::PortraitPart`].
#[derive(Clone)]
pub(crate) struct PreviewPart {
@@ -527,29 +646,7 @@ pub(super) fn spawn_glue_booth(
let pet_root = commands
.spawn((Transform::IDENTITY, Visibility::Visible, layer))
.id();
commands.insert_resource(CreateScene {
root: scene_root,
token: None,
handle: None,
spawned: false,
cam: None,
art: None,
viewport_aspect: None,
char_spot: Vec3::ZERO,
char_facing: Quat::IDENTITY,
char_scale: 1.0,
pet_root,
pet_spot: Vec3::ZERO,
pet_facing: Quat::IDENTITY,
pet_stage_scale: 1.0,
pet_baked: None,
fog: true,
ghost: false,
light: None,
variants: default(),
rev: 0,
pending: None,
});
commands.insert_resource(CreateScene::empty(scene_root, pet_root));
}
/// Drive the glue FFX state ([`benilla_world::ffx_glow::GlueFfx`]) off the live look: the pass
@@ -557,32 +654,48 @@ pub(super) fn spawn_glue_booth(
///
/// | look | reference | pass | glow |
/// |---|---|---|---|
/// | none (login, empty account) | `CSimpleModelFFX::OnShow 0x46fa60` | glow | `*0x8380b4` 0.30 |
/// | none, from the show (login) | `CSimpleModelFFX::OnShow 0x46fa60` | glow | `*0x8380b4` 0.30 |
/// | a create look | same, no select build runs | glow | 0.30 |
/// | a living select record | `0x472ff7`/`0x473002` | glow | `*0x838574` 0.40 |
/// | a ghost select record | `0x472fde`/`0x472fe9` | death | `*0x838570` 0.15 |
///
/// An empty account is the OnShow state: `0x472950` is gated `-1 ≤ [0x83856c] < [0xb42140]`.
/// Only the OnShow and a select build write it. A select scene with nobody standing keeps what
/// the last build wrote: the list ask (`0x4723d0`), the teardown (`0x472580`) and the load
/// (`0x4724d0`) never touch it, and an empty account's build exits before it (`0x472950` is gated
/// `-1 ≤ [0x83856c] < [0xb42140]`). The screen's show ([`GluePreview::shown`]) puts it back.
pub(super) fn sync_glue_ffx(
preview: Res<GluePreview>,
mut ffx: ResMut<benilla_world::ffx_glow::GlueFfx>,
mut shown: Local<Option<u64>>,
) {
use benilla_world::ffx_glow::GlueFfx;
let want = match preview.look {
Some(GlueLook::Select(l)) => {
if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0 {
GlueFfx::SelectGhost
} else {
GlueFfx::SelectLiving
}
}
Some(GlueLook::Create(_)) | None => GlueFfx::Shown,
let held = if *shown != Some(preview.shown) {
*shown = Some(preview.shown);
benilla_world::ffx_glow::GlueFfx::Shown
} else {
*ffx
};
let want = glue_ffx(preview.look.as_ref(), held);
if *ffx != want {
*ffx = want;
}
}
/// The glue FFX state a look leaves, over `held`, the state before it ([`sync_glue_ffx`]).
fn glue_ffx(
look: Option<&GlueLook>,
held: benilla_world::ffx_glow::GlueFfx,
) -> benilla_world::ffx_glow::GlueFfx {
use benilla_world::ffx_glow::GlueFfx;
match look {
Some(GlueLook::Select(l)) if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0 => {
GlueFfx::SelectGhost
}
Some(GlueLook::Select(_)) => GlueFfx::SelectLiving,
Some(GlueLook::Create(_)) => GlueFfx::Shown,
None => held,
}
}
/// Drive the background scene ([`CreateScene`]): load and spawn the race's `UI_*` model, size the
/// target to the window, seat the character, and hold the booth camera on the scene's camera 0.
#[allow(clippy::type_complexity)]
@@ -661,19 +774,16 @@ pub(super) fn sync_glue_scene(
w.physical_width().max(1),
w.physical_height().max(1),
);
let box_aspect = glue_box_aspect(w.width() / w.height().max(1.0));
// A stock pane is its own frame ([`pillarbox_glue_scene`]).
let box_aspect =
glue_box_aspect(w.width() / w.height().max(1.0)).filter(|_| preview.pane.is_none());
if scene.viewport_aspect != box_aspect {
scene.viewport_aspect = box_aspect;
}
}
let token = scene_token(which);
// A race stage fogs at create and not at select (`0x472110` replaces the fog callback); the
// main menu always fogs.
let fog = match which {
GlueScene::MainMenu => true,
GlueScene::Race(_) => matches!(preview.look, Some(GlueLook::Create(_))),
};
let token = which.token();
let fog = preview.fog;
// The ghost light fork (`0x472150`, [`ghost_rig`]), shared by the scene, character and pet.
let ghost = matches!(
preview.look,
@@ -723,8 +833,9 @@ pub(super) fn sync_glue_scene(
commands.entity(scene.root).despawn_related::<Children>();
// The model may still be loading: strip the old rig state off the root now.
super::clear_booth_rig(&mut commands, scene.root);
} else if scene.spawned && scene.fog != fog {
// Same scene, other screen: rebuild in place (the handle is cached, so it respawns now).
} else if scene.spawned && (scene.fog != fog || scene.stage != preview.stage) {
// Same scene, other screen or a new instance of it: rebuild in place (the handle is
// cached, so it respawns now).
scene.spawned = false;
commands.entity(scene.root).despawn_related::<Children>();
} else if scene.spawned && scene.ghost != ghost {
@@ -739,8 +850,7 @@ pub(super) fn sync_glue_scene(
} else {
scene_rig(&model.lights)
};
let (fog_rgb, fog_far) = scene_fog(token);
scene_light_blob(&rig, fog_rgb, fog_far, fog).write(&queue, &light);
scene_light_blob(&rig, fog).write(&queue, &light);
scene.ghost = ghost;
// The character and the pet re-bake onto the rewritten buffer.
scene.rev += 1;
@@ -772,8 +882,7 @@ pub(super) fn sync_glue_scene(
} else {
scene_rig(&model.lights)
};
let (fog_rgb, fog_far) = scene_fog(token);
let blob = scene_light_blob(&rig, fog_rgb, fog_far, fog);
let blob = scene_light_blob(&rig, fog);
let light = scene
.light
.get_or_insert_with(|| blob.create(&device, "wow_create_scene_light"))
@@ -787,6 +896,7 @@ pub(super) fn sync_glue_scene(
anim_mirrors.0.insert("glue_scene", light.clone());
blob.write(&queue, &light);
scene.fog = fog;
scene.stage = preview.stage;
scene.ghost = ghost;
let dc = blob.probe_dc();
info!(
@@ -957,10 +1067,15 @@ pub(super) fn sync_glue_scene(
.unwrap_or(4.0 / 3.0);
let fwd = (cam.target - cam.eye).normalize_or_zero();
let up = Quat::from_axis_angle(fwd, cam.roll) * Vec3::Y;
// The record fov is diagonal; [`glue_scene_framing`] holds the authored 4:3 view box
// where the reference converts at the display's aspect (the Deviation is stated there).
// Far is kept generous (27.8 authored on Orc).
let vert_fov = glue_scene_framing(cam.fov, aspect, scene.art);
// The record fov is diagonal. A stock pane converts it at the pane's own aspect, as the
// reference does; the native screens' [`glue_scene_framing`] holds the authored 4:3 view
// box instead (the Deviation is stated there). Far is kept generous (27.8 authored on Orc).
let pane = window
.single()
.ok()
.zip(preview.pane)
.and_then(|(w, pane)| stock_pane_view(w.physical_width(), w.physical_height(), pane));
let vert_fov = scene_vert_fov(cam.fov, aspect, scene.art, pane);
let rig = (
Transform::from_translation(cam.eye).looking_at(cam.target, up),
Projection::from(PerspectiveProjection {
@@ -974,10 +1089,26 @@ pub(super) fn sync_glue_scene(
}
}
/// Pillarbox the glue booth camera to [`CreateScene::viewport_aspect`], black either side;
/// writes only on change.
/// The scene camera's vertical opening for its record's diagonal `fov`: at a stock pane's own
/// aspect by the reference's law (`0x5c3cc0`), else the native screens' [`glue_scene_framing`]
/// for the window.
fn scene_vert_fov(
fov: f32,
window_aspect: f32,
art: Option<ArtExtent>,
pane: Option<super::framing::PaneView>,
) -> f32 {
match pane {
Some(view) => diag_to_vert(fov, view.aspect),
None => glue_scene_framing(fov, window_aspect, art),
}
}
/// Set the glue booth camera's viewport: a stock pane's rect ([`stock_pane_view`]), else the
/// native pillarbox to [`CreateScene::viewport_aspect`], black either side; writes only on change.
pub(super) fn pillarbox_glue_scene(
scene: Res<CreateScene>,
preview: Res<GluePreview>,
window: Query<&Window, With<PrimaryWindow>>,
mut cams: Query<(&BoothCam, &mut Camera)>,
) {
@@ -985,15 +1116,23 @@ pub(super) fn pillarbox_glue_scene(
return;
};
let full_h = w.physical_height().max(1);
// The same arithmetic the chrome's canvas insets by ([`glue_box_physical`]).
let viewport =
glue_box_physical(w.physical_width(), full_h, scene.viewport_aspect).map(|(x, box_w)| {
bevy::camera::Viewport {
let viewport = match preview.pane {
Some(pane) => {
stock_pane_view(w.physical_width(), full_h, pane).map(|view| bevy::camera::Viewport {
physical_position: view.position,
physical_size: view.size,
..default()
})
}
// The same arithmetic the chrome's canvas insets by ([`glue_box_physical`]).
None => glue_box_physical(w.physical_width(), full_h, scene.viewport_aspect).map(
|(x, box_w)| bevy::camera::Viewport {
physical_position: UVec2::new(x, 0),
physical_size: UVec2::new(box_w, full_h),
..default()
}
});
},
),
};
for (booth, mut cam) in cams.iter_mut() {
if booth.0 != GLUE_SLOT {
continue;
@@ -1064,7 +1203,7 @@ pub(super) fn sync_glue_booth(
// stage matching the request means this bake may show ([`PendingSwap`]).
let staged = scene
.as_deref()
.is_none_or(|s| s.token == preview.scene.map(scene_token));
.is_none_or(|s| s.token == preview.scene.map(GlueScene::token));
let rebake = staged && (last_rev != bake.revision || last_scene != scene_rev);
let reyaw = last_yaw != preview.yaw;
if !rebake && !reyaw {
@@ -1511,8 +1650,7 @@ pub(super) fn drive_create_test(
look.hair_color,
look.facial_hair
);
preview.scene = Some(GlueScene::Race(look.race));
preview.look = Some(GlueLook::Create(look));
preview.show_create(look.race, look);
// Show the booth's render target full-screen, so a live shot shows the body.
if let Some(PortraitSource::Live(image)) = portraits.0.get(GLUE_SLOT) {
commands.spawn((
@@ -1534,6 +1672,170 @@ mod tests {
use benilla_assets::ModelLight;
use benilla_formats::M2Light;
/// `GlueParent`'s rect on a window of `aspect` (`GlueParent.lua:78-84`): the whole screen up to
/// 16:9, a centred 16:9 box past it; as window fractions from the top-left.
fn glue_parent(aspect: f32) -> [f32; 4] {
let bar = ((aspect - 16.0 / 9.0) / (2.0 * aspect)).max(0.0);
[bar, 1.0 - bar, 0.0, 1.0]
}
const WINDOWS: [(u32, u32); 4] = [(1024, 768), (1280, 800), (1920, 1080), (2560, 1080)];
/// Under the stock screens the scene fills the pane, `GlueParent`'s rect, at the pane's
/// aspect, `min(window, 16:9)`, through the diagonal law: no box inside it, and the only bars
/// are outside `GlueParent`, at 21:9.
#[test]
fn a_stock_pane_frames_the_scene_at_its_own_aspect() {
let fov = 1.0;
for (w, h) in WINDOWS {
let a = w as f32 / h as f32;
let view = super::super::framing::stock_pane_view(w, h, glue_parent(a)).unwrap();
let pane_w = (h as f32 * a.min(16.0 / 9.0)).round() as u32;
assert_eq!(view.size, UVec2::new(pane_w, h), "{w}x{h}");
assert_eq!(view.position, UVec2::new((w - pane_w) / 2, 0), "{w}x{h}");
let want = a.min(16.0 / 9.0);
assert!(
(view.aspect - want).abs() < 1e-3,
"{w}x{h}: {}",
view.aspect
);
let vert = scene_vert_fov(fov, a, None, Some(view));
let law = fov / (want * want + 1.0).sqrt();
assert!((vert - law).abs() < 1e-3, "{w}x{h}: {vert} vs {law}");
}
// Not the native screens' held box: at 16:10 that opens wider than the law.
let native = scene_vert_fov(fov, 1.6, None, None);
assert!((native - fov / (1.6f32 * 1.6 + 1.0).sqrt()).abs() > 1e-2);
}
/// The booth camera's viewport is the stock pane's rect, no pillarbox inside it; the native
/// screens keep their pillarbox past [`super::super::framing::GLUE_BOX_ASPECT`].
#[test]
fn the_viewport_is_the_stock_pane_and_the_native_box_otherwise() {
for stock in [true, false] {
for (w, h) in WINDOWS {
let a = w as f32 / h as f32;
let mut app = App::new();
let root = app.world_mut().spawn_empty().id();
let pet = app.world_mut().spawn_empty().id();
let mut scene = CreateScene::empty(root, pet);
scene.viewport_aspect = glue_box_aspect(a).filter(|_| !stock);
let preview = GluePreview {
pane: stock.then(|| glue_parent(a)),
..GluePreview::default()
};
app.insert_resource(scene)
.insert_resource(preview)
.add_systems(Update, pillarbox_glue_scene);
app.world_mut().spawn((
Window {
resolution: UVec2::new(w, h).into(),
..default()
},
PrimaryWindow,
));
let cam = app
.world_mut()
.spawn((BoothCam(GLUE_SLOT.to_string()), Camera::default()))
.id();
app.update();
let vp = app
.world()
.get::<Camera>(cam)
.unwrap()
.viewport
.clone()
.map(|v| (v.physical_position, v.physical_size));
let want = if stock {
let view = super::super::framing::stock_pane_view(w, h, glue_parent(a));
view.map(|v| (v.position, v.size))
} else {
glue_box_physical(w, h, glue_box_aspect(a))
.map(|(x, bw)| (UVec2::new(x, 0), UVec2::new(bw, h)))
};
assert_eq!(vp, want, "stock {stock} {w}x{h}");
if stock && a <= 16.0 / 9.0 {
assert_eq!(
vp,
Some((UVec2::ZERO, UVec2::new(w, h))),
"no bars at {w}x{h}"
);
}
}
}
}
/// The glow a look leaves: a select build writes its own, and nobody standing keeps what the
/// last build wrote until the screen's show puts the OnShow 0.30 back.
#[test]
fn an_empty_select_holds_the_last_builds_glow_until_the_screen_shows() {
use benilla_world::ffx_glow::GlueFfx;
let mut app = App::new();
app.init_resource::<GluePreview>()
.init_resource::<GlueFfx>()
.add_systems(Update, sync_glue_ffx);
let mut ghost = benilla_protocol::Character {
flags: benilla_protocol::CHARACTER_FLAG_GHOST,
..crate::char_select::test_character(1, "Boo")
};
let set = |app: &mut App, look: Option<GlueLook>, show: bool| {
{
let mut p = app.world_mut().resource_mut::<GluePreview>();
p.look = look;
if show {
p.shown += 1;
}
}
app.update();
*app.world().resource::<GlueFfx>()
};
assert_eq!(set(&mut app, None, true), GlueFfx::Shown);
let ghost_look = GlueLook::Select(SelectLook::from(&ghost));
assert_eq!(set(&mut app, Some(ghost_look), false), GlueFfx::SelectGhost);
assert_eq!(
set(&mut app, None, false),
GlueFfx::SelectGhost,
"a reload keeps it"
);
ghost.flags = 0;
let living = GlueLook::Select(SelectLook::from(&ghost));
assert_eq!(set(&mut app, Some(living), false), GlueFfx::SelectLiving);
assert_eq!(set(&mut app, None, false), GlueFfx::SelectLiving);
assert_eq!(
set(&mut app, None, true),
GlueFfx::Shown,
"the show puts 0.30 back"
);
// A build in the show's own frame wins: it runs after the OnShow.
assert_eq!(set(&mut app, Some(living), true), GlueFfx::SelectLiving);
}
/// A scene is its model's token, from the path the glue screens give the pane in any case or
/// extension; `SetBackgroundModel`'s race remap lands on the same scenes.
#[test]
fn a_scene_is_named_by_its_models_token() {
for scene in GlueScene::ALL {
let token = scene.token();
let path = format!("Interface\\Glues\\Models\\UI_{token}\\UI_{token}.mdx");
assert_eq!(GlueScene::from_model_path(&path), Some(scene), "{path}");
}
assert_eq!(
GlueScene::from_model_path("interface/glues/models/ui_nightelf/UI_NIGHTELF.M2"),
Some(GlueScene::NightElf)
);
for odd in [
"",
"UI_",
"UI_Gnome.mdx",
"Interface\\Glues\\Models\\Orc.mdx",
] {
assert_eq!(GlueScene::from_model_path(odd), None, "{odd}");
}
assert_eq!(GlueScene::of_race(8), GlueScene::Orc);
assert_eq!(GlueScene::of_race(7), GlueScene::Dwarf);
assert_eq!(GlueScene::of_race(1), GlueScene::Human);
}
/// `petDisplayId == 0` is the client's whole gate: the server zeroes the triple for all but a
/// living hunter or warlock (vmangos `Player::BuildEnumData`).
#[test]
+8 -2
View File
@@ -56,7 +56,7 @@ pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview};
mod glue_booth;
pub(crate) use glue_booth::{
CreateLook, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook,
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SceneFog, SelectLook, GLUE_SLOT,
};
mod light;
pub(crate) use light::{material_variant, VariantLane};
@@ -120,6 +120,11 @@ pub(crate) fn model_pane_booth(name: &str) -> Option<&'static str> {
.map(|(_, slot)| *slot)
}
/// The systems that write [`GluePreview`], one per glue screen set (the native screens', the
/// stock glue VM's); the glue booth reads it after them, the same frame.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct GluePreviewFeed;
/// World is layer 0, the UI pass layer 1; each portrait slot gets its own layer from here up.
const PORTRAIT_LAYER_BASE: usize = 2;
/// The paper-doll booth's layer, the first past the portrait slots.
@@ -803,7 +808,8 @@ impl Plugin for PortraitPlugin {
// Last: it reads the wake/pending state every sync above may have armed.
gate_booth_cameras,
)
.chain(),
.chain()
.after(GluePreviewFeed),
)
// Re-face each booth's eye-glow cards to its own camera.
.add_systems(Update, booth::face_booth_billboards)
+44 -15
View File
@@ -6,7 +6,9 @@ use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
use crate::bindings::WheelNotches;
use crate::net::{RealmChoice, RealmRequest};
use crate::char_select::ClientState;
use crate::login::GlueLogin;
use crate::net::RealmChoice;
use crate::sound::GlueSound;
use super::screen::{RealmAction, MAX_ROWS};
@@ -17,12 +19,14 @@ const DOUBLE_CLICK_SECS: f32 = 0.4;
/// Clicks: a row selects (a second one enters), a tab fronts its category, the column headers
/// sort, Okay enters, Cancel and the close X leave.
#[allow(clippy::too_many_arguments)]
pub(super) fn clicks(
buttons: Query<(Entity, &RealmAction)>,
hits: Res<crate::glue::GlueClicks>,
mut realms: ResMut<Realms>,
choice: Res<RealmChoice>,
mut sounds: MessageWriter<GlueSound>,
mut cancels: Cancels,
time: Res<Time>,
mut last_click: Local<Option<(String, f32)>>,
) {
@@ -60,13 +64,22 @@ pub(super) fn clicks(
}
}
if enter {
try_enter(&mut realms, &choice, &mut sounds);
try_enter(&mut realms, &choice, &mut sounds, &cancels);
}
if let Some(with_sound) = leave {
do_cancel(&mut realms, &choice, &mut sounds, with_sound);
do_cancel(&mut realms, &mut cancels, &mut sounds, with_sound);
}
}
/// What Okay and Cancel need beyond the list: the screen under it, the login engine's ask, and
/// the abandon generation a dial is stamped with.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct Cancels<'w> {
state: Option<Res<'w, State<ClientState>>>,
login: MessageWriter<'w, GlueLogin>,
abandon: Option<Res<'w, crate::net::LoginAbandon>>,
}
/// `RealmList_OnKeyDown` (ESCAPE / ENTER), plus arrow-key row cycling and the wheel.
pub(crate) fn keys(
keys: Res<ButtonInput<KeyCode>>,
@@ -74,13 +87,14 @@ pub(crate) fn keys(
mut realms: ResMut<Realms>,
choice: Res<RealmChoice>,
mut sounds: MessageWriter<GlueSound>,
mut cancels: Cancels,
) {
if keys.just_pressed(KeyCode::Escape) {
do_cancel(&mut realms, &choice, &mut sounds, true);
do_cancel(&mut realms, &mut cancels, &mut sounds, true);
return;
}
if keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter) {
try_enter(&mut realms, &choice, &mut sounds);
try_enter(&mut realms, &choice, &mut sounds, &cancels);
return;
}
@@ -146,16 +160,16 @@ fn scroll_into_view(realms: &mut Realms, row: usize, total: usize) {
}
/// `RealmList_OnCancel`, and the close X without the sound. `RealmListDialogCancelled`
/// (`0x46ed20` -> `0x46b810`) does nothing off the login screen and there closes the realmd
/// socket (`0x5b3320`) with no screen change; the character park ignores `Abandon` and the login
/// park re-parks, dropping the socket.
/// (`0x46ed20` -> `0x46b810`) does nothing off the login screen and there closes the realmd link
/// (`CLoginMgr::Cancel`, `0x5b3320`) with no screen change, which ends the attempt: the login
/// engine closes the logon park and takes the next Login.
///
/// Deviation: the reference's X only cancels the pending realm query (`0x46ed10` -> `0x46b7e0`)
/// and leaves the realmd link up; ours abandons, because the IO thread blocks at the realm park
/// until answered.
/// Deviation: the reference's X only cancels a pending realm query (`0x46ed10` -> `0x46b7e0`) and
/// leaves the realmd link up; ours closes it as Cancel does, because the native login screen takes
/// a new Login only once the attempt has ended.
fn do_cancel(
realms: &mut Realms,
choice: &RealmChoice,
cancels: &mut Cancels,
sounds: &mut MessageWriter<GlueSound>,
with_sound: bool,
) {
@@ -163,14 +177,25 @@ fn do_cancel(
sounds.write(GlueSound("gsLoginChangeRealmCancel"));
}
realms.hide();
let _ = choice.0.send(RealmRequest::Abandon);
if cancels
.state
.as_ref()
.is_none_or(|s| *s.get() == ClientState::Login)
{
cancels.login.write(GlueLogin::Abandon);
}
}
/// `RealmList_OnOk`: play the click, hide the frame, answer the park.
///
/// Not built: the reference's `REALM_IS_FULL` Yes/No confirm for a `Full` realm with no characters
/// on it; ours enters directly. `Full` is the `0x80` flag, which vmangos never sets.
fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWriter<GlueSound>) {
fn try_enter(
realms: &mut Realms,
choice: &RealmChoice,
sounds: &mut MessageWriter<GlueSound>,
cancels: &Cancels,
) {
let Some(realm) = realms.selected() else {
return; // the reference disables Okay with nothing highlighted
};
@@ -181,7 +206,11 @@ fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWrit
sounds.write(GlueSound("gsLoginChangeRealmOK"));
realms.hide();
realms.store_front_category();
realms.enter(choice, name);
realms.enter(
choice,
name,
crate::net::LoginAbandon::now(cancels.abandon.as_deref()),
);
}
#[cfg(test)]
+4 -52
View File
@@ -2,8 +2,8 @@
//!
//! The load word is a band, not the population the auth server sent: the reference takes the
//! mean and a scaled standard deviation over every realm it knows (all categories) and places each
//! realm in that distribution. Ports of `CGlue::RealmLoadStats` `0x46e510` and
//! `CGlue::RealmLoadClassify` `0x46ec60`, with their x87 rounding, since the band edges are where a
//! realm in that distribution: `realm_load_stats` `0x46e510` and `realm_load_classify` `0x46ec60`,
//! ported with their x87 rounding in [`benilla_ui::script`], since the band edges are where a
//! rounding difference flips the word. The band to string to colour mapping is `RealmListUpdate`
//! in `GlueXML/RealmList.lua`.
@@ -27,56 +27,8 @@ pub(super) const RED: Color = Color::srgb(1.0, 0.1, 0.1);
/// `BLUE_FONT_COLOR`.
pub(super) const BLUE: Color = Color::srgb(0.0, 0.749, 0.953);
/// The std-dev scale, 0.6 (`[0x8038d8]`).
const STDDEV_MULT: f32 = f32::from_bits(0x3f19_999a);
/// `CGlue::RealmLoadStats` (`0x46e510`): `(mean, sqrt(Σ(pop − mean)² / (n − 1)) · 0.6)` over every
/// realm's population. Both accumulators round to `f32` each iteration; the divide, sqrt and scale
/// run at 53 bits. `n <= 1` gives `(1.0, 0.0)`, so a lone realm is banded against the literal 1.0.
pub(super) fn realm_load_stats(populations: &[f32]) -> (f32, f32) {
let n = populations.len();
if n <= 1 {
return (1.0, 0.0);
}
// `fld sum; fadd pop; fstp sum`: stored as f32 each step.
let mut sum: f32 = 0.0;
for &pop in populations {
sum = (f64::from(sum) + f64::from(pop)) as f32;
}
// `fild n; fdivr sum; fstp mean`.
let mean = (f64::from(sum) / n as f64) as f32;
// Stored as f32 each step.
let mut acc: f32 = 0.0;
for &pop in populations {
let d = f64::from(pop) - f64::from(mean);
acc = (d * d + f64::from(acc)) as f32;
}
// `fild n-1; fdivr acc; fsqrt; fmul 0.6; fstp stddev`.
let stddev = ((f64::from(acc) / (n - 1) as f64).sqrt() * f64::from(STDDEV_MULT)) as f32;
(mean, stddev)
}
/// `CGlue::RealmLoadClassify` (`0x46ec60`): the load indicator `GetRealmInfo` returns. The three
/// sentinel bits, synthesized by the parser from magic populations as the reference's does, win in
/// this order; then the band, whose 53-bit comparisons are strict, so an edge reads normal.
pub(super) fn realm_load_classify(flags: u8, population: f32, mean: f32, stddev: f32) -> f32 {
if flags & 0x20 != 0 {
return -3.0;
}
if flags & 0x40 != 0 {
return -2.0;
}
if flags & 0x80 != 0 {
return 2.0;
}
if (f64::from(mean) - f64::from(stddev)) > f64::from(population) {
return -1.0;
}
if (f64::from(mean) + f64::from(stddev)) < f64::from(population) {
return 1.0;
}
0.0
}
/// The load band's two primitives, shared with the stock glue's `GetRealmInfo`.
pub(super) use benilla_ui::script::{realm_load_classify, realm_load_stats};
/// The Population column's key and colour: `RealmListUpdate`'s ladder, offline first, then the
/// sentinels by exact comparison, then the sign.
+141 -16
View File
@@ -5,19 +5,22 @@
//! `GlueScreenInfo` has no entry for it): it is shown over the current screen and hidden again,
//! and that screen is where the player returns.
//!
//! The IO thread publishes the list from two parks (`crate::net::io`), the login-side realm park
//! and the character park, and takes one answer. The policy, in order:
//! The IO thread publishes the list from two parks (`crate::net::io`), the logon park and the
//! character park, and takes one answer. The policy, in order:
//!
//! 1. `WOW_REALM`, matched case-insensitively; a name not on the list falls through.
//! 2. The realm this session is on, so a logout or a dropped connection returns to it.
//! 2. The realm this session is on, so an unattended reconnect returns to it.
//! 3. The remembered realm, the persisted `realmName` CVar.
//! 4. An unattended run (`crate::run_mode::unattended`) takes the first realm that is up.
//! 5. Otherwise the dialog.
//!
//! Change Realm costs a world dial, not a login. `RequestRealmList` (`0x46ecf0` -> `0x46b8d0`)
//! begins a realm query on the realmd socket, which stays open until world entry (`0x46b70c`), and
//! names no screen; only `ConnectToRealm` (`0x46b210`) drops the world connection, once a realm is
//! begins a realm query on the realmd socket, which stays open until world entry (`0x46b70c`) and
//! is dialled again for a query after it (`0x5b2200`), and names no screen; only `ConnectToRealm` (`0x46b210`) drops the world connection, once a realm is
//! chosen. So the character park serves the list in place and Cancel there does nothing.
//!
//! Under `WOW_GLUE_STOCK=1` the stock `RealmList` and its verbs take the list instead
//! ([`crate::ui_script::glue_mgr`]), and this policy stands down.
mod category;
mod input;
@@ -94,7 +97,8 @@ pub(super) enum SortKey {
Load,
/// `SortRealms("name")`, case 2: a case-folding compare.
Name,
/// `SortRealms("mode")`, case 3: the `(pvp, rp)` pair.
/// `SortRealms("mode")`, case 3: the `Cfg_Configs` pair, RP before PvP, so Normal, PvP, RP,
/// RP-PvP ascending.
Type,
}
@@ -164,7 +168,14 @@ impl Realms {
la.total_cmp(&lb)
}
SortKey::Name => ra.name.to_lowercase().cmp(&rb.name.to_lowercase()),
SortKey::Type => self.pvp_rp(ra.realm_type).cmp(&self.pvp_rp(rb.realm_type)),
// RP decides before PvP (`0x46e92c`-`0x46e978`).
SortKey::Type => {
let rp_pvp = |t: u32| {
let (pvp, rp) = self.pvp_rp(t);
(rp, pvp)
};
rp_pvp(ra.realm_type).cmp(&rp_pvp(rb.realm_type))
}
};
if ord != std::cmp::Ordering::Equal {
return if descending { ord.reverse() } else { ord };
@@ -177,6 +188,36 @@ impl Realms {
rows
}
/// The client Region's categories and the realm types, as the stock glue's realm verbs read
/// them; no categories until the DBC is read.
pub(crate) fn glue_setup(
&self,
) -> (
Vec<benilla_ui::script::GlueRealmCategory>,
Vec<benilla_ui::script::GlueRealmType>,
) {
let categories = self
.categories
.iter()
.flatten()
.map(|c| benilla_ui::script::GlueRealmCategory {
id: c.id,
name: c.name.clone(),
})
.collect();
let types = self
.types
.rows()
.iter()
.map(|t| benilla_ui::script::GlueRealmType {
realm_type: t.realm_type,
pvp: t.pvp,
rp: t.rp,
})
.collect();
(categories, types)
}
/// A realm type's `(pvp, rp)` in `Cfg_Configs.dbc`, `(false, false)` for a type with no row.
pub(crate) fn pvp_rp(&self, realm_type: u32) -> (bool, bool) {
load::pvp_rp(&self.types, realm_type)
@@ -240,10 +281,16 @@ impl Realms {
self.offset = self.offset.min(max);
}
/// Answer a park with a realm and remember it as [`Self::current`].
pub(super) fn enter(&mut self, choice: &RealmChoice, name: String) {
self.current = Some(name.clone());
let _ = choice.0.send(RealmRequest::Enter(name));
/// Answer a park with a realm, by the record the list holds, stamped with the abandon
/// generation `generation` the app holds now, and remember it as [`Self::current`].
pub(super) fn enter(&mut self, choice: &RealmChoice, name: String, generation: u64) {
if let Some(realm) = self.realms.iter().find(|r| r.name == name) {
let _ = choice.0.send(RealmRequest::Enter {
realm: realm.clone(),
generation,
});
}
self.current = Some(name);
}
/// `RealmList:Hide()`, and nothing else.
@@ -252,8 +299,33 @@ impl Realms {
}
}
/// `GetRealmInfo`'s `realmDown`: flag bit 0x02, greyed and not enterable; the reference's
/// auto-pick (`0x46ea20`) skips a realm on these low bits.
#[cfg(test)]
impl Realms {
/// A US client's state at startup, read without an install: Region 1's two categories and the
/// shipped `Cfg_Configs.dbc` types 0, 1, 6 and 8.
pub(crate) fn us_client() -> Realms {
let ty = |realm_type, pvp, rp| benilla_formats::RealmConfig {
realm_type,
pvp,
rp,
};
Realms {
categories: Some(category::tests::us()),
types: RealmConfigs::from_rows(vec![
ty(0, false, false),
ty(1, true, false),
ty(6, false, true),
ty(8, true, true),
]),
..Realms::default()
}
}
}
/// `GetRealmInfo`'s `realmDown`: flag bit 0x02 of the record's flags byte (`+0x08`, `0x46ef83`),
/// greyed and not enterable. The reference's own pick, `SetPreferredInfo`'s `0x46ea20`, skips only
/// bit 0x01 (`0x46ea7c`), so it can suggest a realm that is down; `auto_answer` skipping one is
/// benilla's policy ladder, not the reference's.
pub(crate) fn is_down(realm: &RealmInfo) -> bool {
realm.flags & 0x02 != 0
}
@@ -274,6 +346,10 @@ pub(crate) fn take_input(mut realms: ResMut<Realms>) {
/// The realm-selection subsystem: the park's policy plus the screen.
pub(crate) struct RealmSelectPlugin;
/// The subsystem's `Update` chain, for a reader of [`Realms`] to order itself after.
#[derive(SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct RealmSelectSet;
impl Plugin for RealmSelectPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Realms>()
@@ -287,7 +363,7 @@ impl Plugin for RealmSelectPlugin {
.add_systems(
Update,
(
apply_realm_policy,
apply_realm_policy.run_if(crate::ui_script::glue::native_glue),
// Ungated by state: the dialog stands over login and character select alike.
screen::drive_screen,
// Input before the row refresh, so a click shows on the frame it landed.
@@ -295,7 +371,8 @@ impl Plugin for RealmSelectPlugin {
(
(input::clicks, input::keys)
.chain()
.run_if(|realms: Res<Realms>| realms.owns_input()),
.run_if(|realms: Res<Realms>| realms.owns_input())
.run_if(crate::ui_script::glue::native_glue),
(tick_refresh, screen::refresh_list)
.chain()
.run_if(|realms: Res<Realms>| realms.shown),
@@ -303,6 +380,7 @@ impl Plugin for RealmSelectPlugin {
.chain(),
)
.chain()
.in_set(RealmSelectSet)
.after(benilla_world::schedule::WorldStage::Net)
.before(crate::glue::GlueVisuals),
);
@@ -315,12 +393,18 @@ fn apply_realm_policy(
mut realms: ResMut<Realms>,
choice: Res<RealmChoice>,
cvars: Res<crate::cvars::Cvars>,
abandon: Option<Res<crate::net::LoginAbandon>>,
) {
let generation = crate::net::LoginAbandon::now(abandon.as_deref());
if !realms.env_read {
realms.env_read = true;
realms.env_realm = std::env::var("WOW_REALM").ok().filter(|s| !s.is_empty());
}
for msg in msgs.read() {
// A query that got no answer leaves the list as it stands.
if msg.failed {
continue;
}
realms.realms = msg.realms.clone();
realms.refresh_in = REFRESH_SECS;
// The registered default is empty: a client that has never connected.
@@ -339,6 +423,9 @@ fn apply_realm_policy(
if realms.shown {
continue;
}
// A server that lists no realm gets the empty list, as the reference shows it once the
// connect fails `REALM_LIST_REALM_NOT_FOUND` with no realm held (`0x46bf86` → `0x46c06e`):
// no realm answers it.
match auto_answer(
&realms,
remembered.as_deref(),
@@ -346,7 +433,7 @@ fn apply_realm_policy(
) {
Some(name) => {
realms.select(&name);
realms.enter(&choice, name);
realms.enter(&choice, name, generation);
}
None => realms.open(),
}
@@ -669,6 +756,44 @@ mod tests {
);
}
/// A server that lists no realm: nothing is dialled, and the list goes up empty, as the
/// reference shows it (`0x46bf86` → `0x46c06e`).
#[test]
fn an_empty_list_dials_nothing_and_shows_the_list() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<Realms>()
.init_resource::<crate::cvars::Cvars>()
.insert_resource(RealmChoice(tx.into()))
.add_message::<RealmListMessage>()
.add_systems(Update, apply_realm_policy);
app.world_mut().write_message(RealmListMessage {
realms: Vec::new(),
failed: false,
});
app.update();
assert!(rx.try_recv().is_err(), "no dial");
assert!(
app.world().resource::<Realms>().shown,
"the empty list is up"
);
assert_eq!(auto_answer(&list(Vec::new()), None, true), None);
}
/// Mode ascending is Normal, PvP, RP, RP-PvP: RP decides before PvP (`0x46e92c`-`0x46e978`).
#[test]
fn the_mode_sort_puts_rp_after_pvp() {
let mut r = Realms::us_client();
r.realms = vec![
realm("RpPvp", 1, 8, 0, 0, 1.0),
realm("Rp", 1, 6, 0, 0, 1.0),
realm("Pvp", 1, 1, 0, 0, 1.0),
realm("Normal", 1, 0, 0, 0, 1.0),
];
r.sort.click(SortKey::Type);
assert_eq!(names(&r), ["Normal", "Pvp", "Rp", "RpPvp"]);
}
/// The reference disables an offline realm's Okay.
#[test]
fn an_offline_realm_cannot_be_entered() {
@@ -209,6 +209,7 @@ fn spawn_screen(
commands
.spawn((
crate::glue::NativeGlue,
RealmListUi { with_art, s },
GlobalZIndex(REALM_Z),
// `enableMouse="true"` on a full-screen frame: clicks never reach the screen below.
@@ -849,8 +850,9 @@ mod tests {
let (tx, _rx) = crossbeam_channel::unbounded();
app.init_resource::<Time>()
.init_resource::<GlueClicks>()
.insert_resource(crate::net::RealmChoice(tx))
.insert_resource(crate::net::RealmChoice(tx.into()))
.add_message::<crate::sound::GlueSound>()
.add_message::<crate::login::GlueLogin>()
.insert_resource(Realms {
realms: list.iter().map(|&(n, c)| realm(n, c)).collect(),
categories: Some(super::super::category::tests::us()),
+17 -9
View File
@@ -12,7 +12,7 @@
use bevy::prelude::*;
use crate::char_select::{ClientState, Roster};
use crate::net::{RealmChoice, RealmRequest};
use crate::net::RealmChoice;
use super::Realms;
@@ -41,6 +41,7 @@ pub(super) fn debug_realm_smoke(
roster: Res<Roster>,
state: Res<State<ClientState>>,
choice: Res<RealmChoice>,
abandon: Option<Res<crate::net::LoginAbandon>>,
time: Res<Time>,
mut walk: Local<Walk>,
mut exit: MessageWriter<AppExit>,
@@ -110,7 +111,11 @@ pub(super) fn debug_realm_smoke(
return;
}
info!("realm-smoke: dialog up, character select still underneath → Okay");
okay(realms, &choice);
okay(
realms,
&choice,
crate::net::LoginAbandon::now(abandon.as_deref()),
);
(walk.phase, walk.mark, walk.rosters) = (2, now, walk.seen + 1);
}
// ── 2 · Okay has to land: a fresh roster, dialog down, still at select. ──────────────
@@ -122,7 +127,7 @@ pub(super) fn debug_realm_smoke(
// ── 3 · Cancel changes nothing. ──────────────────────────────────────────────────────
3 if realms.shown && now - walk.mark > DWELL => {
info!("realm-smoke: dialog up → Cancel");
cancel(realms, &choice);
cancel(realms);
(walk.phase, walk.mark) = (4, now);
}
4 if !realms.shown && now - walk.mark > DWELL => {
@@ -137,7 +142,11 @@ pub(super) fn debug_realm_smoke(
(walk.phase, walk.mark) = (5, now);
}
5 if realms.shown && now - walk.mark > DWELL => {
okay(realms, &choice);
okay(
realms,
&choice,
crate::net::LoginAbandon::now(abandon.as_deref()),
);
(walk.phase, walk.mark, walk.rosters) = (6, now, walk.seen + 1);
}
6 if walk.seen >= walk.rosters && !realms.shown && at_select => {
@@ -159,7 +168,7 @@ fn open(mut realms: ResMut<Realms>, choice: &RealmChoice) {
}
/// `RealmList_OnOk` on whatever the dialog opened with highlighted, or the first row.
fn okay(mut realms: ResMut<Realms>, choice: &RealmChoice) {
fn okay(mut realms: ResMut<Realms>, choice: &RealmChoice, generation: u64) {
let name = realms.selected().map(|r| r.name.clone()).or_else(|| {
realms
.rows()
@@ -170,11 +179,10 @@ fn okay(mut realms: ResMut<Realms>, choice: &RealmChoice) {
return;
};
realms.hide();
let _ = choice.0.send(RealmRequest::Enter(name));
realms.enter(choice, name, generation);
}
/// `RealmList_OnCancel`.
fn cancel(mut realms: ResMut<Realms>, choice: &RealmChoice) {
/// `RealmList_OnCancel` over character select, where it only hides the list.
fn cancel(mut realms: ResMut<Realms>) {
realms.hide();
let _ = choice.0.send(RealmRequest::Abandon);
}
+17 -1
View File
@@ -21,7 +21,10 @@ pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut realmlist: ResMut<R
}
match normalize(&ev.new) {
Some(address) => realmlist.set(&address),
None => warn!("cvar realmList: unusable realmlist '{}' ignored", ev.new),
None => {
warn!("cvar realmList: unusable realmlist '{}' ignored", ev.new);
realmlist.refused = Some(ev.new.clone());
}
}
}
@@ -51,6 +54,9 @@ pub(crate) struct Realmlist {
address: String,
/// `$WOW_HOST` owns this session: the address shows, the control is disabled, nothing persists.
pinned_by_env: bool,
/// The last `realmList` value that was not an address, for the stock login screen's
/// realmlist dialog to reopen on ([`crate::ui_script::glue_mgr`]).
refused: Option<String>,
}
impl Default for Realmlist {
@@ -60,10 +66,12 @@ impl Default for Realmlist {
Ok(host) if !host.trim().is_empty() => Realmlist {
address: host,
pinned_by_env: true,
refused: None,
},
_ => Realmlist {
address: DEFAULT_REALMLIST.to_string(),
pinned_by_env: false,
refused: None,
},
}
}
@@ -76,6 +84,7 @@ impl Realmlist {
Realmlist {
address: address.to_string(),
pinned_by_env: false,
refused: None,
}
}
@@ -87,6 +96,11 @@ impl Realmlist {
self.pinned_by_env
}
/// The last value refused as no address, taken once.
pub(crate) fn take_refused(&mut self) -> Option<String> {
self.refused.take()
}
/// Points at an already normalized `address`; ignored while pinned by the env.
pub(crate) fn set(&mut self, address: &str) {
if self.pinned_by_env || self.address == address {
@@ -230,6 +244,7 @@ mod tests {
let mut r = Realmlist {
address: "harness.example.org".into(),
pinned_by_env: true,
refused: None,
};
r.set("somewhere.else.org");
assert_eq!(r.address(), "harness.example.org");
@@ -241,6 +256,7 @@ mod tests {
let mut r = Realmlist {
address: DEFAULT_REALMLIST.into(),
pinned_by_env: false,
refused: None,
};
r.set("logon.example.org");
assert_eq!(r.address(), "logon.example.org");
+96
View File
@@ -45,6 +45,50 @@ pub(crate) fn on_app_exit(app: &mut App, systems: ScheduleConfigs<ScheduleSystem
app.add_systems(Last, systems.in_set(OnAppExit))
}
/// Alt+F4 closes the primary window on Windows and Linux.
///
/// Deviation: the reference leaves it to the OS. Its key arm (`0x42d261`-`0x42d276`) hands Alt+F4
/// to `DefWindowProc`, whose `SC_CLOSE` becomes the kind-5 event (`0x42d287`, as `WM_CLOSE` posts at
/// `0x42d292`) and runs the quit at once (`0x423a21` -> `0x423ca0` -> `0x41f5d0` -> `0x422030`),
/// with no logout timer, at the login screen and in game alike. A tiling window manager (i3,
/// sway, Hyprland) binds nothing to it, so benilla reads the chord itself and asks for the same
/// close, which the shutdown tail above already serves. Not on macOS: `Cmd+Q` is
/// [`benilla_world::mac_quit`].
pub(crate) struct AltF4Plugin;
impl Plugin for AltF4Plugin {
fn build(&self, app: &mut App) {
if cfg!(target_os = "macos") {
return;
}
// Before the loading cover swallows the input plane, which would eat the key.
app.add_systems(
PreUpdate,
close_on_alt_f4
.after(bevy::input::InputSystems)
.before(crate::loading_screen::CoverInput),
);
}
}
/// F4 pressed with Alt held asks the primary window to close, as a click on its close box does.
fn close_on_alt_f4(
mut keyboard: MessageReader<bevy::input::keyboard::KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
windows: Query<Entity, With<bevy::window::PrimaryWindow>>,
mut close: MessageWriter<bevy::window::WindowCloseRequested>,
) {
let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight);
let pressed = keyboard.read().any(|ev| {
ev.key_code == KeyCode::F4 && ev.state == bevy::input::ButtonState::Pressed && !ev.repeat
});
if pressed && alt {
if let Ok(window) = windows.single() {
close.write(bevy::window::WindowCloseRequested { window });
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -171,4 +215,56 @@ mod tests {
assert_eq!(app.world().resource::<Saw>().0, 2);
}
/// Alt+F4 writes the close request for the primary window; F4 alone and Alt with another key
/// do not.
#[test]
fn alt_f4_asks_the_primary_window_to_close_and_plain_f4_does_not() {
use bevy::input::keyboard::{Key, KeyboardInput};
use bevy::input::ButtonState;
let run = |alt: bool, key: KeyCode| {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(bevy::input::InputPlugin)
.add_message::<WindowCloseRequested>()
.add_systems(PreUpdate, close_on_alt_f4.after(bevy::input::InputSystems));
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
if alt {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::AltLeft,
logical_key: Key::Alt,
state: ButtonState::Pressed,
text: None,
repeat: false,
window,
});
}
app.world_mut().write_message(KeyboardInput {
key_code: key,
logical_key: Key::F4,
state: ButtonState::Pressed,
text: None,
repeat: false,
window,
});
app.update();
let sent: Vec<Entity> = app
.world_mut()
.resource_mut::<Messages<WindowCloseRequested>>()
.drain()
.map(|c| c.window)
.collect();
(sent, window)
};
let (sent, window) = run(true, KeyCode::F4);
assert_eq!(sent, [window], "Alt+F4 closes the primary window");
assert!(
run(false, KeyCode::F4).0.is_empty(),
"plain F4 does nothing"
);
assert!(run(true, KeyCode::F5).0.is_empty(), "Alt+F5 does nothing");
}
}
+121 -4
View File
@@ -29,10 +29,27 @@ const GLUE_MUSIC_FADE_OUT_MS: u64 = 3000;
#[derive(Message)]
pub(crate) struct GlueSound(pub(crate) &'static str);
/// The stock glue screens' music verbs, which drive the theme in place of the native screens'
/// start-on-entry under `WOW_GLUE_STOCK=1`.
#[derive(Message, Clone, Debug, PartialEq, Eq)]
pub(crate) enum GlueMusicRequest {
/// `PlayGlueMusic(file)` (`0x45aeb0`): the same file still streaming plays on; another stops
/// the old one over 3.0 s and starts at 0.8; an empty name restarts the remembered file if
/// nothing streams.
Play(String),
/// `StopGlueMusic()` (`0x46cf00`): a 1.0 s fade (`0x45b050(1.0)`), the file still remembered.
Stop,
}
/// `StopGlueMusic`'s fade, `0x3f800000` pushed at `0x46cf00`.
const STOP_GLUE_MUSIC_FADE_MS: u64 = 1000;
/// The held glue-music stream and its starvation watch. The handle is kept through the stop-fade
/// so the watch still sees the stream during the world-entry load burst.
struct GlueMusic {
handle: Option<mixer::StreamingSoundHandle<kira::sound::FromFileError>>,
/// The file `PlayGlueMusic` last started (`0xb060a0`), the stock screens' theme.
track: String,
watch: mixer::StreamWatch,
/// The stop-fade is running; the per-frame slider feed stands off so it cannot undo the ramp.
fading: bool,
@@ -42,6 +59,7 @@ impl Default for GlueMusic {
fn default() -> Self {
Self {
handle: None,
track: String::new(),
watch: mixer::StreamWatch::new("glue music"),
fading: false,
}
@@ -90,24 +108,35 @@ fn start_glue_music(
// A theme mid-fade restarts at once: the enter-world stop clears the cached track name
// (`0x45afec`), so the next `PlayGlueMusic` opens a fresh stream. The old handle drops below
// with its ramp armed and finishes fading on the backend.
open_glue_music(&mut music, &mut out, assets.as_deref(), &config, GLUE_MUSIC);
}
/// Open `file` off the chain as the held theme at the Music slider times 0.8.
fn open_glue_music(
music: &mut GlueMusic,
out: &mut SoundOutput,
assets: Option<&WorldAssets>,
config: &SoundConfig,
file: &str,
) {
let (Some(assets), Some(mixer_ref)) = (assets, out.mixer.as_mut()) else {
return;
};
let bytes = {
let chain = assets.chain.lock_recover();
chain.read(GLUE_MUSIC)
chain.read(file)
};
let data = match bytes.and_then(mixer::stream_from_bytes) {
Ok(d) => d,
Err(e) => {
debug!("glue music: {GLUE_MUSIC} — {e:#}");
debug!("glue music: {file} — {e:#}");
return;
}
};
let amp = config.category_amp(SoundCategory::Music) * GLUE_MUSIC_VOLUME;
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
Ok(h) => {
info!("glue music: {GLUE_MUSIC}");
info!("glue music: {file}");
music.handle = Some(h);
music.fading = false;
music.watch.reset();
@@ -116,6 +145,69 @@ fn start_glue_music(
}
}
/// Stop the held theme over `fade_ms`; the handle drops once the watch sees it stopped.
fn fade_glue_music(music: &mut GlueMusic, fade_ms: u64) {
if let Some(h) = music.handle.as_mut() {
h.stop(mixer::fade(fade_ms));
music.fading = true;
}
}
/// What a `PlayGlueMusic(file)` does against the remembered file and whether a stream is live
/// (`0x45aef9`-`0x45afdb`).
#[derive(Debug, PartialEq, Eq)]
enum PlayAction {
Nothing,
/// Start the remembered file again (an empty name, nothing streaming).
Restart,
/// Stop what streams over 3.0 s, remember `file` and start it.
Switch,
}
fn play_action(file: &str, remembered: &str, streaming: bool) -> PlayAction {
if file.is_empty() {
if !streaming && !remembered.is_empty() {
PlayAction::Restart
} else {
PlayAction::Nothing
}
} else if !remembered.is_empty() && file == remembered && streaming {
PlayAction::Nothing
} else {
PlayAction::Switch
}
}
/// The stock glue screens' music verbs.
fn drive_glue_music_requests(
mut requests: MessageReader<GlueMusicRequest>,
mut music: NonSendMut<GlueMusic>,
mut out: NonSendMut<SoundOutput>,
assets: Option<Res<WorldAssets>>,
config: Res<SoundConfig>,
) {
for request in requests.read() {
match request {
GlueMusicRequest::Play(file) => {
let streaming = music.handle.is_some() && !music.fading;
match play_action(file, &music.track, streaming) {
PlayAction::Nothing => {}
PlayAction::Restart => {
let track = music.track.clone();
open_glue_music(&mut music, &mut out, assets.as_deref(), &config, &track);
}
PlayAction::Switch => {
fade_glue_music(&mut music, GLUE_MUSIC_FADE_OUT_MS);
music.track = file.clone();
open_glue_music(&mut music, &mut out, assets.as_deref(), &config, file);
}
}
}
GlueMusicRequest::Stop => fade_glue_music(&mut music, STOP_GLUE_MUSIC_FADE_MS),
}
}
}
/// Arm the theme's 3.0 s fade when the world's load drains.
///
/// The reference's trigger: `CGlueMgr::Update` state 8 spins each frame after entry
@@ -188,6 +280,7 @@ fn report_stream_voices(music: NonSend<GlueMusic>, mut out: NonSendMut<super::So
pub(super) fn plugin(app: &mut App) {
app.add_message::<GlueSound>()
.add_message::<GlueMusicRequest>()
.insert_non_send_resource(GlueMusic::default())
.add_systems(
Update,
@@ -196,7 +289,12 @@ pub(super) fn plugin(app: &mut App) {
// The theme starts at the login screen (`AccountLogin_OnShow` sets the same
// `wow_main_theme`) and keeps across the glue screens.
start_glue_music
.run_if(in_state(ClientState::Login).or(in_state(ClientState::CharSelect))),
.run_if(in_state(ClientState::Login).or(in_state(ClientState::CharSelect)))
.run_if(crate::ui_script::glue::native_glue),
// The stock screens' `PlayGlueMusic`/`StopGlueMusic` drive it instead.
drive_glue_music_requests
.after(crate::ui_script::glue_mgr::drive_glue_mgr)
.before(watch_glue_music),
// No run condition: the theme plays into `InWorld`, and the entry load burst is
// the window the watch exists for.
hand_off_glue_music,
@@ -207,3 +305,22 @@ pub(super) fn plugin(app: &mut App) {
),
);
}
#[cfg(test)]
mod tests {
use super::*;
/// `0x45aeb0`'s three legs: an empty name restarts the remembered file only when nothing
/// streams; the remembered file still streaming plays on; anything else switches.
#[test]
fn play_glue_music_restarts_keeps_or_switches() {
let theme = "Sound\\Music\\GlueScreenMusic\\wow_main_theme.mp3";
assert_eq!(play_action("", theme, false), PlayAction::Restart);
assert_eq!(play_action("", theme, true), PlayAction::Nothing);
assert_eq!(play_action("", "", false), PlayAction::Nothing);
assert_eq!(play_action(theme, theme, true), PlayAction::Nothing);
assert_eq!(play_action(theme, theme, false), PlayAction::Switch);
assert_eq!(play_action(theme, "", false), PlayAction::Switch);
assert_eq!(play_action("other.mp3", theme, true), PlayAction::Switch);
}
}
+245 -16
View File
@@ -12,20 +12,30 @@
//! ([`GreetSeq`], `[0xc4d970]`, reset when the greeted NPC changes) steps `0x623910`
//! ([`greet_line`]): five hello takes, then the pissed kit's takes in order, then a wrap.
//!
//! Gates stand ahead of the select play, in the reference's order. The unit's channel latch comes
//! first (`0x60c28c`), then a dead unit (`0x60c2a5`). The reaction pair (`0x6061e0` both ways,
//! each at least neutral, `0x60c2d9`-`0x60c2f2`) is between the unit and the active mover, the
//! object at `[0xc4da98]` that only `SetActiveMover` writes (`0x6006e0`), so a possessed creature
//! or an Eye of Kilrogg stands in for the player there; none streamed bails. The dead or ghost
//! gate (`0x605f30` at `0x60c31d`) is on the local player, unresolved or not. Only a click that
//! passes all of them re-tracks the NPC (`0x60c326`) and resets the sequence.
//!
//! ## The window hello and goodbye (`0x60c3b0`, right-click)
//!
//! Opening any interaction window (`0x4930d0`, 14 call sites, none a mouseover) plays the NPC's
//! hello once with a free-picked take (`0x60c464`), the same `+0x4` slot. Closing to nothing plays
//! the goodbye (`+0x8`). A swap to another NPC's window plays the new hello and suppresses the old
//! goodbye (`0x4930fd`: `dl = (new_guid != 0)`). The active NPC is the union of the window sessions
//! ([`MerchantOpen`], [`GossipState`], [`QuestGiver`], [`TrainerOpen`]).
//! ([`MerchantOpen`], [`GossipState`], [`QuestGiver`], [`TrainerOpen`]). Every such call also zeroes
//! the select greeting's tracked NPC (`0x60c3d1`), so the next left-click starts its sequence over.
use bevy::prelude::*;
use benilla_formats::NpcGreetingCatalog;
use benilla_protocol::EntityKind;
use crate::net::{GuidIndex, NetEntity, ObjectStore};
use crate::net::{ActiveMover, GuidIndex, NetEntity, ObjectStore, Reputations, SelfPlayer};
use crate::target::{both_reactions_at_least_neutral, Factions};
use crate::ui_gossip::GossipState;
use crate::ui_merchant::MerchantOpen;
use crate::ui_quest::QuestGiver;
@@ -52,7 +62,7 @@ struct NpcGreetings(NpcGreetingCatalog);
/// The select greeting's counter `[0xc4d970]`, keyed by the tracked NPC `[0xc4d908]` and reset
/// when it changes; it advances only on a play that started (`0x60c28c`).
#[derive(Default)]
#[derive(Resource, Default)]
struct GreetSeq {
tracked: Option<Entity>,
seq: usize,
@@ -149,6 +159,50 @@ fn resolve(
Some((row, transform.translation))
}
/// The local player may hear an NPC: streamed, and neither dead nor a ghost (`0x605f30`). The gate
/// of the select greeting (`0x60c31d`) and of the window hello and goodbye (`0x60c401`).
fn player_may_hear(me: Option<&ObjectStore>) -> bool {
me.is_some_and(|s| !s.0.is_dead_or_ghost())
}
/// The select greeting's two gates (`0x60c270`): both reaction directions between the unit and the
/// active mover at least neutral (`0x60c2d9`, `0x60c2ef`), none streamed bailing (`0x60c2d0`), then
/// [`player_may_hear`] on the local player (`0x60c31d`).
fn select_greeting_allowed(
factions: Option<&Factions>,
reputations: &Reputations,
npc: Option<&ObjectStore>,
mover: Option<&ObjectStore>,
me: Option<&ObjectStore>,
) -> bool {
mover.is_some()
&& both_reactions_at_least_neutral(factions, reputations, npc, mover)
&& player_may_hear(me)
}
/// The select greeting's gates and re-track, in the reference's order (`0x60c28c`-`0x60c33e`): a
/// sounding line (the latch) or a dead unit ends the click, then [`select_greeting_allowed`]'s
/// verdict, and only a click that passes all three tracks the NPC, resetting the sequence when it
/// is a different one. `true` means go on to pick the line.
fn begin_select(seq: &mut GreetSeq, npc: Entity, latched: bool, dead: bool, allowed: bool) -> bool {
if latched || dead || !allowed {
return false;
}
if seq.tracked != Some(npc) {
*seq = GreetSeq {
tracked: Some(npc),
seq: 0,
};
}
true
}
/// A window hello or goodbye forgets the tracked NPC (`0x60c3d1`, ahead of every gate after the
/// unit-type check), so the next click greeting starts over wherever it lands.
fn forget_tracked(seq: &mut GreetSeq) {
seq.tracked = None;
}
/// Plays one line, kit 0 being none and `None` a free-picked take, gated on the unit's channel
/// latch (`0x60c28c`).
fn play_line(
@@ -189,8 +243,12 @@ fn play_line(
/// The select greeting.
fn play_select_greetings(
mut reqs: MessageReader<NpcGreetingRequest>,
mut seq: Local<GreetSeq>,
mut seq: ResMut<GreetSeq>,
units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
mover: Query<&ObjectStore, With<ActiveMover>>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
greetings: Option<Res<NpcGreetings>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
@@ -208,21 +266,33 @@ fn play_select_greetings(
let listener = cam.single().map(|t| t.translation).unwrap_or(Vec3::ZERO);
for req in reqs.read() {
let Some((row, pos)) = resolve(req.npc, &units, &greetings) else {
let Ok((net, transform, npc_store)) = units.get(req.npc) else {
continue;
};
// A different NPC resets the sequence counter (`0x60c326`).
if seq.tracked != Some(req.npc) {
*seq = GreetSeq {
tracked: Some(req.npc),
seq: 0,
};
}
// The latch gate precedes the counter: a re-click while the line sounds neither plays nor
// advances (`0x60c28c`).
if kit::source_playing(&out, req.npc) {
if !matches!(net.kind, EntityKind::Unit) {
continue;
}
let allowed = select_greeting_allowed(
factions.as_deref(),
&reputations,
npc_store,
mover.single().ok(),
self_store.single().ok(),
);
let dead = npc_store.is_some_and(|s| s.0.unit_is_dead());
let latched = kit::source_playing(&out, req.npc);
if !begin_select(&mut seq, req.npc, latched, dead, allowed) {
continue;
}
// A unit with no greeting row still took the track above; `0x623910` answers -1 for it.
let Some(row) = net
.display_id
.and_then(|d| greetings.0.for_display(d))
.copied()
else {
continue;
};
let pos = transform.translation;
let (line, next) = greet_line(seq.seq, kits.variations(row.pissed));
let (kit_id, variant) = match line {
GreetLine::Hello => (row.hello, None),
@@ -250,7 +320,9 @@ fn play_window_greetings(
trainer: Res<TrainerOpen>,
index: Res<GuidIndex>,
mut prev_active: Local<Option<u64>>,
mut select_seq: ResMut<GreetSeq>,
units: Query<(&NetEntity, &Transform, Option<&ObjectStore>)>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
greetings: Option<Res<NpcGreetings>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
@@ -279,6 +351,17 @@ fn play_window_greetings(
let Some(&npc) = index.0.get(&guid) else {
return;
};
// The call zeroes the select greeting's tracked NPC for any unit, before the gates below.
if units
.get(npc)
.is_ok_and(|(net, ..)| matches!(net.kind, EntityKind::Unit))
{
forget_tracked(&mut select_seq);
}
// No reaction gate here (`0x60c3b0`), only the player's: dead or a ghost hears nothing.
if !player_may_hear(self_store.single().ok()) {
return;
}
let Some((row, pos)) = resolve(npc, &units, &greetings) else {
return;
};
@@ -299,12 +382,14 @@ fn stop_on_despawn(mut removed: RemovedComponents<NetEntity>, mut out: NonSendMu
/// Registration hook for [`super::SoundPlugin`].
pub(super) fn plugin(app: &mut App) {
app.add_message::<NpcGreetingRequest>()
.init_resource::<GreetSeq>()
.add_systems(Startup, load_npc_greetings.after(AssetSet::Open))
.add_systems(
Update,
(
play_select_greetings,
play_window_greetings,
// Both write the tracked NPC; a click and a window in one frame take it in turn.
play_window_greetings.after(play_select_greetings),
stop_on_despawn,
)
.in_set(WorldStage::Present),
@@ -373,4 +458,148 @@ mod tests {
"gossip→trainer on one NPC is a silent swap — no goodbye",
);
}
use benilla_protocol::field::{
FIELD_PLAYER_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH,
FIELD_UNIT_MAXHEALTH,
};
use benilla_protocol::ObjectFields;
/// A player-controlled player on faction template 1 (Human), with the given health and
/// `PLAYER_FLAGS`.
fn player(health: u32, player_flags: u32) -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(FIELD_UNIT_FACTIONTEMPLATE, 1),
(FIELD_UNIT_FLAGS, 1 << 3),
(FIELD_UNIT_HEALTH, health),
(FIELD_UNIT_MAXHEALTH, 100),
(FIELD_PLAYER_FLAGS, player_flags),
]))
}
/// `0x605f30` over the local player gates both the select greeting (`0x60c31d`) and the window
/// hello and goodbye (`0x60c401`): a corpse (health 0) and a ghost (health 1, the flag) are
/// silent, and so is a player that has not streamed in (`0x60c315`).
#[test]
fn a_dead_or_ghost_or_absent_player_hears_nothing() {
assert!(player_may_hear(Some(&player(100, 0))), "alive hears");
assert!(!player_may_hear(Some(&player(0, 0))), "a corpse does not");
assert!(!player_may_hear(Some(&player(1, 0x10))), "nor a ghost");
assert!(!player_may_hear(None), "nor an unstreamed player");
}
/// The select greeting's reaction gate (`0x60c2d9`-`0x60c2f2`) on the real templates: Monster
/// (14) is hostile to a Human, a Chicken (31) is neutral, and neutral passes (`cmp eax,3` /
/// `jl`). A dead player is refused whatever the reactions.
#[test]
fn the_select_greeting_refuses_a_hostile_unit_and_a_dead_player() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let factions =
Factions::from_catalog(benilla_formats::load_faction_catalog(&mut chain).expect("dbc"));
let reps = Reputations::default();
let unit = |template: u32| {
ObjectStore(ObjectFields::from_pairs(&[(
FIELD_UNIT_FACTIONTEMPLATE,
template,
)]))
};
let allowed = |npc: &ObjectStore, mover: Option<&ObjectStore>, me: &ObjectStore| {
select_greeting_allowed(Some(&factions), &reps, Some(npc), mover, Some(me))
};
let me = player(100, 0);
assert!(
!allowed(&unit(14), Some(&me), &me),
"a hostile monster is silent"
);
assert!(
allowed(&unit(31), Some(&me), &me),
"a neutral critter greets"
);
assert!(
!allowed(&unit(31), Some(&me), &player(0, 0)),
"a dead player hears no greeting"
);
assert!(
!allowed(&unit(31), Some(&me), &player(1, 0x10)),
"nor does a ghost"
);
// The reaction pair is with the active mover, not the player: a monster hostile to the
// player greets the creature we possess, which is its own kind, and a friend of the player
// is refused when the mover is hostile to it. No mover streamed, no greeting.
let possessed = unit(14);
assert!(allowed(&unit(14), Some(&possessed), &me));
assert!(!allowed(&unit(14), None, &me));
}
/// The pair is both directions: with no catalog every faction leg reads neutral, so only a rung
/// ahead of them (two free-for-all players, hostile both ways) is refused.
#[test]
fn the_reaction_gate_is_the_pair_not_one_direction() {
let reps = Reputations::default();
let ffa = ObjectStore(ObjectFields::from_pairs(&[
(FIELD_UNIT_FLAGS, 1 << 3),
(FIELD_PLAYER_FLAGS, 0x80),
]));
let other = ObjectStore(ObjectFields::from_pairs(&[
(FIELD_UNIT_FLAGS, 1 << 3),
(FIELD_PLAYER_FLAGS, 0x80),
]));
assert!(!select_greeting_allowed(
None,
&reps,
Some(&other),
Some(&ffa),
Some(&ffa)
));
let calm = player(100, 0);
assert!(select_greeting_allowed(
None,
&reps,
Some(&calm),
Some(&calm),
Some(&calm)
));
}
fn two_npcs() -> (Entity, Entity) {
let mut world = World::new();
(world.spawn_empty().id(), world.spawn_empty().id())
}
/// The latch is the first gate (`0x60c28c`), ahead of the re-track and reset (`0x60c33e`): a
/// click on A while A's line sounds changes nothing, so B's sequence carries on. Every gate
/// that refuses leaves the track alone, and only a passing click on a new NPC resets.
#[test]
fn a_refused_click_leaves_the_tracked_npc_and_its_sequence_alone() {
let (a, b) = two_npcs();
let mut seq = GreetSeq {
tracked: Some(b),
seq: 3,
};
assert!(!begin_select(&mut seq, a, true, false, true), "latched");
assert!(!begin_select(&mut seq, a, false, true, true), "dead");
assert!(!begin_select(&mut seq, a, false, false, false), "refused");
assert_eq!((seq.tracked, seq.seq), (Some(b), 3), "B continues");
// The same NPC again keeps its count; a different one starts over.
assert!(begin_select(&mut seq, b, false, false, true));
assert_eq!(seq.seq, 3);
assert!(begin_select(&mut seq, a, false, false, true));
assert_eq!((seq.tracked, seq.seq), (Some(a), 0));
}
/// A window hello or goodbye zeroes the tracked NPC (`0x60c3d1`): left-click three times, open
/// the window and close it, and the next left-click on the same NPC is the first hello again.
#[test]
fn a_window_greeting_restarts_the_click_sequence() {
let (a, _) = two_npcs();
let mut seq = GreetSeq::default();
assert!(begin_select(&mut seq, a, false, false, true));
seq.seq = 3;
forget_tracked(&mut seq);
assert!(begin_select(&mut seq, a, false, false, true));
assert_eq!(seq.seq, 0, "the sequence starts over");
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ pub(crate) use emote::EmoteSounds;
pub(crate) fn kit_decodes() -> u32 {
kit::DECODES.load(std::sync::atomic::Ordering::Relaxed)
}
pub(crate) use glue::GlueSound;
pub(crate) use glue::{GlueMusicRequest, GlueSound};
pub(crate) use greeting::NpcGreetingRequest;
/// Named by the schedule tests' class table (`game_plugins::schedule_tests::Classes`).
#[cfg(test)]
+15 -1
View File
@@ -182,7 +182,7 @@ pub(super) fn update_hover(
rig: Res<CameraControl>,
pointer_over_ui: Res<PointerOverUi>,
occlusion: Res<PickOcclusion>,
// The unit whose plate holds the pointer, as of last frame's plate layout.
// The unit whose plate holds the hover after this frame's UI walk.
plate_hover: Res<crate::vplates::PlateHover>,
mut hovered: ResMut<Hovered>,
mesh_assets: Res<Assets<Mesh>>,
@@ -235,6 +235,7 @@ pub(super) fn update_hover(
hovered.corpse_guid = None;
hovered.distance = f32::MAX;
hovered.refused = false;
hovered.plate = false;
// A plate under the pointer is the mouseover, read before the UI gate below, which the plate
// itself trips: the reference's plate OnEnter (`0x7cb850`) publishes `[0xb4e2c8]` ungraded,
// with no world pick behind it. Freelook wins, as the plates hand the mouse back (`0x60f830`).
@@ -243,6 +244,7 @@ pub(super) fn update_hover(
hovered.target = Some(entity);
hovered.guid = units.get(entity).ok().map(|(g, _)| g.0);
hovered.distance = 0.0; // topmost UI: it beats any world GameObject at a tie
hovered.plate = true;
*last_pick = Some(entity);
return;
}
@@ -820,6 +822,18 @@ mod tests {
use crate::spell::targeting::corpse_fixture as fx;
use bevy::ecs::system::RunSystemOnce;
/// The plate hover the pick reads is this frame's walk's: a plate's leave publishes the loss
/// inside the walk (`0x7cb8b0`), so a pick a frame behind would publish the plate again.
#[test]
fn the_pick_reads_the_plate_hover_of_this_frames_walk() {
let mut app = crate::game_plugins::schedule_tests::headless_client();
assert!(crate::test_support::runs_before(
&mut app,
crate::vplates::seed_plate_hover,
update_hover
));
}
fn card(bone: u16, owner: Entity) -> BillboardCard {
let info = benilla_assets::BillboardInfo {
pivot: Vec3::ZERO,
+7 -1
View File
@@ -74,7 +74,10 @@ pub(crate) use by_name::{AssistRequest, PlayerLookup, TargetByNameRequest};
pub(crate) use by_name::SelectCommit;
// The reaction decode and its faction catalog, which also tint the target frame
// (`TargetFrame_CheckFaction`); `duel_rung` is the same walk, for `/reaction`.
pub(crate) use ring::{duel_rung, ring_reaction, selection_variant, Factions, RingVariant};
pub(crate) use ring::{
both_reactions_at_least_neutral, duel_rung, ring_reaction, selection_variant, Factions,
RingVariant,
};
#[cfg(test)]
pub(crate) use ring::{ring_variant, PlayerPath, SelectorInput};
@@ -113,6 +116,9 @@ pub(crate) struct Hovered {
/// [`Self::distance`] holds the hit, so a chest behind the unit does not inherit the mouseover.
/// To [`click::select_on_click`] it is an object hit, which clears no selection.
pub(crate) refused: bool,
/// The pick is the hovered plate's unit, the plate's enter publishing it (`0x7cb850`), not the
/// world's own pick: it stands only while that plate still holds the hover.
pub(crate) plate: bool,
}
impl Hovered {
+13
View File
@@ -625,6 +625,19 @@ pub(crate) fn can_interact_from_player(
if target.0.unit_flags() & UNIT_FLAG_NOT_SELECTABLE != 0 || target.0.unit_npc_flags() == 0 {
return false;
}
both_reactions_at_least_neutral(factions, reputations, target_store, self_store)
}
/// Both `UnitReaction` directions between a unit and the local player are neutral or better: the
/// unit's toward the player ([`ring_reaction`], `this` the unit) and the player's toward the unit
/// ([`reaction_from_player`]), each `>= 3` on the internal scale. The test of `CanInteract`
/// (`0x606847`, `0x606854`) and of the select greeting (`0x60c2d9`–`0x60c2f2`).
pub(crate) fn both_reactions_at_least_neutral(
factions: Option<&Factions>,
reputations: &Reputations,
target_store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
) -> bool {
const NEUTRAL: u8 = Reaction::Neutral as u8;
ring_reaction(factions, reputations, target_store, self_store) >= NEUTRAL
&& reaction_from_player(factions, reputations, target_store, self_store) >= NEUTRAL
@@ -184,3 +184,27 @@ impl HostClipboard {
}
}
}
/// A clipboard holding `text` in memory, so a test never touches the machine's pasteboard.
#[cfg(test)]
impl HostClipboard {
pub(crate) fn holding(text: &str) -> Self {
struct Memory(String);
impl Pasteboard for Memory {
fn read_text(&mut self) -> Result<Option<String>, String> {
Ok((!self.0.is_empty()).then(|| self.0.clone()))
}
fn write_text(&mut self, text: &str) -> Result<(), String> {
self.0 = text.to_string();
Ok(())
}
fn name(&self) -> &'static str {
"memory"
}
}
HostClipboard {
backend: Some(Box::new(Memory(text.to_string()))),
opened: true,
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ use super::{
/// An item action's icon when its template has not answered or its display id has no
/// `ItemDisplayInfo` row: the reference's literal `0x847fe4`, from the resolver's `0x5d8927`.
pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
pub(crate) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
/// The feed's memory of what it last pushed, for per-slot change events.
#[derive(Default)]
+1
View File
@@ -23,6 +23,7 @@ mod drain_tests;
pub(crate) mod drop_item;
mod errors;
mod feed;
pub(crate) use feed::MISSING_ITEM_ICON;
#[cfg(test)]
mod feed_tests;
mod net;
+18 -12
View File
@@ -93,7 +93,14 @@ impl Plugin for UiAuraPlugin {
.init_resource::<PlayerAuraCache>()
.init_resource::<AuraFeedMemory>()
// Feed before the VM dispatch, drain after it.
.add_systems(Update, feed_auras.in_set(UnitFeed).in_set(AuraEvents))
// After this frame's world publish, so the gain names the unit in the same frame.
.add_systems(
Update,
feed_auras
.in_set(UnitFeed)
.in_set(AuraEvents)
.after(crate::ui_tooltip::MouseoverPublish),
)
.add_systems(Update, drain_aura_cancels.after(UiInput))
// Torn down only at the session's end, not with the avatar, which a worldport drops.
.add_systems(OnExit(ClientState::InWorld), end_session_aura_state);
@@ -377,7 +384,8 @@ fn tracking_state_of(
})
}
/// The units the resolver's guid globals name, from `UnitTokens`' own sources.
/// The units the resolver's guid globals name, from `UnitTokens`' own sources; the mouseover is
/// the VM's published one.
#[derive(bevy::ecs::system::SystemParam)]
struct AuraUnits<'w> {
selection: Res<'w, Selection>,
@@ -385,8 +393,6 @@ struct AuraUnits<'w> {
// `Option`: a UI-only harness lacks their plugins; without the party's no group token names
// anyone.
group: Option<Res<'w, crate::ui_party::GroupState>>,
hovered: Option<Res<'w, crate::target::Hovered>>,
hovered_go: Option<Res<'w, crate::target::HoveredObject>>,
interact: Option<Res<'w, crate::ui_session::InteractNpc>>,
}
@@ -530,6 +536,13 @@ fn feed_auras(
)
};
let target_guid = units.selection.guid.unwrap_or(0);
// The resolver reads the published pair `[0xb4e2c8]` (`0x515bb6`, `0x515bdb`), not the pick,
// and takes it only as a unit (`0x515bca mov ecx,8`, `0x515bd9 je`): a GameObject, a corpse or
// nothing names nobody.
let mouseover = script
.world_mouseover()
.filter(|&guid| held(guid).is_some_and(|s| s.is_unit()))
.unwrap_or(0);
let catalog_moved = spells.as_ref().is_some_and(|s| s.is_changed());
let unit_changed = memo.units.feed(
&mut script,
@@ -538,14 +551,7 @@ fn feed_auras(
me: (self_guid.0, changed),
pet: pet_guid,
target: target_guid,
// The world pick, as `UnitTokens` resolves `"mouseover"`.
mouseover: units
.hovered
.as_deref()
.and_then(|h| {
h.mouseover(&units.hovered_go.as_deref().copied().unwrap_or_default())
})
.map_or(0, |(_, guid)| guid),
mouseover,
npc: units.interact.as_deref().and_then(|i| i.1).unwrap_or(0),
held,
buffs_visible,
+28
View File
@@ -965,10 +965,18 @@ mod tests {
(selection.target, selection.guid) = (Some(mob), Some(MOB));
let mut hovered = app.world_mut().resource_mut::<crate::target::Hovered>();
(hovered.target, hovered.guid) = (Some(add), Some(ADD));
publish_mouseover(&mut app, Some(ADD));
app.update();
app
}
/// The world mouseover as the publisher (`0x492890`) writes it to `[0xb4e2c8]`.
fn publish_mouseover(app: &mut App, guid: Option<u64>) {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.publish_world_mouseover(guid);
}
/// `UnitDebuff` reads the list of whatever unit the token names, through the resolver
/// (`0x515970`, called at `0x519542`): the mouseover, a member's target, any depth of `target`
/// chain, in any case, and the player's own list at the end of a chain that comes back to us.
@@ -1036,11 +1044,31 @@ mod tests {
guid: Some(0xF110_0000_0000_0004),
distance: 5.0,
});
publish_mouseover(&mut app, Some(0xF110_0000_0000_0004));
app.update();
assert!(listed(&mut app, "mouseover", true).is_empty());
assert!(listed(&mut app, "mouseovertarget", true).is_empty());
}
/// A hovered plate retired after the pick: its leave (`0x7cb8b0`) publishes the empty
/// mouseover, while the pick still names the plate's unit. The resolver reads the published
/// pair (`0x515bb6`, `0x515bdb`), so in the next frame `"mouseover"` and its chains name
/// nobody.
#[test]
fn a_retired_hovered_plate_names_no_mouseover_in_the_next_frame() {
let mut app = chain_app();
assert_eq!(listed(&mut app, "mouseover", true), [770]);
assert_eq!(listed(&mut app, "mouseovertarget", true), [6788]);
app.world_mut()
.resource_mut::<crate::target::Hovered>()
.plate = true;
publish_mouseover(&mut app, None);
app.update();
assert!(listed(&mut app, "mouseover", true).is_empty());
assert!(listed(&mut app, "mouseovertarget", true).is_empty());
assert!(listed(&mut app, "mouseovertargettarget", true).is_empty());
}
/// A raid member's target that is out of view but on the roster reads its record, as
/// `UnitDebuff`'s roster walk finds it (`0x519b25`); a hop off a unit not held is nobody.
#[test]
+2
View File
@@ -14,6 +14,8 @@ mod ace_gate_tests;
mod away;
/// The world broadcasts: `SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`.
mod broadcast;
/// The chat bubbles' hand-off to their `WorldFrame` widgets.
pub(crate) mod bubbles;
mod channels;
/// The combat log's chat lines: classification, chat type and each sentence's GlobalString key.
pub(crate) mod combat;
+19
View File
@@ -0,0 +1,19 @@
//! The chat bubbles' feed: this frame's live bubbles, gated, faded and seated by
//! [`crate::chat_bubble`], handed to the widget layer, which keeps them as `WorldFrame` children.
use bevy::prelude::*;
use crate::chat_bubble::BubbleFeed;
/// Every frame, after the bubble drive and before the script paint: write the live set into the
/// VM's bubble pool, which hides and pools the frames of lines that ended.
pub(crate) fn feed_bubbles(
feed: Res<BubbleFeed>,
// `None` in a run with no UI VM.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
let (Some(mut script), Some(geometry)) = (script, feed.geometry) else {
return;
};
script.sync_chat_bubbles(geometry, &feed.states);
}
+19 -8
View File
@@ -280,15 +280,26 @@ pub(crate) fn register_dev_commands(script: &benilla_ui::script::UiScript, dev:
}
}
/// Build the table in `PostStartup`, after `Startup` has inserted the VM and the emote catalog.
pub(crate) fn build_slash_commands(
mut commands: Commands,
script: Option<NonSend<benilla_ui::script::UiScript>>,
emotes: Option<Res<crate::sound::EmoteSounds>>,
) {
/// Build the table in `PostStartup`, after `Startup` has inserted the VM and the emote catalog,
/// off the boot VM's `GlobalStrings.lua` and emote tokens. Where the stock glue screens hold the
/// world instead, their VM has neither, so a boot VM's contents are built for this read alone
/// ([`crate::ui_script::strings_vm`]).
pub(crate) fn build_slash_commands(world: &mut World) {
let glue_side = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.is_some_and(|s| s.vm_kind() == benilla_ui::script::VmKind::Glue);
let own = if glue_side {
crate::ui_script::strings_vm(world)
} else {
None
};
let script = own
.as_ref()
.or_else(|| world.get_non_send_resource::<benilla_ui::script::UiScript>());
let emotes = world.get_resource::<crate::sound::EmoteSounds>();
let (Some(script), Some(emotes)) = (script, emotes) else {
warn!("chat: no UI VM or emote catalog — the slash-command table is EMPTY");
commands.insert_resource(SlashCommands::default());
world.insert_resource(SlashCommands::default());
return;
};
let globals = script.lua().globals();
@@ -305,5 +316,5 @@ pub(crate) fn build_slash_commands(
if emote == 0 {
error!("chat: NO emote aliases — every /wave-style command is dead (GlobalStrings?)");
}
commands.insert_resource(table);
world.insert_resource(table);
}
+68
View File
@@ -98,6 +98,13 @@ enum Pending {
},
/// An area discovery (`SMSG_EXPLORATION_EXPERIENCE`), fired at the drain, which holds the VM.
Discovery { area: String, xp: u32 },
/// "You are now <rank> with <faction>." (`0x62c670`), parked by the wire handler in packet
/// order and resolved against the VM's strings at the drain.
Standing {
faction: String,
rank: u8,
female: bool,
},
/// One combat-log line, classified at the packet, waiting on its two endpoint names; the
/// reference's deferred queue (`0xc4e208`) waits only on an item name.
Combat(Box<super::combat::PendingCombat>),
@@ -312,11 +319,57 @@ impl ChatLog {
});
}
/// Queue a reputation rank line (`FACTION_STANDING_CHANGED`) for the drain to fill.
pub(crate) fn push_standing(&mut self, faction: &str, rank: u8, female: bool) {
self.pending.push(Pending::Standing {
faction: faction.to_string(),
rank,
female,
});
}
/// Drop every pending item on disconnect.
pub(crate) fn clear_session(&mut self) {
self.pending.clear();
}
/// What is parked, in order, by kind: the order a packet batch left the lines in.
#[cfg(test)]
pub(crate) fn pending_kinds(&self) -> Vec<&'static str> {
self.pending
.iter()
.map(|p| match p {
Pending::Wire { .. } => "wire",
Pending::Notice { .. } => "notice",
Pending::Addon { .. } => "addon",
Pending::Roll { .. } => "roll",
Pending::XpGain { .. } => "xp",
Pending::HonorGain { .. } => "honor",
Pending::TextEmote { .. } => "emote",
Pending::Discovery { .. } => "discovery",
Pending::Standing { .. } => "standing",
Pending::Combat(_) => "combat",
Pending::Event(_) => "event",
})
.collect()
}
/// The parked rank lines as `(faction, rank, female)`, in order.
#[cfg(test)]
pub(crate) fn pending_standing(&self) -> Vec<(String, u8, bool)> {
self.pending
.iter()
.filter_map(|p| match p {
Pending::Standing {
faction,
rank,
female,
} => Some((faction.clone(), *rank, *female)),
_ => None,
})
.collect()
}
/// The parked addon lines as `(prefix, message, distribution)`.
#[cfg(test)]
pub(crate) fn pending_addons(&self) -> Vec<(String, String, String)> {
@@ -1076,6 +1129,21 @@ pub(super) fn feed_chat(
);
}
}
Pending::Standing {
faction,
rank,
female,
} => {
let line =
crate::ui_reputation::standing_text(&globals(&script), rank, female, &faction);
if let Some(text) = line {
route(
&mut script,
&mut windows,
&ChatEvent::text_only(ChatEventKind::System, text),
);
}
}
Pending::Discovery { area, xp } => {
// The toast fires every time; the chat line only rides XP.
let (toast, line) = {
+6 -2
View File
@@ -1296,6 +1296,7 @@ mod bind_confirm_tests {
texture: Some("Interface\\Icons\\INV_Axe_01".into()),
count: 1,
quality: Some(quality),
inventory_type: 0,
item_id: FLURRY_AXE,
link: Some("|cffa335ee|Hitem:871:0:0:0|h[Flurry Axe]|h|r".into()),
locked: false,
@@ -1692,9 +1693,12 @@ mod bind_confirm_tests {
},
);
}
// Re-run the place now that the item is out of reach.
// Re-run the place now that the item is out of reach. The fixture's own place asked
// first, unjudged; the re-run's pickup retires that question with its `CURSOR_UPDATE`,
// which fires inside the verb.
{
let s = app.world_mut().non_send_resource_mut::<UiScript>();
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.tick(0.01);
s.run("PickupContainerItem(0, 1)").unwrap();
s.run("PickupInventoryItem(16)").unwrap();
}
+154 -65
View File
@@ -399,10 +399,7 @@ pub(super) fn feed_item_stats(
commands: Res<NetCommands>,
spells: Option<Res<crate::ui_action::Spells>>,
// The caster the `$`-tokens read: its spell modifiers and its skills.
(spell_mods, self_q): (
Res<crate::spell::SpellModifiers>,
Query<&ObjectStore, With<SelfPlayer>>,
),
(spell_mods, self_q, locks): CasterReads,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
// The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`.
home_bind: Option<Res<crate::net::HomeBind>>,
@@ -479,23 +476,24 @@ pub(super) fn feed_item_stats(
} else {
mod_sensitive.remove(&id);
}
Some((
id,
template_view(
&t,
spell_res,
Some(&spell_mods),
&skill,
skill_catalog,
home_area,
&|| gender,
factions.as_deref().map(|f| f.catalog()),
sub_classes.as_deref().map(|s| &s.0),
classes.as_deref().map(|c| &c.0),
icons.as_deref(),
&get,
),
))
let mut view = template_view(
&t,
spell_res,
Some(&spell_mods),
&skill,
skill_catalog,
home_area,
&|| gender,
factions.as_deref().map(|f| f.catalog()),
sub_classes.as_deref().map(|s| &s.0),
classes.as_deref().map(|c| &c.0),
icons.as_deref(),
&get,
);
// A lock id with no `Lock.dbc` row is no lock to the builder (`0x52bd6b`..
// `0x52bda9`); with the catalog unloaded the id stands.
view.lock_id = known_lock_id(view.lock_id, locks.as_deref().map(|l| &l.0));
Some((id, view))
})
.collect()
};
@@ -504,6 +502,24 @@ pub(super) fn feed_item_stats(
}
}
/// What the `$`-tokens read of the caster, and the `Lock.dbc` the LOCKED line needs a row from
/// (`0x52bd6b`).
type CasterReads<'w, 's> = (
Res<'w, crate::spell::SpellModifiers>,
Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
Option<Res<'w, crate::go_templates::Locks>>,
);
/// The template's lock id when `Lock.dbc` holds its row, else 0: the builder's LOCKED line starts
/// from that row (`0x52bd57`..`0x52bd76`), and a missing one is no lock. With the catalog not
/// loaded the id stands.
fn known_lock_id(lock_id: u32, locks: Option<&benilla_formats::LockCatalog>) -> u32 {
match locks {
Some(l) if l.slots(lock_id).is_none() => 0,
_ => lock_id,
}
}
/// The player state the tooltip's red requirement lines check: level, class, race, skills,
/// proficiencies (`SMSG_SET_PROFICIENCY`) and reputation ranks (the DBC base plus the
/// `SMSG_INITIALIZE_FACTIONS` standing), pushed on change.
@@ -611,7 +627,8 @@ pub(super) fn feed_player_req(
}
/// One bag slot from its item guid: instance, template, icon and use-spell cooldown. `None` is an
/// empty slot; an occupied one not yet resolved is `Some` with empty fields.
/// empty slot or one whose item object has not streamed; an occupied one whose template is not yet
/// resolved is `Some` with empty fields.
fn resolve_slot(
guid: u64,
objects: &Objects,
@@ -637,48 +654,47 @@ fn resolve_slot(
let enchant_ms: [Option<u64>; crate::items::ENCHANT_SLOTS] =
std::array::from_fn(|s| countdowns.and_then(|c| c.enchant_remaining_display_ms(s as u32)));
let duration_ms = countdowns.and_then(|c| c.lifetime_remaining_display_ms());
let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) =
match objects.object(guid) {
Some(fields) => (
fields.object_entry().unwrap_or(0),
fields.item_stack_count().unwrap_or(1),
// Max 0 is indestructible: no line.
fields
.item_durability()
.zip(fields.item_max_durability())
.filter(|&(_, max)| max > 0),
// Letter text on the instance, which the hover magnifier keys off; a template
// `PageText` book does not set it.
fields.item_text_id().is_some_and(|id| id != 0),
// The creator's name for "<Made by %s>" and "Written by %s", `None` until the
// name query answers.
fields
.item_creator()
.filter(|&g| g != 0)
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
// The tooltip's unlocked (0x4) and wrapped (0x8) bits.
fields.item_flags().unwrap_or(0),
// `0x5da2c0`: soulbound, or carrying a binding enchant, off the raw descriptor.
crate::items::already_bound(fields, rolls.enchants),
// The roll behind the name's suffix; its enchants are already in slots 2..6.
fields.item_random_properties_id(),
// All seven enchant slots with charges and countdowns: our own items stream whole,
// where others' carry two slots.
crate::items::enchant_lines(
(0..7).map(|s| {
(
s,
fields.item_enchant(s).unwrap_or(0),
fields.item_enchant_charges(s),
enchant_ms[usize::from(s)],
)
}),
rolls.enchants,
),
),
// A guid whose create has not landed: occupied, unresolved.
None => return Some(ContainerSlot::default()),
};
// A guid whose create has not landed: the reference finds no item object (`0x6228a0` returns
// NULL), so `GetContainerItemInfo` pushes nothing (`0x4f96dc`, `0x4f96e0`, `0x4f9810`) and
// the slot reads empty.
let fields = objects.object(guid)?;
let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) = (
fields.object_entry().unwrap_or(0),
fields.item_stack_count().unwrap_or(1),
// Max 0 is indestructible: no line.
fields
.item_durability()
.zip(fields.item_max_durability())
.filter(|&(_, max)| max > 0),
// Letter text on the instance, which the hover magnifier keys off; a template
// `PageText` book does not set it.
fields.item_text_id().is_some_and(|id| id != 0),
// The creator's name for "<Made by %s>" and "Written by %s", `None` until the
// name query answers.
fields
.item_creator()
.filter(|&g| g != 0)
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
// The tooltip's unlocked (0x4) and wrapped (0x8) bits.
fields.item_flags().unwrap_or(0),
// `0x5da2c0`: soulbound, or carrying a binding enchant, off the raw descriptor.
crate::items::already_bound(fields, rolls.enchants),
// The roll behind the name's suffix; its enchants are already in slots 2..6.
fields.item_random_properties_id(),
// All seven enchant slots with charges and countdowns: our own items stream whole,
// where others' carry two slots.
crate::items::enchant_lines(
(0..7).map(|s| {
(
s,
fields.item_enchant(s).unwrap_or(0),
fields.item_enchant_charges(s),
enchant_ms[usize::from(s)],
)
}),
rolls.enchants,
),
);
if entry == 0 {
return Some(ContainerSlot::default());
}
@@ -717,6 +733,7 @@ fn resolve_slot(
count,
durability,
quality: Some(t.quality),
inventory_type: t.inventory_type,
item_id: entry,
// The link carries the roll in `randomPropertyId` and in the suffixed name (`0x5d8b00`);
// the tooltip reads its name back off it.
@@ -1408,10 +1425,82 @@ fn diff_and_push(
#[cfg(test)]
mod tests {
use super::{
apply_container_source, charges_count, ContainerSlot, FeedMemory, SlotGuids,
apply_container_source, charges_count, resolve_slot, ContainerSlot, FeedMemory, SlotGuids,
BANK_CONTAINER, BANK_SLOTS,
};
/// A lock id with no `Lock.dbc` row is no lock to the item builder (`0x52bd6b`..`0x52bda9`).
#[test]
fn a_lock_id_without_a_row_is_no_lock() {
use benilla_formats::{LockCatalog, LockSlot, LOCK_KEY_ITEM};
let mut slots = [LockSlot::default(); benilla_formats::MAX_LOCK_SLOTS];
slots[0].key_type = LOCK_KEY_ITEM;
let catalog = LockCatalog::from_rows([(7, slots)]);
assert_eq!(super::known_lock_id(7, Some(&catalog)), 7);
assert_eq!(super::known_lock_id(8, Some(&catalog)), 0);
assert_eq!(
super::known_lock_id(8, None),
8,
"no catalog, the id stands"
);
assert_eq!(super::known_lock_id(0, Some(&catalog)), 0);
}
/// A bag slot carries the template's quality and InventoryType, which `GetContainerItemInfo`
/// gates its fourth value on (`0x4f9794`); an unanswered template leaves them empty.
#[test]
fn a_resolved_slot_carries_the_templates_quality_and_inventory_type() {
use crate::items::{test_template, Items, RollCatalogs};
use crate::names::NameCache;
use crate::net::NetCommands;
use crate::ui_items::TestObjects;
use benilla_protocol::ObjectFields;
const ENTRY: u16 = 3; // OBJECT_FIELD_ENTRY
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut objs = TestObjects::new();
for (guid, entry) in [(0xA1_u64, 1234_u32), (0xA2, 1235), (0xA3, 1236)] {
objs.spawn(guid, ObjectFields::from_pairs(&[(ENTRY, entry)]));
}
let mut items = Items::default();
let mut sword = test_template("Sword");
sword.quality = 4;
sword.inventory_type = 13;
items.insert_template(1234, Some(sword));
items.insert_template(1235, Some(test_template("Healing Potion")));
let names = NameCache::default();
let cooldowns = crate::spell::Cooldowns::default();
let mut petitions = crate::ui_petition::PetitionState::default();
let mut slot = |guid: u64| {
resolve_slot(
guid,
&objs.get(),
&items,
None,
RollCatalogs::NONE,
&commands,
&cooldowns,
None,
&names,
&mut petitions,
std::time::Instant::now(),
0.0,
false,
)
};
// A guid whose item object has not streamed reads as no item at all (`0x6228a0` finds
// none), not as an occupied blank.
assert!(slot(0xA9).is_none());
let sword = slot(0xA1).expect("an occupied slot");
assert_eq!((sword.quality, sword.inventory_type), (Some(4), 13));
let potion = slot(0xA2).expect("an occupied slot");
assert_eq!((potion.quality, potion.inventory_type), (Some(1), 0));
let pending = slot(0xA3).expect("an occupied slot");
assert_eq!((pending.quality, pending.inventory_type), (None, 0));
}
/// No bags and an empty vault.
const NO_BAGS: SlotGuids = SlotGuids {
bags: [0; 10],
+274 -89
View File
@@ -2,14 +2,11 @@
//! (`frame+0xb4 & 0x1000`), restored at the next login. The engine half (snapshot, restore, dirty
//! bit) is `benilla_ui::script`'s `layout_cache`; this is the file and when it is written.
//!
//! The reference writes `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt` with `Frame:`,
//! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` lines; ours is
//! `benilla-config/layout/<realm>-<character>.txt`, keeping `Frame:`, `W:` and `H:`.
//! Deviation: one `Point: <point> <relativeTo> <relativePoint> <x> <y>` line per anchor, `-` for
//! the screen root, instead of `X:`/`Y:`, because a benilla drag moves the authored anchors
//! rather than re-anchoring to one TOPLEFT, and a pair would lose them. There is no
//! `FrameLevel:`: the reference saves an undocked window's raise, and benilla's raise does not
//! persist.
//! The reference writes `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt`; ours is
//! `benilla-config/layout/<realm>-<character>.txt`, in its form: per frame a `Frame:` line, then
//! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` (`0x490e60`), each `Name: value` and an integer, the
//! records back to back. A file of benilla's earlier anchor form, one `Point:` line per anchor, is
//! read once and rewritten in this form at the next save.
//!
//! The reference writes the file only in its UI shutdown tail (`0x490bd0`), at `0x490c79`,
//! after `PLAYER_LOGOUT` (`0x490c2a`) and before the flat saved file (`0x490c7e`), on logout,
@@ -21,22 +18,19 @@ use std::path::PathBuf;
use bevy::prelude::*;
use benilla_ui::script::{FrameLayout, LayoutPoint, UiScript};
use benilla_ui::script::{AnchorLayout, FrameLayout, LayoutPoint, UiScript};
use crate::ui_script::VmMemo;
/// How long a moved window sits before the autosave: one gesture coalesced, at most one lost.
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
/// The screen root in the file, where `GetPoint` answers `nil`; not `UIParent`, a real frame.
/// The screen root as a target in the anchor form, where `GetPoint` answers `nil`.
const SCREEN_TOKEN: &str = "-";
/// The file's header, a `#` block the parser skips.
/// The file's header, a `#` line the parser skips.
const HEADER: &str = "\
# benilla window layout — every frame the player has moved or resized (the client's userPlaced
# bit). A relative of the reference's layout-cache.txt: same scope, same Frame:/W:/H: keys, but
# anchors instead of its X:/Y: pair, because a benilla drag moves a frame's anchors rather than
# collapsing it to a screen position. `-` as a Point: target means the screen root.
# benilla window layout: every frame the player has moved or resized, as layout-cache.txt has it.
";
/// The character's file: its path, the VM it was restored into, and whether a write is owed.
@@ -52,54 +46,134 @@ pub(crate) struct LayoutFile {
last_change: Option<std::time::Instant>,
}
/// Render the cache as [`parse`] reads it, frames name-sorted as the engine hands them.
/// Render the cache as [`parse`] reads it, frames in the order the engine hands them; a field
/// the row does not carry has no line, as the writer's flag gates leave it (`0x490f99`,
/// `0x49109f`).
pub(crate) fn render(frames: &[FrameLayout]) -> String {
let mut out = String::from(HEADER);
for f in frames {
out.push_str(&format!("Frame: {}\n", f.name));
out.push_str(&format!("W: {}\n", f.width));
out.push_str(&format!("H: {}\n", f.height));
for p in &f.points {
out.push_str(&format!(
"Point: {} {} {} {} {}\n",
p.point,
p.relative_to.as_deref().unwrap_or(SCREEN_TOKEN),
p.relative_point,
p.x,
p.y
));
let fields = [
("FrameLevel", f.level.map(i32::from)),
("X", f.x),
("Y", f.y),
("W", f.width),
("H", f.height),
];
for (key, value) in fields {
if let Some(v) = value {
out.push_str(&format!("{key}: {v}\n"));
}
}
}
out
}
/// Parse the cache: keys match case-insensitively, and an unknown key, a malformed `Point:` or a
/// value line before any `Frame:` costs only its own line.
pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
/// What a layout file holds.
#[derive(Debug, PartialEq)]
pub(crate) enum Layout {
/// The reference's rect form.
Rect(Vec<FrameLayout>),
/// benilla's earlier anchor form, which carries `Point:` lines.
Anchors(Vec<AnchorLayout>),
}
/// Parse the cache: keys match case-insensitively, and an unknown key costs only its own line. A
/// value reads as `atoi` reads it (`0x64ac60`). The value lines before any `Frame:`, or after one
/// with no name, belong to no frame and are dropped, as the reference applies them to a null frame
/// (`0x4905e6`); a name no frame has drops its lines at the restore the same way.
///
/// Deviation: a record's fields start empty. The reference's parser clears X, Y, W and the level
/// at each `Frame:` (`ebx`, `edi`, `esi`, `[ebp-0x4]` at `0x49045b`–`0x490469`) but not H
/// (`[ebp-0xc]`), so a record with no `H:` takes the one before's; benilla writes `H:` whenever it
/// writes `W:`, so only a hand-edited file reaches the slip, which would size a window from
/// another window's row.
pub(crate) fn parse(text: &str) -> Layout {
if lines(text).any(|(key, _)| key.eq_ignore_ascii_case("Point")) {
return Layout::Anchors(parse_anchor_form(text));
}
let mut out: Vec<FrameLayout> = Vec::new();
for line in text.lines() {
let mut current = false;
for (key, rest) in lines(text) {
if key.eq_ignore_ascii_case("Frame") {
current = !rest.is_empty();
if current {
out.push(FrameLayout {
name: rest.to_owned(),
..FrameLayout::default()
});
}
continue;
}
let Some(frame) = out.last_mut().filter(|_| current) else {
continue; // no frame owns the line
};
let v = atoi(rest);
if key.eq_ignore_ascii_case("FrameLevel") {
// Below zero is the parser's "none" (`0x490462`), which the apply skips (`0x4905f0`).
frame.level = (v >= 0).then(|| u16::try_from(v).unwrap_or(u16::MAX));
} else if key.eq_ignore_ascii_case("X") {
frame.x = Some(v);
} else if key.eq_ignore_ascii_case("Y") {
frame.y = Some(v);
} else if key.eq_ignore_ascii_case("W") {
frame.width = Some(v);
} else if key.eq_ignore_ascii_case("H") {
frame.height = Some(v);
}
}
Layout::Rect(out)
}
/// The file's `Key: value` lines, trimmed, with blanks and `#` lines skipped.
fn lines(text: &str) -> impl Iterator<Item = (&str, &str)> {
text.lines().filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
return None;
}
let Some((key, rest)) = line.split_once(':') else {
continue;
};
let (key, rest) = (key.trim(), rest.trim());
let (key, rest) = line.split_once(':')?;
Some((key.trim(), rest.trim()))
})
}
/// C's `atoi`: an optional sign and the leading digits, zero when there are none.
fn atoi(s: &str) -> i32 {
let (neg, digits) = match s.as_bytes().first() {
Some(b'-') => (true, &s[1..]),
Some(b'+') => (false, &s[1..]),
_ => (false, s),
};
let n = digits
.bytes()
.take_while(u8::is_ascii_digit)
.fold(0i64, |n, d| {
(n * 10 + i64::from(d - b'0')).min(i64::from(i32::MAX) + 1)
});
let n = if neg { -n } else { n };
n.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
}
/// The anchor form: `W:`, `H:` and one `Point: <point> <relativeTo> <relativePoint> <x> <y>` line
/// per anchor, `-` for the screen root. A malformed `Point:` costs only its own line.
fn parse_anchor_form(text: &str) -> Vec<AnchorLayout> {
let mut out: Vec<AnchorLayout> = Vec::new();
let mut current = false;
for (key, rest) in lines(text) {
if key.eq_ignore_ascii_case("Frame") {
if rest.is_empty() {
continue;
current = !rest.is_empty();
if current {
out.push(AnchorLayout {
name: rest.to_owned(),
width: 0.0,
height: 0.0,
points: Vec::new(),
});
}
out.push(FrameLayout {
name: rest.to_owned(),
width: 0.0,
height: 0.0,
points: Vec::new(),
});
continue;
}
let Some(frame) = out.last_mut() else {
continue; // a value line with no `Frame:` above it owns nothing
let Some(frame) = out.last_mut().filter(|_| current) else {
continue;
};
if key.eq_ignore_ascii_case("W") {
frame.width = rest.parse().unwrap_or(0.0);
@@ -108,11 +182,11 @@ pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
} else if key.eq_ignore_ascii_case("Point") {
let f: Vec<&str> = rest.split_whitespace().collect();
if f.len() != 5 {
warn!("layout: malformed Point line ignored: {line}");
warn!("layout: malformed Point line ignored: {key}: {rest}");
continue;
}
let (Ok(x), Ok(y)) = (f[3].parse::<f32>(), f[4].parse::<f32>()) else {
warn!("layout: Point line with unparsable offsets ignored: {line}");
warn!("layout: Point line with unparsable offsets ignored: {key}: {rest}");
continue;
};
frame.points.push(LayoutPoint {
@@ -159,18 +233,30 @@ pub(crate) fn load_layout(
return;
}
};
let frames = parse(&text);
if frames.is_empty() {
let (count, anchor_form) = match parse(&text) {
Layout::Rect(frames) => {
let n = frames.len();
script.restore_user_placed_layouts(frames);
(n, false)
}
Layout::Anchors(frames) => {
let n = frames.len();
script.restore_anchor_layouts(frames);
(n, true)
}
};
if count == 0 {
return;
}
info!(
"layout: {} frames restored from {}",
frames.len(),
path.display()
);
script.restore_user_placed_layouts(frames);
info!("layout: {count} frames restored from {}", path.display());
// Drain the dirty bit the restore set, so the first save does not rewrite what was just read.
script.take_user_placed_change();
if anchor_form {
// The anchor form is read once: the autosave rewrites it in the rect form once the
// restored windows have settled.
*file.dirty.get(&script) = true;
file.last_change = Some(std::time::Instant::now());
}
}
/// Drain the engine's user-placed-frame-moved bit into the dirty flag.
@@ -253,13 +339,10 @@ impl Plugin for UiLayoutPlugin {
mod tests {
use super::*;
fn point(p: &str, rel: Option<&str>, rp: &str, x: f32, y: f32) -> LayoutPoint {
LayoutPoint {
point: p.into(),
relative_to: rel.map(str::to_owned),
relative_point: rp.into(),
x,
y,
fn rows(layout: Layout) -> Vec<FrameLayout> {
match layout {
Layout::Rect(rows) => rows,
Layout::Anchors(rows) => panic!("read as the anchor form: {rows:?}"),
}
}
@@ -268,53 +351,155 @@ mod tests {
let frames = vec![
FrameLayout {
name: "ChatFrame1".into(),
width: 512.0,
height: 180.5,
points: vec![point(
"BOTTOMLEFT",
Some("UIParent"),
"BOTTOMLEFT",
40.0,
120.0,
)],
level: Some(0),
x: Some(38),
y: Some(-225),
width: Some(431),
height: Some(400),
},
// Movable only: no `W:`/`H:`, as the reference writes `KLHTM_Frame`.
FrameLayout {
name: "Floater".into(),
width: 0.0,
height: 0.0,
points: vec![
point("TOPLEFT", None, "TOPLEFT", -1.5, 2.0),
point("BOTTOMRIGHT", Some("ChatFrame1"), "TOPRIGHT", 0.0, 0.0),
],
level: Some(4),
x: Some(-2),
y: Some(0),
width: None,
height: None,
},
];
assert_eq!(parse(&render(&frames)), frames);
let text = render(&frames);
assert!(
text.ends_with(
"Frame: ChatFrame1\nFrameLevel: 0\nX: 38\nY: -225\nW: 431\nH: 400\n\
Frame: Floater\nFrameLevel: 4\nX: -2\nY: 0\n"
),
"the reference's lines, in its order:\n{text}"
);
assert_eq!(rows(parse(&text)), frames);
}
#[test]
fn the_header_is_skipped_not_parsed() {
assert!(render(&[]).starts_with('#'));
assert_eq!(parse(HEADER), vec![]);
assert_eq!(parse(HEADER), Layout::Rect(vec![]));
}
#[test]
fn junk_costs_only_its_own_line() {
let got = parse(
let got = rows(parse(
"W: 100\n\
Frame: A\n\
FrameLevel: 4\n\
Point: TOPLEFT -\n\
W: 200\n\
point: bottomleft - BOTTOMLEFT 1 2\n",
);
Shade: 4\n\
FrameLevel: -1\n\
x: 12.7\n\
Y: -3px\n\
W: wide\n\
H: 200\n\
Frame:\n\
X: 99\n\
H: 7\n",
));
assert_eq!(
got,
vec![FrameLayout {
name: "A".into(),
width: 200.0,
height: 0.0,
points: vec![point("bottomleft", None, "BOTTOMLEFT", 1.0, 2.0)],
level: None,
x: Some(12),
y: Some(-3),
width: Some(0),
height: Some(200),
}]
);
}
#[test]
fn a_point_line_marks_the_anchor_form() {
let text = "\
# benilla window layout — every frame the player has moved or resized (the client's userPlaced
Frame: ChatFrame1
W: 512
H: 180.5
Point: BOTTOMLEFT UIParent BOTTOMLEFT 40 120
Frame: Floater
W: 0
H: 0
Point: TOPLEFT - TOPLEFT -1.5 2
Point: TOPLEFT -
";
let point = |p: &str, rel: Option<&str>, rp: &str, x: f32, y: f32| LayoutPoint {
point: p.into(),
relative_to: rel.map(str::to_owned),
relative_point: rp.into(),
x,
y,
};
assert_eq!(
parse(text),
Layout::Anchors(vec![
AnchorLayout {
name: "ChatFrame1".into(),
width: 512.0,
height: 180.5,
points: vec![point(
"BOTTOMLEFT",
Some("UIParent"),
"BOTTOMLEFT",
40.0,
120.0
)],
},
AnchorLayout {
name: "Floater".into(),
width: 0.0,
height: 0.0,
points: vec![point("TOPLEFT", None, "TOPLEFT", -1.5, 2.0)],
},
])
);
}
/// A file of the anchor form seats the windows where they were and saves back in the rect
/// form, so a player's windows survive the change of format.
#[test]
fn an_anchor_form_file_migrates_to_the_rect_form() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
local b = CreateFrame("Frame", "Base")
b:SetPoint("BOTTOMLEFT", 0, 0) b:SetWidth(1024) b:SetHeight(768)
local f = CreateFrame("Frame", "Moved")
f:SetPoint("CENTER", 0, 0) f:SetWidth(100) f:SetHeight(50)
f:SetMovable(true) f:SetResizable(true)
local g = CreateFrame("Frame", "Glider")
g:SetPoint("CENTER", 0, 0) g:SetWidth(30) g:SetHeight(30)
g:SetMovable(true)
"#,
)
.unwrap();
let old = "\
Frame: Glider
W: 30
H: 30
Point: TOPLEFT - TOPLEFT 10.4 -20.6
Frame: Moved
W: 213.6
H: 80
Point: BOTTOMLEFT Base BOTTOMLEFT 61 29
";
let Layout::Anchors(rows) = parse(old) else {
panic!("an old file reads as the anchor form");
};
s.restore_anchor_layouts(rows);
let text = render(&s.user_placed_layouts());
assert!(
text.contains(
"Frame: Moved\nFrameLevel: 0\nX: 61\nY: -659\nW: 214\nH: 80\n\
Frame: Glider\nFrameLevel: 0\nX: 10\nY: -21\n"
),
"the same windows, where they were, in the rect form, in creation order:\n{text}"
);
assert!(!text.contains("Point:"), "{text}");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
-3
View File
@@ -445,9 +445,6 @@ fn snapshot(
roll_id: r.roll_id,
name: t.map(|t| catalogs.name(&t.name, r.random_property_id)),
texture,
// The reference's `GetLootRollItemInfo` returns a literal 1 (`0x4c3160`); the
// packet carries no count.
quantity: 1,
quality: t.map(|t| t.quality),
bind_on_pickup: t.is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP),
time_left_ms: r.remaining_ms,
-9
View File
@@ -162,15 +162,6 @@ pub(crate) struct UiQuads {
pub(crate) mod overlay_z {
/// Floating combat, XP and honor numbers, drawn by the client in the world scene under all UI.
pub(crate) const WORLD_TEXT: u64 = 0;
/// The chat bubbles: one frame level per live bubble, farthest from the camera first.
pub(crate) const BUBBLE: u64 = 1 << 8;
/// Keys per bubble level: its bg, edge, tail and text.
pub(crate) const BUBBLE_STRIDE: u64 = 4;
/// The V-plates, over every bubble; a unit never shows both, so this orders only across units.
pub(crate) const VPLATE: u64 = 1 << 16;
/// The highest bubble level below [`VPLATE`], a guard rail far past any real count (a bubble
/// needs a speaker within 20 yd).
pub(crate) const BUBBLE_MAX_LEVEL: u64 = (VPLATE - BUBBLE) / BUBBLE_STRIDE - 1;
}
/// Clear the append lane at the top of the [`UiQuadAppend`] window, before every appender.
+278
View File
@@ -11,6 +11,14 @@
//! stores `standing - BaseRep` (`ReputationMgr.cpp:261`) and reports the sum
//! (`ReputationMgr.cpp:82`). The rank is [`benilla_formats::reputation_rank`], shared with the
//! unit-reaction decode, which caps it at 6, Revered (`0x606439`); the pane shows it uncapped.
//!
//! The chat line "You are now <rank> with <faction>." (`FACTION_STANDING_CHANGED`, `0x62c670`) is
//! owed by three events: a faction newly listed by `SMSG_SET_FACTION_VISIBLE` (`0x4d5733`), a
//! faction listed by the first `SMSG_SET_FACTION_STANDING` that names it (`0x4d5826`), and a
//! standing change that moves the rank (`0x4d5896`) unless the faction is `HIDDEN`. The wire
//! handlers decide the lines ([`visible_line`], [`delta_lines`]) and park them in the chat log in
//! wire order ([`queue_standing_lines`]); the chat feed resolves their strings against the VM,
//! which the wire drain cannot reach.
use bevy::prelude::*;
@@ -18,6 +26,7 @@ use benilla_ui::script::{FactionEntry, ReputationSend, ReputationState, UiScript
use crate::net::{ClientCommand, NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::target::Factions;
use crate::ui_chat::ChatLog;
use crate::ui_script::{UiFeed, UiInput};
/// Rank `r` spans `RANK_BOUNDS[r]..RANK_BOUNDS[r + 1]`, the client's table at `0x80928c`: vmangos's
@@ -40,6 +49,131 @@ impl Plugin for UiReputationPlugin {
}
}
/// One `FACTION_STANDING_CHANGED` line owed: the faction and the rank it names (`0x62c670`'s
/// `ecx` and `edx`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StandingLine {
pub(crate) faction_id: u32,
/// The rank, 0 Hated to 7 Exalted; the label is `FACTION_STANDING_LABEL<rank + 1>`.
pub(crate) rank: u8,
}
/// The faction a wire slot names, with its row, when the player's race and class list it.
fn faction_of_slot(
catalog: &benilla_formats::FactionCatalog,
list_id: u32,
race: u8,
class: u8,
) -> Option<(u32, &benilla_formats::FactionInfo)> {
let index = i32::try_from(list_id).ok()?;
let (id, info) = catalog
.reputation_factions()
.find(|(_, f)| f.rep_index == index)?;
info.slot_for(race, class)?;
Some((id, info))
}
/// `SMSG_SET_FACTION_VISIBLE` (`0x4d5710`): a slot not yet `VISIBLE` is listed by `0x4d5a70` with
/// the print flag set, which names its rank (`0x4d5bfb`); one already `VISIBLE` returns at
/// `0x4d5731` and prints nothing. `store` is the state before the packet applies. A header slot
/// that a listed child already put in the client's list is a dedup there (`0x4d5a93`); vmangos
/// never sends one (`ReputationMgr.cpp:177`, `SetVisible` refuses flag `0x08`), so it is not
/// modelled.
pub(crate) fn visible_line(
list_id: u32,
store: &Reputations,
catalog: &benilla_formats::FactionCatalog,
race: u8,
class: u8,
) -> Option<StandingLine> {
use benilla_formats::faction_flags as flag;
let (flags, wire) = store.0.get(list_id as usize).copied().unwrap_or((0, 0));
if flags & flag::VISIBLE != 0 {
return None;
}
let (faction_id, info) = faction_of_slot(catalog, list_id, race, class)?;
Some(StandingLine {
faction_id,
rank: benilla_formats::reputation_rank(info.base_for(race, class) + wire),
})
}
/// `SMSG_SET_FACTION_STANDING` (`0x4d5760`), per entry against the state before it applies. A
/// slot neither `VISIBLE` nor `HIDDEN` is listed first (`0x4d5815`-`0x4d5826`) and that print is
/// the entry's only line (`0x4d588b`); otherwise a changed rank prints unless the slot is `HIDDEN`
/// (`0x4d5880`-`0x4d5896`). Both name the rank after the new standing.
pub(crate) fn delta_lines(
deltas: &[(u32, i32)],
store: &Reputations,
catalog: &benilla_formats::FactionCatalog,
race: u8,
class: u8,
) -> Vec<StandingLine> {
use benilla_formats::faction_flags as flag;
let mut lines = Vec::new();
for &(list_id, standing) in deltas {
let Some((faction_id, info)) = faction_of_slot(catalog, list_id, race, class) else {
continue;
};
let base = info.base_for(race, class);
let (flags, old) = store.0.get(list_id as usize).copied().unwrap_or((0, 0));
let (old_rank, rank) = (
benilla_formats::reputation_rank(base + old),
benilla_formats::reputation_rank(base + standing),
);
let listed_now = flags & (flag::VISIBLE | flag::HIDDEN) == 0;
let moved = rank != old_rank && flags & flag::HIDDEN == 0;
if listed_now || moved {
lines.push(StandingLine { faction_id, rank });
}
}
lines
}
/// Park the lines in the chat log, after whatever the same packet already queued. The faction's
/// name is the row's (`0x62c670` reads the name column), and `female` picks the label's gender.
pub(crate) fn queue_standing_lines(
lines: impl IntoIterator<Item = StandingLine>,
catalog: &benilla_formats::FactionCatalog,
female: bool,
chat: &mut ChatLog,
) {
for StandingLine { faction_id, rank } in lines {
if let Some(name) = catalog.faction_name(faction_id) {
chat.push_standing(name, rank, female);
}
}
}
/// `0x62c670`'s sentence: `FACTION_STANDING_CHANGED` filled with the rank label and the faction
/// name. The label is `FACTION_STANDING_LABEL<rank + 1>` gendered by `0x612bf0`: a female player
/// tries the `_FEMALE` twin and, when that is absent or empty, the bare key (`0x612c36`). A label
/// absent altogether is an empty string, not a missing line: `0x703bf0` never returns NULL, and
/// `0x62c670` prints with whatever it gave. Only a missing template prints nothing here, where the
/// reference would send an empty string to the chat composer.
pub(crate) fn standing_text(
get: &dyn Fn(&str) -> Option<String>,
rank: u8,
female: bool,
faction_name: &str,
) -> Option<String> {
let key = format!("FACTION_STANDING_LABEL{}", rank.min(7) + 1);
let defined = |k: &str| get(k).filter(|v| !v.is_empty());
let label = female
.then(|| defined(&format!("{key}_FEMALE")))
.flatten()
.or_else(|| defined(&key))
.unwrap_or_default();
let template = defined("FACTION_STANDING_CHANGED")?;
Some(benilla_ui::strings::fill(
&template,
&[
benilla_ui::strings::Arg::S(&label),
benilla_ui::strings::Arg::S(faction_name),
],
))
}
/// One faction's row, or `None` when it is not in the player's list at all; a row the pane does
/// not draw is still a row, with `visible: false`.
pub(crate) fn reputation_row(
@@ -352,6 +486,150 @@ mod tests {
.count();
assert_eq!(visible_parents, 1, "only Alliance carries VISIBLE as well");
}
/// A human warrior (race 1, class 1) and Stormwind's slot on the real DBC: base 4000, Friendly.
fn stormwind() -> Option<(benilla_formats::FactionCatalog, u32)> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cat = benilla_formats::load_faction_catalog(&mut chain).expect("factions");
let slot = u32::try_from(cat.reputation_faction(72).expect("Stormwind").rep_index).unwrap();
Some((cat, slot))
}
fn store(slot: u32, flags: u8, standing: i32) -> Reputations {
let mut v = vec![(0u8, 0i32); slot as usize + 1];
v[slot as usize] = (flags, standing);
Reputations(v)
}
/// `0x4d5731` gates the list on `VISIBLE`: an unmet faction prints its rank (base 4000 is
/// Friendly, rank 4), one already listed prints nothing (the dedup return).
#[test]
fn a_newly_listed_faction_names_its_rank_and_a_listed_one_does_not() {
use benilla_formats::faction_flags as flag;
let Some((cat, slot)) = stormwind() else {
return;
};
assert_eq!(
visible_line(slot, &store(slot, 0, 0), &cat, 1, 1),
Some(StandingLine {
faction_id: 72,
rank: 4
})
);
// An empty store is the same first contact: the slot reads (0, 0).
assert!(visible_line(slot, &Reputations::default(), &cat, 1, 1).is_some());
assert_eq!(
visible_line(slot, &store(slot, flag::VISIBLE, 0), &cat, 1, 1),
None,
"already listed"
);
// The wire standing rides on the base: 5000 more is 9000, Honored.
assert_eq!(
visible_line(slot, &store(slot, 0, 5000), &cat, 1, 1).map(|l| l.rank),
Some(5)
);
// A slot the player's race and class do not list, or no faction names: nothing.
assert_eq!(visible_line(63, &Reputations::default(), &cat, 1, 1), None);
}
/// `0x4d5760`: a rank change prints the new rank, an unchanged rank prints nothing, `HIDDEN`
/// silences a change, and a faction listed by this very entry prints once whatever it moved.
#[test]
fn a_standing_delta_prints_a_rank_change_and_a_first_listing_once() {
use benilla_formats::faction_flags as flag;
let Some((cat, slot)) = stormwind() else {
return;
};
let lines = |flags: u8, old: i32, new: i32| {
delta_lines(&[(slot, new)], &store(slot, flags, old), &cat, 1, 1)
};
let rank = |r: u8| {
vec![StandingLine {
faction_id: 72,
rank: r,
}]
};
// 4000 → 9000 crosses into Honored.
assert_eq!(lines(flag::VISIBLE, 0, 5000), rank(5));
// 4000 → 4100 stays Friendly; no line.
assert!(lines(flag::VISIBLE, 0, 100).is_empty());
// Down a rank is a change too.
assert_eq!(lines(flag::VISIBLE, 0, -1001), rank(3));
// HIDDEN keeps the gain off the screen (`0x4d5885`).
assert!(lines(flag::VISIBLE | flag::HIDDEN, 0, 5000).is_empty());
// Not yet listed: the reveal prints, with no second line for the rank it moved.
assert_eq!(lines(0, 0, 100), rank(4));
assert_eq!(lines(0, 0, 5000), rank(5));
// Several entries print one line each, in order.
let two = delta_lines(
&[(slot, 5000), (63, 1)],
&store(slot, flag::VISIBLE, 0),
&cat,
1,
1,
);
assert_eq!(two, rank(5), "the unnamed slot prints nothing");
}
/// `0x62c670`: `FACTION_STANDING_CHANGED` filled with the label and the name; a female player
/// takes the `_FEMALE` label where there is one; a missing label or template prints nothing.
#[test]
fn the_sentence_is_the_template_with_the_gendered_label_and_the_name() {
let strings = [
("FACTION_STANDING_CHANGED", "You are now %s with %s."),
("FACTION_STANDING_LABEL5", "Friendly"),
("FACTION_STANDING_LABEL6", "Honored"),
("FACTION_STANDING_LABEL6_FEMALE", "Honoured (f)"),
];
let get = |k: &str| {
strings
.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| v.to_string())
};
assert_eq!(
standing_text(&get, 4, false, "Stormwind").as_deref(),
Some("You are now Friendly with Stormwind.")
);
assert_eq!(
standing_text(&get, 5, false, "Stormwind").as_deref(),
Some("You are now Honored with Stormwind.")
);
assert_eq!(
standing_text(&get, 5, true, "Stormwind").as_deref(),
Some("You are now Honoured (f) with Stormwind.")
);
assert_eq!(
standing_text(&get, 4, true, "Stormwind").as_deref(),
Some("You are now Friendly with Stormwind."),
"a missing twin falls back to the base label"
);
assert_eq!(
standing_text(&get, 0, false, "Stormwind").as_deref(),
Some("You are now with Stormwind."),
"an absent label is an empty one (`0x703bf0` never returns NULL), not a missing line"
);
// A `_FEMALE` twin that is defined but empty falls back to the bare key (`0x612c36`).
let empty_twin = |k: &str| match k {
"FACTION_STANDING_LABEL6_FEMALE" => Some(String::new()),
other => get(other),
};
assert_eq!(
standing_text(&empty_twin, 5, true, "Stormwind").as_deref(),
Some("You are now Honored with Stormwind.")
);
assert_eq!(
standing_text(
&|k| get(k).filter(|_| k != "FACTION_STANDING_CHANGED"),
4,
false,
"X"
),
None,
"no template"
);
}
}
#[cfg(test)]
@@ -320,6 +320,109 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `SetAction` answers 1 when the spell tooltip it built holds a line that changes while shown
/// (the chance line, or the remaining cooldown of a spell whose recovery is a minute or more:
/// `0x52f68c`, `0x52f581`), else nil, and the stock `ActionButton_SetTooltip` arms `updateTooltip`
/// from it (`ActionButton.lua:377-381`); `ActionButton_OnUpdate` then rebuilds the hovered
/// tooltip. Run through the stock bar.
#[test]
fn a_spell_tooltip_with_a_live_line_arms_the_stock_refresh() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{ActionState, SpellTooltipView};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Warrior_ShieldWall".into()),
kind: 0x00,
action: 871,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0); // a nonzero GetTime epoch
let view = |cast: &str, recovery_ms: u32, chance: Option<&str>| SpellTooltipView {
name: "Shield Wall".into(),
cast_time: Some(cast.into()),
recovery_ms,
chance: chance.map(str::to_string),
..Default::default()
};
// The value the stock button saved from the setter's answer.
let armed = |s: &mut UiScript| -> Option<f64> {
s.eval::<Option<f64>>("return ActionButton1.updateTooltip")
.unwrap()
};
let line2 = |s: &mut UiScript| -> String {
s.eval::<String>("return GameTooltipTextLeft2:GetText() or ''")
.unwrap()
};
let running = |duration_ms: u32| ActionState {
usable: true,
// Started at 9.4 s on the GetTime clock, so it is running at 10 s.
cooldown: Some((9_400, duration_ms, true)),
..Default::default()
};
let leave_and_enter = |s: &mut UiScript| {
super::test_ui::unhover(s);
s.tick(0.0);
super::test_ui::hover(s, "ActionButton1");
};
// No cooldown, no chance line: the setter answers nil, and nothing is armed.
s.set_spell_tooltip(871, view("Instant", 1_800_000, None));
super::test_ui::hover(&mut s, "ActionButton1");
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
assert_eq!(armed(&mut s), None);
// A 30 minute recovery running: 1, so the button arms the stock refresh.
s.set_action_state(1, Some(running(1_800_000)));
leave_and_enter(&mut s);
assert_eq!(
armed(&mut s),
s.eval::<Option<f64>>("return TOOLTIP_UPDATE_TIME").unwrap()
);
assert!(armed(&mut s).is_some_and(|t| t > 0.0));
assert_eq!(line2(&mut s), "Instant");
// The refresh rebuilds the hovered tooltip from the current view.
s.set_spell_tooltip(871, view("Next melee", 1_800_000, None));
s.tick(0.5);
assert_eq!(
line2(&mut s),
"Next melee",
"ActionButton_OnUpdate rebuilt it"
);
// The same cooldown on a spell whose recovery is under a minute: nil.
s.set_spell_tooltip(871, view("Instant", 30_000, None));
s.set_action_state(1, Some(running(30_000)));
leave_and_enter(&mut s);
assert_eq!(armed(&mut s), None);
// A chance line alone does not: `SetAction` reads the builder's value only with a cooldown
// running (`0x532597`; the other leg pushes nil, `0x532690`), so a passive answers nil.
s.set_action_state(
1,
Some(ActionState {
usable: true,
..Default::default()
}),
);
s.set_spell_tooltip(871, view("Instant", 0, Some("5.00% chance to parry")));
leave_and_enter(&mut s);
assert_eq!(armed(&mut s), None);
// With a cooldown running it does, whatever the spell's recovery.
s.set_action_state(1, Some(running(30_000)));
leave_and_enter(&mut s);
assert!(armed(&mut s).is_some_and(|t| t > 0.0));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `CooldownFrame_SetTimer` hides the pane unless `start > 0 and duration > 0 and enable > 0`
/// (Cooldown.lua:3): a running cooldown whose start predates a restarted `GetTime` never draws.
#[test]
@@ -826,7 +929,7 @@ fn drag_drop_onto_another_button_hops_the_displaced_action() {
s.resolve();
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_move(40.0, 22.0); // past the 4px drag-start threshold
s.mouse_move(40.0, 22.0); // past the drag-start distance
let consumed = s.mouse_button(68.0, 22.0, "LeftButton", false);
assert!(consumed, "OnReceiveDrag consumed the release");
@@ -1061,6 +1164,7 @@ fn shipped_bag_frame_drives_end_to_end() {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
inventory_type: 0,
item_id: 117,
link: None,
locked: false,
File diff suppressed because it is too large Load Diff
@@ -393,6 +393,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
texture: Some("Interface\\Icons\\INV_ThrowingKnife_02".into()),
count: 200,
quality: Some(1),
inventory_type: 0,
item_id: 2947,
link: Some("|cffffffff|Hitem:2947|h[Small Throwing Knife]|h|r".into()),
locked: false,
@@ -470,6 +471,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
count: 1,
quality: Some(1),
inventory_type: 0,
item_id: 25,
link: Some("|cffffffff|Hitem:25|h[Worn Shortsword]|h|r".into()),
locked: false,
@@ -577,6 +579,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
texture: Some("Interface\\Icons\\INV_Misc_Pelt_Wolf_01".into()),
count: 4,
quality: Some(1),
inventory_type: 0,
item_id: 2318,
link: Some("|cffffffff|Hitem:2318|h[Light Leather]|h|r".into()),
locked: false,
@@ -599,6 +602,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()),
count: 1,
quality: Some(1),
inventory_type: 0,
item_id: 9999,
link: Some("|cffffffff|Hitem:9999|h[Shadowforge Key]|h|r".into()),
locked: false,
@@ -786,6 +790,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(3),
inventory_type: 0,
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
locked: false,
@@ -872,6 +877,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
count: 1,
quality: Some(2),
inventory_type: 0,
item_id: 200,
link: Some("|cffffffff|Hitem:200|h[Shiny Gem]|h|r".into()),
locked: false,
@@ -982,7 +988,7 @@ fn equipped_bag_window_snug_fits_its_row_count() {
}
/// Open a backpack holding a five-stack in slot 1; returns that slot button's centre.
fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
pub(super) fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
for file in crate::ui_script::test_ui::production_order(&[
BAG_UI,
&[
@@ -1008,6 +1014,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
inventory_type: 0,
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
locked: false,
@@ -129,7 +129,7 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
let mut registry: Vec<AddOnInfo> = INSTALLED
.iter()
.map(|n| super::addons::info_from_toc(n, &read_toc(root, n)))
.map(|n| super::addons::info_from_toc(n, &read_toc(root, n), false))
.collect();
s.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
s.set_realm_name("Harness");
@@ -576,7 +576,7 @@ fn dragging_a_bagnon_slot_picks_the_item_up() {
let (dx, dy) = centre_of(&mut s, &empty);
s.mouse_move(sx, sy);
s.mouse_button(sx, sy, "LeftButton", true);
s.mouse_move(sx + 12.0, sy + 12.0); // past DRAG_START_THRESHOLD ⇒ OnDragStart
s.mouse_move(sx + 12.0, sy + 12.0); // past the drag-start distance ⇒ OnDragStart
s.mouse_move(dx, dy);
s.mouse_button(dx, dy, "LeftButton", false);
@@ -90,9 +90,9 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() {
"reported"
);
// An engine-caught error still lands in `errors()`; the dispatch adds the dialog on top.
s.run("BrokenProbe = CreateFrame('Frame') BrokenProbe:RegisterEvent('ERROR_PROBE') BrokenProbe:SetScript('OnEvent', function() error('fault') end)")
s.run("BrokenProbe = CreateFrame('Frame') BrokenProbe:RegisterEvent('PLAYER_ALIVE') BrokenProbe:SetScript('OnEvent', function() error('fault') end)")
.unwrap();
s.fire_event("ERROR_PROBE", vec![]);
s.fire_event("PLAYER_ALIVE", vec![]);
assert!(
s.errors().iter().any(|e| e.contains("fault")),
"engine-caught errors stay on the host channel: {:?}",
@@ -86,6 +86,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
count: 1,
quality: Some(3),
inventory_type: 0,
item_id: 2000,
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
locked: false,
@@ -509,8 +510,20 @@ fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The predicate the arm branches on, pinned beside the effect.
assert!(s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
assert!(!s.eval::<bool>("return CursorCanGoInSlot(2)").unwrap());
// `CursorCanGoInSlot` (`0x4c8ff0`) pushes the number 1 (`0x4c9063`) or nil (`0x4c909e`) and
// returns one value (`arg.n` counts the returns).
let ask =
"local function c(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end";
assert_eq!(
s.eval::<String>(&format!("{ask} return c(CursorCanGoInSlot(1))"))
.unwrap(),
"1:number:1"
);
assert_eq!(
s.eval::<String>(&format!("{ask} return c(CursorCanGoInSlot(2))"))
.unwrap(),
"1:nil:nil"
);
assert!(
ring_lit(&mut s, "CharacterHeadSlot"),

Some files were not shown because too many files have changed in this diff Show More