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:
Generated
+10
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, ®istry, &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, ®istry);
|
||||
|
||||
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, ®istry);
|
||||
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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -189,7 +189,6 @@ mod tests {
|
||||
wy: 0.0,
|
||||
wz: 0.0,
|
||||
cursor: None,
|
||||
cursor_ui: None,
|
||||
seam: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+804
-430
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user