Merge upstream benilla main (b396bbf6) into everwood-graphics

105 upstream commits (v0.2.0 ba7fe6e2 -> b396bbf6) onto the graphics programme.

Conflicts resolved, keeping both sides:
- OptionsFrame.xml: upstream renamed every layer global (Options* -> BenillaOptions*,
  OPTIONS_* -> BENILLA_OPTIONS_*, KeyBindings* -> BenillaKeyBindings*, ...) and moved handlers
  to `this`; the Advanced Graphics page (rows, strings, presets) is carried over onto the new
  names, and upstream's Click-to-Move / Spell Detail rows and nameplate store are kept.
- cvars.rs -> cvars/{mod,table}.rs split: our REGISTERED rows go into table.rs, the preset
  ladder + tests into mod.rs beside upstream's ffx/gxMaximize tests.
- video.rs: lighting arms + upstream gxMaximize/ffx arms; VideoConfig keeps both field sets.
- ffx_glow.rs: SkyDither and FfxSwitches both registered/extracted; dither lane on
  upstream's switched player state.
- dest_fx.rs: our AreaLightPlan radius + upstream's spellEffectLevel shard scale.
- fixtures.rs: seed_perf_crowd + upstream's set_enemy_plates.
- options_tests.rs: tipped/described counts 100/99 (upstream 80 + ours 20/19); our Advanced
  Graphics tests use the renamed globals and `this`.
- game_plugins.rs: Update actionable ceiling 4,999 = upstream 4,980 + 19 ours (measured).
- docs/MAP.md regenerated with scripts/genmap.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-10-02 22:35:23 +01:00
co-authored by Claude Opus 5.5
534 changed files with 61657 additions and 18181 deletions
-2
View File
@@ -48,7 +48,5 @@ jobs:
run: scripts/doc-links.py
- name: pass-span-lint
run: scripts/pass-span-lint.py
- name: player-build
run: cargo build -p benilla --no-default-features
- name: player-tests
run: cargo test -p benilla-formats -p benilla-app --no-default-features --lib
Generated
+27 -28
View File
@@ -546,6 +546,7 @@ dependencies = [
"toml",
"wgpu",
"windows-sys 0.59.0",
"winit",
]
[[package]]
@@ -3048,15 +3049,6 @@ dependencies = [
"syn",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "epaint"
version = "0.33.3"
@@ -4120,7 +4112,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kira"
version = "0.12.1"
version = "0.12.4"
dependencies = [
"atomic-arena",
"glam 0.33.2",
@@ -5664,6 +5656,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -6181,9 +6179,9 @@ dependencies = [
[[package]]
name = "symphonia"
version = "0.5.5"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424"
dependencies = [
"lazy_static",
"symphonia-bundle-mp3",
@@ -6196,21 +6194,20 @@ dependencies = [
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.5"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c"
dependencies = [
"log",
"symphonia-core",
@@ -6218,9 +6215,9 @@ dependencies = [
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea"
dependencies = [
"log",
"symphonia-core",
@@ -6228,22 +6225,23 @@ dependencies = [
[[package]]
name = "symphonia-core"
version = "0.5.5"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bitflags 2.11.1",
"bytemuck",
"lazy_static",
"log",
"num-complex",
"smallvec",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe"
dependencies = [
"extended",
"log",
@@ -6253,13 +6251,14 @@ dependencies = [
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"regex-lite",
"smallvec",
"symphonia-core",
]
+1 -1
View File
@@ -39,7 +39,7 @@ sha1 = "0.10.6"
# the hunks and the command that checks them: `third_party/lua-src/BENILLA.md`.
[patch.crates-io]
lua-src = { path = "third_party/lua-src" }
# kira 0.12.1, patched to compute spatial gains and the volume tween per output chunk and
# kira 0.12.4, patched to compute spatial gains and the volume tween per output chunk and
# interpolate them, not per sample frame (`third_party/kira/BENILLA.md`).
kira = { path = "third_party/kira" }
+14 -4
View File
@@ -32,7 +32,8 @@ half = "2"
include_dir = "0.7"
# The 1.12 chat filters (`text_filter`), with one `RegexSet` for the any-match pass per line.
regex = "1"
# `getrusage` for perf CPU time; the audio clock and render thread's realtime policy on macOS.
# `getrusage` for perf CPU time; `localtime_r` for the log stamp; the audio clock and render
# thread's realtime policy on macOS.
libc = "0.2"
# PNG encoding only: the macOS cursor's NSImage data and screenshots.
image = { version = "0.25", default-features = false, features = ["png"] }
@@ -49,6 +50,9 @@ toml = "0.8"
avian3d = "0.6"
# Shapes and rasterizes `FontString` text standalone (`ui_text`), at bevy_text's own 0.16.
cosmic-text = "0.16"
# The layout's unshifted character per key (`KeyEventExtModifierSupplement`), which bevy's
# `KeyboardInput` drops; bevy_winit's own 0.30, whose features it sets.
winit = { version = "0.30", default-features = false }
# `cosmic-text`'s font database, named by `ui_text`; must stay the version it depends on.
fontdb = "0.23"
# The mixer. Its `cpal` backend stays off on every target: `sound::output` is our own backend.
@@ -56,7 +60,7 @@ kira = { version = "0.12", default-features = false, features = ["mp3", "wav", "
# Aborts a debug build on an allocation in the audio IO callback or render pass; off in release.
assert_no_alloc = "1.1"
# `MediaSource`: our source raises kira's decode thread's QoS; must stay kira's version.
symphonia = { version = "0.5", default-features = false }
symphonia = { version = "0.6", default-features = false }
# The audio threads' lock-free SPSC rings (output, mix tap); kira's version, so the graph has one.
rtrb = "0.3"
mint = "0.5"
@@ -93,9 +97,15 @@ objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeomet
[target.'cfg(not(target_os = "macos"))'.dependencies]
cpal = "0.17"
# Windows CPU clocks for `perf::clock`.
# Windows CPU clocks for `perf::clock`; `GetLocalTime` for the log stamp (`ui_chat::logging`); the
# layout's virtual keys for `bindings::layout`.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_UI_Input_KeyboardAndMouse",
] }
# The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`).
[dev-dependencies]
@@ -1,54 +1,18 @@
<!--
Adapters over the stock ContainerFrame.xml and .lua; loaded after them, so these win.
Deviation: our keyring is a container (id -2), not inventory slots as in the reference, so its
tooltip comes from `SetBagItem`; the stock fork (ContainerFrame.lua:615-620) reads the slots.
A full keyring prints an empty error line as the reference does: `PutKeyInKeyRing` reads
`NO_EMPTY_KEYRING_SLOTS`, which 1.12 does not ship.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The stock body, captured before it is shadowed; nil when no client data was sourced.
local refItemButtonOnEnter = ContainerFrameItemButton_OnEnter
function ContainerFrameItemButton_OnEnter(button)
if not button then
button = this
end
if not button then return end
-- The stock bag-slot path, not its inventory-slot keyring detour.
if button:GetParent():GetID() == KEYRING_CONTAINER then
if (button:GetRight() or 0) >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
end
GameTooltip:SetBagItem(KEYRING_CONTAINER, button:GetID())
ResetCursor()
return
end
if not refItemButtonOnEnter then
message("benilla: Interface\\FrameXML\\ContainerFrame.lua was not sourced from the "
.. "game data, so bag-slot tooltips cannot work. Check the client data path.")
return
end
refItemButtonOnEnter(button)
end
-- ══ The three bag verbs benilla answers differently ═══════════════════════════════════
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the vendor,
-- bank, mail and trade windows that call it bring up every bag to sell or deposit from.
-- The was-open memory is keyed by bag id: `ContainerFrame1..12` are recycled across bags.
-- ══ The two bag verbs benilla answers differently ═════════════════════════════════════
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the vendor
-- and mailbox windows that call it (MerchantFrame.lua:35, MailFrame.lua:39) bring up every
-- bag to sell or mail from. The was-open memory is keyed by bag id: `ContainerFrame1..12`
-- are recycled across bags. The stock `CloseAllBags` (ContainerFrame.lua:702-707) runs
-- unchanged: it calls this `CloseBackpack`, then closes bags 1 and up.
BENILLA_BAG_WAS_OPEN = {}
-- Ignores the was-open memory and covers the bank bags, as ContainerFrame.lua:702-707 does.
function BenillaCloseAllBags()
for bagId = 0, (NUM_CONTAINER_FRAMES or -1) do
CloseBag(bagId)
end
end
-- The reference's `backpackWasOpen`, one entry per bag: CloseBackpack leaves those open.
function BenillaOpenBackpack()
for bagId = 0, (NUM_BAG_FRAMES or -1) do
@@ -72,9 +36,9 @@
end
end
-- After the stock ContainerFrame.lua has loaded, so its callers reach these.
-- The stock names, redefined after the stock ContainerFrame.lua has loaded, so its callers
-- (the vendor and mailbox windows, the vendor's close and `CloseAllBags`) reach these.
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
]]></Script>
</Ui>
@@ -0,0 +1,51 @@
<Ui>
<!--
benilla's fixes to the stock FrameXML Lua, loaded after every stock file.
-->
<Script><![CDATA[
-- Deviation: the stock FCF_ChatTabFadeFinished (FloatingChatFrame.lua:989-992) nils
-- oldAlpha even under a live hover, so leaving and re-entering within
-- CHAT_FRAME_FADE_TIME (a flick to the scroll buttons, outside the hover box, does it)
-- stops the plate fading in (l.873) for the rest of the session; this keeps oldAlpha while
-- hover is set.
if FCF_ChatTabFadeFinished then
function FCF_ChatTabFadeFinished(chatTab, chatFrame)
chatTab:Hide()
if not chatFrame.hover then
chatFrame.oldAlpha = nil
end
end
end
-- Deviation: re-runs UIParent_ManageFramePositions after DurabilityFrame's own OnEvent. The
-- pass seats the frame 20 further in while a side glyph shows (UIParent.lua:1759-1768),
-- but the stock frame runs it only from OnShow/OnHide, so a glyph appearing while the frame
-- is shown leaves the shield's overhang off the screen edge. The stock handler runs first,
-- unchanged, on the this/event/arg1 already set for the dispatch.
if DurabilityFrame then
local refOnEvent = DurabilityFrame:GetScript("OnEvent")
DurabilityFrame:SetScript("OnEvent", function()
if refOnEvent then refOnEvent() end
UIParent_ManageFramePositions()
end)
end
-- Deviation: friendly plates the player turned on come back at every world entry. The
-- stock UpdateNameplates (UIOptionsFrame.lua:768-780) shows them and hides them again at
-- once (l.775-776), and FRIENDNAMEPLATES_ON is never saved, since l.182 registers
-- NAMEPLATES_ON a second time.
function UpdateNameplates()
if ( NAMEPLATES_ON ) then
ShowNameplates()
else
HideNameplates()
end
if ( FRIENDNAMEPLATES_ON ) then
ShowFriendNameplates()
else
HideFriendNameplates()
end
end
RegisterForSave("FRIENDNAMEPLATES_ON")
]]></Script>
</Ui>
@@ -0,0 +1,132 @@
<Ui>
<!--
The ESC menu: the stock GameMenuFrame.xml, reshaped in place as a 1.12 addon reshapes it.
Deviation: the rungs are the 1.15 era client's ESC menu, because its settings screens
replace 1.12's, which are much worse to use; the era's AddOns rung is dropped, since the
character-select AddOns screen is the only addon UI.
-->
<!-- The era's header font, which 1.12's Fonts.xml lacks. -->
<Font name="BenillaGameFontNormalMed1" inherits="GameFontNormal" virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
</Font>
<Script><![CDATA[
-- ══ The frame: the era layout, 28+144+28 wide, 32 top, 28 bottom, 7 rungs of 21 and
-- three gaps of 20 ═══════════════════════════════════════════════════════════════════════
GameMenuFrame:SetWidth(200)
GameMenuFrame:SetHeight(267)
-- The stock title (`GameMenuFrame.xml:36`) has no name: found as addons find it.
for _, region in ipairs({ GameMenuFrame:GetRegions() }) do
if region:GetObjectType() == "FontString" and region:GetText() == MAIN_MENU then
region:SetFontObject(BenillaGameFontNormalMed1)
end
end
-- BENILLA_ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale;
-- read at every show, then the stock OnShow.
local Ref_GameMenuFrame_OnShow = GameMenuFrame:GetScript("OnShow")
GameMenuFrame:SetScript("OnShow", function()
this:SetScale(BENILLA_ERA_WINDOW_SCALE or 1)
Ref_GameMenuFrame_OnShow()
end)
-- ══ The rungs ═══════════════════════════════════════════════════════════════════════════
-- Every stock rung stays, anchored TOP to a BOTTOM as in the stock file, so an addon's
-- `SetPoint("TOP", …)` replaces its one anchor. The three the era menu lacks are hidden
-- in place, with Sound and UI Options each laid over the rung above it, so they take no
-- space: a width or height of 0 is no size in 1.12's layout, not a flat rect.
-- Literal: 1.12 has no GAMEMENU_OPTIONS; its OPTIONS_MENU is "Options Menu".
GameMenuButtonOptions:SetText("Options")
GameMenuButtonOptions:ClearAllPoints()
GameMenuButtonOptions:SetPoint("TOP", GameMenuFrame, "TOP", 0, -32)
GameMenuButtonOptions:SetScript("OnClick", function()
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(BenillaOptionsFrame)
end)
for _, fold in ipairs({
{ GameMenuButtonSoundOptions, GameMenuButtonOptions },
{ GameMenuButtonUIOptions, GameMenuButtonSoundOptions },
}) do
local button, above = fold[1], fold[2]
button:Hide()
button:ClearAllPoints()
button:SetPoint("TOP", above, "BOTTOM", 0, button:GetHeight())
end
-- Key Bindings keeps its stock anchor, 1 under UI Options: the era rungs hang from its
-- top, so an addon that inserts a button above it and re-anchors it under that button
-- (ShaguTweaks) moves them down as it moves the stock rungs.
GameMenuButtonKeybindings:Hide()
-- The era's Edit Mode and Support, with nothing behind them yet: disabled.
local editMode = CreateFrame("Button", "BenillaGameMenuButtonEditMode", GameMenuFrame,
"GameMenuButtonTemplate")
editMode:SetPoint("TOP", GameMenuButtonKeybindings, "TOP", 0, -19)
editMode:SetText("Edit Mode")
editMode:Disable()
local support = CreateFrame("Button", "BenillaGameMenuButtonSupport", GameMenuFrame,
"GameMenuButtonTemplate")
support:SetPoint("TOP", editMode, "BOTTOM", 0, 0)
support:SetText("Support")
support:Disable()
GameMenuButtonMacros:SetPoint("TOP", support, "BOTTOM", 0, 0)
GameMenuButtonLogout:SetPoint("TOP", GameMenuButtonMacros, "BOTTOM", 0, -20)
GameMenuButtonQuit:SetPoint("TOP", GameMenuButtonLogout, "BOTTOM", 0, 0)
GameMenuButtonContinue:SetPoint("TOP", GameMenuButtonQuit, "BOTTOM", 0, -20)
-- ══ Our options window in the stock "is an options window open?" checks ═══════════════
-- Each wraps the stock function to add `BenillaOptionsFrame`.
local Ref_IsOptionFrameOpen = IsOptionFrameOpen
local Ref_ToggleGameMenu = ToggleGameMenu
local Ref_UpdateMicroButtons = UpdateMicroButtons
-- Guarded for a test kit that loads the game menu without the options window.
local function BenillaOptions_IsOpen()
return BenillaOptionsFrame and BenillaOptionsFrame:IsVisible()
end
-- `UIParent.lua:996`: a yes keeps the ESC sweep off the centre panel, which our window is.
function IsOptionFrameOpen()
if BenillaOptions_IsOpen() then
return 1
end
return Ref_IsOptionFrameOpen()
end
-- `UIParent.lua:1465-1497`: static popup, then an open options window, then the stock
-- ladder. Our Close click is the discard-and-hide of the reference's `OptionsFrameCancel`.
function ToggleGameMenu(clicked)
if clicked then
-- As the reference's `clicked` arm: close an options window, then toggle.
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
end
return Ref_ToggleGameMenu(clicked)
end
if StaticPopup_EscapePressed() then
return
end
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
return
end
return Ref_ToggleGameMenu(clicked)
end
-- `MainMenuBarMicroButtons.lua:47-58`: only ever pushes the button, so the stock decision
-- stands and ours adds our window. Guarded for a test kit that loads no micro buttons.
function UpdateMicroButtons()
if Ref_UpdateMicroButtons then
Ref_UpdateMicroButtons()
end
if MainMenuMicroButton and BenillaOptions_IsOpen() then
MainMenuMicroButton:SetButtonState("PUSHED", 1)
end
end
]]></Script>
</Ui>
@@ -1,216 +0,0 @@
<Ui>
<!--
The game menu ESC opens, in the chrome of the reference's GameMenuFrame.xml.
Deviation: the rungs are the 1.15 era client's ESC menu, because its settings screens
replace 1.12's, which are much worse to use; the era's AddOns rung is dropped, since the
character-select AddOns screen is the only addon UI. Addons address `GameMenuFrame` by name.
-->
<Script><![CDATA[
-- Label strings as `X = X or "..."`, so a stock GlobalStrings value wins where one exists.
MAIN_MENU = MAIN_MENU or "Main Menu"
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
MACROS = MACROS or "Macros"
LOGOUT = LOGOUT or "Logout"
EXIT_GAME = EXIT_GAME or "Exit Game"
RETURN_TO_GAME = RETURN_TO_GAME or "Return to Game"
CANCEL = CANCEL or "Cancel"
-- A rung with nothing behind it yet (Edit Mode, Support), disabled.
function GameMenuButton_Pending(button, text)
button:SetText(text)
button:Disable()
end
-- ══ Our options window in the stock "is an options window open?" checks ══
-- Each wraps the stock body to add `BenillaOptionsFrame`; here because this file loads
-- last, after MainMenuBarMicroButtons.xml.
local Ref_IsOptionFrameOpen = IsOptionFrameOpen
local Ref_ToggleGameMenu = ToggleGameMenu
local Ref_UpdateMicroButtons = UpdateMicroButtons
-- Guarded for a test kit that loads the game menu without the options window.
local function BenillaOptions_IsOpen()
return BenillaOptionsFrame and BenillaOptionsFrame:IsVisible()
end
-- `UIParent.lua:996`: a yes keeps the ESC sweep off the centre panel, which our window is.
function IsOptionFrameOpen()
if BenillaOptions_IsOpen() then
return 1
end
return Ref_IsOptionFrameOpen()
end
-- `UIParent.lua:1465-1497`: static popup, then an open options window, then the stock
-- ladder. Our Close click is the discard-and-hide of the reference's `OptionsFrameCancel`.
function ToggleGameMenu(clicked)
if clicked then
-- As the reference's `clicked` arm: close an options window, then toggle.
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
end
return Ref_ToggleGameMenu(clicked)
end
if StaticPopup_EscapePressed() then
return
end
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
return
end
return Ref_ToggleGameMenu(clicked)
end
-- `MainMenuBarMicroButtons.lua:47-58`: only ever pushes the button, so the stock decision
-- stands and ours adds our window.
function UpdateMicroButtons()
if Ref_UpdateMicroButtons then
Ref_UpdateMicroButtons()
end
if MainMenuMicroButton and BenillaOptions_IsOpen() then
MainMenuMicroButton:SetButtonState("PUSHED", 1)
end
end
]]></Script>
<!-- The era's header font, which 1.12's Fonts.xml lacks. -->
<Font name="GameFontNormalMed1" inherits="GameFontNormal" virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
</Font>
<!-- GameMenuFrame.xml:4's attributes but `movable`, inert there: it has no drag scripts. -->
<Frame name="GameMenuFrame" parent="UIParent" frameStrata="DIALOG" toplevel="true" enableMouse="true" hidden="true">
<!-- The era layout: 28+144+28 wide, 32 top, 28 bottom, 7 rungs of 21, 3 gaps of 20. -->
<Size><AbsDimension x="200" y="267"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<Texture name="GameMenuFrameHeader" file="Interface\DialogFrame\UI-DialogBox-Header">
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
</Texture>
<!-- The era's 13px font on the 1.12 seat, within half a unit of the era's. -->
<FontString name="GameMenuFrameTitle" inherits="GameFontNormalMed1" text="MAIN_MENU">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuFrameHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- Literal: 1.12 has no GAMEMENU_OPTIONS; its OPTIONS_MENU is "Options Menu". -->
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate" text="Options">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="28" y="-32"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(BenillaOptionsFrame)
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonEditMode" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, HUD_EDIT_MODE_MENU)</OnLoad></Scripts>
</Button>
<Button name="GameMenuButtonSupport" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonEditMode" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, GAMEMENU_SUPPORT)</OnLoad></Scripts>
</Button>
<Button name="GameMenuButtonMacros" inherits="GameMenuButtonTemplate" text="MACROS">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonSupport" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<!-- GameMenuFrame.xml:118-122. -->
<Scripts>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(getglobal("GameMenuFrame"))
ShowMacroFrame()
</OnClick>
</Scripts>
</Button>
<!-- As the reference: disabled while a CAMP or QUIT countdown is up. -->
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate" text="LOGOUT">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnShow>
if not StaticPopup_Visible("CAMP") and not StaticPopup_Visible("QUIT") then
self:Enable()
else
self:Disable()
end
</OnShow>
<OnClick>
PlaySound("igMainMenuLogout")
Logout()
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonQuit" inherits="GameMenuButtonTemplate" text="EXIT_GAME">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonLogout" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnShow>
if not StaticPopup_Visible("CAMP") and not StaticPopup_Visible("QUIT") then
self:Enable()
else
self:Disable()
end
</OnShow>
<OnClick>
PlaySound("igMainMenuQuit")
Quit()
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonContinue" inherits="GameMenuButtonTemplate" text="RETURN_TO_GAME">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonQuit" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
PlaySound("igMainMenuContinue")
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
</Frames>
<!-- The reference's OnShow/OnHide, guarded for a test that loads no action bar. -->
<Scripts>
<OnShow>
-- ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale. A
-- second OnShow on this frame would replace this one.
this:SetScale(ERA_WINDOW_SCALE or 1)
if UpdateMicroButtons then UpdateMicroButtons() end
if Disable_BagButtons then Disable_BagButtons() end
</OnShow>
<OnHide>
if UpdateMicroButtons then UpdateMicroButtons() end
if Enable_BagButtons then Enable_BagButtons() end
</OnHide>
</Scripts>
</Frame>
</Ui>
+197 -135
View File
@@ -4,14 +4,16 @@
OptionsFrame.xml, which loads after. Only commands the engine dispatches appear.
Deviation: the layout is the era Settings panel's Keybindings page (collapsible category
sections), because 1.12's settings screens are much worse to use. Capture follows 1.12's
Blizzard_BindingUI: the next key or mouse 3/4/5 press binds, left and right clicks only
select, and ESC while armed binds Escape (its OnKeyDown has no ESCAPE filter).
Blizzard_BindingUI: while a capsule is selected the window is keyboard-enabled, so the
next key binds through its OnKeyDown, and a mouse 3/4/5 click or a wheel notch binds;
left and right clicks only select, and ESC while armed binds Escape (its OnKeyDown has no
ESCAPE filter).
Deviation: every bind, unbind and reset saves at once, as the era panel commits, where
1.12 had Okay and Cancel; only Reset To Default and leaving the character set confirm.
-->
<!-- 1.12's KeyBindingFrameBindingButtonTemplate; all five buttons reach the capture. -->
<Button name="KeyBindingButtonTemplate" virtual="true">
<!-- 1.12's KeyBindingFrameBindingButtonTemplate; all five buttons reach the handler. -->
<Button name="BenillaKeyBindingButtonTemplate" virtual="true">
<Size><AbsDimension x="180" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
@@ -22,13 +24,13 @@
<DisabledFont inherits="GameFontDisableSmall"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp", "Button4Up", "Button5Up")</OnLoad>
<OnClick>KeyBindingButton_OnClick(arg1)</OnClick>
<OnClick>BenillaKeyBindingButton_OnClick(arg1)</OnClick>
</Scripts>
</Button>
<!-- A binding row or a section header in 1.12's QuestLogTitleButton dress, since the era's
expand arrows are not in 1.12; 25 tall, the era's row height. -->
<Frame name="KeyBindingRowTemplate" virtual="true">
<Frame name="BenillaKeyBindingRowTemplate" virtual="true">
<Size><AbsDimension x="560" y="25"/></Size>
<Layers>
<Layer level="BACKGROUND">
@@ -61,13 +63,13 @@
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<Scripts>
<OnClick>KeyBindings_ToggleSection(this.sectionIndex)</OnClick>
<OnClick>BenillaKeyBindings_ToggleSection(this.sectionIndex)</OnClick>
</Scripts>
</Button>
<Button name="$parentKey1Button" inherits="KeyBindingButtonTemplate" id="1">
<Button name="$parentKey1Button" inherits="BenillaKeyBindingButtonTemplate" id="1">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="175" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentKey2Button" inherits="KeyBindingButtonTemplate" id="2">
<Button name="$parentKey2Button" inherits="BenillaKeyBindingButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="$parentKey1Button" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
</Frames>
@@ -75,7 +77,7 @@
<Script><![CDATA[
-- ── Strings ── a 1.12 GlobalStrings global, or the fallback for a harness without them.
function KeyBindings_String(name, fallback)
function BenillaKeyBindings_String(name, fallback)
local s = getglobal(name)
if s then return s end
return fallback
@@ -83,9 +85,9 @@
-- ── Key display ── 1.12's GetBindingText in full form: modifier prefixes stay tokens, the
-- base key reads its KEY_* global. "CTRL--" is CTRL plus the minus key.
function KeyBindings_KeyText(key)
function BenillaKeyBindings_KeyText(key)
if not key then
return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
return NORMAL_FONT_COLOR_CODE .. BenillaKeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
end
local prefix = ""
local rest = key
@@ -104,43 +106,55 @@
break
end
end
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
return prefix .. BenillaKeyBindings_String("KEY_" .. rest, rest)
end
-- ── Page state ── `searchPainted` maps OptionsFrame.xml's search rows to binding indices.
KeyBindingsPage = { sections = {}, items = {} }
BenillaKeyBindingsPage = { sections = {}, items = {} }
KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
KEYBINDINGS_DISPLAYED = 19
KEYBINDINGS_ROW_HEIGHT = 25
KEYBINDINGS_SEARCH_ROWS = 8
BENILLA_KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
BENILLA_KEYBINDINGS_DISPLAYED = 19
BENILLA_KEYBINDINGS_ROW_HEIGHT = 25
BENILLA_KEYBINDINGS_SEARCH_ROWS = 8
-- ── Section model ── one section per category in Bindings.xml order, collapsed at first
-- as in the era; a rebuild keeps each state.
function KeyBindings_BuildSections()
local page = KeyBindingsPage
-- ── Section model ── one section per `HEADER_*` row of the binding list, in its
-- order (Blizzard_BindingUI.lua:84-92), collapsed at first as in the era; a rebuild keeps
-- each state. `token` is the header's GlobalStrings key, `fallback` its bare name.
-- Deviation: the spacer headers BLANK, BLANK2 and BLANK3 over multibars 2-4
-- (Bindings.xml:883/967/1051), which 1.12 draws blank (BINDING_HEADER_BLANK is " ", the
-- other two are not defined), continue the section above, because this page's sections
-- are named and collapsible.
function BenillaKeyBindings_IsHeader(command)
return strsub(command, 1, 6) == "HEADER"
end
function BenillaKeyBindings_BuildSections()
local page = BenillaKeyBindingsPage
local was = {}
for _, s in ipairs(page.sections) do
was[s.token] = s.expanded
end
page.sections = {}
local byToken = {}
local sec = nil
for i = 1, GetNumBindings() do
local _, category = GetBinding(i)
local sec = byToken[category]
if not sec then
sec = { token = category, indices = {}, expanded = was[category] }
byToken[category] = sec
table.insert(page.sections, sec)
local command = GetBinding(i)
if BenillaKeyBindings_IsHeader(command) then
local token = "BINDING_" .. command
if not (sec and strfind(command, "^HEADER_BLANK%d*$")) then
sec = { token = token, fallback = strsub(command, 8), indices = {},
expanded = was[token] }
table.insert(page.sections, sec)
end
elseif sec then
table.insert(sec.indices, i)
end
table.insert(sec.indices, i)
end
KeyBindings_Flatten()
BenillaKeyBindings_Flatten()
end
-- The visible list the faux scroll runs over.
function KeyBindings_Flatten()
local page = KeyBindingsPage
function BenillaKeyBindings_Flatten()
local page = BenillaKeyBindingsPage
page.items = {}
for n, sec in ipairs(page.sections) do
table.insert(page.items, { header = n })
@@ -153,38 +167,38 @@
end
-- A header click, voiced with the checkbox sound.
function KeyBindings_ToggleSection(n)
local sec = KeyBindingsPage.sections[n]
function BenillaKeyBindings_ToggleSection(n)
local sec = BenillaKeyBindingsPage.sections[n]
if not sec then return end
PlaySound("igMainMenuOptionCheckBoxOn")
sec.expanded = not sec.expanded
KeyBindings_Flatten()
KeyBindingsPage_Update()
BenillaKeyBindings_Flatten()
BenillaKeyBindingsPage_Update()
end
-- Silent, for the capture fixture.
function KeyBindings_ExpandSection(n, on)
local sec = KeyBindingsPage.sections[n]
function BenillaKeyBindings_ExpandSection(n, on)
local sec = BenillaKeyBindingsPage.sections[n]
if not sec then return end
sec.expanded = on and true or false
KeyBindings_Flatten()
BenillaKeyBindings_Flatten()
end
-- ── Painters ── shared by the list and the search rows, which capture in place.
function KeyBindings_PaintBindRow(name, index)
local command, _, key1, key2 = GetBinding(index)
function BenillaKeyBindings_PaintBindRow(name, index)
local command, key1, key2 = GetBinding(index)
local b1 = getglobal(name .. "Key1Button")
local b2 = getglobal(name .. "Key2Button")
b1.commandName = command
b2.commandName = command
b1:SetText(KeyBindings_KeyText(key1))
b2:SetText(KeyBindings_KeyText(key2))
b1:SetText(BenillaKeyBindings_KeyText(key1))
b2:SetText(BenillaKeyBindings_KeyText(key2))
if key1 then b1:SetAlpha(1) else b1:SetAlpha(0.8) end
if key2 then b2:SetAlpha(1) else b2:SetAlpha(0.8) end
local desc = getglobal(name .. "Description")
desc:SetText(KeyBindings_String("BINDING_NAME_" .. command, command))
desc:SetText(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
desc:Show()
local page = KeyBindingsPage
local page = BenillaKeyBindingsPage
if page.selected == command and page.keyID == 1 then
b1:LockHighlight()
else
@@ -200,11 +214,11 @@
getglobal(name .. "Header"):Hide()
end
function KeyBindings_PaintHeaderRow(name, n)
local sec = KeyBindingsPage.sections[n]
function BenillaKeyBindings_PaintHeaderRow(name, n)
local sec = BenillaKeyBindingsPage.sections[n]
local header = getglobal(name .. "Header")
header.sectionIndex = n
header:SetText(KeyBindings_String(sec.token, sec.token))
header:SetText(BenillaKeyBindings_String(sec.token, sec.fallback))
local icon = getglobal(name .. "HeaderIcon")
if sec.expanded then
icon:SetTexture("Interface\\Buttons\\UI-MinusButton-Up")
@@ -218,34 +232,34 @@
end
-- ── The list repaint (1.12 KeyBindingFrame_Update) ──
function KeyBindingsPage_Update()
local page = KeyBindingsPage
function BenillaKeyBindingsPage_Update()
local page = BenillaKeyBindingsPage
local scroll = getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrame")
local offset = FauxScrollFrame_GetOffset(scroll)
for i = 1, KEYBINDINGS_DISPLAYED do
local name = KEYBINDINGS_ROW_BASE .. i
for i = 1, BENILLA_KEYBINDINGS_DISPLAYED do
local name = BENILLA_KEYBINDINGS_ROW_BASE .. i
local row = getglobal(name)
local item = page.items[offset + i]
if item and item.header then
KeyBindings_PaintHeaderRow(name, item.header)
BenillaKeyBindings_PaintHeaderRow(name, item.header)
row:Show()
elseif item then
KeyBindings_PaintBindRow(name, item.bind)
BenillaKeyBindings_PaintBindRow(name, item.bind)
row:Show()
else
row:Hide()
end
end
FauxScrollFrame_Update(scroll, table.getn(page.items),
KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT)
BENILLA_KEYBINDINGS_DISPLAYED, BENILLA_KEYBINDINGS_ROW_HEIGHT)
-- Our trough follows the bar `FauxScrollFrame_Update` has just shown or hidden.
BenillaScrollTrough_Follow(scroll:GetName())
KeyBindings_UpdateUnbindKey()
BenillaKeyBindings_UpdateUnbindKey()
end
-- The page entry: a rebind or a set switch may have changed the table.
function KeyBindingsPage_Refresh()
KeyBindings_BuildSections()
function BenillaKeyBindingsPage_Refresh()
BenillaKeyBindings_BuildSections()
local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck")
if GetCurrentBindingSet() == 2 then
box:SetChecked(1)
@@ -253,23 +267,28 @@
box:SetChecked(nil)
end
getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrameScrollBar"):SetValue(0)
KeyBindingsPage_Update()
BenillaKeyBindingsPage_Update()
end
function KeyBindings_Output(text)
function BenillaKeyBindings_Output(text)
getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
end
-- ── Selection ── arms the host's capture, which hands its chord to KeyBindings_OnHostKey.
function KeyBindings_SetSelected(command, keyID)
KeyBindingsPage.selected = command
KeyBindingsPage.keyID = keyID
BenillaBindCapture(command ~= nil)
-- ── Selection ── while a capsule is selected the window takes every key and the
-- wheel, as 1.12's keyboard-enabled KeyBindingFrame does (Blizzard_BindingUI.xml:73,
-- :541-543); unarmed it takes neither, so keys and the wheel reach the game.
function BenillaKeyBindings_SetSelected(command, keyID)
BenillaKeyBindingsPage.selected = command
BenillaKeyBindingsPage.keyID = keyID
if BenillaOptionsFrame then
BenillaOptionsFrame:EnableKeyboard(command ~= nil)
BenillaOptionsFrame:EnableMouseWheel(command ~= nil)
end
end
function KeyBindings_UpdateUnbindKey()
function BenillaKeyBindings_UpdateUnbindKey()
local unbind = getglobal("BenillaOptionsFrameContainerUnbind")
if KeyBindingsPage.selected then
if BenillaKeyBindingsPage.selected then
unbind:Enable()
else
unbind:Disable()
@@ -277,94 +296,134 @@
end
-- Every search row: a bind from one can unbind a victim shown on another.
function KeyBindings_RepaintVisible()
if KeyBindingsPage.searchPainted then
for name, index in pairs(KeyBindingsPage.searchPainted) do
KeyBindings_PaintBindRow(name, index)
function BenillaKeyBindings_RepaintVisible()
if BenillaKeyBindingsPage.searchPainted then
for name, index in pairs(BenillaKeyBindingsPage.searchPainted) do
BenillaKeyBindings_PaintBindRow(name, index)
end
KeyBindings_UpdateUnbindKey()
BenillaKeyBindings_UpdateUnbindKey()
else
KeyBindingsPage_Update()
BenillaKeyBindingsPage_Update()
end
end
function KeyBindingButton_OnClick(button)
-- As 1.12: while armed, a middle, 4 or 5 press is a bind the capture takes after this.
local page = KeyBindingsPage
-- 1.12's KeyBindingButton_OnClick (Blizzard_BindingUI.lua:228-258): while armed, a middle,
-- 4 or 5 click binds that button.
function BenillaKeyBindingButton_OnClick(button)
local page = BenillaKeyBindingsPage
if page.selected then
if button ~= "LeftButton" and button ~= "RightButton" then
BenillaKeyBindings_OnKeyDown(button)
return
end
if page.selected == this.commandName and page.keyID == this:GetID() then
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindings_Output(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
BenillaKeyBindings_SetSelected(this.commandName, this:GetID())
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
BenillaKeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindings_Output(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
BenillaKeyBindings_SetSelected(this.commandName, this:GetID())
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
BenillaKeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
KeyBindings_RepaintVisible()
BenillaKeyBindings_RepaintVisible()
end
function KeyBindings_Commit()
function BenillaKeyBindings_Commit()
SaveBindings(GetCurrentBindingSet())
end
-- `chord` is ALT-CTRL-SHIFT-TOKEN; 1.12's OnKeyDown armed branch, then the live commit.
function KeyBindings_OnHostKey(chord)
local page = KeyBindingsPage
-- 1.12's KeyBindingFrame_OnKeyDown armed branch (Blizzard_BindingUI.lua:140-212), then
-- the live commit: `button` is a mouse button's or the wheel's name, else the key is
-- `arg1`, the window's OnKeyDown argument. Unarmed it does nothing: the window takes keys
-- only while armed, so 1.12's unarmed ESCAPE arm never runs here.
function BenillaKeyBindings_OnKeyDown(button)
local page = BenillaKeyBindingsPage
if not page.selected then
return
end
if button == "LeftButton" then
button = "BUTTON1"
elseif button == "RightButton" then
button = "BUTTON2"
elseif button == "MiddleButton" then
button = "BUTTON3"
elseif button == "Button4" then
button = "BUTTON4"
elseif button == "Button5" then
button = "BUTTON5"
end
local chord
if button then
if button == "BUTTON1" or button == "BUTTON2" then
return
end
chord = button
else
chord = arg1
end
if chord == "UNKNOWN" or chord == "SHIFT" or chord == "CTRL" or chord == "ALT" then
return
end
if IsShiftKeyDown() then chord = "SHIFT-" .. chord end
if IsControlKeyDown() then chord = "CTRL-" .. chord end
if IsAltKeyDown() then chord = "ALT-" .. chord end
local selected = page.selected
local oldAction = GetBindingAction(chord)
if oldAction ~= "" and oldAction ~= selected then
local k1, k2 = GetBindingKey(oldAction)
if (not k1 or k1 == chord) and (not k2 or k2 == chord) then
KeyBindings_Output(string.format(
KeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
KeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
BenillaKeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
else
KeyBindings_Output(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
BenillaKeyBindings_Output(BenillaKeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
else
KeyBindings_Output(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
BenillaKeyBindings_Output(BenillaKeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
local key1, key2 = GetBindingKey(selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if page.keyID == 1 then
KeyBindings_SetOrRestore(chord, selected, key1)
BenillaKeyBindings_SetOrRestore(chord, selected, key1)
if key2 then SetBinding(key2, selected) end
else
if key1 then SetBinding(key1, selected) end
KeyBindings_SetOrRestore(chord, selected, key2)
BenillaKeyBindings_SetOrRestore(chord, selected, key2)
end
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_RepaintVisible()
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_RepaintVisible()
end
-- 1.12's KeyBindingFrame_SetBinding, message and all; SetBinding refuses only a non-key.
function KeyBindings_SetOrRestore(key, command, oldKey)
function BenillaKeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
KeyBindings_Output(KeyBindings_String(
BenillaKeyBindings_Output(BenillaKeyBindings_String(
"KEYBINDINGFRAME_MOUSEWHEEL_ERROR",
"Can't bind mousewheel to actions with up and down states"))
end
end
-- The list's OnMouseWheel while armed (Blizzard_BindingUI.xml:377-387).
function BenillaKeyBindings_OnMouseWheel(delta)
if delta > 0 then
BenillaKeyBindings_OnKeyDown("MOUSEWHEELUP")
else
BenillaKeyBindings_OnKeyDown("MOUSEWHEELDOWN")
end
end
-- 1.12's Unbind Key body, then the live commit.
function KeyBindings_Unbind()
local page = KeyBindingsPage
function BenillaKeyBindings_Unbind()
local page = BenillaKeyBindingsPage
local key1, key2 = GetBindingKey(page.selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
@@ -373,67 +432,70 @@
else
if key1 then SetBinding(key1, page.selected) end
end
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindings_RepaintVisible()
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindings_RepaintVisible()
end
-- ── Character-specific set ── checking saves set 2 at once (LoadBindings(2) falls back
-- to the account set); unchecking confirms first, as the era does. The accept loads set 1
-- before saving it, or the character binds would be written into the account set.
function KeyBindings_CharacterToggle(box)
function BenillaKeyBindings_CharacterToggle(box)
PlaySound("igMainMenuOptionCheckBoxOn")
if box:GetChecked() then
LoadBindings(2)
SaveBindings(2)
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
else
box:SetChecked(1)
StaticPopup_Show("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
StaticPopup_Show("BENILLA_CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
end
end
StaticPopupDialogs["CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = KeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
-- Our key: Blizzard_BindingUI.lua:10 defines the stock one for its own window.
StaticPopupDialogs["BENILLA_CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = BenillaKeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
"Really switch to general key bindings? All key bindings specific to this character will be permanantly deleted."),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
button1 = BenillaKeyBindings_String("OKAY", "Okay"),
button2 = BenillaKeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
LoadBindings(1)
SaveBindings(1)
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
end,
}
-- Reset To Default confirms, as the era's does; our string, since 1.12 has none.
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
StaticPopupDialogs["BENILLA_CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
button1 = BenillaKeyBindings_String("OKAY", "Okay"),
button2 = BenillaKeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
PlaySound("igMainMenuOptionCheckBoxOn")
LoadBindings(0)
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
end,
}
-- ── Search ── a binding's tag is its display name uppercased, as the era's.
-- A static pool of KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll.
function KeyBindings_SearchMatches(words)
-- A static pool of BENILLA_KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll.
function BenillaKeyBindings_SearchMatches(words)
local out = {}
for i = 1, GetNumBindings() do
local command = GetBinding(i)
local tag = string.upper(KeyBindings_String("BINDING_NAME_" .. command, command))
local s = OptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
if not BenillaKeyBindings_IsHeader(command) then
local tag = string.upper(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
local s = BenillaOptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
end
end
end
return out
File diff suppressed because it is too large Load Diff
+155 -93
View File
@@ -1,16 +1,20 @@
<Ui>
<!--
The script error log, which 1.12 does not have, so every name carries the `Benilla` prefix
(`tests::reference_surface`); the stock `ScriptErrors` dialog shows one message, this keeps
the log. Shaped like the reference's CraftFrame. It polls the engine:
`BenillaGetNumScriptErrors()` is (retained, total-ever), `BenillaGetScriptErrorInfo(i)` is
`seq, kind, message, count` (1-based, oldest first, nil past the end).
The script error log, which 1.12 does not have, so every name carries the `Benilla` prefix;
the stock `ScriptErrors` dialog shows one message, this keeps the log. Laid out on the
ClassTrainer panel art as the class trainer lays itself out (Blizzard_TrainerUI.xml:214-356
and :481-513, Blizzard_TrainerUI.lua:469-474): its rows, its list, its detail under the
list and its two buttons in the art's sockets. It collects as a 1.12 error-log addon does:
it wraps the handler `geterrorhandler` holds, the stock `_ERRORMESSAGE`
(BasicControls.xml:16), which still runs after it. An addon that later calls
`seterrorhandler` replaces the chain, as in 1.12.
-->
<Script><![CDATA[
-- A pushable above every stock panel's, so a game window opening pushes the log aside.
UIPanelWindows["BenillaScriptLogFrame"] = { area = "left", pushable = 9, whileDead = 1 }
BENILLA_SCRIPTLOG_ROWS = 13
-- The class trainer's rows (`CLASS_TRAINER_SKILLS_DISPLAYED`, Blizzard_TrainerUI.lua:470).
BENILLA_SCRIPTLOG_ROWS = 11
BENILLA_SCRIPTLOG_ROW_HEIGHT = 16
-- A wrapped row would break the faux kit's fixed rows.
BENILLA_SCRIPTLOG_ROW_CHARS = 52
@@ -18,6 +22,39 @@
-- A `seq`, not an index: indices shift when the cap evicts.
BenillaScriptLogSelected = nil
-- Distinct messages kept before the oldest is evicted.
BENILLA_SCRIPTLOG_CAP = 256
-- Rows oldest first, each { seq, message, count }; `total` is the last `seq` handed out,
-- kept apart from the row count so an eviction or a clear never renumbers.
BenillaScriptLog = { rows = {}, total = 0 }
-- A repeat bumps its row's count and keeps its place.
function BenillaScriptLog_Record(message)
if type(message) ~= "string" then message = tostring(message) end
local log = BenillaScriptLog
local found = nil
for i = 1, table.getn(log.rows) do
if log.rows[i].message == message then found = log.rows[i] end
end
if found then
found.count = found.count + 1
else
log.total = log.total + 1
if table.getn(log.rows) >= BENILLA_SCRIPTLOG_CAP then table.remove(log.rows, 1) end
table.insert(log.rows, { seq = log.total, message = message, count = 1 })
end
if BenillaScriptLogFrame and BenillaScriptLogFrame:IsVisible() then
BenillaScriptLog_Update()
end
end
-- The chain: the log first, guarded so a fault in it cannot stop the stock handler.
local benillaStockErrorHandler = geterrorhandler()
seterrorhandler(function(message)
pcall(BenillaScriptLog_Record, message)
return benillaStockErrorHandler(message)
end)
-- `strsub` counts bytes, so the cut backs up over UTF-8 continuation bytes (0x80..0xBF).
function BenillaScriptLog_RowText(message)
local cut = strfind(message, "\n", 1, 1)
@@ -35,18 +72,10 @@
return head
end
-- `load` red (the addon is not running), `error` amber (code raised), `warn` grey (a call
-- was accepted and quietly did nothing).
function BenillaScriptLog_RowColor(kind)
if kind == "load" then return 1.0, 0.3, 0.3 end
if kind == "warn" then return 0.65, 0.65, 0.65 end
return 1.0, 0.82, 0.0
end
function BenillaScriptLog_Update()
local shown, total = BenillaGetNumScriptErrors()
local shown = table.getn(BenillaScriptLog.rows)
local total = BenillaScriptLog.total
-- "Problems", because two of the three kinds are not errors.
local summary
if total == 0 then
summary = "No problems this session."
@@ -68,18 +97,17 @@
for i = 1, BENILLA_SCRIPTLOG_ROWS do
local button = getglobal("BenillaScriptLogRow" .. i)
local index = offset + i
local seq, kind, message, count = BenillaGetScriptErrorInfo(index)
if seq then
button.seq = seq
button.message = message
local row = BenillaScriptLog.rows[index]
if row then
button.seq = row.seq
button.message = row.message
-- `#12 x1113`: one deduplicated failure that happened 1,113 times.
local label = "#" .. seq
if count > 1 then label = label .. " x" .. count end
local label = "#" .. row.seq
if row.count > 1 then label = label .. " x" .. row.count end
button.index:SetText(label)
button.label:SetText(BenillaScriptLog_RowText(message))
button.label:SetTextColor(BenillaScriptLog_RowColor(kind))
button.label:SetText(BenillaScriptLog_RowText(row.message))
button:Show()
if seq == BenillaScriptLogSelected then selectedRow = button end
if row.seq == BenillaScriptLogSelected then selectedRow = button end
else
button.seq = nil
button.message = nil
@@ -99,15 +127,16 @@
BenillaScriptLog_UpdateDetail()
end
-- The selected message in full, traceback and all.
-- The selected message in full, traceback and all, in a box a player can select and copy
-- from. A text that has not changed is left alone: `SetText` collapses the selection, so a
-- new error arriving must not undo one about to be copied. A different text takes the
-- keyboard from the box, as a click on another row does.
function BenillaScriptLog_UpdateDetail()
local text = ""
if BenillaScriptLogSelected then
local shown = BenillaGetNumScriptErrors()
for i = 1, shown do
local seq, _, message = BenillaGetScriptErrorInfo(i)
if seq == BenillaScriptLogSelected then
text = message
for _, row in ipairs(BenillaScriptLog.rows) do
if row.seq == BenillaScriptLogSelected then
text = row.message
break
end
end
@@ -115,12 +144,24 @@
if text == "" then
text = "Select a row above to read it in full."
end
getglobal("BenillaScriptLogDetailText"):SetText(text)
local box = getglobal("BenillaScriptLogDetailText")
if text ~= box.shown then
box.shown = text
box:ClearFocus()
box:SetText(text)
end
end
-- Text rects resolve after Lua runs, so the detail child is re-measured every frame.
function BenillaScriptLogFrame_OnUpdate()
BenillaScroll_ResizeChild("BenillaScriptLogDetailScroll", { "BenillaScriptLogDetailText" })
-- The box is read-only: a player's edit is undone. The restore's own `SetText` raises
-- another change, which finds the text equal and stops; `SetText` of an equal text does
-- nothing. The selection an edit replaced is put back, so Ctrl+C still copies it all.
function BenillaScriptLogDetail_OnTextChanged()
local shown = this.shown or ""
if this:GetText() ~= shown then
this:SetText(shown)
this:HighlightText()
end
ScrollingEdit_OnTextChanged(getglobal("BenillaScriptLogDetailScroll"))
end
function BenillaScriptLogRow_OnClick(button)
@@ -133,7 +174,7 @@
BenillaScriptLog_Update()
end
function BenillaScriptLogFrame_OnLoad(self)
function BenillaScriptLogFrame_OnLoad()
-- Masks the square icon to the portrait ring.
SetPortraitToTexture("BenillaScriptLogFramePortrait",
"Interface\\Icons\\INV_Misc_Note_01")
@@ -157,7 +198,7 @@
-- `/errors clear`: keeps the numbering, so a number never names two failures.
function BenillaScriptLog_Clear()
BenillaClearScriptErrors()
BenillaScriptLog.rows = {}
BenillaScriptLogSelected = nil
if getglobal("BenillaScriptLogFrame"):IsVisible() then
BenillaScriptLog_Update()
@@ -184,10 +225,10 @@
<HighlightTexture file="Interface\Buttons\UI-Listbox-Highlight2" alphaMode="ADD"/>
<Scripts>
<OnLoad>
self.index = getglobal(self:GetName() .. "Index")
self.label = getglobal(self:GetName() .. "Label")
this.index = getglobal(this:GetName() .. "Index")
this.label = getglobal(this:GetName() .. "Label")
</OnLoad>
<OnClick>BenillaScriptLogRow_OnClick(self)</OnClick>
<OnClick>BenillaScriptLogRow_OnClick(this)</OnClick>
</Scripts>
</Button>
@@ -232,8 +273,9 @@
</Layer></Layers>
</Frame>
<!-- The class trainer's first row (ClassTrainerSkill1, Blizzard_TrainerUI.xml:214-222). -->
<Button name="BenillaScriptLogRow1" inherits="BenillaScriptLogRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-100"/></Offset></Anchor></Anchors>
</Button>
<Button name="BenillaScriptLogRow2" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow1" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow3" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow2" relativePoint="BOTTOMLEFT"/></Anchors></Button>
@@ -245,28 +287,29 @@
<Button name="BenillaScriptLogRow9" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow8" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow10" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow9" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow11" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow10" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow12" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow11" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow13" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow12" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<!-- Only the faux kit's right-hand Slider renders. -->
<!-- The class trainer's list frame (Blizzard_TrainerUI.xml:313-322, 184 tall as its .lua:471
sets it). Only the faux kit's right-hand Slider renders. -->
<ScrollFrame name="BenillaScriptLogListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="208"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
<Scripts>
<Size><AbsDimension x="296" y="184"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-67" y="-96"/></Offset></Anchor></Anchors>
<Scripts>
<!-- The stock kit has no `updateFunc`, so the repaint is wired here. -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_SCRIPTLOG_ROW_HEIGHT, BenillaScriptLog_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
</ScrollFrame>
<!-- The real-scroll kit: a message with its traceback has variable height. -->
<!-- The class trainer's detail pane, 8 under its list and 119 tall (Blizzard_TrainerUI.xml:
346-356, .lua:472), so it ends above the buttons' sockets. The real-scroll kit: a
message with its traceback has variable height. -->
<ScrollFrame name="BenillaScriptLogDetailScroll">
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-314"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="296" y="119"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogListScrollFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:SetScrollChild(getglobal(self:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(self, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(self)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(self)</OnScrollRangeChanged>
<OnLoad>this:SetScrollChild(getglobal(this:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(this, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(this)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(this)</OnScrollRangeChanged>
</Scripts>
<Frames>
<Slider name="$parentBar" inherits="BenillaScrollBarTemplate" hidden="true">
@@ -274,64 +317,83 @@
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
</Anchors>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(self, arg1)</OnValueChanged></Scripts>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(this, arg1)</OnValueChanged></Scripts>
</Slider>
<Frame name="$parentChild">
<Size><AbsDimension x="293" y="128"/></Size>
<!-- Mouse-enabled and the pane's full height, as SendMailScrollChildFrame is
(MailFrame.xml:604-659): a click below the text focuses the box. -->
<Frame name="$parentChild" enableMouse="true">
<Size><AbsDimension x="296" y="119"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- 270 wide so the traceback wraps rather than clips. -->
<FontString name="BenillaScriptLogDetailText" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP" text="">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="6" y="-6"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- A multi-line box in a scroll frame as MailFrame.xml:616-649 hosts the mail
body: `autoFocus="false"`, so the keyboard stays with the game until a
click, and Escape clears the focus. A multi-line box is as tall as its
insets and its wrapped text, so the scroll frame ranges over it as
ScrollingEdit_OnTextChanged (UIPanelTemplates.lua:307) recomputes. The
insets seat the text 6 in and 6 down, 270 wide, and leave 20 below
the last line. No `letters`, so the whole error goes in. Read-only
through OnTextChanged; a click selects it all, so Ctrl+C copies the
error. Not wired: the stock scroll-to-caret (UIPanelTemplates.lua:
314-342), since selecting all leaves the caret at the end and a
click would scroll a long traceback to its bottom. -->
<EditBox name="BenillaScriptLogDetailText" multiLine="true" autoFocus="false">
<Size><AbsDimension x="296" y="119"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<TextInsets><AbsInset left="6" right="20" top="6" bottom="20"/></TextInsets>
<Scripts>
<OnTextChanged>BenillaScriptLogDetail_OnTextChanged()</OnTextChanged>
<OnEditFocusGained>this:HighlightText()</OnEditFocusGained>
<OnEscapePressed>this:ClearFocus()</OnEscapePressed>
</Scripts>
<FontString inherits="GameFontHighlightSmall"/>
</EditBox>
</Frames>
<Scripts>
<OnMouseUp>BenillaScriptLogDetailText:SetFocus()</OnMouseUp>
</Scripts>
</Frame>
</Frames>
</ScrollFrame>
<!-- The art's two button sockets, where the class trainer's Train and Exit sit
(Blizzard_TrainerUI.xml:481-513). -->
<Button name="BenillaScriptLogClearButton" inherits="UIPanelButtonTemplate" text="Clear">
<Size><AbsDimension x="96" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-455"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="80" y="22"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="224" y="-420"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaScriptLog_Clear()</OnClick></Scripts>
</Button>
<Button name="BenillaScriptLogCloseBottomButton" inherits="UIPanelButtonTemplate" text="Close">
<Size><AbsDimension x="96" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="219" y="-455"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="80" y="22"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="305" y="-420"/></Offset></Anchor></Anchors>
<Scripts><OnClick>HideUIPanel(getglobal("BenillaScriptLogFrame"))</OnClick></Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>BenillaScriptLogFrame_OnLoad(self)</OnLoad>
<OnLoad>BenillaScriptLogFrame_OnLoad()</OnLoad>
<!-- A spin bubbles up the parent chain; the detail pane's own handler wins there. -->
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaScriptLog_Update()</OnShow>
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
</Scripts>
</Frame>
<!-- Benilla's slash commands, handed to SubmitChatInput (ui_chat::input); loads after the
stock ChatFrame.xml, which declares SlashCmdList. -->
<!-- The player's commands, as an addon registers them; after the stock ChatFrame.lua, which
declares SlashCmdList. -->
<Script><![CDATA[
local BENILLA_COMMANDS = {
{ "CHATTEST", "/chattest" },
{ "SHOT", "/shot" },
{ "LIQUID", "/liquid" },
{ "CASTVIS", "/castvis" },
{ "PARTYTEST", "/partytest" },
{ "REACTION", "/reaction", "/react" },
{ "RELOAD", "/reload" },
{ "ERRORS", "/errors", "/err" },
{ "CONVERTRAID", "/convertraid" },
}
for _, row in ipairs(BENILLA_COMMANDS) do
local key = "BENILLA_" .. row[1]
for i = 2, table.getn(row) do
setglobal("SLASH_" .. key .. (i - 1), row[i])
end
SlashCmdList[key] = function(msg)
SubmitChatInput(row[2] .. " " .. (msg or ""))
-- Deviation: 1.12 has no `/reload` (later clients added it; 1.12 reloads through
-- `/console reloadui`); kept so a player can reload the interface after toggling addons.
SLASH_BENILLA_RELOAD1 = "/reload"
SlashCmdList["BENILLA_RELOAD"] = function()
ReloadUI()
end
-- `/errors` toggles the log and `/errors clear` empties it; `/err` is the short form.
SLASH_BENILLA_ERRORS1 = "/errors"
SLASH_BENILLA_ERRORS2 = "/err"
SlashCmdList["BENILLA_ERRORS"] = function(msg)
local arg = string.lower((string.gsub(msg or "", "^%s*(.-)%s*$", "%1")))
if arg == "clear" then
BenillaScriptLog_Clear()
else
BenillaScriptLog_Toggle()
end
end
]]></Script>
@@ -183,7 +183,7 @@
</Button>
</Frames>
<Scripts>
<OnValueChanged>BenillaFauxScrollBar_OnValueChanged(self, arg1)</OnValueChanged>
<OnValueChanged>BenillaFauxScrollBar_OnValueChanged(this, arg1)</OnValueChanged>
</Scripts>
<ThumbTexture name="$parentThumbTexture" file="Interface\Buttons\UI-ScrollBar-Knob">
<Size><AbsDimension x="16" y="16"/></Size>
-167
View File
@@ -1,167 +0,0 @@
# The built-in interface, in load order: a bare filename is ours, from `assets/ui`; a path is
# the reference's own file, off the player's patch chain. Entry 0, Fonts.xml, loads alone into
# every boot VM, from Startup on; the rest loads on entering the world, then third-party addons.
# The font objects thus exist before the login screen; the reference loads Fonts.xml with the
# rest of FrameXML.
# `inherits=`, `parent=` and a named `relativeTo=` resolve only against names declared above; a
# missing one only warns (the template is skipped, the parent falls back to the enclosing frame).
# A later Lua global or template replaces an earlier one; a named frame publishes its global
# only while the name is free.
# A stock `.xml` that does not `<Script file=>` its own `.lua` needs both rows, `.lua` first.
# LoadOnDemand addons have no row, as in the reference; they load off the chain on first use.
## Interface: 11200
## Title: benilla
Interface\FrameXML\Fonts.xml
# Second, not first as in the reference, because entry 0 must be Fonts.xml.
Interface\FrameXML\GlobalStrings.lua
# `Localize()` runs at load and overwrites GlobalStrings keys, so this follows them.
Interface\FrameXML\Localization.xml
# `GetText` is a Lua global, not an engine binding: the client's `0x703bf0` calls it.
Interface\FrameXML\LocaleProperties.lua
Interface\FrameXML\BasicControls.xml
# Before UIParent.xml, whose `LowHealthFrame` has `parent="WorldFrame"`.
Interface\FrameXML\WorldFrame.xml
# Above every file that names `parent="UIParent"` or adds a `UIPanelWindows` row at load.
Interface\FrameXML\UIParent.xml
Interface\FrameXML\ItemButtonTemplate.xml
# Before GameTooltip.xml and StaticPopup.xml, whose money rows inherit `SmallMoneyFrameTemplate`.
Interface\FrameXML\MoneyFrame.lua
Interface\FrameXML\MoneyFrame.xml
Interface\FrameXML\MoneyInputFrame.lua
Interface\FrameXML\MoneyInputFrame.xml
Interface\FrameXML\GameTooltip.xml
Interface\FrameXML\UIDropDownMenu.xml
Interface\FrameXML\UIMenu.xml
# Before the unit frames, whose dropdowns' OnLoad reaches `UnitPopup_ShowMenu`.
Interface\FrameXML\UnitPopup.xml
Interface\FrameXML\ItemRef.xml
# The `TextStatusBar` template and verbs, which the unit bars and `MainMenuExpBar` inherit.
Interface\FrameXML\TextStatusBar.lua
Interface\FrameXML\TextStatusBar.xml
# Before PartyFrame.xml, whose rows reach `RefreshBuffs` from their OnLoad.
Interface\FrameXML\BuffFrame.xml
# Function-only kits the unit windows below call from their OnLoad.
Interface\FrameXML\UnitFrame.xml
Interface\FrameXML\CombatFeedback.xml
Interface\FrameXML\PlayerFrame.xml
Interface\FrameXML\PartyFrame.xml
Interface\FrameXML\TargetFrame.xml
Interface\FrameXML\PetFrame.xml
# Anchors `relativeTo="TargetFrame"`.
Interface\FrameXML\ComboFrame.xml
# Ours: the scroll parts of our options window and script log.
ScrollTemplates.xml
Interface\FrameXML\UIPanelTemplates.lua
Interface\FrameXML\UIPanelTemplates.xml
# The reference's three options windows, loaded hidden so addons find their frames by name.
# Deviation: the player opens our own options window, `OptionsFrame.xml` below, because 1.12's
# settings screens are much worse to use; the stock one holds `OptionsFrame`.
Interface\FrameXML\OptionsFrame.xml
Interface\FrameXML\SoundOptionsFrame.xml
# Inherits `OptionsCheckButtonTemplate`, and declares `UIOptionsFrameCheckButtons`, which
# MultiActionBars.lua writes at load.
Interface\FrameXML\UIOptionsFrame.xml
# StaticPopup.lua starts `StaticPopupDialogs` empty: a file that adds a dialog at load follows it.
Interface\FrameXML\StaticPopup.xml
Interface\FrameXML\CoinPickupFrame.xml
# Ours: the Keybindings page's templates and script, which OptionsFrame.xml uses at load.
KeyBindingsPage.xml
# Ours: `BenillaOptionsFrame`; its rows read the option globals the stock windows above set.
OptionsFrame.xml
# `MainMenuBar`, `MainMenuBarArtFrame` and `MainMenuExpBar`, which later files read at load.
Interface\FrameXML\MainMenuBar.xml
Interface\FrameXML\Cooldown.xml
# `ActionButtonTemplate`, which ActionBarFrame.xml's `ActionBarButtonTemplate` inherits.
Interface\FrameXML\ActionButtonTemplate.xml
Interface\FrameXML\ActionBarFrame.xml
# The four optional bars, action slots 25-72.
Interface\FrameXML\MultiActionBars.xml
Interface\FrameXML\PetActionBarFrame.xml
# `UpdateMicroButtons` reads CharacterFrame, SpellBookFrame, QuestLogFrame, GameMenuFrame, the
# three stock options windows, FriendsFrame, WorldMapFrame, HelpFrame and KeyRingButton
# unguarded, so each must be on this list.
Interface\FrameXML\MainMenuBarMicroButtons.xml
# Before Minimap.xml, whose queue dropdown's OnLoad calls `MiniMapBattlefieldDropDown_OnLoad`.
Interface\FrameXML\BattlefieldFrame.xml
# GameTime.xml, DurabilityFrame.xml and QuestTimerFrame.xml name `MinimapCluster` at load.
Interface\FrameXML\Minimap.xml
Interface\FrameXML\GameTime.xml
# Before ZoneText.xml, whose frames call `FadingFrame_OnLoad` in their OnLoad.
Interface\FrameXML\FadingFrame.xml
Interface\FrameXML\ZoneText.xml
Interface\FrameXML\ColorPickerFrame.xml
Interface\FrameXML\WorldMapFrame.xml
Interface\FrameXML\CastingBarFrame.xml
Interface\FrameXML\MirrorTimer.xml
Interface\FrameXML\DurabilityFrame.xml
Interface\FrameXML\UIErrorsFrame.xml
# Anchors `relativeTo="UIErrorsFrame"`.
Interface\FrameXML\RaidWarning.xml
Interface\FrameXML\ContainerFrame.xml
# `CharacterFrame` parents every tab page below, and its OnLoad reads `PlayerFrameHealthBar`,
# `PlayerFrameManaBar` and `MainMenuExpBar`; PaperDollFrame.xml declares templates the pet page
# and the bag bar inherit.
Interface\FrameXML\CharacterFrame.xml
Interface\FrameXML\PaperDollFrame.xml
Interface\FrameXML\PetPaperDollFrame.xml
Interface\FrameXML\MainMenuBarBagButtons.xml
Interface\FrameXML\StackSplitFrame.xml
# Ours: wraps the stock bag verbs; the keyring is a container (id -2).
ContainerFrameAdapters.xml
Interface\FrameXML\ReputationFrame.xml
Interface\FrameXML\SkillFrame.xml
# Brings HonorFrameTemplates.xml, whose row templates `Blizzard_InspectUI` inherits.
Interface\FrameXML\HonorFrame.xml
Interface\FrameXML\DressUpFrame.xml
Interface\FrameXML\SpellBookFrame.xml
# Ours: the pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
# RaidFrame.xml names `parent="FriendsFrame"` and MailFrame.xml inherits `FriendsFrameTabTemplate`.
Interface\FrameXML\FriendsFrame.xml
Interface\FrameXML\RaidFrame.xml
Interface\FrameXML\GossipFrame.xml
Interface\FrameXML\MerchantFrame.xml
Interface\FrameXML\MailFrame.xml
Interface\FrameXML\TradeFrame.xml
Interface\FrameXML\ItemTextFrame.xml
Interface\FrameXML\BankFrame.xml
Interface\FrameXML\TaxiFrame.xml
Interface\FrameXML\PetStable.xml
Interface\FrameXML\LootFrame.xml
# Brings QuestFrameTemplates.xml, whose templates the guild registrar and quest log inherit; the
# quest log's OnLoad also calls `QuestMoneyFrame_OnLoad` from QuestFrame.lua.
Interface\FrameXML\QuestFrame.xml
Interface\FrameXML\GuildRegistrarFrame.xml
Interface\FrameXML\PetitionFrame.xml
Interface\FrameXML\QuestLogFrame.xml
Interface\FrameXML\QuestTimerFrame.xml
# ChatFrame.lua starts `SlashCmdList` empty: a file that registers slash commands at load follows.
Interface\FrameXML\ChatFrame.xml
Interface\FrameXML\FloatingChatFrame.xml
# `ShapeshiftBar_OnLoad` can reach `UIParent_ManageFramePositions`, which reads the frames above
# unguarded.
Interface\FrameXML\BonusActionBarFrame.xml
# `WorldStateAlwaysUpFrame`'s OnLoad runs the same pass, which also reads
# `ShapeshiftBarLeft/Middle/Right`, so this follows BonusActionBarFrame.xml.
Interface\FrameXML\WorldStateFrame.xml
# `ClassTrainerListScrollFrameTemplate`, which on-demand addons inherit.
Interface\FrameXML\ClassTrainerFrameTemplates.xml
# Ours: the `/errors` script error log and our slash commands.
ScriptLogFrame.xml
Interface\FrameXML\HelpFrame.xml
Interface\FrameXML\CinematicFrame.xml
Interface\FrameXML\TabardFrame.xml
Interface\FrameXML\TutorialFrame.xml
# Ours: the ESC game menu. Last: it wraps `ToggleGameMenu`, `IsOptionFrameOpen` and
# `UpdateMicroButtons` to count `BenillaOptionsFrame`.
GameMenuFrame.xml
+23
View File
@@ -0,0 +1,23 @@
# benilla's own interface, one layer on the stock one (docs/METHOD.md): every file is ours, from
# `assets/ui`, and loads in this order after every `FrameXML.toc` row and before the third-party
# addons. It is listed in no addon list, fires no `ADDON_LOADED` and cannot be switched off; a dev
# build boots without it under `WOW_STOCK_UI=1`. A test names its files, so it does not grow.
## Interface: 11200
# The scroll parts of our options window and script log.
ScrollTemplates.xml
# The Keybindings page's templates and script, which OptionsFrame.xml uses at load.
KeyBindingsPage.xml
# `BenillaOptionsFrame`; its rows read the option globals the stock options windows set.
OptionsFrame.xml
# The ESC menu's era look on the stock `GameMenuFrame`, whose Options rung opens ours.
GameMenuAdapters.xml
# `OpenBackpack` and `CloseBackpack` over every equipped bag, for the vendor and the mailbox.
ContainerFrameAdapters.xml
# The pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
# The `/errors` script error log and our slash commands.
ScriptLogFrame.xml
# Our fixes to the stock Lua.
FrameXMLFixes.xml
@@ -0,0 +1,82 @@
//! `extended_launcher`: the client with a plugin of its own on top, started through
//! [`benilla_app::run_with`] as a crate built on benilla starts it. A feature 1.12.1 does not have
//! lives in a crate like this one, never in benilla.
//!
//! ```text
//! cargo run -p benilla-app --example extended_launcher
//! ```
//!
//! The banner reads `benilla build … · extended`, and the plugin logs one line at `Startup`.
//!
//! A crate of its own copies the `benilla` launcher (`crates/benilla`): a build script that stamps
//! its commit, a `main` like this one that reads the stamp back as `crates/benilla/src/main.rs`
//! does, and a manifest that takes benilla as the workspace does:
//!
//! ```toml
//! [features]
//! default = ["dev"]
//! # The instruments; the player build is `--no-default-features`.
//! dev = ["benilla-app/dev"]
//!
//! [dependencies]
//! # `default-features = false`, or `--no-default-features` still builds benilla-app with `dev`.
//! benilla-app = { git = "https://github.com/samwhosung/benilla", default-features = false }
//! # benilla's version, adding no features: bevy's defaults bring its audio stack back.
//! bevy = { version = "0.18.1", default-features = false }
//!
//! [build-dependencies]
//! benilla-buildstamp = { git = "https://github.com/samwhosung/benilla" }
//!
//! # Cargo reads `[patch]` and `[profile]` from the root manifest only, so benilla's are repeated:
//! # without the patches the addon Lua and the mixer are not benilla's.
//! [patch.crates-io]
//! lua-src = { git = "https://github.com/samwhosung/benilla" }
//! kira = { git = "https://github.com/samwhosung/benilla" }
//!
//! [profile.dev]
//! opt-level = 1
//!
//! [profile.dev.package."*"]
//! opt-level = 3
//! ```
//!
//! ```text
//! // build.rs
//! fn main() {
//! benilla_buildstamp::emit();
//! }
//! ```
//!
//! Its `Cargo.lock` starts as a copy of benilla's: cargo reads only the root's lockfile too, and
//! without it resolves every crate to its newest compatible version, not the one benilla is built
//! with. A dev build keeps its `WoW` link, `benilla-config/` and `.probe-identity` in its own
//! folder, beside that `Cargo.lock`, as benilla's does.
use benilla_app::{AppExit, BuildId};
use bevy::prelude::*;
/// The crate's own plugin: one line at `Startup`, to show it runs inside the client.
struct HelloPlugin;
impl Plugin for HelloPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, || info!("a plugin on top of benilla is running"));
}
}
fn main() -> AppExit {
// A launcher of its own passes the stamp its build script made; an example has no build
// script, so this names the version and profile alone.
let build = BuildId {
version: env!("CARGO_PKG_VERSION"),
profile: if cfg!(debug_assertions) {
"debug"
} else {
"release"
},
..Default::default()
};
benilla_app::run_with(build, |app| {
app.add_plugins(HelloPlugin);
})
}
+17 -12
View File
@@ -1074,6 +1074,9 @@ fn seat_a_session(script: &mut UiScript) {
class_file: Some("WARRIOR".into()),
sex: 2,
is_player: true,
// `UnitCreatureType("player")`: the feed's snapshot carries the race's type, and a
// Human is a Humanoid.
creature_type_name: Some("Humanoid".into()),
// `UnitFactionGroup("player")`: every playable race has a side, and AceDB-2.0
// concatenates it into its per-realm key at file scope.
faction_group: Some("Alliance".into()),
@@ -1088,18 +1091,20 @@ fn seat_a_session(script: &mut UiScript) {
// One buff, one target and one action with a running cooldown, so an addon that only draws when
// there is content (a buff bar, a target frame, cooldown text) is separated from one that
// failed.
script.set_auras(
"player",
Some(vec![benilla_ui::script::AuraState {
spell_id: 1243,
name: Some("Power Word: Fortitude".into()),
icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()),
count: 1,
helpful: true,
cancelable: true,
..Default::default()
}]),
);
// The player's guid, which a token resolves to before its aura list is read.
script.set_unit_guids(&benilla_ui::script::UnitGuids {
player: 1,
..Default::default()
});
script.set_player_auras(vec![benilla_ui::script::AuraState {
spell_id: 1243,
name: Some("Power Word: Fortitude".into()),
icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()),
count: 1,
helpful: true,
cancelable: true,
..Default::default()
}]);
script.set_unit(
"target",
Some(benilla_ui::script::UnitState {
+14 -15
View File
@@ -99,7 +99,7 @@ fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> {
None
}
/// Resolves the texts and PvP info, writes the host globals, then fires the elected zone event and,
/// Resolves the texts and PvP info, pushes the zone caches, then fires the elected zone event and,
/// independently, `MINIMAP_ZONE_CHANGED`.
fn feed_zone_events(
script: Option<NonSendMut<UiScript>>,
@@ -213,21 +213,20 @@ fn feed_zone_events(
};
Some((ty, f.catalog().faction_group_name(zone_mask).unwrap_or("")))
});
let (pvp_type, pvp_faction) = pvp.unwrap_or(("", ""));
let pvp_type = pvp.map_or("", |(ty, _)| ty);
let globals = script.lua().globals();
let pushed = globals
.set("__benilla_zone_name", signal.zone_text.clone())
.and_then(|()| globals.set("__benilla_real_zone_name", real_zone_text))
.and_then(|()| globals.set("__benilla_subzone_name", signal.subzone_text.clone()))
.and_then(|()| globals.set("__benilla_zone_text", minimap_text.clone()))
.and_then(|()| globals.set("__benilla_pvp_type", pvp_type))
.and_then(|()| globals.set("__benilla_pvp_faction", pvp_faction))
.and_then(|()| globals.set("__benilla_pvp_arena", is_arena));
if let Err(e) = pushed {
warn!("area: zone host globals: {e}");
return;
}
script.set_zone_texts(benilla_ui::script::ZoneTexts {
zone: signal.zone_text.clone(),
real_zone: real_zone_text,
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),
is_arena,
});
if let Some(event) = event {
script.fire_event(event, vec![]);
File diff suppressed because it is too large Load Diff
+298 -248
View File
@@ -4,15 +4,37 @@
//!
//! Prefix order is ALT-CTRL-SHIFT (`Blizzard_BindingUI.lua:176-182`; the emitter `0x4b6630` walks
//! the table at `0x846bd0`), and it decides which modifier the fallback drops. Super/Cmd is not a
//! 1.12 modifier: a chord never carries it and a press with it held never matches.
//! 1.12 modifier, so a chord never carries it; that a press with it held runs nothing is
//! `crate::bindings`'s deviation.
use std::fmt;
use bevy::input::mouse::MouseButton;
use bevy::prelude::KeyCode;
/// A bindable base input: a keyboard key, a mouse button, or one wheel direction.
use super::layout::{LayoutName, LayoutNames};
/// A key's 1.12 name: one character (`Z`, `1`, `ù`), or a word from the reference's name table
/// (`F1`, `NUMPAD7`, `SPACE`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum KeyName {
Char(char),
Word(&'static str),
}
impl fmt::Display for KeyName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeyName::Char(c) => write!(f, "{c}"),
KeyName::Word(w) => f.write_str(w),
}
}
}
/// A bindable base input: a keyboard key by its 1.12 name, a mouse button, or one wheel direction.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum BindKey {
Key(KeyCode),
Key(KeyName),
Mouse(MouseButton),
WheelUp,
WheelDown,
@@ -78,186 +100,122 @@ impl Chord {
}
}
/// Build the canonical chord string from live modifier state and a base token.
pub(crate) fn chord_string(alt: bool, ctrl: bool, shift: bool, token: &str) -> String {
let mut s = String::new();
if alt {
s.push_str("ALT-");
/// The 1.12 name a key press gets: a letter, digit or punctuation key what the active layout
/// names it ([`LayoutNames`]), every other key its fixed name. `None` for a key the reference
/// names `UNKNOWN` or drops, and for the modifiers, which are only prefixes
/// (`IsKeyPressIgnoredForBinding`). Both Enters are `ENTER`.
pub(crate) fn key_token(k: KeyCode, layout: &LayoutNames) -> Option<KeyName> {
match layout.name(k) {
// The lookup folds `a`-`z` up (`0x64b419`), so Turkish `VK_OEM_7`'s `i` is `I`'s key.
Some(LayoutName::Char(c)) => Some(KeyName::Char(c.to_ascii_uppercase())),
Some(LayoutName::Dropped) => None,
None => fixed_name(k),
}
if ctrl {
s.push_str("CTRL-");
}
if shift {
s.push_str("SHIFT-");
}
s.push_str(token);
s
}
/// The 1.12 token for a physical key; `None` for a key the reference names `UNKNOWN` and for the
/// modifiers, which are only prefixes (`IsKeyPressIgnoredForBinding`). Both Enters are `ENTER`.
pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
/// The fixed half of `0x42d800` and the Mac table: every key no layout names
/// ([`super::layout::position`]).
fn fixed_name(k: KeyCode) -> Option<KeyName> {
use KeyCode::*;
use KeyName::Word;
Some(match k {
KeyA => "A",
KeyB => "B",
KeyC => "C",
KeyD => "D",
KeyE => "E",
KeyF => "F",
KeyG => "G",
KeyH => "H",
KeyI => "I",
KeyJ => "J",
KeyK => "K",
KeyL => "L",
KeyM => "M",
KeyN => "N",
KeyO => "O",
KeyP => "P",
KeyQ => "Q",
KeyR => "R",
KeyS => "S",
KeyT => "T",
KeyU => "U",
KeyV => "V",
KeyW => "W",
KeyX => "X",
KeyY => "Y",
KeyZ => "Z",
Digit1 => "1",
Digit2 => "2",
Digit3 => "3",
Digit4 => "4",
Digit5 => "5",
Digit6 => "6",
Digit7 => "7",
Digit8 => "8",
Digit9 => "9",
Digit0 => "0",
F1 => "F1",
F2 => "F2",
F3 => "F3",
F4 => "F4",
F5 => "F5",
F6 => "F6",
F7 => "F7",
F8 => "F8",
F9 => "F9",
F10 => "F10",
F11 => "F11",
F12 => "F12",
F1 => Word("F1"),
F2 => Word("F2"),
F3 => Word("F3"),
F4 => Word("F4"),
F5 => Word("F5"),
F6 => Word("F6"),
F7 => Word("F7"),
F8 => Word("F8"),
F9 => Word("F9"),
F10 => Word("F10"),
F11 => Word("F11"),
F12 => Word("F12"),
// On a Mac F13 is print-screen: the Mac key table `0x5bf320` maps keycode `0x69` to
// `0x212`, `PRINTSCREEN`, and macOS has no PrintScreen keycode.
#[cfg(target_os = "macos")]
F13 => "PRINTSCREEN",
F13 => Word("PRINTSCREEN"),
// `IsValidBindingKeyString` accepts `F` + any digits (`0x846c04`) and the Mac table maps
// keycode `0x6A` to `F16` (`0x30d`). Deviation: the Windows key table stops at `VK_F12`
// and drops later F-keys; they are named here so a stored `F13`-`F24` can be pressed.
#[cfg(not(target_os = "macos"))]
F13 => "F13",
F13 => Word("F13"),
// On a Mac F14 and F15 are ScrollLock and Pause: keycodes `0x6B` and `0x71` map to
// `0x210` and `0x211`, which the namer calls `UNKNOWN`, so they stay unbindable there.
#[cfg(not(target_os = "macos"))]
F14 => "F14",
F14 => Word("F14"),
#[cfg(not(target_os = "macos"))]
F15 => "F15",
F16 => "F16",
F17 => "F17",
F18 => "F18",
F19 => "F19",
F20 => "F20",
F21 => "F21",
F22 => "F22",
F23 => "F23",
F24 => "F24",
Space => "SPACE",
Tab => "TAB",
Enter | NumpadEnter => "ENTER",
Escape => "ESCAPE",
Backspace => "BACKSPACE",
Insert => "INSERT",
Delete => "DELETE",
Home => "HOME",
End => "END",
PageUp => "PAGEUP",
PageDown => "PAGEDOWN",
ArrowUp => "UP",
ArrowDown => "DOWN",
ArrowLeft => "LEFT",
ArrowRight => "RIGHT",
Numpad0 => "NUMPAD0",
Numpad1 => "NUMPAD1",
Numpad2 => "NUMPAD2",
Numpad3 => "NUMPAD3",
Numpad4 => "NUMPAD4",
Numpad5 => "NUMPAD5",
Numpad6 => "NUMPAD6",
Numpad7 => "NUMPAD7",
Numpad8 => "NUMPAD8",
Numpad9 => "NUMPAD9",
NumpadAdd => "NUMPADPLUS",
NumpadSubtract => "NUMPADMINUS",
NumpadDivide => "NUMPADDIVIDE",
NumpadMultiply => "NUMPADMULTIPLY",
NumpadDecimal => "NUMPADDECIMAL",
F15 => Word("F15"),
F16 => Word("F16"),
F17 => Word("F17"),
F18 => Word("F18"),
F19 => Word("F19"),
F20 => Word("F20"),
F21 => Word("F21"),
F22 => Word("F22"),
F23 => Word("F23"),
F24 => Word("F24"),
Space => Word("SPACE"),
Tab => Word("TAB"),
Enter | NumpadEnter => Word("ENTER"),
Escape => Word("ESCAPE"),
Backspace => Word("BACKSPACE"),
Insert => Word("INSERT"),
Delete => Word("DELETE"),
Home => Word("HOME"),
End => Word("END"),
PageUp => Word("PAGEUP"),
PageDown => Word("PAGEDOWN"),
ArrowUp => Word("UP"),
ArrowDown => Word("DOWN"),
ArrowLeft => Word("LEFT"),
ArrowRight => Word("RIGHT"),
Numpad0 => Word("NUMPAD0"),
Numpad1 => Word("NUMPAD1"),
Numpad2 => Word("NUMPAD2"),
Numpad3 => Word("NUMPAD3"),
Numpad4 => Word("NUMPAD4"),
Numpad5 => Word("NUMPAD5"),
Numpad6 => Word("NUMPAD6"),
Numpad7 => Word("NUMPAD7"),
Numpad8 => Word("NUMPAD8"),
Numpad9 => Word("NUMPAD9"),
NumpadAdd => Word("NUMPADPLUS"),
NumpadSubtract => Word("NUMPADMINUS"),
NumpadDivide => Word("NUMPADDIVIDE"),
NumpadMultiply => Word("NUMPADMULTIPLY"),
NumpadDecimal => Word("NUMPADDECIMAL"),
// The Mac keypad's `=`: the namer's `0x30c`, and in `IsValidBindingKeyString`'s table.
NumpadEqual => "NUMPADEQUALS",
NumLock => "NUMLOCK",
PrintScreen => "PRINTSCREEN",
NumpadEqual => Word("NUMPADEQUALS"),
NumLock => Word("NUMLOCK"),
PrintScreen => Word("PRINTSCREEN"),
// No ScrollLock or Pause: the namer (`0x4b66b0`) calls `0x210`/`0x211` `UNKNOWN`, and
// `IsValidBindingKeyString` (`0x4b7890`) has neither name.
CapsLock => "CAPSLOCK",
Minus => "-",
Equal => "=",
BracketLeft => "[",
BracketRight => "]",
Backslash => "\\",
Semicolon => ";",
Quote => "'",
Comma => ",",
Period => ".",
Slash => "/",
Backquote => "`",
CapsLock => Word("CAPSLOCK"),
_ => return None,
})
}
/// Fold key aliases that share one 1.12 token (`NumpadEnter` → `Enter`) so a chord parsed from
/// `ENTER` matches either physical key.
/// Fold the keys the reference gives one code (`NumpadEnter` → `Enter`, both `0x201`), so the
/// pressed-key list and a latch treat them as one key.
pub(crate) fn normalize_key(k: KeyCode) -> KeyCode {
match k {
KeyCode::NumpadEnter => KeyCode::Enter,
// The Mac print-screen key; must agree with `key_token`, which names it for capture.
#[cfg(target_os = "macos")]
KeyCode::F13 => KeyCode::PrintScreen,
other => other,
}
}
/// The 1.12 token for a mouse button: BUTTON4 is winit's `Forward` and BUTTON5 its `Back`, so the
/// 1.12 default `BUTTON4 TOGGLEAUTORUN` sits on `Forward`. Which physical button the reference
/// calls BUTTON4 is untraced.
pub(crate) fn mouse_token(b: MouseButton) -> Option<&'static str> {
Some(match b {
MouseButton::Left => "BUTTON1",
MouseButton::Right => "BUTTON2",
MouseButton::Middle => "BUTTON3",
MouseButton::Forward => "BUTTON4",
MouseButton::Back => "BUTTON5",
// The reference names further buttons `BUTTON<n>` (`0x4b6aa0`'s bit-scan fallback).
// winit passes the platform's button number, so the sixth button is `Other(5)`.
MouseButton::Other(n) => return EXTRA_BUTTONS.get(usize::from(n).wrapping_sub(5)).copied(),
})
}
/// `BUTTON6` to `BUTTON20`, indexed by `Other(n) - 5`.
/// `BUTTON6` to `BUTTON20`, indexed by `Other(n) - 5`: the reference names further buttons
/// `BUTTON<n>` (`0x4b6aa0`'s bit-scan fallback), and winit passes the platform's button number, so
/// the sixth button is `Other(5)`.
const EXTRA_BUTTONS: &[&str] = &[
"BUTTON6", "BUTTON7", "BUTTON8", "BUTTON9", "BUTTON10", "BUTTON11", "BUTTON12", "BUTTON13",
"BUTTON14", "BUTTON15", "BUTTON16", "BUTTON17", "BUTTON18", "BUTTON19", "BUTTON20",
];
/// Token to base input: the inverse of [`key_token`] and [`mouse_token`], plus the wheel pair.
/// Token to base input: the inverse of [`key_token`], the mouse buttons and the wheel pair.
/// BUTTON4 is winit's `Forward` and BUTTON5 its `Back`, so the 1.12 default `BUTTON4
/// TOGGLEAUTORUN` sits on `Forward`; which physical button the reference calls BUTTON4 is untraced.
fn token_key(t: &str) -> Option<BindKey> {
use KeyCode::*;
if let Some(b) = match t {
@@ -279,43 +237,15 @@ fn token_key(t: &str) -> Option<BindKey> {
"MOUSEWHEELDOWN" => return Some(BindKey::WheelDown),
_ => {}
}
// One character: what a layout names some key, which the namer writes as UTF-8 (`0x4b66b0`'s
// `[0x21, 0xff]` arm through `0x41abb0`). [`key_token`] folds `a`-`z` up as the lookup does,
// so a lowercase letter is no press's name.
let mut chars = t.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
let pressable = !c.is_control() && !c.is_whitespace() && !c.is_ascii_lowercase();
return pressable.then_some(BindKey::Key(KeyName::Char(c)));
}
let k = match t {
"A" => KeyA,
"B" => KeyB,
"C" => KeyC,
"D" => KeyD,
"E" => KeyE,
"F" => KeyF,
"G" => KeyG,
"H" => KeyH,
"I" => KeyI,
"J" => KeyJ,
"K" => KeyK,
"L" => KeyL,
"M" => KeyM,
"N" => KeyN,
"O" => KeyO,
"P" => KeyP,
"Q" => KeyQ,
"R" => KeyR,
"S" => KeyS,
"T" => KeyT,
"U" => KeyU,
"V" => KeyV,
"W" => KeyW,
"X" => KeyX,
"Y" => KeyY,
"Z" => KeyZ,
"1" => Digit1,
"2" => Digit2,
"3" => Digit3,
"4" => Digit4,
"5" => Digit5,
"6" => Digit6,
"7" => Digit7,
"8" => Digit8,
"9" => Digit9,
"0" => Digit0,
"F1" => F1,
"F2" => F2,
"F3" => F3,
@@ -374,47 +304,45 @@ fn token_key(t: &str) -> Option<BindKey> {
"NUMLOCK" => NumLock,
"PRINTSCREEN" => PrintScreen,
"CAPSLOCK" => CapsLock,
"-" => Minus,
"=" => Equal,
"[" => BracketLeft,
"]" => BracketRight,
"\\" => Backslash,
";" => Semicolon,
"'" => Quote,
"," => Comma,
"." => Period,
"/" => Slash,
"`" => Backquote,
_ => return None,
};
// Fold aliases so a parsed chord equals a normalized press, and accept only a token the
// namer produces on this platform (on a Mac, F13-F15 are other keys).
let k = normalize_key(k);
if key_token(k) != Some(t) {
return None;
// Only a name the namer gives on this platform: on a Mac, F13-F15 are other keys.
match fixed_name(k) {
Some(name @ KeyName::Word(w)) if w == t => Some(BindKey::Key(name)),
_ => None,
}
Some(BindKey::Key(k))
}
#[cfg(test)]
mod tests {
use super::*;
/// Ties this namer to the engine's `IsValidBindingKeyString`: every default chord, and one key
/// of each token shape, since `KeyCode` cannot be enumerated.
/// Ties this namer to the engine's `IsValidBindingKeyString`: every chord the install's
/// `WTF\DefaultBindings.wtf` ships is a key string it accepts and a chord the dispatcher can
/// press, and one key of each token shape, since `KeyCode` cannot be enumerated.
#[test]
fn every_token_the_codec_names_is_one_setbinding_accepts() {
use benilla_ui::script::keybind::normalize_binding_key;
for spec in super::super::commands::SPECS {
for default in [spec.d1, spec.d2].into_iter().flatten() {
assert_eq!(
normalize_binding_key(default).as_deref(),
Some(default),
"the default chord '{default}' ({}) is not a bindable key string",
spec.name
);
}
let defaults = crate::ui_script::default_bindings();
if benilla_formats::wow_data().is_some() {
assert!(
defaults.len() > 100,
"the install's defaults: {}",
defaults.len()
);
}
for (default, command) in &defaults {
assert_eq!(
normalize_binding_key(default).as_deref(),
Some(default.as_str()),
"the default chord '{default}' ({command}) is not a bindable key string"
);
assert!(
Chord::parse(default).is_some(),
"{command}: default '{default}' does not parse"
);
}
let us = LayoutNames::default();
for k in [
KeyCode::KeyW,
KeyCode::Digit0,
@@ -449,28 +377,24 @@ mod tests {
KeyCode::Slash,
KeyCode::Backquote,
] {
let token = key_token(k).expect("named");
let token = key_token(k, &us).expect("named").to_string();
assert!(
normalize_binding_key(token).is_some(),
normalize_binding_key(&token).is_some(),
"{k:?} names '{token}', which SetBinding refuses"
);
}
for b in [
MouseButton::Left,
MouseButton::Right,
MouseButton::Middle,
MouseButton::Forward,
MouseButton::Back,
for token in [
"BUTTON1", "BUTTON2", "BUTTON3", "BUTTON4", "BUTTON5", "BUTTON20",
] {
let token = mouse_token(b).expect("named");
assert!(normalize_binding_key(token).is_some(), "{b:?} → '{token}'");
assert!(token_key(token).is_some(), "'{token}' names no button");
assert!(normalize_binding_key(token).is_some(), "'{token}'");
}
for token in ["MOUSEWHEELUP", "MOUSEWHEELDOWN"] {
assert!(normalize_binding_key(token).is_some());
}
// The reference's namer calls these `UNKNOWN` (`0x210`/`0x211`).
assert_eq!(key_token(KeyCode::ScrollLock), None);
assert_eq!(key_token(KeyCode::Pause), None);
assert_eq!(key_token(KeyCode::ScrollLock, &us), None);
assert_eq!(key_token(KeyCode::Pause, &us), None);
}
/// The validator's accept set is infinite, so this covers the tokens a real keyboard or mouse
@@ -539,6 +463,7 @@ mod tests {
#[test]
fn the_codec_round_trips_every_keyboard_token() {
let us = LayoutNames::default();
let mut checked = 0;
for k in [
KeyCode::KeyW,
@@ -552,38 +477,33 @@ mod tests {
KeyCode::Backquote,
KeyCode::Quote,
] {
let t = key_token(k).unwrap();
let name = key_token(k, &us).unwrap();
assert_eq!(
token_key(t),
Some(BindKey::Key(normalize_key(k))),
"token {t}"
token_key(&name.to_string()),
Some(BindKey::Key(name)),
"token {name}"
);
checked += 1;
}
assert_eq!(checked, 10);
assert_eq!(key_token(KeyCode::NumpadEnter), Some("ENTER"));
assert_eq!(token_key("ENTER"), Some(BindKey::Key(KeyCode::Enter)));
let enter = Some(KeyName::Word("ENTER"));
assert_eq!(key_token(KeyCode::NumpadEnter, &us), enter);
assert_eq!(key_token(KeyCode::Enter, &us), enter);
assert_eq!(token_key("ENTER"), enter.map(BindKey::Key));
}
/// macOS has no `PrintScreen`, so capture and dispatch must both read F13 as `PRINTSCREEN`.
#[cfg(target_os = "macos")]
#[test]
fn f13_is_print_screen_on_a_mac() {
assert_eq!(
key_token(KeyCode::F13),
Some("PRINTSCREEN"),
"the capture arm"
);
assert_eq!(
normalize_key(KeyCode::F13),
KeyCode::PrintScreen,
"the dispatch arm"
);
let name = key_token(KeyCode::F13, &LayoutNames::default());
assert_eq!(name, Some(KeyName::Word("PRINTSCREEN")));
assert_eq!(
token_key("PRINTSCREEN"),
Some(BindKey::Key(normalize_key(KeyCode::F13))),
name.map(BindKey::Key),
"a chord parsed from the shipped default matches an F13 press"
);
assert_eq!(token_key("F13"), None, "no Mac key is F13");
}
#[test]
@@ -594,7 +514,7 @@ mod tests {
alt: true,
ctrl: true,
shift: true,
key: BindKey::Key(KeyCode::F1)
key: BindKey::Key(KeyName::Word("F1"))
})
);
// `CTRL--` is Ctrl + the minus key.
@@ -604,7 +524,7 @@ mod tests {
alt: false,
ctrl: true,
shift: false,
key: BindKey::Key(KeyCode::Minus)
key: BindKey::Key(KeyName::Char('-'))
})
);
assert_eq!(
@@ -626,18 +546,148 @@ mod tests {
})
);
assert_eq!(Chord::parse("BOGUS"), None);
assert_eq!(
chord_string(true, false, true, "PAGEDOWN"),
"ALT-SHIFT-PAGEDOWN"
);
assert_eq!(
Chord::parse("ALT-SHIFT-PAGEDOWN").unwrap(),
Chord {
alt: true,
ctrl: false,
shift: true,
key: BindKey::Key(KeyCode::PageDown)
key: BindKey::Key(KeyName::Word("PAGEDOWN"))
}
);
}
/// The names a layout gives its keys, as `layout::record_layout_names` keeps them.
fn layout(keys: &[(KeyCode, char)]) -> LayoutNames {
let mut names = LayoutNames::default();
for &(k, c) in keys {
names.set(k, Some(LayoutName::Char(c)));
}
names
}
fn name(k: KeyCode, layout: &LayoutNames) -> Option<String> {
key_token(k, layout).map(|n| n.to_string())
}
/// 1.12 names a letter, digit or punctuation key what the active layout names it, its ASCII
/// letters folded up as the lookup folds them, and every other key by the fixed table. AZERTY
/// on Windows: `VK_Z` where a US W sits, `VK_M` on the semicolon key.
#[test]
fn a_layout_key_is_named_what_its_layout_names_it() {
use KeyCode::*;
let mut azerty = layout(&[
(KeyW, 'Z'),
(KeyZ, 'W'),
(KeyQ, 'A'),
(KeyA, 'Q'),
(Semicolon, 'M'),
(KeyM, ','),
(Comma, ';'),
(Period, ':'),
(Slash, '!'),
(Quote, 'ù'),
(Backquote, '²'),
(Minus, ')'),
(BracketLeft, '^'),
(BracketRight, '$'),
(Backslash, '*'),
(IntlBackslash, '<'),
(NumpadComma, '.'),
(Digit1, '1'),
]);
// Turkish Q's `VK_OEM_7` names its key `i`, which the lookup folds to `I`.
azerty.set(KeyCode::KeyI, Some(LayoutName::Char('i')));
for (k, expected) in [
(KeyW, "Z"),
(KeyZ, "W"),
(KeyQ, "A"),
(KeyA, "Q"),
(Semicolon, "M"),
(KeyM, ","),
(Comma, ";"),
(Period, ":"),
(Slash, "!"),
(Quote, "ù"),
(Backquote, "²"),
(Minus, ")"),
(BracketLeft, "^"),
(BracketRight, "$"),
(Backslash, "*"),
(IntlBackslash, "<"),
(NumpadComma, "."),
(Digit1, "1"),
(KeyI, "I"),
(F1, "F1"),
(Numpad7, "NUMPAD7"),
(Space, "SPACE"),
] {
assert_eq!(name(k, &azerty).as_deref(), Some(expected), "{k:?}");
let token = key_token(k, &azerty).unwrap();
assert_eq!(
token_key(expected),
Some(BindKey::Key(token)),
"the stored '{expected}' is that key's chord"
);
}
// A key its layout drops binds nothing (`0x42da49`).
azerty.set(Backquote, Some(LayoutName::Dropped));
assert_eq!(name(Backquote, &azerty), None);
}
/// A US layout, reported or not, names every key as it always has.
#[test]
fn a_us_layout_names_its_keys_as_the_us_positions() {
use KeyCode::*;
let reported = layout(&[
(KeyW, 'w'),
(KeyZ, 'z'),
(Quote, '\''),
(Backquote, '`'),
(Minus, '-'),
(IntlBackslash, '\\'),
]);
let unreported = LayoutNames::default();
for layout in [&reported, &unreported] {
for (k, expected) in [
(KeyW, "W"),
(KeyZ, "Z"),
(Quote, "'"),
(Backquote, "`"),
(Minus, "-"),
(Digit1, "1"),
] {
assert_eq!(name(k, layout).as_deref(), Some(expected), "{k:?}");
}
}
assert_eq!(name(IntlBackslash, &reported).as_deref(), Some("\\"));
assert_eq!(
name(IntlBackslash, &unreported),
None,
"an ISO key no layout has named yet"
);
}
/// Modifiers are prefixes, never part of the name: Shift+1 is `SHIFT-1`, not `!`.
#[test]
fn a_chord_names_its_key_unshifted() {
let azerty = layout(&[(KeyCode::KeyW, 'z')]);
let key = |k| BindKey::Key(key_token(k, &azerty).unwrap());
assert_eq!(
Chord::parse("SHIFT-Z").map(|c| (c.shift, c.key)),
Some((true, key(KeyCode::KeyW)))
);
assert_eq!(
Chord::parse("SHIFT-1").map(|c| (c.shift, c.key)),
Some((true, key(KeyCode::Digit1)))
);
// Nothing presses a lowercase letter, a space or a control character.
for t in ["w", " ", "\u{7}"] {
assert_eq!(Chord::parse(t), None, "{t:?}");
}
assert_eq!(
Chord::parse("ù").map(|c| c.key),
Some(BindKey::Key(KeyName::Char('ù')))
);
}
}
File diff suppressed because it is too large Load Diff
+668
View File
@@ -0,0 +1,668 @@
//! What the active keyboard layout names each letter, digit and punctuation key, which 1.12 binds
//! by. The Windows client names a key from the virtual-key code the layout produced: the
//! translator `0x42d800` keeps `VK_0`-`VK_9` as themselves (`0x42d81c`) and sends every other key
//! outside its fixed table, `VK_A`-`VK_Z` and `VK_OEM_*` among them, to
//! `MapVirtualKeyA(vk, MAPVK_VK_TO_CHAR)` (`0x42da39`), which returns `VK_A`-`VK_Z`'s own letter
//! whatever the layout types there. The Mac client fills its letter and punctuation keys from the
//! live layout, `KeyTranslate` (`0x8a3c3`) on the current `KCHR` (re-read when it changes,
//! `0x8a345`), and fixes its digits by position (`0x5bf320`). Bevy's `KeyboardInput` carries the
//! physical key and a `logical_key` with Shift applied, so each platform's name is read off the
//! raw winit key messages here, before any `Update` reader.
use std::collections::HashMap;
use bevy::input::keyboard::KeyCode;
use bevy::prelude::*;
/// A layout-named key's place on a US keyboard, with the US name there.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Position {
Letter(char),
Digit(char),
/// The ISO and JIS extra keys and the keypad comma have no US name.
Punctuation(Option<char>),
}
impl Position {
fn us(self) -> Option<char> {
match self {
Position::Letter(c) | Position::Digit(c) => Some(c),
Position::Punctuation(c) => c,
}
}
}
/// The keys a layout names: the letters, the digits, the punctuation, the ISO and JIS extras and
/// the keypad comma. Windows sends `VK_OEM_102`, `VK_ABNT_C2` and `VK_SEPARATOR` to the layout arm
/// like any `VK_OEM_*` key, and the Mac table leaves the ISO key `0x0A` and the keypad comma
/// `0x5F` to `KeyTranslate` (`-1` at `0x5bf320`). winit 0.30 cannot deliver those on a Mac, as on
/// main: it reports `0x0A` as `Backquote` (whose scancode it gives back as `0x32`) and leaves
/// `0x5E` and `0x5F` unidentified, so the `IntlBackslash`, `IntlRo` and `NumpadComma` arms never
/// fire there.
pub(crate) fn position(k: KeyCode) -> Option<Position> {
use KeyCode::*;
use Position::{Digit, Letter, Punctuation};
Some(match k {
KeyA => Letter('A'),
KeyB => Letter('B'),
KeyC => Letter('C'),
KeyD => Letter('D'),
KeyE => Letter('E'),
KeyF => Letter('F'),
KeyG => Letter('G'),
KeyH => Letter('H'),
KeyI => Letter('I'),
KeyJ => Letter('J'),
KeyK => Letter('K'),
KeyL => Letter('L'),
KeyM => Letter('M'),
KeyN => Letter('N'),
KeyO => Letter('O'),
KeyP => Letter('P'),
KeyQ => Letter('Q'),
KeyR => Letter('R'),
KeyS => Letter('S'),
KeyT => Letter('T'),
KeyU => Letter('U'),
KeyV => Letter('V'),
KeyW => Letter('W'),
KeyX => Letter('X'),
KeyY => Letter('Y'),
KeyZ => Letter('Z'),
Digit1 => Digit('1'),
Digit2 => Digit('2'),
Digit3 => Digit('3'),
Digit4 => Digit('4'),
Digit5 => Digit('5'),
Digit6 => Digit('6'),
Digit7 => Digit('7'),
Digit8 => Digit('8'),
Digit9 => Digit('9'),
Digit0 => Digit('0'),
Minus => Punctuation(Some('-')),
Equal => Punctuation(Some('=')),
BracketLeft => Punctuation(Some('[')),
BracketRight => Punctuation(Some(']')),
Backslash => Punctuation(Some('\\')),
Semicolon => Punctuation(Some(';')),
Quote => Punctuation(Some('\'')),
Comma => Punctuation(Some(',')),
Period => Punctuation(Some('.')),
Slash => Punctuation(Some('/')),
Backquote => Punctuation(Some('`')),
IntlBackslash | IntlRo | IntlYen | NumpadComma => Punctuation(None),
_ => return None,
})
}
/// A layout-named key's name.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum LayoutName {
Char(char),
/// None: `MapVirtualKeyA` returned 0 and the reference drops the key's message (`0x42da49`).
Dropped,
}
/// Each layout-named key's name under the active layout, as its platform last reported it: an
/// entry changes with the next message of its key after a layout switch, so a press is named under
/// the layout it was typed in. Empty where no winit feeds it (a headless run, a test).
#[derive(Resource, Default, Debug)]
pub(crate) struct LayoutNames(HashMap<KeyCode, LayoutName>);
impl LayoutNames {
/// The name of `key` if a layout names it, `None` for a key the fixed table names. A key never
/// reported (a headless run) keeps its US name.
///
/// Deviation: on the Mac and Linux a key its platform reports no character for keeps its US
/// name too, where the Mac reference drops it (`0x8a4aa`), and a Linux dead key not yet pressed
/// bare stays bindable.
pub(crate) fn name(&self, key: KeyCode) -> Option<LayoutName> {
let position = position(key)?;
Some(match self.0.get(&key) {
Some(name) => *name,
None => position.us().map_or(LayoutName::Dropped, LayoutName::Char),
})
}
/// Set what the layout names `key`; `None` clears it back to the US name.
pub(crate) fn set(&mut self, key: KeyCode, name: Option<LayoutName>) {
match name {
Some(name) => self.0.insert(key, name),
None => self.0.remove(&key),
};
}
}
/// Keeps [`LayoutNames`] current from the raw key messages (every shipped platform).
pub(super) fn plugin(app: &mut App) {
app.init_resource::<LayoutNames>();
// A headless build has no winit to register it.
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
app.add_message::<bevy::winit::RawWinitWindowEvent>()
.add_systems(PreUpdate, record_layout_names);
}
/// Names each key a raw key message reports, by its platform's rule. On the main thread, the
/// window's: Windows reads that thread's layout, and macOS's input-source calls want it.
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
fn record_layout_names(
mut raw: MessageReader<bevy::winit::RawWinitWindowEvent>,
mut names: ResMut<LayoutNames>,
#[cfg(target_os = "linux")] mut mods: Local<LinuxMods>,
_main_thread: bevy::ecs::system::NonSendMarker,
) {
use bevy::winit::converters::convert_physical_key_code;
use winit::event::WindowEvent;
#[cfg(target_os = "macos")]
let mut ascii_capable = None;
for ev in raw.read() {
let event = match &ev.event {
WindowEvent::KeyboardInput { event, .. } => event,
#[cfg(target_os = "linux")]
WindowEvent::ModifiersChanged(m) => {
mods.state = m.state();
continue;
}
_ => continue,
};
#[cfg(target_os = "linux")]
mods.key(event.physical_key, event.state.is_pressed());
let key = convert_physical_key_code(event.physical_key);
let Some(position) = position(key) else {
continue;
};
#[cfg(target_os = "windows")]
{
let _ = position;
names.set(key, windows::name(event.physical_key));
}
// The Mac and Linux keep their digits by position.
#[cfg(target_os = "macos")]
if !matches!(position, Position::Digit(_)) {
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
let capable = *ascii_capable.get_or_insert_with(mac::current_is_ascii_capable);
let current = one_char(&event.key_without_modifiers());
let name = mac_name(capable, current, || {
mac::ascii_capable_char(event.physical_key)
});
names.set(key, name);
}
#[cfg(target_os = "linux")]
if !matches!(position, Position::Digit(_)) {
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
let unshifted = event.key_without_modifiers();
if let Some(name) = linux_name(position, &unshifted, &event.logical_key, mods.bare()) {
names.set(key, name);
}
}
}
}
/// The one printable character a winit key stands for.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
fn one_char(key: &winit::keyboard::Key) -> Option<char> {
let winit::keyboard::Key::Character(s) = key else {
return None;
};
let mut chars = s.chars();
chars
.next()
.filter(|c| chars.next().is_none() && !c.is_control() && !c.is_whitespace())
}
/// The reference's name for the virtual key `vk`, given `MapVirtualKeyExW(vk, MAPVK_VK_TO_CHAR)`;
/// `None` for a key `0x42d800`'s fixed table names.
///
/// Deviation: a character above U+00FF names the key by itself. The reference's `MapVirtualKeyA`
/// returns an ANSI code-page byte, which `0x42da4b` drops past `0xff` and the namer's UTF-8
/// encoder (`0x41abb0`) otherwise reads as Latin-1, so such a key is mislabelled or dropped; named
/// by what it types, it stays bindable and readable.
#[cfg(any(target_os = "windows", test))]
fn windows_name(vk: u32, to_char: u32) -> Option<LayoutName> {
match vk {
// `VK_A`-`VK_Z`, named by their letter, and `VK_0`-`VK_9`, kept as themselves.
0x41..=0x5a | 0x30..=0x39 => Some(LayoutName::Char(char::from(vk as u8))),
_ if fixed_vk(vk) => None,
// The dynamic arm: `and eax,0xffff` (`0x42da42`) drops the dead-key bit, and 0 drops the
// key (`0x42da49`). U+F000-U+F002 are a layout table's `WCH_NONE`, `WCH_DEAD` and
// `WCH_LGTR` markers, no character, should the call ever return one.
_ => Some(match char::from_u32(to_char & 0xffff) {
Some(c)
if !c.is_control()
&& !c.is_whitespace()
&& !('\u{f000}'..='\u{f002}').contains(&c) =>
{
LayoutName::Char(c)
}
_ => LayoutName::Dropped,
}),
}
}
/// `0x42d800`'s fixed table, read from its byte remap at `0x42dafc` and jump table at `0x42da60`:
/// the keys it names itself, never by character. F1-F12 take their own arm (`0x42d800`).
#[cfg(any(target_os = "windows", test))]
fn fixed_vk(vk: u32) -> bool {
matches!(
vk,
0x08 | 0x09
| 0x0d
| 0x10..=0x14
| 0x1b
| 0x20..=0x28
| 0x2c..=0x2e
| 0x60..=0x6b
| 0x6d..=0x7b
| 0x90
| 0x91
)
}
#[cfg(target_os = "windows")]
mod windows {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
GetKeyboardLayout, MapVirtualKeyExW, MAPVK_VK_TO_CHAR, MAPVK_VSC_TO_VK_EX,
};
/// The reference's own path for the key at `physical`: the virtual key the window thread's
/// layout gives its scancode, then that key's name (`super::windows_name`).
pub(super) fn name(physical: winit::keyboard::PhysicalKey) -> Option<super::LayoutName> {
use winit::platform::scancode::PhysicalKeyExtScancode;
let scancode = physical.to_scancode()?;
// SAFETY: plain Win32 calls with no pointers. `GetKeyboardLayout(0)` is the calling
// thread's layout; the caller runs on the main thread, the window's, so it is the layout
// the window's key messages were made under.
let (vk, to_char) = unsafe {
let hkl = GetKeyboardLayout(0);
let vk = MapVirtualKeyExW(scancode, MAPVK_VSC_TO_VK_EX, hkl);
(vk, MapVirtualKeyExW(vk, MAPVK_VK_TO_CHAR, hkl))
};
if vk == 0 {
return None;
}
super::windows_name(vk, to_char)
}
}
/// The Mac name of a letter or punctuation key: the layout's own unshifted character, as
/// `KeyTranslate` gives the reference, or none.
///
/// Deviation: a layout that is not ASCII-capable (Hebrew, Greek, Russian, Arabic, Thai) names its
/// keys through the ASCII-capable layout macOS pairs with it, as macOS names its own shortcuts. The
/// reference reads a non-Roman `KCHR`'s byte as MacRoman (`0x8a40f`), which names no real key.
///
/// Deviation: a character above U+00FF (Czech `ě`, Polish `ł`, Turkish `ş`) names the key by
/// itself. The reference reads `KeyTranslate`'s byte as MacRoman (`0x8a40f`) and keeps it only if
/// it converts to Latin-1 (`CFStringGetBytes`, `0x8a46b`, stored at `0x8a482`), else leaves the
/// slot `-1`, which `0x8a4aa` drops, so such a key is mislabelled or dropped; named by what it
/// types, it stays bindable and readable.
#[cfg(any(target_os = "macos", test))]
fn mac_name(
ascii_capable: bool,
current: Option<char>,
ascii_layout_char: impl FnOnce() -> Option<char>,
) -> Option<LayoutName> {
let c = if ascii_capable {
current
} else {
ascii_layout_char()
};
c.map(LayoutName::Char)
}
#[cfg(target_os = "macos")]
mod mac {
use std::ffi::c_void;
use std::os::raw::c_ulong;
type CFTypeRef = *const c_void;
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
fn CFRelease(cf: CFTypeRef);
fn CFDataGetBytePtr(data: CFTypeRef) -> *const u8;
fn CFBooleanGetValue(boolean: CFTypeRef) -> u8;
}
#[link(name = "Carbon", kind = "framework")]
extern "C" {
static kTISPropertyInputSourceIsASCIICapable: CFTypeRef;
static kTISPropertyUnicodeKeyLayoutData: CFTypeRef;
fn TISCopyCurrentKeyboardLayoutInputSource() -> CFTypeRef;
fn TISCopyCurrentASCIICapableKeyboardLayoutInputSource() -> CFTypeRef;
fn TISGetInputSourceProperty(source: CFTypeRef, key: CFTypeRef) -> CFTypeRef;
fn LMGetKbdType() -> u8;
fn UCKeyTranslate(
layout: *const u8,
virtual_key_code: u16,
key_action: u16,
modifier_key_state: u32,
keyboard_type: u32,
key_translate_options: u32,
dead_key_state: *mut u32,
max_string_length: c_ulong,
actual_string_length: *mut c_ulong,
unicode_string: *mut u16,
) -> i32;
}
const K_UC_KEY_ACTION_DISPLAY: u16 = 3;
const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK: u32 = 1;
/// Is the current keyboard layout one macOS types shortcuts with directly?
pub(super) fn current_is_ascii_capable() -> bool {
// SAFETY: the copied source is released here; the property is a borrowed CFBoolean.
unsafe {
let source = TISCopyCurrentKeyboardLayoutInputSource();
if source.is_null() {
return true;
}
let value = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsASCIICapable);
let capable = value.is_null() || CFBooleanGetValue(value) != 0;
CFRelease(source);
capable
}
}
/// The unshifted character the ASCII-capable layout macOS pairs with the current input
/// source makes on `physical`, dead keys off.
pub(super) fn ascii_capable_char(physical: winit::keyboard::PhysicalKey) -> Option<char> {
use winit::platform::scancode::PhysicalKeyExtScancode;
let keycode = u16::try_from(physical.to_scancode()?).ok()?;
let mut buf = [0u16; 4];
let mut len: c_ulong = 0;
// SAFETY: the copied source owns the layout bytes and is released after the translate;
// `buf` and `len` are valid for the length passed.
let status = unsafe {
let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource();
if source.is_null() {
return None;
}
let data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData);
let status = if data.is_null() {
-1
} else {
let mut dead = 0u32;
UCKeyTranslate(
CFDataGetBytePtr(data),
keycode,
K_UC_KEY_ACTION_DISPLAY,
0,
u32::from(LMGetKbdType()),
K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK,
&mut dead,
buf.len() as c_ulong,
&mut len,
buf.as_mut_ptr(),
)
};
CFRelease(source);
status
};
if status != 0 {
return None;
}
let units = buf.get(..usize::try_from(len).ok()?)?;
let mut chars = char::decode_utf16(units.iter().copied());
let c = chars.next()?.ok()?;
(chars.next().is_none() && !c.is_control() && !c.is_whitespace()).then_some(c)
}
}
/// The modifiers a Linux key press is made under, from the raw key stream. winit's
/// `ModifiersState` has no AltGr, so the right Alt key is followed by its own messages: an AltGr
/// press on a dead key types its AltGr level, never the key's own accent.
#[cfg(any(target_os = "linux", test))]
#[derive(Default)]
struct LinuxMods {
state: winit::keyboard::ModifiersState,
alt_right: bool,
}
#[cfg(any(target_os = "linux", test))]
impl LinuxMods {
/// Follow the right Alt key through a key message.
fn key(&mut self, physical: winit::keyboard::PhysicalKey, pressed: bool) {
use winit::keyboard::{KeyCode, PhysicalKey};
if physical == PhysicalKey::Code(KeyCode::AltRight) {
self.alt_right = pressed;
}
}
/// No modifier held, AltGr included.
fn bare(&self) -> bool {
self.state.is_empty() && !self.alt_right
}
}
/// The Linux name of a letter or punctuation key, `None` to keep what the key had. No 1.12 client
/// ran on Linux, so this is the closest consistent rule: a letter position takes an ASCII letter or
/// ASCII punctuation the layout types there, and anything else there (another script's letter, an
/// accented letter, a mark) keeps its US letter, as Windows keeps `VK_A`-`VK_Z` there on nearly
/// every such layout; any other key takes its character. A dead keysym has no character
/// (`key_without_modifiers` is `Unidentified`), so it takes the dead key's own character from a
/// press with no modifier held, and otherwise keeps what it had.
#[cfg(any(target_os = "linux", test))]
fn linux_name(
position: Position,
unshifted: &winit::keyboard::Key,
logical: &winit::keyboard::Key,
bare: bool,
) -> Option<Option<LayoutName>> {
use winit::keyboard::Key;
let c = match (unshifted, logical) {
(Key::Unidentified(_), Key::Dead(Some(c))) if bare => Some(*c),
(Key::Unidentified(_), _) => return None,
_ => one_char(unshifted),
};
Some(c.map(|c| {
LayoutName::Char(match position {
Position::Letter(us) if !(c.is_ascii_alphabetic() || c.is_ascii_punctuation()) => us,
_ => c,
})
}))
}
#[cfg(test)]
mod tests {
use super::*;
/// A layout key's name under `names`, its ASCII letters folded up as the lookup folds them.
fn named(names: &LayoutNames, k: KeyCode) -> Option<char> {
match names.name(k) {
Some(LayoutName::Char(c)) => Some(c.to_ascii_uppercase()),
_ => None,
}
}
const VK_OEM_1: u32 = 0xba;
const VK_OEM_COMMA: u32 = 0xbc;
const VK_OEM_3: u32 = 0xc0;
const VK_OEM_6: u32 = 0xdd;
const VK_OEM_7: u32 = 0xde;
/// Windows: the reference's path on rows of the layouts' own tables (the key, its virtual
/// key, its unshifted character), as `MapVirtualKeyExW` returns them.
#[test]
fn windows_names_a_key_by_its_virtual_key_as_the_reference_does() {
let vk = |c: u8| u32::from(c);
let rows: &[(&str, KeyCode, u32, u32, char)] = &[
// Hebrew: the W key types `'` but is `VK_W`; Q types `/` and is `VK_Q`.
("hebrew W", KeyCode::KeyW, vk(b'W'), '\''.into(), 'W'),
("hebrew Q", KeyCode::KeyQ, vk(b'Q'), '/'.into(), 'Q'),
("greek Q", KeyCode::KeyQ, vk(b'Q'), ';'.into(), 'Q'),
("lithuanian Q", KeyCode::KeyQ, vk(b'Q'), 'ą'.into(), 'Q'),
("lithuanian W", KeyCode::KeyW, vk(b'W'), 'ž'.into(), 'W'),
("turkish ı", KeyCode::KeyI, vk(b'I'), 'ı'.into(), 'I'),
// Turkish Q's `VK_OEM_7` types `i`: one name with `I`, as the lookup folds it.
("turkish i", KeyCode::Quote, VK_OEM_7, 'i'.into(), 'I'),
// Bulgarian: `VK_Q` on the Period key, `VK_OEM_COMMA` on Q.
("bulgarian .", KeyCode::Period, vk(b'Q'), 'л'.into(), 'Q'),
("bulgarian Q", KeyCode::KeyQ, VK_OEM_COMMA, ','.into(), ','),
// Hungarian: `VK_0` on Backquote, `VK_OEM_3` `ö` on the 0 key.
("hungarian `", KeyCode::Backquote, vk(b'0'), '0'.into(), '0'),
("hungarian 0", KeyCode::Digit0, VK_OEM_3, 'ö'.into(), 'ö'),
// Dvorak's Q position is `VK_OEM_7`, `'`.
("dvorak Q", KeyCode::KeyQ, VK_OEM_7, '\''.into(), '\''),
("us ;", KeyCode::Semicolon, VK_OEM_1, ';'.into(), ';'),
// A dead key's bit (`0x80000000`) is masked off: French `^`.
("french ^", KeyCode::BracketLeft, VK_OEM_6, 0x8000_005e, '^'),
// Above U+00FF the key is named by what it types (the deviation).
("russian ж", KeyCode::Semicolon, VK_OEM_1, 'ж'.into(), 'ж'),
// The ISO key, German `VK_OEM_102`; the keypad comma, Brazilian ABNT's `VK_ABNT_C2`
// and a `VK_SEPARATOR` keypad.
("german <", KeyCode::IntlBackslash, 0xe2, '<'.into(), '<'),
("abnt keypad .", KeyCode::NumpadComma, 0xc2, '.'.into(), '.'),
("keypad ,", KeyCode::NumpadComma, 0x6c, ','.into(), ','),
];
let mut names = LayoutNames::default();
for &(what, key, vk, to_char, expected) in rows {
names.set(key, windows_name(vk, to_char));
assert_eq!(named(&names, key), Some(expected), "{what}");
}
// `MapVirtualKeyA` returning 0 drops the key (`0x42da49`): Japanese 106's
// `VK_OEM_AUTO` on Backquote.
assert_eq!(windows_name(0xf3, 0), Some(LayoutName::Dropped));
names.set(KeyCode::Backquote, windows_name(0xf3, 0));
assert_eq!(names.name(KeyCode::Backquote), Some(LayoutName::Dropped));
// A layout table's `WCH_NONE`/`WCH_DEAD`/`WCH_LGTR` marker is no character.
for marker in 0xf000..=0xf002 {
assert_eq!(
windows_name(VK_OEM_1, marker),
Some(LayoutName::Dropped),
"{marker:#x}"
);
}
// A key the fixed table names is no layout key's: F1, Space, Numpad 0.
for vk in [0x70, 0x20, 0x60] {
assert_eq!(windows_name(vk, 0), None, "{vk:#x}");
}
}
/// The fixed table is `0x42d800`'s, decoded from its remap bytes: the 60 entries less the
/// digits and F1-F12, which take their own arms.
#[test]
fn the_fixed_virtual_keys_are_the_translators_table() {
let fixed: Vec<u32> = (0..=0xffu32)
.filter(|&vk| fixed_vk(vk) && !(0x70..=0x7b).contains(&vk))
.collect();
assert_eq!(
fixed,
[
0x08, 0x09, 0x0d, 0x10, 0x11, 0x12, 0x13, 0x14, 0x1b, 0x20, 0x21, 0x22, 0x23, 0x24,
0x25, 0x26, 0x27, 0x28, 0x2c, 0x2d, 0x2e, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6d, 0x6e, 0x6f, 0x90, 0x91,
]
);
}
/// macOS: a Latin layout's own character; a layout that is not ASCII-capable names through
/// its ASCII-capable partner.
#[test]
fn a_mac_layout_that_is_not_ascii_capable_names_through_its_ascii_partner() {
// Hebrew: W types `'`; its U.S. partner types `w` there.
assert_eq!(
mac_name(false, Some('\''), || Some('w')),
Some(LayoutName::Char('w'))
);
// French: W types `z`, and the partner is never asked.
assert_eq!(
mac_name(true, Some('z'), || panic!(
"an ASCII-capable layout names itself"
)),
Some(LayoutName::Char('z'))
);
// No character: the US name stands in (the fallback deviation).
let mut names = LayoutNames::default();
names.set(KeyCode::KeyW, mac_name(true, None, || None));
assert_eq!(named(&names, KeyCode::KeyW), Some('W'));
}
/// Linux: a letter position keeps its US letter unless the layout types an ASCII letter or
/// ASCII punctuation there; any other key takes its character.
#[test]
fn linux_names_a_letter_position_by_its_ascii_character_or_its_us_letter() {
use winit::keyboard::{Key, NativeKey};
let ch = |c: &str| Key::Character(c.into());
for (key, typed, expected) in [
(KeyCode::KeyW, "z", 'z'),
(KeyCode::KeyM, ",", ','),
(KeyCode::KeyQ, "'", '\''),
(KeyCode::KeyQ, "ą", 'Q'),
(KeyCode::KeyI, "ı", 'I'),
(KeyCode::KeyW, "ц", 'W'),
// Thai's tone mark on H, INSCRIPT's virama on D.
(KeyCode::KeyH, "\u{e48}", 'H'),
(KeyCode::KeyD, "\u{94d}", 'D'),
(KeyCode::Semicolon, "ж", 'ж'),
(KeyCode::Quote, "ù", 'ù'),
] {
let got = linux_name(position(key).unwrap(), &ch(typed), &ch(typed), true);
assert_eq!(
got,
Some(Some(LayoutName::Char(expected))),
"{key:?} typing {typed}"
);
}
// A dead keysym: its character from a bare press, kept through a modified one.
let none = Key::Unidentified(NativeKey::Unidentified);
let bracket = position(KeyCode::BracketLeft).unwrap();
assert_eq!(
linux_name(bracket, &none, &Key::Dead(Some('^')), true),
Some(Some(LayoutName::Char('^')))
);
assert_eq!(
linux_name(bracket, &none, &Key::Dead(Some('¨')), false),
None
);
}
#[test]
fn a_layout_switch_renames_a_key_at_its_next_message() {
let mut names = LayoutNames::default();
names.set(KeyCode::KeyW, Some(LayoutName::Char('z')));
assert_eq!(named(&names, KeyCode::KeyW), Some('Z'));
names.set(KeyCode::KeyW, Some(LayoutName::Char('w')));
assert_eq!(
named(&names, KeyCode::KeyW),
Some('W'),
"back on a US layout"
);
// A layout that makes no character there falls back to the US name.
names.set(KeyCode::KeyQ, Some(LayoutName::Char('a')));
names.set(KeyCode::KeyQ, None);
assert_eq!(named(&names, KeyCode::KeyQ), Some('Q'));
// Digits and punctuation name themselves until a layout says otherwise; an ISO key no
// layout reported has no name.
assert_eq!(named(&names, KeyCode::Digit1), Some('1'));
assert_eq!(named(&names, KeyCode::Quote), Some('\''));
for key in [KeyCode::IntlBackslash, KeyCode::NumpadComma] {
assert_eq!(names.name(key), Some(LayoutName::Dropped), "{key:?}");
}
assert_eq!(names.name(KeyCode::F1), None, "the fixed table's");
}
/// Linux: AltGr counts as a modifier for a dead key's bare press, though winit's
/// `ModifiersState` has no AltGr; AltGr on a dead-key position keeps the key's own accent.
#[test]
fn linux_altgr_is_no_bare_press() {
use winit::keyboard::{Key, KeyCode, ModifiersState, NativeKey, PhysicalKey};
let none = Key::Unidentified(NativeKey::Unidentified);
let bracket = position(bevy::input::keyboard::KeyCode::BracketLeft).unwrap();
let mut mods = LinuxMods::default();
assert!(mods.bare());
mods.key(PhysicalKey::Code(KeyCode::AltRight), true);
assert!(!mods.bare(), "AltGr held");
// AltGr on the French `^` key types `~`: the key keeps its `^`.
assert_eq!(
linux_name(bracket, &none, &Key::Dead(Some('~')), mods.bare()),
None
);
mods.key(PhysicalKey::Code(KeyCode::AltRight), false);
assert!(mods.bare(), "AltGr released");
mods.key(PhysicalKey::Code(KeyCode::KeyQ), true);
assert!(mods.bare(), "another key is no modifier");
mods.state = ModifiersState::SHIFT;
assert!(!mods.bare(), "Shift held");
}
}
@@ -0,0 +1,104 @@
//! What the binding functions called from Lua ([`BindingInput`]) leave for the engine. Every key
//! runs its command's `Bindings.xml` body, so this is the one way a key moves the player: in the
//! reference `MOVEFORWARD` runs `MoveForwardStart()` on the press and `MoveForwardStop()` on the
//! release, and both halves set and clear one bit of `[InputControl+4]` through `0x515090`; so a
//! Start with no Stop keeps moving, a Stop clears the bit whichever input set it, and a one-shot
//! fires once per call.
use benilla_ui::script::{BindingInput, FiredInput, HeldInput};
use super::BindingsState;
/// One engine input a binding function drives: a held bit of `[InputControl+4]` or a one-shot.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Input {
/// `MoveForwardStart`/`Stop`, bit `0x10`.
MoveForward,
/// `MoveBackwardStart`/`Stop`, bit `0x20`.
MoveBackward,
/// `TurnLeftStart`/`Stop`, bit `0x100`.
TurnLeft,
/// `TurnRightStart`/`Stop`, bit `0x200`.
TurnRight,
/// `StrafeLeftStart`/`Stop`, bit `0x40`.
StrafeLeft,
/// `StrafeRightStart`/`Stop`, bit `0x80`.
StrafeRight,
/// `TurnOrActionStart`/`Stop`, bit `0x1`.
TurnOrAction,
/// `CameraOrSelectOrMoveStart`/`Stop`, bit `0x2`.
CameraOrSelectOrMove,
/// `Jump()`.
Jump,
/// `ToggleRun()`, the walk toggle.
ToggleRun,
/// `ToggleAutoRun()`.
ToggleAutoRun,
/// `SitOrStand()`.
SitOrStand,
/// `ToggleSheath()`.
ToggleSheath,
/// `CameraZoomIn([yards])`, its yards in [`BindingsState::amount`].
CameraZoomIn,
/// `CameraZoomOut([yards])`, its yards in [`BindingsState::amount`].
CameraZoomOut,
}
/// The bit a Start or Stop names.
fn held_input(input: HeldInput) -> Input {
match input {
HeldInput::MoveForward => Input::MoveForward,
HeldInput::MoveBackward => Input::MoveBackward,
HeldInput::TurnLeft => Input::TurnLeft,
HeldInput::TurnRight => Input::TurnRight,
HeldInput::StrafeLeft => Input::StrafeLeft,
HeldInput::StrafeRight => Input::StrafeRight,
HeldInput::TurnOrAction => Input::TurnOrAction,
HeldInput::CameraOrSelectOrMove => Input::CameraOrSelectOrMove,
}
}
/// The one-shot a fire names, and its amount: a zoom's yards, else 1.0.
fn fired_input(input: FiredInput) -> (Input, f32) {
match input {
FiredInput::Jump => (Input::Jump, 1.0),
FiredInput::ToggleRun => (Input::ToggleRun, 1.0),
FiredInput::ToggleAutoRun => (Input::ToggleAutoRun, 1.0),
FiredInput::SitOrStand => (Input::SitOrStand, 1.0),
FiredInput::ToggleSheath => (Input::ToggleSheath, 1.0),
FiredInput::CameraZoomIn(yards) => (Input::CameraZoomIn, yards),
FiredInput::CameraZoomOut(yards) => (Input::CameraZoomOut, yards),
}
}
impl BindingsState {
/// Apply Lua's binding calls, in call order.
pub(super) fn apply_script_input(&mut self, inputs: Vec<BindingInput>) {
for input in inputs {
match input {
// The set helper `0x514840` early-outs on a bit already set, so a Start on a held
// bit is no new edge.
BindingInput::Start(held) => {
let i = held_input(held);
if !self.pressed(i) {
self.just.push(i);
self.held.push(i);
}
}
// `0x514b70` clears the bit whoever set it, so a second key still down on the
// same command stops too, and its release is a no-op second Stop.
BindingInput::Stop(held) => {
let i = held_input(held);
self.held.retain(|&h| h != i);
}
// A one-shot is its own press edge.
BindingInput::Fire(fired) => {
let (i, amount) = fired_input(fired);
self.fired.push(i);
self.just.push(i);
self.amounts.push((i, amount));
}
}
}
}
}
+86 -82
View File
@@ -13,29 +13,48 @@
//! Deviation: the reference saves a full snapshot (`bindings-cache.wtf`); a diff is stored
//! because a full snapshot would leave every command added later unbound for a saved file.
use std::collections::HashMap;
use benilla_ui::script::keybind::KeyBinding;
use super::commands::SPECS;
/// A key list grouped by command, in first-appearance order; names compare case-insensitively.
fn by_command(list: &[KeyBinding]) -> Vec<(String, Vec<String>)> {
let mut out: Vec<(String, Vec<String>)> = Vec::new();
for (key, command) in list {
match out
.iter_mut()
.find(|(c, _)| c.eq_ignore_ascii_case(command))
{
Some((_, keys)) => keys.push(key.clone()),
None => out.push((command.clone(), vec![key.clone()])),
}
}
out
}
/// Serialize the live `(command, keys)` table, in registry order, as the diff file.
pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)]) -> String {
let defaults: HashMap<&str, Vec<&str>> = SPECS
.iter()
.map(|s| (s.name, [s.d1, s.d2].into_iter().flatten().collect()))
.collect();
/// Serialize the live `(command, keys)` table (`keybind_snapshot`) as the diff file against the
/// defaults (`WTF\DefaultBindings.wtf`): a line for each command whose keys differ, including a
/// defaulted command the live table left unbound.
pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)], defaults: &[KeyBinding]) -> String {
let defaults = by_command(defaults);
let default_of = |name: &str| {
defaults
.iter()
.find(|(c, _)| c.eq_ignore_ascii_case(name))
.map_or(&[][..], |(_, k)| k.as_slice())
};
let mut out = String::from("# benilla key bindings — diff vs defaults\n");
for (name, keys) in snapshot {
let is_default = defaults
.get(name.as_str())
.is_some_and(|d| d.iter().copied().eq(keys.iter().map(String::as_str)));
if is_default {
let unlisted = defaults
.iter()
.filter(|(c, _)| !snapshot.iter().any(|(n, _)| n.eq_ignore_ascii_case(c)))
.map(|(c, _)| (c.clone(), Vec::new()));
for (name, keys) in snapshot.iter().cloned().chain(unlisted) {
if default_of(&name) == keys.as_slice() {
continue;
}
out.push_str("bind ");
out.push_str(name);
out.push_str(&name);
for k in keys {
out.push(' ');
out.push_str(k);
out.push_str(&k);
}
out.push('\n');
}
@@ -64,35 +83,14 @@ pub(crate) fn from_diff(text: &str) -> Vec<(String, Vec<String>)> {
out
}
/// Resolve a diff into the full key table: defaults, then each override in file order stealing
/// its keys from every other command, so one key always maps to one command.
pub(crate) fn resolve(diff: &[(String, Vec<String>)]) -> Vec<(String, Vec<String>)> {
let mut table: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
(
s.name.to_string(),
[s.d1, s.d2]
.into_iter()
.flatten()
.map(str::to_owned)
.collect(),
)
})
.collect();
/// Resolve a diff into the full key table: the defaults, then each override in file order
/// replacing its command's keys and stealing each key from every other command, so one key always
/// maps to one command.
pub(crate) fn resolve(diff: &[(String, Vec<String>)], defaults: &[KeyBinding]) -> Vec<KeyBinding> {
let mut table = defaults.to_vec();
for (name, keys) in diff {
// Steal each key from wherever it currently sits, then install the command's list.
for k in keys {
for (_, held) in table.iter_mut() {
held.retain(|h| h != k);
}
}
match table.iter_mut().find(|(n, _)| n == name) {
Some((_, slot)) => *slot = keys.clone(),
// A name not in SPECS is an addon's command, registered later at world entry: keep
// its row so the binding has its keys to restore.
None => table.push((name.clone(), keys.clone())),
}
table.retain(|(k, c)| !c.eq_ignore_ascii_case(name) && !keys.contains(k));
table.extend(keys.iter().map(|k| (k.clone(), name.clone())));
}
table
}
@@ -101,65 +99,73 @@ pub(crate) fn resolve(diff: &[(String, Vec<String>)]) -> Vec<(String, Vec<String
mod tests {
use super::*;
fn defaults() -> Vec<KeyBinding> {
[
("W", "MOVEFORWARD"),
("UP", "MOVEFORWARD"),
("Q", "STRAFELEFT"),
("SPACE", "JUMP"),
("NUMPAD0", "JUMP"),
("Z", "TOGGLESHEATH"),
]
.into_iter()
.map(|(k, c)| (k.to_string(), c.to_string()))
.collect()
}
fn keys(table: &[KeyBinding], command: &str) -> Vec<String> {
table
.iter()
.filter(|(_, c)| c == command)
.map(|(k, _)| k.clone())
.collect()
}
#[test]
fn the_diff_round_trips_and_defaults_stay_silent() {
// All defaults: a header-only file.
let snapshot: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
(
s.name.to_string(),
[s.d1, s.d2]
.into_iter()
.flatten()
.map(str::to_owned)
.collect(),
)
})
.collect();
let text = to_diff(&snapshot);
let snapshot = by_command(&defaults());
let text = to_diff(&snapshot, &defaults());
assert_eq!(text.lines().count(), 1, "defaults produce no bind lines");
// Move JUMP to F (unbinding SPACE/NUMPAD0), unbind TOGGLESHEATH entirely.
let mut moved = snapshot.clone();
// Move JUMP to F (unbinding SPACE/NUMPAD0), unbind TOGGLESHEATH entirely, and leave
// STRAFELEFT out of the snapshot with no keys (a defaulted command no file declared).
let mut moved: Vec<(String, Vec<String>)> = snapshot
.into_iter()
.filter(|(n, _)| n != "STRAFELEFT")
.collect();
moved.iter_mut().find(|(n, _)| n == "JUMP").unwrap().1 = vec!["F".into()];
moved
.iter_mut()
.find(|(n, _)| n == "TOGGLESHEATH")
.unwrap()
.1 = vec![];
let text = to_diff(&moved);
let text = to_diff(&moved, &defaults());
assert!(text.contains("bind JUMP F\n"));
assert!(
text.contains("bind TOGGLESHEATH\n"),
"unbound = a bare line"
);
assert!(
text.contains("bind STRAFELEFT\n"),
"a defaulted command the snapshot lacks is unbound too"
);
assert!(
!text.contains("MOVEFORWARD"),
"untouched commands stay absent"
);
let parsed = from_diff(&text);
let resolved = resolve(&parsed);
let get = |n: &str| {
resolved
.iter()
.find(|(name, _)| name == n)
.map(|(_, k)| k.clone())
.unwrap()
};
assert_eq!(get("JUMP"), vec!["F".to_string()]);
assert!(get("TOGGLESHEATH").is_empty());
assert_eq!(get("MOVEFORWARD"), vec!["W".to_string(), "UP".to_string()]);
let resolved = resolve(&from_diff(&text), &defaults());
assert_eq!(keys(&resolved, "JUMP"), ["F"]);
assert!(keys(&resolved, "TOGGLESHEATH").is_empty());
assert_eq!(keys(&resolved, "MOVEFORWARD"), ["W", "UP"]);
}
#[test]
fn resolve_steals_across_commands_even_without_a_line_for_the_victim() {
let resolved = resolve(&[("JUMP".to_string(), vec!["W".to_string()])]);
let fwd = &resolved.iter().find(|(n, _)| n == "MOVEFORWARD").unwrap().1;
assert_eq!(fwd, &vec!["UP".to_string()]);
let jump = &resolved.iter().find(|(n, _)| n == "JUMP").unwrap().1;
assert_eq!(jump, &vec!["W".to_string()]);
let resolved = resolve(&[("JUMP".to_string(), vec!["W".to_string()])], &defaults());
assert_eq!(keys(&resolved, "MOVEFORWARD"), ["UP"]);
assert_eq!(keys(&resolved, "JUMP"), ["W"]);
}
#[test]
@@ -173,12 +179,10 @@ mod tests {
]
);
// An unknown command (an addon's, registered later) is kept, and still steals its key.
let resolved = resolve(&parsed);
let bogus = &resolved.iter().find(|(n, _)| n == "BOGUSCMD").unwrap().1;
assert_eq!(bogus, &vec!["Q".to_string()]);
let strafe = &resolved.iter().find(|(n, _)| n == "STRAFELEFT").unwrap().1;
let resolved = resolve(&parsed, &defaults());
assert_eq!(keys(&resolved, "BOGUSCMD"), ["Q"]);
assert!(
strafe.is_empty(),
keys(&resolved, "STRAFELEFT").is_empty(),
"Q was stolen by the unknown command's line"
);
}
+23 -12
View File
@@ -100,7 +100,6 @@ pub(super) fn seed_ui_fixture(
mut names: ResMut<crate::names::NameCache>,
icons: Option<Res<crate::entities::ItemDisplays>>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut vplates: ResMut<crate::vplates::VPlateMode>,
mut selection: ResMut<crate::target::Selection>,
mut player: ResMut<crate::player::Player>,
// A nested tuple, one param under Bevy's 16-param cap.
@@ -1003,7 +1002,8 @@ pub(super) fn seed_ui_fixture(
Visibility::default(),
))
.id();
vplates.enemies = true;
// As the V binding does, so FrameXML's replay at `PLAYER_ENTERING_WORLD` agrees.
set_enemy_plates(script.as_deref(), true);
// The wolf is the target, so its plate draws lit with the target ring.
selection.target = Some(wolf);
selection.guid = Some(WOLF_GUID);
@@ -1095,17 +1095,17 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::KeyBindings => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The Keybindings page: the real command registry and CVar set first, then the live
// open path, with Movement expanded to show a header row and the default bindings.
// The Keybindings page over the commands the load registered: the real CVar set
// first, then the live open path, with Movement expanded to show a header row and the
// default bindings.
script.register_cvars(crate::cvars::registered_pairs());
script.register_bindings(&crate::bindings::registry_commands());
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); \
BenillaOptionsFrameCategoryListRowKeybindings:Click(); \
KeyBindings_ExpandSection(1, true); KeyBindingsPage_Update()",
BenillaKeyBindings_ExpandSection(1, true); BenillaKeyBindingsPage_Update()",
) {
warn!("capture: ui-keybindings seed failed: {e}");
}
@@ -1364,7 +1364,7 @@ pub(super) fn seed_ui_fixture(
Visibility::default(),
));
// A plated unit draws no floating name, so enemy plates go off.
vplates.enemies = false;
set_enemy_plates(script.as_deref(), false);
}
// The lighting matrix: one spawn with a streamed entity's component set, anonymous and
// with plates off, so no glyph rides over the body.
@@ -1408,7 +1408,7 @@ pub(super) fn seed_ui_fixture(
));
}
}
vplates.enemies = false;
set_enemy_plates(script.as_deref(), false);
}
}
}
@@ -1429,9 +1429,7 @@ fn seed_cooldown_filmstrip(
) {
// The clock first: `set_container` stores each triple against it, and
// `GetContainerItemCooldown`'s expiry guard reads it.
if let Err(e) = script.run(&format!("__benilla_now = {COOLDOWN_NOW_S}")) {
warn!("capture: ui-cooldown failed to pin the session clock: {e}");
}
script.set_now(COOLDOWN_NOW_S);
const DISP_STONE: u32 = 6418;
let texture = icons
.and_then(|i| i.catalog.get(DISP_STONE))
@@ -1747,3 +1745,16 @@ pub(super) fn seed_perf_crowd(
));
}
}
/// Turn enemy plates on or off as the V binding does, the verb and FrameXML's global together,
/// so FrameXML's own replay agrees.
fn set_enemy_plates(script: Option<&benilla_ui::script::UiScript>, on: bool) {
let lua = if on {
"NAMEPLATES_ON = 1; ShowNameplates()"
} else {
"NAMEPLATES_ON = nil; HideNameplates()"
};
if let Some(Err(e)) = script.map(|s| s.run(lua)) {
warn!("nameplates: {e}");
}
}
@@ -431,6 +431,7 @@ fn auction_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -781,6 +782,7 @@ fn auction_probe(
kind: ChatKind::Say,
target: None,
text: format!(".additem {ITEM_ENTRY} {ITEM_COUNT}"),
language: None,
});
probe.phase = Phase::EnsureItem {
since: now,
@@ -202,6 +202,7 @@ fn bank_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -276,6 +277,7 @@ fn bank_probe(
kind: ChatKind::Say,
target: None,
text: format!(".additem {ITEM_ENTRY} 1"),
language: None,
});
probe.phase = Phase::EnsureItem {
since: now,
@@ -395,6 +397,7 @@ fn bank_probe(
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
text: format!(".modify money {grant}"),
language: None,
target: None,
});
probe.phase = Phase::BuySlot {
@@ -482,6 +485,7 @@ fn bank_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {by} {bz} 0"),
language: None,
});
probe.phase = Phase::Refusal {
since: now,
@@ -291,6 +291,7 @@ fn gm(net: &NetCommands, text: impl Into<String>) {
kind: ChatKind::Say,
target: None,
text: text.into(),
language: None,
});
}
@@ -87,6 +87,7 @@ fn bg_queue_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: ".revive".to_string(),
language: None,
});
// A body under the bracket floor is refused at the hello, with no list to join from.
let level = store.0.unit_level().unwrap_or(0);
@@ -96,6 +97,7 @@ fn bg_queue_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".levelup {}", QUEUE_LEVEL - level),
language: None,
});
}
let [x, y, z] = BATTLEMASTER_AT;
@@ -104,6 +106,7 @@ fn bg_queue_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Hopped { sent_at: now };
}
@@ -235,6 +235,7 @@ fn binder_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {INNKEEPER_MAP}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -239,6 +239,7 @@ fn book_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -371,6 +371,7 @@ fn charter_probe(
kind: ChatKind::Say,
target: None,
text: format!(".modify money {FUND_COPPER}"),
language: None,
});
info!(
"PROBE_CHARTER: 0 (precheck) — funding the body with {FUND_COPPER} copper so the \
@@ -710,7 +711,7 @@ fn charter_probe(
}
}
// ── Step 6: the item-use fork ───────────────────────────────────────────────────────
// A real bag right-click through `UseContainerItem`, so `drain_container_uses` reaches
// A real bag right-click through `UseContainerItem`, so `use_container_item` reaches
// `ItemUseRoute::ShowPetition`. A FAIL is either the fork missing the charter arm or
// `SMSG_PETITION_SHOW_SIGNATURES` never becoming `PETITION_SHOW`. No getter exposes the
// open charter's item guid, so its identity rests on no window being up before the click.
@@ -1046,6 +1047,7 @@ fn hop(probe: &mut CharterProbe, net: &NetCommands, now: f64) {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {REGISTRAR_MAP}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -200,6 +200,7 @@ fn chest_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
+3 -1
View File
@@ -126,6 +126,7 @@ fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase {
kind: ChatKind::Say,
target: None,
text: format!(".additem {entry} -1"),
language: None,
});
Phase::Cleanup { since: now }
}
@@ -197,6 +198,7 @@ fn clam_probe(
kind: ChatKind::Say,
target: None,
text: format!(".additem {entry}"),
language: None,
});
probe.phase = Phase::Stocking { sent_at: now };
}
@@ -321,7 +323,7 @@ fn clam_probe(
}
/// Right-click the clam through the live VM's `UseContainerItem`, so the real dispatcher
/// (`ui_items::drain::drain_container_uses`) picks the packet.
/// (`ui_items::ScriptItemUse::use_container_item`) picks the packet.
fn click(script: &mut UiScript, probe: &mut ClamProbe, round: u8) {
let slot = probe.slot;
info!("PROBE_CLAM: round {round} — UseContainerItem(0, {slot}) through the live VM");
@@ -134,6 +134,7 @@ fn crossing_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {}", m as i32),
language: None,
});
return;
}
@@ -167,6 +168,7 @@ fn crossing_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {} ", z + DROP_HEIGHT),
language: None,
});
probe.phase = Phase::Boarding {
boat: guid.0,
@@ -147,6 +147,7 @@ fn level_to(net: &NetCommands, from: u32, to: u32) {
kind: ChatKind::Say,
target: None,
text: format!(".levelup {delta}"),
language: None,
});
}
@@ -209,6 +210,7 @@ fn goquest_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -83,6 +83,7 @@ fn guard_poi_probe(
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
language: None,
});
probe.phase = Phase::Hopped { sent_at: now };
}
@@ -232,6 +232,7 @@ fn mail_probe(
kind: ChatKind::Say,
target: None,
text,
language: None,
});
}
probe.phase = Phase::Settling { sent_at: now };
@@ -1,7 +1,7 @@
//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it fires the
//! attack-nearest core ([`AttackNearestRequest`]) every few seconds, so the character fights
//! whatever is closest while `WOW_MOVE_TRACE=<path>` records the swing, impact and combat-text
//! timeline.
//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it presses the
//! player's Attack with nothing selected ([`AttackNearestRequest`]) every few seconds, so the
//! character acquires and fights whatever is nearest while `WOW_MOVE_TRACE=<path>` records the
//! swing, impact and combat-text timeline.
//!
//! Never run it unattended (`docs/METHOD.md`, "The local server"): it fights with no health
//! awareness and the character dies.
@@ -21,7 +21,7 @@ impl Plugin for ProbeMeleePlugin {
}
/// Every 3 s: swing at the selection (idempotent while auto-attacking, and it covers an attacker
/// the acquire core skips), or with none, run the acquire core.
/// the acquire skips), or with none, press Attack, which acquires.
fn melee_probe(
time: ProbeClock,
mut last: Local<f64>,
@@ -641,6 +641,7 @@ fn service_probe(
kind: ChatKind::Say,
target: None,
text: format!(".additem {SELL_ITEM_ENTRY} 1"),
language: None,
});
probe.phase = Phase::VendorArm { since: now, guid };
} else if now - since > WINDOW_TIMEOUT_SECS {
@@ -813,6 +814,7 @@ fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {}", leg.map),
language: None,
});
probe.polls = 0;
probe.phase = Phase::Hop {
@@ -837,6 +839,7 @@ fn next(probe: &mut ServiceProbe, net: &NetCommands, i: usize, now: f64) {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {VENDOR_MAP}"),
language: None,
});
probe.phase = Phase::VendorHop { sent_at: now };
}
@@ -192,6 +192,7 @@ fn stone_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
language: None,
});
probe.phase = Phase::Settling { sent_at: now };
}
@@ -251,6 +252,7 @@ fn stone_probe(
kind: ChatKind::Say,
target: None,
text: format!(".character level {LEVEL_BELOW}"),
language: None,
});
probe.phase = Phase::WaitLevel {
want: LEVEL_BELOW,
@@ -302,6 +304,7 @@ fn stone_probe(
kind: ChatKind::Say,
target: None,
text: format!(".character level {want}"),
language: None,
});
probe.phase = Phase::WaitLevel {
want,
@@ -170,6 +170,7 @@ fn taxi_probe(
kind: crate::net::ChatKind::Say,
target: None,
text,
language: None,
});
}
probe.phase = Phase::Hopped { sent_at: now };
@@ -173,6 +173,7 @@ fn vendor_swap_probe(
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {MAP}"),
language: None,
});
probe.phase = Phase::Settle { sent_at: now };
}
+5 -1
View File
@@ -141,8 +141,12 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"Tab" => KeyCode::Tab,
// TOGGLEAUTORUN's 1.12 default; with `WOW_PROBE_LOOK` it scripts a drive.
"NumLock" => KeyCode::NumLock,
// The nameplate toggle: bare `V` shows enemy plates, `Shift`+`V` friendly ones.
// The nameplate toggles: `V`, `Shift`+`V`, `Ctrl`+`V`.
"V" => KeyCode::KeyV,
// A window toggle (TOGGLEBACKPACK) and an action button (ACTIONBUTTON1), by their 1.12
// defaults.
"B" => KeyCode::KeyB,
"1" => KeyCode::Digit1,
// Free-fly is the dev chord (`Ctrl`+`Shift`) + `F` (`player.rs`); a held `Ctrl` is its ×5
// speed boost (`camera.rs`).
"F" => KeyCode::KeyF,
+4 -4
View File
@@ -41,8 +41,8 @@ pub(super) enum UiFixture {
Bag,
/// The cooldown sweep at sixteen phases in one still: each backpack slot's
/// `GetContainerItemCooldown` sits at its own fraction of one long cooldown, in reading order
/// (slot 1 renders top-left, `ContainerFrame_GenerateFrame` numbering backwards). The VM clock
/// `__benilla_now` is parked at a large value, so the settle's seconds are ~5e-4 of a phase.
/// (slot 1 renders top-left, `ContainerFrame_GenerateFrame` numbering backwards). The VM's
/// `GetTime` clock is parked at a large value, so the settle's seconds are ~5e-4 of a phase.
Cooldown,
/// The cooldown filmstrip with the pet bar's autocast shine beside it: `UI-AutoCastButton.m2`
/// is four additive emitters and no batch, sharing the tile atlas, so this asks whether its
@@ -106,8 +106,8 @@ pub(super) enum UiFixture {
OptionsDropdownList,
/// The Options window mid-search: "volume" lists the four volume sliders under the Audio head.
OptionsSearch,
/// The Options window's Keybindings page, Movement expanded, over the 1.12 default bindings and
/// GlobalStrings; the command registry registers in-fixture.
/// The Options window's Keybindings page, Movement expanded, over the stock commands and
/// default bindings the load read off the install, and GlobalStrings.
KeyBindings,
/// An overhead name with the river behind it: a named unit 25 yd out in the Elwynn river (the
/// `water-noon` camera). Deep water is opaque (`WATER_DEEP_ALPHA` 1.0), so a name sorted before
+4 -2
View File
@@ -340,8 +340,9 @@ fn create_input(
keys: Res<ButtonInput<KeyCode>>,
catalog: Option<Res<CharCreate>>,
mut sel: ResMut<CreateSelection>,
// The host pasteboard and the window handle its Wayland backend needs.
mut clipboard: NonSendMut<HostClipboard>,
// The host pasteboard and the window handle its Wayland backend needs, and the layout that
// names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
time: Res<Time>,
mut preview: ResMut<GluePreview>,
@@ -421,6 +422,7 @@ fn create_input(
&mut sel.name,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Letters,
+148 -135
View File
@@ -104,21 +104,20 @@ enum Hover {
Check(usize),
}
/// The screen's state; edits stage per character and only Okay writes the files.
/// The screen's state; a click writes the in-memory store and only Okay writes the files.
#[derive(Resource)]
pub(super) struct AddonsPanel {
pub(super) open: bool,
/// The installed list read at open, so a row's index is stable; the enable state is `staged`'s.
/// The installed list read at open, so a row's index is stable.
list: Vec<InstalledAddOn>,
/// The roster's realm, half of every enable file's `(realm, name)` key.
realm: String,
/// The roster's character names, the dropdown's entries after "All".
chars: Vec<String>,
/// `staged[c][i]` is character `c`'s bit for `list[i]`; with no characters, one anonymous
/// column that Okay does not write.
staged: Vec<Vec<bool>>,
/// What the files said at open; Okay writes only the columns that moved off it.
baseline: Vec<Vec<bool>>,
/// Every roster character's enable hash, which a click sets and Okay saves where dirty.
store: addons::EnableStore,
/// `bits[c][i]` is the query's answer for character `c` and `list[i]`, re-read from the store
/// after every change, so a character with no row follows the others' edits at once; with no
/// characters, one anonymous column of `## DefaultState`s.
bits: Vec<Vec<bool>>,
/// The dropdown's selection: `None` is "All", `Some(c)` is `chars[c]`.
view: Option<usize>,
dropdown_open: bool,
@@ -145,10 +144,9 @@ impl Default for AddonsPanel {
Self {
open: false,
list: Vec::new(),
realm: String::new(),
chars: Vec::new(),
staged: Vec::new(),
baseline: Vec::new(),
store: addons::EnableStore::default(),
bits: Vec::new(),
view: None,
dropdown_open: false,
// The registered default, "1"; a `false` would paint out-of-date rows loadable for a
@@ -165,29 +163,14 @@ impl Default for AddonsPanel {
}
impl AddonsPanel {
/// Open for this realm's roster: read the folder, and one staged column per character, as
/// `AddonList_OnShow` re-reads. The store loads for the whole roster: an addon a character has
/// no row for takes what the other characters agree on, else the manifest's `## DefaultState`.
/// Open for this realm's roster: read the folder and every character's enable file, as
/// `AddonList_OnShow` re-reads. An addon a character has no row for takes what the other
/// characters agree on, else the manifest's `## DefaultState`.
pub(super) fn open_for(&mut self, realm: String, chars: Vec<String>) {
self.list = addons::installed_rows();
self.staged.clear();
let store = addons::EnableStore::load(&realm, &chars);
let columns: Vec<Option<&str>> = if chars.is_empty() {
vec![None]
} else {
chars.iter().map(|c| Some(c.as_str())).collect()
};
for character in columns {
self.staged.push(
self.list
.iter()
.map(|a| store.enabled_for(&a.name, a.default_state, character))
.collect(),
);
}
self.baseline = self.staged.clone();
self.realm = realm;
self.store = addons::EnableStore::load(&realm, &chars);
self.chars = chars;
self.reread();
// "All" is the reference's default selection.
self.view = None;
self.dropdown_open = false;
@@ -201,31 +184,53 @@ impl AddonsPanel {
self.open = false;
self.list.clear();
self.chars.clear();
self.staged.clear();
self.baseline.clear();
self.store = addons::EnableStore::default();
self.bits.clear();
self.view = None;
self.dropdown_open = false;
self.tip = None;
self.dirty = true;
}
/// Okay, `SaveAddOns`: write the enable file of each character whose column changed.
fn save_staged(&self) {
for (c, name) in self.chars.iter().enumerate() {
if self.staged.get(c) == self.baseline.get(c) {
continue;
}
let id = (self.realm.clone(), name.clone());
let states: Vec<(String, bool)> = self
.list
.iter()
.zip(self.staged[c].iter())
.map(|(a, &on)| (a.name.clone(), on))
.collect();
addons::write_enable_state(Some(&id), &states);
/// Re-read every checkbox through the query `0x51e470`, as `AddonList_Update` does after each
/// `EnableAddOn`/`DisableAddOn`.
fn reread(&mut self) {
let columns: Vec<Option<&str>> = if self.chars.is_empty() {
vec![None]
} else {
self.chars.iter().map(|c| Some(c.as_str())).collect()
};
self.bits = columns
.into_iter()
.map(|character| {
self.list
.iter()
.map(|a| self.store.enabled_for(&a.name, a.default_state, character))
.collect()
})
.collect();
self.dirty = true;
}
/// The glue `EnableAddOn`/`DisableAddOn` (`0x46d7b0`, `0x46d8a0`) for the current view: the
/// setter on the viewed character, or on every character in the All view (`nil`). With no
/// characters there is no node to set, so a click changes nothing.
fn set(&mut self, i: usize, on: bool) {
let character = self
.view
.and_then(|c| self.chars.get(c))
.map(String::as_str);
if let Some(a) = self.list.get(i) {
self.store.set(&a.name, character, on);
}
}
/// Okay, `SaveAddOns` (`0x46d990` into the writer `0x51ef20`): each character a click changed
/// gets its enable hash written; the rest are left as they are.
fn save(&mut self) {
self.store.save();
}
/// Whether any addon is installed (`UpdateAddonButton` hides the button otherwise). Cached for
/// the process: the screen respawns every frame of a drag-resize.
pub(super) fn any_installed() -> bool {
@@ -269,7 +274,7 @@ impl AddonsPanel {
match self.view {
Some(c) => {
if self
.staged
.bits
.get(c)
.is_some_and(|col| col.get(i) == Some(&true))
{
@@ -280,13 +285,13 @@ impl AddonsPanel {
}
None => {
let on = self
.staged
.bits
.iter()
.filter(|col| col.get(i) == Some(&true))
.count();
if on == 0 {
BoxState::Off
} else if on == self.staged.len() {
} else if on == self.bits.len() {
BoxState::On
} else {
BoxState::Mixed
@@ -339,40 +344,17 @@ impl AddonsPanel {
/// A checkbox click. In the All view a mixed box counts as checked, so clicking On or Mixed
/// disables for every character and only an unchecked box enables for all.
fn click_row(&mut self, i: usize) {
match self.view {
Some(c) => {
if let Some(slot) = self.staged.get_mut(c).and_then(|col| col.get_mut(i)) {
*slot = !*slot;
self.dirty = true;
}
}
None => {
let target = self.box_state(i) == BoxState::Off;
for col in &mut self.staged {
if let Some(slot) = col.get_mut(i) {
*slot = target;
}
}
self.dirty = true;
}
}
let on = self.box_state(i) == BoxState::Off;
self.set(i, on);
self.reread();
}
/// Enable All and Disable All sweep the current view's columns.
/// Enable All and Disable All: the setter for every listed addon, on the current view.
fn set_all(&mut self, on: bool) {
match self.view {
Some(c) => {
if let Some(col) = self.staged.get_mut(c) {
col.iter_mut().for_each(|s| *s = on);
}
}
None => {
for col in &mut self.staged {
col.iter_mut().for_each(|s| *s = on);
}
}
for i in 0..self.list.len() {
self.set(i, on);
}
self.dirty = true;
self.reread();
}
fn view_name<'a>(&'a self, strings: &'a GlueStrings) -> &'a str {
@@ -564,7 +546,7 @@ pub(super) fn drive_addons_panel(
if close_and_save {
// `AddonList_OnOk` and `AddonList_OnCancel` play the realm dialog's pair.
sounds.write(GlueSound("gsLoginChangeRealmOK"));
panel.save_staged();
panel.save();
panel.close();
return;
}
@@ -1531,27 +1513,36 @@ mod tests {
}
}
/// A panel over `chars` staged columns, each starting at the manifest defaults, viewing All.
/// A panel over `chars` characters with no enable rows, so every bit starts at the manifest
/// default, viewing All.
fn panel_for(chars: usize, list: Vec<InstalledAddOn>) -> AddonsPanel {
let staged: Vec<Vec<bool>> = (0..chars.max(1))
.map(|_| list.iter().map(|a| a.default_state).collect())
.collect();
AddonsPanel {
let chars: Vec<String> = (0..chars).map(|c| format!("Char{c}")).collect();
let mut p = AddonsPanel {
open: true,
realm: "TestRealm".into(),
chars: (0..chars).map(|c| format!("Char{c}")).collect(),
baseline: staged.clone(),
staged,
store: addons::EnableStore::blank("TestRealm", &chars),
chars,
list,
..Default::default()
}
};
p.reread();
p.dirty = false; // the tests spawn no tree
p
}
/// One column, whose All view is that column, so `p.staged[0][i]` drives everything.
/// One character, whose All view is that character.
fn panel(list: Vec<InstalledAddOn>) -> AddonsPanel {
panel_for(1, list)
}
/// The glue setter for character `c` (`None`: every character), then the re-read
/// `AddonList_Enable` runs.
fn set(p: &mut AddonsPanel, c: Option<usize>, i: usize, on: bool) {
let view = std::mem::replace(&mut p.view, c);
p.set(i, on);
p.view = view;
p.reread();
}
/// Every fallback label is its token's shipped value (`GlueStrings.lua:44-56`).
#[test]
fn the_status_labels_are_the_reference_globalstrings_values() {
@@ -1611,11 +1602,11 @@ mod tests {
assert_eq!(p.verdict(5).token(), None);
p.version_check = true;
p.staged[0][1] = false;
set(&mut p, Some(0), 1, false);
assert_eq!(p.verdict(1).token().as_deref(), Some("DISABLED"));
assert_eq!(p.verdict(2).token().as_deref(), Some("DEP_DISABLED"));
p.staged[0][3] = false;
set(&mut p, Some(0), 3, false);
assert_eq!(p.verdict(3).token().as_deref(), Some("DISABLED"));
}
@@ -1630,7 +1621,7 @@ mod tests {
assert_eq!(p.title_colour(0), GOLD, "loadable = gold");
assert_eq!(p.title_colour(2), BROKEN, "enabled + out of date = red");
p.staged[0][0] = false;
set(&mut p, Some(0), 0, false);
assert_eq!(p.title_colour(0), DIM, "player-disabled = grey");
assert_eq!(
p.title_colour(1),
@@ -1646,7 +1637,9 @@ mod tests {
2,
vec![addon("Lib", &[], 11200), addon("Needs", &["Lib"], 11200)],
);
p.staged[0][0] = false; // Char0 turned Lib off, Char1 has not
// Char0 turned Lib off and Char1 on; with no row Char1 would follow Char0.
set(&mut p, Some(0), 0, false);
set(&mut p, Some(1), 0, true);
assert_eq!(
p.verdict(0).token(),
@@ -1668,20 +1661,21 @@ mod tests {
let mut p = panel_for(3, vec![addon("A", &[], 11200)]);
assert_eq!(p.box_state(0), BoxState::On, "enabled for all three");
p.staged[1][0] = false;
set(&mut p, None, 0, true); // a row for each, so one can differ
set(&mut p, Some(1), 0, false);
assert_eq!(
p.box_state(0),
BoxState::Mixed,
"enabled for SOME (state 1)"
);
p.staged[0][0] = false;
p.staged[2][0] = false;
set(&mut p, Some(0), 0, false);
set(&mut p, Some(2), 0, false);
assert_eq!(p.box_state(0), BoxState::Off, "disabled for all");
p.view = Some(1);
assert_eq!(p.box_state(0), BoxState::Off);
p.staged[1][0] = true;
set(&mut p, Some(1), 0, true);
assert_eq!(p.box_state(0), BoxState::On);
}
@@ -1689,47 +1683,51 @@ mod tests {
#[test]
fn an_all_view_click_follows_the_references_checkbutton() {
let mut p = panel_for(3, vec![addon("A", &[], 11200)]);
p.staged[1][0] = false; // mixed
set(&mut p, None, 0, true);
set(&mut p, Some(1), 0, false); // mixed
p.click_row(0);
assert!(
p.staged.iter().all(|col| !col[0]),
p.bits.iter().all(|col| !col[0]),
"mixed → disabled for ALL characters"
);
p.click_row(0);
assert!(
p.staged.iter().all(|col| col[0]),
p.bits.iter().all(|col| col[0]),
"unchecked → enabled for ALL characters"
);
p.click_row(0);
assert!(p.staged.iter().all(|col| !col[0]), "checked → back off");
assert!(p.bits.iter().all(|col| !col[0]), "checked → back off");
p.view = Some(2);
p.click_row(0);
assert!(p.staged[2][0] && !p.staged[0][0] && !p.staged[1][0]);
assert!(p.bits[2][0] && !p.bits[0][0] && !p.bits[1][0]);
}
/// No enable file is touched until Okay; Cancel (`ResetAddOns`) is `close`.
#[test]
fn edits_are_staged_and_cancel_discards_them() {
let mut p = panel(vec![addon("A", &[], 11200), addon("B", &[], 11200)]);
assert_eq!(p.staged, vec![vec![true, true]]);
p.staged[0][0] = false;
assert_eq!(p.bits, vec![vec![true, true]]);
set(&mut p, Some(0), 0, false);
assert_eq!(p.bits, vec![vec![false, true]]);
assert!(
p.list[0].default_state,
"the read-in list is never mutated — only `staged` is, which is what makes Cancel free"
"the read-in list is never mutated — only the store is, which is what makes Cancel free"
);
p.close();
assert!(!p.open);
assert!(
p.staged.is_empty(),
p.bits.is_empty() && p.store.node("Char0").is_none(),
"a cancelled edit leaves nothing behind"
);
}
/// Okay writes each changed character's file and creates none for an untouched one.
/// Okay (`SaveAddOns`) writes only the characters whose hash a click changed, and each one's
/// own rows only: the issue's Alice and Bob, with Carol never touched. Bob's disable reaches
/// Alice and Carol through the query, on screen at once and after the files are read back.
#[test]
fn okay_writes_every_changed_characters_file_and_only_those() {
let _l = crate::local_state::test_env::ENV_LOCK
@@ -1747,6 +1745,13 @@ mod tests {
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _h =
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
let path = |who: &str| {
addons::enable_state_path(Some(&("TestRealm".to_string(), who.to_string()))).unwrap()
};
// Alice's file as some other writer left it: a rewrite would drop the stray line.
let alice_file = "Gone: disabled\nstray line\nAlpha: enabled\n";
std::fs::create_dir_all(path("Alice").parent().unwrap()).unwrap();
std::fs::write(path("Alice"), alice_file).unwrap();
let mut p = AddonsPanel::default();
p.open_for(
@@ -1755,12 +1760,31 @@ mod tests {
);
assert!(p.view.is_none(), "the reference's default selection is All");
assert_eq!(p.list.len(), 2, "discovery found the two hermetic addons");
assert_eq!(p.staged.len(), 3, "one staged column per roster character");
assert_eq!(p.staged, p.baseline);
assert_eq!(p.bits.len(), 3, "one column per roster character");
p.staged[0][0] = false;
p.staged[1][1] = false;
p.save_staged();
set(&mut p, Some(0), 0, true); // Alice re-ticks what her row already says
set(&mut p, Some(1), 1, false); // Bob unticks Beta
assert_eq!(
p.bits,
vec![vec![true, false]; 3],
"Bob is the only one with a Beta row, so Alice and Carol follow him at once"
);
p.save();
assert_eq!(
std::fs::read_to_string(path("Alice")).unwrap(),
alice_file,
"a setter call that changed nothing leaves the node clean and the file unwritten"
);
assert_eq!(
std::fs::read_to_string(path("Bob")).unwrap(),
"Beta: disabled\n",
"Bob's file carries his one toggle, not a row per listed addon"
);
assert!(
!path("Carol").exists(),
"an untouched character writes no file"
);
// Read back through the store world entry resolves against.
let roster = ["Alice".to_string(), "Bob".to_string(), "Carol".to_string()];
@@ -1771,20 +1795,9 @@ mod tests {
.map(|a| store.enabled_for(&a.name, a.default_state, Some(who)))
.collect()
};
assert_eq!(
read("Alice"),
vec![false, true],
"Alice's file carries HER edit"
);
assert_eq!(read("Bob"), vec![true, false], "Bob's carries HIS");
let carol = ("TestRealm".to_string(), "Carol".to_string());
assert!(
!addons::enable_state_path(Some(&carol)).unwrap().exists(),
"an unchanged column writes no file — only the diffs against the open-time baseline"
);
// Carol, with no file, takes what the others agree on; they split on both, so each falls
// to its manifest's `## DefaultState`.
assert_eq!(read("Carol"), vec![true, true]);
for who in ["Alice", "Bob", "Carol"] {
assert_eq!(read(who), vec![true, false], "{who}");
}
let _ = std::fs::remove_dir_all(&tmp);
}
@@ -1815,7 +1828,7 @@ mod tests {
vec!["Onemage".into(), "Onerogue".into()],
);
p.set_all(false);
p.save_staged();
p.save();
p.close();
// A created character, with no enable file.
@@ -1824,9 +1837,9 @@ mod tests {
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
);
assert!(p.view.is_none(), "reopens on All, the reference's default");
assert_eq!(p.staged.len(), 3);
assert_eq!(p.bits.len(), 3);
assert_eq!(
p.staged[2],
p.bits[2],
vec![false, false],
"the new character inherits the unanimous disable, not a blank slate"
);
@@ -1879,7 +1892,7 @@ mod tests {
p.set_all(false); // the mage turns both off; the rogue keeps the defaults
p.view = Some(1);
p.set_all(true);
p.save_staged();
p.save();
p.close();
p.open_for(
@@ -1887,7 +1900,7 @@ mod tests {
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
);
assert_eq!(
p.staged[2],
p.bits[2],
vec![true, false],
"split roster → each row falls to its own `## DefaultState`"
);
+8 -11
View File
@@ -92,7 +92,8 @@ pub(super) fn drive_delete_dialog(
Option<&mut Text>,
&mut Visibility,
)>,
mut clipboard: NonSendMut<HostClipboard>,
// The pasteboard, and the layout that names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
// The window drives the glue scale; its raw handle carries the Wayland clipboard's display.
window: Query<
(&Window, Option<&bevy::window::RawHandleWrapper>),
@@ -139,6 +140,7 @@ pub(super) fn drive_delete_dialog(
&mut dialog.typed,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
@@ -385,8 +387,8 @@ fn spawn_dialog(
..default()
},))
.with_children(|f| {
// The five-part field row: segments either side of the
// selection, a caret slot at each edge.
// The five-part field row: the text either side of the
// selection, which splits at the caret.
let segment = |f: &mut ChildSpawnerCommands, part| {
outlined_text(
f,
@@ -404,14 +406,9 @@ fn spawn_dialog(
);
};
segment(f, GlueFieldPart::Before);
caret_bar(
f,
(DeleteCaret, GlueFieldPart::CaretAtStart),
15.0,
s,
);
segment(f, GlueFieldPart::Selected);
caret_bar(f, (DeleteCaret, GlueFieldPart::CaretAtEnd), 15.0, s);
segment(f, GlueFieldPart::SelectedHead);
caret_bar(f, (DeleteCaret, GlueFieldPart::Caret), 15.0, s);
segment(f, GlueFieldPart::SelectedTail);
segment(f, GlueFieldPart::After);
});
});
+51
View File
@@ -157,6 +157,7 @@ impl Plugin for CinematicPlugin {
Update,
drive_letterbox.in_set(WorldStage::Input).after(drive),
)
.add_systems(Update, send_opening_cinematic.in_set(WorldStage::Input))
// Leaving the world drops a cinematic with no ack, as the reference's teardown does
// (`0x490a80` clears the flag and sends no `CMSG_COMPLETE_CINEMATIC`).
.add_systems(OnExit(ClientState::InWorld), abandon_on_leaving_world);
@@ -577,6 +578,20 @@ fn feed_ui(
);
}
/// Each Lua `OpeningCinematic()` is one empty `CMSG_OPENING_CINEMATIC` (`0x48c8c0`); the server
/// answers, if at all, with the `SMSG_TRIGGER_CINEMATIC` [`take_trigger`] plays.
fn send_opening_cinematic(script: Option<NonSendMut<UiScript>>, net: Option<Res<NetCommands>>) {
let Some(mut script) = script else {
return;
};
let asks = script.take_opening_cinematic_asks();
if let Some(net) = net {
for _ in 0..asks {
let _ = net.0.send(ClientCommand::OpeningCinematic);
}
}
}
fn ack(net: Option<&NetCommands>) {
if let Some(net) = net {
let _ = net.0.send(ClientCommand::CompleteCinematic);
@@ -837,3 +852,39 @@ mod settling_under_black {
);
}
}
/// Lua's `OpeningCinematic()` reaches the wire as one empty `CMSG_OPENING_CINEMATIC` per call.
#[cfg(test)]
mod opening_cinematic {
use super::*;
#[test]
fn each_call_sends_one_opening_cinematic() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx));
app.insert_non_send_resource(UiScript::new().unwrap());
app.add_systems(Update, send_opening_cinematic);
app.world()
.non_send_resource::<UiScript>()
.run("OpeningCinematic() OpeningCinematic()")
.unwrap();
app.update();
let sent = rx
.try_iter()
.filter(|c| matches!(c, ClientCommand::OpeningCinematic))
.count();
assert_eq!(sent, 2);
app.update();
assert_eq!(rx.try_iter().count(), 0, "drained once");
// The opcode the reference puts at `0x48c8c9`.
assert_eq!(
benilla_protocol::messages::opcode::CMSG_OPENING_CINEMATIC,
0xf9
);
assert_eq!(
benilla_protocol::messages::opcode_name(0xf9),
Some("CMSG_OPENING_CINEMATIC")
);
}
}
+30 -2
View File
@@ -61,12 +61,31 @@ impl ConsoleCommandApp for App {
}
}
/// Lines a CVar's change callback prints to the console (`0x63cb50`), such as `spellEffectLevel`'s
/// "Spell effect level set to %d.". A console command's own write collects them into its output;
/// any other write (a Lua `SetCVar`, the boot load) sends them to the log, as the reference's go
/// to a console screen nobody has open.
#[derive(Resource, Default)]
pub(crate) struct ConsoleEcho {
capture: Option<Vec<String>>,
}
impl ConsoleEcho {
pub(crate) fn print(&mut self, line: String) {
match &mut self.capture {
Some(lines) => lines.push(line),
None => info!("console: {line}"),
}
}
}
/// The built-ins: the reference's four `ConsoleVar.cpp` commands and `help`.
pub(crate) struct ConsolePlugin;
impl Plugin for ConsolePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ConsoleCommands>()
.init_resource::<ConsoleEcho>()
.console_command("set", "Set the value of a CVar.", set)
.console_command("cvar_reset", "Reset a CVar to its default.", cvar_default)
.console_command("cvar_default", "Reset a CVar to its default.", cvar_default)
@@ -136,10 +155,18 @@ fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec<String> {
let outcome = cvars.set(name, value);
(outcome, cvars.take_events())
};
// The callbacks' own lines come first, as they print inside `CVar::Set`.
if let Some(mut echo) = world.get_resource_mut::<ConsoleEcho>() {
echo.capture = Some(Vec::new());
}
for event in events {
world.trigger(event);
}
match outcome {
let mut lines = world
.get_resource_mut::<ConsoleEcho>()
.and_then(|mut echo| echo.capture.take())
.unwrap_or_default();
lines.extend(match outcome {
SetOutcome::Unknown => vec![format!("console: no CVar named '{name}'")],
SetOutcome::Refused => vec![format!(
"console: '{value}' is not a number, and {name} takes one"
@@ -148,7 +175,8 @@ fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec<String> {
SetOutcome::Staged => vec![format!(
"{name} staged as \"{value}\" — applies at the next RestartGx"
)],
}
});
lines
}
/// `set <name> <value>` (`0x63d500`). Deviation: the reference registers an unknown name as a new
+16 -6
View File
@@ -16,8 +16,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::BuildId;
/// Chains the crash-report hook after the current one. Called once from [`crate::run`], before
/// the `App` exists, so a panic while plugins build is covered.
/// Chains the crash-report hook after the current one. Called once as the client launches
/// ([`crate::run`], [`crate::run_with`]), before the `App` exists, so a panic while plugins build
/// is covered.
pub(crate) fn install(build: BuildId) {
let started = Instant::now();
let previous = std::panic::take_hook();
@@ -139,12 +140,14 @@ mod tests {
short: "0123456",
date: "2026-09-16",
profile: "debug",
project_dir: "",
extended: false,
};
#[test]
fn a_report_carries_the_build_the_place_the_payload_and_the_log() {
let log = vec!["+ 1.000s INFO benilla_app::net: entered world".to_owned()];
let text = render(&Report {
let mut report = Report {
build: BUILD,
unix: 1_800_000_000,
uptime: Duration::from_millis(12_345),
@@ -153,10 +156,10 @@ mod tests {
payload: "index out of bounds: the len is 3 but the index is 7",
backtrace: " 0: benilla_app::net::apply::apply_net_updates",
log: &log,
});
};
let text = render(&report);
for needle in [
"sha 0123456789abcdef0123456789abcdef01234567",
"0.2.0+3 · 0123456 · 2026-09-16 · debug",
"build: 0.2.0+3 · 0123456 · 2026-09-16 · debug (sha 0123456789abcdef0123456789abcdef01234567)",
"12.3 s after launch",
"thread: main",
"at: crates/benilla-app/src/net/apply.rs:100:5",
@@ -167,6 +170,13 @@ mod tests {
] {
assert!(text.contains(needle), "missing {needle:?} in:\n{text}");
}
// A client a crate on top of benilla extended says so in the build line.
report.build.extended = true;
let extended = render(&report);
assert!(
extended.contains("build: 0.2.0+3 · 0123456 · 2026-09-16 · debug · extended (sha "),
"{extended}"
);
let dir = std::env::temp_dir().join(format!("benilla-crash-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
+21
View File
@@ -424,6 +424,10 @@ pub(crate) struct EmoteAnim {
pub(crate) anim_id: u16,
/// `PlaySeq` stamp; a kit anim inherits its `CastEvent`'s.
pub(crate) seq: u64,
/// Came through the shared one-shot emote player (`0x5fcd20`: `SMSG_EMOTE`, the gesture
/// dispatcher), which skips an id already armed on the key bone or bone 0 (`0x5fcd56`). A kit
/// anim and `DoEmote`'s local play call `0x5fe2f0` directly and take no such test.
pub(crate) via_player: bool,
}
/// A state kit's `AnimID`, compared and never played: both field-4 callers (`0x5ff4c6`,
@@ -465,6 +469,7 @@ fn flourish_to_anim(
entity: child.0,
anim_id: select::MOUNT_SPECIAL,
seq: play_seq.next(),
via_player: false,
});
}
}
@@ -511,6 +516,7 @@ mod emote_anim;
/// The client-local gestures (chat, NPC interact), the reference's `0x60bb30`.
mod gesture;
use emote_anim::emote_to_anim;
pub(crate) use emote_anim::{local_play_eligible, LocalPlay};
pub(crate) use gesture::{select_gesture, Gesture, GestureQueue};
/// The Bevy systems that execute the state machine `select` picks.
@@ -564,6 +570,16 @@ pub(crate) struct AnimDriver {
/// The movement flags the base was last armed for: the reference has no per-one-shot latch,
/// so a root landing with a cast one-shot (Ice Block) still lets the base overwrite bone 0.
gait_flags: u32,
/// Whether the unit was the interact NPC last frame: `SetInteractNPC` (`0x4930d0`) re-picks
/// its base on open (`0x493198`) and on clear (`0x493219`), so a flip cuts a one-shot.
interacting: bool,
/// Whether the interaction in `interacting` (or the one just cleared) targets a player, whose
/// open and clear skip the re-pick (`0x493159`, `0x493203`: the PLAYER typemask bit).
interact_player: bool,
/// The clear's re-pick (`0x493219`) runs while `[0xb4e2d0]` still names the NPC, so it picks
/// Stand; the global is zeroed after (`0x49334b`). The state returns at the next re-pick:
/// the Stand window's end, a movement-flag edge, or leaving Gait.
interact_hold: bool,
/// The reference's `[unit+0xd58] & 0xc0000`: while Knockdown, LiftOff or Land holds it, base
/// requests are refused, so a stun's knockdown survives the root's Stand recompute.
base_lock: driver::play::BaseAnimLock,
@@ -673,6 +689,9 @@ impl Default for AnimDriver {
mode: Mode::Gait,
gait: None,
gait_flags: 0,
interacting: false,
interact_player: false,
interact_hold: false,
base_lock: driver::play::BaseAnimLock::default(),
sheath_cur: None,
sheath_byte: None,
@@ -832,6 +851,8 @@ impl Plugin for CreatureAnimPlugin {
// Same frame as the loot kneel: the reference force-plays Loot 50 in the
// handler that arms the latch.
.after(crate::ui_loot::resolve_loot_kneel)
// `DoEmote`'s local play, written in the chat drain.
.after(crate::ui_chat::ChatDrain)
// After Input so a sheath request (the Z toggle) executes the same frame.
.after(WorldStage::Input)
// `VisualSheath` must land before `resolve_equipment` reads it, or a sheath
+140 -16
View File
@@ -19,8 +19,8 @@ use crate::net::{
use crate::sound::EmoteSounds;
use super::select::{
self, current_special, defense_anim, is_swing_id, route_oneshot, swing_anim_main,
swing_anim_off, unify, Mode, OneShotRoute, DEATH, DEFAULT_WALK_SPEED, STAND,
self, current_special, defense_anim, is_swing_id, route_mounted, route_oneshot,
swing_anim_main, swing_anim_off, unify, Mode, OneShotRoute, DEATH, DEFAULT_WALK_SPEED, STAND,
};
use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony};
use super::{
@@ -53,9 +53,11 @@ const ONESHOT_RELEASE_FADE: f32 = 0.150;
const JUMP_ARC_MIN_UP: f32 = 0.5;
/// A one-shot play request from this frame's messages, resolved to an anim id per unit.
#[derive(Clone, Copy)]
enum OneShotReq {
Swing(u32),
Emote(u16),
/// The id, and whether it came through the emote player's armed-id test.
Emote(u16, bool),
}
/// The live one-shot the combat fast path tests: the key bone's clip, else bone 0's (`0x5fe422`).
@@ -79,6 +81,31 @@ fn live_oneshot(
None
}
/// The id armed on the unit, as `0x5fdb50` reads it: the key bone's while one runs, else bone 0's.
/// It is the REQUESTED id, not the fallback clip that plays: op4 stores its raw argument at
/// `+0xf8` (`0x71252f`) and the getter returns it (`0x7120d3`). A clip that has finished counts as
/// gone: its completion callback re-arms the base.
fn armed_id(
drv: &AnimDriver,
player: &AnimationPlayer,
tr: &AnimationTransitions,
anims: &ModelAnimations,
) -> Option<u16> {
let running = |n| player.animation(n).is_some_and(|a| !a.is_finished());
if let Some(ov) = drv.overlay.filter(|ov| running(ov.node)) {
return Some(ov.id);
}
let node = tr.get_main_animation().filter(|&n| running(n))?;
// The clip's own id only when the mode holds no requested id.
drv.active_anim().or_else(|| {
anims
.clips
.iter()
.find(|c| c.node == node)
.map(|c| c.anim_id)
})
}
/// Whether a one-shot is live on the unit; the missile queue polls it to launch a missile whose
/// release keyframe never fired when the cast ends (the reference's `0x5fc920` → `0x60c9b0`).
pub(crate) fn oneshot_is_live(
@@ -277,13 +304,18 @@ pub(super) fn drive_animations(
// Read for `DO_NOT_PLAY_WOUND_ANIM`; a missing cache reads as an unreceived template.
Option<Res<crate::names::NameCache>>,
MessageReader<BaseAnimRecompute>,
// A rider's mount child, where a mount-set request goes (`0x5fe7c1`).
Query<(&ObjectStore, &crate::entities::mount::MountChild)>,
// The open NPC session's unit (`[0xb4e2d0]`): its state emote is passed over when the
// row's `EmoteFlags` carry `0x2000` (`0x5fd7e2`-`0x5fd7fd`).
Option<Res<crate::ui_session::InteractNpc>>,
),
// The variation roll's LCG, the reference's single CRT `_rand` stream shared by every play.
mut rng: ResMut<benilla_assets::AnimRng>,
// The last anim trace line per traced unit; the trace writes only on change.
mut anim_trace_last: Local<std::collections::HashMap<Entity, String>>,
) {
let (emote_sounds, loot_kneel, time, names, mut recomputes) = aux;
let (emote_sounds, loot_kneel, time, names, mut recomputes, riders, interact) = aux;
let dt = time.delta_secs();
// This frame's one-shot plays per unit, replayed in the reference's call order (`PlaySeq`
// stamps): a later call overwrites an earlier, and the combat fast path keys on what is playing
@@ -332,7 +364,37 @@ pub(super) fn drive_animations(
pending
.entry(e.entity)
.or_default()
.push((OneShotReq::Emote(e.anim_id), e.seq));
.push((OneShotReq::Emote(e.anim_id, e.via_player), e.seq));
}
// A mounted rider's mount-set emotes go to the mount model (`0x5fe7c1`); the rider keeps the
// request for its own CLASS_A and nowhere routes. The emote player's armed test (`0x5fcd56`)
// runs once, on the rider, ahead of that routing, so the forwarded copy carries none.
let mut to_mount: Vec<(Entity, (OneShotReq, u64))> = Vec::new();
for (rider, reqs) in pending.iter() {
let Ok((store, child)) = riders.get(*rider) else {
continue;
};
if store.0.unit_mount_display_id() == 0 {
continue;
}
for &(req, seq) in reqs {
let OneShotReq::Emote(id, via_player) = req else {
continue;
};
if !route_mounted(id).mount {
continue;
}
let armed = via_player
.then(|| units.get(*rider).ok())
.flatten()
.and_then(|u| armed_id(u.4, u.2, u.3, u.1));
if armed != Some(id) {
to_mount.push((child.0, (OneShotReq::Emote(id, false), seq)));
}
}
}
for (child, req) in to_mount {
pending.entry(child).or_default().push(req);
}
// Stage-2 base recomputes by unit, last wins: of two in a frame the second's verdict stands.
let mut pending_recompute: bevy::ecs::entity::EntityHashMap<u16> = default();
@@ -706,7 +768,7 @@ pub(super) fn drive_animations(
});
// In `PlaySeq` order, defense last: the reference plays it from the deferred impact scan,
// after every message handler.
let mut requests: Vec<u16> = pending
let mut requests: Vec<(u16, Option<u16>)> = pending
.remove(&entity)
.map(|mut v| {
v.sort_by_key(|&(_, seq)| seq);
@@ -722,19 +784,21 @@ pub(super) fn drive_animations(
swing_anim_main(w.armed_main())
};
debug!("swing: unit {entity} anim {id} (hitInfo {hit_info:#x})");
id
(id, None)
}
OneShotReq::Emote(id) => id,
// The armed-id test compares the record's own AnimID, before the play
// seam's unarmed remap.
OneShotReq::Emote(id, via_player) => (id, via_player.then_some(id)),
})
.collect()
})
.unwrap_or_default();
requests.extend(defense);
requests.extend(defense.map(|id| (id, None)));
// At the play seam (`0x5fe2f0`), Special1H/2H with both hands empty becomes
// SpecialUnarmed(118), for every request.
{
let w = wielded.copied().unwrap_or_default();
for id in &mut requests {
for (id, _) in &mut requests {
*id = select::unarmed_special(*id, w.armed_main(), w.armed_off());
}
}
@@ -746,9 +810,22 @@ pub(super) fn drive_animations(
&& !airborne_frozen
&& live_oneshot(&drv, &player, &tr, anims, catalog).is_none()
{
requests.extend(drv.deferred.take());
requests.extend(drv.deferred.take().map(|id| (id, None)));
}
for id in requests {
for (id, armed_test) in requests {
// The emote player's own test first (`0x5fcd56`): an id already armed on the key bone
// or bone 0 plays nothing, ahead of the lock and the fast path in `0x5fe2f0`.
if let Some(raw) = armed_test {
if armed_id(&drv, &player, &tr, anims) == Some(raw) {
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!("anim armed-skip unit={entity} id={raw}"),
);
}
continue;
}
}
// The lock first: `0x5fe2f0`'s head guard returns before routing, the fast path and
// the dedup, so a locked unit gets no arm and no deferral.
if drv.base_lock.refuses() {
@@ -794,13 +871,30 @@ pub(super) fn drive_animations(
}
continue;
}
// Mounted forces the masked route (`0x5fe2f0`'s mounted branch).
let masked =
mounted || route_oneshot(id, mv.flags, mv.stand_state) == OneShotRoute::Masked;
// A mounted rider plays only its CLASS_A ids, on the key bone, and nothing else
// (`0x5fe7b5`); a mount child plays the clip on its own bone 0 (`0x5fe7c1`).
let masked = if mounted {
if !route_mounted(id).rider_upper {
continue;
}
true
} else {
mount_body.is_none()
&& route_oneshot(id, mv.flags, mv.stand_state) == OneShotRoute::Masked
};
// Resolve to a clip this model has, roll its variation and its replay budget (a clamp
// one-shot authored `(min,max)` plays R times).
let picked =
find_resolved(anims, id, catalog).map(|h| roll_oneshot(anims, h, &mut rng));
// No split bone (`[+0xd5c] == -1`): a key-bone play arms nothing (`0x5fdcc4`).
if masked
&& match picked {
Some((c, _)) => c.upper_node.is_none(),
None => mounted,
}
{
continue;
}
let upper = masked
.then(|| picked.and_then(|(c, r)| c.upper_node.map(|n| (n, r))))
.flatten();
@@ -826,7 +920,7 @@ pub(super) fn drive_animations(
masked_played = true;
played_oneshot = Some(id);
} else {
// Full-body, or a model with no key bone (the −1 sentinel arms bone 0): the clip
// Full-body: the clip
// replaces the base on bone 0 even over a Special, last writer wins (`0x5fe6c8`).
// An airborne arc's own clip freezes first, op4's pose-snapshot decay.
if let Some(sp) =
@@ -896,6 +990,7 @@ pub(super) fn drive_animations(
{
if drv.mode == Mode::Gait {
drv.gait = None;
drv.interact_hold = false; // the window's end is a re-pick (`0x5fc45a`)
} else {
let head = anims
.clips
@@ -936,6 +1031,34 @@ pub(super) fn drive_animations(
}
}
// `SetInteractNPC` re-picks the base at once on open and on clear (`0x5fd9e0(-1)`, sites
// `0x493198` and `0x493219`), over a live one-shot as any base arm; in Gait the per-frame
// pick already follows. A player's open and clear skip it (`0x493159`, `0x493203`), a pose
// under the one-shot re-picks to its own id and an airborne unit to the freeze
// (`0x5fd8e8`), which cut nothing.
let target = interact.as_ref().filter(|i| i.0 == Some(entity));
let interacting = target.is_some();
if let Some(guid) = target.and_then(|i| i.1) {
drv.interact_player = benilla_protocol::guid::is_player(guid);
}
if std::mem::replace(&mut drv.interacting, interacting) != interacting
&& !drv.interact_player
{
// Opening re-picks with the NPC named, so it ends a hold; clearing re-picks with it
// still named, so the state stays passed over until the next re-pick.
drv.interact_hold = !interacting;
if matches!(drv.mode, Mode::Swing { under: None, .. }) && !airborne_frozen {
drv.deferred = None;
drv.mode = Mode::Gait;
drv.gait = None;
}
}
if drv.interact_hold
&& (mv.flags != drv.gait_flags || !matches!(drv.mode, Mode::Gait | Mode::Swing { .. }))
{
drv.interact_hold = false;
}
// ── The mode machine: the base track's decision.
mode::run(
mode::Frame {
@@ -955,6 +1078,7 @@ pub(super) fn drive_animations(
wielded,
store,
emote_sounds: emote_sounds.as_deref(),
interacting: interacting || drv.interact_hold,
walk,
model_scale,
traced,
@@ -9,10 +9,7 @@ use bevy::prelude::*;
use crate::net::ObjectStore;
use crate::sound::EmoteSounds;
use super::super::select::{
self, gait_candidates, is_bare_stand, playback_rate, ready_anim, state_emote_gait, Mode, DEATH,
STAND,
};
use super::super::select::{self, base_candidates, playback_rate, ready_anim, Mode, DEATH, STAND};
use super::super::{find_resolved, move_flags, AnimDriver, CastHold, MovementState, Wielded};
use super::play::{enter_special, leave_special, oneshot_finished, play, play_clip, roll_loop};
use super::transplant_up;
@@ -36,6 +33,8 @@ pub(super) struct Frame<'a> {
pub(super) wielded: Option<&'a Wielded>,
pub(super) store: Option<&'a ObjectStore>,
pub(super) emote_sounds: Option<&'a EmoteSounds>,
/// Whether this unit is the current interact NPC.
pub(super) interacting: bool,
pub(super) walk: f32,
pub(super) model_scale: f32,
/// Whether this body's plays go to the anim trace; `subject` labels them (rider or mount).
@@ -68,6 +67,7 @@ pub(super) fn run(
wielded,
store,
emote_sounds,
interacting,
walk,
model_scale,
traced,
@@ -315,7 +315,22 @@ pub(super) fn run(
load
}
});
let cands = gait_candidates(&mv, walk, ready, ranged_load);
// The looping state-emote idle (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC work loops, and
// `/stand`'s state 26) is the resolver `0x5fd770`: it claims the base ahead of the
// idle fallbacks (swim, prowl, Stand) and behind everything else in the chain.
let state_anim = store.and_then(|s| {
let state = s.0.unit_emote_state();
emote_sounds.and_then(|e| {
select::state_emote_anim(
state,
e.state_anim(state),
e.emote_flags(state),
interacting,
)
})
});
let base = base_candidates(&mv, walk, ready, ranged_load, state_anim);
let cands = base.as_slice();
// The stationary cast/channel pin (`[CGUnit+0xb4]`), full-body over the Ready and
// state-emote idles; stationary is `[9e8] & 0x20000f`, never the turn bits, so a
// turning caster keeps it and a moving one gets the masked hold instead.
@@ -327,22 +342,6 @@ pub(super) fn run(
}
_ => cands,
};
// The looping state-emote idle (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC work loops)
// fills only the bare-Stand slot; whatever outranks Stand already routed `cands`.
let state_emote_cands;
let cands: &[u16] = if is_bare_stand(cands) {
let emote_anim = store
.and_then(|s| emote_sounds.and_then(|e| e.anim(s.0.unit_emote_state())));
match emote_anim {
Some(id) => {
state_emote_cands = state_emote_gait(id as u16);
&state_emote_cands
}
None => cands,
}
} else {
cands
};
// The loot kneel (Loot 50) of a stationary, unmounted unit with the trigger up,
// over the cast pin, the Ready and ranged idles, the chair loops and the state
// emote: `0x5fd8b0` tries locomotion, then loot (`0x5fd260`), then the cast/channel
@@ -662,6 +662,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
entity: spinner,
anim_id: 57,
seq: 1,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: swinger,
@@ -747,12 +748,14 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
entity: spin_last,
anim_id: 57,
seq: 2,
via_player: false,
});
// swing_last: the spin arrives first.
app.world_mut().write_message(EmoteAnim {
entity: swing_last,
anim_id: 57,
seq: 3,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: swing_last,
@@ -1450,6 +1453,7 @@ fn a_jump_in_place_cast_replaces_the_hang_and_survives_the_arc() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
fn drv(app: &App, unit: Entity) -> &AnimDriver {
@@ -1494,6 +1498,7 @@ fn landing_mid_cast_plays_the_land_pick() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
app.world_mut()
@@ -1525,6 +1530,7 @@ fn a_cast_over_the_fall_loop_holds_until_landing() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update(); // Swing { 54, under: Jump }
app.world_mut().entity_mut(unit).insert(MovementState {
@@ -1543,6 +1549,7 @@ fn a_cast_over_the_fall_loop_holds_until_landing() {
entity: unit,
anim_id: 54,
seq: 2,
via_player: false,
});
app.update();
assert!(
@@ -1597,6 +1604,7 @@ fn a_moving_jump_cast_masks_onto_the_overlay() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
let drv = app.world().entity(unit).get::<AnimDriver>().unwrap();
@@ -1622,6 +1630,7 @@ fn a_jump_over_a_live_cast_transplants_it_to_the_torso() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
{
@@ -1774,6 +1783,7 @@ fn a_finished_masked_cast_fades_out_instead_of_snapping() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
{
@@ -1903,6 +1913,7 @@ fn a_midair_deferred_park_survives_the_level_and_dies_at_the_landing_play() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.world_mut().write_message(SwingMessage {
attacker: unit,
@@ -2226,6 +2237,7 @@ fn a_root_landing_with_the_cast_returns_the_body_to_neutral() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -2252,6 +2264,7 @@ fn a_run_starting_under_a_cast_still_transplants_it_up() {
entity: unit,
anim_id: 54,
seq: 1,
via_player: false,
});
app.update();
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
@@ -2553,6 +2566,7 @@ fn the_mount_transition_takes_bone_0_back_from_a_full_body_one_shot() {
entity: unit,
anim_id: SPELL_CAST_OMNI,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -2872,6 +2886,7 @@ fn a_mid_volley_fire_clip_re_pulls_and_the_pull_promotes_to_the_hold() {
entity: unit,
anim_id: 46,
seq: 1,
via_player: false,
});
app.update();
assert_eq!(
@@ -3022,6 +3037,7 @@ fn every_shot_of_a_volley_re_arms_the_fire_clip_through_the_emote_lane() {
entity: unit,
anim_id: 46,
seq: shot,
via_player: false,
});
app.update();
assert_eq!(mode(&app), FIRING, "shot {shot} takes bone 0");
@@ -3429,6 +3445,7 @@ fn a_special_goes_unarmed_only_when_both_hands_are_empty() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
unit
};
@@ -3698,6 +3715,7 @@ fn a_gait_change_raises_the_anim_edge_and_a_steady_frame_does_not() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.update();
assert!(edge(&app), "and so does a one-shot");
@@ -3729,6 +3747,7 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
entity: unit,
anim_id: 57,
seq: 1,
via_player: false,
});
app.update();
assert!(edge(&app), "the first combat play arms normally");
@@ -3806,6 +3825,7 @@ mod base_anim_lock {
entity: unit,
anim_id,
seq: 1,
via_player: false,
});
app.update();
}
@@ -3978,3 +3998,675 @@ mod base_anim_lock {
);
}
}
/// The one-shot routing seams `0x5fe2f0` runs ahead of the clip: the mounted three-way split
/// (`0x5fe7b5`), and a key-bone play on a model with no split bone (`0x5fdcc4`).
mod routing {
use super::*;
const FIELD_MOUNTDISPLAYID: u16 = 133;
const MOUNT: u16 = 91;
const PRAY: u16 = 75; // EmoteKneel, not CLASS_A
const CHEER: u16 = 68; // CLASS_A
const SHUFFLE: u16 = 12; // mount-set, not CLASS_A
const EAT: u16 = 61;
fn masked(anim_id: u16, node: u32, upper: u32) -> AnimClip {
let mut c = clip(anim_id, node, false);
c.upper_node = Some(AnimationNodeIndex::new(upper as usize));
c
}
fn model(clips: Vec<AnimClip>) -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
clips,
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
fn rider_model() -> ModelAnimations {
model(vec![
clip(0, 1, true),
clip(MOUNT, 2, true),
masked(PRAY, 3, 13),
masked(CHEER, 4, 14),
masked(SHUFFLE, 5, 15),
])
}
fn spawn(app: &mut App, model: ModelAnimations, mount_display: u32) -> Entity {
app.world_mut()
.spawn((
model,
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
crate::net::ObjectStore(ObjectFields::from_pairs(&[(
FIELD_MOUNTDISPLAYID,
mount_display,
)])),
MovementState::default(),
))
.id()
}
fn emote(app: &mut App, entity: Entity, anim_id: u16) {
app.world_mut().write_message(EmoteAnim {
entity,
anim_id,
seq: 1,
via_player: false,
});
app.update();
}
fn drv(app: &App, e: Entity) -> &AnimDriver {
app.world().entity(e).get::<AnimDriver>().unwrap()
}
#[test]
fn a_mounted_pray_arms_nothing_and_keeps_the_mounted_base() {
let mut app = app();
let rider = spawn(&mut app, rider_model(), 2404);
app.update();
assert_eq!(drv(&app, rider).active_anim(), Some(MOUNT));
emote(&mut app, rider, PRAY);
assert!(drv(&app, rider).overlay.is_none(), "no key-bone play");
assert_eq!(drv(&app, rider).active_anim(), Some(MOUNT), "no base play");
}
#[test]
fn a_mounted_class_a_id_plays_on_the_riders_upper_body() {
let mut app = app();
let rider = spawn(&mut app, rider_model(), 2404);
app.update();
emote(&mut app, rider, CHEER);
assert!(drv(&app, rider).overlay.is_some_and(|o| o.id == CHEER));
assert_eq!(drv(&app, rider).active_anim(), Some(MOUNT));
}
#[test]
fn a_mounted_mount_set_id_goes_to_the_mount_child() {
let mut app = app();
let rider = spawn(&mut app, rider_model(), 2404);
let child = app
.world_mut()
.spawn((
model(vec![clip(0, 1, true), masked(SHUFFLE, 2, 12)]),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
crate::entities::mount::MountBody { host: rider },
))
.id();
app.world_mut()
.entity_mut(rider)
.insert(crate::entities::mount::MountChild(child));
app.update();
emote(&mut app, rider, SHUFFLE);
assert_eq!(
drv(&app, child).active_anim(),
Some(SHUFFLE),
"the mount's bone 0, full-body"
);
assert!(drv(&app, child).overlay.is_none());
assert!(
drv(&app, rider).overlay.is_none(),
"not CLASS_A: the rider idles"
);
assert_eq!(drv(&app, rider).active_anim(), Some(MOUNT));
}
#[test]
fn standing_eat_takes_the_key_bone() {
let mut app = app();
let unit = spawn(
&mut app,
model(vec![clip(0, 1, true), masked(EAT, 2, 12)]),
0,
);
app.update();
emote(&mut app, unit, EAT);
assert!(drv(&app, unit).overlay.is_some_and(|o| o.id == EAT));
assert_eq!(
drv(&app, unit).active_anim(),
Some(0),
"the legs keep Stand"
);
}
#[test]
fn a_key_bone_play_without_a_split_bone_arms_nothing() {
let mut app = app();
let unit = spawn(
&mut app,
model(vec![clip(0, 1, true), clip(EAT, 2, false)]),
0,
);
app.update();
emote(&mut app, unit, EAT);
assert!(drv(&app, unit).overlay.is_none());
assert_eq!(
drv(&app, unit).active_anim(),
Some(0),
"no full-body fallback"
);
}
/// The armed id is the requested one (`0x712090` returns `+0xf8`, op4's raw argument), not
/// the clip a fallback plays: Stand runs on bone 0 while 68 is the requested id.
#[test]
fn the_armed_id_is_the_requested_one_not_the_fallback_clip() {
use super::super::super::select::Mode;
let mut app = app();
let unit = spawn(&mut app, model(vec![clip(0, 1, true)]), 0);
app.update();
let armed = |app: &App| {
let e = app.world().entity(unit);
super::super::armed_id(
e.get::<AnimDriver>().unwrap(),
e.get::<AnimationPlayer>().unwrap(),
e.get::<AnimationTransitions>().unwrap(),
e.get::<ModelAnimations>().unwrap(),
)
};
assert_eq!(armed(&app), Some(0), "Stand requested, Stand playing");
app.world_mut()
.entity_mut(unit)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Swing {
id: CHEER,
under: None,
};
assert_eq!(armed(&app), Some(CHEER), "the requested id, not clip 0");
}
/// A mounted rider's mount-set emote is tested once, on the rider: an id the rider has armed
/// is not forwarded to the mount child.
#[test]
fn a_rider_armed_id_stops_the_forward_to_the_mount() {
use super::super::super::select::Mode;
let mut app = app();
let rider = spawn(&mut app, model(vec![clip(0, 1, true)]), 1);
let child = app
.world_mut()
.spawn((
model(vec![clip(0, 1, true), clip(SHUFFLE, 2, false)]),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
))
.id();
app.world_mut()
.entity_mut(rider)
.insert(crate::entities::mount::MountChild(child));
app.update();
let child_mode = |app: &App| app.world().entity(child).get::<AnimDriver>().unwrap().mode;
let send = |app: &mut App, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: rider,
anim_id: SHUFFLE,
seq: 1,
via_player,
});
app.update();
};
// A control: a kit anim (no armed test) is forwarded.
send(&mut app, false);
assert!(
matches!(child_mode(&app), Mode::Swing { id: SHUFFLE, .. }),
"the mount-set emote reaches the mount"
);
app.world_mut()
.entity_mut(child)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Gait;
// The rider has it armed: the player's test refuses before the forward.
app.world_mut()
.entity_mut(rider)
.get_mut::<AnimDriver>()
.unwrap()
.mode = Mode::Swing {
id: SHUFFLE,
under: None,
};
send(&mut app, true);
assert_eq!(child_mode(&app), Mode::Gait, "not forwarded to the mount");
}
/// `DoEmote`'s local play (`0x5fe2f0`) arms the clip at once; the `SMSG_EMOTE` echo that follows
/// through the emote player finds the id armed (`0x5fcd56`) and leaves it running.
#[test]
fn the_echo_of_a_local_emote_does_not_restart_it() {
let mut app = app();
let unit = spawn(
&mut app,
model(vec![clip(0, 1, true), masked(CHEER, 2, 12)]),
0,
);
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FORWARD,
..Default::default()
});
app.update();
let send = |app: &mut App, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: CHEER,
seq: 1,
via_player,
});
app.update();
};
let node = AnimationNodeIndex::new(12);
let speed = |app: &App| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(node)
.map(bevy::animation::ActiveAnimation::speed)
};
send(&mut app, false);
assert_eq!(speed(&app), Some(1.0), "the local play armed the key bone");
// A fresh arm resets the rate to 1.0, so a marker rate shows a restart.
app.world_mut()
.entity_mut(unit)
.get_mut::<AnimationPlayer>()
.unwrap()
.animation_mut(node)
.unwrap()
.set_speed(0.25);
send(&mut app, true);
assert_eq!(speed(&app), Some(0.25), "the echo did not re-arm the clip");
}
}
/// The `UNIT_NPC_EMOTESTATE` resolver (`0x5fd770`): the current interact NPC passes over a state
/// whose `Emotes.dbc` flags carry `0x2000`, and `SetInteractNPC` re-picks its base on open and
/// on clear.
mod emote_state {
use super::*;
use crate::creature_anim::Mode;
use crate::net::ObjectStore;
use crate::sound::EmoteSounds;
use crate::ui_session::InteractNpc;
use benilla_formats::EmoteSoundCatalog;
/// Emote states: 28 plays AnimID 234 and yields to interaction (`0x2000`); 29 plays 235 and
/// does not.
const YIELDING: u32 = 28;
const STUBBORN: u32 = 29;
/// `/stand`'s state 26 (vmangos `ChatHandler.cpp` `HandleTextEmoteOpcode`): AnimID 0.
const STAND_STATE: u32 = 26;
const FIELD_NPC_EMOTESTATE: u16 = 148;
/// Real graph nodes, so loop windows complete: every clip lasts 0.1 s, and the loops are one
/// pass per window.
fn model(
app: &mut App,
) -> (
ModelAnimations,
bevy::animation::graph::AnimationGraphHandle,
) {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
let ids: [(u16, bool); 6] = [
(0, true), // Stand
(234, true), // the yielding state's loop
(235, true), // the other state's loop
(118, false), // SpecialUnarmed, a one-shot
(41, true), // SwimIdle
(42, true), // Swim
];
let handles: Vec<_> = ids
.iter()
.map(|_| {
let mut c = AnimationClip::default();
c.set_duration(0.1);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
})
.collect();
let (graph, nodes) = AnimationGraph::from_clips(handles);
let graph = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
let clips = ids
.iter()
.zip(nodes)
.map(|(&(id, looping), node)| {
let mut c = clip(id, 0, looping);
c.node = node;
c.duration = 0.1;
c.blend_time = 0.0;
c.frequency = 0x4000;
c.replay = (1, 1);
c
})
.collect();
(
ModelAnimations {
graph: graph.clone(),
clips,
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationGraphHandle(graph),
)
}
fn app_with_rows() -> App {
let mut app = app();
app.init_resource::<InteractNpc>();
app.insert_resource(EmoteSounds(
EmoteSoundCatalog::default()
.with_row(YIELDING, 234, 0x2000)
.with_row(STUBBORN, 235, 0)
.with_row(STAND_STATE, 0, 0),
));
app
}
fn unit(app: &mut App, state: u32) -> Entity {
let (model, graph) = model(app);
app.world_mut()
.spawn((
model,
graph,
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_EMOTESTATE, state)])),
))
.id()
}
fn gait(app: &App, unit: Entity) -> Option<u16> {
app.world().entity(unit).get::<AnimDriver>().unwrap().gait
}
/// A creature's guid, which `SetInteractNPC` re-picks for; a player's does not.
const CREATURE: u64 = 0xF130_0000_0000_0042;
const PLAYER: u64 = 0x42;
fn interact_guid(app: &mut App, npc: Option<Entity>, guid: u64) {
*app.world_mut().resource_mut::<InteractNpc>() = InteractNpc(npc, npc.map(|_| guid));
app.update();
}
fn interact(app: &mut App, npc: Option<Entity>) {
interact_guid(app, npc, CREATURE);
}
fn mode(app: &App, unit: Entity) -> Mode {
app.world().entity(unit).get::<AnimDriver>().unwrap().mode
}
#[test]
fn a_yielding_state_stops_while_its_unit_is_the_interact_npc() {
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
app.update();
assert_eq!(gait(&app, npc), Some(234), "the work loop plays");
interact(&mut app, Some(npc));
assert_eq!(gait(&app, npc), Some(0), "the window is open: Stand");
// The clear's re-pick runs with the NPC still named (`0x493219` before the zeroing at
// `0x49334b`), so Stand holds until the next re-pick: the end of its window.
interact(&mut app, None);
for _ in 0..3 {
advance(&mut app, 10);
assert_eq!(
gait(&app, npc),
Some(0),
"cleared: Stand until its window ends"
);
}
for _ in 0..20 {
advance(&mut app, 25);
}
assert_eq!(gait(&app, npc), Some(234), "then the loop resumes");
}
/// A live one-shot is cut to Stand by the clear, not to the loop.
#[test]
fn the_clear_cuts_a_one_shot_to_stand() {
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
interact(&mut app, Some(npc));
app.world_mut().write_message(EmoteAnim {
entity: npc,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
assert!(matches!(mode(&app, npc), Mode::Swing { id: 118, .. }));
interact(&mut app, None);
assert_eq!(mode(&app, npc), Mode::Gait);
assert_eq!(gait(&app, npc), Some(0));
}
/// `0x493159` and `0x493203` skip the re-pick for a player (a trade partner).
#[test]
fn a_player_target_is_not_re_picked() {
let mut app = app_with_rows();
let partner = unit(&mut app, YIELDING);
app.update();
app.world_mut().write_message(EmoteAnim {
entity: partner,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
interact_guid(&mut app, Some(partner), PLAYER);
assert!(
matches!(mode(&app, partner), Mode::Swing { id: 118, .. }),
"open"
);
interact_guid(&mut app, None, PLAYER);
assert!(
matches!(mode(&app, partner), Mode::Swing { id: 118, .. }),
"clear"
);
}
/// The re-pick of a seated unit goes to its pose (`0x5fd550`), so the one-shot over it is
/// left to finish and the sit-down entry is not replayed.
#[test]
fn a_pose_under_the_one_shot_is_not_cut() {
use crate::creature_anim::select::Special;
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
app.world_mut().entity_mut(npc).insert(MovementState {
stand_state: 1,
..Default::default()
});
let swing = Mode::Swing {
id: 118,
under: Some(Special::Pose(1)),
};
app.world_mut()
.entity_mut(npc)
.get_mut::<AnimDriver>()
.unwrap()
.mode = swing;
interact(&mut app, Some(npc));
assert!(
!matches!(mode(&app, npc), Mode::Gait | Mode::Entering(_)),
"no forced Gait, so no replay of the entry: {:?}",
mode(&app, npc)
);
}
#[test]
fn a_state_without_the_bit_keeps_playing_while_interacted() {
let mut app = app_with_rows();
let npc = unit(&mut app, STUBBORN);
interact(&mut app, Some(npc));
assert_eq!(gait(&app, npc), Some(235));
}
#[test]
fn only_the_interact_npc_stops() {
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
let other = unit(&mut app, YIELDING);
interact(&mut app, Some(npc));
assert_eq!(gait(&app, npc), Some(0));
assert_eq!(gait(&app, other), Some(234), "a neighbour keeps working");
}
/// The re-pick overwrites bone 0 like any base arm, so it cuts a live one-shot.
#[test]
fn opening_the_window_re_picks_over_a_live_one_shot() {
let mut app = app_with_rows();
let npc = unit(&mut app, YIELDING);
app.update();
app.world_mut().write_message(EmoteAnim {
entity: npc,
anim_id: 118,
seq: 1,
via_player: false,
});
app.update();
assert!(matches!(
app.world().entity(npc).get::<AnimDriver>().unwrap().mode,
Mode::Swing { id: 118, .. }
));
interact(&mut app, Some(npc));
assert_eq!(gait(&app, npc), Some(0), "the one-shot is cut for Stand");
}
/// State 26 holds Stand over the swim idle until the unit moves (`0x5fd770` before `0x5fd830`).
#[test]
fn a_set_state_holds_stand_over_the_swim_idle_until_the_unit_moves() {
let mut app = app_with_rows();
let swimmer = unit(&mut app, STAND_STATE);
let bare = unit(&mut app, 0);
for u in [swimmer, bare] {
app.world_mut().entity_mut(u).insert(MovementState {
flags: move_flags::SWIMMING,
..Default::default()
});
}
app.update();
assert_eq!(gait(&app, swimmer), Some(0), "state 26 stands in the water");
assert_eq!(gait(&app, bare), Some(41), "no state: the swim idle");
app.world_mut().entity_mut(swimmer).insert(MovementState {
speed: 4.7,
flags: move_flags::SWIMMING | move_flags::FORWARD,
..Default::default()
});
app.update();
assert_eq!(gait(&app, swimmer), Some(42), "moving, it swims");
}
}
/// The emote player's armed-id test (`0x5fcd56`, `0x5fdb50`): an id already running on bone 0 is
/// not played again, whether it is the gait clip or an earlier one-shot; a different id is, and a
/// kit anim (`0x5fe2f0` directly) takes no such test.
#[test]
fn the_emote_player_skips_an_id_already_armed_but_plays_a_different_one() {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
let mut app = app();
let mut asset = |secs: f32| {
let mut c = AnimationClip::default();
c.set_duration(secs);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
};
let (stand, dance, wave) = (asset(10.0), asset(10.0), asset(10.0));
let (graph, nodes) = AnimationGraph::from_clips([stand, dance, wave]);
let graph_handle = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
let mut clips = vec![clip(0, 0, true), clip(69, 0, true), clip(67, 0, false)];
for (c, n) in clips.iter_mut().zip(nodes.iter().copied()) {
c.node = n;
c.duration = 10.0;
}
let unit = app
.world_mut()
.spawn((
ModelAnimations {
graph: graph_handle.clone(),
clips,
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph_handle),
AnimDriver::default(),
))
.id();
app.update(); // settle: Stand holds bone 0
let play = |app: &mut App, anim_id: u16, via_player: bool| {
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id,
seq: 1,
via_player,
});
advance(app, 500);
};
let swing = |app: &App| match app.world().entity(unit).get::<AnimDriver>().unwrap().mode {
super::super::select::Mode::Swing { id, .. } => Some(id),
_ => None,
};
let time = |app: &App, node| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(node)
.map(|a| a.seek_time())
};
// The gait's own id over itself: nothing arms.
play(&mut app, 0, true);
assert_eq!(swing(&app), None, "Stand over Stand is skipped");
// A kit anim has no such test: it arms the same id.
play(&mut app, 0, false);
assert_eq!(swing(&app), Some(0), "a kit anim is not skipped");
// A different id arms and runs.
play(&mut app, 69, true);
assert_eq!(swing(&app), Some(69), "a different id plays");
let t = time(&app, nodes[1]).expect("armed");
assert!(t > 0.4, "the dance has run since its arm: {t}");
// The same id over itself does not restart: its clip time keeps advancing.
play(&mut app, 69, true);
let t2 = time(&app, nodes[1]).expect("armed");
assert!(t2 > t, "the dance kept advancing, {t} then {t2}");
// A different id replaces it.
play(&mut app, 67, true);
assert_eq!(swing(&app), Some(67));
}
@@ -13,7 +13,7 @@ use bevy::prelude::*;
use crate::net::{EmoteKind, EmoteMessage, ObjectStore, RemoteMotion};
use crate::sound::EmoteSounds;
use super::{move_flags, EmoteAnim, Engaged, MovementState};
use super::{move_flags, select, EmoteAnim, Engaged, MovementState};
/// The performer's gate inputs: stand state, move flags (`MovementState` for us, `RemoteMotion`
/// for a remote player; a creature's spline carries no swim bit) and the [`Engaged`] marker.
@@ -45,7 +45,7 @@ pub(super) fn emote_to_anim(
};
let (store, movement, remote, engaged) =
units.get(entity).unwrap_or((None, None, None, false));
if !play_eligible(store, movement, remote, engaged) {
if !play_eligible(store, movement, remote, engaged, anim_id) {
debug!("emote_anim: suppressed anim {anim_id} for {entity:?}");
continue;
}
@@ -53,6 +53,7 @@ pub(super) fn emote_to_anim(
entity,
anim_id: anim_id as u16,
seq: play_seq.next(),
via_player: true,
});
}
}
@@ -65,10 +66,13 @@ pub(super) fn receive_eligible(stand_state: u8, swimming: bool) -> bool {
}
/// The shared player's half, `0x5fcd20`: no play while channeling (`0x5fcd83`) or in combat
/// (`0x5fcd9d`). Its already-armed test (`0x5fcd5d`) is the driver's same-id dedup, and its
/// `[+0xd58] & 0x400` test (`0x5fcd8e`) reads an anim-state bit with no counterpart here.
fn player_eligible(channeling: bool, in_combat: bool) -> bool {
!channeling && !in_combat
/// (`0x5fcd9d`), and LiftOff (192) and Land (200) skip the combat test alone (`0x5fcd5f`/`0x5fcd67`
/// set the flag that `0x5fcd90` branches past `0x60ecd0` on; the channel test at `0x5fcd83` runs
/// first). Its already-armed test (`0x5fcd5d`) is the driver's `armed_id`, and its
/// `[+0xd58] & 0x400` test (`0x5fcd8e`, the weapon-visual hold) is [`super::RangedHold`].
fn player_eligible(anim_id: u32, channeling: bool, in_combat: bool) -> bool {
let exempt = anim_id == u32::from(select::LIFT_OFF) || anim_id == u32::from(select::LAND);
!channeling && (exempt || !in_combat)
}
/// The whole gate for one unit, as both producers call it; `engaged` stands for the client's
@@ -78,6 +82,7 @@ pub(super) fn play_eligible(
movement: Option<&MovementState>,
remote: Option<&RemoteMotion>,
engaged: bool,
anim_id: u32,
) -> bool {
let fields = store.map(|s| &s.0);
let stand_state = fields.map_or(0, |f| f.unit_stand_state());
@@ -89,7 +94,53 @@ pub(super) fn play_eligible(
!= 0;
let channeling = fields.is_some_and(|f| f.unit_channel_spell() != 0);
let in_combat = engaged || fields.is_some_and(|f| f.unit_flags() & UNIT_FLAGS_COMBAT_BIT != 0);
receive_eligible(stand_state, swimming) && player_eligible(channeling, in_combat)
receive_eligible(stand_state, swimming) && player_eligible(anim_id, channeling, in_combat)
}
/// Our own unit's inputs to `DoEmote`'s local-play gate `0x5fd680`.
#[derive(Clone, Copy, Default)]
pub(crate) struct LocalPlay {
/// The shared dead test `0x605f90`: health 0, `UNIT_DYNFLAG_DEAD` or stand state 7.
pub(crate) reads_dead: bool,
pub(crate) stand_state: u8,
pub(crate) move_flags: u32,
pub(crate) vertical_speed: f32,
/// On a flying server spline (a taxi), which exempts the turn-shuffle test (`0x5fce47`).
pub(crate) flying: bool,
/// The loot-kneel latch with its kneeling target (`0x6126b0` and `0x612710`).
pub(crate) loot_kneel: bool,
pub(crate) channeling: bool,
/// The auto-attack target `[+0xc48]` alone (`0x60ecb0`), not the combat flag bit.
pub(crate) engaged: bool,
}
/// Move flags that void the turn-shuffle test: HOVER, SWIMMING and `0x800` (`0x5fce5d`).
const SHUFFLE_VOID: u32 = move_flags::HOVER | move_flags::SWIMMING | 0x800;
/// `0x5fd680`, the gate on `DoEmote`'s local play (`0x5ef660`, then `0x5fe2f0` at `0x5ef66f`): the emote plays at once only while
/// a direction key is held, and every other case waits for the server's `SMSG_EMOTE`. It refuses
/// the dead (`0x605f90`), a unit both jumping and falling far (`0x5fd6a9`), a jump with vertical
/// speed (`0x5fd6c5`), a kneeling looter (`0x5fd6e7`), a channel (`0x5fd701`), an auto-attack
/// target (`0x5fd721`), a turn shuffle (`0x5fd72c`, approximated) and any stand state but 0 (`0x5fd74d`, and
/// the cached `[+0xc1c]` at `0x5fd757`, which tracks it). The `[+0xd58]` test at `0x5fd711` refuses
/// only when both 0x400 ([`super::RangedHold`]) and 0x10000 (unmodelled) are set, and so is not
/// tested here; the turn-shuffle test approximates `0x5fce30`, which also exempts on the armed id,
/// exempts on `d58 & 0xc` and fires on the mouse-turn latch `d58 & 0x1800`. The ranged auto-repeat
/// test (`0x5fd735`)
/// needs sheath state 2, which the stow `DoEmote` runs first (`0x611cf0(0)`) has cleared.
pub(crate) fn local_play_eligible(i: &LocalPlay) -> bool {
let f = i.move_flags;
let turning = f & (move_flags::TURN_LEFT | move_flags::TURN_RIGHT) != 0;
let shuffling = turning && !i.flying && f & SHUFFLE_VOID == 0;
!i.reads_dead
&& !(f & move_flags::FALLING != 0 && f & move_flags::FALLING_FAR != 0)
&& (f & move_flags::FALLING == 0 || i.vertical_speed == 0.0)
&& f & move_flags::ANY_MOVE != 0
&& !i.loot_kneel
&& !i.channeling
&& !i.engaged
&& !shuffling
&& i.stand_state == 0
}
/// The flag half of the in-combat test `0x60ecd0` (`UNIT_FIELD_FLAGS` bit 11); the other half is
@@ -132,11 +183,24 @@ mod tests {
#[test]
fn channeling_or_combat_suppresses_the_play() {
assert!(
player_eligible(false, false),
player_eligible(66, false, false),
"idle and out of combat plays"
);
assert!(!player_eligible(true, false), "channeling suppresses");
assert!(!player_eligible(false, true), "in combat suppresses");
assert!(!player_eligible(66, true, false), "channeling suppresses");
assert!(!player_eligible(66, false, true), "in combat suppresses");
}
/// `0x5fcd5f`/`0x5fcd67`: LiftOff (192) and Land (200) skip the combat test, not the channel.
#[test]
fn lift_off_and_land_play_in_combat_but_not_while_channeling() {
assert!(player_eligible(192, false, true), "LiftOff in combat");
assert!(player_eligible(200, false, true), "Land in combat");
assert!(!player_eligible(66, false, true), "any other id refuses");
assert!(!player_eligible(193, false, true), "the neighbours refuse");
assert!(
!player_eligible(192, true, true),
"channeling still refuses"
);
}
/// The attack-target half of `0x60ecd0` (`[+0xc48]`, from ATTACKSTART to ATTACKSTOP) refuses
@@ -144,10 +208,114 @@ mod tests {
#[test]
fn an_engaged_unit_refuses_the_play_without_the_flag_bit() {
assert!(
play_eligible(None, None, None, false),
play_eligible(None, None, None, false, 66),
"idle, no store: plays"
);
assert!(!play_eligible(None, None, None, true), "engaged: refused");
assert!(
!play_eligible(None, None, None, true, 66),
"engaged: refused"
);
assert!(
play_eligible(None, None, None, true, 192),
"engaged LiftOff"
);
assert!(play_eligible(None, None, None, true, 200), "engaged Land");
}
fn moving() -> LocalPlay {
LocalPlay {
move_flags: move_flags::FORWARD,
..Default::default()
}
}
#[test]
fn a_local_emote_plays_only_while_a_direction_key_is_held() {
assert!(local_play_eligible(&moving()));
for dir in [
move_flags::BACKWARD,
move_flags::STRAFE_LEFT,
move_flags::STRAFE_RIGHT,
] {
let i = LocalPlay {
move_flags: dir,
..moving()
};
assert!(local_play_eligible(&i), "{dir:#x}");
}
assert!(
!local_play_eligible(&LocalPlay::default()),
"standing still"
);
let turn_only = LocalPlay {
move_flags: move_flags::TURN_LEFT,
..Default::default()
};
assert!(!local_play_eligible(&turn_only), "turning in place");
}
#[test]
fn each_local_play_gate_refuses_on_its_own() {
let base = moving();
let refused = [
LocalPlay {
reads_dead: true,
..base
},
LocalPlay {
stand_state: 1,
..base
},
LocalPlay {
loot_kneel: true,
..base
},
LocalPlay {
channeling: true,
..base
},
LocalPlay {
engaged: true,
..base
},
// Both jumping and falling far.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING | move_flags::FALLING_FAR,
..base
},
// A jump still rising.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING,
vertical_speed: 3.0,
..base
},
// Turning while moving is a turn shuffle.
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
..base
},
];
for (n, i) in refused.iter().enumerate() {
assert!(!local_play_eligible(i), "case {n}");
}
// A jump whose vertical speed reads exactly 0.0 (a step-off fall's first tick), and a turn while swimming or on a flying spline, pass.
for i in [
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::FALLING,
..base
},
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT | move_flags::SWIMMING,
..base
},
LocalPlay {
move_flags: move_flags::FORWARD | move_flags::TURN_RIGHT,
flying: true,
..base
},
] {
assert!(local_play_eligible(&i));
}
}
#[test]
@@ -114,7 +114,7 @@ pub(super) fn drive_gestures(
};
let (store, movement, remote, engaged) =
units.get(entity).unwrap_or((None, None, None, false));
if !super::emote_anim::play_eligible(store, movement, remote, engaged) {
if !super::emote_anim::play_eligible(store, movement, remote, engaged, anim_id) {
debug!("gesture: {gesture:?} suppressed for {entity:?}");
continue;
}
@@ -122,6 +122,7 @@ pub(super) fn drive_gestures(
entity,
anim_id: anim_id as u16,
seq: play_seq.next(),
via_player: true,
});
}
}
@@ -333,6 +333,7 @@ fn text_emote(
chat_log: &mut crate::ui_chat::ChatLog,
) {
out.write(EmoteMessage {
guid,
source: index.0.get(&guid).copied(),
kind: EmoteKind::Text(text_emote),
});
@@ -342,6 +343,7 @@ fn text_emote(
/// `SMSG_EMOTE`: an `Emotes.dbc` id to play on a unit (NPC scripts, a `/`-emote's animation).
fn emote(guid: u64, emote_id: u32, index: &GuidIndex, out: &mut MessageWriter<EmoteMessage>) {
out.write(EmoteMessage {
guid,
source: index.0.get(&guid).copied(),
kind: EmoteKind::Anim(emote_id),
});
+143 -44
View File
@@ -281,17 +281,82 @@ pub(super) enum Mode {
/// The ids a unit in [`Mode::Gait`] tries, most specific first: the `0x5fd8b0` chain without its
/// Special states. `ready` is the engaged idle, which locomotion outranks.
///
/// The pick without a state emote, which [`base_candidates`] adds.
pub(super) fn gait_candidates(
state: &MovementState,
walk_speed: f32,
ready: Option<u16>,
ranged_load: Option<u16>,
) -> &'static [u16] {
claimed_candidates(state, walk_speed, ready, ranged_load)
.unwrap_or_else(|| idle_fallback(state))
}
/// What the base picks: a fixed candidate list, or the unit's emote state's loop, Stand behind it.
pub(super) enum BaseCands {
Fixed(&'static [u16]),
State([u16; 2]),
}
impl BaseCands {
pub(super) fn as_slice(&self) -> &[u16] {
match self {
Self::Fixed(c) => c,
Self::State(c) => c,
}
}
}
/// The full pick of the `0x5fd8b0` chain in its byte order: the resolvers [`claimed_candidates`]
/// covers, then the state-emote resolver (`0x5fd770`, `0x5fd9a1`), then the idle fallback
/// (`0x5fd830`). `state_anim` is the resolver's verdict ([`state_emote_anim`]); a set state claims
/// the base over the swim idle, the prowl idle and Stand, so `/stand`'s state 26 holds Stand
/// against both until the unit moves.
pub(super) fn base_candidates(
state: &MovementState,
walk_speed: f32,
ready: Option<u16>,
ranged_load: Option<u16>,
state_anim: Option<u16>,
) -> BaseCands {
if let Some(c) = claimed_candidates(state, walk_speed, ready, ranged_load) {
return BaseCands::Fixed(c);
}
match state_anim {
Some(anim) => BaseCands::State([anim, STAND]),
None => BaseCands::Fixed(idle_fallback(state)),
}
}
/// The idle fallback (`0x5fd830`), the chain's last resolver: the swim idle on `SWIMMING`
/// (`0x5fd83c`, `[9e8] & 0x200000`), else the prowl idle, else Stand. The hover idle (193,
/// `0x5fd88d`) is not modelled.
fn idle_fallback(state: &MovementState) -> &'static [u16] {
if state.flags & move_flags::SWIMMING != 0 {
&[41, 0]
} else if state.stealthed {
&[STEALTH_STAND, STAND]
} else {
&[STAND]
}
}
/// The resolvers ahead of the state-emote resolver, `None` where none claims the base. The
/// locomotion resolver (`0x5fd100`) runs on `[9e8] & 0xf` alone, so a stationary swimmer is not
/// its: it falls through the standing resolvers to the swim idle in [`idle_fallback`].
fn claimed_candidates(
state: &MovementState,
walk_speed: f32,
ready: Option<u16>,
ranged_load: Option<u16>,
) -> Option<&'static [u16]> {
use move_flags::*;
let f = state.flags;
// Swimming (`0x5fd100`): turn, then strafe, then backward, then forward.
if f & SWIMMING != 0 {
return if f & (TURN_LEFT | TURN_RIGHT) != 0 {
let swimming = f & SWIMMING != 0;
// Swimming locomotion (`0x5fd100`): turn, then strafe, then backward, then forward.
if swimming && f & ANY_MOVE != 0 {
return Some(if f & (TURN_LEFT | TURN_RIGHT) != 0 {
&[41, 0]
} else if f & STRAFE_LEFT != 0 {
&[43, 42, 41, 0]
@@ -299,57 +364,58 @@ pub(super) fn gait_candidates(
&[44, 42, 41, 0]
} else if f & BACKWARD != 0 {
&[45, 41, 0]
} else if f & FORWARD != 0 {
&[42, 41, 0]
} else {
&[41, 0]
};
&[42, 41, 0]
});
}
// A flying spline, the taxi (`0x5fd19c`): Fly 135 ahead of backward and speed.
if state.flying {
return &[135, 0];
return Some(&[135, 0]);
}
// Backward dominates strafe (`0x5fd1bc`, `[9e8] & 2` → WalkBackwards 13).
if f & BACKWARD != 0 {
return &[13, 4, 0];
return Some(&[13, 4, 0]);
}
// Stealthed and moving, the prowl (`0x5fd1d3`); Walk is row 119's AnimationData fallback.
if state.stealthed && f & ANY_MOVE != 0 {
return &[STEALTH_WALK, 4, 0];
return Some(&[STEALTH_WALK, 4, 0]);
}
// Ground gaits by live speed (`0x5fd202`/`0x5fd224`): Sprint at 11 and above, Run above twice
// walk, else Walk. Strafe takes these too: the reference has no ground strafe branch.
if f & ANY_MOVE != 0 {
let s = state.speed;
return if s >= FAST_RUN_SPEED {
return Some(if s >= FAST_RUN_SPEED {
&[143, 5, 4, 0]
} else if s > 2.0 * walk_speed {
&[5, 4, 0]
} else {
&[4, 0]
};
});
}
// Turning in place: the shuffle (`0x5fd3f0`, 11 left, 12 right).
if f & TURN_LEFT != 0 {
return &[SHUFFLE_LEFT, STAND];
// Turning in place: the shuffle (`0x5fd3f0`, 11 left, 12 right); the fidget gate (`0x5fce30`)
// refuses a swimmer.
if !swimming && f & TURN_LEFT != 0 {
return Some(&[SHUFFLE_LEFT, STAND]);
}
if f & TURN_RIGHT != 0 {
return &[SHUFFLE_RIGHT, STAND];
if !swimming && f & TURN_RIGHT != 0 {
return Some(&[SHUFFLE_RIGHT, STAND]);
}
// Standing and engaged: the Ready idle (`0x5fd360`), gated on engagement, not the sheath.
// Standing and engaged: the Ready idle (`0x5fd360`), gated on engagement, not the sheath; a
// swimmer's is the swim idle (`0x5fd3ac`).
if let Some(r) = ready {
return match r {
return Some(match r {
_ if swimming => &[41, 0],
26 => &[26, 25, 0],
27 => &[27, 25, 0],
28 => &[28, 25, 0],
_ => &[25, 0],
};
});
}
// Auto-repeat in the ranged stance (`0x5fd460`), ahead of the chair loops (`0x5fd550`).
if let Some(l) = ranged_load {
// A finished Load becomes its Hold through the completion dispatch (`0x5fc3f0`, fired at
// `0x7075af`); each Hold falls back to its Load, so a model without it holds full draw.
return match l {
return Some(match l {
105 => &[105, 25, 0],
106 => &[106, 25, 0],
109 => &[109, 105, 25, 0],
@@ -357,16 +423,15 @@ pub(super) fn gait_candidates(
111 => &[111, 25, 0],
112 => &[112, 25, 0],
_ => &[25, 0],
};
});
}
// Standing: a chair loop (4/5/6), else the prowl idle, else Stand; state 2 (`SIT_CHAIR`)
// writes no override in the reference (its row declines, `0x5fd644`), so it stands.
// Standing: a chair loop (4/5/6); state 2 (`SIT_CHAIR`) writes no override in the reference
// (its row declines, `0x5fd644`), so it falls through.
match state.stand_state {
4 => &[102, 0],
5 => &[103, 0],
6 => &[104, 0],
_ if state.stealthed => &[STEALTH_STAND, STAND],
_ => &[STAND],
4 => Some(&[102, 0]),
5 => Some(&[103, 0]),
6 => Some(&[104, 0]),
_ => None,
}
}
@@ -453,16 +518,21 @@ pub(super) fn is_cast_anim(id: u16) -> bool {
matches!(id, 2 | 32 | 33 | 53 | 54)
}
/// Whether `cands` is bare Stand, the one slot the state-emote idle (`UNIT_NPC_EMOTESTATE`) may
/// fill. The prowl idle counts as bare: the reference's emote-state resolver (`0x5fd770`) runs
/// before the fallback idle (`0x5fd830`) that picks it.
pub(super) fn is_bare_stand(cands: &[u16]) -> bool {
cands == [STAND] || cands == [STEALTH_STAND, STAND]
}
/// The state-emote idle's candidates, Stand as the fallback; only for a bare-Stand frame.
pub(super) fn state_emote_gait(emote_anim: u16) -> [u16; 2] {
[emote_anim, STAND]
/// The state-emote resolver's verdict (`0x5fd770`): the row's `AnimID` for the unit's
/// `UNIT_NPC_EMOTESTATE`, `None` when it passes over. A state with no row passes over, a row
/// whose `AnimID` is 0 claims the base for Stand (`0x5fd816` stores `row+8` as it is), and the
/// current interact NPC passes over a row whose `EmoteFlags` carry `EMOTE_FLAG_INTERACTION`
/// (`0x5fd7e2`-`0x5fd7fd`), so a vendor stops hammering while his window is open.
pub(super) fn state_emote_anim(
state: u32,
anim: Option<u32>,
flags: Option<u32>,
interacting: bool,
) -> Option<u16> {
if state == 0 || (interacting && flags? & benilla_formats::EMOTE_FLAG_INTERACTION != 0) {
return None;
}
anim.map(|a| a as u16)
}
/// The mainhand swing id (`0x6246a0`); anything but an equipped melee weapon swings unarmed (16).
@@ -630,11 +700,33 @@ pub(super) enum OneShotRoute {
FullBody,
}
/// The CLASS_A set (`0x5fed90`), the only ids that may mask; its ranges' interior is inferred,
/// but only swings, emotes and casts reach [`route_oneshot`].
/// The CLASS_A set (`0x5fed90`'s byte table over `id - 2`), the only ids that may play on the
/// key bone; every other id plays full-body.
fn is_class_a(id: u16) -> bool {
matches!(id,
2 | 8..=10 | 14..=36 | 46..=49 | 51..=90 | 105..=113 | 117..=118 | 122..=138 | 185..=186 | 195)
2 | 8..=10 | 14..=36 | 46..=49 | 51..=74 | 76..=78 | 80..=90 | 105..=113 | 117 | 118
| 122..=125 | 128..=130 | 133 | 134 | 136..=138 | 185 | 186 | 195)
}
/// The mount-set (`0x5fec80`'s byte table), the ids a mounted rider's request sends to the mount
/// model.
fn is_mount_set(id: u16) -> bool {
matches!(id, 0 | 1 | 3..=6 | 8..=13 | 37..=45 | 92..=95 | 119 | 120 | 127 | 131 | 132 | 135 | 143 | 187)
}
/// Where a request goes while the unit has a mount model (`0x5fe7b5`..`0x5fe803`): a mount-set id
/// to the mount, a CLASS_A id to the rider's key bone (8-10 are both), anything else nowhere.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) struct MountedRoute {
pub(super) mount: bool,
pub(super) rider_upper: bool,
}
pub(super) fn route_mounted(id: u16) -> MountedRoute {
MountedRoute {
mount: is_mount_set(id),
rider_upper: is_class_a(id),
}
}
/// The COMBAT set (`0x5fcc10`), the ids an airborne unit masks: every swing, no emote and no
@@ -649,10 +741,17 @@ fn is_forced_full_body(id: u16) -> bool {
matches!(id, 1 | 6 | 131 | 132 | 57 | 58 | 118)
}
/// Route a one-shot by live state (`0x5fe6c8`..`0x5fe74d`): masked while the lower body is
/// EmoteEat, the `/eat` and `/drink` loop.
const EMOTE_EAT: u16 = 61;
/// Route an unmounted one-shot by live state (`0x5fe6c8`..`0x5fe74d`): masked while the lower body is
/// committed (moving, turning, swimming, a non-zero stand state, or a COMBAT id airborne), else
/// full-body, so one Attack1H is full-body standing and masked running.
pub(super) fn route_oneshot(id: u16, flags: u32, stand_state: u8) -> OneShotRoute {
// EmoteEat (61) unmounted always takes the key bone, whatever the state (`0x5fe834`).
if id == EMOTE_EAT {
return OneShotRoute::Masked;
}
if is_forced_full_body(id) || !is_class_a(id) {
return OneShotRoute::FullBody;
}
@@ -705,7 +804,7 @@ pub(super) fn scaled_rate(clip: &AnimClip, speed: f32, model_scale: f32) -> Opti
/// remote's relayed flags ([`RemoteMotion`]), a creature's spline, else still.
/// `creature_swimming` supplies the `SWIMMING` the wire never sends for a creature; `modes`, the
/// `SMSG_SPLINE_MOVE_*` grants, join the remote and creature flags (one word in the reference).
pub(super) fn unify(
pub(crate) fn unify(
movement: Option<&MovementState>,
remote: Option<&RemoteMotion>,
spline: Option<&Spline>,
@@ -472,13 +472,11 @@ fn ranged_load_idle_selects_by_weapon_and_ranks_below_ready() {
gait_candidates(&moving_forward(3.0), 2.5, None, Some(105))[0],
4
);
// It takes the bare-Stand slot, so it also blocks the state-emote idle.
assert!(!is_bare_stand(gait_candidates(
&standing,
2.5,
None,
Some(105)
)));
// It outranks the state-emote resolver (`0x5fd460` runs before `0x5fd770`).
assert_eq!(
base_candidates(&standing, 2.5, None, Some(105), Some(234)).as_slice(),
&[105, 25, 0]
);
}
/// `0x5fd460` claims the drawn ranged idle ([`ranged_idle_gate`]) on the ranged sheath
@@ -510,46 +508,85 @@ fn each_ranged_load_promotes_to_its_weapon_familys_hold() {
assert_eq!(ranged_hold_anim(46), 46); // a fire id is no Load and maps onto no hold
}
/// The state-emote resolver (`0x5fd770`) runs right after the chair loops and before the idle
/// fallback (`0x5fd830`, `0x5fd9a1`): a set state outranks the swim idle, the prowl idle and
/// Stand, and everything ahead of it outranks the state.
#[test]
fn state_emote_idle_only_fills_the_bare_stand_slot() {
assert!(is_bare_stand(gait_candidates(
&MovementState::default(),
2.5,
None,
None
)));
assert_eq!(state_emote_gait(200), [200, STAND]);
assert!(!is_bare_stand(gait_candidates(
&moving_forward(3.0),
2.5,
None,
None
)));
let turning = MovementState {
flags: move_flags::TURN_LEFT,
..Default::default()
fn a_set_state_outranks_the_idle_fallbacks_and_nothing_before_them() {
let claimed = |state: &MovementState, ready, ranged, anim| {
base_candidates(state, 2.5, ready, ranged, anim)
.as_slice()
.to_vec()
};
assert!(!is_bare_stand(gait_candidates(&turning, 2.5, None, None)));
let still = MovementState::default();
assert_eq!(claimed(&still, None, None, Some(234)), [234, STAND]);
assert_eq!(claimed(&still, None, None, None), [STAND]);
// Behind it: the swim idle (`/stand`, state 26, plays AnimID 0) and the prowl idle.
let swimming = MovementState {
flags: move_flags::SWIMMING,
..Default::default()
};
assert!(!is_bare_stand(gait_candidates(&swimming, 2.5, None, None)));
assert!(!is_bare_stand(gait_candidates(
&MovementState::default(),
2.5,
Some(26),
None
)));
// The chair stand states 4, 5 and 6 have their own slot, above bare Stand.
for stand_state in [4, 5, 6] {
let chair = MovementState {
stand_state,
..Default::default()
};
assert!(!is_bare_stand(gait_candidates(&chair, 2.5, None, None)));
}
assert_eq!(claimed(&swimming, None, None, None), [41, 0]);
assert_eq!(claimed(&swimming, None, None, Some(0)), [0, STAND]);
assert_eq!(claimed(&swimming, None, None, Some(234)), [234, STAND]);
let prowling = MovementState {
stealthed: true,
..Default::default()
};
assert_eq!(claimed(&prowling, None, None, None), [STEALTH_STAND, STAND]);
assert_eq!(claimed(&prowling, None, None, Some(234)), [234, STAND]);
// Ahead of it: locomotion (`0x5fd100`), swimming included, runs on the move bits alone.
let swim_forward = MovementState {
flags: move_flags::SWIMMING | move_flags::FORWARD,
..Default::default()
};
assert_eq!(claimed(&swim_forward, None, None, Some(234)), [42, 41, 0]);
assert_eq!(claimed(&moving_forward(3.0), None, None, Some(234)), [4, 0]);
// The turn shuffle, the Ready idle (a swimmer's is the swim idle), the ranged idle and the
// chair loops all come first.
let turning = MovementState {
flags: move_flags::TURN_LEFT,
..Default::default()
};
assert_eq!(claimed(&turning, None, None, Some(234)), [11, 0]);
assert_eq!(claimed(&still, Some(26), None, Some(234)), [26, 25, 0]);
assert_eq!(claimed(&swimming, Some(26), None, Some(234)), [41, 0]);
assert_eq!(claimed(&still, None, Some(105), Some(234)), [105, 25, 0]);
// `0x5fd460` has no swim gate: a swimmer takes it too.
assert_eq!(claimed(&swimming, None, Some(105), Some(234)), [105, 25, 0]);
let chair = MovementState {
stand_state: 4,
..Default::default()
};
assert_eq!(claimed(&chair, None, None, Some(234)), [102, 0]);
// A turning swimmer is no shuffle (`0x5fce30` refuses `SWIMMING`) and no mover: the state.
let swim_turning = MovementState {
flags: move_flags::SWIMMING | move_flags::TURN_LEFT,
..Default::default()
};
assert_eq!(claimed(&swim_turning, None, None, Some(234)), [234, STAND]);
assert_eq!(claimed(&swim_turning, None, None, None), [41, 0]);
}
/// The resolver's verdict: a row's `AnimID` is taken as it is, 0 included (`0x5fd816`); a missing
/// row or no state passes over; the interact NPC passes over a row with `0x2000`.
#[test]
fn the_state_resolver_claims_a_row_even_when_its_anim_is_stand() {
assert_eq!(state_emote_anim(26, Some(0), Some(0), false), Some(0));
assert_eq!(
state_emote_anim(28, Some(234), Some(0x2000), false),
Some(234)
);
assert_eq!(state_emote_anim(28, Some(234), Some(0x2000), true), None);
assert_eq!(state_emote_anim(29, Some(235), Some(0), true), Some(235));
assert_eq!(state_emote_anim(30, None, None, false), None, "no row");
assert_eq!(
state_emote_anim(0, Some(0), Some(0), false),
None,
"no state"
);
}
// ── The per-play one-shot route ──
@@ -631,8 +668,36 @@ fn route_classifier_memberships_are_the_decoded_bytes() {
for id in [16, 17, 18, 19, 85, 87, 88, 117] {
assert!(is_combat(id) && is_class_a(id), "swing {id}");
}
for id in [17, 66, 68, 80] {
assert!(is_class_a(id), "class-A {id}");
// The whole table, member and non-member alike, from the decoded bytes of `0x5fed90`.
let class_a: Vec<u16> = [2, 8, 9, 10, 14, 15]
.into_iter()
.chain(16..=36)
.chain(46..=49)
.chain(51..=74)
.chain(76..=78)
.chain(80..=90)
.chain(105..=113)
.chain([117, 118])
.chain(122..=125)
.chain(128..=130)
.chain([133, 134])
.chain(136..=138)
.chain([185, 186, 195])
.collect();
for id in 0..=300u16 {
assert_eq!(is_class_a(id), class_a.contains(&id), "CLASS_A {id}");
}
// The mount-set, `0x5fec80`.
let mount: Vec<u16> = [0, 1]
.into_iter()
.chain(3..=6)
.chain(8..=13)
.chain(37..=45)
.chain(92..=95)
.chain([119, 120, 127, 131, 132, 135, 143, 187])
.collect();
for id in 0..=300u16 {
assert_eq!(is_mount_set(id), mount.contains(&id), "mount-set {id}");
}
// Forced-full-body carve-outs never mask, even seated.
for id in [1, 6, 131, 132, 57, 58, 118] {
@@ -640,6 +705,34 @@ fn route_classifier_memberships_are_the_decoded_bytes() {
}
}
#[test]
fn mounted_routing_sends_each_id_where_the_bytes_do() {
let r = |id| route_mounted(id);
// EmoteKneel and EmoteBeg are in neither set: they play nowhere.
for id in [75, 79] {
assert_eq!((r(id).mount, r(id).rider_upper), (false, false), "{id}");
}
assert_eq!((r(68).mount, r(68).rider_upper), (false, true));
assert_eq!((r(12).mount, r(12).rider_upper), (true, false));
// Wound ids 8-10 are in both.
assert_eq!((r(9).mount, r(9).rider_upper), (true, true));
}
#[test]
fn standing_eat_is_masked_whatever_the_state() {
assert_eq!(route_oneshot(61, 0, 0), Masked);
assert_eq!(route_oneshot(61, move_flags::FORWARD, 0), Masked);
assert_eq!(route_oneshot(61, 0, 1), Masked);
}
#[test]
fn whirlwind_and_birth_play_full_body_while_moving() {
for id in [126, 127] {
assert_eq!(route_oneshot(id, move_flags::FORWARD, 0), FullBody, "{id}");
assert_eq!(route_oneshot(id, 0, 1), FullBody, "{id}");
}
}
#[test]
fn state_emote_idle_never_reached_during_a_special() {
// `current_special` takes jumps and poses before `gait_candidates` runs, so the order is:
@@ -974,9 +1067,10 @@ fn the_prowl_idle_is_the_lowest_priority_stand() {
gait_candidates(&idle, 2.5, None, None),
&[STEALTH_STAND, STAND],
);
assert!(
is_bare_stand(gait_candidates(&idle, 2.5, None, None)),
"the state-emote idle still owns this slot",
assert_eq!(
base_candidates(&idle, 2.5, None, None, Some(234)).as_slice(),
&[234, STAND],
"a set state outranks it",
);
let chair = MovementState {
stand_state: 4,
@@ -443,6 +443,7 @@ fn play_kit(
entity,
anim_id,
seq,
via_player: false,
});
}
}
+250
View File
@@ -0,0 +1,250 @@
//! A unit's creature type as the reference resolves it (`0x605570`), which the spell bind's
//! creature-type gate and the tracking dots read: the shapeshift form's type when above 0
//! (`SpellShapeshiftForm.dbc` column 12, `0x60559a`), else the cached creature template's
//! (`[unit+0xb30] + 0x18`, `0x6055a8`), else the race's (`ChrRaces.dbc` column 9, `0x6055ca`).
//! 0 is a unit with no type, which a creature-type-limited spell refuses.
use std::collections::HashMap;
use benilla_formats::ShapeshiftForm;
use crate::names::NameCache;
use crate::net::ObjectStore;
use crate::ui_action::Spells;
/// What the resolver reads beside the unit's own fields: the creature template cache and the
/// form table. A source that is absent resolves nothing at its stage.
#[derive(Clone, Copy, Default)]
pub(crate) struct CreatureTypeSources<'a> {
pub(crate) names: Option<&'a NameCache>,
pub(crate) forms: Option<&'a HashMap<u32, ShapeshiftForm>>,
}
impl<'a> CreatureTypeSources<'a> {
/// The sources a system holds as resources: the name cache and, once `Spell.dbc` has loaded,
/// the form table it carries.
pub(crate) fn of_resources(names: &'a NameCache, spells: Option<&'a Spells>) -> Self {
Self {
names: Some(names),
forms: spells.map(|s| &s.forms),
}
}
/// The unit's creature type, 0 for none. The template stage answers whatever the record
/// holds, 0 included, and is keyed by `OBJECT_FIELD_ENTRY` as the reference's cache is.
pub(crate) fn of(&self, store: &ObjectStore) -> u32 {
let fields = &store.0;
let form = self
.forms
.and_then(|forms| forms.get(&u32::from(fields.unit_shapeshift_form())))
.map(|row| row.creature_type)
.filter(|&ty| ty > 0);
if let Some(ty) = form {
return ty as u32;
}
let record = self
.names
.zip(fields.object_entry())
.and_then(|(names, entry)| names.creature_type(entry));
if let Some(ty) = record {
return ty;
}
// The race byte of `UNIT_FIELD_BYTES_0`: a player's, or 0 on a creature whose template
// has not come back, which has no row.
race_creature_type((fields.unit_bytes_0().unwrap_or(0) & 0xff) as u8)
}
}
/// `ChrRaces.dbc` column 9 by race id: Humanoid on all nine shipped rows, and 0 where the table
/// has no row (`0x6055b3`-`0x6055d1`).
fn race_creature_type(race: u8) -> u32 {
if (1..=9).contains(&race) {
CREATURE_TYPE_HUMANOID
} else {
0
}
}
/// `CreatureType.dbc` id 7.
const CREATURE_TYPE_HUMANOID: u32 = 7;
#[cfg(test)]
mod tests {
use super::*;
use crate::names::CreatureRecord;
use benilla_protocol::ObjectFields;
/// `UNIT_FIELD_BYTES_1` and `UNIT_FIELD_BYTES_0`, absolute descriptor indices.
const BYTES_1: u16 = 138;
const BYTES_0: u16 = 36;
const OBJECT_FIELD_ENTRY: u16 = 3;
fn store(entry: u32, race: u32, form: u32) -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(OBJECT_FIELD_ENTRY, entry),
(BYTES_0, race),
(BYTES_1, form << 16),
]))
}
fn names_with(entry: u32, creature_type: u32) -> NameCache {
let mut names = NameCache::default();
names.insert_creature(
entry,
Some(CreatureRecord {
name: "Test".into(),
subname: None,
creature_type,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
names
}
fn forms() -> HashMap<u32, ShapeshiftForm> {
let row = |creature_type| ShapeshiftForm {
creature_type,
..Default::default()
};
// Cat form is a Beast; a warrior stance carries type 0, and Tree of Life -1.
HashMap::from([(1, row(1)), (2, row(-1)), (17, row(0))])
}
/// The three stages in the reference's order: form above 0, then the template, then the
/// race, and 0 for a unit none of them names.
#[test]
fn stages_resolve_in_the_references_order() {
let names = names_with(69, 1);
let forms = forms();
let sources = CreatureTypeSources {
names: Some(&names),
forms: Some(&forms),
};
// A cat-form player is a Beast; unshifted, a Humanoid by race.
assert_eq!(sources.of(&store(0, 1, 1)), 1);
assert_eq!(sources.of(&store(0, 1, 0)), 7);
// A row of type 0 or -1 falls through to the race, not to a fixed answer.
assert_eq!(sources.of(&store(0, 5, 17)), 7);
assert_eq!(sources.of(&store(0, 5, 2)), 7);
// A cached creature is its template's type; the form still wins over it.
assert_eq!(sources.of(&store(69, 0, 0)), 1);
let humanoid = names_with(69, 7);
let sources = CreatureTypeSources {
names: Some(&humanoid),
forms: Some(&forms),
};
assert_eq!(sources.of(&store(69, 0, 0)), 7);
assert_eq!(
sources.of(&store(69, 0, 1)),
1,
"a form outranks the template"
);
// The template answers whatever it holds: type 0 does not fall through to the race.
let untyped = names_with(69, 0);
let sources = CreatureTypeSources {
names: Some(&untyped),
forms: None,
};
assert_eq!(sources.of(&store(69, 1, 0)), 0);
}
/// A creature whose template has not answered has race 0, which has no row: type 0. A form
/// id with no row, or no form table at all, resolves nothing at that stage.
#[test]
fn an_uncached_creature_and_a_missing_row_read_type_zero() {
let names = NameCache::default();
let forms = forms();
let sources = CreatureTypeSources {
names: Some(&names),
forms: Some(&forms),
};
assert_eq!(sources.of(&store(69, 0, 0)), 0, "no template, race 0");
assert_eq!(sources.of(&store(69, 0, 99)), 0, "no form row either");
assert_eq!(sources.of(&store(0, 10, 0)), 0, "race 10 has no row");
assert_eq!(CreatureTypeSources::default().of(&store(0, 1, 1)), 7);
assert_eq!(CreatureTypeSources::default().of(&store(69, 0, 1)), 0);
}
/// The shipped `SpellShapeshiftForm.dbc` through the resolver: the forms of type 1 name a
/// Beast, and every other row falls through to the race.
#[test]
fn the_shipped_forms_name_a_beast_or_fall_through_to_the_race() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let forms =
benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc");
let sources = CreatureTypeSources {
names: None,
forms: Some(&forms),
};
// Cat, Travel, Aquatic, Bear, Dire Bear, Creature - Bear, Creature - Cat, Ghost Wolf.
for id in [1, 3, 4, 5, 8, 14, 15, 16] {
assert_eq!(forms[&id].creature_type, 1, "form {id} is type 1");
assert_eq!(sources.of(&store(0, 1, id)), 1, "form {id}");
}
// Tree of Life and Battle Stance carry -1: the human race's type answers.
for id in [2, 17] {
assert!(forms[&id].creature_type <= 0, "form {id} carries no type");
assert_eq!(sources.of(&store(0, 1, id)), 7, "form {id}");
}
// Every row by the rule: above 0 names the type, else the race.
for (&id, row) in &forms {
let expected = u32::try_from(row.creature_type).ok().filter(|&t| t > 0);
assert_eq!(
sources.of(&store(0, 1, id)),
expected.unwrap_or(7),
"form {id}"
);
}
}
/// The unit snapshot carries the resolver's word for every unit, players included: a
/// shapeshifted player a Beast, an unshifted one a Humanoid by race, a form of no type the
/// same, and a creature its template's.
#[test]
fn the_snapshot_carries_the_resolved_type_word() {
let names = names_with(69, 1);
let forms = forms();
let sources = CreatureTypeSources {
names: Some(&names),
forms: Some(&forms),
};
let word = |store: &ObjectStore| {
crate::ui_unit::snapshot(store, 0, None, 0, None, sources).creature_type_name
};
assert_eq!(word(&store(0, 1, 1)).as_deref(), Some("Beast"), "cat form");
assert_eq!(
word(&store(0, 1, 0)).as_deref(),
Some("Humanoid"),
"no form"
);
assert_eq!(
word(&store(0, 1, 2)).as_deref(),
Some("Humanoid"),
"type -1"
);
assert_eq!(word(&store(69, 0, 0)).as_deref(), Some("Beast"), "template");
assert_eq!(word(&store(70, 0, 0)), None, "uncached: race 0, no type");
}
/// The frozen race table against the shipped `ChrRaces.dbc`.
#[test]
fn race_creature_type_matches_the_shipped_table() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let rows = benilla_formats::load_race_creature_types(&mut chain).expect("ChrRaces walk");
assert_eq!(rows.len(), 9, "ChrRaces.dbc row count");
for race in 0..=u8::MAX {
assert_eq!(
race_creature_type(race),
rows.get(&race).copied().unwrap_or(0),
"race {race}"
);
}
}
}
+1 -1
View File
@@ -483,7 +483,7 @@ mod macos {
}
// A cinematic hides the pointer too (`0x58b590`); `CursorOptions.visible` is inert here, so
// it joins the look's hide/unhide pair, keeping AppKit's hide counter balanced.
let looking = rig.is_looking() || cinematic.is_some_and(|c| c.is_playing());
let looking = rig.holds_cursor() || cinematic.is_some_and(|c| c.is_playing());
let stem = mode.0.stem();
// The key names the chosen cursor, so `NSCursor::set`, a WindowServer round-trip that can
// stall the main thread for milliseconds, fires only on a change or a drift.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54 -27
View File
@@ -28,6 +28,10 @@ use display::{
mod attach;
use attach::{attach_entity_visuals, build_dressup_preview, build_glue_pet, build_glue_preview};
/// Composited body skins by look, built off the main thread.
mod skin_composite;
use skin_composite::{land_skin_composites, SkinComposites, SkinKey, SkinSections};
/// The dynamic point lights an entity's own model carries, such as a held torch.
mod carried_light;
use carried_light::spawn_carried_lights;
@@ -251,6 +255,11 @@ impl Creatures {
self.catalog.foley_material(display_id)
}
/// A display's override race and sex (`CreatureDisplayInfoExtra`), `None` without one.
pub(crate) fn display_race_sex(&self, display_id: u32) -> Option<(u8, u8)> {
self.catalog.display_race_sex(display_id)
}
/// A display's collision height in raw model units; [`CollisionHeight`] is the world value.
pub(crate) fn collision_height(&self, display_id: u32) -> Option<f32> {
self.catalog.collision_height(display_id)
@@ -384,34 +393,28 @@ struct GameObjects {
#[derive(Resource)]
struct Characters(CharacterGeosets);
/// The `CharSections` skin lookup; without it a player's body skin stays untextured.
#[derive(Resource)]
struct SkinSections(CharSections);
/// Character-creation data (body displays, race and class combos, appearance ranges).
#[derive(Resource)]
pub(crate) struct CharCreate(pub(crate) CharCreateCatalog);
/// Composited body skins by look, so every player wearing a look shares one 256² atlas.
#[derive(Resource, Default)]
struct SkinComposites(benilla_assets::SpatialCache<SkinKey, Handle<Image>>);
/// When a net entity arrived, on the appear fade's clock: the reference stamps a unit's fade as its
/// create block is processed (`0x465c50` → `0x5fb880`, a player's through `0x5debe0` →
/// `0x613af0` at `0x5fb956` → `0x614f80`), before its model or composite is ready, so the ramp runs
/// from here however long the visual waits. A mount child carries its rider's, as the reference
/// writes the unit's one fade onto the mount model (`0x614ae6`–`0x614af4`, `0x614b9e`–`0x614bba`).
#[derive(Component, Clone, Copy, Debug, PartialEq)]
pub(crate) struct Arrival(pub(crate) f32);
/// What decides a composited body skin: race and sex pick the `CharSections` rows, the dials pick
/// the variations, and `equip` holds the worn armour display ids by body slot − 2.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(super) struct SkinKey {
pub(super) race: u8,
pub(super) sex: u8,
pub(super) skin: u8,
pub(super) face: u8,
pub(super) facial_hair: u8,
pub(super) hair_style: u8,
pub(super) hair_color: u8,
pub(super) equip: [u32; 8],
/// The guild emblem: two guilds' members wear one tabard display but must not share an atlas.
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
/// The tabard designer's preview: the emblem paints over an empty tabard slot.
pub(super) tabard_preview: bool,
/// Stamp each net entity's [`Arrival`] the frame it streams in.
fn stamp_arrivals(
mut commands: Commands,
time: Res<Time>,
arrived: Query<Entity, (Added<NetEntity>, Without<Arrival>)>,
) {
let now = time.elapsed_secs();
for entity in &arrived {
commands.entity(entity).insert(Arrival(now));
}
}
/// Marks a net entity whose visual is attached; the `waterfx` rig pre-marks its dummy unit.
@@ -449,9 +452,9 @@ fn evict_display_caches(
items.as_ref().map_or(0, |i| i.models.len()),
fx.models.len(),
glows.as_ref().map_or(0, |g| g.models.len()),
composites.0.len(),
composites.done.len(),
);
composites.0.clear();
composites.clear();
fx.models.clear();
if let Some(mut c) = creatures {
c.models.clear();
@@ -474,7 +477,10 @@ fn scope_entity_art(
mut scope: benilla_world::art_scope::ArtScope,
mut composites: ResMut<SkinComposites>,
) {
scope.apply(&mut composites.0, benilla_world::art_scope::ArtSlot::Skins);
scope.apply(
&mut composites.done,
benilla_world::art_scope::ArtSlot::Skins,
);
}
/// A built body's armed-idle box in model space, which [`publish_world_units`] restates as
@@ -603,6 +609,21 @@ impl Plugin for EntitiesPlugin {
.add_message::<live_display::DisplaySwapped>()
.add_systems(Startup, setup_entities.after(AssetSet::Open))
.add_systems(Update, (evict_display_caches, scope_entity_art))
// An arrival is stamped the frame it streams in, before its visual is asked for.
.add_systems(
Update,
stamp_arrivals
.after(WorldStage::Net)
.before(EntityVisualsSet),
)
// Finished body composites land before the frame's bodies ask for their atlas.
.add_systems(
Update,
land_skin_composites
.after(evict_display_caches)
.before(EntityVisualsSet)
.after(WorldStage::Net),
)
// After the net stage, whose Commands create the entity; until then its readers take the
// constructor default.
.add_systems(Update, stamp_collision_heights.after(WorldStage::Net))
@@ -874,7 +895,9 @@ fn setup_entities(
Err(e) => warn!("character geosets unavailable, players show every geoset: {e:#}"),
}
match CharSections::load(&mut chain) {
Ok(sections) => commands.insert_resource(SkinSections(sections)),
Ok(sections) => {
commands.insert_resource(SkinSections::new(sections, world_assets.chain.clone()))
}
Err(e) => warn!("char sections unavailable, player bodies stay untextured: {e:#}"),
}
match CharCreateCatalog::load(&mut chain) {
@@ -936,6 +959,10 @@ fn setup_entities(
Ok(catalog) => commands.insert_resource(crate::ui_mail::Stationery(catalog)),
Err(e) => warn!("stationery catalog unavailable, mail uses the default backdrop: {e:#}"),
}
match benilla_formats::load_packages(&mut chain) {
Ok(rows) => commands.insert_resource(crate::ui_mail::Packages(rows)),
Err(e) => warn!("mail packages unavailable, GetNumPackages answers 0: {e:#}"),
}
match benilla_formats::load_page_text_material_catalog(&mut chain) {
Ok(catalog) => commands.insert_resource(crate::ui_item_text::PageMaterials(catalog)),
Err(e) => warn!("page text materials unavailable, books read on parchment: {e:#}"),
@@ -0,0 +1,455 @@
//! Bodies arriving together, against the real install: each composite runs off the main thread and
//! its body is built only once it can draw dressed, as the reference withholds a unit whose first
//! composite is pending (`0x607da0` → `0x477860`, `0x481749` → `0x710c50`).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use benilla_formats::{load_item_display_catalog, open_chain, CharSections, NpcAppearance};
use benilla_protocol::ObjectFields;
use super::super::skin_composite::{land_skin_composites, SkinKey};
use super::super::{Equipment, ItemDisplays};
use super::*;
/// The one character-body display every arrival wears.
const BODY: u32 = 42;
/// Worn display ids by bodyslot − 2: tier-2 sets at their 1.12 displays.
const SETS: [[u32; 8]; 4] = [
[10840, 33983, 33990, 33986, 33989, 33982, 33984, 0],
[0, 33650, 31110, 31115, 31111, 31127, 33651, 0],
[0, 33667, 33665, 33672, 34269, 33666, 33668, 0],
[0, 34081, 34078, 34084, 34083, 34079, 34082, 0],
];
/// One synthetic batch that can feather, so a built body has a child to show and joins its
/// appear ramp.
fn body_part() -> super::super::EntityPart {
super::super::EntityPart {
mesh: Handle::default(),
geometry: Arc::new(benilla_formats::RenderSubmesh::default()),
aabb: None,
skinned_mesh: None,
welded_billboard: false,
material: Handle::default(),
material_interior: None,
material_interior_bake: None,
material_interior_bake_blend: None,
fade_blend: Some(Handle::default()),
zfill: None,
blend: benilla_formats::ModelBlend::Opaque,
additive: false,
two_sided: false,
geoset_id: 0,
char_slot: None,
billboard: None,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
uv_scale_seq: None,
ground_quad: None,
}
}
/// A loaded character-body display; `npc` gives it a creature-display look.
fn body_display(npc: Option<NpcAppearance>) -> DisplayModel {
DisplayModel {
handle: ModelHandle::M2(Handle::default()),
parts: Some(vec![body_part()]),
is_character_body: true,
npc_appearance: npc,
..super::super::display::empty_shell()
}
}
/// The attach chain over the real tables and chain: finished composites land, then bodies build.
fn app() -> Option<App> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = open_chain(&data).expect("open chain");
let tables = CharSections::load(&mut chain).expect("CharSections");
let items = load_item_display_catalog(&mut chain).expect("ItemDisplayInfo");
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<Image>()
.init_asset::<Mesh>()
.init_asset::<benilla_assets::materials::WowModelMaterial>()
.init_resource::<benilla_world::model_render::ModelMaterials>()
.init_resource::<super::super::corpse::BonesModels>()
.init_resource::<SkinComposites>()
.init_resource::<merge::MergedFormsCache>()
.init_resource::<benilla_world::doodad_anim::UvAnimMaterials>()
.init_resource::<benilla_world::doodad_anim::TintAnimMaterials>()
.init_resource::<benilla_world::mat_anim_table::MatAnimTable>()
.init_resource::<benilla_world::rig_palette::RigPalettes>()
.init_resource::<benilla_world::collision::ColliderEpoch>()
.insert_resource(CubeAssets {
mesh: Handle::default(),
player_mesh: Handle::default(),
player_mat: Handle::default(),
npc_mat: Handle::default(),
})
.insert_resource(SkinSections::new(tables, Arc::new(Mutex::new(chain))))
.insert_resource(ItemDisplays::icons_for_tests(items))
.add_systems(
Update,
(
super::super::stamp_arrivals,
land_skin_composites,
attach_entity_visuals,
)
.chain(),
);
Some(app)
}
/// A player's look: race, sex, face, hair style and its worn set.
#[derive(Clone, Copy)]
struct Look {
race: u8,
sex: u8,
face: u8,
hair: u8,
equip: [u32; 8],
}
impl Look {
fn key(&self) -> SkinKey {
SkinKey {
race: self.race,
sex: self.sex,
skin: 0,
face: self.face,
facial_hair: 0,
hair_style: self.hair,
hair_color: 0,
equip: self.equip,
emblem: None,
tabard_preview: false,
}
}
}
/// A player arriving in `look`, its gear settled.
fn arrive(app: &mut App, look: Look) -> Entity {
let bytes_0 = u32::from(look.race) | u32::from(look.sex) << 16;
let bytes = u32::from(look.face) << 8 | u32::from(look.hair) << 16;
app.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Player,
display_id: Some(BODY),
scale: 1.0,
},
Equipment {
bodyslots: look.equip,
settled: true,
..Default::default()
},
// UNIT_FIELD_BYTES_0 (36): race, gender; PLAYER_BYTES (193): face, hair style.
ObjectStore(ObjectFields::from_pairs(&[(36, bytes_0), (193, bytes)])),
Transform::default(),
Visibility::default(),
))
.id()
}
fn built(app: &App, e: Entity) -> bool {
app.world().entity(e).contains::<VisualAttached>()
}
fn children(app: &App, e: Entity) -> usize {
app.world().get::<Children>(e).map_or(0, |c| c.len())
}
/// A crowd arriving in one frame costs that frame no composite: every body waits, drawing
/// nothing, while its look composites off the main thread, and each is built on a frame its
/// finished atlas is already cached. A look already cached builds at once.
#[test]
fn a_crowd_composites_off_the_main_thread_and_each_body_waits_for_its_atlas() {
let Some(mut app) = app() else { return };
app.world_mut().insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([(BODY, body_display(None))]),
});
let races = [1u8, 2, 3, 4, 5, 6, 7, 8];
let looks: Vec<Look> = (0..12)
.map(|i| Look {
race: races[i % races.len()],
sex: (i / races.len()) as u8,
face: (i % 3) as u8,
hair: (i % 4) as u8,
equip: SETS[i % SETS.len()],
})
.collect();
let crowd: Vec<Entity> = looks.iter().map(|&l| arrive(&mut app, l)).collect();
app.update();
let composites = app.world().resource::<SkinComposites>();
assert_eq!(
composites.done.len(),
0,
"the arrival frame composited nothing on the main thread"
);
assert_eq!(composites.running(), crowd.len(), "one composite per look");
for &e in &crowd {
assert!(!built(&app, e), "a body waits for its composite");
assert_eq!(children(&app, e), 0, "and draws nothing meanwhile");
}
let deadline = Instant::now() + Duration::from_secs(20);
while crowd.iter().any(|&e| !built(&app, e)) {
assert!(Instant::now() < deadline, "every composite lands");
std::thread::sleep(Duration::from_millis(1));
app.update();
for (&e, look) in crowd.iter().zip(&looks) {
if built(&app, e) {
let atlas = app
.world_mut()
.resource_mut::<SkinComposites>()
.done
.fetch(&look.key());
assert!(
matches!(atlas, Some(Some(_))),
"a body is built only over its finished atlas"
);
assert!(children(&app, e) > 0, "and draws once built");
}
}
}
assert_eq!(app.world().resource::<SkinComposites>().running(), 0);
// A look already composited is not waited on.
let twin = arrive(&mut app, looks[0]);
app.update();
assert!(built(&app, twin), "a cached look builds on its first frame");
assert_eq!(app.world().resource::<SkinComposites>().running(), 0);
}
/// A body that waited on its composite joins the appear ramp its arrival started: the reference's
/// fade is stamped by the create block's appear handler (`0x613af0` → `0x614f80`) and ramps on the
/// wall clock whatever ShouldRender answers (`0x614a90` at `0x60800c`).
#[test]
fn a_body_that_waited_for_its_atlas_fades_from_its_arrival() {
let Some(mut app) = app() else { return };
app.world_mut().insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([(BODY, body_display(None))]),
});
let look = Look {
race: 1,
sex: 0,
face: 0,
hair: 0,
equip: SETS[0],
};
let player = arrive(&mut app, look);
app.update();
let arrived = app
.world()
.get::<super::super::Arrival>(player)
.expect("stamped the frame it arrived")
.0;
assert!(!built(&app, player), "the body waits for its atlas");
let deadline = Instant::now() + Duration::from_secs(20);
while !built(&app, player) {
assert!(Instant::now() < deadline, "the composite lands");
std::thread::sleep(Duration::from_millis(1));
app.update();
}
let built_at = app.world().resource::<Time>().elapsed_secs();
assert!(
built_at > arrived,
"the body was built frames after it arrived"
);
assert_eq!(
app.world()
.get::<benilla_world::model_fade::UnitAppearFade>(player)
.copied(),
Some(benilla_world::model_fade::UnitAppearFade::Pending {
since: built_at,
arrived,
}),
"its ramp runs from its arrival, its backstop from its build"
);
}
/// benilla's own palette heal rebuilds a body that was drawn: its composite is forced, so even a
/// look the cache has dropped rebuilds in the same frame and never drops out.
#[test]
fn a_healed_rig_rebuilds_its_body_in_the_same_frame() {
use bevy::ecs::system::RunSystemOnce;
let Some(mut app) = app() else { return };
app.world_mut().insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([(BODY, body_display(None))]),
});
let look = Look {
race: 2,
sex: 1,
face: 1,
hair: 2,
equip: SETS[1],
};
let player = arrive(&mut app, look);
let deadline = Instant::now() + Duration::from_secs(20);
while !built(&app, player) {
assert!(Instant::now() < deadline, "the composite lands");
std::thread::sleep(Duration::from_millis(1));
app.update();
}
// The scope sweep dropped the look, then the rig starved and heals.
app.world_mut()
.resource_mut::<SkinComposites>()
.done
.clear();
app.world_mut()
.entity_mut(player)
.insert((crate::net::Guid(7), benilla_world::rig_palette::RigStarved));
app.world_mut()
.run_system_once(super::super::live_display::heal_rig_starved)
.expect("the heal runs");
assert!(!built(&app, player), "the heal tore the visual down");
app.update();
assert!(built(&app, player), "and it rebuilt in the same frame");
assert!(children(&app, player) > 0, "drawing");
assert_eq!(app.world().resource::<SkinComposites>().running(), 0);
}
/// A creature display with a baked atlas loads it as is and is never waited on (`0x477866`); one
/// without composites like a player and waits.
#[test]
fn a_baked_npc_skin_never_waits_on_the_compositor() {
let Some(mut app) = app() else { return };
let npc = |bake_name: Option<&str>| NpcAppearance {
race: 1,
sex: 0,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
equipment: [0; 10],
bake_name: bake_name.map(str::to_owned),
};
app.world_mut().insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([
(7, body_display(Some(npc(Some("HumanMaleGuard.blp"))))),
(8, body_display(Some(npc(None)))),
]),
});
let mut unit = |display: u32| {
app.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Unit,
display_id: Some(display),
scale: 1.0,
},
Transform::default(),
Visibility::default(),
))
.id()
};
let (baked, live) = (unit(7), unit(8));
app.update();
assert!(built(&app, baked), "a baked skin builds on its first frame");
assert!(
!built(&app, live),
"a composited NPC skin waits for its atlas"
);
assert_eq!(app.world().resource::<SkinComposites>().running(), 1);
}
/// What a crowd of 40 distinct looks costs the main thread: its arrival frame's requests, each
/// frame's landing, against compositing the same crowd on this thread.
#[test]
#[ignore = "instrument: run by hand — cargo test -p benilla-app --lib arrival_tests::crowd_main_thread_cost -- --ignored --nocapture"]
fn crowd_main_thread_cost() {
use super::super::skin_composite::SkinComposites;
let data = benilla_formats::wow_data_or_skip!();
bevy::tasks::AsyncComputeTaskPool::get_or_init(bevy::tasks::TaskPool::new);
let mut chain = open_chain(&data).expect("open chain");
let tables = CharSections::load(&mut chain).expect("CharSections");
let items = load_item_display_catalog(&mut chain).expect("ItemDisplayInfo");
let sections = SkinSections::new(tables, Arc::new(Mutex::new(chain)));
let races = [1u8, 2, 3, 4, 5, 6, 7, 8];
let keys: Vec<SkinKey> = (0..40)
.map(|i| {
Look {
race: races[i % races.len()],
sex: (i / races.len() % 2) as u8,
face: (i % 3) as u8,
hair: (i % 4) as u8,
equip: SETS[i % SETS.len()],
}
.key()
})
.collect();
let plan = |key: &SkinKey| {
let worn: [Option<&benilla_formats::ItemDisplay>; 8] = std::array::from_fn(|i| {
(key.equip[i] != 0)
.then(|| items.get(key.equip[i]))
.flatten()
});
sections.tables.composite_plan(
key.race,
key.sex,
key.skin,
key.face,
key.facial_hair,
key.hair_style,
key.hair_color,
worn,
key.emblem,
key.tabard_preview,
)
};
let ms = |d: Duration| d.as_secs_f64() * 1e3;
let mut images = Assets::<Image>::default();
let mut lane = SkinComposites::default();
let t = Instant::now();
for key in &keys {
lane.request(*key, &sections, || plan(key));
}
let arrival = t.elapsed();
let (mut landing, mut worst, mut frames) = (Duration::ZERO, Duration::ZERO, 0);
let wall = Instant::now();
while lane.running() > 0 {
std::thread::sleep(Duration::from_millis(1));
let t = Instant::now();
if lane.land(&mut images) > 0 {
frames += 1;
}
let d = t.elapsed();
landing += d;
worst = worst.max(d);
}
println!(
"LANE arrival frame: {:.3} ms for {} looks; landing {:.3} ms over {frames} landing frames \
(worst {:.3} ms); all landed {:.1} ms after arrival",
ms(arrival),
keys.len(),
ms(landing),
ms(worst),
ms(wall.elapsed())
);
let mut sync = SkinComposites::default();
let t = Instant::now();
for key in &keys {
sync.force(*key, &sections, || plan(key), &mut images);
}
println!(
"LANE the same crowd composited on this thread: {:.1} ms",
ms(t.elapsed())
);
}
@@ -7,9 +7,9 @@ use bevy::prelude::*;
use crate::net::{NetEntity, ObjectStore};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::{repeat_texture_authored, LockRecover, WorldAssets};
use super::super::{DisplayModel, EntityPart, SkinKey, SkinSections};
use super::super::skin_composite::BodyAtlas;
use super::super::{DisplayModel, EntityPart, SkinComposites, SkinKey, SkinSections};
/// A character-model body's appearance, from the wire or from its display's
/// CreatureDisplayInfoExtra row; it drives both the geoset selection and the skin materials.
@@ -214,24 +214,131 @@ fn shared_skin_probe() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_PROBE_SHARED_SKIN").is_some())
}
/// Build a character body's per-appearance materials: the body atlas (composited live, or an NPC's
/// baked BLP), the hair, the cape and the extra skin, each at its batches' own blend and
/// sidedness from `parts`. Without the tables or the light buffer, parts keep their built material.
#[allow(clippy::type_complexity)]
pub(super) fn build_char_skin_materials(
/// The composite a look wears: `None` for a baked NPC atlas, which is never composited.
pub(super) fn skin_key(
look: &CharLook,
// The armor ids paint only a live composite; a baked NPC atlas already carries its gear.
equip: [u32; 8],
cloak: u32,
// Painted over a tabard whose display asks for it; `None` leaves the tabard's own art.
emblem: Option<benilla_formats::GuildEmblem>,
tabard_preview: bool,
) -> Option<SkinKey> {
let BodySkin::Composite { face } = look.body else {
return None;
};
// `WOW_PROBE_SHARED_SKIN` prices a shared body material and is never a look: one key for every
// body lets Bevy's batcher instance them.
if shared_skin_probe() {
return Some(SkinKey {
race: 1,
sex: 0,
skin: 0,
face: 0,
facial_hair: 0,
hair_style: 0,
hair_color: 0,
equip: [0; 8],
emblem: None,
tabard_preview: false,
});
}
Some(SkinKey {
race: look.race,
sex: look.sex,
skin: look.skin,
face,
facial_hair: look.facial_hair,
hair_style: look.hair_style,
hair_color: look.hair_color,
equip,
emblem,
tabard_preview,
})
}
/// What `key`'s composite reads, from the tables and the worn displays' rows.
fn composite_plan(
key: &SkinKey,
sections: &SkinSections,
displays: Option<&super::super::ItemDisplays>,
) -> Option<benilla_formats::CompositePlan> {
let mut worn: [Option<&benilla_formats::ItemDisplay>; 8] = [None; 8];
if let Some(catalog) = displays.map(|d| &d.catalog) {
for (i, id) in key.equip.iter().enumerate() {
if *id != 0 {
worn[i] = catalog.get(*id);
}
}
}
sections.tables.composite_plan(
key.race,
key.sex,
key.skin,
key.face,
key.facial_hair,
key.hair_style,
key.hair_color,
worn,
key.emblem,
key.tabard_preview,
)
}
/// A world body's atlas. A baked NPC skin loads as is and is never waited on (the fork at
/// `0x477866`); a composited one is cached, or running off the main thread, started here on a
/// miss. Without the tables the body stays untextured.
pub(super) fn body_atlas(
look: &CharLook,
key: Option<SkinKey>,
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
world_assets: Option<&WorldAssets>,
parts: &[EntityPart],
composites: &mut SkinComposites,
asset_server: &AssetServer,
) -> BodyAtlas {
if let BodySkin::Baked(name) = &look.body {
return BodyAtlas::Ready(Some(asset_server.load::<Image>(baked_npc_url(name))));
}
match (key, sections) {
(Some(key), Some(sections)) => {
composites.request(key, sections, || composite_plan(&key, sections, displays))
}
_ => BodyAtlas::Ready(None),
}
}
/// [`body_atlas`] composited on this thread on a miss ([`SkinComposites::force`]): the previews, whose
/// composite the reference forces, a re-dress of a standing body and a rig-heal rebuild.
pub(super) fn forced_body_atlas(
look: &CharLook,
key: Option<SkinKey>,
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
composites: &mut SkinComposites,
asset_server: &AssetServer,
images: &mut Assets<Image>,
skin_cache: &mut benilla_assets::SpatialCache<SkinKey, Handle<Image>>,
) -> Option<Handle<Image>> {
if let BodySkin::Baked(name) = &look.body {
return Some(asset_server.load::<Image>(baked_npc_url(name)));
}
let (key, sections) = (key?, sections?);
composites.force(
key,
sections,
|| composite_plan(&key, sections, displays),
images,
)
}
/// Build a character body's per-appearance materials: the body atlas ([`body_atlas`]), the hair,
/// the cape and the extra skin, each at its batches' own blend and sidedness from `parts`. Without
/// the tables or the light buffer, parts keep their built material.
pub(super) fn build_char_skin_materials(
look: &CharLook,
body_tex: Option<Handle<Image>>,
cloak: u32,
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
parts: &[EntityPart],
asset_server: &AssetServer,
mats: &mut benilla_world::model_render::M2BatchMaterials,
) -> CharSkinMaterials {
@@ -250,80 +357,6 @@ pub(super) fn build_char_skin_materials(
)
};
// The composite reads its BLPs synchronously off the shared chain, once per look behind the
// cache.
let body_tex: Option<Handle<Image>> = match &look.body {
BodySkin::Baked(name) => Some(asset_server.load::<Image>(baked_npc_url(name))),
BodySkin::Composite { face } => world_assets.and_then(|world| {
let mut key = SkinKey {
race: look.race,
sex: look.sex,
skin: look.skin,
face: *face,
facial_hair: look.facial_hair,
hair_style: look.hair_style,
hair_color: look.hair_color,
equip,
emblem,
tabard_preview,
};
// `WOW_PROBE_SHARED_SKIN` prices a shared body material and is never a look: one key
// for every body lets Bevy's batcher instance them.
if shared_skin_probe() {
key = SkinKey {
race: 1,
sex: 0,
skin: 0,
face: 0,
facial_hair: 0,
hair_style: 0,
hair_color: 0,
equip: [0; 8],
emblem: None,
tabard_preview: false,
};
}
match skin_cache.fetch(&key) {
Some(handle) => Some(handle),
None => {
let catalog = displays.map(|d| &d.catalog);
let mut worn: [Option<&benilla_formats::ItemDisplay>; 8] = [None; 8];
if let Some(catalog) = catalog {
for (i, id) in equip.iter().enumerate() {
if *id != 0 {
worn[i] = catalog.get(*id);
}
}
}
let chain = &mut world.chain.lock_recover();
let composed = sections
.0
.composite_body(
chain,
key.race,
key.sex,
key.skin,
key.face,
key.facial_hair,
key.hair_style,
key.hair_color,
worn,
key.emblem,
key.tabard_preview,
)
.ok()??;
// Through the upload gate like every texture: a no-op on this RGBA8
// composite, but it keeps the format and the bytes in agreement.
let handle = images.add(repeat_texture_authored(
benilla_assets::for_upload(composed),
(true, true),
));
skin_cache.insert(key, handle.clone());
Some(handle)
}
}
}),
};
// A single-sided and a two-sided set, chosen per batch by its own M2 `0x04`: the robe skirt
// (geoset 1302) is authored two-sided, the closed body is not.
let body = body_tex.map(|tex| {
@@ -342,7 +375,7 @@ pub(super) fn build_char_skin_materials(
// The hair texture (M2 type 6) also dresses the facial hair of races whose beards are
// geometry, so it resolves through `hair_mesh_texture`'s bald fallback: a bald orc has a beard.
let hair = sections
.0
.tables
.hair_mesh_texture(look.race, look.sex, look.hair_style, look.hair_color)
.and_then(|path| {
let hair_part = parts
@@ -377,7 +410,7 @@ pub(super) fn build_char_skin_materials(
// skinColor, loaded plain as the reference does, never composited. Its batches are an opaque
// single-sided core and alpha-cut two-sided fringe cards, so one set per sidedness.
let skin_extra = sections
.0
.tables
.skin_extra_texture(look.race, look.sex, look.skin)
.map_or((None, None), |path| {
let tex = asset_server.load::<Image>(format!(
@@ -660,7 +660,10 @@ mod tests {
bake_center: Vec3::ZERO,
idle_aabb: None,
now: 0.0,
fade: JoinedFade::Pending { since: SINCE },
fade: JoinedFade::Pending {
since: SINCE,
arrived: SINCE,
},
};
let mut own = TestOwn::default();
let mut queue = bevy::ecs::world::CommandQueue::default();
+149 -23
View File
@@ -7,7 +7,6 @@ use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use benilla_assets::WorldAssets;
use bevy::animation::transition::AnimationTransitions;
use crate::creature_anim::AnimDriver;
@@ -19,13 +18,17 @@ use benilla_world::model_fade::JoinedFade;
use benilla_world::model_render::ModelKind;
use benilla_world::particles;
use super::skin_composite::BodyAtlas;
use super::{
Characters, Creatures, CubeAssets, DisplayModel, GameObjects, ModelHandle, SkinComposites,
SkinSections, VisualAttached,
};
mod char_skin;
use char_skin::{build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip};
use char_skin::{
body_atlas, build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip,
skin_key,
};
mod dress;
mod merge;
use dress::{spawn_group, PartDress};
@@ -33,6 +36,8 @@ pub(super) use merge::MergedFormsCache;
mod preview;
pub(crate) use preview::equip_slot;
pub(super) use preview::{build_dressup_preview, build_glue_pet, build_glue_preview};
#[cfg(test)]
mod arrival_tests;
mod redress;
/// One body's drawn parts as `WOW_DRESS_CENSUS` prints them: index, merge group, blend, material.
@@ -229,8 +234,14 @@ pub(super) fn attach_entity_visuals(
Option<&super::mount::MountChild>,
Option<&super::mount::MountBody>,
Has<crate::transport::TransportAnchor>,
Option<&super::Arrival>,
Has<super::live_display::ShownRebuild>,
),
// A torn-down unit gets no model: the reference frees it at once (`0x464920`).
(
Without<VisualAttached>,
Without<benilla_world::model_fade::DespawnFade>,
),
Without<VisualAttached>,
>,
// A mounted rider waits on its mount child, whose attachment-0 point is the seat.
mount_children: super::mount::MountChildren,
@@ -250,8 +261,6 @@ pub(super) fn attach_entity_visuals(
// The character-skin build chain, nested for Bevy's 16-param system limit.
skin_build: (
Option<Res<SkinSections>>,
Option<Res<WorldAssets>>,
ResMut<Assets<Image>>,
ResMut<SkinComposites>,
Res<AssetServer>,
benilla_world::model_render::M2BatchMaterials,
@@ -260,6 +269,8 @@ pub(super) fn attach_entity_visuals(
ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
ResMut<benilla_world::mat_anim_table::MatAnimTable>,
// A forced composite's upload.
ResMut<Assets<Image>>,
),
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
mut collider_epoch: ResMut<benilla_world::collision::ColliderEpoch>,
@@ -267,8 +278,6 @@ pub(super) fn attach_entity_visuals(
) {
let (
sections,
world_assets,
mut images,
mut skin_composites,
asset_server,
mut mats,
@@ -277,9 +286,23 @@ pub(super) fn attach_entity_visuals(
mut uv_reg,
mut tint_reg,
mut anim_table,
mut images,
) = skin_build;
let now = time.elapsed_secs();
for (entity, net, equipment, reattached, mount_child, mount_body, anchored) in &pending {
for (
entity,
net,
equipment,
reattached,
mount_child,
mount_body,
anchored,
arrival,
shown_rebuild,
) in &pending
{
// The appear ramp's origin: the unit's arrival, however long its visual waited to build.
let arrived = arrival.map_or(now, |a| a.0);
// A player attaches once its equipment settles, so it never flashes naked.
if net.kind == EntityKind::Player && !equipment.is_some_and(|e| e.settled) {
continue;
@@ -324,6 +347,44 @@ pub(super) fn attach_entity_visuals(
None => None,
};
// A character model carries every hair, facial and body geoset, and is shared by its
// display, so the entity's own look picks which show.
let look = model.and_then(|_| resolve_char_look(net, dm, entity, &stores));
// A body whose composite is still running is not built: nothing of it draws, its mount
// included, until it can draw dressed, as the reference's ShouldRender answers the
// composite driver's 0 (`0x607e7c` → `0x481749`). Retried each frame, with the look of
// that frame. A display swap waits the same way, a fresh component in the reference
// (`0x607da0` → `0x5fb200`); a body benilla rebuilds while it was drawn does not.
let body_tex = match look.as_ref() {
Some(l) => {
let key = skin_key(l, equip, worn.emblem, worn.tabard_preview);
if shown_rebuild {
char_skin::forced_body_atlas(
l,
key,
displays.as_deref(),
sections.as_deref(),
&mut skin_composites,
&asset_server,
&mut images,
)
} else {
match body_atlas(
l,
key,
displays.as_deref(),
sections.as_deref(),
&mut skin_composites,
&asset_server,
) {
BodyAtlas::Ready(tex) => tex,
BodyAtlas::Pending => continue,
}
}
}
None => None,
};
// Invisible GameObjects and trigger creatures hide by transparent or empty M2s, never by a
// flag: the reference draws any loaded model and culls zero-alpha batches (`0x707b3a`),
// so their `parts` are empty and `named_a_model` keeps the cube off them.
@@ -349,6 +410,7 @@ pub(super) fn attach_entity_visuals(
mount_child.map(|&super::mount::MountChild(c)| c),
mount_display,
reattached,
arrival.copied(),
) {
super::mount::Seat::Wait => continue,
super::mount::Seat::Frame(anchor) => rider_root = anchor,
@@ -525,9 +587,6 @@ pub(super) fn attach_entity_visuals(
.insert(crate::creature_anim::BodyTwist::new(spine, head));
}
}
// A character model carries every hair, facial and body geoset, and is shared by its
// display, so the entity's own look picks which show.
let look = resolve_char_look(net, dm, entity, &stores);
// The worn geoset selectors, the helm's hide-mask rows included (`0x4799a0`).
let equip_geosets = equip_geosets(
displays.as_deref(),
@@ -536,7 +595,7 @@ pub(super) fn attach_entity_visuals(
worn.helm,
worn.tabard_preview,
);
let visible_geosets: Option<Vec<u16>> = look.as_ref().and_then(|l| {
let visible_geosets = look.as_ref().and_then(|l| {
let cg = characters.as_deref()?;
Some(cg.0.visible_geosets(
l.race,
@@ -549,16 +608,11 @@ pub(super) fn attach_entity_visuals(
let char_mats = match look.as_ref() {
Some(l) => build_char_skin_materials(
l,
equip,
body_tex,
worn.cloak,
worn.emblem,
worn.tabard_preview,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut mats,
),
@@ -625,7 +679,10 @@ pub(super) fn attach_entity_visuals(
fade: if reattached {
JoinedFade::Steady
} else {
JoinedFade::Pending { since: now }
JoinedFade::Pending {
since: now,
arrived,
}
},
};
// The shown batches spawn as material groups (`merge`).
@@ -653,9 +710,12 @@ pub(super) fn attach_entity_visuals(
}
// A `Reattached` rebuild drops any in-flight fade, as its parts spawned steady.
if unit_will_fade {
commands
.entity(entity)
.insert(benilla_world::model_fade::UnitAppearFade::Pending { since: now });
commands.entity(entity).insert(
benilla_world::model_fade::UnitAppearFade::Pending {
since: now,
arrived,
},
);
} else if reattached {
commands
.entity(entity)
@@ -856,7 +916,10 @@ pub(super) fn attach_entity_visuals(
.insert(VisualAttached)
// The display the visual was built with, for `refresh_live_display` to diff.
.insert(super::live_display::AppliedDisplay(net.display_id))
.remove::<super::equipment::Reattached>();
.remove::<(
super::equipment::Reattached,
super::live_display::ShownRebuild,
)>();
}
}
@@ -962,4 +1025,67 @@ mod tests {
*app.world_mut() = world;
}
}
/// A unit torn down before its visual built gets none: `0x464920` frees it at once, so the
/// build would run commands on an entity `apply_despawn_fade` has despawned by the time they
/// apply.
#[test]
fn a_torn_down_unit_is_not_built() {
use benilla_world::model_fade::DespawnFade;
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<Image>()
.init_asset::<Mesh>()
.init_asset::<benilla_assets::materials::WowModelMaterial>()
.init_resource::<benilla_world::model_render::ModelMaterials>()
.init_resource::<super::super::corpse::BonesModels>()
.init_resource::<SkinComposites>()
.init_resource::<merge::MergedFormsCache>()
.init_resource::<benilla_world::doodad_anim::UvAnimMaterials>()
.init_resource::<benilla_world::doodad_anim::TintAnimMaterials>()
.init_resource::<benilla_world::mat_anim_table::MatAnimTable>()
.init_resource::<benilla_world::rig_palette::RigPalettes>()
.init_resource::<benilla_world::collision::ColliderEpoch>()
.insert_resource(CubeAssets {
mesh: Handle::default(),
player_mesh: Handle::default(),
player_mat: Handle::default(),
npc_mat: Handle::default(),
});
let mut spawn = |torn_down: bool| {
let mut unit = app.world_mut().spawn(NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
});
if torn_down {
unit.insert(DespawnFade::default());
}
unit.id()
};
let (gone, kept, live) = (spawn(true), spawn(true), spawn(false));
// The build's commands are queued, the fade's despawn of the unit with nothing fadeable
// lands, then the queue applies: the order two unordered chains can interleave in.
let world = app.world_mut();
let mut build = IntoSystem::into_system(attach_entity_visuals);
build.initialize(world);
build
.run_without_applying_deferred((), world)
.expect("the build system runs");
world.despawn(gone);
build.apply_deferred(world);
let kept = world.entity(kept);
assert!(
!kept.contains::<VisualAttached>(),
"a torn-down unit has no visual"
);
assert!(!kept.contains::<benilla_world::interior::BodyBakeCenter>());
assert!(
world.entity(live).contains::<VisualAttached>(),
"a live unit still builds"
);
}
}
@@ -17,7 +17,6 @@ use crate::portrait::{
PreviewRider,
};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WorldAssets;
use super::super::equipment::{attach_id, ensure_item_model, placement, ItemModelKind};
use super::super::item_glow::{self, ItemGlows};
@@ -102,7 +101,6 @@ pub(in crate::entities) struct PreviewCtx<'a, 'w> {
/// nobody wears.
pub(in crate::entities) glows: Option<&'a mut ItemGlows>,
pub(in crate::entities) sections: Option<&'a SkinSections>,
pub(in crate::entities) world_assets: Option<&'a WorldAssets>,
pub(in crate::entities) images: &'a mut Assets<Image>,
pub(in crate::entities) skin_composites: &'a mut SkinComposites,
pub(in crate::entities) asset_server: &'a AssetServer,
@@ -120,7 +118,6 @@ pub(in crate::entities) fn build_glue_preview(
mut displays: Option<ResMut<ItemDisplays>>,
mut glows: Option<ResMut<ItemGlows>>,
sections: Option<Res<SkinSections>>,
world_assets: Option<Res<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut skin_composites: ResMut<SkinComposites>,
asset_server: Res<AssetServer>,
@@ -208,7 +205,6 @@ pub(in crate::entities) fn build_glue_preview(
displays: displays.as_deref_mut(),
glows: glows.as_deref_mut(),
sections: sections.as_deref(),
world_assets: world_assets.as_deref(),
images: &mut images,
skin_composites: &mut skin_composites,
asset_server: &asset_server,
@@ -286,7 +282,6 @@ pub(in crate::entities) fn build_dressup_preview(
mut displays: Option<ResMut<ItemDisplays>>,
mut glows: Option<ResMut<ItemGlows>>,
sections: Option<Res<SkinSections>>,
world_assets: Option<Res<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut skin_composites: ResMut<SkinComposites>,
asset_server: Res<AssetServer>,
@@ -342,7 +337,6 @@ pub(in crate::entities) fn build_dressup_preview(
displays: displays.as_deref_mut(),
glows: glows.as_deref_mut(),
sections: sections.as_deref(),
world_assets: world_assets.as_deref(),
images: &mut images,
skin_composites: &mut skin_composites,
asset_server: &asset_server,
@@ -574,18 +568,22 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
let visible = ctx.characters.map(|c| {
c.0.visible_geosets(race, sex, char_look.hair_style, char_look.facial_hair, &eg)
});
let char_mats = super::char_skin::build_char_skin_materials(
let body_tex = super::char_skin::forced_body_atlas(
&char_look,
bodyslots,
cloak,
spec.emblem,
false,
super::char_skin::skin_key(&char_look, bodyslots, spec.emblem, false),
ctx.displays.as_deref(),
ctx.sections,
ctx.world_assets,
parts,
ctx.skin_composites,
ctx.asset_server,
ctx.images,
&mut ctx.skin_composites.0,
);
let char_mats = super::char_skin::build_char_skin_materials(
&char_look,
body_tex,
cloak,
ctx.displays.as_deref(),
ctx.sections,
parts,
ctx.asset_server,
ctx.mats,
);
+125 -24
View File
@@ -11,7 +11,6 @@ use bevy::prelude::*;
use crate::net::{NetEntity, ObjectStore};
use crate::portrait::PortraitPart;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WorldAssets;
use benilla_world::interior::InteriorLit;
use benilla_world::model_fade::{
join_unit_appear_fade, FadeMaterials, PendingAppearFade, RenderFade,
@@ -23,8 +22,8 @@ use super::super::{
VisualAttached,
};
use super::char_skin::{
build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip,
CharSkinMaterials,
build_char_skin_materials, equip_geosets, forced_body_atlas, resolve_char_look,
resolve_worn_equip, skin_key, CharSkinMaterials,
};
use super::dress::{part_materials, spawn_group, DressedPart, PartDress};
use super::merge::{self, DressedGroup, MergedFormsCache};
@@ -57,7 +56,11 @@ pub(in crate::entities) fn redress_player_looks(
Option<&benilla_world::interior::BodyBakeCenter>,
Option<&benilla_world::model_fade::UnitAppearFade>,
),
With<VisualAttached>,
// A torn-down player is freed, not re-dressed (`0x464920`).
(
With<VisualAttached>,
Without<benilla_world::model_fade::DespawnFade>,
),
>,
// The standing parts, found again by their batch index.
mut standing: Query<(
@@ -77,13 +80,13 @@ pub(in crate::entities) fn redress_player_looks(
// The skin build chain, nested for Bevy's system-param limit.
skin_build: (
Option<Res<SkinSections>>,
Option<Res<WorldAssets>>,
ResMut<Assets<Image>>,
ResMut<SkinComposites>,
Res<AssetServer>,
benilla_world::model_render::M2BatchMaterials,
ResMut<Assets<Mesh>>,
ResMut<MergedFormsCache>,
// The composite's upload.
ResMut<Assets<Image>>,
),
// The own-material lane `spawn_part` takes; no character batch in the shipped data uses it.
mut own_lane: (
@@ -93,16 +96,8 @@ pub(in crate::entities) fn redress_player_looks(
),
time: Res<Time>,
) {
let (
sections,
world_assets,
mut images,
mut skin_composites,
asset_server,
mut mats,
mut meshes,
mut merged,
) = skin_build;
let (sections, mut skin_composites, asset_server, mut mats, mut meshes, mut merged, mut images) =
skin_build;
let now = time.elapsed_secs();
for (entity, net, live, mut applied, children, rig, bones, mut pose, bake_center, unit_fade) in
&mut players
@@ -125,6 +120,21 @@ pub(in crate::entities) fn redress_player_looks(
let worn = resolve_worn_equip(net, Some(live), Some(dm));
let look = resolve_char_look(net, Some(dm), entity, &stores);
// A re-dress is the reference's incremental composite (`0x47789c`–`0x4778c3`): the new
// geosets apply at once and the atlas composites that frame when its sections are
// resident, never gated or withheld. So it composites here, in the frame, taking over a
// running composite of the same look; only an arriving body waits for its atlas.
let body_tex = look.as_ref().and_then(|l| {
forced_body_atlas(
l,
skin_key(l, worn.bodyslots, worn.emblem, worn.tabard_preview),
displays.as_deref(),
sections.as_deref(),
&mut skin_composites,
&asset_server,
&mut images,
)
});
let eg = equip_geosets(
displays.as_deref(),
&worn.bodyslots,
@@ -132,7 +142,7 @@ pub(in crate::entities) fn redress_player_looks(
worn.helm,
worn.tabard_preview,
);
let visible: Option<Vec<u16>> = look.as_ref().and_then(|l| {
let visible = look.as_ref().and_then(|l| {
let cg = characters.as_deref()?;
Some(cg.0.visible_geosets(l.race, l.sex, l.hair_style, l.facial_hair, &eg))
});
@@ -140,16 +150,11 @@ pub(in crate::entities) fn redress_player_looks(
let char_mats: CharSkinMaterials = match look.as_ref() {
Some(l) => build_char_skin_materials(
l,
worn.bodyslots,
body_tex,
worn.cloak,
worn.emblem,
worn.tabard_preview,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut mats,
),
@@ -486,6 +491,58 @@ mod tests {
self.app.update();
}
/// Composite against the real tables and chain, landing each frame before the re-dress;
/// `false` without the install.
fn with_skin_data(&mut self) -> bool {
let Some(data) = benilla_formats::wow_data() else {
benilla_formats::skipped("no WoW install found", &benilla_formats::candidates());
return false;
};
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let tables = benilla_formats::CharSections::load(&mut chain).expect("CharSections");
let items = benilla_formats::load_item_display_catalog(&mut chain).expect("items");
self.app
.insert_resource(SkinSections::new(
tables,
std::sync::Arc::new(std::sync::Mutex::new(chain)),
))
.insert_resource(ItemDisplays::icons_for_tests(items))
.add_systems(
Update,
super::super::super::skin_composite::land_skin_composites
.before(redress_player_looks),
);
true
}
/// What the player's visual is dressed with.
fn applied(&self) -> Equipment {
self.app
.world()
.get::<AppliedEquipment>(self.player)
.unwrap()
.0
}
/// Whether the atlas of the fixture's look (race 1, sex 0, every dial 0) in `gear` is
/// composited and cached.
fn composited(&mut self, gear: Equipment) -> bool {
let key = super::super::super::SkinKey {
race: 1,
sex: 0,
skin: 0,
face: 0,
facial_hair: 0,
hair_style: 0,
hair_color: 0,
equip: gear.bodyslots,
emblem: None,
tabard_preview: false,
};
let mut lane = self.app.world_mut().resource_mut::<SkinComposites>();
matches!(lane.done.fetch(&key), Some(Some(_))) && lane.running() == 0
}
/// The batch indices currently standing under the player.
fn showing(&mut self) -> Vec<u32> {
let mut out: Vec<u32> = self
@@ -523,7 +580,7 @@ mod tests {
);
}
/// The re-dress fires once per change: the composite behind it reads BLPs synchronously.
/// The re-dress fires once per change.
#[test]
fn a_gear_change_restamps_what_the_visual_is_dressed_with() {
let mut s = stand(&[0], &[0], None);
@@ -565,4 +622,48 @@ mod tests {
"and the held item never moved",
);
}
/// Tier-2 gear, worn by bodyslot − 2.
fn tier2(sets: [u32; 8]) -> Equipment {
Equipment {
bodyslots: sets,
settled: true,
..Default::default()
}
}
/// A gear change on a standing body is the reference's incremental composite, never gated or
/// withheld (`0x47789c`–`0x4778c3`): the new look, atlas included, lands the frame it is seen.
#[test]
fn a_gear_change_composites_in_the_frame_it_is_seen() {
let mut s = stand(&[0, 401], &[0, 1], None);
if !s.with_skin_data() {
return;
}
let gear = tier2([10840, 33983, 33990, 33986, 33989, 33982, 33984, 0]);
s.wear(gear);
assert_eq!(s.applied(), gear, "the change lands this frame");
assert!(s.composited(gear), "over its own composited atlas");
assert_eq!(s.showing(), vec![0, 1], "the body kept drawing");
assert!(
s.app.world().get_entity(s.held).is_ok(),
"the held item never moved"
);
}
/// Gear that changes again: each change lands its own frame, and the latest is the look worn.
#[test]
fn the_latest_gear_wins() {
let mut s = stand(&[0], &[0], None);
if !s.with_skin_data() {
return;
}
let first = tier2([0, 33650, 31110, 31115, 31111, 31127, 33651, 0]);
let last = tier2([0, 33667, 33665, 33672, 34269, 33666, 33668, 0]);
s.wear(first);
assert_eq!(s.applied(), first);
s.wear(last);
assert_eq!(s.applied(), last, "the latest gear is worn");
assert!(s.composited(last));
}
}
+23 -3
View File
@@ -32,6 +32,13 @@ fn shard_model_index(param0: f32) -> usize {
idx.min(SHARD_MODELS.len() - 1)
}
/// The shard emitter's rate factor from the `spellEffectLevel` record's integer, read once at the
/// emitter's Initialize (`0x6eb95e`-`0x6eb989`), so a change reaches only emitters created after
/// it: 0.33 at 0, 0.66 at 1, and 1.0 for any other value, unclamped, or with no record.
fn shard_rate_scale(spell_effect_level: Option<i32>) -> f32 {
spell_effect_level.map_or(1.0, crate::video::spell_effect_scale)
}
/// A dest-anchored effect model: visual A, a shard or a GO burst.
#[derive(Component)]
pub(super) struct GroundFx {
@@ -92,8 +99,8 @@ pub(super) struct ShardEmitter {
path: String,
/// `DYNAMICOBJECT_RADIUS`: the wire radius is the spread (`0x6ebad0`).
radius: f32,
/// Shards per second: `CharParamOne` times the `spellEffectLevel` factor, 1.0 at its
/// default "2".
/// Shards per second: `CharParamOne` times the `spellEffectLevel` factor (`0x6eb95e`-
/// `0x6eb98f`), 1.0 at its default "2".
rate: f32,
/// Fractional emissions carried between frames.
accum: f32,
@@ -121,10 +128,12 @@ pub(super) fn arm_ground_effects(
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
mut sounds: MessageWriter<SpellKitSound>,
spell_effect_level: Option<Res<crate::video::SpellEffectLevel>>,
) {
let (Some(visuals), Some(spells), Some(mut fx)) = (visuals, spells, fx) else {
return;
};
let level_scale = shard_rate_scale(spell_effect_level.map(|l| l.0));
for (anchor, net, store) in &created {
if net.kind != EntityKind::DynamicObject {
continue;
@@ -180,7 +189,7 @@ pub(super) fn arm_ground_effects(
commands.entity(anchor).insert(ShardEmitter {
path,
radius,
rate: proc.params[1],
rate: proc.params[1] * level_scale,
accum: 0.0,
rng: 0x9e3779b97f4a7c15 ^ anchor.to_bits(),
});
@@ -415,6 +424,17 @@ pub(super) fn attach_ground_fx_models(
mod tests {
use super::*;
#[test]
fn the_shard_rate_follows_the_spell_effect_level_record() {
assert_eq!(shard_rate_scale(Some(0)), 0.33);
assert_eq!(shard_rate_scale(Some(1)), 0.66);
assert_eq!(shard_rate_scale(Some(2)), 1.0);
// The record's integer, not the handler's clamped copy: out of range is the ×1.0 arm.
assert_eq!(shard_rate_scale(Some(-1)), 1.0);
assert_eq!(shard_rate_scale(Some(5)), 1.0);
assert_eq!(shard_rate_scale(None), 1.0);
}
/// The `0x5d55c0` decode, `bits(f32(param0 + 512.0)) >> 14 & 0xff`; the real rows carry 0.0
/// (Blizzard) and 1.0 (Rain of Fire).
#[test]
@@ -98,20 +98,24 @@ impl SeatWriters<'_, '_> {
#[allow(clippy::type_complexity)]
pub(in crate::entities) fn attach_held_items(
mut commands: Commands,
mut units: Query<(
&HeldItems,
&BoneAttach,
Option<&mut HeldAttached>,
Entity,
Option<&UnitAppearFade>,
Option<&benilla_world::interior::BodyBakeCenter>,
Option<&Transform>,
// The wearer's slot, which a slotless item's parts carry so the body tint reaches them;
// an attached model inherits its parent's computed colours (`0x714000`).
Option<&benilla_world::rig_palette::RigSkin>,
// The attach joint spawns on demand from the live pose, never the rest pose.
Option<&mut benilla_world::rig_anim::RigPose>,
)>,
mut units: Query<
(
&HeldItems,
&BoneAttach,
Option<&mut HeldAttached>,
Entity,
Option<&UnitAppearFade>,
Option<&benilla_world::interior::BodyBakeCenter>,
Option<&Transform>,
// The wearer's slot, which a slotless item's parts carry so the body tint reaches them;
// an attached model inherits its parent's computed colours (`0x714000`).
Option<&benilla_world::rig_palette::RigSkin>,
// The attach joint spawns on demand from the live pose, never the rest pose.
Option<&mut benilla_world::rig_anim::RigPose>,
),
// A torn-down wearer gets no new item model: the reference frees it at once (`0x464920`).
Without<benilla_world::model_fade::DespawnFade>,
>,
held: Option<Res<ItemDisplays>>,
time: Res<Time>,
mut seats: SeatWriters,
@@ -1251,7 +1255,10 @@ mod tests {
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
UnitAppearFade::Pending {
since: SINCE,
arrived: SINCE,
},
))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
@@ -1339,7 +1346,10 @@ mod tests {
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
UnitAppearFade::Pending {
since: SINCE,
arrived: SINCE,
},
))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
@@ -82,7 +82,11 @@ pub(super) fn refresh_live_display(
Option<&CollisionHeight>,
&Transform,
),
With<VisualAttached>,
// A torn-down unit is freed, not rebuilt (`0x464920`).
(
With<VisualAttached>,
Without<benilla_world::model_fade::DespawnFade>,
),
>,
mut swapped: MessageWriter<DisplaySwapped>,
) {
@@ -188,6 +192,11 @@ pub(super) fn tick_scale_ease(
}
}
/// A rebuild of a body that was drawn, benilla's own palette heal: its composite is forced, so the
/// body never drops out for frames as an arriving one waits, and rebuilds at once as it always has.
#[derive(Component)]
pub(super) struct ShownRebuild;
/// Palette headroom the healer waits for, under the doodad reaper's low-water (256) so the reaper
/// makes room first; rebuilding into a tight table would starve again.
const HEAL_MIN_HEADROOM: usize = 128;
@@ -206,6 +215,7 @@ pub(super) fn heal_rig_starved(
(
With<benilla_world::rig_palette::RigStarved>,
With<VisualAttached>,
Without<benilla_world::model_fade::DespawnFade>,
),
>,
) {
@@ -241,7 +251,7 @@ pub(super) fn heal_rig_starved(
super::equipment::HeldAttached,
benilla_world::rig_palette::RigStarved,
)>()
.insert(super::equipment::Reattached);
.insert((super::equipment::Reattached, ShownRebuild));
}
}
@@ -314,6 +324,29 @@ mod tests {
);
}
#[test]
fn a_torn_down_starved_unit_is_not_rebuilt() {
let mut app = App::new();
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
app.add_systems(Update, heal_rig_starved);
let unit = app
.world_mut()
.spawn((
benilla_world::rig_palette::RigStarved,
VisualAttached,
benilla_world::model_fade::DespawnFade::default(),
crate::net::Guid(0xB0B),
))
.id();
app.update();
let e = app.world().entity(unit);
assert!(
e.contains::<VisualAttached>(),
"a fading unit keeps its visual"
);
assert!(!e.contains::<super::super::equipment::Reattached>());
}
#[test]
fn scale_ease_is_the_2s_cosine_smoothstep() {
let w = |elapsed: f32| {
+22 -3
View File
@@ -44,6 +44,8 @@ pub(super) fn spawn_mount_child(
display: u32,
scale: f32,
fade_skip: bool,
// The rider's arrival, so the mount rides the rider's appear ramp.
arrival: Option<super::Arrival>,
) -> Entity {
let child = commands
.spawn((
@@ -57,6 +59,9 @@ pub(super) fn spawn_mount_child(
Visibility::default(),
))
.id();
if let Some(arrival) = arrival {
commands.entity(child).insert(arrival);
}
if fade_skip {
commands.entity(child).insert(super::equipment::Reattached);
}
@@ -120,13 +125,21 @@ pub(super) fn seat_or_spawn_mount(
child: Option<Entity>,
mount_display: u32,
fade_skip: bool,
arrival: Option<super::Arrival>,
) -> Seat {
// `0x607a75`: `SCALE_X` × `creatureModelScale` alone, without `CreatureModelData.modelScale`.
let mount_scale = creatures
.and_then(|c| c.catalog.display_scale(mount_display))
.unwrap_or(1.0);
let Some(child) = child else {
spawn_mount_child(commands, unit, mount_display, mount_scale, fade_skip);
spawn_mount_child(
commands,
unit,
mount_display,
mount_scale,
fade_skip,
arrival,
);
return Seat::Wait;
};
// Built for a display the field has left, or gone: drop it and start over next pass.
@@ -226,8 +239,13 @@ pub(super) fn reseat_mounts(
&ObjectStore,
Option<&AppliedMount>,
Option<&MountChild>,
Option<&super::Arrival>,
),
// A torn-down unit is freed, not re-seated (`0x464920`).
(
With<super::VisualAttached>,
Without<benilla_world::model_fade::DespawnFade>,
),
With<super::VisualAttached>,
>,
// One pose query for both sides, the rider's rig and the mount's, borrowed in sequence below.
mut poses: Query<&mut RigPose>,
@@ -235,7 +253,7 @@ pub(super) fn reseat_mounts(
conforms: Query<(), With<super::conform::ConformNode>>,
creatures: Option<Res<super::Creatures>>,
) {
for (entity, net, store, applied, child) in &units {
for (entity, net, store, applied, child, arrival) in &units {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
@@ -293,6 +311,7 @@ pub(super) fn reseat_mounts(
child,
live,
true,
arrival.copied(),
) {
Seat::Wait => {}
Seat::Frame(anchor) => {
@@ -286,3 +286,35 @@ fn a_mount_without_a_seat_leaves_the_body_at_the_unit_matrix() {
assert_eq!(s.frame(), s.rider);
assert!(s.attachments_alive());
}
/// A mount child rides its rider's appear ramp: it carries the rider's arrival, which the arrival
/// stamp leaves alone, as the reference writes the unit's one fade onto the mount model
/// (`0x614ae6`–`0x614af4`, `0x614b9e`–`0x614bba`).
#[test]
fn a_mount_child_carries_its_riders_arrival() {
use crate::entities::Arrival;
use bevy::ecs::world::CommandQueue;
let mut app = App::new();
app.init_resource::<Time>()
.add_systems(Update, crate::entities::stamp_arrivals);
app.world_mut()
.resource_mut::<Time>()
.advance_to(std::time::Duration::from_secs(5));
let rider = app
.world_mut()
.spawn((Transform::default(), Visibility::default(), Arrival(1.0)))
.id();
let mut queue = CommandQueue::default();
let child = {
let mut commands = Commands::new(&mut queue, app.world());
spawn_mount_child(&mut commands, rider, 7, 1.0, false, Some(Arrival(1.0)))
};
queue.apply(app.world_mut());
app.update();
assert_eq!(
app.world().get::<Arrival>(child),
Some(&Arrival(1.0)),
"the mount fades on its rider's clock, not from its own spawn"
);
}
@@ -0,0 +1,363 @@
//! Composited body skins, one 256² atlas per look. An arriving body's first composite runs off the
//! main thread: the reference's world composite is unforced, so its section loads poll and never
//! block the frame (`0x44b430`), and it draws nothing of a unit whose first composite has not
//! finished, its ShouldRender answering `0x477860(cc, 0)`'s result (`0x607e7c`). Only the forced
//! callers wait on their loads (`0x44ad50`): the glue model (`0x470c59`, `0x471308`, `0x4731b6`),
//! the dressing room (`0x504485`), `PlayerModel`'s `SetUnit` (`0x5059be`) and world entry
//! (`0x49091e`). Here the forced lane ([`SkinComposites::force`]) composites on the calling thread.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use anyhow::Context;
use benilla_assets::{repeat_texture_authored, LockRecover, SpatialCache};
use benilla_formats::{blp_bytes_to_mip_chain, BlpMipChain, Chain, CharSections, CompositePlan};
use bevy::prelude::*;
use bevy::tasks::{block_on, futures_lite::future, AsyncComputeTaskPool, Task};
/// The `CharSections` skin lookup and the chain its textures read from; without it a player's body
/// skin stays untextured.
#[derive(Resource)]
pub(super) struct SkinSections {
pub(super) tables: CharSections,
chain: Arc<Mutex<Chain>>,
}
impl SkinSections {
pub(super) fn new(tables: CharSections, chain: Arc<Mutex<Chain>>) -> Self {
Self { tables, chain }
}
}
/// What decides a composited body skin: race and sex pick the `CharSections` rows, the dials pick
/// the variations, and `equip` holds the worn armour display ids by body slot − 2.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(super) struct SkinKey {
pub(super) race: u8,
pub(super) sex: u8,
pub(super) skin: u8,
pub(super) face: u8,
pub(super) facial_hair: u8,
pub(super) hair_style: u8,
pub(super) hair_color: u8,
pub(super) equip: [u32; 8],
/// The guild emblem: two guilds' members wear one tabard display but must not share an atlas.
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
/// The tabard designer's preview: the emblem paints over an empty tabard slot.
pub(super) tabard_preview: bool,
}
/// Where a body's atlas stands.
#[derive(Debug, PartialEq)]
pub(super) enum BodyAtlas {
/// Ready to bind; `None` for a look with no atlas (no base skin row, or art that never read).
Ready(Option<Handle<Image>>),
/// Its composite is running.
Pending,
}
/// A composite's whole work, run where it lands: reads, decodes and blits.
type Work = Box<dyn FnOnce() -> Option<BlpMipChain> + Send>;
/// Composited body skins by look, so every body wearing a look shares one atlas, and the
/// composites still running.
#[derive(Resource, Default)]
pub(super) struct SkinComposites {
/// Finished atlases, swept by distance ([`benilla_world::art_scope`]); `None` for a look with
/// no atlas, so it is never retried.
pub(super) done: SpatialCache<SkinKey, Option<Handle<Image>>>,
/// Composites on the async compute pool, one per look however many bodies wait on it.
running: HashMap<SkinKey, Task<Option<BlpMipChain>>>,
}
impl SkinComposites {
/// A world body's atlas: finished, or running, its composite started here on a miss. `plan` is
/// read only then; `None` is a look with no atlas.
pub(super) fn request(
&mut self,
key: SkinKey,
sections: &SkinSections,
plan: impl FnOnce() -> Option<CompositePlan>,
) -> BodyAtlas {
self.request_with(key, || plan().map(|p| work(p, sections.chain.clone())))
}
/// [`Self::request`] over any work, so the lane is testable without the install.
fn request_with(&mut self, key: SkinKey, work: impl FnOnce() -> Option<Work>) -> BodyAtlas {
if let Some(done) = self.done.fetch(&key) {
return BodyAtlas::Ready(done);
}
if self.running.contains_key(&key) {
return BodyAtlas::Pending;
}
let Some(work) = work() else {
self.done.insert(key, None);
return BodyAtlas::Ready(None);
};
self.running.insert(
key,
AsyncComputeTaskPool::get().spawn(async move { work() }),
);
BodyAtlas::Pending
}
/// An atlas composited on this thread on a miss, or waited for if it is already running, as
/// the reference's forced composite waits on its section loads (`0x44ad50`) and passes no
/// admission test: the glue model's (`0x477860(cc, 1)` at `0x470c59`, `0x471308`, `0x4731b6`)
/// and the dressing room's (`0x504485`, in `0x504470`). A re-dress of a standing body and a
/// rig-heal rebuild come here too, so neither drops out for a frame.
pub(super) fn force(
&mut self,
key: SkinKey,
sections: &SkinSections,
plan: impl FnOnce() -> Option<CompositePlan>,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
self.force_with(
key,
|| plan().map(|p| work(p, sections.chain.clone())),
images,
)
}
/// [`Self::force`] over any work, so the lane is testable without the install.
fn force_with(
&mut self,
key: SkinKey,
work: impl FnOnce() -> Option<Work>,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
if let Some(done) = self.done.fetch(&key) {
return done;
}
let atlas = match self.running.remove(&key) {
Some(task) => block_on(task),
None => work().and_then(|w| w()),
};
self.install(key, atlas, images)
}
/// Move every finished composite into [`Self::done`]; how many landed.
pub(super) fn land(&mut self, images: &mut Assets<Image>) -> usize {
let mut finished = Vec::new();
self.running
.retain(|key, task| match block_on(future::poll_once(task)) {
Some(atlas) => {
finished.push((*key, atlas));
false
}
None => true,
});
let landed = finished.len();
for (key, atlas) in finished {
self.install(key, atlas, images);
}
landed
}
/// Upload one atlas and cache it by look.
fn install(
&mut self,
key: SkinKey,
atlas: Option<BlpMipChain>,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
// Through the upload gate like every texture: a no-op on this RGBA8 composite, but it
// keeps the format and the bytes in agreement.
let handle = atlas.map(|a| {
images.add(repeat_texture_authored(
benilla_assets::for_upload(a),
(true, true),
))
});
self.done.insert(key, handle.clone());
handle
}
/// How many composites are running.
#[cfg(test)]
pub(super) fn running(&self) -> usize {
self.running.len()
}
/// Drop every atlas and every running composite: the map-change teardown.
pub(super) fn clear(&mut self) {
self.done.clear();
self.running.clear();
}
}
/// A plan's work over the shared chain, which each read locks for its archive lookup alone.
fn work(plan: CompositePlan, chain: Arc<Mutex<Chain>>) -> Work {
Box::new(move || {
plan.run(|path| read_texture(&chain, path))
.inspect_err(|e| warn!("body skin composite failed: {e:#}"))
.ok()
})
}
/// Read and decode one BLP off `chain`, locked only to find its archive.
fn read_texture(chain: &Mutex<Chain>, path: &str) -> anyhow::Result<BlpMipChain> {
let name = path.replace('/', "\\");
let archive = chain.lock_recover().archive_for(&name)?;
let bytes = archive
.read_file(&name)
.with_context(|| format!("reading texture '{name}'"))?;
blp_bytes_to_mip_chain(&bytes).with_context(|| format!("decoding texture '{name}'"))
}
/// Land every finished composite before this frame's bodies ask for theirs.
pub(super) fn land_skin_composites(
mut composites: ResMut<SkinComposites>,
mut images: ResMut<Assets<Image>>,
) {
composites.land(&mut images);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
fn key(race: u8) -> SkinKey {
SkinKey {
race,
sex: 0,
skin: 0,
face: 0,
facial_hair: 0,
hair_style: 0,
hair_color: 0,
equip: [0; 8],
emblem: None,
tabard_preview: false,
}
}
/// A 1×1 atlas.
fn atlas() -> BlpMipChain {
BlpMipChain {
width: 1,
height: 1,
texels: benilla_formats::BlpTexels::Rgba8Unorm,
mips: vec![vec![255; 4]],
}
}
/// Work that finishes when the test says so, and counts how many times it ran. It gives up
/// after two seconds, so work run inline on the test's thread fails the test, never hangs it.
fn gated(ran: &Arc<std::sync::atomic::AtomicUsize>) -> (mpsc::Sender<()>, Work) {
let (tx, rx) = mpsc::channel::<()>();
let ran = ran.clone();
let work: Work = Box::new(move || {
ran.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
rx.recv_timeout(std::time::Duration::from_secs(2)).ok()?;
Some(atlas())
});
(tx, work)
}
/// Land until `key` is done, or give up after two seconds.
fn land_until(c: &mut SkinComposites, images: &mut Assets<Image>, key: SkinKey) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while c.running.contains_key(&key) && std::time::Instant::now() < deadline {
c.land(images);
std::thread::yield_now();
}
}
/// A look's composite runs once off the main thread however many bodies ask, and every body
/// gets the one atlas once it lands.
#[test]
fn a_look_composites_once_off_the_main_thread() {
AsyncComputeTaskPool::get_or_init(bevy::tasks::TaskPool::new);
let mut images = Assets::<Image>::default();
let mut c = SkinComposites::default();
let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (tx, w) = gated(&ran);
let mut w = Some(w);
assert_eq!(c.request_with(key(1), || w.take()), BodyAtlas::Pending);
// A second body of the same look waits on the same composite; its work is never built.
assert_eq!(
c.request_with(key(1), || panic!(
"a running look starts no second composite"
)),
BodyAtlas::Pending
);
assert_eq!(c.running(), 1);
assert_eq!(
c.land(&mut images),
0,
"nothing lands before the work finishes"
);
tx.send(()).unwrap();
land_until(&mut c, &mut images, key(1));
let BodyAtlas::Ready(Some(first)) = c.request_with(key(1), || None) else {
panic!("the landed atlas is ready");
};
assert_eq!(
c.request_with(key(1), || None),
BodyAtlas::Ready(Some(first))
);
assert_eq!(ran.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(c.running(), 0);
}
/// A forced composite of a look already running waits for that composite rather than starting
/// a second, and the atlas is the one every later request gets.
#[test]
fn a_forced_composite_takes_over_the_running_one() {
AsyncComputeTaskPool::get_or_init(bevy::tasks::TaskPool::new);
let mut images = Assets::<Image>::default();
let mut c = SkinComposites::default();
let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (tx, w) = gated(&ran);
let mut w = Some(w);
assert_eq!(c.request_with(key(3), || w.take()), BodyAtlas::Pending);
tx.send(()).unwrap();
let forced = c.force_with(
key(3),
|| panic!("a running look starts no second composite"),
&mut images,
);
assert!(forced.is_some(), "the running composite's atlas");
assert_eq!(ran.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(c.running(), 0);
assert_eq!(c.request_with(key(3), || None), BodyAtlas::Ready(forced));
}
/// A look with no atlas is ready at once, as `None`, and never retried.
#[test]
fn a_look_without_an_atlas_never_waits() {
let mut c = SkinComposites::default();
assert_eq!(c.request_with(key(9), || None), BodyAtlas::Ready(None));
assert_eq!(
c.request_with(key(9), || panic!("a look with no atlas is never retried")),
BodyAtlas::Ready(None)
);
assert_eq!(c.running(), 0);
}
/// The map-change teardown drops running composites with the cache: a body still waiting
/// starts its composite again.
#[test]
fn a_teardown_drops_the_running_composites() {
AsyncComputeTaskPool::get_or_init(bevy::tasks::TaskPool::new);
let mut c = SkinComposites::default();
let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (_tx, w) = gated(&ran);
let mut w = Some(w);
assert_eq!(c.request_with(key(2), || w.take()), BodyAtlas::Pending);
c.clear();
assert_eq!(c.running(), 0);
let (_tx2, w2) = gated(&ran);
let mut w2 = Some(w2);
assert_eq!(c.request_with(key(2), || w2.take()), BodyAtlas::Pending);
assert!(
w2.is_none(),
"a body still waiting starts its composite again"
);
}
}
+31 -10
View File
@@ -254,8 +254,6 @@ impl PluginGroup for GamePlugins {
.add(crate::raid_marks::RaidMarksPlugin)
.add(crate::vplates::VPlatesPlugin)
.add(crate::chat_bubble::ChatBubblePlugin)
// TOGGLEUI (`ALT-Z`): the whole quad layer goes dark, leaving the world and the cursor.
.add(crate::ui_hide::UiHidePlugin)
.add(UiItemsPlugin)
.add(UiGossipPlugin)
.add(UiMerchantPlugin)
@@ -698,11 +696,32 @@ pub(crate) mod schedule_tests {
/// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and
/// `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: it only reads templates, which
/// those two do not write, and a chat line commutes with their pushes;
/// - `spell::targeting::drain_spell_target_unit` against the lone `.after(UiInput)` cast
/// drains over the `CastLadder`, and the party, pet-book and chat drains over the unit-token
/// resolver: the class `drop_item_on_unit` and the world click's legs already carry, as none
/// of those drains orders against the target chain. A cast press and a unit-frame bind in one
/// frame take either order until the cast drains share a set that does.
/// - `script_calls::apply_script_calls`, in the target chain, against the `.after(UiInput)`
/// drains of gestures that are not such script calls (the ATTACKTARGET binding, the
/// item-pick commit, the GameObject openers, the chat, party, duel, trade and death drains)
/// over the `CastLadder`, the selection and the unit-token resolver: the class
/// `drop_item_on_unit` and the world click's legs already carry, as none of those drains
/// orders against the target chain. Such a gesture and a script call in one frame take
/// either order;
/// - the range compare's readers (`ui_action::state::feed_action_state`,
/// `ui_tooltip::spell_feed::feed_spell_tooltips`, `spell::targeting::feed_targeting_to_vm`,
/// the targeting cursor and the object-click commit) against the writers of `Player` and
/// `RemoteMotion` (`camera_saved::load_camera_pose`, `world_focus`'s focus publish and settle
/// release, `transport::compose_riders` and `ground_deck_riders`): the readers take the
/// movement flags of the caster (`Player::move_flags`) and its target
/// (`RemoteMotion::flags`), and those writers touch `login_pitch`, the settle fields and a
/// rider's pose, never the flags;
/// - Click to Move's steer and arrival (`player::approach::steer_approach`,
/// `target::click::act_on_arrival`) against what `steer_follow` and `act_on_right_click`
/// already pair with: the steer is chained after follow's and writes the same `Player`
/// fields, the arrival runs the click's dispatchers, and their `Transform` reads are the
/// disjoint-lane kind. The dispatchers also carry the walk's start (`Player`,
/// `FollowState`, `Approach`, the stand request), which only a click, an arrival or a
/// crate's interaction writes;
/// - a crate's interaction (`target::click::act_on_interact`) against what
/// `act_on_right_click` and the cursor classifier already pair with: it runs the click's
/// dispatchers over the classifier's reading of its object, and its attack leg
/// selects as the click does, so it writes `Selection` where the click's drain does.
///
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
@@ -715,7 +734,11 @@ pub(crate) mod schedule_tests {
/// rows) and one pair each of the new systems against the two exclusive systems. Upstream
/// 5,481 + ours = 5,509, read off the merged tree (upstream v0.2.0 ba7fe6e2; three more of ours
/// pair with upstream's new v0.2.0 systems).
const UPDATE_ACTIONABLE_CEILING: usize = 5_509;
///
/// MONKEY (merge upstream b396bbf6, 2026-10-02): upstream's own declarations dropped its
/// ceiling to 4,980; the merged tree reads 4,999 = upstream 4,980 + 19 of ours (the lanes'
/// residual pairs above, unchanged in kind). Read off the merged tree's test run.
const UPDATE_ACTIONABLE_CEILING: usize = 4_999;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
@@ -1196,8 +1219,6 @@ pub(crate) mod schedule_tests {
"`told` is a `VmMemo` inside the session (a fresh VM re-begins), and the open is a player click"),
("ui_items/drain.rs", "drain_container_destroys", Because::FilledByVm,
"`take_container_destroys` is a VM-owned queue filled by Lua's `DeleteCursorItem`"),
("ui_items/drain.rs", "drain_container_uses", Because::FilledByVm,
"`take_container_uses` and `take_container_repairs` are Lua intents held by the VM"),
("ui_logout.rs", "feed_logout", Because::PlayerRoundTrip,
"both packets answer the `CMSG_LOGOUT_REQUEST`/cancel the game menu sent"),
("ui_loot/mod.rs", "drain_loot", Because::Deliberate,
+2 -2
View File
@@ -140,8 +140,8 @@ impl GlueDialog {
self.kind = Some(DialogKind::Realmlist);
self.edit = crate::textinput::field(crate::realmlist::MAX_LETTERS, false);
self.edit.set_text(current);
// `HighlightText(0, -1)`, the reference's select-all (`0x77cca0`), also resets the blink
// so the box opens on a solid caret.
// `HighlightText(0, -1)`, the reference's select-all (`0x77cca0`); the dialog's spawn
// starts the caret solid.
self.edit.highlight_text(0, -1);
self.set_text(prompt);
}
+90 -40
View File
@@ -515,22 +515,54 @@ pub(crate) fn caret_bar<C: Bundle>(
});
}
/// Which item of a glue edit box's row `[before][caret][selected][caret][after]` an entity is.
/// Flex places the caret and selection with no text measuring; an empty `Selected` has zero
/// width, and the caret slots are zero-width seams, so the text is one unbroken line.
/// Which item of a glue edit box's row `[before][selected][caret][selected][after]` an entity is.
/// Flex places the caret and selection with no text measuring: the selection splits at the caret,
/// an empty run has zero width and the caret is a zero-width seam, so the text is one unbroken line.
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlueFieldPart {
/// `display[..sel_start]`
/// The text before the selection.
Before,
/// The caret at the selection's start, and whenever nothing is selected.
CaretAtStart,
/// `display[sel_start..sel_end]`, the highlighted run.
Selected,
CaretAtEnd,
/// `display[sel_end..]`
/// The highlighted run from the selection's start to the caret.
SelectedHead,
/// The caret, which can sit inside the selection: `HighlightText` leaves it (`0x77cca0`).
Caret,
/// The highlighted run from the caret to the selection's end.
SelectedTail,
/// The text after the selection.
After,
}
/// What `part` of `field`'s row draws: its slice of `display`, for a text run, and whether it
/// shows. `focused` gates the caret, not the highlight: the reference's caret flush hides when
/// `E != [0xcf4dc8]` (`0x77da80`), but the selection flush (`0x77d950`, `0x77de70`) never reads
/// focus, so an unfocused box paints the selection it holds.
fn glue_field_part<'d>(
field: &EditBoxState,
display: &'d str,
focused: bool,
part: GlueFieldPart,
) -> (Option<&'d str>, bool) {
let (mut lo, mut hi) = (
field.sel_start.min(field.sel_end),
field.sel_start.max(field.sel_end),
);
// An empty selection has no place of its own: it sits at the caret.
if lo == hi {
(lo, hi) = (field.cursor, field.cursor);
}
// A caret outside a selection, which no glue field makes, draws at its nearer end.
let caret = field.cursor.clamp(lo, hi);
let [lo, caret, hi] = [lo, caret, hi].map(|b| field.text_to_display(b).min(display.len()));
match part {
GlueFieldPart::Before => (Some(&display[..lo]), true),
GlueFieldPart::SelectedHead => (Some(&display[lo..caret]), true),
// `caret_shown` is ticked by `textinput::tick_caret`, the chat caret's clock too.
GlueFieldPart::Caret => (None, focused && field.caret_shown),
GlueFieldPart::SelectedTail => (Some(&display[caret..hi]), true),
GlueFieldPart::After => (Some(&display[hi..]), true),
}
}
/// Paint one glue edit box's segments, selection and caret from its [`EditBoxState`].
pub(crate) fn paint_glue_field<'a>(
field: &EditBoxState,
@@ -543,24 +575,9 @@ pub(crate) fn paint_glue_field<'a>(
),
>,
) {
// `focused` gates the caret, not the highlight: the reference's caret flush hides when
// `E != [0xcf4dc8]` (`0x77da80`), but the selection flush (`0x77d950`, `0x77de70`) never reads
// focus, so an unfocused box paints the selection it holds.
let display = field.display();
let lo = field.sel_start.min(field.sel_end);
let hi = field.sel_start.max(field.sel_end);
let (d_lo, d_hi) = (field.text_to_display(lo), field.text_to_display(hi));
let d_cursor = field.text_to_display(field.cursor);
// `caret_shown` is ticked by `textinput::tick_caret`, the chat caret's clock too.
let caret_on = focused && field.caret_shown;
for (part, text, mut vis) in parts {
let (want_text, want_vis) = match part {
GlueFieldPart::Before => (Some(&display[..d_lo]), true),
GlueFieldPart::Selected => (Some(&display[d_lo..d_hi]), true),
GlueFieldPart::After => (Some(&display[d_hi..]), true),
GlueFieldPart::CaretAtStart => (None, caret_on && d_cursor <= d_lo),
GlueFieldPart::CaretAtEnd => (None, caret_on && d_cursor > d_lo),
};
let (want_text, want_vis) = glue_field_part(field, &display, focused, *part);
if let (Some(want), Some(mut t)) = (want_text, text) {
if t.0 != want {
t.0 = want.to_string();
@@ -650,7 +667,10 @@ pub(crate) fn glue_edit_box<E: Bundle, T: Bundle + Clone>(
..default()
},
));
if part == GlueFieldPart::Selected {
if matches!(
part,
GlueFieldPart::SelectedHead | GlueFieldPart::SelectedTail
) {
// `SetHighlightColor`'s default, opaque medium grey, as the chat box.
e.insert(BackgroundColor(Color::srgb(
96.0 / 255.0,
@@ -660,19 +680,9 @@ pub(crate) fn glue_edit_box<E: Bundle, T: Bundle + Clone>(
}
};
segment(f, GlueFieldPart::Before);
caret_bar(
f,
(marker.clone(), GlueFieldPart::CaretAtStart),
EDIT_FONT_SIZE,
s,
);
segment(f, GlueFieldPart::Selected);
caret_bar(
f,
(marker.clone(), GlueFieldPart::CaretAtEnd),
EDIT_FONT_SIZE,
s,
);
segment(f, GlueFieldPart::SelectedHead);
caret_bar(f, (marker.clone(), GlueFieldPart::Caret), EDIT_FONT_SIZE, s);
segment(f, GlueFieldPart::SelectedTail);
segment(f, GlueFieldPart::After);
});
});
@@ -761,6 +771,46 @@ pub(crate) fn glue_button<A: Component>(
mod tests {
use super::*;
/// The selection splits at the caret, so a caret that `HighlightText` left inside the
/// selection (`0x77cca0`) draws where it is; an empty selection sits at the caret.
#[test]
fn the_caret_draws_where_it_is_inside_a_selection() {
use GlueFieldPart::*;
let mut f = EditBoxState {
text: "hello".into(),
cursor: 2,
..Default::default()
};
f.highlight_text(0, -1);
let d = f.display();
let row = |f: &EditBoxState, focused| {
[Before, SelectedHead, Caret, SelectedTail, After]
.map(|part| glue_field_part(f, &d, focused, part))
};
assert_eq!(
row(&f, true),
[
(Some(""), true),
(Some("he"), true),
(None, true),
(Some("llo"), true),
(Some(""), true),
]
);
f.highlight_text(0, 0);
assert_eq!(
row(&f, false),
[
(Some("he"), true),
(Some(""), true),
(None, false),
(Some(""), true),
(Some("llo"), true),
],
"nothing selected, the caret hidden out of focus"
);
}
#[test]
fn colour_escapes_become_spans_not_text() {
let base = GOLD;
+7 -7
View File
@@ -99,25 +99,25 @@ impl Plugin for HoverLogPlugin {
}
}
/// The tooltip context, asked of the Lua VM, once per frame while recording.
/// The tooltip context, asked of the Lua VM, once per frame while recording; the owner, which 1.12
/// does not answer to Lua, from the engine.
fn tooltip_context(script: &UiScript) -> Option<(String, i64, String)> {
let chunk = r#"
if not GameTooltip or not GameTooltip:IsShown() then return "" end
local owner = GameTooltip:BenillaGetTooltipOwner()
local name = "(no owner)"
if owner and owner.GetName and owner:GetName() then name = owner:GetName() end
local first = ""
local first = "
if GameTooltipTextLeft1 and GameTooltipTextLeft1:GetText() then
first = GameTooltipTextLeft1:GetText()
end
return name .. "\t" .. GameTooltip:NumLines() .. "\t" .. first
return GameTooltip:NumLines() .. "\t" .. first
"#;
let s: String = script.eval(chunk).ok()?;
if s.is_empty() {
return None;
}
let mut parts = s.split('\t');
let owner = parts.next()?.to_string();
let owner = script
.tooltip_owner_name("GameTooltip")
.unwrap_or_else(|| "(no owner)".to_string());
let lines = parts.next()?.trim().parse::<f64>().ok()? as i64;
let first = parts.next().unwrap_or("").to_string();
Some((owner, lines, first))
+8 -2
View File
@@ -564,6 +564,12 @@ fn equipped_class(
Some(items.template(entry, guid, commands)?.class as u8)
}
/// Whether the disarm flag is up (`UNIT_FIELD_FLAGS & UNIT_FLAG_DISARMED`), which hides one hand
/// from the equipped-item search.
pub(crate) fn is_disarmed(store: &ObjectStore) -> bool {
store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED != 0
}
/// The equipment slot this character's disarm hides ([`crate::creature_anim::disarmed_hand`] over
/// the raw inventory), for the action bar's equipped-item requirement (`0x5f0c50`) and the
/// item-use refusal (`CGItem::Use` rung 15). `None` while the flag is down or neither hand holds
@@ -574,7 +580,7 @@ pub(crate) fn disarmed_equipment_slot(
items: &Items,
commands: &NetCommands,
) -> Option<u8> {
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
if !is_disarmed(store) {
return None;
}
let main = equipped_class(store, objects, items, commands, EQUIPMENT_SLOT_MAINHAND);
@@ -588,7 +594,7 @@ pub(crate) fn disarmed_equipment_slot_cached(
objects: &Objects,
items: &Items,
) -> Option<u8> {
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
if !is_disarmed(store) {
return None;
}
let class_of = |slot: u8| -> Option<u8> {
+39 -4
View File
@@ -42,6 +42,7 @@ mod combat_text;
mod console;
mod crash;
mod creature_anim;
mod creature_type;
mod cursor;
mod cvars;
mod death;
@@ -107,6 +108,7 @@ mod realmlist;
mod run_mode;
mod screen_fade;
mod screenshot;
mod script_calls;
mod shaders;
mod shadow_core;
@@ -152,7 +154,6 @@ mod ui_gamma;
mod ui_gm_ticket;
mod ui_gossip;
mod ui_guild;
mod ui_hide;
mod ui_honor;
mod ui_inspect;
mod ui_instance;
@@ -209,17 +210,36 @@ mod world_state_ui;
use bevy::prelude::*;
// The `benilla` launcher shim stamps the build id at compile time and hands it to [`run`];
// re-exported so the shim needs no bevy dependency of its own.
// A launcher stamps the build id at compile time and hands it to [`run`] or [`run_with`];
// re-exported so the `benilla` shim needs no bevy dependency of its own.
pub use benilla_world::build_id::BuildId;
/// The world viewer's entry point, the engine with no game attached, called by the
/// `benilla-worldview` shim.
pub use benilla_world::worldview::run as run_worldview;
pub use bevy::app::AppExit;
// A crate on top of benilla reads objects and interacts through these, with no protocol dependency.
pub use benilla_protocol::{messages::ObjectType, ObjectFields};
pub use net::Objects;
pub use target::Interact;
/// Builds and runs the client app. `build` is the launcher's compile-time git stamp, passed in as
/// data so the sha lives in the shim's fingerprint and a commit does not recompile this crate.
pub fn run(build: BuildId) -> AppExit {
launch(build, None)
}
/// [`run`], plus `extend` adding a crate's own plugins: how a feature 1.12.1 lacks is built on top
/// of benilla (`examples/extended_launcher.rs`), the build marked `extended` wherever it is named.
/// `extend` gets the client fully built and runs before the probe fleet, which sees what it adds.
pub fn run_with(build: BuildId, extend: impl FnOnce(&mut App)) -> AppExit {
launch(build, Some(Box::new(extend)))
}
/// What a crate on top of benilla adds to the built app ([`run_with`]).
type Extension<'a> = Box<dyn FnOnce(&mut App) + 'a>;
/// The one body behind [`run`] and [`run_with`], not generic, so a launcher compiles none of it.
fn launch(build: BuildId, extend: Option<Extension<'_>>) -> AppExit {
// `WOW_HOVER_LOG_REPORT=<csv>` and `WOW_CAPTURE=list` print and exit before any setup.
if let Ok(path) = std::env::var("WOW_HOVER_LOG_REPORT") {
dev::report_recorded_hover_log(&path);
@@ -235,12 +255,22 @@ pub fn run(build: BuildId) -> AppExit {
return AppExit::Success;
}
// Every line that names the build says whether a crate on top extended it.
let build = BuildId {
extended: extend.is_some(),
..build
};
// 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);
// 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.
crash::install(build);
let mut app = App::new();
// The panel footer and the preflight banner read the stamp back.
// The panel footer and the build banner read the stamp back.
app.insert_resource(build);
// Static-scene transform tracking pinned on: the default threshold re-decides every frame with
// two full scans of the rows the tracking exists to skip, and this scene is static-heavy. It
@@ -426,6 +456,11 @@ pub fn run(build: BuildId) -> AppExit {
}
}
// A crate on top of benilla adds its plugins here ([`run_with`]).
if let Some(extend) = extend {
extend(&mut app);
}
// The probe fleet, last so it observes the fully-built app; compiled out by
// `--no-default-features`.
app.add_plugins(dev::DevProbesPlugin);
+117 -12
View File
@@ -4,8 +4,8 @@
//!
//! Resolution, in order, the same shape as [`benilla_formats::wow_data`]:
//! 1. `$BENILLA_HOME`.
//! 2. `<project folder>/benilla-config/`, dev builds only: a shipped binary must not carry the
//! build machine's source tree.
//! 2. `<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/`.
//!
//! With `$WOW_CAPTURE` set every path resolves to `None`: a capture neither reads nor writes
@@ -28,6 +28,14 @@ pub(crate) fn home() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("BENILLA_HOME") {
return Some(PathBuf::from(over));
}
if cfg!(test) {
return None; // a unit test reaches no real state folder unless it pins `$BENILLA_HOME`
}
resident_home()
}
/// Steps 2 and 3 of [`home`]: the folder the build itself lives in.
fn resident_home() -> Option<PathBuf> {
// 2 · the project folder, dev builds only.
if let Some(root) = dev_project_root() {
return Some(root.join(STATE_DIR));
@@ -39,11 +47,15 @@ pub(crate) fn home() -> Option<PathBuf> {
.map(|dir| dir.join(STATE_DIR))
}
/// The project folder a dev build keeps its state in: the primary checkout, whichever worktree
/// built the binary, so every worktree shares one settings folder. Anything unexpected falls back
/// to the crate root's grandparent rather than `None`.
/// The project folder a dev build keeps its state in ([`benilla_formats::project_folder`]), through
/// [`shared_root`].
fn dev_project_root() -> Option<PathBuf> {
let here = crate::run_mode::dev_source_dir()?.ancestors().nth(2)?;
Some(shared_root(benilla_formats::project_folder()?))
}
/// `here`, or for a linked worktree its primary checkout, whichever worktree built the binary, so
/// every worktree shares one settings folder. Anything unexpected keeps `here`.
fn shared_root(here: &Path) -> PathBuf {
let dot_git = here.join(".git");
// A linked worktree: `.git` is a file pointing at `<primary>/.git/worktrees/<name>`.
if dot_git.is_file() {
@@ -59,12 +71,12 @@ fn dev_project_root() -> Option<PathBuf> {
.and_then(|d| d.parent())
{
if primary.is_dir() {
return Some(primary.to_path_buf());
return primary.to_path_buf();
}
}
}
}
Some(here.to_path_buf())
here.to_path_buf()
}
/// `benilla-config/config.toml`: the CVar overrides, the `Config.wtf` analog.
@@ -372,6 +384,19 @@ mod tests {
std::fs::remove_dir_all(&tmp).ok();
}
/// A unit test that does not pin `$BENILLA_HOME` resolves no state folder, so no test can write
/// the real one.
#[test]
fn a_test_without_an_override_has_no_state_folder() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::unset("BENILLA_HOME");
assert_eq!(home(), None);
assert_eq!(config_path(), None);
}
/// A missing or wrong `$WOW_DATA` does not move or lose the state folder.
#[test]
fn the_state_folder_no_longer_depends_on_finding_the_install() {
@@ -381,7 +406,8 @@ mod tests {
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::unset("BENILLA_HOME");
let _d = EnvGuard::set("WOW_DATA", "/nonexistent/benilla-test/Data");
let h = home().expect("a broken install path must not cost the player their config");
let h =
resident_home().expect("a broken install path must not cost the player their config");
assert!(
!h.starts_with("/nonexistent"),
"home() still reads $WOW_DATA: {}",
@@ -399,11 +425,10 @@ mod tests {
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::unset("BENILLA_HOME");
let h = home().expect("home() always resolves outside a capture");
let h = resident_home().expect("the resident folder always resolves");
assert!(h.ends_with(STATE_DIR), "{}", h.display());
let Some(here) = crate::run_mode::dev_source_dir().and_then(|d| d.ancestors().nth(2))
else {
let Some(here) = benilla_formats::project_folder() else {
// Player build: beside the binary.
let exe_dir = std::env::current_exe()
.unwrap()
@@ -430,6 +455,86 @@ mod tests {
}
}
/// A project folder that is a linked worktree keeps its state in the primary checkout, so every
/// worktree shares one; any other folder, a crate on top of benilla's included, keeps its own.
#[test]
fn the_state_root_is_the_folder_or_its_primary_checkout() {
let tmp = std::env::temp_dir().join(format!("benilla-root-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let primary = tmp.join("benilla");
std::fs::create_dir_all(primary.join(".git/worktrees/pool-3")).unwrap();
let slot = tmp.join("pool-3");
std::fs::create_dir_all(&slot).unwrap();
let gitdir = primary.join(".git/worktrees/pool-3");
std::fs::write(slot.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap();
assert_eq!(shared_root(&slot), primary);
assert_eq!(shared_root(&primary), primary);
let hello_mod = tmp.join("hello-mod");
std::fs::create_dir_all(hello_mod.join(".git")).unwrap();
assert_eq!(shared_root(&hello_mod), hello_mod);
let plain = tmp.join("plain");
std::fs::create_dir_all(&plain).unwrap();
assert_eq!(shared_root(&plain), plain);
std::fs::remove_dir_all(&tmp).ok();
}
/// A launcher's recorded folder is where a dev build keeps its state and reads its probe
/// identity; a player build resolves neither from it. Recording is process-wide, so the parent
/// runs this same test in a child process of the test binary, which records and resolves.
#[test]
fn a_recorded_launcher_folder_holds_the_state_and_the_identity() {
const CHILD: &str = "BENILLA_TEST_LAUNCHER_FOLDER";
if let Some(dir) = std::env::var_os(CHILD) {
let dir = PathBuf::from(dir);
benilla_formats::set_project_folder(dir.to_str().unwrap());
if crate::run_mode::dev_affordances() {
assert_eq!(resident_home(), Some(dir.join(STATE_DIR)));
assert_eq!(
crate::run_mode::declared_identity(),
Some(crate::run_mode::DeclaredIdentity {
user: "probe9".into(),
character: "Modchar".into()
})
);
} else {
let exe_dir = std::env::current_exe().unwrap();
assert_eq!(
resident_home(),
Some(exe_dir.parent().unwrap().join(STATE_DIR))
);
assert_eq!(crate::run_mode::declared_identity(), None);
}
return;
}
let tmp = std::env::temp_dir().join(format!("benilla-launcher-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join(".probe-identity"),
"WOW_USER=probe9\nWOW_PASS=unused\nWOW_CHAR=Modchar\n",
)
.unwrap();
let name =
"local_state::tests::a_recorded_launcher_folder_holds_the_state_and_the_identity";
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", name, "--nocapture"])
.env(CHILD, &tmp)
.env_remove("BENILLA_HOME")
.env_remove("WOW_CAPTURE")
.output()
.unwrap();
let report = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success() && report.contains("1 passed"),
"the child run:\n{report}"
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn write_atomic_creates_dirs_and_replaces_whole_files() {
let _l = ENV_LOCK
+19 -10
View File
@@ -596,17 +596,17 @@ impl Default for LoginForm {
}
impl LoginForm {
/// Give `field` the keyboard, select all its text and collapse the selection in the box left.
///
/// Deviation: select on focus and collapse on leaving, because a player entering a login
/// field means to replace what is there. The reference's focus gain (`0x77e3f6`) and loss
/// (`0x77af50`) leave the selection alone, and its click collapses it (`0x77b800` calls
/// `0x77ccf0` before `SetFocus`). `HighlightText(0, -1)` (`0x77cca0`) also resets the blink,
/// so the caret opens solid.
/// Give `field` the keyboard as the stock `AccountLogin.xml` boxes take it: the box left drops
/// its selection (`OnEditFocusLost`, `HighlightText(0, 0)`), and the box gaining it selects
/// all (`OnEditFocusGained`, `HighlightText()`), which leaves its caret where it was
/// (`0x77cca0`). The focus change raises the caret bit (`0x77afa8`), whose flush restarts the
/// blink (`0x77dd68`, `0x77de35`), so the caret opens solid.
fn focus(&mut self, field: Field) {
self.focused().collapse();
self.focused().highlight_text(0, 0);
self.focus = field;
self.focused().highlight_text(0, -1);
let f = self.focused();
f.highlight_text(0, -1);
f.reset_blink();
}
fn focused(&mut self) -> &mut EditBoxState {
@@ -651,7 +651,8 @@ fn login_input(
clicks: Res<crate::glue::GlueClicks>,
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
mut clipboard: NonSendMut<HostClipboard>,
// The pasteboard, and the layout that names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
mut form: ResMut<LoginForm>,
mut attempt: Attempt,
@@ -742,6 +743,7 @@ fn login_input(
&mut dialog.edit,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
@@ -755,6 +757,7 @@ fn login_input(
form.focused(),
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
@@ -1412,10 +1415,16 @@ mod tests {
form.account.set_text("remembered");
form.password.set_text("secret");
// Mid-blink, caret off and mid-text: the focus restarts it solid, where it was.
form.account.caret_shown = false;
form.account.blink_accum = 0.3;
form.account.cursor = 3;
form.focus(Field::Account);
assert_eq!(form.focus, Field::Account);
assert_eq!(form.account.selected_text().as_deref(), Some("remembered"));
assert_eq!(form.account.cursor, 3, "HighlightText leaves the caret");
assert!(form.account.caret_shown, "a fresh focus starts solid");
assert_eq!(form.account.blink_accum, 0.0, "with a full half-period");
// Checked as a range: a password box's `selected_text` returns the `*` mask.
form.focus(Field::Password);
+23 -98
View File
@@ -10,6 +10,7 @@ use benilla_assets::coords::bevy_to_wow;
use benilla_formats::{LockCatalog, ShapeshiftForm, LOCK_KEY_SKILL};
use benilla_protocol::EntityKind;
use crate::creature_type::CreatureTypeSources;
use crate::go_templates::GameObjectTemplates;
use crate::names::NameCache;
use crate::net::{GuidIndex, NetEntity, ObjectStore};
@@ -31,10 +32,6 @@ const TRACKED_UNIT_CELL: [f32; 4] = [0.25, 0.5, 0.0, 0.25];
/// `UNIT_DYNAMIC_FLAGS` bit 0x2, set per viewer on a Hunter's Mark victim for its caster; one of
/// `0x5ed210`'s two always-show clauses (`+0x224 & 0x2`).
const UNIT_DYNFLAG_TRACK_UNIT: u32 = 0x2;
/// Creature type 7, Humanoid: a player's type (`ChrRaces.dbc` col 9 is 7 for all nine playable
/// races, read by `0x605570`) and the `<= 0` fallback of the shapeshift override.
const CREATURE_TYPE_HUMANOID: u32 = 7;
/// Only DIALOG_STATUS 7 draws a quest dot, the gold cell 3 (`cmp [obj+0xcb8],7` at `0x4eac31`).
fn quest_dot_cell(status: u32) -> Option<[f32; 4]> {
(status == 7).then_some([0.75, 1.0, 0.0, 0.25])
@@ -162,33 +159,6 @@ fn tracked_creature(
.is_some_and(|t| (1..=32).contains(&t) && tracking.creatures & (1u32 << (t - 1)) != 0)
}
/// The creature-type resolver `0x605570`: a shapeshift form's `SpellShapeshiftForm.dbc` type
/// first (`<= 0` reads Humanoid), else an NPC's cached template, else Humanoid for a player.
fn creature_type_of(
kind: EntityKind,
shapeshift_form: u8,
entry: Option<u32>,
names: &NameCache,
forms: Option<&HashMap<u32, ShapeshiftForm>>,
) -> Option<u32> {
if shapeshift_form != 0 {
if let Some(t) =
forms.and_then(|f| f.get(&u32::from(shapeshift_form)).map(|r| r.creature_type))
{
return Some(if t >= 1 {
t as u32
} else {
CREATURE_TYPE_HUMANOID
});
}
}
match kind {
EntityKind::Unit => entry.and_then(|e| names.creature_type(e)),
EntityKind::Player => Some(CREATURE_TYPE_HUMANOID),
_ => None,
}
}
/// Draw the tracking dots, gold cell 0 per GameObject then red cell 1 per unit not at quest
/// status 7 (`0x4eac31` tests that first). Drawn before the quest and party dots, as the cell
/// lists draw in order.
@@ -257,6 +227,10 @@ pub(in crate::minimap) fn emit_tracking_dots(
}
}
// Cell 1, units.
let types = CreatureTypeSources {
names: Some(names),
forms,
};
for (guid, net, tf, store) in candidates.iter() {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
@@ -267,22 +241,10 @@ pub(in crate::minimap) fn emit_tracking_dots(
if statuses.get(&guid.0).copied() == Some(7) {
continue; // the ==7 branch already drew the quest dot
}
let (dyn_flags, form, creeping) = store
.map(|s| {
(
s.0.unit_dynamic_flags(),
s.0.unit_shapeshift_form(),
s.0.unit_is_stealthed(),
)
})
.unwrap_or((0, 0, false));
let creature_type = creature_type_of(
net.kind,
form,
benilla_protocol::guid::entry(guid.0),
names,
forms,
);
let (dyn_flags, creeping) = store
.map(|s| (s.0.unit_dynamic_flags(), s.0.unit_is_stealthed()))
.unwrap_or((0, false));
let creature_type = store.map(|s| types.of(s));
if tracked_creature(tracking, creature_type, dyn_flags, creeping) {
dot(guid.0, tf, TRACKED_UNIT_CELL, DotName::Guid);
}
@@ -332,6 +294,9 @@ pub(in crate::minimap) fn emit_party_dots(
mod tests {
use super::*;
/// Creature type 7, Humanoid: a player's, by race (`ChrRaces.dbc` column 9).
const CREATURE_TYPE_HUMANOID: u32 = 7;
/// The first row is the control: an ordinary live creature still passes.
#[test]
fn the_dead_our_own_minions_and_the_untrackable_draw_no_dot_of_either_kind() {
@@ -495,51 +460,6 @@ mod tests {
);
}
#[test]
fn creature_type_resolver_prefers_the_shapeshift_override() {
use benilla_formats::ShapeshiftForm;
let names = NameCache::default();
let forms: HashMap<u32, ShapeshiftForm> = [
(
1,
ShapeshiftForm {
creature_type: 1, // Cat → Beast
..Default::default()
},
),
(
16,
ShapeshiftForm {
creature_type: 0, // a <=0 row reads Humanoid (the resolver's fallback)
..Default::default()
},
),
]
.into();
// A cat-form player is a Beast; unshifted, a Humanoid.
assert_eq!(
creature_type_of(EntityKind::Player, 1, None, &names, Some(&forms)),
Some(1)
);
assert_eq!(
creature_type_of(EntityKind::Player, 0, None, &names, Some(&forms)),
Some(CREATURE_TYPE_HUMANOID)
);
assert_eq!(
creature_type_of(EntityKind::Player, 16, None, &names, Some(&forms)),
Some(CREATURE_TYPE_HUMANOID)
);
// An NPC with no cached template yet resolves nothing.
assert_eq!(
creature_type_of(EntityKind::Unit, 0, Some(69), &names, Some(&forms)),
None
);
assert_eq!(
creature_type_of(EntityKind::GameObject, 0, None, &names, Some(&forms)),
None
);
}
/// The tracking spell's `EffectMiscValue`, the server's mask bit and the node's `Lock.dbc`
/// skill slot, on the install's data.
#[test]
@@ -586,12 +506,17 @@ mod tests {
0,
false
));
let cat = creature_type_of(
EntityKind::Player,
1,
None,
&NameCache::default(),
Some(&forms),
// A cat-form player (`UNIT_FIELD_BYTES_1` byte 2 is form 1).
let cat_druid = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
138,
1 << 16,
)]));
let cat = Some(
CreatureTypeSources {
names: Some(&NameCache::default()),
forms: Some(&forms),
}
.of(&cat_druid),
);
assert_eq!(cat, Some(1), "cat form resolves Beast (DBC col 12)");
assert!(tracked_creature(beasts, cat, 0, false));
+4 -10
View File
@@ -842,14 +842,14 @@ fn feed_minimap_player_facing(
script.set_minimap_player_facing(player.facing().rem_euclid(std::f32::consts::TAU));
}
/// Push the game clock into `GetGameTime()`'s backing globals when the minute ticks, the API's own
/// resolution. Before the first `SMSG_LOGIN_SETTIMESPEED` they hold 0:00.
/// Push the game clock `GetGameTime()` answers when the minute ticks, the API's own resolution.
/// Before the first `SMSG_LOGIN_SETTIMESPEED` it reads 0:00.
fn feed_game_time(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
time: Res<crate::net::ServerTime>,
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
) {
let Some(script) = script else { return };
let Some(mut script) = script else { return };
let last = last.get(&script);
let Some(gt) = time.0 else { return };
let minute = gt.minute_of_day();
@@ -857,13 +857,7 @@ fn feed_game_time(
return;
}
*last = Some(minute);
let globals = script.lua().globals();
let pushed = globals
.set("__benilla_game_hour", minute / 60)
.and_then(|()| globals.set("__benilla_game_minute", minute % 60));
if let Err(e) = pushed {
warn!("minimap: game-time globals: {e}");
}
script.set_game_time(minute / 60, minute % 60);
}
/// The app half of the `<Minimap>` widget. The zone label feed is `crate::area`'s: the reference
+116 -9
View File
@@ -226,6 +226,18 @@ pub(crate) fn current_speed(s: &MoveSpeeds, flags: u32) -> f32 {
#[derive(Component, Clone, Default)]
pub(crate) struct ObjectStore(pub(crate) ObjectFields);
impl ObjectStore {
/// The object manager's unit lookup (`0x468460`, `ecx = 8`): a unit or a player, which share
/// the unit block; a game object, corpse or item is not one.
pub(crate) fn is_unit(&self) -> bool {
use benilla_protocol::messages::ObjectType;
matches!(
self.0.object_type(),
Some(ObjectType::Unit | ObjectType::Player)
)
}
}
/// One descriptor dword moved on a streamed object, the reference's `CMirrorHandler` edge: the
/// values notifier (`0x465330`) diffs live against a shadow copy and passes the old value
/// (`0x465570`). A first create fires nothing; a re-create of a live guid fires like a delta.
@@ -406,23 +418,24 @@ pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc<std::sync::atomic::Atom
#[derive(Resource, Default)]
pub(crate) struct GuidIndex(pub(crate) HashMap<u64, Entity>);
/// The object manager's guid lookup (`ClntObjMgrObjectPtr 0x468460`): a guid to its descriptor
/// store, whatever the kind, and an item's countdown cells. Read-only.
/// The streamed objects, whatever the kind, with their descriptor stores, our own player, and an
/// item's countdown cells. Read-only. The by-guid lookups are `ClntObjMgrObjectPtr` (`0x468460`).
#[derive(SystemParam)]
pub(crate) struct Objects<'w, 's> {
pub struct Objects<'w, 's> {
index: Res<'w, GuidIndex>,
stores: Query<'w, 's, &'static ObjectStore>,
countdowns: Query<'w, 's, &'static crate::items::Countdowns>,
me: Query<'w, 's, Entity, With<SelfPlayer>>,
}
impl Objects<'_, '_> {
/// The entity behind a guid, if streamed.
pub(crate) fn entity(&self, guid: u64) -> Option<Entity> {
/// The entity behind a guid, if streamed (`ClntObjMgrObjectPtr`, `0x468460`).
pub fn entity(&self, guid: u64) -> Option<Entity> {
self.index.0.get(&guid).copied()
}
/// A streamed object's merged descriptor fields.
pub(crate) fn object(&self, guid: u64) -> Option<&ObjectFields> {
/// A streamed object's merged descriptor fields, found as `0x468460` does, by guid.
pub fn object(&self, guid: u64) -> Option<&ObjectFields> {
self.index
.0
.get(&guid)
@@ -430,6 +443,19 @@ impl Objects<'_, '_> {
.map(|s| &s.0)
}
/// Every streamed object, items included.
pub fn iter(&self) -> impl Iterator<Item = (u64, Entity, &ObjectFields)> + '_ {
self.index
.0
.iter()
.filter_map(|(&guid, &e)| Some((guid, e, &self.stores.get(e).ok()?.0)))
}
/// Our own player, once in the world.
pub fn player(&self) -> Option<Entity> {
self.me.single().ok()
}
/// An item object's countdown cells.
pub(crate) fn countdowns(&self, guid: u64) -> Option<&crate::items::Countdowns> {
self.index
@@ -678,23 +704,62 @@ pub(crate) fn addon_wire_chat_type(distribution: benilla_ui::script::AddonDistri
/// `HandleChatMessageOpcode`). `Whisper` and `Channel` name their target in `target`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ChatKind {
/// `/say`; GM dot-commands go out this way, parsed after the language gate.
Say,
/// `/yell` (`CHAT_MSG_YELL`, `SharedDefines.h:1199`).
Yell,
/// A custom `/emote`, shown verbatim as `"PlayerName <text>"`.
Emote,
/// `/whisper`: the target's name precedes the text (`Chat.cpp:3-12`).
Whisper,
/// `/p`, dropped silently when ungrouped (`ChatHandler.cpp:472-493`).
Party,
/// `/ra`; needs a raid (`ChatHandler.cpp:514-536`).
Raid,
/// `/rl`, leader only (`ChatHandler.cpp:538-559`).
RaidLeader,
/// `/rw`, leader or assistant only (`ChatHandler.cpp:561-576`).
RaidWarning,
/// `/g`; needs a guild (`ChatHandler.cpp:494-503`).
Guild,
/// `/o`; needs a guild (`ChatHandler.cpp:504-513`).
Officer,
/// Needs a battleground group (`ChatHandler.cpp:579-593`).
Battleground,
/// Battleground leader only (`ChatHandler.cpp:595-609`).
BattlegroundLeader,
/// Toggles AFK, the text the auto-reply if any; it clears DND (`ChatHandler.cpp:611-630`).
Afk,
/// Toggles DND, which clears AFK (`ChatHandler.cpp:632-648`).
Dnd,
/// A channel line by name, dropped unless we are on it (`ChatHandler.cpp:255-327`).
Channel,
}
impl ChatKind {
/// The `CMSG_MESSAGECHAT` `type` field.
pub(crate) fn chat_type(self) -> u32 {
use benilla_protocol::messages as m;
match self {
Self::Say => m::CHAT_TYPE_SAY,
Self::Yell => m::CHAT_TYPE_YELL,
Self::Emote => m::CHAT_TYPE_EMOTE,
Self::Whisper => m::CHAT_TYPE_WHISPER,
Self::Party => m::CHAT_TYPE_PARTY,
Self::Raid => m::CHAT_TYPE_RAID,
Self::RaidLeader => m::CHAT_TYPE_RAID_LEADER,
Self::RaidWarning => m::CHAT_TYPE_RAID_WARNING,
Self::Guild => m::CHAT_TYPE_GUILD,
Self::Officer => m::CHAT_TYPE_OFFICER,
Self::Battleground => m::CHAT_TYPE_BATTLEGROUND,
Self::BattlegroundLeader => m::CHAT_TYPE_BATTLEGROUND_LEADER,
Self::Afk => m::CHAT_TYPE_AFK,
Self::Dnd => m::CHAT_TYPE_DND,
Self::Channel => m::CHAT_TYPE_CHANNEL,
}
}
}
/// `WOW_CAST_TRACE=1`: log our own cast packets and every outbound movement packet. vmangos
/// interrupts a cast on a movement report whose position differs at all (`Player::SetPosition`).
pub(crate) static CAST_TRACE: std::sync::LazyLock<bool> =
@@ -780,6 +845,8 @@ pub(crate) enum ClientCommand {
kind: ChatKind,
target: Option<String>,
text: String,
/// The `Languages.dbc` id `SendChatMessage` named; `None` speaks the character's own.
language: Option<u32>,
},
/// `SendAddonMessage`: a `CMSG_MESSAGECHAT` in `LANG_ADDON` (1.12 has no addon opcode and no
/// whispered addon message). `text` is `prefix` TAB `message`.
@@ -919,8 +986,9 @@ pub(crate) enum ClientCommand {
toggles: u8,
},
/// `CMSG_PET_ACTION`: `packed` is the slot's word as the server sent it, dispatched on its type
/// byte; `target_guid` is our selection (`0x4bd212`). The server does not reply, so the
/// caller applies the change locally first.
/// byte; `target_guid` is our selection (`0x4bd212`), or the player under the pet book's
/// `onSelf` (`0x4b4345`). The server does not reply, so the caller applies the change locally
/// first.
PetAction {
pet_guid: u64,
packed: u32,
@@ -1361,6 +1429,8 @@ pub(crate) enum ClientCommand {
body: String,
/// The `Stationery.dbc` id, the sixth field.
stationery: u32,
/// The `Package.dbc` id, the seventh field, 0 without an item.
package: u32,
item_guid: u64,
money: u32,
cod: u32,
@@ -1581,6 +1651,8 @@ pub(crate) enum ClientCommand {
},
/// `/played` (`CMSG_PLAYED_TIME`).
PlayedTime,
/// `OpeningCinematic()`: `CMSG_OPENING_CINEMATIC`, empty.
OpeningCinematic,
/// `CMSG_COMPLETE_CINEMATIC`, at the end or skip, or at once for an unresolvable trigger.
/// Unacked, vmangos keeps visibility on the cinematic camera and nearby NPCs despawn.
CompleteCinematic,
@@ -2120,6 +2192,8 @@ pub(crate) enum ServerSoundKind {
/// and sex).
#[derive(Message, Clone, Copy)]
pub(crate) struct EmoteMessage {
/// The performer's wire guid, known even while its entity has not streamed.
pub(crate) guid: u64,
pub(crate) source: Option<Entity>,
pub(crate) kind: EmoteKind,
}
@@ -2395,4 +2469,37 @@ mod tests {
);
}
}
#[test]
fn objects_lists_every_streamed_object_and_names_our_own() {
use bevy::ecs::system::RunSystemOnce;
const ME: u64 = 0x5E1F;
const BOAR: u64 = 0xF130_0000_0000_0042;
let mut world = World::new();
world.init_resource::<GuidIndex>();
let fields = |health| {
ObjectStore(ObjectFields::from_pairs(&[(
benilla_protocol::field::FIELD_UNIT_HEALTH,
health,
)]))
};
let me = world.spawn((SelfPlayer, Guid(ME), fields(256))).id();
let boar = world.spawn((Guid(BOAR), fields(40))).id();
world
.resource_mut::<GuidIndex>()
.0
.extend([(ME, me), (BOAR, boar)]);
let (listed, player) = world
.run_system_once(|objects: Objects| {
let mut listed: Vec<_> = objects
.iter()
.map(|(guid, e, f)| (guid, e, f.unit_health()))
.collect();
listed.sort_by_key(|&(guid, ..)| guid);
(listed, objects.player())
})
.unwrap();
assert_eq!(listed, [(ME, me, Some(256)), (BOAR, boar, Some(40))]);
assert_eq!(player, Some(me));
}
}
+12 -25
View File
@@ -25,7 +25,7 @@ use benilla_protocol::{
};
use crossbeam_channel::{Receiver, Sender};
use super::{CharRequest, ChatKind, ClientCommand, RealmRequest};
use super::{CharRequest, ClientCommand, RealmRequest};
/// The inbound census: every packet off the world socket, and the unix ms of the latest, which
/// tell a silent server from a dead socket for the runaway watch ([`crate::net::motion`]).
@@ -920,27 +920,12 @@ fn writer_loop(
ClientCommand::CancelAutoRepeat => w.cancel_auto_repeat(),
ClientCommand::CancelCast { spell_id } => w.cancel_cast(spell_id),
ClientCommand::CancelChannelling { spell_id } => w.cancel_channelling(spell_id),
ClientCommand::Chat { kind, target, text } => match kind {
ChatKind::Say => w.send_chat(&text),
ChatKind::Yell => w.send_yell(&text),
ChatKind::Emote => w.send_emote_chat(&text),
ChatKind::Whisper => {
w.send_whisper(target.as_deref().unwrap_or_default(), &text)
}
ChatKind::Party => w.send_party(&text),
ChatKind::Raid => w.send_raid(&text),
ChatKind::RaidLeader => w.send_raid_leader(&text),
ChatKind::RaidWarning => w.send_raid_warning(&text),
ChatKind::Guild => w.send_guild(&text),
ChatKind::Officer => w.send_officer(&text),
ChatKind::Battleground => w.send_battleground(&text),
ChatKind::BattlegroundLeader => w.send_battleground_leader(&text),
ChatKind::Afk => w.send_afk(&text),
ChatKind::Dnd => w.send_dnd(&text),
ChatKind::Channel => {
w.send_channel(target.as_deref().unwrap_or_default(), &text)
}
},
ClientCommand::Chat {
kind,
target,
text,
language,
} => w.send_message_chat(kind.chat_type(), language, target.as_deref(), &text),
// The distribution is an enum, so its map to a chat type is total.
ClientCommand::AddonMessage { distribution, text } => {
w.send_addon_message(super::addon_wire_chat_type(distribution), &text)
@@ -1247,14 +1232,15 @@ fn writer_loop(
subject,
body,
stationery,
package,
item_guid,
money,
cod,
} => w.send_mail(
mailbox, &receiver, &subject, &body,
// The chosen stationery and package 0, as the reference sends; vmangos
// stores `MAIL_STATIONERY_DEFAULT` (41) regardless.
stationery, 0, item_guid, money, cod,
// vmangos stores `MAIL_STATIONERY_DEFAULT` (41) regardless of the chosen
// stationery, and reads the package id (`Packets/Mail.cpp:10`) unused.
stationery, package, item_guid, money, cod,
),
ClientCommand::MailTakeMoney { mailbox, mail_id } => {
w.mail_take_money(mailbox, mail_id)
@@ -1349,6 +1335,7 @@ fn writer_loop(
ClientCommand::ClearTradeItem { trade_slot } => w.clear_trade_item(trade_slot),
ClientCommand::Logout => w.logout_request(),
ClientCommand::LogoutCancel => w.logout_cancel(),
ClientCommand::OpeningCinematic => w.opening_cinematic(),
ClientCommand::CompleteCinematic => w.complete_cinematic(),
ClientCommand::NextCinematicCamera => w.next_cinematic_camera(),
ClientCommand::MoveModeAck {
+1 -1
View File
@@ -36,7 +36,7 @@ const UNIT_FLAG_STUNNED: u32 = 0x0004_0000;
/// The `EmoteFlags` bit that permits the target and interaction facing during a state emote
/// (`600d98 test ch,0x20`); the name is ours, the tested bit is the reference's.
const EMOTE_PERMITS_FACING: u32 = 0x2000;
const EMOTE_PERMITS_FACING: u32 = benilla_formats::EMOTE_FLAG_INTERACTION;
/// A stationary unit's display-facing state, the client's `CGUnit+0xc98` goal and
/// `+0xc9c..+0xca8` delta ring; present only while [`drive_display_facing`] governs the unit.
+18 -4
View File
@@ -302,8 +302,15 @@ fn on_object_values(In(ev): In<SessionEvent>, mut sc: Scene) {
fn on_object_destroyed(In(ev): In<SessionEvent>, mut sc: Scene) {
if let SessionEvent::ObjectDestroyed(guid) = ev {
crate::death::net::forget_corpse(guid, &mut sc.death_net);
let store = sc.index.0.get(&guid).and_then(|e| sc.stores.get(*e).ok());
crate::ui_party::net::member_deactivated(guid, &mut sc.group, store, &sc.net);
let held = |g: u64| sc.index.0.get(&g).and_then(|e| sc.stores.get(*e).ok());
crate::ui_party::net::member_deactivated(
guid,
&mut sc.group,
held(guid),
held,
&sc.names,
&sc.net,
);
object_destroyed(guid, &mut sc.commands, &mut sc.index);
}
}
@@ -313,8 +320,15 @@ fn on_object_destroyed(In(ev): In<SessionEvent>, mut sc: Scene) {
fn on_objects_removed(In(ev): In<SessionEvent>, mut sc: Scene) {
if let SessionEvent::ObjectsRemoved(guids) = ev {
for guid in &guids {
let store = sc.index.0.get(guid).and_then(|e| sc.stores.get(*e).ok());
crate::ui_party::net::member_deactivated(*guid, &mut sc.group, store, &sc.net);
let held = |g: u64| sc.index.0.get(&g).and_then(|e| sc.stores.get(*e).ok());
crate::ui_party::net::member_deactivated(
*guid,
&mut sc.group,
held(*guid),
held,
&sc.names,
&sc.net,
);
}
objects_removed(guids, &mut sc.commands, &mut sc.index);
}
+1 -1
View File
@@ -228,7 +228,7 @@ fn on_worldport(
} = ev
{
// Every streamed roster member is about to be purged; the reference deactivates each.
crate::ui_party::net::roster_deactivated(&mut group, &b.index, &b.stores, &b.net);
crate::ui_party::net::roster_deactivated(&mut group, &b.index, &b.stores, &b.names, &b.net);
worldport(
map_id,
position,
+15 -7
View File
@@ -23,6 +23,7 @@ use benilla_assets::AssetSet;
use benilla_world::interact::{WorldClick, WorldRightClick};
use benilla_world::schedule::WorldStage;
mod approach;
mod arc;
mod net;
// Writes the frame onto the body we drive: pose, `MovementState`, the counter-twist gap.
@@ -30,6 +31,7 @@ mod body_pose;
pub(crate) mod camera;
mod camera_channel;
pub(crate) mod camera_dynamics;
pub(crate) mod camera_zoom;
mod controller;
mod world_focus;
// The remembered camera pose, inside `player/` to read the rig's `pub(super)` fields.
@@ -70,13 +72,17 @@ pub(crate) use camera::apply_self_model_fade;
use controller::control;
// The private camera and `state` items are re-imported for the modules beside this one, which
// name them `super::X`.
use camera::{
apply_zoom_scroll, model_pivot_height, run_look_session, FlyCam, LookButton, CAM_DIST_DEFAULT,
};
pub(crate) use camera::{head_height, CameraControl, CameraPivot};
use camera::{model_pivot_height, run_look_session, FlyCam, LookButton};
use camera_zoom::{apply_zoom_scroll, CAM_DIST_DEFAULT};
// `/follow`: chat sends the request, `crate::target` resolves the subject into the state, and
// `follow` owns the motion.
pub(crate) use follow::{FollowRequest, FollowState};
// Click to Move: `crate::target`'s dispatchers start it and run the verb it owes on arrival.
pub(crate) use approach::{
can_auto_interact, Approach, ApproachVerb, AutoMove, Refused, Subject, RANGE_STOP_FRACTION,
TALK_STOP,
};
use state::{
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, FALL_FAR_DROP, FALL_FAR_TIME, FOOT_CONE_HEIGHT,
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
@@ -205,6 +211,7 @@ impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
net::register(app);
follow::plugin(app);
approach::plugin(app);
camera_saved::plugin(app);
camera_view::plugin(app);
// The stream focus is published after `control`'s teleport snap and before the stream
@@ -228,7 +235,7 @@ impl Plugin for PlayerPlugin {
app.add_observer(camera::on_cvar);
app.add_observer(camera_dynamics::on_cvar);
app.init_resource::<camera::LookConfig>();
app.init_resource::<camera::ZoomLimit>();
app.init_resource::<camera_zoom::ZoomLimit>();
app.init_resource::<camera::FollowConfig>();
app.init_resource::<camera_dynamics::CameraOptions>();
// Far sight resolves `PLAYER_FARSIGHT` to a pose before `control` seats the camera: a
@@ -343,11 +350,12 @@ impl Plugin for PlayerPlugin {
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
.in_set(crate::char_select::InWorldGated),
)
// `/follow` steers before `control`, so the player's own turn input lands after it and
// wins over the follow's steering.
// `/follow` and Click to Move steer before `control`, so the player's own turn input
// lands after them and wins over their steering.
.add_systems(
Update,
follow::steer_follow
(follow::steer_follow, approach::steer_approach)
.chain()
.in_set(WorldStage::Input)
.before(control)
.in_set(crate::char_select::InWorldGated),
File diff suppressed because it is too large Load Diff
+213 -98
View File
@@ -1,5 +1,5 @@
//! The third-person camera rig: the two mouse-look modes, the wheel-zoom glide, the
//! collision-swept boom on the framing pivot, and the self-avatar fade into first person.
//! The third-person camera rig: the two mouse-look modes, the collision-swept boom on the
//! framing pivot, and the self-avatar fade into first person. The zoom is [`super::camera_zoom`].
use bevy::ecs::entity::EntityHashSet;
use bevy::input::mouse::AccumulatedMouseMotion;
@@ -9,6 +9,7 @@ use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
use super::camera_channel::{Arm, SmoothChannel};
use super::camera_dynamics::{DynamicsInput, HeadBob, SmartPivot, TerrainTilt};
use super::camera_zoom::{ZoomLimit, CAM_DISTANCE_MAX_RANGE};
use crate::creature_anim::wrap_pi;
use crate::net::Embodied;
use benilla_assets::materials::WowModelMaterial;
@@ -57,53 +58,6 @@ impl PressGesture {
}
}
/// The zoom floor, yd: at 0 the eye sits at the framing pivot, inside the head, and the avatar
/// fades out. The reference clamps the orbit to `cameraDistanceMax × cameraDistanceMaxFactor`,
/// capped at 50 (`0x5112d0`). Deviation: the starting zoom, [`CAM_DIST_DEFAULT`], is 15 yd where
/// the reference's `cameraDistance` is 5.55 (`0x84f488`), for a wider view.
pub(super) const CAM_DIST_MIN: f32 = 0.0;
/// The reference's `cameraDistanceMax` (default 15); 1.12's panel offers only the factor.
pub(super) const CAM_DIST_BASE_MAX: f32 = 15.0;
/// `cameraDistanceMaxFactor`'s slider, MAX_FOLLOW_DIST (1 to 2 by 0.1, `UIOptionsFrame.lua:90`).
pub(crate) const CAM_DIST_FACTOR_RANGE: std::ops::RangeInclusive<f32> = 1.0..=2.0;
/// The factor slider's top: the clamp for a distance read back off disk ([`ZoomLimit`] is live).
pub(super) const CAM_DIST_MAX: f32 = CAM_DIST_BASE_MAX * 2.0;
pub(super) const CAM_DIST_DEFAULT: f32 = 15.0;
/// The max orbit distance: 1.12's `cameraDistanceMaxFactor` over [`CAM_DIST_BASE_MAX`], 15 yd at
/// the reference's defaults (`cameraDistanceMax` "15.0" at `0x84fbd0`, `cameraDistanceMaxFactor`
/// "1.0" at `0x82e92c`). Lowering the factor pulls the live target in on the next frame.
#[derive(Resource)]
pub(crate) struct ZoomLimit {
pub(crate) max: f32,
}
impl Default for ZoomLimit {
fn default() -> Self {
// Factor 1.0, the reference's default; `CAM_DIST_MAX` is the slider's top.
Self {
max: CAM_DIST_BASE_MAX,
}
}
}
impl ZoomLimit {
pub(crate) fn set_factor(&mut self, factor: f32) {
let f = factor.clamp(*CAM_DIST_FACTOR_RANGE.start(), *CAM_DIST_FACTOR_RANGE.end());
self.max = CAM_DIST_BASE_MAX * f;
}
/// The live factor, the inverse of [`Self::set_factor`], for tests.
#[cfg(test)]
pub(crate) fn factor(&self) -> f32 {
self.max / CAM_DIST_BASE_MAX
}
}
/// Yards per wheel notch, the stock bindings' `CameraZoomIn(1.0)` (`Bindings.xml:707`).
const CAM_ZOOM_STEP: f32 = 1.0;
/// Zoom speed in yd/s, `cameraDistanceMoveSpeed`'s default: the reference glides the distance to
/// the wheel target at this constant speed (`0x5112d0`), not an ease.
const CAM_MOVE_SPEED: f32 = 8.33;
/// Radians of camera rotation per raw mouse unit at the default move speeds and `mousespeed` 1.0.
const LOOK_SENSITIVITY: f32 = 0.003;
/// `mousespeed`'s slider, MOUSE_SENSITIVITY (0.5 to 1.5 by 0.05, `UIOptionsFrame.lua:87`).
@@ -138,6 +92,16 @@ pub(crate) fn on_cvar(
look.pitch_speed = v;
}
}
"cameradistancemax" => {
if !zoom.set_distance_max(v) {
warn!(
"cvar {}: value out of range ({} - {}) — ignored",
ev.name,
CAM_DISTANCE_MAX_RANGE.start(),
CAM_DISTANCE_MAX_RANGE.end()
);
}
}
"cameradistancemaxfactor" => zoom.set_factor(v),
// The stock dropdown writes 1 Smart, 2 Always, 0 Never (`UIOptionsFrame.lua:518`).
"camerasmoothstyle" => follow.style = FollowStyle::from_cvar(v),
@@ -581,6 +545,8 @@ pub(crate) struct CameraControl {
pub(super) world_mouse: WorldMouse,
/// Logical cursor position captured when look began, to restore on release.
pub(super) cursor_stash: Option<Vec2>,
/// A look session is running with the OS cursor handed back: the window is unfocused.
pub(super) cursor_released: bool,
/// The self-avatar's alpha from the camera-to-pivot distance, 1 in third person to 0 in first
/// (`self_model_fade_alpha`); applied by [`apply_self_model_fade`].
pub(super) self_fade_alpha: f32,
@@ -609,11 +575,18 @@ impl CameraControl {
self.target_distance = d;
}
/// True while a mouse-look drag is active (right- or left-button). The cursor is hidden then.
/// True while a mouse-look drag is active (right- or left-button).
pub(crate) fn is_looking(&self) -> bool {
self.look.is_some()
}
/// True while a look session holds the OS cursor, hidden and locked: looking, and focused.
/// Read by the macOS cursor hide, the one platform that hides the cursor itself.
#[cfg(any(target_os = "macos", test))]
pub(crate) fn holds_cursor(&self) -> bool {
self.look.is_some() && !self.cursor_released
}
/// The self-avatar's render alpha this frame. The blob shadow multiplies it in, as the
/// reference's shadow diffuse rides the body's model fade slot (`[model+0x180]`).
pub(crate) fn self_fade(&self) -> f32 {
@@ -637,16 +610,23 @@ impl LookButton {
}
}
/// The mouse buttons the world owns, latched at the press. 1.12 reads the `TurnOrAction` and
/// `CameraOrSelectOrMove` bindings, which a press a UI frame captured never dispatches: no mouse
/// bit in `[InputControl+0x4]`, no look session, no [`FollowState`]. The world's down sets the bit
/// and that button's up clears it, so a frame appearing under the locked cursor cannot steal it.
/// The mouse buttons the world owns, latched at the press, and the two channels they drive. 1.12
/// reads the `TurnOrAction` and `CameraOrSelectOrMove` bindings, which a press a UI frame captured
/// never dispatches: no mouse bit in `[InputControl+0x4]`, no look session, no [`FollowState`].
/// The world's down sets the bit and that button's up clears it, so a frame appearing under the
/// locked cursor cannot steal it. `MouselookStart` sets the TurnOrAction bit with no button.
#[derive(Default)]
pub(super) struct WorldMouse {
/// Held by the world right now, indexed by [`LookButton`].
held: [bool; 2],
/// Took its down edge from the world this frame, same index.
down: [bool; 2],
/// `MouselookStart` holds the TurnOrAction channel (`0x515090(1, 1, …)` at `0x514231`).
scripted: bool,
/// This frame's `MouselookStart` raised the channel.
scripted_rise: bool,
/// A `MouselookStart`/`Stop` ran this frame; each disarms the pending click (`0x514810(0)`).
disarmed: bool,
}
impl WorldMouse {
@@ -658,20 +638,60 @@ impl WorldMouse {
self.down[b as usize]
}
/// Both primaries in the world's hand: the both-button run.
/// The TurnOrAction channel, `[InputControl+4] & 1`: the world's right button, or a
/// `MouselookStart` with none.
pub(super) fn turn(&self) -> bool {
self.held(LookButton::Right) || self.scripted
}
/// The channel `b`'s look session runs on: TurnOrAction for right, the left button's own.
pub(super) fn channel(&self, b: LookButton) -> bool {
match b {
LookButton::Right => self.turn(),
LookButton::Left => self.held(LookButton::Left),
}
}
/// Both channels held: the both-button run (`0x514da0` nets `0x1` and `0x2` as forward).
pub(super) fn both(&self) -> bool {
self.held(LookButton::Right) && self.held(LookButton::Left)
self.turn() && self.held(LookButton::Left)
}
/// A channel rose this frame, by a press or a `MouselookStart`: with [`Self::both`], the
/// transition into the both-button run (`0x514a73`, in the set helper both reach).
pub(super) fn rose(&self) -> bool {
self.down(LookButton::Left) || self.down(LookButton::Right) || self.scripted_rise
}
/// A `MouselookStart`/`Stop` this frame: no click may settle from the gesture in flight.
pub(super) fn disarmed(&self) -> bool {
self.disarmed
}
/// Latch this frame; `world_press` says whether a down edge belongs to the world. A held bit
/// rides to its release, including a cover's emptying of the button planes.
fn update(&mut self, buttons: &ButtonInput<MouseButton>, world_press: bool) {
self.scripted_rise = false;
self.disarmed = false;
for b in [LookButton::Right, LookButton::Left] {
let i = b as usize;
self.down[i] = world_press && buttons.just_pressed(b.button());
self.held[i] = (self.held[i] || self.down[i]) && buttons.pressed(b.button());
}
}
/// `MouselookStart` (`on`) or `MouselookStop`: one bit, whoever set it, so a stop also ends
/// a right drag in flight, whose release then clears nothing.
fn mouselook(&mut self, on: bool) {
self.disarmed = true;
if on {
self.scripted_rise |= !self.turn();
self.scripted = true;
} else {
self.scripted = false;
self.held[LookButton::Right as usize] = false;
}
}
}
/// Decide once per frame which mouse buttons the world owns: a press in the viewport off the UI,
@@ -691,13 +711,40 @@ pub(super) fn latch_world_mouse(
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
cover: Option<Res<crate::loading_screen::LoadingScreen>>,
) {
let calls = script
.as_mut()
.map(|s| s.take_mouselook_calls())
.unwrap_or_default();
let Ok(camera) = cameras.single() else {
return;
};
let over_ui = pointer_over_ui.0;
let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui);
rig.world_mouse.update(&buttons, world_press);
let wm = &mut rig.world_mouse;
wm.update(&buttons, world_press);
// The script's calls, made since the last latch, before this frame's release.
for on in calls {
wm.mouselook(on);
}
// The right button's release ends a scripted channel too, wherever its press went: in
// freelook every button event goes to the `WorldFrame` (`0x492b50`), whose TURNORACTION up
// runs `TurnOrActionStop` (`0x514160`), `0x515090(1, 0, …)`.
if buttons.just_released(MouseButton::Right) {
wm.scripted = false;
}
// The world enter behind the cover clears the whole word (`0x5144c0` → `0x514b70(-1, …)`).
// A window deactivate clears none of it (`0x5144a3 and eax,0xfffff00f` keeps the low bits):
// the session outlives the focus, and only the OS cursor goes back ([`sync_look_focus`]).
if cover.is_some_and(|c| c.covering()) {
wm.scripted = false;
}
// `IsMouselooking` (`0x514270`) reads `[InputControl+4] & 1`, the TurnOrAction channel.
if let Some(mut script) = script {
script.set_turn_or_action_held(rig.world_mouse.turn());
}
}
/// The right button's down edge in the world, before the press is judged a click or a drag:
@@ -744,9 +791,9 @@ pub(crate) struct CameraPivot {
/// The mouse-look session: start, stop and hand-off between the buttons, cursor grab and restore,
/// the look rotation, and the click tests behind [`WorldClick`]/[`WorldRightClick`]. Orbit and
/// select are independent: each primary press engages its session at once and arms a click test,
/// and the release decides on [`PressGesture::is_click`] alone.
/// and the release decides on [`PressGesture::is_click`] alone. A session lasts while its
/// [`WorldMouse::channel`] is held, so a `MouselookStart` runs the right button's session.
pub(super) fn run_look_session(
buttons: &ButtonInput<MouseButton>,
mouse_motion: &AccumulatedMouseMotion,
both_buttons: bool,
rig: &mut CameraControl,
@@ -767,18 +814,30 @@ pub(super) fn run_look_session(
dynamics: &DynamicsInput,
// Seconds on the app clock, for the press predicate's two time gates.
now: f32,
// The primary window has the OS focus.
focused: bool,
) {
// The mouse moves the view only through a held cursor: motion while unfocused, or on the frame
// that takes the cursor back, is the pointer travelling in another app.
let motion = if sync_look_focus(rig, focused, window, cursor_opts) {
mouse_motion.delta
} else {
Vec2::ZERO
};
// A chord is a both-button run, never a select: the reference kills the pending click and arms
// none while another primary's binding is held (`0x514ac1`, `0x51481a`).
if rig.world_mouse.both() {
// none while another primary's binding is held (`0x514ac1`, `0x51481a`). A `MouselookStart` or
// `Stop` disarms it too, ahead of the channel's move (`0x514810(0)` stores mode 0 unguarded,
// then `0x515090`), so a stop's release, or the button's after it, selects nothing.
let disarmed = rig.world_mouse.disarmed();
if rig.world_mouse.both() || disarmed {
*left_click = None;
*right_click = None;
}
// Both tests accumulate the rotation the press asked for, charged before the pitch clamp: the
// reference accumulates raw motion, so a drag pinned at the pitch limit still spends budget.
let (yaw_rate, pitch_rate) = (look_cfg.yaw_rate(), look_cfg.pitch_rate());
let dyaw = (mouse_motion.delta.x * yaw_rate).abs();
let dpitch = (mouse_motion.delta.y * pitch_rate).abs();
let dyaw = (motion.x * yaw_rate).abs();
let dpitch = (motion.y * pitch_rate).abs();
for test in [&mut *left_click, &mut *right_click].into_iter().flatten() {
test.yaw_travel += dyaw;
test.pitch_travel += dpitch;
@@ -787,8 +846,8 @@ pub(super) fn run_look_session(
// Both buttons engage their session on the down edge, with no threshold (`0x51491f`); the click
// test rides along to the release. Looking hides and locks the cursor until the release.
if let Some(active) = rig.look {
if !buttons.pressed(active.button()) {
// The button went up: settle its click test (a chord already cancelled both).
if !rig.world_mouse.channel(active) {
// The channel went down: settle its click test (a chord already cancelled both).
let test = match active {
LookButton::Left => left_click.take(),
LookButton::Right => right_click.take(),
@@ -805,51 +864,43 @@ pub(super) fn run_look_session(
}
}
}
// Hand off to the other button if the world holds it, as the reference keeps turning;
// a button the UI holds never fired its binding.
// Hand off to the other channel if it is held, as the reference keeps turning; a
// button the UI holds never fired its binding.
let other = match active {
LookButton::Right => LookButton::Left,
LookButton::Left => LookButton::Right,
};
if rig.world_mouse.held(other) {
if rig.world_mouse.channel(other) {
rig.look = Some(other);
} else {
rig.look = None;
cursor_opts.grab_mode = CursorGrabMode::None;
// Show the cursor again; on macOS hiding is the cursor subsystem's job.
cursor_opts.visible = true;
if let Some(pos) = rig.cursor_stash.take() {
window.set_cursor_position(Some(pos));
}
disengage_look(rig, window, cursor_opts);
}
}
} else {
// `WorldMouse` has already dropped a press over the UI, the dev overlay or outside the
// viewport. Right-drag turns, and arms its click test unless left is held.
if rig.world_mouse.down(LookButton::Right) {
rig.look = Some(LookButton::Right);
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
*right_click =
(!rig.world_mouse.held(LookButton::Left)).then(|| PressGesture::new(now));
engage_look(rig, LookButton::Right, focused, window, cursor_opts);
*right_click = (!rig.world_mouse.held(LookButton::Left) && !disarmed)
.then(|| PressGesture::new(now));
} else if rig.world_mouse.turn() {
// `MouselookStart` with no button: the channel's rise enters the same freelook
// (`0x514840` → `0x51491f`), and no click is armed (`0x514810(0)`).
engage_look(rig, LookButton::Right, focused, window, cursor_opts);
} else if rig.world_mouse.down(LookButton::Left) && !inspect_enabled {
// Left-drag orbits, engaged on the press like right (`0x51491f`); the select settles
// at the release. While the inspector is armed, left belongs to it.
rig.look = Some(LookButton::Left);
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
engage_look(rig, LookButton::Left, focused, window, cursor_opts);
// A cursor-payload world drop still orbits (`0x51491f`) but must not also select.
*right_click = None;
*left_click = (!click_consumed && !rig.world_mouse.held(LookButton::Right))
*left_click = (!click_consumed && !rig.world_mouse.turn() && !disarmed)
.then(|| PressGesture::new(now));
}
}
// Look rotation; a right-drag also turns the character.
if let Some(active) = rig.look {
let delta = mouse_motion.delta;
let delta = motion;
let d_yaw = -delta.x * yaw_rate;
cam.yaw += d_yaw;
// `mouseInvertPitch` flips only the pitch axis.
@@ -880,18 +931,79 @@ pub(super) fn run_look_session(
rig.freelook = rig.look == Some(LookButton::Right) || (rig.look.is_some() && both_buttons);
}
/// Wheel zoom, run every frame: `CAMERAZOOMIN`/`OUT` move the target, and the distance glides to it
/// at a constant `cameraDistanceMoveSpeed`, as the reference's does. `scroll` is this frame's net
/// zoom-in in notches (positive is closer).
pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl, max: f32) {
if scroll != 0.0 {
rig.target_distance =
(rig.target_distance - scroll * CAM_ZOOM_STEP).clamp(CAM_DIST_MIN, max);
/// Start `button`'s look session: stash the cursor, then lock and hide it until the session ends,
/// or leave it free while the window is unfocused ([`sync_look_focus`]).
fn engage_look(
rig: &mut CameraControl,
button: LookButton,
focused: bool,
window: &Window,
cursor_opts: &mut CursorOptions,
) {
rig.look = Some(button);
if focused {
take_cursor(rig, window, cursor_opts);
} else {
rig.cursor_released = true;
}
}
/// End the look session: free and show the cursor, and put it back where the look began unless
/// it was already free, when it is wherever the player left it.
fn disengage_look(rig: &mut CameraControl, window: &mut Window, cursor_opts: &mut CursorOptions) {
rig.look = None;
let stash = rig.cursor_stash.take();
if !std::mem::take(&mut rig.cursor_released) {
release_cursor(cursor_opts);
if let Some(pos) = stash {
window.set_cursor_position(Some(pos));
}
}
}
/// Stash the cursor, then lock and hide it for the session.
fn take_cursor(rig: &mut CameraControl, window: &Window, cursor_opts: &mut CursorOptions) {
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
}
/// Unlock and show the cursor; on macOS hiding is the cursor subsystem's job.
fn release_cursor(cursor_opts: &mut CursorOptions) {
cursor_opts.grab_mode = CursorGrabMode::None;
cursor_opts.visible = true;
}
/// Hand the OS cursor back while the window is unfocused and take it again on focus, without
/// touching the session: the look state outlives a deactivate (`0x514490` keeps the mouse bits).
/// Platform behaviour, not a reference fact. On macOS winit's `Locked` is the global
/// `CGAssociateMouseAndMouseCursorPosition(false)`, and losing key status (`windowDidResignKey`)
/// leaves it, so a lock held while unfocused freezes the pointer in every other app; 1.12.1's
/// Windows client gives the cursor back to other applications while inactive. Returns whether the
/// session holds the cursor through this whole frame, so its motion may turn the view.
fn sync_look_focus(
rig: &mut CameraControl,
focused: bool,
window: &Window,
cursor_opts: &mut CursorOptions,
) -> bool {
if rig.look.is_none() {
return focused;
}
match (focused, rig.cursor_released) {
(false, false) => {
release_cursor(cursor_opts);
rig.cursor_released = true;
false
}
(true, true) => {
// The pointer is where the player brought it back: the release restores it there.
take_cursor(rig, window, cursor_opts);
rig.cursor_released = false;
false
}
(focused, _) => focused,
}
// Re-clamp every frame, so lowering the max-distance slider pulls the camera in.
rig.target_distance = rig.target_distance.min(max);
let max_step = CAM_MOVE_SPEED * dt;
rig.distance += (rig.target_distance - rig.distance).clamp(-max_step, max_step);
}
/// Seat the camera on whatever it orbits: our own body, or the far-sight subject `PLAYER_FARSIGHT`
@@ -1410,6 +1522,9 @@ pub(crate) fn cam_dump_enabled() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_CAM_DUMP").is_some())
}
#[cfg(test)]
mod mouselook_tests;
#[cfg(test)]
mod tests {
use super::super::camera_channel::CHANNEL_EPS;
@@ -0,0 +1,389 @@
//! `MouselookStart`/`MouselookStop` against the look session: the latch and the session run as the
//! frame runs them, with the VM's calls in between.
use benilla_ui::script::UiScript;
use bevy::ecs::system::RunSystemOnce;
use bevy::math::DVec2;
use super::super::camera_dynamics::{CameraOptions, SubjectState};
use super::*;
/// What the session's caller keeps between frames: the facing and the two click tests.
#[derive(Resource, Default)]
struct Hand {
face_yaw: f32,
left: Option<PressGesture>,
right: Option<PressGesture>,
}
/// `control`'s call of [`run_look_session`], with the both-button run read off the latch.
fn look_frame(
motion: Res<AccumulatedMouseMotion>,
mut rig: ResMut<CameraControl>,
cam: Single<&mut FlyCam>,
window: Single<(&mut Window, &mut CursorOptions), With<PrimaryWindow>>,
mut clicks: (MessageWriter<WorldClick>, MessageWriter<WorldRightClick>),
mut hand: ResMut<Hand>,
) {
let (mut window, mut opts) = window.into_inner();
let focused = window.focused;
let mut cam = cam.into_inner();
let hand = &mut *hand;
let both = rig.world_mouse.both();
let dynamics = DynamicsInput {
options: CameraOptions::default(),
smooth_style: FollowStyle::default(),
tracking_style: FollowStyle::default(),
subject: SubjectState::default(),
nearclip: 0.2,
surface_y: None,
};
run_look_session(
&motion,
both,
&mut rig,
&mut cam,
&mut hand.face_yaw,
&mut window,
&mut opts,
false,
false,
&mut clicks.0,
&mut clicks.1,
&mut hand.left,
&mut hand.right,
LookConfig::default(),
&dynamics,
0.0,
focused,
);
}
/// A world with the camera, the window, a VM and nothing held; `over_ui` says where the cursor is.
fn world(over_ui: bool) -> World {
let mut world = World::new();
world.init_resource::<ButtonInput<MouseButton>>();
world.init_resource::<AccumulatedMouseMotion>();
world.insert_resource(crate::ui_script::PointerOverUi(over_ui));
world.init_resource::<CameraControl>();
world.init_resource::<Hand>();
world.init_resource::<Messages<WorldClick>>();
world.init_resource::<Messages<WorldRightClick>>();
world.spawn((
Camera::default(),
FlyCam {
yaw: 0.0,
pitch: 0.0,
speed: 0.0,
},
));
let mut window = Window::default();
window.set_physical_cursor_position(Some(DVec2::new(100.0, 100.0)));
world.spawn((window, CursorOptions::default(), PrimaryWindow));
world.insert_non_send_resource(UiScript::new().unwrap());
world
}
/// One frame with the mouse moved by `dx` pixels: the latch, then the session, then the input
/// planes age as bevy's do.
fn frame(world: &mut World, dx: f32) {
world.resource_mut::<AccumulatedMouseMotion>().delta = Vec2::new(dx, 0.0);
world.run_system_once(latch_world_mouse).unwrap();
world.run_system_once(look_frame).unwrap();
world.resource_mut::<ButtonInput<MouseButton>>().clear();
}
/// The OS gives the window focus or takes it.
fn focus(world: &mut World, focused: bool) {
world
.query::<&mut Window>()
.single_mut(world)
.unwrap()
.focused = focused;
}
/// Where the OS pointer is, in logical window pixels.
fn pointer(world: &mut World) -> Option<Vec2> {
world
.query::<&Window>()
.single(world)
.unwrap()
.cursor_position()
}
fn lua(world: &mut World, chunk: &str) {
world.non_send_resource::<UiScript>().run(chunk).unwrap();
}
fn is_mouselooking(world: &World) -> bool {
world
.non_send_resource::<UiScript>()
.eval::<Option<i64>>("return IsMouselooking()")
.unwrap()
== Some(1)
}
fn yaw(world: &mut World) -> f32 {
world.query::<&FlyCam>().single(world).unwrap().yaw
}
fn cursor(world: &mut World) -> CursorOptions {
world
.query::<&CursorOptions>()
.single(world)
.unwrap()
.clone()
}
fn press(world: &mut World, b: MouseButton) {
world.resource_mut::<ButtonInput<MouseButton>>().press(b);
}
fn release(world: &mut World, b: MouseButton) {
world.resource_mut::<ButtonInput<MouseButton>>().release(b);
}
/// The right button's session, entered with no button: the mouse turns the camera and the body
/// together, the cursor is locked and hidden, and `IsMouselooking` answers 1 (`0x514210`).
#[test]
fn mouselook_start_turns_the_camera_and_the_character_with_no_button_held() {
let mut w = world(false);
frame(&mut w, 0.0);
assert!(!is_mouselooking(&w));
lua(&mut w, "MouselookStart()");
frame(&mut w, 40.0);
let rig = w.resource::<CameraControl>();
assert_eq!(
rig.look,
Some(LookButton::Right),
"the right button's session"
);
assert!(rig.freelook);
let turned = yaw(&mut w);
assert!(turned < 0.0, "the mouse turns the camera: {turned}");
assert_eq!(
w.resource::<Hand>().face_yaw,
turned,
"and the character with it"
);
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::Locked);
assert!(!c.visible);
assert!(is_mouselooking(&w));
// It holds with no button while the mouse keeps moving.
frame(&mut w, 40.0);
assert!(yaw(&mut w) < turned);
assert!(w.resource::<CameraControl>().is_looking());
}
/// `MouselookStop` (`0x514240`) ends the session: the cursor comes back and the mouse turns
/// nothing, and `IsMouselooking` answers nil.
#[test]
fn mouselook_stop_ends_the_session() {
let mut w = world(false);
lua(&mut w, "MouselookStart()");
frame(&mut w, 40.0);
let turned = yaw(&mut w);
lua(&mut w, "MouselookStop()");
frame(&mut w, 40.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert_eq!(yaw(&mut w), turned, "the mouse turns nothing");
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::None);
assert!(c.visible);
assert!(!is_mouselooking(&w));
}
/// CustomNameplates' steer: a right press a plate took calls `MouselookStart`, and the button's
/// release ends it (`0x492b50` routes it to `TurnOrActionStop`) without a world click.
#[test]
fn the_right_release_ends_a_mouselook_its_press_started_on_the_ui() {
let mut w = world(true);
press(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert_eq!(
w.resource::<CameraControl>().look,
None,
"the plate's press alone starts no look"
);
lua(&mut w, "MouselookStart()");
frame(&mut w, 40.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Right));
assert!(is_mouselooking(&w));
release(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert!(!is_mouselooking(&w));
assert!(
w.resource::<Messages<WorldRightClick>>().is_empty(),
"no click was armed"
);
}
/// The loading cover's world enter clears the whole word (`0x5144c0`).
#[test]
fn the_cover_ends_a_scripted_mouselook() {
let mut w = world(false);
lua(&mut w, "MouselookStart()");
frame(&mut w, 0.0);
w.insert_resource(crate::loading_screen::LoadingScreen::test_covering());
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert!(!is_mouselooking(&w));
}
/// A session across a focus loss: the look state stays as the deactivate leaves it (`0x514490`
/// keeps the mouse bits), the OS cursor is free and shown meanwhile and the mouse turns nothing,
/// and the focus takes it back locked and hidden, with no jolt on the frame it returns.
fn a_session_survives_focus_loss(start: impl Fn(&mut World)) {
let mut w = world(false);
start(&mut w);
frame(&mut w, 40.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Right));
let before = yaw(&mut w);
focus(&mut w, false);
frame(&mut w, 40.0);
assert_eq!(
w.resource::<CameraControl>().look,
Some(LookButton::Right),
"the session outlives the focus"
);
assert!(is_mouselooking(&w));
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::None, "the OS pointer is free");
assert!(c.visible);
assert!(!w.resource::<CameraControl>().holds_cursor());
assert_eq!(
yaw(&mut w),
before,
"the pointer in another app turns nothing"
);
focus(&mut w, true);
frame(&mut w, 40.0);
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::Locked);
assert!(!c.visible);
assert!(w.resource::<CameraControl>().holds_cursor());
assert_eq!(
yaw(&mut w),
before,
"the returning frame's travel is not a turn"
);
frame(&mut w, 40.0);
assert!(yaw(&mut w) < before, "and the mouse turns the view again");
assert!(is_mouselooking(&w));
}
#[test]
fn a_scripted_mouselook_survives_focus_loss() {
a_session_survives_focus_loss(|w| lua(w, "MouselookStart()"));
}
#[test]
fn a_right_drag_survives_focus_loss() {
a_session_survives_focus_loss(|w| press(w, MouseButton::Right));
}
/// `MouselookStop` while unfocused ends the session; the pointer stays where the player left it
/// in the other app, and the focus coming back takes nothing.
#[test]
fn mouselook_stop_while_unfocused_ends_it_and_the_focus_takes_nothing_back() {
let mut w = world(false);
lua(&mut w, "MouselookStart()");
frame(&mut w, 0.0);
focus(&mut w, false);
frame(&mut w, 0.0);
let away = Vec2::new(300.0, 250.0);
w.query::<&mut Window>()
.single_mut(&mut w)
.unwrap()
.set_cursor_position(Some(away));
lua(&mut w, "MouselookStop()");
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert!(!is_mouselooking(&w));
assert_eq!(pointer(&mut w), Some(away), "no warp back to the stash");
focus(&mut w, true);
frame(&mut w, 0.0);
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::None);
assert!(c.visible);
assert!(!w.resource::<CameraControl>().is_looking());
}
/// One bit, whoever set it: `MouselookStop` ends a right drag in flight and disarms its click, so
/// the release after it selects nothing (`0x514810(0)`, then `0x515090(1, 0, …)`).
#[test]
fn mouselook_stop_ends_a_right_drag_and_its_release_clicks_nothing() {
let mut w = world(false);
press(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert!(
w.resource::<Hand>().right.is_some(),
"the press armed a click"
);
lua(&mut w, "MouselookStop()");
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert!(!is_mouselooking(&w));
release(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert!(w.resource::<Messages<WorldRightClick>>().is_empty());
assert!(!is_mouselooking(&w));
// The control: the same press and release with no stop is a click.
let mut w = world(false);
press(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
release(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert_eq!(w.resource::<Messages<WorldRightClick>>().len(), 1);
}
/// `MouselookStart` under a left drag sets the other primary channel: the both-button run, its
/// rise, and the left click disarmed; the left release hands the session to the mouselook.
#[test]
fn mouselook_start_under_a_left_drag_is_the_both_button_run() {
let mut w = world(false);
press(&mut w, MouseButton::Left);
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Left));
assert!(w.resource::<Hand>().left.is_some());
lua(&mut w, "MouselookStart()");
w.resource_mut::<AccumulatedMouseMotion>().delta = Vec2::new(40.0, 0.0);
w.run_system_once(latch_world_mouse).unwrap();
{
let wm = &w.resource::<CameraControl>().world_mouse;
assert!(wm.both(), "both channels held");
assert!(wm.rose(), "the transition into the run (0x514a73)");
}
w.run_system_once(look_frame).unwrap();
w.resource_mut::<ButtonInput<MouseButton>>().clear();
assert!(
w.resource::<Hand>().left.is_none(),
"the left click disarmed"
);
let cam_yaw = yaw(&mut w);
assert!(cam_yaw < 0.0);
assert_eq!(
w.resource::<Hand>().face_yaw,
cam_yaw,
"the body turns with the camera"
);
release(&mut w, MouseButton::Left);
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Right));
assert!(w.resource::<Messages<WorldClick>>().is_empty());
}
@@ -18,9 +18,8 @@ use bevy::prelude::*;
use crate::char_select::{ClientState, InWorldGated};
use super::camera::{
CameraControl, FlyCam, CAM_DIST_DEFAULT, CAM_DIST_MAX, CAM_DIST_MIN, CAM_PITCH_LIMIT,
};
use super::camera::{CameraControl, FlyCam, CAM_PITCH_LIMIT};
use super::camera_zoom::{CAM_DIST_DEFAULT, CAM_DIST_MAX, CAM_DIST_MIN};
use super::Player;
use benilla_world::view::WorldCamera;
+2 -1
View File
@@ -25,8 +25,9 @@ use benilla_world::view::WorldCamera;
use crate::creature_anim::wrap_pi;
use super::camera::{CameraControl, FlyCam, CAM_DIST_MAX, CAM_DIST_MIN, CAM_PITCH_LIMIT};
use super::camera::{CameraControl, FlyCam, CAM_PITCH_LIMIT};
use super::camera_saved::{pitch_from_file, pitch_to_file};
use super::camera_zoom::{CAM_DIST_MAX, CAM_DIST_MIN};
use super::Player;
/// The reference's five views, through the UI crate's constant so the Lua range check agrees.
@@ -0,0 +1,196 @@
//! The zoom: the orbit distance's range, from `cameraDistanceMax` × `cameraDistanceMaxFactor`
//! under the reference's 0 and 50 yd bounds, and the wheel's glide to the chosen distance.
use bevy::prelude::*;
use super::camera::CameraControl;
/// The zoom floor, yd (`ds:0x8089a8` = 0.0): at 0 the eye sits at the framing pivot, inside the
/// head, and the avatar fades out. Deviation: the starting zoom, [`CAM_DIST_DEFAULT`], is 15 yd
/// where the reference's `cameraDistance` is 5.55 (`0x84f488`), for a wider view.
pub(super) const CAM_DIST_MIN: f32 = 0.0;
/// The zoom ceiling, yd (`ds:0x8089a4` = 50.0): the cap on [`ZoomLimit`] (`0x511309`) and the top
/// of the distance validator `0x50b310`; also the clamp for a distance read back off disk.
pub(super) const CAM_DIST_MAX: f32 = 50.0;
pub(super) const CAM_DIST_DEFAULT: f32 = 15.0;
/// `cameraDistanceMax`'s registered default, "15.0" (`0x84fbd0`).
const CAM_DISTANCE_MAX_DEFAULT: f32 = 15.0;
/// `cameraDistanceMaxFactor`'s registered default, "1.0" (`0x82e92c`). The CVar has no validator:
/// the options slider's 1 to 2 (`UIOptionsFrame.lua:90`) bounds the slider, not the value.
const CAM_DISTANCE_MAX_FACTOR_DEFAULT: f32 = 1.0;
/// `cameraDistanceMax`'s validator (`0x50b310` → `0x50b330(v, 0.0, 50.0)`) accepts `0 ≤ v ≤ 50`;
/// anything else, NaN included (`fcom` unordered sets C0), is refused and the old value stands.
pub(crate) const CAM_DISTANCE_MAX_RANGE: std::ops::RangeInclusive<f32> =
CAM_DIST_MIN..=CAM_DIST_MAX;
/// Yards per wheel notch, the stock bindings' `CameraZoomIn(1.0)` (`Bindings.xml:707`).
const CAM_ZOOM_STEP: f32 = 1.0;
/// Zoom speed in yd/s, `cameraDistanceMoveSpeed`'s default: the reference glides the distance to
/// the wheel target at this constant speed (`0x5112d0`), not an ease.
const CAM_MOVE_SPEED: f32 = 8.33;
/// The max orbit distance, yd, from `cameraDistanceMax` and `cameraDistanceMaxFactor`: 15 at the
/// reference's defaults. Lowering it pulls the live target in on the next frame.
#[derive(Resource)]
pub(crate) struct ZoomLimit {
/// The live limit, [`orbit_limit`] of the two below: in `[0, 50]`, never NaN.
pub(crate) max: f32,
distance_max: f32,
factor: f32,
}
impl Default for ZoomLimit {
fn default() -> Self {
let (distance_max, factor) = (CAM_DISTANCE_MAX_DEFAULT, CAM_DISTANCE_MAX_FACTOR_DEFAULT);
Self {
max: orbit_limit(distance_max, factor),
distance_max,
factor,
}
}
}
impl ZoomLimit {
/// `cameraDistanceMax`, through its validator's range ([`CAM_DISTANCE_MAX_RANGE`]); `false`
/// when refused, the old value standing.
pub(crate) fn set_distance_max(&mut self, v: f32) -> bool {
if !CAM_DISTANCE_MAX_RANGE.contains(&v) {
return false;
}
self.distance_max = v;
self.max = orbit_limit(self.distance_max, self.factor);
true
}
/// `cameraDistanceMaxFactor`, taken as it comes: the reference registers it with no validator.
pub(crate) fn set_factor(&mut self, factor: f32) {
self.factor = factor;
self.max = orbit_limit(self.distance_max, self.factor);
}
#[cfg(test)]
pub(crate) fn distance_max(&self) -> f32 {
self.distance_max
}
#[cfg(test)]
pub(crate) fn factor(&self) -> f32 {
self.factor
}
}
/// The reference's orbit limit (`0x5112d6`–`0x51131e`): `cameraDistanceMax × cameraDistanceMaxFactor`
/// through two ordered x87 compares. Below 0.0 (`fcom ds:0x8089a8`, C0 alone) it is 0.0; else
/// below 50.0 (`fcom ds:0x8089a4`, C0 alone) it stands; else it is 50.0. A NaN product compares
/// unordered (C0, C2 and C3 all set), which passes the first test and fails the second: 50.0.
fn orbit_limit(distance_max: f32, factor: f32) -> f32 {
let product = distance_max * factor;
if product < CAM_DIST_MIN {
CAM_DIST_MIN
} else if product < CAM_DIST_MAX {
product
} else {
CAM_DIST_MAX
}
}
/// Wheel zoom, run every frame: `CAMERAZOOMIN`/`OUT` move the target, and the distance glides to it
/// at a constant `cameraDistanceMoveSpeed`, as the reference's does. `scroll` is this frame's net
/// zoom-in in notches (positive is closer). `max` is [`ZoomLimit::max`], CVar-fed, so it bounds by
/// `max`/`min`, never `f32::clamp`, which panics on a NaN or inverted bound.
pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl, max: f32) {
if scroll != 0.0 {
rig.target_distance = (rig.target_distance - scroll * CAM_ZOOM_STEP)
.max(CAM_DIST_MIN)
.min(max);
}
// Re-clamp every frame, so lowering the max-distance slider pulls the camera in.
rig.target_distance = rig.target_distance.min(max);
let max_step = CAM_MOVE_SPEED * dt;
rig.distance += (rig.target_distance - rig.distance).clamp(-max_step, max_step);
}
#[cfg(test)]
mod tests {
use super::*;
fn limit(distance_max: f32, factor: f32) -> f32 {
let mut z = ZoomLimit::default();
assert!(
z.set_distance_max(distance_max),
"{distance_max} is in range"
);
z.set_factor(factor);
z.max
}
#[test]
fn the_limit_is_the_product_of_both_cvars() {
assert_eq!(ZoomLimit::default().max, 15.0, "the reference's defaults");
// The factor has no validator: 3 is not held to the slider's 2.
assert_eq!(limit(15.0, 3.0), 45.0);
assert_eq!(limit(15.0, 0.5), 7.5, "nor raised to the slider's 1");
assert_eq!(limit(25.0, 1.0), 25.0);
}
#[test]
fn the_limit_holds_to_zero_and_fifty() {
assert_eq!(limit(15.0, 4.0), 50.0);
assert_eq!(limit(50.0, 1.0), 50.0);
assert_eq!(limit(15.0, f32::INFINITY), 50.0);
assert_eq!(limit(15.0, -2.0), 0.0);
assert_eq!(limit(15.0, f32::NEG_INFINITY), 0.0);
}
#[test]
fn a_nan_product_fails_both_compares_and_is_fifty() {
assert_eq!(limit(15.0, f32::NAN), 50.0);
// 0 × ∞ is NaN too, with both values accepted.
assert_eq!(limit(0.0, f32::INFINITY), 50.0);
}
#[test]
fn cameradistancemax_is_refused_outside_its_validator_range() {
let mut z = ZoomLimit::default();
for bad in [-0.5, 50.5, f32::NAN, f32::INFINITY] {
assert!(!z.set_distance_max(bad), "{bad} is refused");
assert_eq!(
(z.distance_max(), z.max),
(15.0, 15.0),
"the old value stands"
);
}
assert!(
z.set_distance_max(0.0) && z.max == 0.0,
"both ends are inclusive"
);
assert!(z.set_distance_max(50.0) && z.max == 50.0);
}
#[test]
fn a_nan_factor_zooms_without_panicking() {
let mut z = ZoomLimit::default();
z.set_factor(f32::NAN);
let mut rig = CameraControl {
target_distance: 10.0,
distance: 10.0,
..default()
};
apply_zoom_scroll(-1.0, 0.1, &mut rig, z.max);
assert_eq!(rig.target_distance, 11.0);
// Even a NaN bound handed straight in bounds nothing and panics nowhere.
apply_zoom_scroll(-1.0, 0.1, &mut rig, f32::NAN);
assert_eq!(rig.target_distance, 12.0);
}
#[test]
fn the_wheel_stops_at_the_limit() {
let mut rig = CameraControl {
target_distance: 44.5,
..default()
};
apply_zoom_scroll(-1.0, 0.1, &mut rig, limit(15.0, 3.0));
assert_eq!(rig.target_distance, 45.0);
apply_zoom_scroll(1000.0, 0.1, &mut rig, 45.0);
assert_eq!(rig.target_distance, CAM_DIST_MIN);
}
}
+6 -5
View File
@@ -16,7 +16,7 @@ pub(super) fn control(
pointer: (
Res<AccumulatedMouseMotion>,
Res<camera::LookConfig>,
Res<camera::ZoomLimit>,
Res<camera_zoom::ZoomLimit>,
Res<camera::FollowConfig>,
Res<camera_dynamics::CameraOptions>,
// Only `nearclip` is read here, for the self-avatar fade.
@@ -227,8 +227,8 @@ pub(super) fn control(
*left_click = None;
*right_click = None;
}
let focused = window.focused;
run_look_session(
&buttons,
mouse_motion,
both_buttons,
&mut rig,
@@ -245,6 +245,7 @@ pub(super) fn control(
look_cfg,
&dynamics,
time.elapsed_secs(),
focused,
);
// The mouse turns the view, but the body hand-off (`0x514474`) skips a body that is stunned,
// dead or seated (`0x5145e0`) or not ours to drive (`control_lost`, `reseat`), while the
@@ -265,8 +266,8 @@ pub(super) fn control(
}
// A rebound zoom key steps 1.0 per press, the stock `CameraZoomIn(1.0)` (`Bindings.xml:707`).
let zoom = binds.amount(crate::bindings::cmd::CAMERA_ZOOM_IN)
- binds.amount(crate::bindings::cmd::CAMERA_ZOOM_OUT);
let zoom = binds.amount(crate::bindings::Input::CameraZoomIn)
- binds.amount(crate::bindings::Input::CameraZoomOut);
apply_zoom_scroll(zoom, dt, &mut rig, zoom_max);
// Free fly is a dev tool on the dev chord: a bare `F` is the player's to bind.
@@ -492,7 +493,7 @@ pub(super) fn control(
// `Jump` (`0x513bd0`) inlines `0x5144e0` and `0x514560`, which is `may_translate` term for
// term: health, root and stand state 7. Hover's refusal is the movement handler's
// (`0x7c623a`), which keeps the mounted flourish reachable while hovering.
let mut want_jump = binds.fired(crate::bindings::cmd::JUMP) && may_translate;
let mut want_jump = binds.fired(crate::bindings::Input::Jump) && may_translate;
// Swim or walk, latched with hysteresis at the `0x6030c0` boundary against flicker.
let surface_y = swim::surface_over_feet(world, player.pos);

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