merge: absorb upstream/main into the Turtle fork
Fork policy (docs/METHOD.md): upstream structure wins; local-unique work re-applied on top. This commit is the upstream half of the reconciliation. Upstream is the same program at a rewritten history (884dff77==4e0e89cbtree), so the merge base was replaced to keep a real three-way diff. The large conflicts are upstream moving first: the UI manifest (benilla.toc -> the binary LAYER_MANIFEST/layer.toc split), the CVar table (cvars.rs -> cvars/{mod,table}.rs, +24 video rows), the editbox flush model, the host-side GetTime, the Options* -> BenillaOptions* rename, expanded ui_unit.rs, and a rebuilt volumetric_light.rs. Local-unique pieces re-instated on the upstream structure: - OptionsFrame.xml: the 1.12 OptionsFrameSliders table restored under the Benilla rename, with the TurtleWoW deviation note (patch-4 replaces OptionsFrame.lua). - layer.toc: CharacterFrameAdapters.xml (the sixth TurtleWoW character tab). - ui script: wipe/sizeof, BenillaGetMerchantItemStats, and the attach/mount teardown re-walk all survive. - game_plugins.rs keeps UPDATE_ACTIONABLE_CEILING=5_509 (re-measure pending). The LFT/Turtle_TransmogUI rows benilla.toc named off the player's install now come from the chain's own FrameXML.toc via the new core loader.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
ko_fi: marcusaga
|
||||
@@ -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" }
|
||||
|
||||
|
||||
+27
-27
@@ -12,33 +12,33 @@ behind each constant.
|
||||
|
||||
## What it does
|
||||
|
||||
| Lane | What you see | Main switch |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Sun shadows, characters | real silhouettes for units instead of the oval blob | `characterShadows` |
|
||||
| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside; the static caster cache also refreshes when streamed geometry arrives, so a standing camera picks up late trees (`world_shadow.rs::refresh_streamed_shadows`, independent of volumetric fog) | `worldShadows` |
|
||||
| Dynamic interiors | WMO rooms are lit per fragment by their own fixtures with a soft falloff, gated per room so light does not leak through walls | `interiorLight`, `interiorRoomGate` |
|
||||
| Synthesised lights | torches, braziers, lanterns, campfires and lampposts emit a light in the colour of their flame, with flicker | `fireLightGain`, `fireFlicker` |
|
||||
| Torch shadows | cube-map shadows from point lights onto buildings, models and terrain, with a contact-hardening penumbra and a shadow floor | `interiorShadows`, `exteriorShadows`, `torchShadowStrength`, `interiorShadowSoft` |
|
||||
| Daylight and doorways | calibrated fixtures at doors, windows and open boundaries carry daylight into a room, and doorways between rooms bleed light | `interiorDaylight`, env `WOW_DAYLIGHT`, `WOW_BLEED` |
|
||||
| Terrain in torch shadows | the ground casts into an outdoor fire's cube map, so a hill or bank blocks the fire (settled exterior slots only; `torch_terrain.rs` gathers the resident MCNK chunks inside the cube's 48 yd; the gather and the ground receiver both work, but stock Elwynn lamps/campfires show no visible change: the terrain-blocked ground is grazing (N·L ≈ 0, inside the 0.15 yd normal offset) or at the pool rim, below one 8-bit step — measured by the `lamp_terrain_occlusion_scan` census) | `torchTerrainShadows` (default 0, High 1), env `WOW_TORCH_TERRAIN=0\|1` |
|
||||
| City day floor | `interiorDaylight`'s room floor also reaches Stormwind/Ironforge rooms that the portal graph connects to the sky (an exterior-facing portal, or one hop from one; `district_sky_rooms` in `daylight.rs`); before this no city room could take it | `interiorDaylight` (default 0, suggested High 0.10) |
|
||||
| Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` |
|
||||
| Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` |
|
||||
| Moonlight | GFX (moonlight): the moon as a light. The stock 1.12 night (Light.dbc night colours on the never-setting lighting sun) stays the base; a cool (0.62, 0.74, 1.0), dim directional term from the visible white moon (`daynight::moon_direction`, the disc the sky draws) is ADDED per fragment with a soft-wrapped N·L on terrain, models, WMO exteriors and retained doodads, shadowed by the moon's own map (`shadow_hook::realtime_shadow_moonlit` `.z`, faded in with the rig's hand-over ramp). It ramps with the moon's elevation (0-12°) and out with the sun, dims under storms, and never reaches interiors or the glue booth. Packed as MonkeyFrame row 16 (`moon`: direction + intensity, base 0.16 gamma at `moonLight 1`) and `misc.w` (hand-over confidence); the per-frame blob is 8800 B (`lighting/moonlight.rs`, `moonlight_hook.wgsl`) | Advanced Graphics → Moonlight, cvar `moonLight` 0..2 (0 = the reference night, bit for bit; Graphics preset Low..Ultra = 1) |
|
||||
| Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` |
|
||||
| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors; the dawn curve follows the rendered clock, so a manual or capture time sets it; over an ocean a point below sea level is hazed at the sea surface, since water writes no depth) and daytime sun shafts through gaps beyond 10 yd, sampled from the shadow map (never moon shafts: they are gated by daylight; retired while Volumetric Light is on); own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` |
|
||||
| Volumetric light | GFX (volumetric light): shadow-mapped sun and moon shafts. The directional light the shadow rig holds (sun by day, moon at night) is ray-marched along each view ray at HALF resolution: 16 (Medium) or 32 (High) samples, quadratic spacing from 0.35 yd (dense near the eye), a per-pixel interleaved-gradient-noise offset, cascade lookups with the covered-sample mean past the cascades, air thinning with height above the eye (10 yd e-fold, so shafts live under canopies and between buildings rather than in the open sky), a Henyey-Greenstein g = 0.6 phase with a quarter isotropic. A depth-aware 4x4 half-res blur removes the jitter and a depth-aware 2x2 upsample composites it with the fog pass's soft screen curve. Extinction = the volumetric fog density law (dawn mist, weather) x the zone's fog end (350 yd reference, x0.7-2.5); colour = sun diffuse (or the moon tint x moonlight intensity, faint) leaned 35 % toward the zone fog hue. No shafts in WMO interiors. While it is on, the volumetric fog pass retires its own midpoint shafts and keeps only its haze and lamp halos. Three passes after the fog node (`benilla-app/src/volumetric_light.rs` + `.wgsl`); no temporal history. Dev capture levers `WOW_VOLLIGHT_DENSITY/_HEIGHT/_GAIN/_DEBUG` (1 = the in-scatter alone) | Advanced Graphics → Volumetric Light (Off/Medium/High) + Volumetric Light Strength 0..2, cvars `volumetricLight`, `volumetricLightStrength`, env `WOW_VOLLIGHT=0\|1\|2`; Graphics preset Medium = 1, High/Ultra = 2 |
|
||||
| Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` |
|
||||
| HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 |
|
||||
| Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` |
|
||||
| Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` |
|
||||
| Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; MONKEY (rainshelter): a camera-centred rain-occlusion map (128² cells of 0.75 yd, filled by vertical collision rays) keeps ground, walls and water under roofs, porches and bridges dry, with a ~1.5 yd drip margin and a soft edge; tree canopies without colliders do not shelter | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `weather/shelter.rs`, `wet_hook.wgsl` |
|
||||
| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), plus the slider Foliage Wind Strength, cvar `foliageWindStrength` 0.25..3 (default 1, not on the preset ladder; a gain on the sway, not the parting; a storm adds up to +60 %). Tuning at 1 (fix-wind): grass tips about ±0.2-0.3 yd on a 2.5 s wave with 1.1 s cross flutter, gust bands 55 yd wide rolling downwind; tree crowns about ±0.3 yd (lever-arm weight grows with height to 1.3x at 8.5 yd) on a 4 s sway, 5.5 s side wave, 0.8 s leaf flutter; bent tips dip slightly. capture override `WOW_FOLIAGE_WIND` (dev build + `WOW_CAPTURE` only); field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets |
|
||||
| Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels; on alpha-tested foliage (a dense window of two-sided depth spikes) the blur widens its depth tolerance and the AO drops 40 %, which removes the leaf grain while planes, silhouettes and contact creases keep full AO (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..5` diagnostic views (factor, protection, distance, raw occlusion, foliage mask), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning |
|
||||
| Lamp fog | MONKEY (lampfog): the fog fullscreen pass selects the nearest resolved exterior and interior point-table sources and analytically integrates their softened inverse-square light over the camera-to-scene-depth ray. A near-field haze floor makes lamps visible inside the ordinary volumetric haze's 10 yd clear zone; it is zero by day, stronger in rain/fog, and deliberately unshadowed. The table colour already includes intensity, flame flicker and `fireLightGain`. Halo output uses destination headroom and stays below 1.0, so a later bloom pass cannot blow it out | Advanced Graphics → Lamp Fog, cvar `lampFog` 0 Off (default, unchanged) / 1 Low (16 lamps) / 2 High (32 lamps; Graphics preset High = 2), env `WOW_LAMPFOG=0\|1\|2`; `benilla-app/src/volumetric_fog.rs` |
|
||||
| City window daylight | MONKEY (fix-daylight/integration): a district WMO's oversized window batch (e.g. the Cathedral of Light's stained-glass walls) is split into window-sized daylight apertures, so the nave takes daylight pane by pane. Applies to WMOs loaded after a change | Advanced Graphics → City Window Daylight, cvar `daylightWindowSplit` 0/1 (default 1, the merged behaviour); `benilla-world/src/lighting/daylight.rs` (`set_window_split`), bridge `benilla-app/src/dynamic_interior.rs` |
|
||||
| Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, `0x8` forces the screen-space sun shafts through a low or just-set sun (the sky's weight lifts their daylight fade; `post/sun_shafts.rs`), `0x10` fades the Modern fog's sun-colour lobe out by the sky's weight (`lighting/fog_model.rs`), plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` |
|
||||
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
|
||||
| Lane | What you see | Main switch |
|
||||
|---|---|---|
|
||||
| Sun shadows, characters | real silhouettes for units instead of the oval blob | `characterShadows` |
|
||||
| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside, and so does the MCSH 0.5 sun dim on models standing on them (`wow_model.wgsl` / `static_gx.wgsl`, terrain's gate and dusk weight); the static caster cache also refreshes when streamed geometry arrives, so a standing camera picks up late trees (`world_shadow.rs::refresh_streamed_shadows`, independent of volumetric fog) | `worldShadows` |
|
||||
| Dynamic interiors | WMO rooms are lit per fragment by their own fixtures with a soft falloff, gated per room so light does not leak through walls | `interiorLight`, `interiorRoomGate` |
|
||||
| Synthesised lights | torches, braziers, lanterns, campfires and lampposts emit a light in the colour of their flame, with flicker | `fireLightGain`, `fireFlicker` |
|
||||
| Torch shadows | cube-map shadows from point lights onto buildings, models and terrain, with a contact-hardening penumbra and a shadow floor | `interiorShadows`, `exteriorShadows`, `torchShadowStrength`, `interiorShadowSoft` |
|
||||
| Daylight and doorways | calibrated fixtures at doors, windows and open boundaries carry daylight into a room, and doorways between rooms bleed light | `interiorDaylight`, env `WOW_DAYLIGHT`, `WOW_BLEED` |
|
||||
| Terrain in torch shadows | the ground casts into an outdoor fire's cube map, so a hill or bank blocks the fire (settled exterior slots only; `torch_terrain.rs` gathers the resident MCNK chunks inside the cube's 48 yd; the gather and the ground receiver both work, but stock Elwynn lamps/campfires show no visible change: the terrain-blocked ground is grazing (N·L ≈ 0, inside the 0.15 yd normal offset) or at the pool rim, below one 8-bit step — measured by the `lamp_terrain_occlusion_scan` census) | `torchTerrainShadows` (default 0, High 1), env `WOW_TORCH_TERRAIN=0\|1` |
|
||||
| City day floor | `interiorDaylight`'s room floor also reaches Stormwind/Ironforge rooms that the portal graph connects to the sky (an exterior-facing portal, or one hop from one; `district_sky_rooms` in `daylight.rs`); before this no city room could take it | `interiorDaylight` (default 0, suggested High 0.10) |
|
||||
| Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` |
|
||||
| Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` |
|
||||
| Moonlight | GFX (moonlight): the moon as a light. The stock 1.12 night (Light.dbc night colours on the never-setting lighting sun) stays the base; a cool (0.62, 0.74, 1.0), dim directional term from the visible white moon (`daynight::moon_direction`, the disc the sky draws) is ADDED per fragment with a soft-wrapped N·L on terrain, models, WMO exteriors and retained doodads, shadowed by the moon's own map (`shadow_hook::realtime_shadow_moonlit` `.z`, faded in with the rig's hand-over ramp). It ramps with the moon's elevation (0-12°) and out with the sun, dims under storms, and never reaches interiors or the glue booth. Packed as MonkeyFrame row 16 (`moon`: direction + intensity, base 0.16 gamma at `moonLight 1`) and `misc.w` (hand-over confidence); the per-frame blob is 8800 B (`lighting/moonlight.rs`, `moonlight_hook.wgsl`) | Advanced Graphics → Moonlight, cvar `moonLight` 0..2 (0 = the reference night, bit for bit; Graphics preset Low..Ultra = 1) |
|
||||
| Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` |
|
||||
| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors; the dawn curve follows the rendered clock, so a manual or capture time sets it; over an ocean a point below sea level is hazed at the sea surface, since water writes no depth) and daytime sun shafts through gaps beyond 10 yd, sampled from the shadow map (never moon shafts: they are gated by daylight; retired while Volumetric Light is on); own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` |
|
||||
| Volumetric light | GFX (volumetric light): shadow-mapped sun and moon shafts. The directional light the shadow rig holds (sun by day, moon at night) is ray-marched along each view ray at HALF resolution: 16 (Medium) or 32 (High) samples, quadratic spacing from 0.35 yd (dense near the eye), a per-pixel interleaved-gradient-noise offset, cascade lookups with the covered-sample mean past the cascades, air thinning with height above the eye (10 yd e-fold, so shafts live under canopies and between buildings rather than in the open sky), a Henyey-Greenstein g = 0.6 phase with a quarter isotropic. A depth-aware 4x4 half-res blur removes the jitter and a depth-aware 2x2 upsample composites it with the fog pass's soft screen curve. Extinction = the volumetric fog density law (dawn mist, weather) x the zone's fog end (350 yd reference, x0.7-2.5); colour = sun diffuse (or the moon tint x moonlight intensity, faint) leaned 35 % toward the zone fog hue. No shafts in WMO interiors. While it is on, the volumetric fog pass retires its own midpoint shafts and keeps only its haze and lamp halos. Three passes after the fog node (`benilla-app/src/volumetric_light.rs` + `.wgsl`); no temporal history. Dev capture levers `WOW_VOLLIGHT_DENSITY/_HEIGHT/_GAIN/_DEBUG` (1 = the in-scatter alone) | Advanced Graphics → Volumetric Light (Off/Medium/High) + Volumetric Light Strength 0..2, cvars `volumetricLight`, `volumetricLightStrength`, env `WOW_VOLLIGHT=0\|1\|2`; Graphics preset Medium = 1, High/Ultra = 2 |
|
||||
| Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` |
|
||||
| HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 |
|
||||
| Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` |
|
||||
| Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` |
|
||||
| Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; MONKEY (rainshelter): a camera-centred rain-occlusion map (128² cells of 0.75 yd, filled by vertical collision rays) keeps ground, walls and water under roofs, porches and bridges dry, with a ~1.5 yd drip margin and a soft edge; tree canopies without colliders do not shelter | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `weather/shelter.rs`, `wet_hook.wgsl` |
|
||||
| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), plus the slider Foliage Wind Strength, cvar `foliageWindStrength` 0.25..3 (default 1, not on the preset ladder; a gain on the sway, not the parting; a storm adds up to +60 %). Tuning at 1 (fix-wind): grass tips about ±0.2-0.3 yd on a 2.5 s wave with 1.1 s cross flutter, gust bands 55 yd wide rolling downwind; tree crowns about ±0.3 yd (lever-arm weight grows with height to 1.3x at 8.5 yd) on a 4 s sway, 5.5 s side wave, 0.8 s leaf flutter; bent tips dip slightly. capture override `WOW_FOLIAGE_WIND` (dev build + `WOW_CAPTURE` only); field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets |
|
||||
| Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels; on alpha-tested foliage (a dense window of two-sided depth spikes) the blur widens its depth tolerance and the AO drops 40 %, which removes the leaf grain while planes, silhouettes and contact creases keep full AO (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..5` diagnostic views (factor, protection, distance, raw occlusion, foliage mask), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning |
|
||||
| Lamp fog | MONKEY (lampfog): the fog fullscreen pass selects the nearest resolved exterior and interior point-table sources and analytically integrates their softened inverse-square light over the camera-to-scene-depth ray. A near-field haze floor makes lamps visible inside the ordinary volumetric haze's 10 yd clear zone; it is zero by day, stronger in rain/fog, and deliberately unshadowed. The table colour already includes intensity, flame flicker and `fireLightGain`. Halo output uses destination headroom and stays below 1.0, so a later bloom pass cannot blow it out | Advanced Graphics → Lamp Fog, cvar `lampFog` 0 Off (default, unchanged) / 1 Low (16 lamps) / 2 High (32 lamps; Graphics preset High = 2), env `WOW_LAMPFOG=0\|1\|2`; `benilla-app/src/volumetric_fog.rs` |
|
||||
| City window daylight | MONKEY (fix-daylight/integration): a district WMO's oversized window batch (e.g. the Cathedral of Light's stained-glass walls) is split into window-sized daylight apertures, so the nave takes daylight pane by pane. Applies to WMOs loaded after a change | Advanced Graphics → City Window Daylight, cvar `daylightWindowSplit` 0/1 (default 1, the merged behaviour); `benilla-world/src/lighting/daylight.rs` (`set_window_split`), bridge `benilla-app/src/dynamic_interior.rs` |
|
||||
| Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, `0x8` forces the screen-space sun shafts through a low or just-set sun (the sky's weight lifts their daylight fade; `post/sun_shafts.rs`), `0x10` fades the Modern fog's sun-colour lobe out by the sky's weight (`lighting/fog_model.rs`), plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` |
|
||||
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
|
||||
|
||||
`zoneSkyboxes = 0` disables only the added living-player zone slot. WMO and ghost skyboxes are
|
||||
the reference 1.12 slots, so their fidelity fixes remain active in Classic: authored colour and
|
||||
|
||||
@@ -27,32 +27,33 @@ technique is listed in the table below, and the file is listed in
|
||||
[`THIRD-PARTY.md`](THIRD-PARTY.md), the one table of every WarcraftXL-derived file across the
|
||||
fork. Do not remove any of them.
|
||||
|
||||
| Technique | Status here | From WarcraftXL |
|
||||
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Visual-only wave riding for swimmers (authoritative position never moves) | shipped (`water_fx/bob.rs`) | same principle as `world/Ride.cpp`, implemented independently |
|
||||
| Shore surf limited by the TERRAIN column, so objects in the water never foam | shipped (authored depth gate, `on_bed`) | principle from `sea/Shore.hpp` |
|
||||
| Per-channel extinction + scene-copy refraction | shipped (`enhanced_water.wgsl` DEPTH LOOK; bent samples landing on anything in front of the water are refused via the scene depth) | ported from `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
|
||||
| Caustics, two layers at incommensurate scales/rates | shipped (floor web; built from animated cell edges, not value noise) | ported from `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
|
||||
| Screen-space reflection of the scenery | shipped on `High` (a bisected march against the scene depth, not WXL's single probe) | principle from `shaders/Surface.ps.hlsl` |
|
||||
| Gerstner trains + breaker index, crest-fold foam | planned | `sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli` |
|
||||
| Specular lobe widened by the normal's screen derivative, distance glints | shipped / planned | `shaders/Surface.ps.hlsl` |
|
||||
| Technique | Status here | From WarcraftXL |
|
||||
|---|---|---|
|
||||
| Visual-only wave riding for swimmers (authoritative position never moves) | shipped (`water_fx/bob.rs`) | same principle as `world/Ride.cpp`, implemented independently |
|
||||
| Shore surf limited by the TERRAIN column, so objects in the water never foam | shipped (authored depth gate, `on_bed`) | principle from `sea/Shore.hpp` |
|
||||
| Per-channel extinction + scene-copy refraction | shipped (`enhanced_water.wgsl` DEPTH LOOK; bent samples landing on anything in front of the water are refused via the scene depth) | ported from `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
|
||||
| Caustics, two layers at incommensurate scales/rates | shipped (floor web; built from animated cell edges, not value noise) | ported from `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
|
||||
| Screen-space reflection of the scenery | the planar mirror's fallback for water at another height than the mirror plane (a bisected march against the scene depth, not WXL's single probe; a bisected hit still deep behind the depth is a ray passing behind an occluder and marches on) | principle from `shaders/Surface.ps.hlsl` |
|
||||
| Gerstner trains + breaker index, crest-fold foam | planned | `sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli` |
|
||||
| Specular lobe widened by the normal's screen derivative, distance glints | shipped / planned | `shaders/Surface.ps.hlsl` |
|
||||
|
||||
## What it does
|
||||
|
||||
| Lane | What you see | Main switch |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
|
||||
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
|
||||
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear. The fold mask adds the next two wave bands, a slow domain-warped gust field moves the break threshold and calms patches, and ~6 yd segments break each crest into short runs, so the caps do not form a lattice (`whitecap_fold`, `enhanced_water.wgsl`) | tier |
|
||||
| Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | tier |
|
||||
| Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier |
|
||||
| WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier |
|
||||
| Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier |
|
||||
| Reflection and glints | Fresnel sky reflection from the zone's sky rows, sun path, moon path, point-light glints | tier |
|
||||
| Beach surf | a travelling wave front, a lace of foam that dissolves behind it, a swash sheet on the sand | tier |
|
||||
| Swimmers | wake foam, treading rings and a visual-only bob with the swell | tier |
|
||||
| Lava glow | magma surfaces emit warm point lights through the lighting module | Advanced Graphics → Lava Glow, `lavaLightGain` |
|
||||
| Lava HDR | magma partially resists distance fog and feeds the screen-space halo | Advanced Graphics → Bloom, `bloom` (Off/Low/High) |
|
||||
| Lane | What you see | Main switch |
|
||||
|---|---|---|
|
||||
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
|
||||
| Planar reflections | High only: the scene drawn a second time from the eye mirrored in the water plane the view looks at (half resolution, clipped at the plane by an oblique near plane, no water, no sun shadow cascades), so docks reflect their undersides and anything above the frame reflects; water at another height falls back to the screen-space march. About +1 ms CPU per frame, GPU about even (the march is skipped where the mirror covers). It REPLACES screen-space reflections on High; there is no player option | tier; dev env `WOW_WATER_REFLECT=0\|1` (0 = screen-space only, for A/B) |
|
||||
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
|
||||
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear. The fold mask adds the next two wave bands, a slow domain-warped gust field moves the break threshold and calms patches, and ~6 yd segments break each crest into short runs, so the caps do not form a lattice (`whitecap_fold`, `enhanced_water.wgsl`) | tier |
|
||||
| Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | tier |
|
||||
| Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier |
|
||||
| WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier |
|
||||
| Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier |
|
||||
| Reflection and glints | Fresnel sky reflection from the zone's sky rows, sun path, moon path, point-light glints | tier |
|
||||
| Beach surf | a travelling wave front, a lace of foam that dissolves behind it, a swash sheet on the sand | tier |
|
||||
| Swimmers | wake foam, treading rings and a visual-only bob with the swell | tier |
|
||||
| Lava glow | magma surfaces emit warm point lights through the lighting module | Advanced Graphics → Lava Glow, `lavaLightGain` |
|
||||
| Lava HDR | magma partially resists distance fog and feeds the screen-space halo | Advanced Graphics → Bloom, `bloom` (Off/Low/High) |
|
||||
|
||||
Rivers deliberately have NO flow effect: a derived current was tried and removed at the owner's
|
||||
request. If it ever returns it should read the authored MCLQ flow records, not derive one.
|
||||
@@ -62,15 +63,17 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
|
||||
- **Shader**: `crates/benilla-assets/src/shaders/enhanced_water.wgsl` - the whole module on the GPU
|
||||
side, an importable library (`benilla::enhanced_water`) with its own bindings: 103 scene depth,
|
||||
104 `WaterParams` (`benilla_assets::WaterUniform`), 105 the shared light buffer (a second
|
||||
read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection). Upstream's `liquid.wgsl` carries three hooks only: the
|
||||
read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection), 107 the planar mirror (`liquid/mirror.rs`). Upstream's `liquid.wgsl` carries three hooks only: the
|
||||
`#import`, `water_swell` in its vertex stage, and the `water_active()` branch at the top of its
|
||||
fragment stage, which returns the module's result as-is (the module fogs its own surface terms
|
||||
only: the scene it refracts and reflects is already fogged). Keep it that way: new
|
||||
water code goes in the module, not in `liquid.wgsl`.
|
||||
- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (`WaterUniform`, the scene-depth image),
|
||||
`materials.rs` (`LiquidExt` fields 103-106, shader registration), `lib.rs` (`WaterQuality`).
|
||||
`materials.rs` (`LiquidExt` fields 103-107, shader registration), `lib.rs` (`WaterQuality`,
|
||||
`WaterReflections`).
|
||||
- **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after
|
||||
the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
|
||||
the main opaque pass), `liquid/mirror.rs` (the planar mirror camera, its plane vote and binding 107),
|
||||
`liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs`
|
||||
(per-kind material parameters and the opt-in `WOW_WATER_PROBE` WMO classification/depth log),
|
||||
`liquid/lod.rs` (High-only 4x near-grid swap),
|
||||
`water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`.
|
||||
@@ -90,3 +93,8 @@ request. If it ever returns it should read the authored MCLQ flow records, not d
|
||||
- The temporary water wind is `WATER_WIND_DIR`; all wind-aligned shader terms derive from it until
|
||||
the weather lane supplies shared wind.
|
||||
- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
|
||||
- The planar mirror is read at the water pixel's own screen position, flipped vertically: the mirror
|
||||
camera is the reflected eye rolled half a turn (a proper rotation, so culling and winding stay as they
|
||||
are). It must keep the world camera's view key (HDR, MSAA, perspective, shadow filter) or every
|
||||
pipeline compiles a second time; it never draws water (`drop_water_from_mirror`).
|
||||
- `WOW_WATER_MIRROR_LOG=1` prints the mirror's verdict once a second (plane, votes, active).
|
||||
|
||||
@@ -37,7 +37,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"] }
|
||||
@@ -54,6 +55,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.
|
||||
@@ -66,7 +70,7 @@ kira = { version = "0.12", default-features = false, features = [
|
||||
# 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"
|
||||
@@ -116,11 +120,14 @@ objc2-foundation = { version = "0.2", features = [
|
||||
[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_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
] }
|
||||
|
||||
# The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`).
|
||||
|
||||
@@ -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,15 +225,16 @@
|
||||
<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>
|
||||
|
||||
<Frame name="BenillaScriptLogFrame" hidden="true">
|
||||
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
|
||||
<Frame name="BenillaScriptLogFrame" parent="UIParent" hidden="true">
|
||||
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. Under UIParent,
|
||||
as the stock panels are, so it takes the UI scale with them. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -232,8 +274,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 +288,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 +318,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,203 +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
|
||||
# `CharacterFrameTabButtonTemplate`, which `CharacterFrame.xml`'s own tabs (and TurtleWoW's LFT
|
||||
# group finder) inherit; the reference's toc lists it apart, so bring it in ahead of its users.
|
||||
Interface\FrameXML\CharacterFrameTemplates.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
|
||||
# Ours: the sixth tab page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped.
|
||||
CharacterFrameAdapters.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
|
||||
# TurtleWoW's Looking-For-Turtle group finder (`/lft`, and the minimap eye): the reference's own
|
||||
# toc lists LFT_Data.lua, LFT.lua, LFTTemplates.xml and LFT.xml in that order, and OctoWoW's
|
||||
# blink-pause overlay rides last. `json.lua` is the codec the group payloads decode through.
|
||||
Interface\FrameXML\json.lua
|
||||
Interface\FrameXML\LFT\LFT_Data.lua
|
||||
Interface\FrameXML\LFT\LFT.lua
|
||||
Interface\FrameXML\LFT\LFTTemplates.xml
|
||||
Interface\FrameXML\LFT\LFT.xml
|
||||
Interface\FrameXML\OctoLFTBlinkPause\OctoLFTBlinkPause.lua
|
||||
# TurtleWoW's own FrameXML additions, in the reference `FrameXML.toc` order (patch-3.mpq). These
|
||||
# are the client half of Turtle's custom systems: `Turtle_TransmogUI` hooks `GOSSIP_SHOW`, hides
|
||||
# the stock gossip frame for its NPCs and drives the transmog window over `TW_TRANSMOG` addon
|
||||
# messages; `Turtle_ShopUI` (prefix `TW_SHOP`) is the custom merchant/shop. Without these rows the
|
||||
# server's trigger gossip text (`TRANSMOG_TRIGGER`, `CUSTOM_MERCHANT_TRIGGER`) is what the stock
|
||||
# gossip window shows, and neither custom window exists. Turtle's Inspect-Talents and Guild-Bank
|
||||
# UIs are the same pattern but need `ToggleInspect`/`SetTalentButtonLocation` and the GuildBank
|
||||
# globals built first, so they are not loaded here yet.
|
||||
Interface\FrameXML\Turtle_TransmogUI\Turtle_TransmogSets.lua
|
||||
# The slot buttons in Turtle_TransmogUI.xml inherit TransmogPlayerSlotTemplate, and the look grid
|
||||
# and new-transmog alert inherit TransmogFrameLookTemplate/TransmogWonItemTemplate; without this
|
||||
# file they materialize bare and the UI's `_G[slot.."BorderSelected"]`-style reads raise on nil.
|
||||
Interface\FrameXML\Turtle_TransmogUI\Turtle_TransmogUITemplates.xml
|
||||
Interface\FrameXML\Turtle_TransmogUI\Turtle_TransmogUI.xml
|
||||
Interface\FrameXML\Turtle_ShopUI\Turtle_ShopUITemplates.xml
|
||||
Interface\FrameXML\Turtle_ShopUI\Turtle_ShopUI.xml
|
||||
# Turtle's honour/arena merchant (Stormwind armoury PvP gear, the Custom_Merchant NPCs): it hooks
|
||||
# `GOSSIP_SHOW`, hides the gossip frame for an NPC whose greeting is `CUSTOM_MERCHANT_TRIGGER` and
|
||||
# fills over `TW_Merchant` addon messages. Without it that trigger text is what the stock gossip
|
||||
# window shows. The `.xml` scripts its own `.lua`, and it inherits SmallAlterateCurrencyFrameTemplate
|
||||
# (MoneyFrame.xml) and uses `json` (json.lua above), both already on the chain.
|
||||
Interface\FrameXML\Custom_Merchant\CustomMerchantFrame.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,25 @@
|
||||
# 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
|
||||
# Ours: the sixth tab page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped.
|
||||
CharacterFrameAdapters.xml
|
||||
# The `/errors` script error log and our slash commands.
|
||||
ScriptLogFrame.xml
|
||||
# Our fixes to the stock Lua.
|
||||
FrameXMLFixes.xml
|
||||
@@ -0,0 +1,87 @@
|
||||
//! `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`: the
|
||||
//! `AddOns/` folder in [`benilla_app::config_dir`], where an addon the crate ships is written.
|
||||
//!
|
||||
//! 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 and where its
|
||||
/// addons go.
|
||||
struct HelloPlugin;
|
||||
|
||||
impl Plugin for HelloPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, || {
|
||||
let addons = benilla_app::config_dir().map(|dir| dir.join("AddOns"));
|
||||
info!("a plugin on top of benilla is running; its addons go in {addons:?}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+194
-37
@@ -26,13 +26,13 @@
|
||||
//! Deviation: the indoor dedup keys `(WMOID, MOGP uniqueID)`, not the client's raw per-WMO group
|
||||
//! index, because the raw index can alias across adjacent buildings.
|
||||
//!
|
||||
//! The reference's PvP display gate `[0x88272c]` (a cached boolean, not the realm type) is
|
||||
//! untraced; ours is always open.
|
||||
//! `GetZonePVPInfo`'s type and faction show only on a PvP realm or in one of the six capitals
|
||||
//! (zone Flags `0x10`); elsewhere they are nil and the stock zone texts stay uncoloured.
|
||||
|
||||
use bevy::ecs::system::NonSendMut;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::AreaTableCatalog;
|
||||
use benilla_formats::{AreaTableCatalog, AreaTableRow};
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use crate::net::{ObjectStore, SelfPlayer};
|
||||
@@ -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>>,
|
||||
@@ -107,6 +107,7 @@ fn feed_zone_events(
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
factions: Option<Res<Factions>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
realm_pvp: Option<Res<RealmPvp>>,
|
||||
mut cache: Local<crate::ui_script::VmMemo<ZoneCache>>,
|
||||
) {
|
||||
let (Some(mut script), Some(areas)) = (script, areas) else {
|
||||
@@ -193,41 +194,37 @@ fn feed_zone_events(
|
||||
return;
|
||||
}
|
||||
|
||||
// GetZonePVPInfo (`0x48d540`): isArena is the leaf's Flags `0x80`; the type is the zone's
|
||||
// FactionGroupMask against the player template's friend then enemy masks, "contested" when
|
||||
// neither; never "arena"; nil only on structural failure. factionName is FactionGroup.dbc's
|
||||
// Name for the zone's mask bit; the realm type never enters.
|
||||
let is_arena = leaf_row.flags & 0x80 != 0;
|
||||
let zone_mask = areas.0.get(zone).map_or(0, |r| r.faction_group_mask);
|
||||
let pvp = factions
|
||||
let zone_row = areas.0.get(zone);
|
||||
let template = factions
|
||||
.as_ref()
|
||||
.zip(self_store.single().ok())
|
||||
.and_then(|(f, store)| {
|
||||
let tpl = f.catalog().template(store.0.unit_faction_template()?)?;
|
||||
let ty = if zone_mask & tpl.friend_group_mask != 0 {
|
||||
"friendly"
|
||||
} else if zone_mask & tpl.enemy_group_mask != 0 {
|
||||
"hostile"
|
||||
} else {
|
||||
"contested"
|
||||
};
|
||||
Some((ty, f.catalog().faction_group_name(zone_mask).unwrap_or("")))
|
||||
});
|
||||
let (pvp_type, pvp_faction) = pvp.unwrap_or(("", ""));
|
||||
.and_then(|(f, store)| f.catalog().template(store.0.unit_faction_template()?))
|
||||
.map(|t| (t.friend_group_mask, t.enemy_group_mask));
|
||||
let realm_pvp = realm_pvp.is_some_and(|r| r.0);
|
||||
let (ty, is_arena) = zone_pvp_info(realm_pvp, leaf_row, zone_row, template);
|
||||
// factionName is FactionGroup.dbc's Name for the zone's mask bit.
|
||||
let pvp = ty.map(|ty| {
|
||||
let mask = zone_row.map_or(0, |r| r.faction_group_mask);
|
||||
let name = factions
|
||||
.as_ref()
|
||||
.and_then(|f| f.catalog().faction_group_name(mask))
|
||||
.unwrap_or("");
|
||||
(ty, name)
|
||||
});
|
||||
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![]);
|
||||
@@ -248,12 +245,70 @@ fn feed_zone_events(
|
||||
cache.wmo_group = wmo_group;
|
||||
}
|
||||
|
||||
/// `GetZonePVPInfo` (`0x48d540`): `(pvpType, isArena)`. isArena is the leaf's Flags `0x80`, read
|
||||
/// before any bail. The type is nil with no zone row, no faction template, or the display gate
|
||||
/// closed: it opens on a PvP realm ([`RealmPvp`]) or in a zone whose Flags carry `0x10`, the six
|
||||
/// capitals (`0x48d5c2`/`0x48d5cc`). Open, it is the zone's FactionGroupMask against the template's
|
||||
/// `(friend, enemy)` masks, "contested" when neither, never "arena".
|
||||
fn zone_pvp_info(
|
||||
realm_pvp: bool,
|
||||
leaf: &AreaTableRow,
|
||||
zone: Option<&AreaTableRow>,
|
||||
template: Option<(u32, u32)>,
|
||||
) -> (Option<&'static str>, bool) {
|
||||
let is_arena = leaf.flags & 0x80 != 0;
|
||||
let ty = zone
|
||||
.filter(|z| realm_pvp || z.flags & 0x10 != 0)
|
||||
.zip(template)
|
||||
.map(|(z, (friend, enemy))| {
|
||||
let mask = z.faction_group_mask;
|
||||
if mask & friend != 0 {
|
||||
"friendly"
|
||||
} else if mask & enemy != 0 {
|
||||
"hostile"
|
||||
} else {
|
||||
"contested"
|
||||
}
|
||||
});
|
||||
(ty, is_arena)
|
||||
}
|
||||
|
||||
/// `[0x88272c]`, the realm's PvP flag the display gate reads: the selected realm's
|
||||
/// `PlayerKillingAllowed` in `Cfg_Configs.dbc`, stored once per world entry from the character
|
||||
/// list (`0x4015de` in `0x401570`), so a `/reload` or a far teleport keeps it.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct RealmPvp(bool);
|
||||
|
||||
/// Latch [`RealmPvp`] from the realm this session entered the world on.
|
||||
fn latch_realm_pvp(
|
||||
mut latch: ResMut<RealmPvp>,
|
||||
roster: Option<Res<crate::char_select::Roster>>,
|
||||
realms: Option<Res<crate::realm_select::Realms>>,
|
||||
) {
|
||||
let realm_type = roster.and_then(|r| r.realm.as_ref().map(|realm| realm.realm_type));
|
||||
let row = realm_type
|
||||
.zip(realms)
|
||||
.and_then(|(t, realms)| realms.row_pvp(t));
|
||||
latch.0 = latched_realm_pvp(latch.0, row);
|
||||
}
|
||||
|
||||
/// A realm type with no `Cfg_Configs.dbc` row skips the store (`0x4015d2`) and keeps the last
|
||||
/// world entry's value, which the process starts at 0 (`.data`).
|
||||
fn latched_realm_pvp(previous: bool, row_pvp: Option<bool>) -> bool {
|
||||
row_pvp.unwrap_or(previous)
|
||||
}
|
||||
|
||||
/// The shared area catalog and the zone-event feed.
|
||||
pub(crate) struct AreaPlugin;
|
||||
|
||||
impl Plugin for AreaPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, load_area_table.after(AssetSet::Open))
|
||||
app.init_resource::<RealmPvp>()
|
||||
.add_systems(Startup, load_area_table.after(AssetSet::Open))
|
||||
.add_systems(
|
||||
OnEnter(crate::char_select::ClientState::InWorld),
|
||||
latch_realm_pvp,
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
// After the leaf authority: leaf, indoor bit and names must come from one frame,
|
||||
@@ -330,6 +385,108 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn row(flags: u32, faction_group_mask: u32) -> AreaTableRow {
|
||||
AreaTableRow {
|
||||
map_id: 0,
|
||||
zone_id: 0,
|
||||
explore_flag: 0,
|
||||
flags,
|
||||
faction_group_mask,
|
||||
exploration_level: 0,
|
||||
name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An Alliance player's template: friend Alliance (2), enemy Horde (4).
|
||||
const ALLIANCE: Option<(u32, u32)> = Some((2, 4));
|
||||
|
||||
/// The display gate (`0x48d5c2`/`0x48d5cc`): a PvE realm outside a capital gets no type; a
|
||||
/// capital's Flags `0x10` opens it there, and a PvP realm opens it everywhere.
|
||||
#[test]
|
||||
fn the_pvp_type_shows_on_a_pvp_realm_or_in_a_capital() {
|
||||
let elwynn = row(0x40, 2);
|
||||
let stormwind = row(0x138, 2);
|
||||
let orgrimmar = row(0x138, 4);
|
||||
let stranglethorn = row(0x40, 0);
|
||||
let pvp =
|
||||
|realm_pvp, zone: &AreaTableRow| zone_pvp_info(realm_pvp, zone, Some(zone), ALLIANCE).0;
|
||||
|
||||
assert_eq!(pvp(false, &elwynn), None, "PvE realm, outside a capital");
|
||||
assert_eq!(
|
||||
pvp(false, &stranglethorn),
|
||||
None,
|
||||
"PvE realm, ownerless zone"
|
||||
);
|
||||
assert_eq!(
|
||||
pvp(false, &stormwind),
|
||||
Some("friendly"),
|
||||
"PvE realm, own capital"
|
||||
);
|
||||
assert_eq!(
|
||||
pvp(false, &orgrimmar),
|
||||
Some("hostile"),
|
||||
"PvE realm, enemy capital"
|
||||
);
|
||||
|
||||
assert_eq!(pvp(true, &elwynn), Some("friendly"));
|
||||
assert_eq!(pvp(true, &stranglethorn), Some("contested"));
|
||||
assert_eq!(pvp(true, &orgrimmar), Some("hostile"));
|
||||
}
|
||||
|
||||
/// isArena is the leaf's `0x80`, read before any bail, so a closed gate keeps it.
|
||||
#[test]
|
||||
fn is_arena_survives_a_closed_gate() {
|
||||
let battle_ring = row(0xd0, 0);
|
||||
let stranglethorn = row(0x40, 0);
|
||||
assert_eq!(
|
||||
zone_pvp_info(false, &battle_ring, Some(&stranglethorn), ALLIANCE),
|
||||
(None, true)
|
||||
);
|
||||
assert_eq!(
|
||||
zone_pvp_info(true, &battle_ring, Some(&stranglethorn), ALLIANCE),
|
||||
(Some("contested"), true)
|
||||
);
|
||||
assert_eq!(
|
||||
zone_pvp_info(true, &battle_ring, Some(&stranglethorn), None),
|
||||
(None, true),
|
||||
"no faction template"
|
||||
);
|
||||
}
|
||||
|
||||
/// A realm type with no row keeps what the last world entry stored.
|
||||
#[test]
|
||||
fn a_realm_type_with_no_row_keeps_the_last_latch() {
|
||||
assert!(!latched_realm_pvp(false, None));
|
||||
assert!(latched_realm_pvp(true, None));
|
||||
assert!(!latched_realm_pvp(true, Some(false)));
|
||||
assert!(latched_realm_pvp(false, Some(true)));
|
||||
}
|
||||
|
||||
/// No zone row is a bail, not an ownerless zone.
|
||||
#[test]
|
||||
fn a_missing_zone_row_has_no_pvp_type() {
|
||||
assert_eq!(
|
||||
zone_pvp_info(true, &row(0, 0), None, ALLIANCE),
|
||||
(None, false)
|
||||
);
|
||||
}
|
||||
|
||||
/// On the install's AreaTable the gate opens in exactly the six capitals for a PvE realm.
|
||||
#[test]
|
||||
fn the_six_capitals_are_the_zones_a_pve_realm_colours() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("chain");
|
||||
let cat = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable");
|
||||
let open: Vec<u32> = (0..=u32::from(u16::MAX))
|
||||
.filter(|&id| {
|
||||
cat.get(id).is_some_and(|r| {
|
||||
r.zone_id == 0 && zone_pvp_info(false, r, Some(r), ALLIANCE).0.is_some()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(open, [1497, 1519, 1537, 1637, 1638, 1657]);
|
||||
}
|
||||
|
||||
/// The `0x67e670` override skip (abbey) and fire (inn) branches on the install's data.
|
||||
#[test]
|
||||
fn indoor_naming_matches_the_byte_law_on_real_data() {
|
||||
|
||||
+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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The capture UI fixtures: each arm seeds one window's synthetic but realistic state.
|
||||
|
||||
use super::*;
|
||||
use benilla_protocol::messages::trainer_spell_state;
|
||||
|
||||
/// The `vplates` fixture's wolf, the reference screenshot's subject (vmangos `creature_template`
|
||||
/// 69 "Timber Wolf": level 2, faction template 32, display 604), at the scenario's look point.
|
||||
@@ -82,6 +83,66 @@ pub(super) fn seed_wind_player(
|
||||
));
|
||||
}
|
||||
|
||||
/// One trainer fixture's canned seed: the trainer's identity, its `SMSG_TRAINER_LIST` type, its
|
||||
/// greeting, the character's hearthstone bind area, and the wire rows
|
||||
/// `(spell, state, copper cost, required level)`.
|
||||
struct TrainerSeed {
|
||||
name: &'static str,
|
||||
subname: &'static str,
|
||||
trainer_type: u32,
|
||||
greeting: &'static str,
|
||||
bind: u32,
|
||||
rows: &'static [(u32, u8, u32, u8)],
|
||||
}
|
||||
|
||||
/// Llane Beshere (entry 911), Northshire's warrior trainer: the rank-1 learn wrappers a vmangos
|
||||
/// trainer lists (`1605` teaches Heroic Strike 78 and so on), so each row's name and rank mirror
|
||||
/// the ability while its description is the taught spell's own text.
|
||||
const TRAINER_WARRIOR: TrainerSeed = TrainerSeed {
|
||||
name: "Llane Beshere",
|
||||
subname: "Warrior Trainer",
|
||||
trainer_type: 0,
|
||||
greeting: "I can train you in the ways of the warrior.",
|
||||
bind: 9, // Northshire Valley, where this character's hearthstone is
|
||||
rows: &[
|
||||
(1605, trainer_spell_state::GREEN, 10, 1), // Heroic Strike Rank 1
|
||||
(1738, trainer_spell_state::GREEN, 100, 4), // Charge Rank 1
|
||||
(1423, trainer_spell_state::GREEN, 100, 4), // Rend Rank 1
|
||||
(6674, trainer_spell_state::GREEN, 200, 6), // Battle Shout Rank 1
|
||||
(1716, trainer_spell_state::GREEN, 300, 8), // Hamstring Rank 1
|
||||
(3128, trainer_spell_state::GREEN, 200, 8), // Parry
|
||||
],
|
||||
};
|
||||
|
||||
/// A shaman trainer's Astral Recall (wrapper 1352 teaches 556). One service only: the stock
|
||||
/// window's `ClassTrainer_SelectFirstLearnableSkill` selects row 2, so the row the shot is about
|
||||
/// must be the first service of the first group.
|
||||
const TRAINER_SHAMAN: TrainerSeed = TrainerSeed {
|
||||
name: "Sian'tsu",
|
||||
subname: "Shaman Trainer",
|
||||
trainer_type: 0,
|
||||
greeting: "The spirits are strong within you, shaman.",
|
||||
bind: 362, // Razor Hill, a Durotar hearthstone
|
||||
rows: &[(1352, trainer_spell_state::GREEN, 4000, 30)],
|
||||
};
|
||||
|
||||
/// Bengus Deepforge, Ironforge's blacksmithing trainer, with the two row shapes a profession
|
||||
/// trainer lists: the profession-learn wrapper (2020 teaches 2018, whose own text the row shows)
|
||||
/// and a recipe (7820 teaches 7818, whose text is the description of the Silver Rod it makes).
|
||||
/// The learn row sorts into group 1 (`Effect` 44 `SKILL_STEP`) ahead of the recipe's group 2
|
||||
/// (`0x4d77b6`), so the stock window's row-2 selection lands on it.
|
||||
const TRAINER_BLACKSMITH: TrainerSeed = TrainerSeed {
|
||||
name: "Bengus Deepforge",
|
||||
subname: "Blacksmithing Trainer",
|
||||
trainer_type: 2, // a tradeskill trainer
|
||||
greeting: "The forge shapes what the mine provides.",
|
||||
bind: 1537, // Ironforge
|
||||
rows: &[
|
||||
(2020, trainer_spell_state::GREEN, 9, 5), // Apprentice Blacksmith
|
||||
(7820, trainer_spell_state::GREEN, 90, 0), // Silver Rod
|
||||
],
|
||||
};
|
||||
|
||||
/// Seeds the fixture window's state once the scene is resident; the real feeds push it into the VM
|
||||
/// during the settle window as live wire data would. Icons resolve through the offline
|
||||
/// `ItemDisplayCatalog`, and names go straight into the caches.
|
||||
@@ -94,13 +155,17 @@ pub(super) fn seed_ui_fixture(
|
||||
mut quest: ResMut<crate::ui_quest::QuestGiver>,
|
||||
mut quest_log: ResMut<crate::ui_quest_log::QuestLog>,
|
||||
mut loot: ResMut<crate::ui_loot::LootState>,
|
||||
mut trainer: ResMut<crate::ui_trainer::TrainerOpen>,
|
||||
// One param under Bevy's 16-param cap: item objects are entities in the index, spawned as
|
||||
// the wire does.
|
||||
store: (ResMut<crate::items::Items>, ResMut<crate::net::GuidIndex>),
|
||||
// the wire does; the hearthstone bind (`SMSG_BINDPOINTUPDATE`) rides along for the `$z` token.
|
||||
store: (
|
||||
ResMut<crate::items::Items>,
|
||||
ResMut<crate::net::GuidIndex>,
|
||||
ResMut<crate::net::HomeBind>,
|
||||
),
|
||||
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.
|
||||
@@ -111,7 +176,7 @@ pub(super) fn seed_ui_fixture(
|
||||
ResMut<crate::loading_screen::LoadingScreen>,
|
||||
),
|
||||
) {
|
||||
let (mut items, mut index) = store;
|
||||
let (mut items, mut index, mut home_bind) = store;
|
||||
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
|
||||
let Some(scenario) = ctx.scenario else {
|
||||
return;
|
||||
@@ -266,6 +331,62 @@ pub(super) fn seed_ui_fixture(
|
||||
});
|
||||
}
|
||||
}
|
||||
UiFixture::Trainer(list) => {
|
||||
// The seed's rows are the wire list: its copper costs, level gates and the states the
|
||||
// server computed, in wire order.
|
||||
use benilla_protocol::messages::TrainerSpell;
|
||||
let seed = match list {
|
||||
TrainerList::Warrior => &TRAINER_WARRIOR,
|
||||
TrainerList::Shaman => &TRAINER_SHAMAN,
|
||||
TrainerList::Blacksmith => &TRAINER_BLACKSMITH,
|
||||
};
|
||||
names.insert_creature(
|
||||
NPC_ENTRY,
|
||||
Some(crate::names::CreatureRecord {
|
||||
name: seed.name.into(),
|
||||
subname: Some(seed.subname.into()),
|
||||
creature_type: 7,
|
||||
pet_family: 0,
|
||||
rank: 0,
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
home_bind.0 = Some(seed.bind);
|
||||
let Some(script) = script.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// The window's title and portrait read `UnitName("npc")`; no session seats it here.
|
||||
script.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some(seed.name.into()),
|
||||
level: 12,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
// A purse large enough for every cost above, so the money frame renders white.
|
||||
script.set_money(10_000);
|
||||
let services: Vec<TrainerSpell> = seed
|
||||
.rows
|
||||
.iter()
|
||||
.map(|&(spell, state, cost, req_level)| TrainerSpell {
|
||||
spell,
|
||||
state,
|
||||
cost,
|
||||
can_learn_primary_prof: false,
|
||||
is_primary_prof_first_rank: false,
|
||||
req_level,
|
||||
req_skill: 0,
|
||||
req_skill_value: 0,
|
||||
req_spells: [0; 3],
|
||||
})
|
||||
.collect();
|
||||
trainer.open(NPC_GUID, seed.trainer_type, services, seed.greeting.into());
|
||||
}
|
||||
UiFixture::Gossip => {
|
||||
names.insert_creature(
|
||||
NPC_ENTRY,
|
||||
@@ -1029,7 +1150,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);
|
||||
@@ -1123,17 +1245,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}");
|
||||
}
|
||||
@@ -1307,15 +1429,23 @@ pub(super) fn seed_ui_fixture(
|
||||
warn!("capture: ui-chat-tabhover select failed: {e}");
|
||||
}
|
||||
script.resolve();
|
||||
// Each in the root's units the cursor is fed in: the frame's own times its scale.
|
||||
let expr = match mode.as_str() {
|
||||
"2" | "3" => "return ChatFrame2Tab:GetCenter()",
|
||||
"2" | "3" => {
|
||||
"local x, y = ChatFrame2Tab:GetCenter() \
|
||||
local k = ChatFrame2Tab:GetEffectiveScale() return x * k, y * k"
|
||||
}
|
||||
"0" => {
|
||||
"return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \
|
||||
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2"
|
||||
"local k = ChatFrame2:GetEffectiveScale() \
|
||||
return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2 * k, \
|
||||
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2 * k"
|
||||
}
|
||||
// 9: park far away, so the dock conceals itself.
|
||||
"9" => "return 2000, 2000",
|
||||
_ => "return ChatFrame1Tab:GetCenter()",
|
||||
_ => {
|
||||
"local x, y = ChatFrame1Tab:GetCenter() \
|
||||
local k = ChatFrame1Tab:GetEffectiveScale() return x * k, y * k"
|
||||
}
|
||||
};
|
||||
let centre: Result<(f32, f32), _> = script.eval(expr);
|
||||
match centre {
|
||||
@@ -1392,7 +1522,51 @@ 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);
|
||||
}
|
||||
UiFixture::NameRank => {
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
// The self player at the eye, as `name-water`; the subject is the other player's name.
|
||||
const SELF_GUID: u64 = 0x51;
|
||||
names.insert_player(SELF_GUID, "Benilla".into(), None);
|
||||
commands.spawn((
|
||||
crate::net::ObjectStore(ObjectFields::from_pairs(&[
|
||||
(34, 2), // UNIT_FIELD_LEVEL
|
||||
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
|
||||
(36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior
|
||||
])),
|
||||
crate::net::SelfPlayer,
|
||||
crate::net::Guid(SELF_GUID),
|
||||
Transform::from_translation(wow_to_bevy(scenario.eye)),
|
||||
));
|
||||
// The subject at the `vplates` wolf's dry spot: a human player holding rank,
|
||||
// `PLAYER_BYTES_3` byte 3 (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos
|
||||
// `HonorMgr.cpp:900`) at internal 7: the visual rank 3, "Sergeant" (team 1 off race
|
||||
// 1's `ChrRaces` row).
|
||||
const RANKED_GUID: u64 = 0x52;
|
||||
names.insert_player(RANKED_GUID, "Bob".into(), None);
|
||||
commands.spawn((
|
||||
crate::net::Guid(RANKED_GUID),
|
||||
crate::net::NetEntity {
|
||||
kind: benilla_protocol::EntityKind::Player,
|
||||
// HumanMale, the body a human `UNIT_FIELD_DISPLAYID` carries.
|
||||
display_id: Some(49),
|
||||
scale: 1.0,
|
||||
},
|
||||
crate::net::ObjectStore(ObjectFields::from_pairs(&[
|
||||
(34, 60), // UNIT_FIELD_LEVEL
|
||||
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
|
||||
(36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior, male
|
||||
(195, 7 << 24), // PLAYER_BYTES_3 byte 3: the current honor rank, internal 7
|
||||
])),
|
||||
Transform {
|
||||
translation: wow_to_bevy(WOLF_POS),
|
||||
..default()
|
||||
},
|
||||
Visibility::default(),
|
||||
));
|
||||
// A plated unit draws no floating name, so enemy plates go off.
|
||||
set_enemy_plates(script.as_deref(), false);
|
||||
}
|
||||
UiFixture::NameRank => {
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
@@ -1480,7 +1654,7 @@ pub(super) fn seed_ui_fixture(
|
||||
));
|
||||
}
|
||||
}
|
||||
vplates.enemies = false;
|
||||
set_enemy_plates(script.as_deref(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1513,9 +1687,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))
|
||||
@@ -1834,3 +2006,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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ mod probe_vendor_swap;
|
||||
mod probes;
|
||||
mod realm_list;
|
||||
mod scenarios;
|
||||
mod spell_light;
|
||||
use crate::run_mode::CaptureMode;
|
||||
pub(crate) use depth_probe::DepthProbePlugin;
|
||||
use fixtures::{seed_perf_crowd, seed_ui_fixture, seed_wind_player};
|
||||
@@ -117,7 +118,9 @@ pub(crate) use probes::{
|
||||
};
|
||||
pub(crate) use scenarios::ui_opted_in;
|
||||
use scenarios::GlueScreen;
|
||||
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
|
||||
use scenarios::{
|
||||
Scenario, SubjectKind, TrainerList, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS,
|
||||
};
|
||||
|
||||
pub(crate) mod fxview;
|
||||
// The scripted aim, swim-pitch and camera-rig drivers order themselves before `player::control`:
|
||||
@@ -680,6 +683,11 @@ impl Plugin for CapturePlugin {
|
||||
})
|
||||
.add_systems(Update, pin_scene.in_set(WorldStage::Present))
|
||||
.add_systems(Update, seed_wind_player)
|
||||
.add_systems(Update, spell_light::seed)
|
||||
.add_systems(
|
||||
Last,
|
||||
spell_light::trace.after(crate::shadow_core::ShadowSet::Lanes),
|
||||
)
|
||||
// MONKEY (perf): the probe crowd (`WOW_PERF_CROWD`).
|
||||
.add_systems(Update, seed_perf_crowd)
|
||||
// Before `UnitFeed`: the seed stands in for wire data live play delivers on earlier
|
||||
@@ -708,7 +716,21 @@ fn pin_scene(
|
||||
return;
|
||||
};
|
||||
debug.lighting.follow_server_time = false;
|
||||
debug.lighting.manual_minute = scenario.minute;
|
||||
// MONKEY (capture framing): `WOW_CAPTURE_MINUTE=<0..1440>`, `WOW_CAPTURE_EYE=x,y,z` and
|
||||
// `WOW_CAPTURE_LOOK=x,y,z` (raw WoW coords) re-aim a scenario without a rebuild, for framing a
|
||||
// reported spot; a scenario's baseline never sets them.
|
||||
let triple = |var: &str| {
|
||||
std::env::var(var).ok().and_then(|s| {
|
||||
let v: Vec<f32> = s.split(',').filter_map(|x| x.trim().parse().ok()).collect();
|
||||
(v.len() == 3).then(|| [v[0], v[1], v[2]])
|
||||
})
|
||||
};
|
||||
debug.lighting.manual_minute = std::env::var("WOW_CAPTURE_MINUTE")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(scenario.minute);
|
||||
let scene_eye = triple("WOW_CAPTURE_EYE").unwrap_or(scenario.eye);
|
||||
let scene_look = triple("WOW_CAPTURE_LOOK").unwrap_or(scenario.look);
|
||||
|
||||
// `WOW_MM_PROBE=x,y,z` (raw WoW coords) places an active player there, so the interior
|
||||
// minimap renders server-less, with the camera above so the WMO streams in around it.
|
||||
@@ -742,7 +764,7 @@ fn pin_scene(
|
||||
* Quat::from_rotation_x(-req.el_deg.to_radians());
|
||||
(look + orbit * (Vec3::Z * req.dist), look)
|
||||
}
|
||||
_ => (wow_to_bevy(scenario.eye), wow_to_bevy(scenario.look)),
|
||||
_ => (wow_to_bevy(scene_eye), wow_to_bevy(scene_look)),
|
||||
};
|
||||
for mut t in &mut cam {
|
||||
*t = Transform::from_translation(eye).looking_at(look, Vec3::Y);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -335,6 +335,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,
|
||||
@@ -546,13 +550,13 @@ struct ProbeDrag {
|
||||
to: (f32, f32),
|
||||
}
|
||||
|
||||
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space
|
||||
/// [`crate::ui_script::input`] feeds the cursor in.
|
||||
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, times its effective
|
||||
/// scale: the edges answer in the frame's own units, the cursor is fed in the root's.
|
||||
fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> {
|
||||
let read = |edge: &str| {
|
||||
script
|
||||
.eval::<f32>(&format!(
|
||||
"local f = getglobal(\"{name}\") return f and f:Get{edge}()"
|
||||
"local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()"
|
||||
))
|
||||
.ok()
|
||||
};
|
||||
|
||||
@@ -18,6 +18,20 @@ pub(super) struct Scenario {
|
||||
pub(super) ui: Option<UiFixture>,
|
||||
}
|
||||
|
||||
/// Which trainer [`UiFixture::Trainer`] captures.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum TrainerList {
|
||||
/// Llane Beshere (entry 911), Northshire's warrior trainer: the rank-1 learn wrappers a
|
||||
/// vmangos trainer lists, so the description is the taught ability's own text.
|
||||
Warrior,
|
||||
/// A shaman trainer's Astral Recall row (wrapper 1352 teaches 556), the shipped spell whose
|
||||
/// description carries the `$z` token, so the shot pins the bind area's name too.
|
||||
Shaman,
|
||||
/// A profession trainer's two row shapes: the learn wrapper whose text is the taught
|
||||
/// profession's, and a recipe whose text is its product's.
|
||||
Blacksmith,
|
||||
}
|
||||
|
||||
/// A UI window opened with synthetic state that mirrors what the server sends, so the capture
|
||||
/// runs the real feed, VM, extract and render chain.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -29,6 +43,9 @@ pub(super) enum UiFixture {
|
||||
Merchant,
|
||||
Gossip,
|
||||
Quest,
|
||||
/// The class trainer window fed as `SMSG_TRAINER_LIST` would: the first learnable service
|
||||
/// selected, its description expanded from `Spell.dbc`.
|
||||
Trainer(TrainerList),
|
||||
/// The bank window fed through a synthetic self-player descriptor: `PLAYER_FIELD_BANK_SLOT`
|
||||
/// guids, a bank bag, the purchased count in `PLAYER_BYTES_2` byte 2, and coinage.
|
||||
Bank,
|
||||
@@ -41,8 +58,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
|
||||
@@ -109,8 +126,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
|
||||
@@ -294,6 +311,55 @@ pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
|
||||
|
||||
/// Every other named viewpoint, capturable by name (`WOW_CAPTURE=<name>`) but not in the sweep.
|
||||
pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
// MONKEY (wmo surface lights): the portcullis/archery yard and lamp-lined gate ramp.
|
||||
Scenario {
|
||||
name: "stormwind-training-yard-day",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-8728.0, 341.0, 104.0],
|
||||
look: [-8517.4951, 792.4295, 147.57787],
|
||||
minute: 720,
|
||||
ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "stormwind-training-yard-dusk",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-8728.0, 341.0, 104.0],
|
||||
look: [-8517.4951, 792.4295, 147.57787],
|
||||
minute: 1110,
|
||||
ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "stormwind-training-yard-night",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-8728.0, 341.0, 104.0],
|
||||
look: [-8517.4951, 792.4295, 147.57787],
|
||||
minute: 0,
|
||||
ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "stormwind-gate-ramp-day",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-9095.0, 412.0, 124.0],
|
||||
look: [-8818.3066, 632.09216, -229.55338],
|
||||
minute: 720,
|
||||
ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "stormwind-gate-ramp-dusk",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-9095.0, 412.0, 124.0],
|
||||
look: [-8818.3066, 632.09216, -229.55338],
|
||||
minute: 1110,
|
||||
ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "stormwind-gate-ramp-night",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-9095.0, 412.0, 124.0],
|
||||
look: [-8818.3066, 632.09216, -229.55338],
|
||||
minute: 0,
|
||||
ui: None,
|
||||
},
|
||||
// MONKEY (volumetric fog): UI proof uses the same live page selection as a player.
|
||||
Scenario {
|
||||
name: "ui-options-advanced",
|
||||
@@ -350,6 +416,17 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
minute: 720,
|
||||
ui: None,
|
||||
},
|
||||
// MONKEY (planar water): Lakeshire's small dock from the boardwalk, looking down at the deck
|
||||
// and its reflection (the owner's stretched-reflection screenshot); the `vista` instrument at
|
||||
// feet -9297,-2170,67, facing 205, pitch -30.
|
||||
Scenario {
|
||||
name: "water-lakeshire-dock",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: [-9297.0, -2170.0, 69.0],
|
||||
look: [-9689.4, -2353.0, -181.0],
|
||||
minute: 720,
|
||||
ui: None,
|
||||
},
|
||||
// Westfall western coast: sand in the foreground, open sea to the west.
|
||||
Scenario {
|
||||
name: "water-ocean",
|
||||
@@ -1289,6 +1366,29 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
minute: 720,
|
||||
ui: None,
|
||||
},
|
||||
// MONKEY (gfx-1009 unlit): Lakeshire's report sites. The sunken `lionstatue.mdx` (MDDF uid
|
||||
// 83242, (-9355.5, -2246.1, 47.9), ~8 yd under the 56.16 surface) seen from the water surface
|
||||
// and from under it, and the gryphon master's two `gryphonroost01.mdx` (uids 325784/325785).
|
||||
Scenario {
|
||||
name: "lakeshire-statue", map: Some(MAP_AZEROTH),
|
||||
eye: [-9318.0, -2238.0, 59.0], look: [-9356.0, -2247.0, 49.0], minute: 720, ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "lakeshire-statue-dusk", map: Some(MAP_AZEROTH),
|
||||
eye: [-9318.0, -2238.0, 59.0], look: [-9356.0, -2247.0, 49.0], minute: 1170, ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "lakeshire-statue-under", map: Some(MAP_AZEROTH),
|
||||
eye: [-9343.0, -2254.0, 52.0], look: [-9356.0, -2246.0, 48.5], minute: 720, ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "lakeshire-gryphons", map: Some(MAP_AZEROTH),
|
||||
eye: [-9407.0, -2250.0, 72.5], look: [-9422.5, -2237.0, 69.5], minute: 720, ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "lakeshire-gryphons-dusk", map: Some(MAP_AZEROTH),
|
||||
eye: [-9407.0, -2250.0, 72.5], look: [-9422.5, -2237.0, 69.5], minute: 1170, ui: None,
|
||||
},
|
||||
// Duskwood: the road into Darkshire, the zone's short dark fog.
|
||||
Scenario {
|
||||
name: "duskwood-road",
|
||||
@@ -1726,6 +1826,35 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::NameRank),
|
||||
},
|
||||
// The warrior trainer's window over the noon ground view, a learnable service selected.
|
||||
Scenario {
|
||||
name: "ui-trainer",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: GROUND_EYE,
|
||||
look: GROUND_LOOK,
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::Trainer(TrainerList::Warrior)),
|
||||
},
|
||||
// A shaman trainer's window: the one shipped service whose description carries `$z`, over a
|
||||
// bind point, so the shot pins the token against `AreaTable`.
|
||||
Scenario {
|
||||
name: "ui-trainer-astral",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: GROUND_EYE,
|
||||
look: GROUND_LOOK,
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::Trainer(TrainerList::Shaman)),
|
||||
},
|
||||
// A blacksmithing trainer's window: the profession-learn row, whose text is the taught
|
||||
// profession's, with a recipe row beneath it.
|
||||
Scenario {
|
||||
name: "ui-trainer-blacksmith",
|
||||
map: Some(MAP_AZEROTH),
|
||||
eye: GROUND_EYE,
|
||||
look: GROUND_LOOK,
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::Trainer(TrainerList::Blacksmith)),
|
||||
},
|
||||
];
|
||||
|
||||
/// GFX (volumetric light): the Goldshire lake bank among the trees (eye 2 yd over the feet).
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! MONKEY (gfx): `WOW_CAPTURE_SPELL_LIGHT=x,y,z` adds a held spell-light fixture at WoW coords.
|
||||
//! Exercises the real envelope, room claims and shadow budget without a server or spell assets.
|
||||
//! The trace compares the legacy first-within-one-yard shadow lookup with the nearest match.
|
||||
|
||||
use crate::entities::{
|
||||
spell_fx::{SpellLight, SpellLightMode},
|
||||
HeldLight,
|
||||
};
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use benilla_world::{
|
||||
lighting::{ResolvedPointLights, SpellFxLight, SyntheticFireLight},
|
||||
static_gx::{LightOwner, TorchShadowViews},
|
||||
terrain_stream::point_light,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub(super) fn seed(mut commands: Commands, mut seeded: Local<bool>) {
|
||||
if *seeded {
|
||||
return;
|
||||
}
|
||||
*seeded = true;
|
||||
let Ok(value) = std::env::var("WOW_CAPTURE_SPELL_LIGHT") else {
|
||||
return;
|
||||
};
|
||||
let coords: Vec<f32> = value
|
||||
.split(',')
|
||||
.filter_map(|v| v.trim().parse().ok())
|
||||
.collect();
|
||||
let [x, y, z] = coords.as_slice() else {
|
||||
panic!("WOW_CAPTURE_SPELL_LIGHT needs x,y,z in WoW coordinates");
|
||||
};
|
||||
let at = wow_to_bevy([*x, *y, *z]);
|
||||
let parent = commands
|
||||
.spawn((Transform::from_translation(at), Visibility::default()))
|
||||
.id();
|
||||
let mut light = point_light([1.0, 0.3, 0.05], 1.0);
|
||||
let base = light.intensity;
|
||||
light.intensity = 0.0;
|
||||
let entity = commands
|
||||
.spawn((
|
||||
light,
|
||||
Transform::IDENTITY,
|
||||
Visibility::default(),
|
||||
SyntheticFireLight,
|
||||
SpellFxLight,
|
||||
HeldLight,
|
||||
SpellLight::new(base, 0.0, SpellLightMode::Kit),
|
||||
LightOwner::Instance(parent),
|
||||
))
|
||||
.id();
|
||||
commands.entity(parent).add_child(entity);
|
||||
info!("capture-spell-light: spawned {entity} wow [{x},{y},{z}] bevy {at:?}");
|
||||
}
|
||||
|
||||
pub(super) fn trace(
|
||||
points: Res<ResolvedPointLights>,
|
||||
shadows: Res<TorchShadowViews>,
|
||||
mut previous: Local<Vec<(usize, usize, usize)>>,
|
||||
) {
|
||||
if std::env::var_os("WOW_CAPTURE_SPELL_LIGHT").is_none() {
|
||||
return;
|
||||
}
|
||||
let mut mismatches = Vec::new();
|
||||
for (point, light) in points.as_slice().iter().enumerate() {
|
||||
if light.lane < 0.5 {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<_> = shadows
|
||||
.positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(shadows.count as usize)
|
||||
.filter(|(i, pos)| pos.w > 0.0 && shadows.caster_meshes[*i].is_some())
|
||||
.map(|(i, pos)| (i, pos.truncate().distance_squared(light.position)))
|
||||
.filter(|(_, d)| *d < 1.0)
|
||||
.collect();
|
||||
let Some(&(first, _)) = candidates.first() else {
|
||||
continue;
|
||||
};
|
||||
let &(nearest, distance) = candidates
|
||||
.iter()
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.unwrap();
|
||||
if first != nearest {
|
||||
mismatches.push((point, first, nearest));
|
||||
if !previous.contains(&(point, first, nearest)) {
|
||||
info!("capture-spell-light: point {point} at {:?}: first slot {first}, nearest slot {nearest}, nearest distance {distance:.6} yd squared (CPU publication; GPU readiness not asserted)", light.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
if *previous != mismatches {
|
||||
info!(
|
||||
"capture-spell-light: {} ambiguous first-match rows",
|
||||
mismatches.len()
|
||||
);
|
||||
*previous = mismatches;
|
||||
}
|
||||
}
|
||||
@@ -363,8 +363,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>,
|
||||
@@ -444,6 +445,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(super) fn select_input(
|
||||
) {
|
||||
// A modal owns the input while it is up: the delete confirm, the AddOns list, the realm list
|
||||
// over this screen, or the glue dialog (whose Okay must not double as Enter World or Escape).
|
||||
if dialog.open || panel.open || realms.shown || glue_dialog.is_open() {
|
||||
if dialog.open || panel.open || realms.owns_input() || glue_dialog.is_open() {
|
||||
return;
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
@@ -169,3 +169,78 @@ pub(super) fn rotate_model(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::{RealmChoice, RealmRequest};
|
||||
use crate::realm_select::{list_keys, take_input, Realms};
|
||||
|
||||
/// Escape on the realm list raised by Change Realm closes the list and nothing else: the
|
||||
/// press is the list's (`Realms::owns_input`), so this screen does not also go back to
|
||||
/// login, in either order the two run.
|
||||
#[test]
|
||||
fn escape_on_the_realm_list_does_not_also_leave_for_login() {
|
||||
for list_first in [true, false] {
|
||||
let (pick_tx, pick_rx) = crossbeam_channel::unbounded();
|
||||
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
|
||||
.insert_state(ClientState::CharSelect)
|
||||
.init_resource::<crate::glue::GlueClicks>()
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
.init_resource::<Roster>()
|
||||
.init_resource::<Realms>()
|
||||
.init_resource::<DeleteDialog>()
|
||||
.init_resource::<super::super::addons::AddonsPanel>()
|
||||
.init_resource::<crate::login::LoginIntent>()
|
||||
.init_resource::<crate::glue::dialog::GlueDialog>()
|
||||
.insert_resource(CharPick(pick_tx))
|
||||
.insert_resource(RealmChoice(realm_tx))
|
||||
.add_message::<GlueSound>()
|
||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||
.add_systems(PreUpdate, take_input);
|
||||
if list_first {
|
||||
app.add_systems(Update, (list_keys, select_input).chain());
|
||||
} else {
|
||||
app.add_systems(Update, (select_input, list_keys).chain());
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_scope(|world, mut realms: Mut<Realms>| {
|
||||
crate::realm_select::open_over_char_select(
|
||||
&mut realms,
|
||||
world.resource::<RealmChoice>(),
|
||||
);
|
||||
});
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Escape);
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.clear();
|
||||
app.update(); // `StateTransition` applies a pending state at the next frame
|
||||
|
||||
let order = if list_first {
|
||||
"list first"
|
||||
} else {
|
||||
"screen first"
|
||||
};
|
||||
assert!(
|
||||
realm_rx
|
||||
.try_iter()
|
||||
.any(|r| matches!(r, RealmRequest::Abandon)),
|
||||
"{order}: the list's Cancel ran"
|
||||
);
|
||||
assert_eq!(
|
||||
*app.world().resource::<State<ClientState>>().get(),
|
||||
ClientState::CharSelect,
|
||||
"{order}: the press was the list's, so the screen under it stays"
|
||||
);
|
||||
assert!(
|
||||
pick_rx.try_recv().is_err(),
|
||||
"{order}: and the session is not dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::{class_name, Roster};
|
||||
pub(super) fn refresh_list(
|
||||
roster: Res<Roster>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
realms: Option<Res<crate::realm_select::Realms>>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
status: Res<NetStatus>,
|
||||
mut rows: Query<(&SelectAction, &mut Visibility), (With<Button>, Without<Hilight>)>,
|
||||
@@ -90,7 +91,10 @@ pub(super) fn refresh_list(
|
||||
Some(realm) => {
|
||||
// `GetServerName`'s `isPVP, isRP` pair. A normal realm has no suffix, unlike
|
||||
// the realm list's `Normal`: the reference leaves `serverType = ""`.
|
||||
let suffix = match crate::realm_select::pvp_rp(realm.realm_type) {
|
||||
let pvp_rp = realms
|
||||
.as_deref()
|
||||
.map_or((false, false), |r| r.pvp_rp(realm.realm_type));
|
||||
let suffix = match pvp_rp {
|
||||
(true, true) => strings.text("RPPVP_PARENTHESES", "(RPPVP)"),
|
||||
(false, true) => strings.text("RP_PARENTHESES", "(RP)"),
|
||||
(true, false) => strings.text("PVP_PARENTHESES", "(PVP)"),
|
||||
|
||||
@@ -70,6 +70,8 @@ fn collect_character_shadows(
|
||||
Option<&RigPart>,
|
||||
&ShadowOccluder,
|
||||
Option<&MeshMaterial3d<WowModelMaterial>>,
|
||||
// MONKEY (B035): the part's mesh bound, for the WMO/doodad caster reach.
|
||||
Option<&bevy::camera::primitives::Aabb>,
|
||||
),
|
||||
Without<BillboardCard>,
|
||||
>,
|
||||
|
||||
@@ -553,7 +553,7 @@ fn draw_bubble(
|
||||
border_color: [1.0; 4],
|
||||
};
|
||||
let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x);
|
||||
for p in pieces(up, &bd) {
|
||||
for p in pieces(up, &bd, 1.0) {
|
||||
// Equal insets keep every piece axis-aligned: a y-down rect from its TL and BR corners.
|
||||
let rect = Rect::new(
|
||||
p.corners[0][0],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,7 +56,8 @@ const JUMP_ARC_MIN_UP: f32 = 0.5;
|
||||
#[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`).
|
||||
@@ -80,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(
|
||||
@@ -280,13 +306,16 @@ pub(super) fn drive_animations(
|
||||
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, riders) = 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
|
||||
@@ -335,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);
|
||||
}
|
||||
// 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.
|
||||
@@ -728,7 +787,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);
|
||||
@@ -744,19 +803,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());
|
||||
}
|
||||
}
|
||||
@@ -768,9 +829,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() {
|
||||
@@ -935,6 +1009,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
|
||||
@@ -975,6 +1050,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 {
|
||||
@@ -994,6 +1097,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();
|
||||
}
|
||||
@@ -4041,6 +4061,7 @@ mod routing {
|
||||
entity,
|
||||
anim_id,
|
||||
seq: 1,
|
||||
via_player: false,
|
||||
});
|
||||
app.update();
|
||||
}
|
||||
@@ -4137,4 +4158,515 @@ mod routing {
|
||||
"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),
|
||||
});
|
||||
|
||||
@@ -111,10 +111,13 @@ pub(crate) mod move_flags {
|
||||
/// Any horizontal direction bit: the client's `[9e8] & 0xf` gate.
|
||||
pub const ANY_MOVE: u32 = FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT;
|
||||
|
||||
/// The low byte: forward, back, strafe, turn and pitch, the loot response's test (`0x5ebc40`).
|
||||
pub const STEERING: u32 = 0xff;
|
||||
|
||||
/// The bits that get a mover integrated at all, the client's `0x20ff` (`0x616e20`, `0x6166f5`):
|
||||
/// without one a mover keeps its last packet's pose, and a flag-less unit is not even in the
|
||||
/// mover list (`0x618940`). Swim and the mode bits are not among them.
|
||||
pub const INTEGRATED: u32 = 0xff | FALLING;
|
||||
pub const INTEGRATED: u32 = STEERING | FALLING;
|
||||
|
||||
/// The committed-lower-body test routing a one-shot to the masked overlay (`0x5fe6dc`); the
|
||||
/// client's separate mouse-turn test (`d58 & 0x1800`) reads a field benilla does not model.
|
||||
@@ -281,17 +284,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 +367,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 +426,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 +521,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).
|
||||
@@ -734,7 +807,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 ──
|
||||
@@ -1030,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.
|
||||
@@ -217,24 +217,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 {
|
||||
@@ -253,80 +360,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| {
|
||||
@@ -345,7 +378,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
|
||||
@@ -380,7 +413,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 as soon as its display model is ready. 1.12.1 withholds a unit only
|
||||
// until its first composite (`0x477860`) is done, never until every `PLAYER_VISIBLE_ITEM`
|
||||
// template is answered: the compositor paints the armour it knows and re-dresses when a
|
||||
@@ -326,6 +349,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.
|
||||
@@ -351,6 +412,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,
|
||||
@@ -533,9 +595,6 @@ pub(super) fn attach_entity_visuals(
|
||||
.try_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(),
|
||||
@@ -544,7 +603,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,
|
||||
@@ -557,16 +616,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,
|
||||
),
|
||||
@@ -633,7 +687,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`).
|
||||
@@ -661,9 +718,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)
|
||||
.try_insert(benilla_world::model_fade::UnitAppearFade::Pending { since: now });
|
||||
commands.entity(entity).try_insert(
|
||||
benilla_world::model_fade::UnitAppearFade::Pending {
|
||||
since: now,
|
||||
arrived,
|
||||
},
|
||||
);
|
||||
} else if reattached {
|
||||
commands
|
||||
.entity(entity)
|
||||
@@ -870,7 +930,10 @@ pub(super) fn attach_entity_visuals(
|
||||
.try_insert(VisualAttached)
|
||||
// The display the visual was built with, for `refresh_live_display` to diff.
|
||||
.try_insert(super::live_display::AppliedDisplay(net.display_id))
|
||||
.try_remove::<super::equipment::Reattached>();
|
||||
.try_remove::<(
|
||||
super::equipment::Reattached,
|
||||
super::live_display::ShownRebuild,
|
||||
)>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,4 +1039,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(),
|
||||
});
|
||||
@@ -409,6 +418,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 {
|
||||
// Child-side `ChildOf(host)`, not `add_child`: a parent-side add panics against a host that a
|
||||
// same-frame teardown despawned mid-build.
|
||||
@@ -60,6 +62,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);
|
||||
}
|
||||
@@ -124,13 +129,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.
|
||||
@@ -231,8 +244,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>,
|
||||
@@ -240,7 +258,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;
|
||||
}
|
||||
@@ -298,6 +316,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,8 @@ impl PluginGroup for GamePlugins {
|
||||
// The game's own WGSL, compiled in, before anything that could ask for one.
|
||||
.add(crate::shaders::plugin)
|
||||
.add(benilla_world::liquid::WaterDepthPlugin)
|
||||
// MONKEY (planar water): the High tier's mirrored view, rendered before the world.
|
||||
.add(benilla_world::liquid::WaterMirrorPlugin)
|
||||
.add(BowstringPlugin)
|
||||
.add(crate::weapon_trail::WeaponTrailPlugin)
|
||||
.add(FishingLinePlugin)
|
||||
@@ -254,8 +256,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 +698,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
|
||||
@@ -1196,12 +1217,12 @@ 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,
|
||||
"the pre-VM take of `LootMoveStart` is documented at the line and publishes nothing to the VM; the event-firing takes are Lua's queues"),
|
||||
("ui_loot/mod.rs", "feed_loot", Because::PlayerRoundTrip,
|
||||
"it takes `LootMoveStart` only on a window it has just opened, which answers a loot click"),
|
||||
("ui_loot_roll.rs", "drain_loot_rolls", Because::FilledByVm,
|
||||
"only a Need/Greed/Pass click queues a confirm or vote, held in the VM"),
|
||||
("ui_loot_roll.rs", "feed_loot_rolls", Because::PlayerRoundTrip,
|
||||
|
||||
@@ -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;
|
||||
@@ -106,6 +107,7 @@ mod realmlist;
|
||||
mod run_mode;
|
||||
mod screen_fade;
|
||||
mod screenshot;
|
||||
mod script_calls;
|
||||
mod shaders;
|
||||
mod shadow_core;
|
||||
mod zone_skybox;
|
||||
@@ -153,7 +155,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,45 @@ 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)))
|
||||
}
|
||||
|
||||
/// The state folder, `benilla-config/`, where a crate on top keeps its own files: an addon it ships
|
||||
/// goes in its `AddOns/`, where benilla finds addons and their art and fonts. `None` when
|
||||
/// persistence is off (a capture run, or no executable path), and it may not exist yet. A dev
|
||||
/// build resolves it from the launcher's folder, which [`run_with`] sets, so ask from `extend` or
|
||||
/// a system, never before.
|
||||
pub fn config_dir() -> Option<std::path::PathBuf> {
|
||||
local_state::home()
|
||||
}
|
||||
|
||||
/// 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 +264,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 +465,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,
|
||||
@@ -663,8 +664,9 @@ fn login_input(
|
||||
time: Res<Time>,
|
||||
) {
|
||||
// The reference's `RealmList` is a DIALOG-strata frame over this screen, so while it is up
|
||||
// everything here is inert, Escape included (it is the list's Cancel).
|
||||
if realms.shown {
|
||||
// everything here is inert, Escape included (it is the list's Cancel), through the frame
|
||||
// its own Escape closes it in.
|
||||
if realms.owns_input() {
|
||||
return;
|
||||
}
|
||||
let empty = GlueStrings::default();
|
||||
@@ -742,6 +744,7 @@ fn login_input(
|
||||
&mut dialog.edit,
|
||||
ev,
|
||||
mods,
|
||||
&layout,
|
||||
&mut clipboard,
|
||||
wl,
|
||||
textinput::CharFilter::Any,
|
||||
@@ -755,6 +758,7 @@ fn login_input(
|
||||
form.focused(),
|
||||
ev,
|
||||
mods,
|
||||
&layout,
|
||||
&mut clipboard,
|
||||
wl,
|
||||
textinput::CharFilter::Any,
|
||||
@@ -1003,6 +1007,73 @@ mod tests {
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// Escape on the realm list over this screen closes the list and nothing else: the press is
|
||||
/// the list's (`Realms::owns_input`), so the login screen does not also quit, in either
|
||||
/// order the two run.
|
||||
#[test]
|
||||
fn escape_on_the_realm_list_does_not_also_quit() {
|
||||
for list_first in [true, false] {
|
||||
let (tx, _requests) = crossbeam_channel::unbounded();
|
||||
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.init_resource::<crate::glue::GlueClicks>()
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
.init_resource::<crate::bindings::LayoutNames>()
|
||||
.insert_non_send_resource(HostClipboard::default())
|
||||
.init_resource::<LoginForm>()
|
||||
.init_resource::<LoginIntent>()
|
||||
.init_resource::<GlueDialog>()
|
||||
.init_resource::<crate::realm_select::Realms>()
|
||||
.insert_resource(crate::realmlist::Realmlist::unpinned(
|
||||
crate::realmlist::DEFAULT_REALMLIST,
|
||||
))
|
||||
.insert_resource(LoginSubmit(tx))
|
||||
.insert_resource(LoginAbandon(std::sync::Arc::new(
|
||||
std::sync::atomic::AtomicU64::new(0),
|
||||
)))
|
||||
.insert_resource(crate::net::RealmChoice(realm_tx))
|
||||
.add_message::<KeyboardInput>()
|
||||
.add_message::<GlueSound>()
|
||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||
.add_systems(PreUpdate, crate::realm_select::take_input);
|
||||
let list = crate::realm_select::list_keys;
|
||||
if list_first {
|
||||
app.add_systems(Update, (list, login_input).chain());
|
||||
} else {
|
||||
app.add_systems(Update, (login_input, list).chain());
|
||||
}
|
||||
// The list stands over this screen, as the realm park raises it after logon.
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::realm_select::Realms>()
|
||||
.shown = true;
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Escape);
|
||||
app.update();
|
||||
|
||||
let order = if list_first {
|
||||
"list first"
|
||||
} else {
|
||||
"screen first"
|
||||
};
|
||||
assert!(
|
||||
!app.world().resource::<crate::realm_select::Realms>().shown,
|
||||
"{order}: the list's Cancel closed it"
|
||||
);
|
||||
assert!(
|
||||
realm_rx
|
||||
.try_iter()
|
||||
.any(|r| matches!(r, crate::net::RealmRequest::Abandon)),
|
||||
"{order}: and answered the park"
|
||||
);
|
||||
assert!(
|
||||
!app.world().contains_resource::<QuitArm>(),
|
||||
"{order}: the press was the list's, so the login screen does not also quit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A lost session does not log itself back in: a displaced client's resubmit would kick the
|
||||
/// client that displaced it. `GlueParent.lua` retries nothing.
|
||||
#[test]
|
||||
@@ -1412,10 +1483,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));
|
||||
|
||||
@@ -160,7 +160,6 @@ pub(super) type BlipInputs<'w, 's> = (
|
||||
Query<'w, 's, &'static GlobalTransform, With<NetEntity>>,
|
||||
Query<'w, 's, &'static Window, With<PrimaryWindow>>,
|
||||
ResMut<'w, MinimapBlipHover>,
|
||||
Res<'w, crate::ui_script::UiScaleCvar>,
|
||||
Res<'w, crate::ui_party::GroupState>,
|
||||
TrackedCandidates<'w, 's>,
|
||||
Query<'w, 's, (&'static ObjectStore, &'static Guid), With<SelfPlayer>>,
|
||||
@@ -200,11 +199,11 @@ pub(super) struct BlipCtx {
|
||||
pub(super) wz: f32,
|
||||
/// The cursor in the quads' y-down logical px.
|
||||
pub(super) cursor: Option<Vec2>,
|
||||
/// The cursor in y-up UI space; the reference seats the blip tooltip at the cursor, its exact
|
||||
/// offset untraced.
|
||||
/// The cursor in the y-up root units; the reference seats the blip tooltip at the cursor, its
|
||||
/// exact offset untraced.
|
||||
pub(super) cursor_ui: Option<Vec2>,
|
||||
/// Window px per UI unit: everything else here is window px, while Lua
|
||||
/// ([`Minimap:PingLocation`](super::ping)) speaks UI units.
|
||||
/// Window px per root unit: everything else here is window px, while Lua
|
||||
/// ([`Minimap:PingLocation`](super::ping)) hands root units.
|
||||
pub(super) seam: f32,
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,6 @@ fn emit_minimap(
|
||||
unit_pos,
|
||||
window,
|
||||
mut blip_hover,
|
||||
ui_scale,
|
||||
group,
|
||||
tracked,
|
||||
self_store,
|
||||
@@ -621,7 +620,7 @@ fn emit_minimap(
|
||||
}
|
||||
let win = window.iter().next();
|
||||
let cursor = win.and_then(|w| w.cursor_position());
|
||||
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height(), ui_scale.0));
|
||||
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
|
||||
// The pan origin snapped to a half-logical-pixel grid: every blip offset and rim bearing
|
||||
// derives from `wx`/`wy`, so the layer steps together instead of rewriting the batch mesh
|
||||
// every frame while walking. Blip world positions stay exact.
|
||||
@@ -637,11 +636,11 @@ fn emit_minimap(
|
||||
wy: (wy / q).round() * q,
|
||||
wz: wow[2],
|
||||
cursor,
|
||||
// The cursor in UI space (y-up, ÷ the seam scale): the tooltip anchor resolves in
|
||||
// 768-virtual units, not window px.
|
||||
// The cursor in the root's units (y-up, ÷ the seam scale): the tooltip anchor resolves
|
||||
// in them, not window px.
|
||||
cursor_ui: cursor
|
||||
.zip(win)
|
||||
.map(|(c, w)| Vec2::new(c.x / seam, (w.height() - c.y) / seam)),
|
||||
.map(|(c, w)| crate::ui_script::window_to_ui(w.height(), c)),
|
||||
seam,
|
||||
}
|
||||
});
|
||||
@@ -842,14 +841,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 +856,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
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
//! [`drive_minimap_ping`] republishes against the live view radius, so the marker follows the pan
|
||||
//! and the zoom with no second copy to fall out of step.
|
||||
//!
|
||||
//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in UI units from the
|
||||
//! centre; the renderer converts it with the geometry of the frame it draws: UI units × the seam
|
||||
//! scale = window px, ÷ `px_per_yd` = yards.
|
||||
//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in the minimap's units
|
||||
//! from the centre, which the engine hands over in root units; the renderer converts them with
|
||||
//! the geometry of the frame it draws: root units × the seam scale = window px, ÷ `px_per_yd` =
|
||||
//! yards.
|
||||
//! - Across: our ping sends `MSG_MINIMAP_PING` (raw world floats, relayed to the rest of the group
|
||||
//! only, `GroupHandler.cpp:384-391`); a member's seats the same way. A ping seats locally at
|
||||
//! click time, so a solo ping works.
|
||||
@@ -59,8 +60,8 @@ impl MinimapPing {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative UI
|
||||
/// units (x right, y up), `seam` window px per UI unit ([`crate::ui_script::seam_scale`]), `ctx`
|
||||
/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative root
|
||||
/// units (x right, y up), `seam` window px per root unit ([`crate::ui_script::seam_scale`]), `ctx`
|
||||
/// the map as drawn this frame. The inverse of [`BlipCtx::offset`]: screen right is −WoW y, screen
|
||||
/// up +WoW x. `None` outside the disc, the stock test (`Minimap.lua:135`) in yards.
|
||||
fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)> {
|
||||
@@ -193,13 +194,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The click is in UI units and `px_per_yd` in window px: at 0.9 uiScale on a 1080p window the
|
||||
/// seam is ≈1.27, and a conversion that skips it seats every ping ≈27 % too far out.
|
||||
/// The click is in root units and `px_per_yd` in window px: on a 1080p window the seam is
|
||||
/// ≈1.41, and a conversion that skips it seats every ping ≈41 % too far out.
|
||||
#[test]
|
||||
fn a_click_converts_through_the_seam_scale() {
|
||||
let c = ctx();
|
||||
let seam = 1080.0 / 768.0 * 0.9; // the shipped default at 1080p
|
||||
// 20 UI units right → 20·seam px → yards west (−y).
|
||||
let seam = 1080.0 / 768.0;
|
||||
// 20 root units right → 20·seam px → yards west (−y).
|
||||
let (x, y) = click_to_world(&c, (20.0, 0.0), seam).expect("inside the disc");
|
||||
let expect_yd = 20.0 * seam / c.px_per_yd;
|
||||
assert!((x - 0.0).abs() < 1e-3, "no northing from a due-east click");
|
||||
|
||||
@@ -35,7 +35,7 @@ use benilla_ui::script::{JustifyH, JustifyV, Outline};
|
||||
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::target::{ring_reaction, ring_variant, CombatFlash, Factions, RingVariant, Selection};
|
||||
use crate::target::{selection_variant, CombatFlash, Factions, RingVariant, Selection};
|
||||
use crate::ui_text::{layout_text_quads, FontSpec, Justify, TextSeat, UiFontAtlas};
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
@@ -209,8 +209,8 @@ pub(crate) fn height_scale(d: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a name line is painted with: the ring's selector ([`ring_variant`]), which the reference's
|
||||
/// name render also calls (`0x605960`), or the combat flash.
|
||||
/// What a name line is painted with: the ring's selector ([`selection_variant`]), which the
|
||||
/// reference's name render also calls (`0x605960`), or the combat flash.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
enum NamePaint {
|
||||
/// The selector's answer.
|
||||
@@ -513,27 +513,17 @@ pub(crate) fn drive_nameplates(
|
||||
.and_then(|s| crate::ui_guild::unit_guild_name(&s.0, &mut guilds, &net_commands)),
|
||||
_ => None,
|
||||
};
|
||||
// The colour: the ring's reaction rank and the shared selector.
|
||||
let rank = ring_reaction(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
store,
|
||||
self_store.single().ok(),
|
||||
);
|
||||
let is_dead = store.is_some_and(|s| s.0.unit_is_dead());
|
||||
// The ring's player inputs, for this unit: PvP flag (`UNIT_FIELD_FLAGS` 0x1000) and party.
|
||||
let pvp = store.is_some_and(|s| s.0.unit_flags() & 0x1000 != 0);
|
||||
let in_party = group.members.iter().any(|m| m.guid == guid.0);
|
||||
// The selector's first-priority branch: the combat flash while we melee this unit.
|
||||
let color = if flash.unit == Some(entity) {
|
||||
NamePaint::Flash
|
||||
} else {
|
||||
NamePaint::Variant(ring_variant(
|
||||
rank,
|
||||
NamePaint::Variant(selection_variant(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
store,
|
||||
self_store.single().ok(),
|
||||
net.kind == EntityKind::Player,
|
||||
is_dead,
|
||||
pvp,
|
||||
in_party,
|
||||
group.members.iter().any(|m| m.guid == guid.0),
|
||||
))
|
||||
};
|
||||
|
||||
@@ -782,6 +772,7 @@ fn evict_name_meshes(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::target::{ring_variant, PlayerPath, SelectorInput};
|
||||
|
||||
/// `>` not `>=` at the knee and `0.075*d` beyond: the jump at the knee is the reference's.
|
||||
#[test]
|
||||
@@ -816,12 +807,18 @@ mod tests {
|
||||
/// A PvP-flagged friendly player's name is the ring's green, not the soft blue.
|
||||
#[test]
|
||||
fn name_color_is_the_ring_selector_itself() {
|
||||
let paint = |rank, is_player, is_dead, pvp, in_party| {
|
||||
NamePaint::Variant(ring_variant(rank, is_player, is_dead, pvp, in_party))
|
||||
let player = |attacks_us, attackable, pvp, in_party| {
|
||||
NamePaint::Variant(ring_variant(SelectorInput::Player(PlayerPath {
|
||||
attacks_us,
|
||||
attackable,
|
||||
pvp,
|
||||
in_party,
|
||||
})))
|
||||
};
|
||||
let npc = |rank, dead| NamePaint::Variant(ring_variant(SelectorInput::Npc { rank, dead }));
|
||||
// Flagged is green, unflagged the soft blue, and the two differ.
|
||||
let flagged = paint(6, true, false, true, false);
|
||||
let unflagged = paint(6, true, false, false, false);
|
||||
let flagged = player(false, false, true, false);
|
||||
let unflagged = player(false, false, false, false);
|
||||
assert_eq!(flagged, NamePaint::Variant(RingVariant::Friendly));
|
||||
assert_eq!(unflagged, NamePaint::Variant(RingVariant::Player));
|
||||
assert_ne!(flagged.color(), unflagged.color());
|
||||
@@ -831,24 +828,25 @@ mod tests {
|
||||
"0xFF00FF00 — the ring's own green, byte for byte"
|
||||
);
|
||||
assert_eq!(
|
||||
paint(6, true, false, true, true),
|
||||
player(false, false, true, true),
|
||||
NamePaint::Variant(RingVariant::PartyPvp)
|
||||
);
|
||||
assert_eq!(
|
||||
paint(6, true, false, false, true),
|
||||
player(false, false, false, true),
|
||||
NamePaint::Variant(RingVariant::Party)
|
||||
);
|
||||
assert_eq!(paint(0, false, false, false, false).color(), RED);
|
||||
assert_eq!(npc(0, false).color(), RED);
|
||||
assert_eq!(
|
||||
paint(6, true, true, false, false),
|
||||
NamePaint::Variant(RingVariant::Player),
|
||||
"dead player never grays"
|
||||
player(true, true, true, false).color(),
|
||||
RED,
|
||||
"mutual attack"
|
||||
);
|
||||
assert_eq!(paint(1, true, false, true, false).color(), RED, "hostile");
|
||||
assert_eq!(
|
||||
paint(6, false, true, false, false),
|
||||
NamePaint::Variant(RingVariant::Dead)
|
||||
player(false, true, false, false).color(),
|
||||
Color::linear_rgb(1.0, 1.0, 0.0),
|
||||
"attackable, not attacking: yellow"
|
||||
);
|
||||
assert_eq!(npc(6, true), NamePaint::Variant(RingVariant::Dead));
|
||||
}
|
||||
|
||||
const RED: Color = Color::linear_rgb(1.0, 0.0, 0.0);
|
||||
|
||||
@@ -235,6 +235,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.
|
||||
@@ -415,23 +427,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)
|
||||
@@ -439,6 +452,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
|
||||
@@ -690,23 +716,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> =
|
||||
@@ -792,6 +857,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`.
|
||||
@@ -931,8 +998,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,
|
||||
@@ -1373,6 +1441,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,
|
||||
@@ -1593,6 +1663,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,
|
||||
@@ -2138,6 +2210,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,
|
||||
}
|
||||
@@ -2413,4 +2487,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));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user