Merge upstream benilla main (b396bbf6) into everwood-graphics
105 upstream commits (v0.2.0ba7fe6e2->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:
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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![]);
|
||||
|
||||
+761
-682
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`"
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, §ions, || 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, §ions, || 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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user