Merge upstream/main (samwhosung/benilla v0.2.0 ba7fe6e2) into the graphics programme
Two conflicts: docs/MAP.md (our map plus upstream's cfg_categories/wow_ini modules and WOW_CHAR row) and the Update ambiguity ceiling in game_plugins.rs (kept ours, re-measured in a follow-up commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,4 +22,4 @@ body:
|
||||
attributes:
|
||||
label: Build and setup
|
||||
description: The `benilla build` line from the start of the terminal output, your OS, and the server you play on.
|
||||
placeholder: "benilla build 64cca13 · 2026-09-24 · release, macOS 15, vmangos"
|
||||
placeholder: "benilla build 0.2.0 · 64cca13 · 2026-09-24 · release, macOS 15, vmangos"
|
||||
|
||||
Generated
+22
-19
@@ -488,7 +488,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "benilla"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"benilla-app",
|
||||
"benilla-buildstamp",
|
||||
@@ -496,14 +496,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-adt"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"benilla-bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "benilla-app"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -512,6 +512,7 @@ dependencies = [
|
||||
"benilla-assets",
|
||||
"benilla-formats",
|
||||
"benilla-protocol",
|
||||
"benilla-srp",
|
||||
"benilla-ui",
|
||||
"benilla-world",
|
||||
"bevy",
|
||||
@@ -528,6 +529,7 @@ dependencies = [
|
||||
"kira",
|
||||
"libc",
|
||||
"mint",
|
||||
"num-bigint",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-core-audio",
|
||||
@@ -538,6 +540,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rtrb",
|
||||
"serde",
|
||||
"sha1",
|
||||
"smithay-clipboard",
|
||||
"symphonia",
|
||||
"toml",
|
||||
@@ -547,7 +550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-assets"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"benilla-formats",
|
||||
@@ -558,7 +561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-blp"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"benilla-bytes",
|
||||
"texpresso",
|
||||
@@ -566,22 +569,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-buildstamp"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "benilla-bytes"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "benilla-dbc"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"benilla-bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "benilla-formats"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"benilla-adt",
|
||||
@@ -599,14 +602,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-m2"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"benilla-bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "benilla-mpq"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"benilla-bytes",
|
||||
"flate2",
|
||||
@@ -614,7 +617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-protocol"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"benilla-srp",
|
||||
@@ -628,7 +631,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-srp"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"rand 0.8.6",
|
||||
@@ -637,7 +640,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-ui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"mlua",
|
||||
"roxmltree",
|
||||
@@ -645,7 +648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-visual"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"image",
|
||||
@@ -653,18 +656,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-wdt"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "benilla-wmo"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"benilla-bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "benilla-world"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"avian3d",
|
||||
"benilla-assets",
|
||||
@@ -680,7 +683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "benilla-worldview"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"benilla-buildstamp",
|
||||
"benilla-world",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["sam <samuv.eth@gmail.com>"]
|
||||
|
||||
@@ -72,11 +72,22 @@ benilla builds and runs on macOS, Linux and Windows. You need:
|
||||
WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla
|
||||
```
|
||||
|
||||
On Windows, in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:WOW_DATA="C:\path\to\WoW\Data"; cargo run --release -p benilla
|
||||
```
|
||||
|
||||
Each release is a tag on the [Releases page](https://github.com/samwhosung/benilla/releases):
|
||||
`git checkout <tag>` first runs that release, and `main` is the development tip.
|
||||
|
||||
`WOW_DATA` names the install's `Data` folder; a link to the install named `WoW` at the repo root
|
||||
does the same. The server defaults to `localhost:3724`, the stock auth port. Point `WOW_HOST` at
|
||||
another (`WOW_HOST=play.example.com`, or `play.example.com:5000` for a remapped port), or set it
|
||||
from the Realmlist button on the login screen, which remembers it. Credentials go in at the login
|
||||
screen, or set `WOW_USER` and `WOW_PASS` to skip it.
|
||||
does the same (`ln -s /path/to/WoW WoW`, or on Windows a junction, which needs no admin rights:
|
||||
`New-Item -ItemType Junction -Path WoW -Target C:\path\to\WoW`). The server defaults to
|
||||
`localhost:3724`, the stock auth port. Point `WOW_HOST` at another (`WOW_HOST=play.example.com`, or
|
||||
`play.example.com:5000` for a remapped port), or set it from the Realmlist button on the login
|
||||
screen, which remembers it. Credentials go in at the login screen, or set `WOW_USER` and `WOW_PASS`
|
||||
to skip it.
|
||||
|
||||
Settings, screenshots and addons live in `benilla-config/` at the repo root: a 1.12 addon goes in
|
||||
`benilla-config/AddOns/`. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) has the rest, from the
|
||||
|
||||
@@ -96,3 +96,9 @@ cpal = "0.17"
|
||||
# Windows CPU clocks for `perf::clock`.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
|
||||
|
||||
# The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`).
|
||||
[dev-dependencies]
|
||||
benilla-srp = { path = "../benilla-srp" }
|
||||
num-bigint = "0.4.6"
|
||||
sha1.workspace = true
|
||||
|
||||
@@ -76,6 +76,7 @@ mod probe_stone;
|
||||
mod probe_taxi;
|
||||
mod probe_vendor_swap;
|
||||
mod probes;
|
||||
mod realm_list;
|
||||
mod scenarios;
|
||||
use crate::run_mode::CaptureMode;
|
||||
pub(crate) use depth_probe::DepthProbePlugin;
|
||||
@@ -138,7 +139,7 @@ pub(crate) use probe_pitch::ProbePitchPlugin;
|
||||
pub(crate) fn start_state() -> crate::char_select::ClientState {
|
||||
match glue_screen() {
|
||||
Some(GlueScreen::CharCreate) => crate::char_select::ClientState::CharCreate,
|
||||
Some(GlueScreen::Login) => crate::char_select::ClientState::Login,
|
||||
Some(GlueScreen::Login | GlueScreen::RealmList) => crate::char_select::ClientState::Login,
|
||||
None if crate::run_mode::scenario_active() => crate::char_select::ClientState::InWorld,
|
||||
None => crate::char_select::ClientState::Login,
|
||||
}
|
||||
@@ -684,6 +685,7 @@ impl Plugin for CapturePlugin {
|
||||
// Before `UnitFeed`: the seed stands in for wire data live play delivers on earlier
|
||||
// frames, so the same frame's feeds and the one-shot `MERCHANT_SHOW` paint must see it.
|
||||
.add_systems(Update, seed_ui_fixture.before(crate::ui_unit::UnitFeed))
|
||||
.add_systems(Update, realm_list::seed_realm_list)
|
||||
.add_systems(Last, drive_capture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! `glue-realmlist`: the realm list over the login screen with realms in two categories, which the
|
||||
//! live server cannot show (it lists one). The list is published as the realm park publishes one,
|
||||
//! then the dialog is raised as Change Realm raises it; the tabs are named from the install's own
|
||||
//! Region, so bytes 1 and 2 are "English" and "German" under Region 3 and 1 alone under Region 1.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
use super::CaptureCtx;
|
||||
|
||||
/// The scenario's name in [`super::scenarios::GLUE_SCENARIOS`].
|
||||
pub(super) const NAME: &str = "glue-realmlist";
|
||||
|
||||
/// Invented realms: three on category byte 1, two on byte 2, a mix of types, loads and counts.
|
||||
fn fixture() -> Vec<RealmInfo> {
|
||||
let realm =
|
||||
|name: &str, category: u8, realm_type: u32, population: f32, characters: u8| RealmInfo {
|
||||
name: name.into(),
|
||||
address: "127.0.0.1:8085".into(),
|
||||
population,
|
||||
characters,
|
||||
realm_type,
|
||||
flags: 0,
|
||||
category,
|
||||
id: 0,
|
||||
};
|
||||
vec![
|
||||
realm("Amberfield", 1, 0, 0.5, 2),
|
||||
realm("Brightwater", 1, 1, 1.5, 0),
|
||||
realm("Copperdale", 1, 6, 1.0, 0),
|
||||
realm("Dunmoor", 2, 1, 0.8, 0),
|
||||
realm("Eisenwald", 2, 0, 1.2, 0),
|
||||
]
|
||||
}
|
||||
|
||||
/// Publish the list, let the policy take it, then raise the dialog.
|
||||
pub(super) fn seed_realm_list(
|
||||
ctx: Res<CaptureCtx>,
|
||||
mut step: Local<u8>,
|
||||
mut lists: MessageWriter<crate::net::RealmListMessage>,
|
||||
mut realms: ResMut<crate::realm_select::Realms>,
|
||||
choice: Res<crate::net::RealmChoice>,
|
||||
) {
|
||||
if ctx.name != NAME {
|
||||
return;
|
||||
}
|
||||
match *step {
|
||||
0 => {
|
||||
lists.write(crate::net::RealmListMessage { realms: fixture() });
|
||||
}
|
||||
2 => crate::realm_select::open_over_char_select(&mut realms, &choice),
|
||||
3.. => return,
|
||||
_ => {}
|
||||
}
|
||||
*step += 1;
|
||||
}
|
||||
@@ -171,10 +171,13 @@ pub(crate) enum GlueScreen {
|
||||
/// The login screen, `UI_MainMenu` behind the account form. Its backdrop is the narrowest of
|
||||
/// the seven scenes, so a wide window (`WOW_WIN=2560x1440`) reaches its edges first.
|
||||
Login,
|
||||
/// The realm list over the login screen, fed a list in two categories
|
||||
/// (`super::realm_list`).
|
||||
RealmList,
|
||||
}
|
||||
|
||||
/// The glue scenarios: character creation as a human male warrior, the race the reference's own
|
||||
/// screenshots use, and the login screen.
|
||||
/// screenshots use, the login screen, and the realm list's category tabs over it.
|
||||
pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[
|
||||
GlueScenario {
|
||||
name: "glue-charcreate",
|
||||
@@ -186,6 +189,11 @@ pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[
|
||||
screen: GlueScreen::Login,
|
||||
pick: None,
|
||||
},
|
||||
GlueScenario {
|
||||
name: super::realm_list::NAME,
|
||||
screen: GlueScreen::RealmList,
|
||||
pick: None,
|
||||
},
|
||||
];
|
||||
|
||||
/// The golden baseline: two spots at noon and at night, Elwynn water and a Felwood hollow on
|
||||
|
||||
@@ -133,6 +133,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
const BUILD: BuildId = BuildId {
|
||||
version: "0.2.0",
|
||||
describe: "v0.2.0-3-g0123456",
|
||||
sha: "0123456789abcdef0123456789abcdef01234567",
|
||||
short: "0123456",
|
||||
date: "2026-09-16",
|
||||
@@ -154,7 +156,7 @@ mod tests {
|
||||
});
|
||||
for needle in [
|
||||
"sha 0123456789abcdef0123456789abcdef01234567",
|
||||
"0123456 · 2026-09-16 · debug",
|
||||
"0.2.0+3 · 0123456 · 2026-09-16 · debug",
|
||||
"12.3 s after launch",
|
||||
"thread: main",
|
||||
"at: crates/benilla-app/src/net/apply.rs:100:5",
|
||||
|
||||
@@ -234,14 +234,7 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// `autoSelfCast` (register site `0x6e731d`, record `[0xceac34]`, read at `0x6e53d7`; `0x870dc0`
|
||||
// is its name string): a friendly cast that binds nothing falls back to the caster.
|
||||
// `TOGGLEAUTOSELFCAST` toggles it.
|
||||
deviates(
|
||||
"autoSelfCast",
|
||||
"1",
|
||||
"0",
|
||||
"with it off, an unbindable friendly cast falls into the reference's \
|
||||
targeting-cursor machine, which is unmodeled — leaving no path at all. Flip when that \
|
||||
machine lands",
|
||||
),
|
||||
same("autoSelfCast", "0"),
|
||||
// The five saved camera views and the live index, at the reference's names and default strings;
|
||||
// owned by [`crate::player::camera_view`]. Registered so a `SaveView` persists.
|
||||
same(
|
||||
@@ -2370,7 +2363,6 @@ mod tests {
|
||||
names,
|
||||
vec![
|
||||
"SoundReverb",
|
||||
"autoSelfCast",
|
||||
"frillDensity",
|
||||
"gxApi",
|
||||
"gxColorBits",
|
||||
|
||||
@@ -443,8 +443,8 @@ pub(super) fn inspect_ui(
|
||||
3 => "neutral",
|
||||
4 => "friendly",
|
||||
5 => "honored",
|
||||
6 => "revered",
|
||||
_ => "exalted",
|
||||
// `0x606439` caps the rank at 6: Exalted reads here too.
|
||||
_ => "revered+",
|
||||
};
|
||||
// A faction with a reputation slot reacts by our standing (`0x605fc0` -> `0x4d63a0`),
|
||||
// before any template comparison; everything else by the template comparator.
|
||||
|
||||
@@ -695,7 +695,12 @@ pub(crate) mod schedule_tests {
|
||||
/// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`;
|
||||
/// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and
|
||||
/// `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.
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
|
||||
@@ -144,6 +144,10 @@ pub(crate) struct GlueArt {
|
||||
pub(crate) column_tabs: Option<(Handle<Image>, Vec2)>,
|
||||
pub(crate) tab_highlight: Option<Handle<AddUiMaterial>>,
|
||||
pub(crate) sort_arrow: Option<(Handle<Image>, Vec2)>,
|
||||
/// The realm list's category tabs (`RealmListTabButtonTemplate`): the front tab's
|
||||
/// `UI-Character-ActiveTab` and the others' `UI-Character-InActiveTab`.
|
||||
pub(crate) tab_active: Option<(Handle<Image>, Vec2)>,
|
||||
pub(crate) tab_inactive: Option<(Handle<Image>, Vec2)>,
|
||||
}
|
||||
|
||||
/// `RealmSortButtonTemplate`'s `WhoFrame-ColumnTabs` slices, `[left, right, top, bottom]` each:
|
||||
@@ -155,6 +159,13 @@ pub(crate) const COLUMN_TAB_TC: [[f32; 4]; 3] = [
|
||||
];
|
||||
/// `$parentArrow`'s texcoords into `UI-SortArrow`, the down-pointing half.
|
||||
pub(crate) const SORT_ARROW_TC: [f32; 4] = [0.0, 0.5625, 0.0, 1.0];
|
||||
/// `RealmListTabButtonTemplate`'s `UI-Character-(In)ActiveTab` slices, `[left, right, top,
|
||||
/// bottom]` each: the left end (20 wide), the middle stretched to the label, the right end.
|
||||
pub(crate) const REALM_TAB_TC: [[f32; 4]; 3] = [
|
||||
[0.0, 0.156_25, 0.0, 1.0],
|
||||
[0.156_25, 0.843_75, 0.0, 1.0],
|
||||
[0.843_75, 1.0, 0.0, 1.0],
|
||||
];
|
||||
|
||||
/// The `HelpFrame-*` plate: six pieces tiling a 640×512 panel (TopLeft/Top 256², TopRight
|
||||
/// 128×256 on top; BotLeft/Bottom/BotRight below).
|
||||
@@ -427,10 +438,20 @@ impl GlueArt {
|
||||
add_mats,
|
||||
);
|
||||
self.sort_arrow = sized(assets, "Interface\\Buttons\\UI-SortArrow", images);
|
||||
self.tab_active = sized(
|
||||
assets,
|
||||
"Interface\\PaperDollInfoFrame\\UI-Character-ActiveTab",
|
||||
images,
|
||||
);
|
||||
self.tab_inactive = sized(
|
||||
assets,
|
||||
"Interface\\PaperDollInfoFrame\\UI-Character-InActiveTab",
|
||||
images,
|
||||
);
|
||||
debug!(
|
||||
"glue art: addonlist set — helpframe {} header {} close {} droparrow {}/{} \
|
||||
questhl {} titlehl {} greycheck {} tipborder {} scrolltrack {} \
|
||||
coltabs {} tabhl {} sortarrow {}",
|
||||
coltabs {} tabhl {} sortarrow {} realmtabs {}/{}",
|
||||
self.help_frame.is_some(),
|
||||
self.dialog_header.is_some(),
|
||||
self.close_btn.is_some(),
|
||||
@@ -444,6 +465,8 @@ impl GlueArt {
|
||||
self.column_tabs.is_some(),
|
||||
self.tab_highlight.is_some(),
|
||||
self.sort_arrow.is_some(),
|
||||
self.tab_active.is_some(),
|
||||
self.tab_inactive.is_some(),
|
||||
);
|
||||
debug!(
|
||||
"glue art: races {} classes {} gender {} factions {} banners {} hilight {} logo {} \
|
||||
|
||||
@@ -66,7 +66,7 @@ impl Plugin for NetPlugin {
|
||||
crate::query_cache::register::<crate::names::NameCache>(app);
|
||||
crate::query_cache::register::<crate::go_templates::GameObjectTemplates>(app);
|
||||
crate::query_cache::register::<crate::items::Items>(app);
|
||||
let handles = io::spawn_net(io::NetConfig::from_env(), self.connect);
|
||||
let handles = io::spawn_net(self.connect);
|
||||
if !self.connect {
|
||||
app.insert_resource(NetOffline);
|
||||
}
|
||||
@@ -884,6 +884,12 @@ pub(crate) enum ClientCommand {
|
||||
spell_id: u32,
|
||||
target: Option<u64>,
|
||||
},
|
||||
/// `CMSG_CAST_SPELL` at a corpse: its bit and packed guid, a resurrection on a released player.
|
||||
CastSpellCorpse {
|
||||
spell_id: u32,
|
||||
target: benilla_protocol::messages::CorpseTarget,
|
||||
corpse_guid: u64,
|
||||
},
|
||||
/// `CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION`; `dest` in WoW coordinates.
|
||||
CastSpellAtDest {
|
||||
spell_id: u32,
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use anyhow::{anyhow, Result};
|
||||
use benilla_assets::LockRecover;
|
||||
use benilla_protocol::{
|
||||
host_port, messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent,
|
||||
@@ -145,21 +145,6 @@ impl PingClock {
|
||||
/// Cap on send warnings per connection, so a movement stream during an outage cannot flood the log.
|
||||
const SEND_WARN_CAP: u32 = 8;
|
||||
|
||||
/// The per-process connection parameters. Credentials and address ride each [`LoginRequest`];
|
||||
/// `$WOW_HOST` is read by `Realmlist::default()`.
|
||||
pub(super) struct NetConfig {
|
||||
/// `WOW_CHAR`: here only the name of the starter character on an empty account.
|
||||
character: Option<String>,
|
||||
}
|
||||
|
||||
impl NetConfig {
|
||||
pub(super) fn from_env() -> Self {
|
||||
NetConfig {
|
||||
character: std::env::var("WOW_CHAR").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one wake-up at the character park asked for, so every jump out is made outside `select!`.
|
||||
enum Parked {
|
||||
/// `CMSG_PLAYER_LOGIN` with this guid.
|
||||
@@ -204,7 +189,7 @@ pub(super) struct NetHandles {
|
||||
}
|
||||
|
||||
/// Spawns the read thread with its park and cycle loop, and the one long-lived write thread.
|
||||
pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
|
||||
pub(super) fn spawn_net(connect: bool) -> NetHandles {
|
||||
let (events_tx, events_rx) = crossbeam_channel::unbounded();
|
||||
let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded();
|
||||
let (pick_tx, pick_rx) = crossbeam_channel::unbounded::<CharRequest>();
|
||||
@@ -247,7 +232,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
|
||||
// writer, since the keepalive can still fire on the stale one until then.
|
||||
read_clock.lock_recover().clear();
|
||||
match run(
|
||||
&cfg,
|
||||
&events_tx,
|
||||
&writer_tx,
|
||||
&parks,
|
||||
@@ -310,7 +294,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
|
||||
/// dies, the character logs out or the app exits. A pre-roster failure emits
|
||||
/// [`SessionEvent::LoginFailed`] and re-parks; any retry is the app's.
|
||||
fn run(
|
||||
cfg: &NetConfig,
|
||||
events_tx: &Sender<SessionEvent>,
|
||||
writer_tx: &Sender<WorldWriter>,
|
||||
parks: &Parks,
|
||||
@@ -501,31 +484,10 @@ fn run(
|
||||
return Ok(Cycle::Repark);
|
||||
}
|
||||
|
||||
// The roster, creating a starter character on an empty account so `PLAYER_LOGIN` has a
|
||||
// target. Failures here are still pre-roster.
|
||||
let mut characters = match (|| -> Result<Vec<benilla_protocol::Character>> {
|
||||
let mut characters = session.char_enum()?;
|
||||
if characters.is_empty() {
|
||||
let name = cfg.character.as_deref().unwrap_or("One");
|
||||
let starter = messages::CharCreateReq {
|
||||
name: name.to_string(),
|
||||
race: messages::RACE_HUMAN,
|
||||
class: messages::CLASS_WARRIOR,
|
||||
gender: messages::GENDER_MALE,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
};
|
||||
match session.create_character(&starter)? {
|
||||
messages::CHAR_CREATE_SUCCESS | messages::CHAR_CREATE_NAME_IN_USE => {}
|
||||
other => bail!("character creation failed: result {other:#x}"),
|
||||
}
|
||||
characters = session.char_enum()?;
|
||||
}
|
||||
Ok(characters)
|
||||
})() {
|
||||
// The roster as the account holds it, an empty one included: the reference builds
|
||||
// `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the create screen.
|
||||
// Failures here are still pre-roster.
|
||||
let mut characters = match session.char_enum() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if canceled() {
|
||||
@@ -1067,6 +1029,11 @@ fn writer_loop(
|
||||
count,
|
||||
} => w.destroy_item(bag_index, slot, count),
|
||||
ClientCommand::CastSpell { spell_id, target } => w.cast_spell(spell_id, target),
|
||||
ClientCommand::CastSpellCorpse {
|
||||
spell_id,
|
||||
target,
|
||||
corpse_guid,
|
||||
} => w.cast_spell_corpse(spell_id, target, corpse_guid),
|
||||
ClientCommand::CastSpellAtDest { spell_id, dest } => {
|
||||
w.cast_spell_at_dest(spell_id, dest)
|
||||
}
|
||||
@@ -1530,6 +1497,9 @@ fn writer_loop(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cycle_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod rtt_tests {
|
||||
use super::{PingClock, RTT_RING};
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! The login cycle, [`super::run`], against a fake realmd and a fake world server on loopback: the
|
||||
//! real logon, realm park, world handshake and roster, driven through the parks as the app does.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use benilla_protocol::messages::{self, opcode};
|
||||
use benilla_protocol::{SessionEvent, WorldWriter};
|
||||
use benilla_srp::vanilla_header::HeaderCrypto;
|
||||
use benilla_srp::{
|
||||
password_verifier, NormalizedString, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN,
|
||||
SESSION_KEY_LENGTH,
|
||||
};
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use num_bigint::BigUint;
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use super::{run, Cycle, LoginRequest, Parks, PingClock};
|
||||
use crate::net::{CharRequest, RealmRequest};
|
||||
|
||||
const USER: &str = "empty";
|
||||
const PASS: &str = "secret";
|
||||
const REALM: &str = "Fake";
|
||||
const SALT: [u8; 32] = [0x5a; 32];
|
||||
/// Every wait in the test: long enough for a loaded machine, short of a hung gate.
|
||||
const WAIT: Duration = Duration::from_secs(20);
|
||||
|
||||
fn sha1(parts: &[&[u8]]) -> [u8; 20] {
|
||||
let mut h = Sha1::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
fn le32(v: &BigUint) -> [u8; 32] {
|
||||
let raw = v.to_bytes_le();
|
||||
let mut out = [0u8; 32];
|
||||
out[..raw.len()].copy_from_slice(&raw);
|
||||
out
|
||||
}
|
||||
|
||||
/// `SHA1_Interleave` over all 32 bytes of `S`, as vmangos's `SRP6::HashSessionKey` does.
|
||||
fn interleave(s: &[u8; 32]) -> [u8; SESSION_KEY_LENGTH] {
|
||||
let even: Vec<u8> = s.iter().step_by(2).copied().collect();
|
||||
let odd: Vec<u8> = s.iter().skip(1).step_by(2).copied().collect();
|
||||
let (g, h) = (sha1(&[&even]), sha1(&[&odd]));
|
||||
let mut out = [0u8; SESSION_KEY_LENGTH];
|
||||
for (i, (gi, hi)) in g.iter().zip(&h).enumerate() {
|
||||
out[i * 2] = *gi;
|
||||
out[i * 2 + 1] = *hi;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A realmd that logs [`USER`] in with the server half of SRP6 and lists one realm, [`REALM`] at
|
||||
/// `world`. The session key goes to the fake world server, which needs it for the header cipher.
|
||||
fn fake_realmd(world: String, keys: Sender<[u8; SESSION_KEY_LENGTH]>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let n = BigUint::from_bytes_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
|
||||
let g = BigUint::from(GENERATOR);
|
||||
let user = NormalizedString::new(USER).unwrap();
|
||||
let pass = NormalizedString::new(PASS).unwrap();
|
||||
let v = BigUint::from_bytes_le(&password_verifier(&user, &pass, &SALT));
|
||||
|
||||
// CMD_AUTH_LOGON_CHALLENGE_Client: opcode, protocol, u16 size, then the body.
|
||||
let mut head = [0u8; 4];
|
||||
s.read_exact(&mut head).unwrap();
|
||||
let mut body = vec![0u8; usize::from(u16::from_le_bytes([head[2], head[3]]))];
|
||||
s.read_exact(&mut body).unwrap();
|
||||
|
||||
// B = k·v + g^b, with k = 3; `b` walks until `B`'s top byte is non-zero, since the client
|
||||
// redials on a zero one.
|
||||
let (b, b_pub) = (1u32..)
|
||||
.map(|i| {
|
||||
let b = BigUint::from_bytes_le(&[0x3c; 32]) + i;
|
||||
let b_pub = le32(&((BigUint::from(3u8) * &v + g.modpow(&b, &n)) % &n));
|
||||
(b, b_pub)
|
||||
})
|
||||
.find(|(_, b_pub)| b_pub[31] != 0)
|
||||
.unwrap();
|
||||
let mut reply = vec![0x00, 0x00, 0x00];
|
||||
reply.extend_from_slice(&b_pub);
|
||||
reply.extend_from_slice(&[1, GENERATOR, 32]);
|
||||
reply.extend_from_slice(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
|
||||
reply.extend_from_slice(&SALT);
|
||||
reply.extend_from_slice(&[0u8; 16]); // crc_salt
|
||||
reply.push(0); // security_flag
|
||||
s.write_all(&reply).unwrap();
|
||||
|
||||
// CMD_AUTH_LOGON_PROOF_Client: opcode, A, M1, crc_hash, key count, security flag.
|
||||
let mut proof = [0u8; 75];
|
||||
s.read_exact(&mut proof).unwrap();
|
||||
let (a_pub, m1) = (&proof[1..33], &proof[33..53]);
|
||||
let u = BigUint::from_bytes_le(&sha1(&[a_pub, &b_pub]));
|
||||
let a = BigUint::from_bytes_le(a_pub);
|
||||
let big_s = (a * v.modpow(&u, &n) % &n).modpow(&b, &n);
|
||||
let key = interleave(&le32(&big_s));
|
||||
let mut reply = vec![0x01, 0x00];
|
||||
reply.extend_from_slice(&sha1(&[a_pub, m1, &key]));
|
||||
reply.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id
|
||||
s.write_all(&reply).unwrap();
|
||||
keys.send(key).unwrap();
|
||||
|
||||
// CMD_REALM_LIST_Client, answered with the one realm.
|
||||
s.read_exact(&mut [0u8; 5]).unwrap();
|
||||
let mut realm = Vec::new();
|
||||
realm.extend_from_slice(&0u32.to_le_bytes()); // realm_type
|
||||
realm.push(0); // flags
|
||||
realm.extend_from_slice(format!("{REALM}\0{world}\0").as_bytes());
|
||||
realm.extend_from_slice(&0f32.to_le_bytes()); // population
|
||||
realm.extend_from_slice(&[0, 1, 1]); // characters, category, id
|
||||
let mut list = vec![0x10];
|
||||
list.extend_from_slice(&((4 + 1 + realm.len() + 2) as u16).to_le_bytes());
|
||||
list.extend_from_slice(&0u32.to_le_bytes());
|
||||
list.push(1);
|
||||
list.extend_from_slice(&realm);
|
||||
list.extend_from_slice(&0u16.to_le_bytes());
|
||||
s.write_all(&list).unwrap();
|
||||
|
||||
// Held until the cycle drops the logon, as realmd keeps it for the list refresh.
|
||||
let _ = s.read(&mut [0u8; 1]);
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// A server packet: `u16` BE size counting the opcode, `u16` LE opcode, the body.
|
||||
fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u8]) {
|
||||
let size = ((body.len() + 2) as u16).to_be_bytes();
|
||||
let op = op.to_le_bytes();
|
||||
let mut header = [size[0], size[1], op[0], op[1]];
|
||||
if let Some(c) = crypto {
|
||||
c.encrypter().encrypt(&mut header);
|
||||
}
|
||||
s.write_all(&header).unwrap();
|
||||
s.write_all(body).unwrap();
|
||||
}
|
||||
|
||||
/// A client packet's opcode, reading past its body: `u16` BE size counting the 4-byte opcode.
|
||||
fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option<u16> {
|
||||
let mut header = [0u8; 6];
|
||||
s.read_exact(&mut header).ok()?;
|
||||
if let Some(c) = crypto {
|
||||
c.decrypter().decrypt(&mut header);
|
||||
}
|
||||
let size = usize::from(u16::from_be_bytes([header[0], header[1]]));
|
||||
s.read_exact(&mut vec![0u8; size.saturating_sub(4)]).ok()?;
|
||||
Some(u16::from_le_bytes([header[2], header[3]]))
|
||||
}
|
||||
|
||||
/// A world server whose account has no characters: it admits the session under realmd's key,
|
||||
/// answers every `CMSG_CHAR_ENUM` with an empty roster and a `CMSG_CHAR_CREATE` with success, and
|
||||
/// logs every opcode the client sends.
|
||||
fn fake_world(
|
||||
listener: TcpListener,
|
||||
keys: Receiver<[u8; SESSION_KEY_LENGTH]>,
|
||||
) -> Arc<Mutex<Vec<u16>>> {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = Arc::clone(&sent);
|
||||
thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
send(
|
||||
&mut s,
|
||||
None,
|
||||
opcode::SMSG_AUTH_CHALLENGE,
|
||||
&0xDEAD_BEEFu32.to_le_bytes(),
|
||||
);
|
||||
assert_eq!(recv(&mut s, None), Some(opcode::CMSG_AUTH_SESSION));
|
||||
let mut crypto = HeaderCrypto::from_session_key(keys.recv_timeout(WAIT).unwrap());
|
||||
send(
|
||||
&mut s,
|
||||
Some(&mut crypto),
|
||||
opcode::SMSG_AUTH_RESPONSE,
|
||||
&[messages::AUTH_OK],
|
||||
);
|
||||
while let Some(op) = recv(&mut s, Some(&mut crypto)) {
|
||||
log.lock().unwrap().push(op);
|
||||
match op {
|
||||
opcode::CMSG_CHAR_ENUM => {
|
||||
send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &[0]);
|
||||
}
|
||||
opcode::CMSG_CHAR_CREATE => send(
|
||||
&mut s,
|
||||
Some(&mut crypto),
|
||||
opcode::SMSG_CHAR_CREATE,
|
||||
&[messages::CHAR_CREATE_SUCCESS],
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
sent
|
||||
}
|
||||
|
||||
/// The next event, skipping the stage announcements; a login failure fails the test by name.
|
||||
fn next_event(events: &Receiver<SessionEvent>) -> SessionEvent {
|
||||
loop {
|
||||
match events.recv_timeout(WAIT).expect("the cycle went quiet") {
|
||||
SessionEvent::LoginStage { .. } => continue,
|
||||
SessionEvent::LoginFailed { reason, .. } => panic!("the login failed: {reason}"),
|
||||
ev => return ev,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference builds `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the glue's
|
||||
/// `CreateCharacter`, which the stock glue calls only from the create screen's Okay; so a login to
|
||||
/// an account with no characters parks at select on the empty list and creates nothing.
|
||||
#[test]
|
||||
fn an_empty_account_parks_at_select_without_creating_a_character() {
|
||||
let world_listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let world = world_listener.local_addr().unwrap().to_string();
|
||||
let (keys_tx, keys_rx) = crossbeam_channel::unbounded();
|
||||
let sent = fake_world(world_listener, keys_rx);
|
||||
let realmd = fake_realmd(world, keys_tx);
|
||||
|
||||
let (events_tx, events) = crossbeam_channel::unbounded();
|
||||
let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::<WorldWriter>();
|
||||
let (login_tx, login_rx) = crossbeam_channel::unbounded();
|
||||
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
|
||||
let (pick_tx, pick_rx) = crossbeam_channel::unbounded();
|
||||
let cycle = thread::spawn(move || {
|
||||
let parks = Parks {
|
||||
login_rx,
|
||||
realm_rx,
|
||||
pick_rx,
|
||||
};
|
||||
let ended = run(
|
||||
&events_tx,
|
||||
&writer_tx,
|
||||
&parks,
|
||||
&AtomicU64::new(0),
|
||||
&Mutex::new(PingClock::default()),
|
||||
&mut Default::default(),
|
||||
);
|
||||
matches!(ended, Ok(Cycle::Repark))
|
||||
});
|
||||
|
||||
login_tx
|
||||
.send(LoginRequest {
|
||||
user: USER.into(),
|
||||
pass: PASS.into(),
|
||||
host: realmd,
|
||||
generation: 0,
|
||||
})
|
||||
.unwrap();
|
||||
match next_event(&events) {
|
||||
SessionEvent::RealmList { realms } => assert_eq!(realms[0].name, REALM),
|
||||
other => panic!("expected the realm list, got {other:?}"),
|
||||
}
|
||||
realm_tx.send(RealmRequest::Enter(REALM.into())).unwrap();
|
||||
match next_event(&events) {
|
||||
SessionEvent::CharacterList { characters, .. } => assert!(
|
||||
characters.is_empty(),
|
||||
"the roster at select must be the account's, empty: {characters:?}"
|
||||
),
|
||||
other => panic!("expected the roster at select, got {other:?}"),
|
||||
}
|
||||
// Select's Back ends the cycle, so the socket is done and its log complete.
|
||||
pick_tx.send(CharRequest::Abandon).unwrap();
|
||||
assert!(cycle.join().unwrap(), "Back returns to the login park");
|
||||
let sent = sent.lock().unwrap().clone();
|
||||
assert!(
|
||||
!sent.contains(&opcode::CMSG_CHAR_CREATE),
|
||||
"the login sent CMSG_CHAR_CREATE on its own: {sent:#06x?}"
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
/// four channels and the `0x5107f0`/`0x5106f0` displacement predicates.
|
||||
pub(super) const CHANNEL_EPS: f32 = 0.001;
|
||||
|
||||
/// One `arm` request: the armer's four arguments plus the ground channel's duration clamp.
|
||||
/// One `arm` request: the armer's four arguments plus the ground channel's duration bound.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Arm {
|
||||
/// In the live value's units.
|
||||
@@ -29,9 +29,26 @@ pub(super) struct Arm {
|
||||
pub(super) factor: f32,
|
||||
/// Live-value units per second: `cvar.to_radians()` for the angular channels, yd/s for height.
|
||||
pub(super) rate: f32,
|
||||
/// The ground channel's bound: after the arm, `0x50dd29` clamps `[cam+0x1ac]` between
|
||||
/// `Factor × cameraTerrainTiltTimeMin` and `Factor × cameraTerrainTiltTimeMax`.
|
||||
pub(super) duration: Option<(f32, f32)>,
|
||||
/// The ground channel's `(cameraTerrainTiltTimeMin, cameraTerrainTiltTimeMax)`, seconds, which
|
||||
/// [`tilt_bound`] scales by `factor` to bound the duration.
|
||||
pub(super) bound: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
/// The ground channel's duration bound, `0x50dd29`-`0x50dd83`, with `lo = TimeMin × factor` and
|
||||
/// `hi = TimeMax × factor`: below `lo` takes `lo`, else at or above `hi`, or NaN, takes `hi`, else
|
||||
/// the duration stands. Nothing orders the pair, so an inverted one is an odd duration, not a fault.
|
||||
fn tilt_bound(duration: f32, (time_min, time_max): (f32, f32), factor: f32) -> f32 {
|
||||
// `lo` is compared unrounded, live on the x87 stack (`0x50dd4c`); `hi` is stored as an f32
|
||||
// first (`0x50dd40`). Only an inverted pair can see the difference.
|
||||
let lo = f64::from(time_min) * f64::from(factor);
|
||||
let hi = time_max * factor;
|
||||
if f64::from(duration) < lo {
|
||||
lo as f32
|
||||
} else if duration < hi {
|
||||
duration
|
||||
} else {
|
||||
hi
|
||||
}
|
||||
}
|
||||
|
||||
impl Arm {
|
||||
@@ -42,7 +59,7 @@ impl Arm {
|
||||
delay: 0.0,
|
||||
factor: 1.0,
|
||||
rate,
|
||||
duration: None,
|
||||
bound: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,11 +139,16 @@ impl SmoothChannel {
|
||||
self.live += two_pi;
|
||||
}
|
||||
}
|
||||
if let Some(f) = self.flight {
|
||||
if let Some(f) = self.flight.as_mut() {
|
||||
if (self.to - arm.target).abs() < CHANNEL_EPS
|
||||
&& (f.memo.0 - arm.delay).abs() < CHANNEL_EPS
|
||||
&& (f.memo.1 - arm.factor).abs() < CHANNEL_EPS
|
||||
{
|
||||
// The bound runs on this return too (`0x512555` returns 1, `0x50dd21` tests it),
|
||||
// so it bounds the duration in flight again every frame.
|
||||
if let Some(bound) = arm.bound {
|
||||
f.duration = tilt_bound(f.duration, bound, arm.factor);
|
||||
}
|
||||
return Armed::Already;
|
||||
}
|
||||
}
|
||||
@@ -138,15 +160,15 @@ impl SmoothChannel {
|
||||
return Armed::AtRest;
|
||||
}
|
||||
let mut duration = gap / arm.rate.max(f32::EPSILON) * arm.factor;
|
||||
if let Some((lo, hi)) = arm.duration {
|
||||
duration = duration.clamp(lo, hi);
|
||||
if let Some(bound) = arm.bound {
|
||||
duration = tilt_bound(duration, bound, arm.factor);
|
||||
}
|
||||
self.from = self.live;
|
||||
self.to = arm.target;
|
||||
self.flight = Some(Flight {
|
||||
elapsed: 0.0,
|
||||
delay: arm.delay,
|
||||
duration: duration.max(f32::EPSILON),
|
||||
duration,
|
||||
memo: (arm.delay, arm.factor),
|
||||
});
|
||||
Armed::Started
|
||||
@@ -158,7 +180,10 @@ impl SmoothChannel {
|
||||
f.elapsed += dt;
|
||||
let t = f.elapsed - f.delay;
|
||||
if t >= 0.0 {
|
||||
let s = t / f.duration;
|
||||
// The floor lands a zero or NaN duration on the target. The reference's ground step
|
||||
// divides by it raw (`0x50f45e`) and eases whenever C0 is set (`0x50f46f`), which a
|
||||
// NaN `s` sets, so a NaN duration writes NaN into its live value.
|
||||
let s = t / f.duration.max(f32::EPSILON);
|
||||
if s >= 1.0 {
|
||||
self.live = self.to;
|
||||
self.flight = None;
|
||||
@@ -230,7 +255,7 @@ mod tests {
|
||||
delay: 0.0,
|
||||
factor,
|
||||
rate,
|
||||
duration: None
|
||||
bound: None
|
||||
}),
|
||||
Armed::Started
|
||||
);
|
||||
@@ -302,7 +327,7 @@ mod tests {
|
||||
delay: 0.5,
|
||||
factor: 1.0,
|
||||
rate: 1.0,
|
||||
duration: None,
|
||||
bound: None,
|
||||
});
|
||||
let frames = run(&mut c, 0.45);
|
||||
assert!(frames.iter().all(|v| *v == 0.0), "held for the delay");
|
||||
@@ -318,7 +343,7 @@ mod tests {
|
||||
delay: 0.0,
|
||||
factor: 1.0,
|
||||
rate: 7.5,
|
||||
duration: Some((0.1, 0.5)),
|
||||
bound: Some((0.1, 0.5)),
|
||||
});
|
||||
let frames = run(&mut c, 0.6);
|
||||
let arrived = frames
|
||||
@@ -330,4 +355,145 @@ mod tests {
|
||||
"|Δ|/rate would be 13.3 s; the bound caps it at 0.5"
|
||||
);
|
||||
}
|
||||
|
||||
// The expectations below are `0x50dd29`-`0x50dd83` read directly: `lo = TimeMin × factor`,
|
||||
// `hi = TimeMax × factor`; below `lo` → `lo`, else at or above `hi` or NaN → `hi`, else as is.
|
||||
|
||||
#[test]
|
||||
fn an_ordered_tilt_bound_keeps_its_clamp() {
|
||||
for (duration, factor, expected) in [
|
||||
(1.0, 1.0, 3.0),
|
||||
(5.0, 1.0, 5.0),
|
||||
(10.0, 1.0, 10.0),
|
||||
(20.0, 1.0, 10.0),
|
||||
(1.0, 2.0, 6.0),
|
||||
(10.0, 2.0, 10.0),
|
||||
(25.0, 2.0, 20.0),
|
||||
] {
|
||||
assert_eq!(tilt_bound(duration, (3.0, 10.0), factor), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inverted_tilt_bound_gives_the_reference_duration() {
|
||||
// `cameraTerrainTiltTimeMin 20` over `cameraTerrainTiltTimeMax 1`: at factor 2, lo 40, hi 2.
|
||||
for (duration, expected) in [
|
||||
(1.0, 40.0), // below both: under lo
|
||||
(10.0, 40.0), // between hi and lo: still under lo
|
||||
(40.0, 2.0), // at lo: not under it, and at or above hi
|
||||
(100.0, 2.0), // above both
|
||||
] {
|
||||
assert_eq!(tilt_bound(duration, (20.0, 1.0), 2.0), expected);
|
||||
}
|
||||
assert_eq!(tilt_bound(0.5, (20.0, 1.0), 1.0), 20.0);
|
||||
assert_eq!(tilt_bound(30.0, (20.0, 1.0), 1.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nan_tilt_lo_is_no_floor() {
|
||||
// No duration is under a NaN lo.
|
||||
assert_eq!(tilt_bound(1.0, (f32::NAN, 10.0), 1.0), 1.0);
|
||||
assert_eq!(tilt_bound(20.0, (f32::NAN, 10.0), 1.0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nan_tilt_hi_is_the_answer_above_lo() {
|
||||
assert_eq!(tilt_bound(1.0, (3.0, f32::NAN), 1.0), 3.0);
|
||||
assert!(tilt_bound(5.0, (3.0, f32::NAN), 1.0).is_nan());
|
||||
// A NaN factor makes both bounds NaN, so hi.
|
||||
assert!(tilt_bound(5.0, (3.0, 10.0), f32::NAN).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nan_tilt_duration_takes_hi() {
|
||||
// Under neither bound, so at or above hi.
|
||||
assert_eq!(tilt_bound(f32::NAN, (3.0, 10.0), 1.0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tilt_bound_compares_lo_unrounded() {
|
||||
// TimeMin 20 × (1 + 2⁻²³) is 20 + 1.25 ulp, which rounds down to 20 + 1 ulp as an f32.
|
||||
let factor = f32::from_bits(0x3f80_0001);
|
||||
let lo_f32 = f32::from_bits(0x41a0_0001);
|
||||
assert_eq!(20.0 * factor, lo_f32);
|
||||
assert!(f64::from(20.0_f32) * f64::from(factor) > f64::from(lo_f32));
|
||||
// A duration of exactly the rounded lo is under the unrounded one, so it takes lo; an f32
|
||||
// compare would pass it on to the hi of TimeMax 1 below it.
|
||||
assert_eq!(
|
||||
tilt_bound(lo_f32, (20.0, 1.0), factor).to_bits(),
|
||||
lo_f32.to_bits()
|
||||
);
|
||||
}
|
||||
|
||||
/// Arm and step once a frame, as the terrain tilt drives the ground channel, until it lands;
|
||||
/// the seconds it took.
|
||||
fn arrival(c: &mut SmoothChannel, arm: &Arm, limit: f32) -> Option<f32> {
|
||||
let dt = 1.0 / 60.0;
|
||||
(1..=(limit / dt).round() as usize).find_map(|frame| {
|
||||
c.arm(arm);
|
||||
c.advance(dt);
|
||||
(!c.in_flight()).then_some(frame as f32 * dt)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inverted_tilt_pair_arms_and_is_bounded_again_in_flight() {
|
||||
let arm = Arm {
|
||||
target: 1.0,
|
||||
delay: 0.0,
|
||||
factor: 1.0,
|
||||
rate: 1.0,
|
||||
bound: Some((20.0, 1.0)),
|
||||
};
|
||||
// The 1 s tween is under lo 20, so the arm takes 20.
|
||||
let mut c = SmoothChannel::default();
|
||||
assert_eq!(c.arm(&arm), Armed::Started);
|
||||
assert_eq!(c.flight.map(|f| f.duration), Some(20.0));
|
||||
// The next frame's arm is already arming this, and the bound runs again: 20 is not under
|
||||
// lo, and is at or above hi 1, so 1.
|
||||
assert_eq!(c.arm(&arm), Armed::Already);
|
||||
assert_eq!(c.flight.map(|f| f.duration), Some(1.0));
|
||||
let took = arrival(&mut SmoothChannel::default(), &arm, 30.0).expect("lands");
|
||||
assert!(
|
||||
(took - 1.0).abs() < 0.05,
|
||||
"lands at hi, 1 s, took {took:.2}s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tilt_bound_changed_in_flight_bounds_the_tween_again() {
|
||||
let mut arm = Arm {
|
||||
target: 100.0,
|
||||
delay: 0.0,
|
||||
factor: 1.0,
|
||||
rate: 1.0,
|
||||
bound: Some((3.0, 10.0)),
|
||||
};
|
||||
let mut c = SmoothChannel::default();
|
||||
assert_eq!(c.arm(&arm), Armed::Started);
|
||||
assert_eq!(c.flight.map(|f| f.duration), Some(10.0));
|
||||
// `cameraTerrainTiltTimeMax 5` mid-tween.
|
||||
arm.bound = Some((3.0, 5.0));
|
||||
assert_eq!(c.arm(&arm), Armed::Already);
|
||||
assert_eq!(c.flight.map(|f| f.duration), Some(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nan_tilt_duration_lands_on_the_target() {
|
||||
let arm = Arm {
|
||||
target: 5.0,
|
||||
delay: 0.0,
|
||||
factor: 1.0,
|
||||
rate: 1.0,
|
||||
bound: Some((3.0, f32::NAN)),
|
||||
};
|
||||
let mut c = SmoothChannel::default();
|
||||
assert_eq!(c.arm(&arm), Armed::Started);
|
||||
assert!(
|
||||
c.flight.is_some_and(|f| f.duration.is_nan()),
|
||||
"the bound's answer is the NaN hi"
|
||||
);
|
||||
assert_eq!(c.advance(1.0 / 60.0), 5.0);
|
||||
assert!(!c.in_flight());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +421,7 @@ impl TerrainTilt {
|
||||
delay,
|
||||
factor,
|
||||
rate,
|
||||
duration: Some((cfg.tilt_time_min * factor, cfg.tilt_time_max * factor)),
|
||||
bound: Some((cfg.tilt_time_min, cfg.tilt_time_max)),
|
||||
});
|
||||
}
|
||||
self.ground.advance(dt)
|
||||
@@ -878,6 +878,40 @@ mod tests {
|
||||
assert!(zeroed.abs() < CHANNEL_EPS, "levels off, at {zeroed}");
|
||||
}
|
||||
|
||||
/// `cameraTerrainTiltTimeMin 20` over `cameraTerrainTiltTimeMax 1`, a pair 1.12.1 accepts, onto
|
||||
/// a 20° slope: the first arm takes `20 × Factor`, and every later one bounds that to
|
||||
/// `1 × Factor`, since it is not under lo and is at or above hi (`0x50dd29`).
|
||||
#[test]
|
||||
fn an_inverted_tilt_time_pair_leans_over_the_max_times_the_factor() {
|
||||
use super::super::camera::FollowStyle;
|
||||
let cfg = CameraOptions {
|
||||
terrain_tilt: true,
|
||||
tilt_time_min: 20.0,
|
||||
tilt_time_max: 1.0,
|
||||
..CameraOptions::default()
|
||||
};
|
||||
let target = TerrainTilt::slope_to_pitch(0.5);
|
||||
// Walking is the Factor 1 row, falling the 0.75 one.
|
||||
for (move_flags, factor) in [(mf::FORWARD, 1.0), (mf::FORWARD | mf::FALLING, 0.75)] {
|
||||
let subject = SubjectState {
|
||||
move_flags,
|
||||
..SubjectState::default()
|
||||
};
|
||||
let mut t = TerrainTilt::default();
|
||||
let took = (1..=600)
|
||||
.find_map(|frame| {
|
||||
let p = t.advance(|| 0.5, true, &subject, FollowStyle::Smart, &cfg, 1.0 / 60.0);
|
||||
(p == target).then_some(frame as f32 / 60.0)
|
||||
})
|
||||
.expect("the lean arrives");
|
||||
let expected = cfg.tilt_time_max * factor;
|
||||
assert!(
|
||||
(took - expected).abs() < 0.05,
|
||||
"Factor {factor}: {expected} s, took {took:.2}s"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_bob_arms_on_the_movement_command_word_and_on_the_mouse_chord() {
|
||||
use super::super::camera::follow_cmd as c;
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! The realm categories behind the list's tabs.
|
||||
//!
|
||||
//! The category set is the client Region's `Cfg_Categories.dbc` rows, built once per process
|
||||
//! (`0x46e300`). Each list rebuild (`0x46e510`) places every realm by its wire category byte: a 0
|
||||
//! byte takes the first category's id (`0x46e5b8`), then the byte joins every category whose id
|
||||
//! equals it (`0x46e60b`), and a byte no category carries leaves the realm on no tab at all.
|
||||
//!
|
||||
//! A category the Lua names crosses as a 1-based ordinal over the non-empty categories only; the
|
||||
//! map to the stored index (`0x46edf0`) has no sentinel, so an ordinal past the last one reads
|
||||
//! the first stored category, empty or not.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::RealmCategory;
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
use super::Realms;
|
||||
|
||||
/// `MAX_REALM_CATEGORY_TABS` (`RealmList.lua:4`).
|
||||
pub(super) const MAX_TABS: usize = 8;
|
||||
|
||||
/// Every category's realms, as indices into the list in wire order. Before the category set is
|
||||
/// read, or when the Region has none, there is one category holding every realm.
|
||||
fn place(categories: Option<&[RealmCategory]>, realms: &[RealmInfo]) -> Vec<Vec<usize>> {
|
||||
// Deviation: no Region rows faults the reference on a NULL category (`0x46e5b8`); list all.
|
||||
let Some(categories) = categories.filter(|c| !c.is_empty()) else {
|
||||
return vec![(0..realms.len()).collect()];
|
||||
};
|
||||
let mut buckets = vec![Vec::new(); categories.len()];
|
||||
for (i, realm) in realms.iter().enumerate() {
|
||||
let byte = match realm.category {
|
||||
0 => match categories.first() {
|
||||
Some(first) => first.id,
|
||||
None => continue,
|
||||
},
|
||||
b => u32::from(b),
|
||||
};
|
||||
// No break: a realm joins every category carrying its id.
|
||||
for (bucket, category) in buckets.iter_mut().zip(categories) {
|
||||
if category.id == byte {
|
||||
bucket.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
buckets
|
||||
}
|
||||
|
||||
/// `0x46edf0`: the stored index of the `visible`-th non-empty category (1-based), or 0.
|
||||
fn stored_index(buckets: &[Vec<usize>], visible: usize) -> usize {
|
||||
buckets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| !b.is_empty())
|
||||
.nth(visible.wrapping_sub(1))
|
||||
.map_or(0, |(i, _)| i)
|
||||
}
|
||||
|
||||
impl Realms {
|
||||
/// Every category's realms; see [`place`].
|
||||
fn buckets(&self) -> Vec<Vec<usize>> {
|
||||
place(self.categories.as_deref(), &self.realms)
|
||||
}
|
||||
|
||||
/// The realms of the tab in front, unsorted: `GetNumRealms` and `GetRealmInfo` with
|
||||
/// `RealmList.selectedCategory`.
|
||||
pub(super) fn category_realms(&self) -> Vec<usize> {
|
||||
let mut buckets = self.buckets();
|
||||
let at = stored_index(&buckets, self.category());
|
||||
if at < buckets.len() {
|
||||
std::mem::take(&mut buckets[at])
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmList.selectedCategory`: nil until the first `RealmListUpdate` makes it 1.
|
||||
pub(super) fn category(&self) -> usize {
|
||||
self.category.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The tab labels, `GetRealmCategories` (`0x46f1f0`): the names of the categories holding a
|
||||
/// realm. `RealmList_UpdateTabs` hides the strip unless there are two or more, so the one name
|
||||
/// the reference still pushes when none hold a realm (`0x46f255`) is left out.
|
||||
pub(super) fn tabs(&self) -> Vec<&str> {
|
||||
let Some(categories) = &self.categories else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.buckets()
|
||||
.iter()
|
||||
.zip(categories)
|
||||
.filter(|(b, _)| !b.is_empty())
|
||||
.map(|(_, c)| c.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `GetSelectedCategory` (`0x46f400`): the ordinal of the first non-empty category at or after
|
||||
/// the stored one, else 1.
|
||||
pub(super) fn selected_category(&self) -> usize {
|
||||
let from = self.chosen_category.unwrap_or(0);
|
||||
self.buckets()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| !b.is_empty())
|
||||
.enumerate()
|
||||
.find(|(_, (i, _))| *i >= from)
|
||||
.map_or(1, |(ordinal, _)| ordinal + 1)
|
||||
}
|
||||
|
||||
/// `RealmList_OnShow`: the tab `GetSelectedCategory` names comes to the front, when a tab
|
||||
/// button carries that ordinal.
|
||||
pub(super) fn front_selected_category(&mut self) {
|
||||
let ordinal = self.selected_category();
|
||||
if ordinal <= MAX_TABS {
|
||||
self.category = Some(ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmListTab_OnClick`: the tab comes to the front and the list redraws. The redraw has no
|
||||
/// clicked name to keep (`RealmListUpdate` clears `selectedName`), so the highlight falls back
|
||||
/// to the realm this session is on, when that realm is on the new tab.
|
||||
pub(super) fn click_tab(&mut self, ordinal: usize) {
|
||||
self.category = Some(ordinal);
|
||||
self.refresh_in = super::REFRESH_SECS;
|
||||
self.highlight_current();
|
||||
self.clamp_offset();
|
||||
}
|
||||
|
||||
/// `ChangeRealm` stores the front tab's category (`0x46f188`), unconditionally, before it
|
||||
/// dials; `GetSelectedCategory` reads it when the list next opens.
|
||||
pub(super) fn store_front_category(&mut self) {
|
||||
self.chosen_category = Some(stored_index(&self.buckets(), self.category()));
|
||||
}
|
||||
|
||||
/// The rebuild's tail (`0x46e6f0`): the category holding the realm this session names becomes
|
||||
/// the stored one; with no such realm, or one on no tab, it is left as it was.
|
||||
pub(super) fn store_current_category(&mut self) {
|
||||
let (Some(categories), Some(want)) = (&self.categories, &self.current) else {
|
||||
return;
|
||||
};
|
||||
// The walk visits every realm, so the last one of the name decides.
|
||||
let Some(realm) = self
|
||||
.realms
|
||||
.iter()
|
||||
.rfind(|r| r.name.eq_ignore_ascii_case(want))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let byte = match realm.category {
|
||||
0 => match categories.first() {
|
||||
Some(first) => first.id,
|
||||
None => return,
|
||||
},
|
||||
b => u32::from(b),
|
||||
};
|
||||
if let Some(at) = categories.iter().position(|c| c.id == byte) {
|
||||
self.chosen_category = Some(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the client Region's category set once, after the archive chain opens.
|
||||
pub(super) fn load_categories(
|
||||
mut realms: ResMut<Realms>,
|
||||
assets: Option<Res<benilla_assets::WorldAssets>>,
|
||||
) {
|
||||
use benilla_assets::LockRecover;
|
||||
let Some(assets) = assets else {
|
||||
return;
|
||||
};
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
let region = benilla_formats::client_region(&chain);
|
||||
match benilla_formats::load_realm_categories(&mut chain, region) {
|
||||
Ok(categories) => {
|
||||
info!(
|
||||
"realm list: Region {region}, categories {:?}",
|
||||
categories
|
||||
.iter()
|
||||
.map(|c| (c.id, c.name.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
if categories.is_empty() {
|
||||
warn!(
|
||||
"realm list: Region {region} names no Cfg_Categories.dbc row, so every realm \
|
||||
lists on one untabbed page (the reference's Region is Wow.ini's [WoW Config] \
|
||||
Region)"
|
||||
);
|
||||
}
|
||||
realms.categories = Some(categories);
|
||||
}
|
||||
Err(e) => warn!(
|
||||
"realm list: Cfg_Categories.dbc failed to load, so every realm lists on one untabbed \
|
||||
page: {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cat(id: u32, name: &str) -> RealmCategory {
|
||||
RealmCategory {
|
||||
id,
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Region 1's rows, `Cfg_Categories.dbc` in file order.
|
||||
pub(in crate::realm_select) fn us() -> Vec<RealmCategory> {
|
||||
vec![cat(1, "United States"), cat(5, "Oceanic")]
|
||||
}
|
||||
|
||||
fn realm(name: &str, category: u8) -> RealmInfo {
|
||||
RealmInfo {
|
||||
name: name.into(),
|
||||
address: "127.0.0.1:8085".into(),
|
||||
population: 1.0,
|
||||
characters: 0,
|
||||
realm_type: 0,
|
||||
flags: 0,
|
||||
category,
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn realms(list: &[(&str, u8)]) -> Realms {
|
||||
Realms {
|
||||
realms: list.iter().map(|&(n, c)| realm(n, c)).collect(),
|
||||
categories: Some(us()),
|
||||
..Realms::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn names(r: &Realms) -> Vec<String> {
|
||||
r.rows().iter().map(|&i| r.realms[i].name.clone()).collect()
|
||||
}
|
||||
|
||||
/// The US client reads its two categories from the install's own `Cfg_Categories.dbc`.
|
||||
#[test]
|
||||
fn the_us_categories_come_from_the_install() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let loaded = benilla_formats::load_realm_categories(&mut chain, 1).expect("DBC");
|
||||
assert_eq!(loaded, us());
|
||||
}
|
||||
|
||||
/// A category byte is an id, matched for equality: 1 and 5 are the two US tabs.
|
||||
#[test]
|
||||
fn a_realm_lands_on_the_tab_whose_id_is_its_category_byte() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5), ("Beta", 1)]);
|
||||
assert_eq!(r.tabs(), ["United States", "Oceanic"]);
|
||||
assert_eq!(names(&r), ["Alpha", "Beta"]);
|
||||
r.category = Some(2);
|
||||
assert_eq!(names(&r), ["Down Under"]);
|
||||
}
|
||||
|
||||
/// Byte 0 (a vmangos development-zone realm) folds into the first category.
|
||||
#[test]
|
||||
fn a_zero_byte_joins_the_first_category() {
|
||||
let r = realms(&[("Dev", 0), ("Alpha", 1)]);
|
||||
assert_eq!(r.tabs(), ["United States"], "one tab: the strip hides");
|
||||
assert_eq!(names(&r), ["Alpha", "Dev"]);
|
||||
}
|
||||
|
||||
/// A byte no category of the Region carries lists nowhere.
|
||||
#[test]
|
||||
fn a_byte_outside_the_region_is_on_no_tab() {
|
||||
let r = realms(&[("Alpha", 1), ("Berlin", 2)]);
|
||||
assert_eq!(r.tabs(), ["United States"]);
|
||||
assert_eq!(names(&r), ["Alpha"]);
|
||||
assert_eq!(r.category_realms(), [0]);
|
||||
}
|
||||
|
||||
/// Only a category holding a realm gets a tab, and ordinals count those alone.
|
||||
#[test]
|
||||
fn an_empty_category_has_no_tab_and_no_ordinal() {
|
||||
let mut r = realms(&[("Down Under", 5)]);
|
||||
assert_eq!(r.tabs(), ["Oceanic"], "United States holds nothing");
|
||||
assert_eq!(
|
||||
names(&r),
|
||||
["Down Under"],
|
||||
"ordinal 1 is the Oceanic category"
|
||||
);
|
||||
// Past the last ordinal the map reads stored category 0, the empty United States.
|
||||
r.category = Some(2);
|
||||
assert!(names(&r).is_empty());
|
||||
}
|
||||
|
||||
/// Before the category set is read every realm lists, untabbed.
|
||||
#[test]
|
||||
fn with_no_category_set_every_realm_lists_untabbed() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Berlin", 2), ("Dev", 0)]);
|
||||
r.categories = None;
|
||||
assert!(r.tabs().is_empty());
|
||||
assert_eq!(names(&r), ["Alpha", "Berlin", "Dev"]);
|
||||
}
|
||||
|
||||
/// A Region no row names (Region 0, no `Wow.ini` Region) lists every realm untabbed too,
|
||||
/// where the reference faults; the list opens on it and a realm on it can be entered.
|
||||
#[test]
|
||||
fn a_region_with_no_categories_lists_every_realm_untabbed() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Berlin", 2), ("Dev", 0)]);
|
||||
r.categories = Some(Vec::new());
|
||||
assert!(r.tabs().is_empty());
|
||||
assert_eq!(names(&r), ["Alpha", "Berlin", "Dev"]);
|
||||
r.current = Some("Berlin".into());
|
||||
r.store_current_category();
|
||||
r.open();
|
||||
assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Berlin"));
|
||||
assert!(r.can_enter());
|
||||
}
|
||||
|
||||
/// A tab click fronts its category, and the highlight moves to the session's realm if it is
|
||||
/// there, else nowhere, so Okay cannot enter a realm from another tab.
|
||||
#[test]
|
||||
fn a_tab_click_relists_and_resets_the_highlight() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
r.current = Some("Alpha".into());
|
||||
r.select("Alpha");
|
||||
r.click_tab(2);
|
||||
assert_eq!(names(&r), ["Down Under"]);
|
||||
assert!(r.selected().is_none() && !r.can_enter());
|
||||
r.click_tab(1);
|
||||
assert_eq!(names(&r), ["Alpha"]);
|
||||
assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Alpha"));
|
||||
}
|
||||
|
||||
/// A tab with fewer realms than the scroll offset shows from its top.
|
||||
#[test]
|
||||
fn a_tab_click_clamps_the_scroll_to_the_new_tab() {
|
||||
let mut list: Vec<(String, u8)> = (0..30).map(|i| (format!("R{i:02}"), 1)).collect();
|
||||
list.push(("Down Under".into(), 5));
|
||||
let refs: Vec<(&str, u8)> = list.iter().map(|(n, c)| (n.as_str(), *c)).collect();
|
||||
let mut r = realms(&refs);
|
||||
r.offset = 12;
|
||||
r.click_tab(2);
|
||||
assert_eq!(r.offset, 0);
|
||||
}
|
||||
|
||||
/// The list opens on the tab of the realm this session is on, and a realm entered from a tab
|
||||
/// brings that tab back next time.
|
||||
#[test]
|
||||
fn the_list_opens_on_the_current_realms_tab() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
assert_eq!(r.selected_category(), 1, "nothing stored: the first tab");
|
||||
r.current = Some("down under".into());
|
||||
r.store_current_category();
|
||||
assert_eq!(r.selected_category(), 2);
|
||||
r.front_selected_category();
|
||||
assert_eq!(names(&r), ["Down Under"]);
|
||||
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
r.category = Some(2);
|
||||
r.store_front_category();
|
||||
r.category = Some(1);
|
||||
r.front_selected_category();
|
||||
assert_eq!(r.category(), 2, "ChangeRealm's stored tab");
|
||||
}
|
||||
|
||||
/// A stored category that has emptied gives the next non-empty one's ordinal.
|
||||
#[test]
|
||||
fn a_stored_category_that_emptied_reads_the_next_tab_on() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
r.chosen_category = Some(0);
|
||||
r.realms.remove(0);
|
||||
assert_eq!(r.selected_category(), 1, "Oceanic, now the only tab");
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ use super::{is_down, Realms};
|
||||
/// The double-click window, the interval the character select screen uses.
|
||||
const DOUBLE_CLICK_SECS: f32 = 0.4;
|
||||
|
||||
/// Clicks: a row selects (a second one enters), the column headers sort, Okay enters, Cancel and
|
||||
/// the close X leave.
|
||||
/// Clicks: a row selects (a second one enters), a tab fronts its category, the column headers
|
||||
/// sort, Okay enters, Cancel and the close X leave.
|
||||
pub(super) fn clicks(
|
||||
buttons: Query<(Entity, &RealmAction)>,
|
||||
hits: Res<crate::glue::GlueClicks>,
|
||||
@@ -49,6 +49,9 @@ pub(super) fn clicks(
|
||||
enter = true;
|
||||
}
|
||||
}
|
||||
// The front tab is `Disable()`d (`GlueTemplates_SelectTab`) and takes no click.
|
||||
RealmAction::Tab(ordinal) if ordinal != realms.category() => realms.click_tab(ordinal),
|
||||
RealmAction::Tab(_) => {}
|
||||
RealmAction::Ok => enter = true,
|
||||
RealmAction::Cancel => leave = Some(true),
|
||||
RealmAction::Close => leave = Some(false),
|
||||
@@ -177,6 +180,7 @@ fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWrit
|
||||
let name = realm.name.clone();
|
||||
sounds.write(GlueSound("gsLoginChangeRealmOK"));
|
||||
realms.hide();
|
||||
realms.store_front_category();
|
||||
realms.enter(choice, name);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! names no screen; only `ConnectToRealm` (`0x46b210`) drops the world connection, once a realm is
|
||||
//! chosen. So the character park serves the list in place and Cancel there does nothing.
|
||||
|
||||
mod category;
|
||||
mod input;
|
||||
mod load;
|
||||
mod screen;
|
||||
@@ -29,6 +30,7 @@ pub(crate) use load::pvp_rp;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{RealmChoice, RealmListMessage, RealmRequest};
|
||||
use benilla_formats::RealmCategory;
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
/// The persisted 1.12 CVar naming the last realm connected to (`0x83f2d0`); the SavedVariables
|
||||
@@ -46,8 +48,15 @@ pub(crate) struct Realms {
|
||||
pub(super) realms: Vec<RealmInfo>,
|
||||
/// The highlighted row by realm name, so a refresh that adds or drops a realm cannot move it.
|
||||
selected: Option<String>,
|
||||
/// The category tab in front (`RealmList.selectedCategory`), by the wire's category byte.
|
||||
pub(super) category: Option<u8>,
|
||||
/// The client Region's categories ([`category`]), read once at startup; `None` until then,
|
||||
/// and every realm lists untabbed.
|
||||
pub(super) categories: Option<Vec<RealmCategory>>,
|
||||
/// The tab in front, `RealmList.selectedCategory`: a 1-based ordinal over the categories
|
||||
/// holding a realm.
|
||||
pub(super) category: Option<usize>,
|
||||
/// `[0x837e98]`, the stored category `GetSelectedCategory` opens the list on, by index into
|
||||
/// [`Self::categories`]; `None` is its `-1` load value.
|
||||
chosen_category: Option<usize>,
|
||||
/// First visible row within the selected category (`RealmList.offset`).
|
||||
pub(super) offset: usize,
|
||||
/// Which column the list is sorted on, and which way.
|
||||
@@ -117,17 +126,11 @@ impl Sort {
|
||||
}
|
||||
|
||||
impl Realms {
|
||||
/// The rows the screen draws: indices into [`Self::realms`] for the selected category, sorted.
|
||||
/// The rows the screen draws: indices into [`Self::realms`] for the tab in front, sorted per
|
||||
/// category (`0x46e750`).
|
||||
pub(super) fn rows(&self) -> Vec<usize> {
|
||||
let category = self.category;
|
||||
let (mean, stddev) = self.stats();
|
||||
let mut rows: Vec<usize> = self
|
||||
.realms
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| category.is_none_or(|c| r.category == c))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let mut rows = self.category_realms();
|
||||
rows.sort_by(|&a, &b| {
|
||||
let (ra, rb) = (&self.realms[a], &self.realms[b]);
|
||||
for (key, descending) in self.sort.0 {
|
||||
@@ -153,15 +156,6 @@ impl Realms {
|
||||
rows
|
||||
}
|
||||
|
||||
/// The category bytes present, ascending, one tab each; `RealmList_UpdateTabs` hides the strip
|
||||
/// when there is only one.
|
||||
pub(super) fn categories(&self) -> Vec<u8> {
|
||||
let mut cats: Vec<u8> = self.realms.iter().map(|r| r.category).collect();
|
||||
cats.sort_unstable();
|
||||
cats.dedup();
|
||||
cats
|
||||
}
|
||||
|
||||
/// The load distribution, over every realm, not the selected category: `0x46e510` walks the
|
||||
/// flat all-categories array, so switching tabs moves no band.
|
||||
pub(super) fn stats(&self) -> (f32, f32) {
|
||||
@@ -186,22 +180,35 @@ impl Realms {
|
||||
self.selected().is_some_and(|r| !is_down(r))
|
||||
}
|
||||
|
||||
/// `RealmList:Show()`: reset the scroll, highlight [`Self::current`], start the refresh timer.
|
||||
/// `RealmList:Show()`: front the stored category's tab, reset the scroll, highlight
|
||||
/// [`Self::current`], start the refresh timer.
|
||||
fn open(&mut self) {
|
||||
self.shown = true;
|
||||
self.front_selected_category();
|
||||
self.offset = 0;
|
||||
self.selected = self
|
||||
.current
|
||||
.as_deref()
|
||||
.and_then(|want| {
|
||||
self.realms
|
||||
.iter()
|
||||
.find(|r| r.name.eq_ignore_ascii_case(want))
|
||||
})
|
||||
.map(|r| r.name.clone());
|
||||
self.highlight_current();
|
||||
self.refresh_in = REFRESH_SECS;
|
||||
}
|
||||
|
||||
/// `RealmListUpdate` with no clicked name: the highlight is the row `GetRealmInfo` flags
|
||||
/// `currentRealm`, [`Self::current`] case-folded, on the tab in front, or none.
|
||||
fn highlight_current(&mut self) {
|
||||
self.selected = self.current.as_deref().and_then(|want| {
|
||||
self.rows()
|
||||
.into_iter()
|
||||
.map(|i| &self.realms[i])
|
||||
.find(|r| r.name.eq_ignore_ascii_case(want))
|
||||
.map(|r| r.name.clone())
|
||||
});
|
||||
}
|
||||
|
||||
/// `GlueScrollFrame_Update` clamps the scroll bar to the tab's row count, and the bar's
|
||||
/// change handler writes `RealmList.offset` back.
|
||||
fn clamp_offset(&mut self) {
|
||||
let max = self.rows().len().saturating_sub(screen::MAX_ROWS);
|
||||
self.offset = self.offset.min(max);
|
||||
}
|
||||
|
||||
/// Answer a park with a realm and remember it as [`Self::current`].
|
||||
pub(super) fn enter(&mut self, choice: &RealmChoice, name: String) {
|
||||
self.current = Some(name.clone());
|
||||
@@ -230,27 +237,32 @@ pub(crate) struct RealmSelectPlugin;
|
||||
|
||||
impl Plugin for RealmSelectPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Realms>().add_systems(
|
||||
Update,
|
||||
(
|
||||
apply_realm_policy,
|
||||
// Ungated by state: the dialog stands over login and character select alike.
|
||||
screen::drive_screen,
|
||||
// Input before the row refresh, so a click shows on the frame it landed.
|
||||
smoke::debug_realm_smoke,
|
||||
app.init_resource::<Realms>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
category::load_categories.after(benilla_assets::AssetSet::Open),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
input::clicks,
|
||||
input::keys,
|
||||
tick_refresh,
|
||||
screen::refresh_rows,
|
||||
apply_realm_policy,
|
||||
// Ungated by state: the dialog stands over login and character select alike.
|
||||
screen::drive_screen,
|
||||
// Input before the row refresh, so a click shows on the frame it landed.
|
||||
smoke::debug_realm_smoke,
|
||||
(
|
||||
input::clicks,
|
||||
input::keys,
|
||||
tick_refresh,
|
||||
screen::refresh_list,
|
||||
)
|
||||
.chain()
|
||||
.run_if(|realms: Res<Realms>| realms.shown),
|
||||
)
|
||||
.chain()
|
||||
.run_if(|realms: Res<Realms>| realms.shown),
|
||||
)
|
||||
.chain()
|
||||
.after(benilla_world::schedule::WorldStage::Net)
|
||||
.before(crate::glue::GlueVisuals),
|
||||
);
|
||||
.after(benilla_world::schedule::WorldStage::Net)
|
||||
.before(crate::glue::GlueVisuals),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,16 +280,6 @@ fn apply_realm_policy(
|
||||
for msg in msgs.read() {
|
||||
realms.realms = msg.realms.clone();
|
||||
realms.refresh_in = REFRESH_SECS;
|
||||
// Keep the tab across a refresh while it still exists, else the first.
|
||||
let cats = realms.categories();
|
||||
if !realms.category.is_some_and(|c| cats.contains(&c)) {
|
||||
realms.category = cats.first().copied();
|
||||
}
|
||||
|
||||
// A list published while the dialog is up is a refresh, never a question.
|
||||
if realms.shown {
|
||||
continue;
|
||||
}
|
||||
// The registered default is empty: a client that has never connected.
|
||||
let remembered = cvars
|
||||
.get(CVAR_REALM_NAME)
|
||||
@@ -286,6 +288,14 @@ fn apply_realm_policy(
|
||||
if realms.current.is_none() {
|
||||
realms.current = remembered.clone();
|
||||
}
|
||||
// The rebuild (`0x46e510`): the tab in front is kept as its ordinal, as the Lua keeps it.
|
||||
realms.store_current_category();
|
||||
realms.clamp_offset();
|
||||
|
||||
// A list published while the dialog is up is a refresh, never a question.
|
||||
if realms.shown {
|
||||
continue;
|
||||
}
|
||||
match auto_answer(
|
||||
&realms,
|
||||
remembered.as_deref(),
|
||||
@@ -383,11 +393,11 @@ mod tests {
|
||||
r.rows().iter().map(|&i| r.realms[i].name.clone()).collect()
|
||||
}
|
||||
|
||||
/// A list under the US client's categories.
|
||||
fn list(realms: Vec<RealmInfo>) -> Realms {
|
||||
let category = realms.first().map(|r| r.category);
|
||||
Realms {
|
||||
realms,
|
||||
category,
|
||||
categories: Some(category::tests::us()),
|
||||
..Realms::default()
|
||||
}
|
||||
}
|
||||
@@ -407,13 +417,15 @@ mod tests {
|
||||
assert!(!is_down(&sentinel) && !is_invalid(&sentinel));
|
||||
}
|
||||
|
||||
/// The load distribution spans every realm, so switching tabs relabels no band.
|
||||
/// The load distribution spans every realm, a realm on no tab included, so switching tabs
|
||||
/// relabels no band.
|
||||
#[test]
|
||||
fn a_category_tab_scopes_the_rows_but_not_the_load_distribution() {
|
||||
let mut r = list(vec![
|
||||
realm("Alpha", 1, 0, 0, 0, 1.0),
|
||||
realm("Beta", 1, 0, 0, 0, 1.0),
|
||||
realm("Gamma", 2, 0, 0, 0, 400.0),
|
||||
realm("Gamma", 5, 0, 0, 0, 400.0),
|
||||
realm("Berlin", 2, 0, 0, 0, 7.0),
|
||||
]);
|
||||
r.category = Some(1);
|
||||
assert_eq!(r.rows().len(), 2, "the tab scopes the rows");
|
||||
@@ -426,8 +438,8 @@ mod tests {
|
||||
mean_on_tab_one, mean_on_tab_two,
|
||||
"the distribution is global — the tab must not move it"
|
||||
);
|
||||
// (1 + 1 + 400) / 3.
|
||||
assert!((mean_on_tab_one - 134.0).abs() < 1e-3, "{mean_on_tab_one}");
|
||||
// (1 + 1 + 400 + 7) / 4: the category-2 realm is on no US tab and still counts.
|
||||
assert!((mean_on_tab_one - 102.25).abs() < 1e-3, "{mean_on_tab_one}");
|
||||
}
|
||||
|
||||
/// The reference hides the tab strip for a single category.
|
||||
@@ -437,7 +449,7 @@ mod tests {
|
||||
realm("Alpha", 1, 0, 0, 0, 1.0),
|
||||
realm("Beta", 1, 0, 0, 0, 1.0),
|
||||
]);
|
||||
assert_eq!(r.categories(), vec![1]);
|
||||
assert_eq!(r.tabs(), ["United States"]);
|
||||
}
|
||||
|
||||
/// The default order is characters, load, name, mode, all ascending; more characters first.
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
//! The realm list's layout: `GlueXML/RealmList.xml` in Bevy UI, on the 1024×768 glue scale.
|
||||
//!
|
||||
//! A full-screen dim, then the 640×512 `HelpFrame` plate (the AddOns list's) with the title plate,
|
||||
//! four sort headers, eighteen rows, the selection band, the close X, and Okay and Cancel. Rows are
|
||||
//! spawned empty and [`refresh_rows`] writes them every frame from [`super::Realms`].
|
||||
//! four sort headers, the eight category tabs under the plate, eighteen rows, the selection band,
|
||||
//! the close X, and Okay and Cancel. Rows and tabs are spawned empty and [`refresh_list`] writes
|
||||
//! them every frame from [`super::Realms`].
|
||||
//!
|
||||
//! The category tab strip is not built: `RealmList_UpdateTabs` hides it for a single category, and
|
||||
//! with several, only the first category's realms show.
|
||||
//! The tab strip shows only with two or more categories holding a realm (`RealmList_UpdateTabs`),
|
||||
//! one tab per category, and the rows are the front tab's.
|
||||
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, SORT_ARROW_TC};
|
||||
use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, REALM_TAB_TC, SORT_ARROW_TC};
|
||||
use crate::glue::widgets::{
|
||||
abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight,
|
||||
abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueDisabled, GlueText, Hilight,
|
||||
};
|
||||
use crate::glue_strings::GlueStrings;
|
||||
|
||||
use super::category::MAX_TABS;
|
||||
use super::load;
|
||||
use super::{Realms, SortKey};
|
||||
|
||||
@@ -67,6 +70,23 @@ const COL_TYPE: (f32, f32) = (235.0, 50.0);
|
||||
const COL_PLAYERS: (f32, f32) = (336.0, 32.0);
|
||||
const COL_LOAD: (f32, f32) = (418.0, 115.0);
|
||||
|
||||
/// `RealmListTab1`'s BOTTOMLEFT sits at the plate's BOTTOMLEFT (11, −15), so the 32-tall tabs hang
|
||||
/// below the plate's lower edge.
|
||||
const TAB_LEFT: f32 = 11.0;
|
||||
const TAB_H: f32 = 32.0;
|
||||
const TAB_TOP: f32 = BG_H + 15.0 - TAB_H;
|
||||
/// Each next tab's LEFT at the one before's RIGHT −15.
|
||||
const TAB_OVERLAP: f32 = 15.0;
|
||||
/// `$parentLeft` and `$parentRight`, 20 wide; `GlueTemplates_TabResize(0)` makes the middle the
|
||||
/// label's own width.
|
||||
const TAB_END: f32 = 20.0;
|
||||
/// `$parentLeftDisabled` at TOPLEFT (0, 3): the front tab's art stands 3 higher.
|
||||
const TAB_FRONT_RISE: f32 = 3.0;
|
||||
/// `$parentText` at CENTER (0, 2), and the highlight's two anchors at y 2.
|
||||
const TAB_TEXT_RISE: f32 = 2.0;
|
||||
/// `$parentHighlightTexture`, anchored 10 in from each side; its height is its art's, 32.
|
||||
const TAB_HILIGHT_INSET: f32 = 10.0;
|
||||
|
||||
/// Root of the realm list; `with_art` and `s` trigger a rebuild when the art lands or the scale
|
||||
/// changes.
|
||||
#[derive(Component)]
|
||||
@@ -87,6 +107,8 @@ pub(super) enum RealmAction {
|
||||
/// declares no sound) where Cancel plays `gsLoginChangeRealmCancel`.
|
||||
Close,
|
||||
Sort(SortKey),
|
||||
/// `RealmListTab<n>`: the category tab with that 1-based ordinal.
|
||||
Tab(usize),
|
||||
}
|
||||
|
||||
/// The screen row of a row button and of each of its four texts.
|
||||
@@ -107,6 +129,14 @@ pub(super) struct RowHighlight;
|
||||
/// The Okay button, so the refresh can grey it out.
|
||||
#[derive(Component)]
|
||||
pub(super) struct OkButton;
|
||||
/// A category tab's button, label and art pieces, by the tab's 1-based ordinal.
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub(super) struct TabOf(pub(super) usize);
|
||||
/// One of a tab's six art pieces: the front tab's `ActiveTab` set, or the others' `InActiveTab`.
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub(super) struct TabArt {
|
||||
front: bool,
|
||||
}
|
||||
|
||||
/// Spawn, rebuild and despawn the dialog as `shown` says, over whatever glue screen is current.
|
||||
pub(super) fn drive_screen(
|
||||
@@ -205,6 +235,7 @@ fn spawn_screen(
|
||||
spawn_header(b, art, &font, &text("SERVER_SELECTION"), s);
|
||||
spawn_close(b, art, &font, s);
|
||||
spawn_sort_headers(b, art, &font, strings, s);
|
||||
spawn_tabs(b, art, &font, s);
|
||||
spawn_highlight(b, art, s);
|
||||
for row in 0..MAX_ROWS {
|
||||
spawn_row(b, &font, row, s);
|
||||
@@ -442,6 +473,108 @@ fn spawn_sort_headers(
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmListTab1`..`8` (`RealmListTabButtonTemplate`), spawned hidden in a row that chains each
|
||||
/// tab 15 into the one before. A tab is its two 20-wide ends round a middle as wide as the label,
|
||||
/// in both art sets; [`paint_tabs`] labels them and picks the set.
|
||||
fn spawn_tabs(b: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>, s: f32) {
|
||||
let px = |v: f32| Val::Px(v * s);
|
||||
let mut strip = b.spawn(Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(TAB_LEFT),
|
||||
top: px(TAB_TOP),
|
||||
height: px(TAB_H),
|
||||
flex_direction: FlexDirection::Row,
|
||||
..default()
|
||||
});
|
||||
strip.with_children(|strip| {
|
||||
for ordinal in 1..=MAX_TABS {
|
||||
let overlap = if ordinal == 1 { 0.0 } else { -TAB_OVERLAP };
|
||||
let mut tab = strip.spawn((
|
||||
RealmAction::Tab(ordinal),
|
||||
TabOf(ordinal),
|
||||
Button,
|
||||
GlueDisabled(false),
|
||||
Visibility::Hidden,
|
||||
Node {
|
||||
height: px(TAB_H),
|
||||
flex_shrink: 0.0,
|
||||
margin: UiRect::left(px(overlap)),
|
||||
padding: UiRect::horizontal(px(TAB_END)),
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
));
|
||||
tab.with_children(|t| {
|
||||
for (front, sheet) in [(false, &art.tab_inactive), (true, &art.tab_active)] {
|
||||
let Some((tex, size)) = sheet else {
|
||||
continue;
|
||||
};
|
||||
let top = if front { -TAB_FRONT_RISE } else { 0.0 };
|
||||
for (piece, tc) in REALM_TAB_TC.iter().enumerate() {
|
||||
let (left, right, width) = match piece {
|
||||
0 => (px(0.0), Val::Auto, px(TAB_END)),
|
||||
1 => (px(TAB_END), px(TAB_END), Val::Auto),
|
||||
_ => (Val::Auto, px(0.0), px(TAB_END)),
|
||||
};
|
||||
t.spawn((
|
||||
TabOf(ordinal),
|
||||
TabArt { front },
|
||||
Visibility::Hidden,
|
||||
ImageNode {
|
||||
image: tex.clone(),
|
||||
rect: Some(tc_rect(*size, *tc)),
|
||||
..default()
|
||||
},
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left,
|
||||
right,
|
||||
width,
|
||||
top: px(top),
|
||||
height: px(TAB_H),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(hi) = &art.tab_highlight {
|
||||
t.spawn((
|
||||
Hilight,
|
||||
Visibility::Hidden,
|
||||
MaterialNode(hi.clone()),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(TAB_HILIGHT_INSET),
|
||||
right: px(TAB_HILIGHT_INSET),
|
||||
top: px(-TAB_TEXT_RISE),
|
||||
height: px(TAB_H),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
let label = outlined_text(
|
||||
t,
|
||||
Node {
|
||||
top: px(-TAB_TEXT_RISE),
|
||||
..default()
|
||||
},
|
||||
(),
|
||||
(),
|
||||
GlueText {
|
||||
text: "",
|
||||
size: 12.0, // GlueFontNormalSmall
|
||||
color: GOLD,
|
||||
wrap: false,
|
||||
},
|
||||
font,
|
||||
s,
|
||||
);
|
||||
t.commands().entity(label).insert(TabOf(ordinal));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `RealmListHighlight`: one band, moved to the selected row and vertex-coloured to match it.
|
||||
fn spawn_highlight(b: &mut ChildSpawnerCommands, art: &GlueArt, s: f32) {
|
||||
let mut band = b.spawn((
|
||||
@@ -503,10 +636,40 @@ fn spawn_row(b: &mut ChildSpawnerCommands, font: &Handle<Font>, row: usize, s: f
|
||||
});
|
||||
}
|
||||
|
||||
/// `RealmListUpdate`: write every visible row, each frame, since any realm's population moves
|
||||
/// every band.
|
||||
/// The tab strip's buttons, art pieces and labels, kept apart from the rows' by their markers.
|
||||
#[derive(SystemParam)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_rows(
|
||||
pub(super) struct TabNodes<'w, 's> {
|
||||
tabs: Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
&'static TabOf,
|
||||
&'static Interaction,
|
||||
&'static mut Visibility,
|
||||
&'static mut GlueDisabled,
|
||||
),
|
||||
(
|
||||
With<RealmAction>,
|
||||
Without<RowOf>,
|
||||
Without<OkButton>,
|
||||
Without<TabArt>,
|
||||
),
|
||||
>,
|
||||
arts: Query<
|
||||
'w,
|
||||
's,
|
||||
(&'static TabOf, &'static TabArt, &'static mut Visibility),
|
||||
(Without<RealmAction>, Without<RowHighlight>),
|
||||
>,
|
||||
labels:
|
||||
Query<'w, 's, (&'static TabOf, &'static mut Text, &'static mut TextColor), Without<RowOf>>,
|
||||
}
|
||||
|
||||
/// `RealmListUpdate`: the tab strip (`RealmList_UpdateTabs`), then every visible row, each frame,
|
||||
/// since any realm's population moves every band.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_list(
|
||||
realms: Res<Realms>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
mut rows: Query<
|
||||
@@ -518,9 +681,11 @@ pub(super) fn refresh_rows(
|
||||
(&mut Node, &mut Visibility, Option<&mut ImageNode>),
|
||||
(With<RowHighlight>, Without<RealmAction>),
|
||||
>,
|
||||
mut ok: Query<&mut crate::glue::widgets::GlueDisabled, With<OkButton>>,
|
||||
mut ok: Query<&mut GlueDisabled, With<OkButton>>,
|
||||
mut tab_nodes: TabNodes,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
paint_tabs(&realms, &mut tab_nodes);
|
||||
let s = crate::glue::screen_scale(window.single().ok());
|
||||
let visible = realms.rows();
|
||||
let (mean, stddev) = realms.stats();
|
||||
@@ -602,3 +767,202 @@ pub(super) fn refresh_rows(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmList_UpdateTabs` and `GlueTemplates_UpdateTabs`: one category hides the strip; two to
|
||||
/// eight show that many tabs, each labelled with its category's name. The front tab is
|
||||
/// `Disable()`d on the `ActiveTab` art in its `DisabledFont` (`GlueFontHighlightSmall`, white);
|
||||
/// the others stand on `InActiveTab` in gold, white with the highlight under the cursor.
|
||||
fn paint_tabs(realms: &Realms, nodes: &mut TabNodes) {
|
||||
let TabNodes { tabs, arts, labels } = nodes;
|
||||
let names = realms.tabs();
|
||||
let shown = if names.len() >= 2 { names.len() } else { 0 };
|
||||
let front = realms.category();
|
||||
let mut hovered = None;
|
||||
for (TabOf(ordinal), interaction, mut vis, mut disabled) in tabs.iter_mut() {
|
||||
let want = if *ordinal <= shown {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
let is_front = *ordinal == front;
|
||||
if disabled.0 != is_front {
|
||||
disabled.0 = is_front;
|
||||
}
|
||||
if !is_front && *interaction != Interaction::None {
|
||||
hovered = Some(*ordinal);
|
||||
}
|
||||
}
|
||||
for (TabOf(ordinal), art, mut vis) in arts.iter_mut() {
|
||||
let want = if art.front == (*ordinal == front) {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
}
|
||||
for (TabOf(ordinal), mut text, mut color) in labels.iter_mut() {
|
||||
let Some(name) = names.get(*ordinal - 1) else {
|
||||
continue;
|
||||
};
|
||||
if text.0 != *name {
|
||||
text.0 = name.to_string();
|
||||
}
|
||||
let c = if *ordinal == front || hovered == Some(*ordinal) {
|
||||
load::HIGHLIGHT
|
||||
} else {
|
||||
GOLD
|
||||
};
|
||||
if color.0 != c {
|
||||
color.0 = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::glue::{glue_clicks, GlueClicks};
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
fn realm(name: &str, category: u8) -> RealmInfo {
|
||||
RealmInfo {
|
||||
name: name.into(),
|
||||
address: "127.0.0.1:8085".into(),
|
||||
population: 1.0,
|
||||
characters: 0,
|
||||
realm_type: 0,
|
||||
flags: 0,
|
||||
category,
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab strip as [`spawn_tabs`] builds it, both art sets stood in, under the US client's
|
||||
/// categories, with the click and refresh passes the plugin runs.
|
||||
fn tab_app(list: &[(&str, u8)]) -> App {
|
||||
let mut app = App::new();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.init_resource::<Time>()
|
||||
.init_resource::<GlueClicks>()
|
||||
.insert_resource(crate::net::RealmChoice(tx))
|
||||
.add_message::<crate::sound::GlueSound>()
|
||||
.insert_resource(Realms {
|
||||
realms: list.iter().map(|&(n, c)| realm(n, c)).collect(),
|
||||
categories: Some(super::super::category::tests::us()),
|
||||
shown: true,
|
||||
..Realms::default()
|
||||
})
|
||||
.add_systems(
|
||||
Update,
|
||||
(glue_clicks, super::super::input::clicks, refresh_list).chain(),
|
||||
);
|
||||
let mut art = GlueArt::default();
|
||||
art.tab_active = Some((Handle::default(), Vec2::new(128.0, 32.0)));
|
||||
art.tab_inactive = Some((Handle::default(), Vec2::new(128.0, 32.0)));
|
||||
app.world_mut()
|
||||
.commands()
|
||||
.spawn(Node::default())
|
||||
.with_children(|b| spawn_tabs(b, &art, &Handle::default(), 1.0));
|
||||
app.world_mut().flush();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// Each shown tab's `(ordinal, label, front)`, in ordinal order.
|
||||
fn shown_tabs(app: &mut App) -> Vec<(usize, String, bool)> {
|
||||
let mut labels = app.world_mut().query::<(&TabOf, &Text)>();
|
||||
let labels: Vec<(usize, String)> = labels
|
||||
.iter(app.world())
|
||||
.map(|(t, text)| (t.0, text.0.clone()))
|
||||
.collect();
|
||||
let mut tabs = app
|
||||
.world_mut()
|
||||
.query::<(&TabOf, &Visibility, &GlueDisabled, &RealmAction)>();
|
||||
let mut out: Vec<(usize, String, bool)> = tabs
|
||||
.iter(app.world())
|
||||
.filter(|(_, vis, _, _)| **vis != Visibility::Hidden)
|
||||
.map(|(t, _, disabled, _)| {
|
||||
let label = labels.iter().find(|(o, _)| *o == t.0).unwrap().1.clone();
|
||||
(t.0, label, disabled.0)
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The front tab stands on the `ActiveTab` art alone, every other on `InActiveTab`.
|
||||
fn art_matches_front(app: &mut App, front: usize) -> bool {
|
||||
let mut arts = app.world_mut().query::<(&TabOf, &TabArt, &Visibility)>();
|
||||
arts.iter(app.world())
|
||||
.all(|(t, art, vis)| (*vis != Visibility::Hidden) == (art.front == (t.0 == front)))
|
||||
}
|
||||
|
||||
fn click(app: &mut App, ordinal: usize) {
|
||||
let mut tabs = app.world_mut().query::<(Entity, &TabOf, &RealmAction)>();
|
||||
let tab = tabs
|
||||
.iter(app.world())
|
||||
.find(|(_, t, _)| t.0 == ordinal)
|
||||
.unwrap()
|
||||
.0;
|
||||
for interaction in [Interaction::Pressed, Interaction::Hovered] {
|
||||
*app.world_mut().get_mut::<Interaction>(tab).unwrap() = interaction;
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(app: &App) -> Vec<String> {
|
||||
let realms = app.world().resource::<Realms>();
|
||||
realms
|
||||
.rows()
|
||||
.iter()
|
||||
.map(|&i| realms.realms[i].name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One category holding a realm, a byte-0 realm folded in: `RealmList_UpdateTabs` hides all.
|
||||
#[test]
|
||||
fn one_category_hides_the_whole_strip() {
|
||||
let mut app = tab_app(&[("Alpha", 1), ("Dev", 0)]);
|
||||
assert!(shown_tabs(&mut app).is_empty());
|
||||
assert_eq!(rows(&app), ["Alpha", "Dev"]);
|
||||
}
|
||||
|
||||
/// Two categories: two tabs named from `Cfg_Categories.dbc`, the first in front.
|
||||
#[test]
|
||||
fn two_categories_show_two_named_tabs_the_first_in_front() {
|
||||
let mut app = tab_app(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
assert_eq!(
|
||||
shown_tabs(&mut app),
|
||||
[
|
||||
(1, "United States".to_string(), true),
|
||||
(2, "Oceanic".to_string(), false)
|
||||
]
|
||||
);
|
||||
assert!(art_matches_front(&mut app, 1));
|
||||
assert_eq!(rows(&app), ["Alpha"]);
|
||||
}
|
||||
|
||||
/// A click on the second tab fronts it and lists its realms; the front tab takes no click.
|
||||
#[test]
|
||||
fn a_tab_click_fronts_the_tab_and_relists() {
|
||||
let mut app = tab_app(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
click(&mut app, 2);
|
||||
assert_eq!(rows(&app), ["Down Under"]);
|
||||
assert_eq!(
|
||||
shown_tabs(&mut app),
|
||||
[
|
||||
(1, "United States".to_string(), false),
|
||||
(2, "Oceanic".to_string(), true)
|
||||
]
|
||||
);
|
||||
assert!(art_matches_front(&mut app, 2));
|
||||
|
||||
click(&mut app, 1);
|
||||
assert_eq!(rows(&app), ["Alpha"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ pub(crate) enum TargetedBind {
|
||||
Item(u64),
|
||||
/// The world click's GameObject guid: a chest, door, vein or herb.
|
||||
Object(u64),
|
||||
/// The world click's unit guid.
|
||||
Unit(u64),
|
||||
/// The world click's corpse guid and the corpse bit `BindTarget`'s corpse arm bound.
|
||||
Corpse(u64, benilla_protocol::messages::CorpseTarget),
|
||||
}
|
||||
|
||||
impl CastLadder<'_, '_> {
|
||||
@@ -126,6 +130,15 @@ impl CastLadder<'_, '_> {
|
||||
TargetedBind::Object(go_guid) => {
|
||||
ClientCommand::CastSpellGameObject { spell_id, go_guid }
|
||||
}
|
||||
TargetedBind::Unit(unit_guid) => ClientCommand::CastSpell {
|
||||
spell_id,
|
||||
target: Some(unit_guid),
|
||||
},
|
||||
TargetedBind::Corpse(corpse_guid, target) => ClientCommand::CastSpellCorpse {
|
||||
spell_id,
|
||||
target,
|
||||
corpse_guid,
|
||||
},
|
||||
},
|
||||
CastCommit::Item {
|
||||
bag_index,
|
||||
@@ -141,6 +154,8 @@ impl CastLadder<'_, '_> {
|
||||
TargetedBind::Source(src) => UseItemTarget::Source(src),
|
||||
TargetedBind::Item(guid) => UseItemTarget::Item(guid),
|
||||
TargetedBind::Object(guid) => UseItemTarget::Object(guid),
|
||||
TargetedBind::Unit(guid) => UseItemTarget::Unit(guid),
|
||||
TargetedBind::Corpse(guid, target) => UseItemTarget::Corpse(target, guid),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -404,18 +419,7 @@ fn send_spell_cast(
|
||||
if let Some(d) = def {
|
||||
if target.is_some() && target == ctx.selection_guid && target != ctx.self_guid {
|
||||
let row = spells.and_then(|s| s.ranges.get(d.range_index));
|
||||
let dist_sq = ctx
|
||||
.range
|
||||
.self_pos
|
||||
.zip(ctx.range.target_pos)
|
||||
.map(|(a, b)| a.distance_squared(b));
|
||||
if let Some(reason) = validator::cast_range_refusal(
|
||||
d,
|
||||
row,
|
||||
ctx.range.self_reach,
|
||||
ctx.range.target_reach,
|
||||
dist_sq,
|
||||
) {
|
||||
if let Some(reason) = ctx.range.refusal(d, row) {
|
||||
debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})");
|
||||
cast_errors.push_local(spell_id, reason);
|
||||
return;
|
||||
@@ -723,6 +727,7 @@ mod tests {
|
||||
auto_self_cast: false,
|
||||
rel: cast_target::TargetRelations {
|
||||
target_store: None,
|
||||
target_owner_store: None,
|
||||
self_store: None,
|
||||
factions: None,
|
||||
reputations: &EMPTY_REPUTATIONS,
|
||||
|
||||
@@ -9,18 +9,20 @@
|
||||
//! - otherwise each bit is cleared against the selection by its relation check, then against the
|
||||
//! player behind `autoSelfCast` (`0x6e53d7`); only a fully cleared word commits, as a unit guid.
|
||||
//! - a word with item, lock, GameObject or location bits enters the targeting cursor carrying
|
||||
//! the whole word: its three predicates (`0x6e6320` `& 0x60`, `0x6e6330` `& 0x4010`, `0x6e62d0`
|
||||
//! `& 0x4800`) can hold at once, and the click picks the leg.
|
||||
//! the whole word: its location (`0x6e6320`, `& 0x60`), item (`0x6e6330`, `& 0x4010`),
|
||||
//! GameObject (`0x6e62d0`, `& 0x4800`) and unit (`0x6e6460`) predicates can hold at once, and
|
||||
//! the click picks the leg.
|
||||
//!
|
||||
//! Not built: the unit hand cursor the reference enters for a residual unit word, and the STRING
|
||||
//! bit 13; both refuse locally with the client's "You have no target." or "Invalid target".
|
||||
//! The unit hand cursor receives a residual unit word without the enemy bit. A word carrying
|
||||
//! `0x80` instead raises the local no-target/invalid-target refusal; STRING bit 13 remains
|
||||
//! unmodeled and refuses locally with "Invalid target".
|
||||
|
||||
use benilla_formats::SpellDisplay;
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{ObjectStore, Reputations, SelfGuid, SelfPlayer};
|
||||
use crate::target::{can_attack, ring_reaction, Factions, Selection};
|
||||
use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid, SelfPlayer};
|
||||
use crate::target::{can_assist, can_attack, Factions, Selection};
|
||||
|
||||
/// `TARGET_FLAG_*` bits of the client's targeting flag word (`0xcecac0`).
|
||||
const TF_UNIT: u16 = 0x0002;
|
||||
@@ -41,8 +43,7 @@ const UNIT_BITS: u16 = TF_UNIT
|
||||
| TF_EXPLICIT_GATE
|
||||
| TF_CORPSE_ALLY;
|
||||
|
||||
/// Client cast-failed reasons "You have no target." and "Invalid target", raised locally where
|
||||
/// the reference would enter its unit targeting cursor.
|
||||
/// Client cast-failed reasons used when an enemy word cannot bind.
|
||||
pub(crate) const ERR_NO_TARGET: u8 = 0x09;
|
||||
pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A;
|
||||
|
||||
@@ -82,8 +83,7 @@ pub(crate) enum CastWireTarget {
|
||||
/// `IsTargeting 0x6e48a0`). Each click seam tests the word with its own mask, so one word can
|
||||
/// serve several: Opening and Pick Lock take a bag item or a world GameObject.
|
||||
Targeting(u16),
|
||||
/// Do not send; show this client error. The reference raises nothing here: it enters its unit
|
||||
/// targeting cursor, which is not built.
|
||||
/// Do not send; show this client error.
|
||||
Refused(u8),
|
||||
/// A refusal the reference raises in its cast tail after `ArmCast` returns false (`0x6e5045`).
|
||||
/// That tail runs after the requirement validator `0x6094f0`, so it fires at the cursor-entry
|
||||
@@ -108,6 +108,8 @@ pub(crate) struct CastCandidates {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct TargetRelations<'a> {
|
||||
pub(crate) target_store: Option<&'a ObjectStore>,
|
||||
/// The target's charmer, creator or summoner store, for `CanAssist`'s `IsPvP` owner chase.
|
||||
pub(crate) target_owner_store: Option<&'a ObjectStore>,
|
||||
pub(crate) self_store: Option<&'a ObjectStore>,
|
||||
pub(crate) factions: Option<&'a Factions>,
|
||||
pub(crate) reputations: &'a Reputations,
|
||||
@@ -140,6 +142,24 @@ pub(crate) struct RangeInputs {
|
||||
pub(crate) target_reach: Option<f32>,
|
||||
}
|
||||
|
||||
impl RangeInputs {
|
||||
/// The one range compare: the squared 3D distance against `GetMinMaxRange 0x6e3480`'s bounds,
|
||||
/// "Out of range." (0x59) past max², "Target too close" (0x76) inside a nonzero min². The
|
||||
/// pre-send gate (`IsTargetInRange 0x6e47b0`) and the targeting cursor's unit leg (`0x6e6460`,
|
||||
/// `6e677c`–`6e6802`) both ask it. No row or no position passes; the server judges.
|
||||
pub(crate) fn refusal(
|
||||
&self,
|
||||
def: &SpellDisplay,
|
||||
row: Option<&benilla_formats::SpellRange>,
|
||||
) -> Option<u8> {
|
||||
let dist_sq = self
|
||||
.self_pos
|
||||
.zip(self.target_pos)
|
||||
.map(|(a, b)| a.distance_squared(b));
|
||||
super::validator::cast_range_refusal(def, row, self.self_reach, self.target_reach, dist_sq)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RangeInputs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -158,6 +178,7 @@ pub(crate) struct CastTargeting<'w, 's> {
|
||||
pub(crate) selection: Res<'w, Selection>,
|
||||
pub(crate) self_store: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
|
||||
stores: Query<'w, 's, &'static ObjectStore>,
|
||||
index: Option<Res<'w, GuidIndex>>,
|
||||
self_guid: Res<'w, SelfGuid>,
|
||||
auto_self_cast: Res<'w, AutoSelfCast>,
|
||||
factions: Option<Res<'w, Factions>>,
|
||||
@@ -173,12 +194,21 @@ impl CastTargeting<'_, '_> {
|
||||
/// This frame's [`CastContext`].
|
||||
pub(crate) fn context(&self) -> CastContext<'_> {
|
||||
let target_store = self.selection.target.and_then(|e| self.stores.get(e).ok());
|
||||
let target_owner_store = target_store
|
||||
.and_then(|store| {
|
||||
store
|
||||
.0
|
||||
.unit_owner(benilla_protocol::messages::OwnerFallback::CreatedBy)
|
||||
})
|
||||
.and_then(|guid| self.index.as_ref()?.0.get(&guid).copied())
|
||||
.and_then(|entity| self.stores.get(entity).ok());
|
||||
CastContext {
|
||||
selection_guid: self.selection.guid,
|
||||
self_guid: self.self_guid.0,
|
||||
auto_self_cast: self.auto_self_cast.0,
|
||||
rel: TargetRelations {
|
||||
target_store,
|
||||
target_owner_store,
|
||||
self_store: self.self_store.iter().next(),
|
||||
factions: self.factions.as_deref(),
|
||||
reputations: &self.reputations,
|
||||
@@ -212,9 +242,7 @@ impl CastTargeting<'_, '_> {
|
||||
const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
|
||||
|
||||
/// The `autoSelfCast` CVar (name `0x870dc0`, read at `0x6e53d7`; reference default `"0"`).
|
||||
/// Deviation: defaults on, because with it off an unbindable friendly cast needs the unit
|
||||
/// targeting cursor, which is not built.
|
||||
#[derive(bevy::prelude::Resource)]
|
||||
#[derive(bevy::prelude::Resource, Default)]
|
||||
pub(crate) struct AutoSelfCast(pub(crate) bool);
|
||||
|
||||
/// `autoSelfCast`'s change callback: a flag.
|
||||
@@ -224,12 +252,6 @@ pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut auto: ResMut<AutoSe
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoSelfCast {
|
||||
fn default() -> Self {
|
||||
Self(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag word: `Targets`, then one arm keyed on `EffectImplicitTargetA[0]` (`0x6e525a`-
|
||||
/// `0x6e52ef`); every implicit target not matched below leaves the word as it is.
|
||||
pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 {
|
||||
@@ -253,18 +275,20 @@ pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 {
|
||||
|
||||
/// `BindTarget 0x6e5b40`'s unit branch: clear every flag-word bit the candidate satisfies.
|
||||
///
|
||||
/// The reference asks `CanAssist 0x6066f0` for the assist bit; this uses reaction alone (>= 4).
|
||||
/// The assist bit asks `CanAssist 0x6066f0`: selectable, friendly or better, and an NPC's owner
|
||||
/// (or the NPC itself) PvP-enabled. This keeps friendly ambient NPCs and critters unbindable.
|
||||
/// The enemy bit asks `CanAttack` (`0x606980`). Party and raid (`0x606c20`, `0x606d20`) accept
|
||||
/// only the player; the corpse check (`0x6067d0`) is assistable with health 0 here.
|
||||
fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 {
|
||||
let mut word = word;
|
||||
let reaction = ring_reaction(
|
||||
rel.factions,
|
||||
rel.reputations,
|
||||
rel.target_store,
|
||||
rel.self_store,
|
||||
);
|
||||
let assist = is_self || reaction >= 4;
|
||||
let assist = is_self
|
||||
|| can_assist(
|
||||
rel.target_store,
|
||||
rel.factions,
|
||||
rel.reputations,
|
||||
rel.self_store,
|
||||
|_| rel.target_owner_store.cloned(),
|
||||
);
|
||||
let dead = rel
|
||||
.target_store
|
||||
.is_some_and(|s| s.0.unit_health() == Some(0));
|
||||
@@ -304,6 +328,12 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16
|
||||
word
|
||||
}
|
||||
|
||||
/// Whether `BindTarget 0x6e5b40`'s unit arm clears the whole standing word for this unit.
|
||||
/// The initial selection, a world click and `SpellTargetUnit` all use this one predicate.
|
||||
pub(super) fn unit_word_binds(word: u16, is_self: bool, rel: &TargetRelations) -> bool {
|
||||
word & UNIT_BITS != 0 && clear_satisfied_bits(word, is_self, rel) == 0
|
||||
}
|
||||
|
||||
/// The wire target for casting `def`. An unknown spell sends the selection as is, or no target
|
||||
/// without one, and the server validates.
|
||||
pub(crate) fn resolve_cast_target(
|
||||
@@ -361,7 +391,7 @@ pub(crate) fn resolve_cast_target(
|
||||
// The selection (`0xb4e2d8`, `0x6e539f`).
|
||||
if let Some(guid) = cand.selection {
|
||||
let is_self = cand.caster == Some(guid);
|
||||
if clear_satisfied_bits(word, is_self, rel) == 0 {
|
||||
if unit_word_binds(word, is_self, rel) {
|
||||
return CastWireTarget::Unit(guid);
|
||||
}
|
||||
}
|
||||
@@ -370,19 +400,25 @@ pub(crate) fn resolve_cast_target(
|
||||
if let Some(guid) = cand.caster {
|
||||
let self_rel = TargetRelations {
|
||||
target_store: rel.self_store,
|
||||
target_owner_store: None,
|
||||
..*rel
|
||||
};
|
||||
if clear_satisfied_bits(word, true, &self_rel) == 0 {
|
||||
if unit_word_binds(word, true, &self_rel) {
|
||||
return CastWireTarget::Unit(guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reference enters its unit targeting cursor here; not built, so refuse.
|
||||
CastWireTarget::Refused(if cand.selection.is_some() {
|
||||
ERR_INVALID_TARGET
|
||||
// A standing enemy word refuses locally. Every other residual unit word proceeds to
|
||||
// `BindTarget`'s hand cursor.
|
||||
if word & TF_UNIT_ENEMY != 0 {
|
||||
CastWireTarget::Refused(if cand.selection.is_some() {
|
||||
ERR_INVALID_TARGET
|
||||
} else {
|
||||
ERR_NO_TARGET
|
||||
})
|
||||
} else {
|
||||
ERR_NO_TARGET
|
||||
})
|
||||
CastWireTarget::Targeting(word)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -406,6 +442,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_self_cast_boots_at_the_reference_default() {
|
||||
assert!(
|
||||
!AutoSelfCast::default().0,
|
||||
"autoSelfCast defaults to the reference's disabled state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_follows_the_arm_map() {
|
||||
assert_eq!(cast_target_mask(&spell(0, 1)), 0, "Ice Armor: self");
|
||||
@@ -442,6 +486,7 @@ mod tests {
|
||||
let it = crate::net::ObjectStore(ObjectFields::from_pairs(&[(35, 0)]));
|
||||
let rel = TargetRelations {
|
||||
target_store: Some(&it),
|
||||
target_owner_store: None,
|
||||
self_store: Some(&me),
|
||||
factions: None,
|
||||
reputations: &Reputations(Vec::new()),
|
||||
@@ -455,7 +500,8 @@ mod tests {
|
||||
let fireball = spell(0, 6);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&fireball), &cands(None, Some(1)), true, &rel),
|
||||
CastWireTarget::Refused(ERR_NO_TARGET)
|
||||
CastWireTarget::Refused(ERR_NO_TARGET),
|
||||
"enemy words retain the no-target refusal"
|
||||
);
|
||||
// Neutral (3) is attackable by the mixed arm's `< 4` and not assistable by `>= 4`.
|
||||
assert_eq!(
|
||||
@@ -463,6 +509,10 @@ mod tests {
|
||||
CastWireTarget::Unit(42)
|
||||
);
|
||||
let intellect = spell(0, 21);
|
||||
assert!(
|
||||
!unit_word_binds(TF_UNIT_ASSIST, false, &rel),
|
||||
"the cursor's unit binder must reject the same neutral unit as the initial cast arm"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), true, &rel),
|
||||
CastWireTarget::Unit(1),
|
||||
@@ -470,8 +520,8 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), false, &rel),
|
||||
CastWireTarget::Refused(ERR_INVALID_TARGET),
|
||||
"autoSelfCast off: the fallback is gated"
|
||||
CastWireTarget::Targeting(TF_UNIT_ASSIST),
|
||||
"autoSelfCast off leaves the friendly word for the unit cursor"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), &cands(None, Some(1)), true, &rel),
|
||||
@@ -490,6 +540,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assist_word_requires_the_npc_pvp_flag_not_only_a_friendly_reaction() {
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let factions = Factions::from_catalog(
|
||||
benilla_formats::load_faction_catalog(&mut chain).expect("FactionTemplate.dbc"),
|
||||
);
|
||||
// Faction templates 35 and 1 are a real friendly pair. Field 46 is UNIT_FIELD_FLAGS.
|
||||
let me = ObjectStore(ObjectFields::from_pairs(&[(35, 1), (46, 0x8)]));
|
||||
let quiet = ObjectStore(ObjectFields::from_pairs(&[(35, 35), (46, 0)]));
|
||||
let pvp = ObjectStore(ObjectFields::from_pairs(&[(35, 35), (46, 0x1000)]));
|
||||
let reputations = Reputations(Vec::new());
|
||||
let rel = |target| TargetRelations {
|
||||
target_store: Some(target),
|
||||
target_owner_store: None,
|
||||
self_store: Some(&me),
|
||||
factions: Some(&factions),
|
||||
reputations: &reputations,
|
||||
};
|
||||
|
||||
assert!(
|
||||
!unit_word_binds(TF_UNIT_ASSIST, false, &rel(&quiet)),
|
||||
"a friendly ambient NPC is not assistable"
|
||||
);
|
||||
assert!(
|
||||
unit_word_binds(TF_UNIT_ASSIST, false, &rel(&pvp)),
|
||||
"the same friendly NPC becomes assistable with UNIT_FLAG_PVP"
|
||||
);
|
||||
}
|
||||
|
||||
/// Flamestrike (implicit 16) defers even with a selection, Blizzard (implicit 28) by its
|
||||
/// bare DEST word; a word of 0 still commits first (`0x6e5338`).
|
||||
#[test]
|
||||
@@ -660,6 +742,7 @@ mod tests {
|
||||
static EMPTY: Reputations = Reputations(Vec::new());
|
||||
TargetRelations {
|
||||
target_store: None,
|
||||
target_owner_store: None,
|
||||
self_store: None,
|
||||
factions: None,
|
||||
reputations: &EMPTY,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::ClientState;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
use crate::ui_unit::UnitFeed;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
@@ -32,7 +32,9 @@ pub(crate) use inflight::{
|
||||
};
|
||||
pub(crate) use mods::{SpellModifiers, OP_COST};
|
||||
// `TargetingWants` is exported for the ground reticle, which draws for the location word alone.
|
||||
pub(crate) use targeting::{ground_cast_radius, SpellTargeting, TargetingWants};
|
||||
pub(crate) use targeting::{
|
||||
ground_cast_radius, CorpsePick, PicksSelf, SpellTargeting, TargetingWants,
|
||||
};
|
||||
|
||||
/// The local self-cancel's set: a reader of the in-flight state orders `.after(LocalCancel)` so a
|
||||
/// cast ended by a move, jump or Esc drops its cast bar the same frame.
|
||||
@@ -53,6 +55,8 @@ impl Plugin for SpellPlugin {
|
||||
.init_resource::<AutoSelfCast>()
|
||||
.init_resource::<SpellTargeting>()
|
||||
.init_resource::<targeting::EnchantConfirmItem>()
|
||||
.init_resource::<targeting::PicksSelf>()
|
||||
.init_resource::<targeting::CorpsePick>()
|
||||
.add_observer(cast_target::on_cvar)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -65,7 +69,16 @@ impl Plugin for SpellPlugin {
|
||||
.before(UnitFeed),
|
||||
// The state push runs before the input pass's `ToggleGameMenu` and the drain
|
||||
// after it, so an Esc cancel lands before next frame's cursor reads the mode.
|
||||
targeting::feed_targeting_to_vm.in_set(UnitFeed),
|
||||
// After the old-target clear, the pet bar's writer in the feed: `"pet"`
|
||||
// resolves off the bar.
|
||||
targeting::feed_targeting_to_vm
|
||||
.in_set(UnitFeed)
|
||||
.after(crate::ui_pet::pet_stop_on_old_target_clear),
|
||||
(
|
||||
targeting::publish_picks_self,
|
||||
targeting::publish_corpse_pick,
|
||||
)
|
||||
.in_set(UiFeed),
|
||||
targeting::drain_stop_targeting.after(UiInput),
|
||||
// The item-target commit (`0x495d60`): after the input pass so a bag click
|
||||
// binds the same frame; outside the target chain, as its clicks never reach
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! The targeting cursor's corpse leg: a released player's corpse under a word carrying a corpse
|
||||
//! bit, `0x8000` (Resurrection, Redemption, Rebirth) or `0x200`. A dead player who has not released
|
||||
//! is a unit, and the unit leg takes him.
|
||||
//!
|
||||
//! - The pick ([`pick_admits`]): `0x480816` admits a corpse only with pick flag `0x40`, which while
|
||||
//! targeting only a word in `0x8600` sets (`0x6e6230`), and then only a corpse `0x6e6260` passes.
|
||||
//! - The cursor ([`super::SpellTargeting::can_target_corpse`]): `0x6e6460`'s corpse leg
|
||||
//! (`6e6719`–`6e676d`), then its range tail.
|
||||
//! - The click ([`bind_target_corpse`]): `BindTarget 0x6e5b40`'s corpse arm (`6e5f89`–`6e6020`),
|
||||
//! then the merge's range test, as for a unit.
|
||||
//!
|
||||
//! All three read two facts of the corpse, [`CorpseFacts`]: bones are never a spell target, and the
|
||||
//! reaction gate `0x6067d0` splits the ally bit from the enemy bit.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::messages::CorpseTarget;
|
||||
|
||||
use crate::net::ObjectStore;
|
||||
use crate::spell::cast_send::TargetedBind;
|
||||
use crate::target::Factions;
|
||||
|
||||
/// `TARGET_FLAG_CORPSE_ALLY` and `TARGET_FLAG_CORPSE_ENEMY`, as the flag word carries them.
|
||||
const WORD_ALLY: u16 = 0x8000;
|
||||
const WORD_ENEMY: u16 = 0x0200;
|
||||
|
||||
/// What the corpse legs read of a corpse.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct CorpseFacts {
|
||||
/// `CORPSE_FIELD_FLAGS & 1`, `CORPSE_FLAG_BONES` (`[desc+0x74]`).
|
||||
pub(crate) bones: bool,
|
||||
/// `0x6067d0(caster, corpse)`, [`crate::target::corpse_friendly`].
|
||||
pub(crate) friendly: bool,
|
||||
}
|
||||
|
||||
impl CorpseFacts {
|
||||
pub(super) fn of(
|
||||
store: &ObjectStore,
|
||||
factions: Option<&Factions>,
|
||||
self_store: Option<&ObjectStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bones: store.0.corpse_is_bones(),
|
||||
friendly: crate::target::corpse_friendly(store, factions, self_store),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`pick_admits`] over a corpse's store, for [`crate::target`]'s hover.
|
||||
pub(crate) fn corpse_pick_admits(
|
||||
word: u16,
|
||||
store: &ObjectStore,
|
||||
factions: Option<&Factions>,
|
||||
self_store: Option<&ObjectStore>,
|
||||
) -> bool {
|
||||
pick_admits(word, CorpseFacts::of(store, factions, self_store))
|
||||
}
|
||||
|
||||
/// `0x6e6260`, the pick's corpse filter while targeting: a word with the ally bit takes a corpse
|
||||
/// that is not bones and that `0x6067d0` passes (`6e6291`–`6e62a7`); only a word without it tries
|
||||
/// the enemy bit, on a corpse that is not bones and that `0x6067d0` fails (`6e62ad`–`6e62c6`).
|
||||
/// Both bits are inside `0x8600`, so a corpse this admits was already a candidate (`0x6e6230`).
|
||||
fn pick_admits(word: u16, f: CorpseFacts) -> bool {
|
||||
if word & WORD_ALLY != 0 {
|
||||
!f.bones && f.friendly
|
||||
} else {
|
||||
word & WORD_ENEMY != 0 && !f.bones && !f.friendly
|
||||
}
|
||||
}
|
||||
|
||||
/// `BindTarget 0x6e5b40`'s corpse arm: the ally arm (`6e5f97`–`6e5fda`) binds a corpse that is not
|
||||
/// bones and that `0x6067d0` passes, then the enemy arm (`6e5fdc`–`6e6020`) one it fails. Each
|
||||
/// writes its bit to the wire mask (`6e5fc7`, `6e600c`), parks the corpse's guid and clears its bit
|
||||
/// from the word. `None` binds nothing, silently, and the cursor stays (`6e6026`). The read-only
|
||||
/// mirror `0x6e6460` (`6e6719`–`6e676d`) passes exactly the corpses this binds.
|
||||
fn bind_arm(word: u16, f: CorpseFacts) -> Option<CorpseTarget> {
|
||||
if f.bones {
|
||||
None
|
||||
} else if word & WORD_ALLY != 0 && f.friendly {
|
||||
Some(CorpseTarget::Ally)
|
||||
} else if word & WORD_ENEMY != 0 && !f.friendly {
|
||||
Some(CorpseTarget::Enemy)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The world pick's corpse admission as the frame began, which [`crate::target`]'s hover reads:
|
||||
/// `None` outside targeting, where the pick flags carry `0x5c` and so `0x40`, and every corpse is a
|
||||
/// candidate (`0x480816`); while targeting, the standing word, through [`pick_admits`].
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct CorpsePick(pub(crate) Option<u16>);
|
||||
|
||||
/// Publish [`CorpsePick`] before the input pass, beside [`super::PicksSelf`].
|
||||
pub(crate) fn publish_corpse_pick(
|
||||
targeting: Res<super::SpellTargeting>,
|
||||
mut pick: ResMut<CorpsePick>,
|
||||
) {
|
||||
let now = targeting.0.as_ref().map(|t| t.word);
|
||||
if pick.0 != now {
|
||||
pick.0 = now;
|
||||
}
|
||||
}
|
||||
|
||||
impl super::SpellTargeting {
|
||||
/// `0x6e6460`'s corpse leg with the range flag the hover passes (`48290b`): [`bind_arm`]'s
|
||||
/// test, then min² ≤ d² ≤ max² (`6e668c`).
|
||||
pub(crate) fn can_target_corpse(&self, entity: Entity, checks: &super::BindChecks) -> bool {
|
||||
self.0.as_ref().is_some_and(|t| {
|
||||
checks
|
||||
.corpse_facts(entity)
|
||||
.is_some_and(|f| bind_arm(t.word, f).is_some())
|
||||
&& checks.corpse_range_refusal(t.spell_id, entity).is_none()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `BindTarget 0x6e5b40`'s corpse arm for the world click (`0x493540` at `4935d5`): a corpse no
|
||||
/// arm binds leaves the cursor up; otherwise the merge's range test and the commit, with the
|
||||
/// corpse's guid and its bit (`0x7e4e10` writes the guid for any mask bit in `0x8a02`).
|
||||
///
|
||||
/// The reference sends only once the word is empty (`6e60c1`), so a word the corpse bit does not
|
||||
/// empty waits for another click with the corpse parked. No player spell has one (every shipped
|
||||
/// `0x8000` word with another bit is an NPC or test spell), and as the unit arm does, this binds
|
||||
/// only a click that empties the word.
|
||||
pub(super) fn bind_target_corpse(
|
||||
ladder: &mut crate::spell::CastLadder,
|
||||
checks: &super::BindChecks,
|
||||
entity: Entity,
|
||||
guid: u64,
|
||||
) {
|
||||
let Some((spell_id, commit, word)) = ladder.ground.pending() else {
|
||||
return;
|
||||
};
|
||||
let Some(bit) = checks.corpse_facts(entity).and_then(|f| bind_arm(word, f)) else {
|
||||
return;
|
||||
};
|
||||
if word & !bit.target_flag() != 0 {
|
||||
return;
|
||||
}
|
||||
let range = checks.corpse_range_refusal(spell_id, entity);
|
||||
super::merge(
|
||||
ladder,
|
||||
spell_id,
|
||||
commit,
|
||||
range,
|
||||
TargetedBind::Corpse(guid, bit),
|
||||
);
|
||||
}
|
||||
|
||||
/// A human caster, and a human corpse and an orc corpse toward him, on the shipped rows of
|
||||
/// `FactionTemplate.dbc` 1 and 2 and `ChrRaces.dbc` races 1 and 2.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod fixture {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_formats::{FactionCatalog, FactionTemplate};
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
use crate::net::ObjectStore;
|
||||
use crate::target::Factions;
|
||||
|
||||
pub(crate) const HUMAN: u8 = 1;
|
||||
pub(crate) const ORC: u8 = 2;
|
||||
/// Resurrection rank 1: `Targets 0x8000`, range row 4.
|
||||
pub(crate) const RESURRECTION: u32 = 2006;
|
||||
/// A corpse guid, `HIGHGUID_CORPSE` 0xF101 (vmangos `ObjectGuid.h:76`).
|
||||
pub(crate) const CORPSE: u64 = 0xF101_0000_0000_002A;
|
||||
|
||||
/// Templates 1 (Human) and 2 (Orc): group, friend and enemy masks 3/2/12 and 5/4/10.
|
||||
pub(crate) fn factions() -> Factions {
|
||||
let tpl = |faction, group_mask, friend_group_mask, enemy_group_mask| FactionTemplate {
|
||||
faction,
|
||||
group_mask,
|
||||
friend_group_mask,
|
||||
enemy_group_mask,
|
||||
enemies: [0; 4],
|
||||
friends: [0; 4],
|
||||
};
|
||||
Factions::from_catalog(FactionCatalog::from_rows(
|
||||
HashMap::from([(1, tpl(1, 3, 2, 12)), (2, tpl(2, 5, 4, 10))]),
|
||||
HashMap::from([(HUMAN, 1), (ORC, 2)]),
|
||||
))
|
||||
}
|
||||
|
||||
/// A human caster, template 1, with this `UNIT_FIELD_COMBATREACH` (field 130).
|
||||
pub(crate) fn caster(reach: f32) -> ObjectStore {
|
||||
ObjectStore(ObjectFields::from_pairs(&[(35, 1), (130, reach.to_bits())]))
|
||||
}
|
||||
|
||||
/// A released player's corpse: the race in `CORPSE_FIELD_BYTES_1` byte 1 (field 32) and
|
||||
/// `CORPSE_FLAG_BONES` in `CORPSE_FIELD_FLAGS` (field 35).
|
||||
pub(crate) fn corpse(race: u8, bones: bool) -> ObjectStore {
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(32, u32::from(race) << 8),
|
||||
(35, u32::from(bones)),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Resurrection on range row 4, 0 to 30 yd.
|
||||
pub(crate) fn spells() -> crate::ui_action::Spells {
|
||||
let mut spells = crate::ui_action::Spells::empty_for_tests();
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([(
|
||||
RESURRECTION,
|
||||
benilla_formats::SpellDisplay {
|
||||
range_index: 4,
|
||||
targets: 0x8000,
|
||||
..Default::default()
|
||||
},
|
||||
)]));
|
||||
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([(
|
||||
4,
|
||||
benilla_formats::SpellRange {
|
||||
min: 0.0,
|
||||
max: 30.0,
|
||||
flags: 0,
|
||||
},
|
||||
)]));
|
||||
spells
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BONES: CorpseFacts = CorpseFacts {
|
||||
bones: true,
|
||||
friendly: true,
|
||||
};
|
||||
const ALLY: CorpseFacts = CorpseFacts {
|
||||
bones: false,
|
||||
friendly: true,
|
||||
};
|
||||
const ENEMY: CorpseFacts = CorpseFacts {
|
||||
bones: false,
|
||||
friendly: false,
|
||||
};
|
||||
|
||||
/// `0x6e6260`: bones never, the ally bit only a friendly corpse, the enemy bit only an
|
||||
/// unfriendly one, and only on a word without the ally bit.
|
||||
#[test]
|
||||
fn the_pick_admits_a_corpse_as_0x6e6260_does() {
|
||||
assert!(pick_admits(0x8000, ALLY), "Resurrection over a friend");
|
||||
assert!(!pick_admits(0x8000, ENEMY), "never an enemy's corpse");
|
||||
assert!(!pick_admits(0x8000, BONES), "never bones");
|
||||
assert!(pick_admits(0x0200, ENEMY));
|
||||
assert!(!pick_admits(0x0200, ALLY));
|
||||
assert!(!pick_admits(
|
||||
0x0200,
|
||||
CorpseFacts {
|
||||
bones: true,
|
||||
friendly: false
|
||||
}
|
||||
));
|
||||
// Both bits: the ally arm decides alone (`6e6291 jns`), so no enemy corpse.
|
||||
assert!(pick_admits(0x8200, ALLY));
|
||||
assert!(!pick_admits(0x8200, ENEMY));
|
||||
// A word with no corpse bit admits none, `0x400` inside `0x8600` included.
|
||||
for word in [0x0002, 0x0100, 0x0400, 0x0040, 0x4800] {
|
||||
assert!(!pick_admits(word, ALLY), "{word:#06x}");
|
||||
assert!(!pick_admits(word, ENEMY), "{word:#06x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `BindTarget`'s corpse arm, which `0x6e6460`'s corpse leg mirrors: the ally arm then the
|
||||
/// enemy arm, each on a corpse that is not bones.
|
||||
#[test]
|
||||
fn the_bind_takes_the_arm_the_reaction_picks() {
|
||||
assert_eq!(bind_arm(0x8000, ALLY), Some(CorpseTarget::Ally));
|
||||
assert_eq!(
|
||||
bind_arm(0x8000, ENEMY),
|
||||
None,
|
||||
"a hostile corpse under 0x8000"
|
||||
);
|
||||
assert_eq!(bind_arm(0x8000, BONES), None, "bones");
|
||||
assert_eq!(bind_arm(0x0200, ENEMY), Some(CorpseTarget::Enemy));
|
||||
assert_eq!(bind_arm(0x0200, ALLY), None);
|
||||
// Both bits: unlike the pick, the enemy arm still runs after the ally arm fails.
|
||||
assert_eq!(bind_arm(0x8200, ENEMY), Some(CorpseTarget::Enemy));
|
||||
assert_eq!(bind_arm(0x8200, ALLY), Some(CorpseTarget::Ally));
|
||||
for word in [0x0002, 0x0100, 0x0400, 0x0040] {
|
||||
assert_eq!(bind_arm(word, ALLY), None, "{word:#06x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The cursor while a cast waits for its click: the ground point's range verdict
|
||||
//! (`CheckGroundPointInRange 0x6e6810`, in `0x4820f0`), the hovered object's validity (`0x6e6460`,
|
||||
//! in `0x4828d0`) and the reticle's radius (`GetCurrentCastRadius 0x6e6350`).
|
||||
//! in `0x4828d0`: a GameObject, a unit or a corpse) and the reticle's radius
|
||||
//! (`GetCurrentCastRadius 0x6e6350`).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -75,12 +76,14 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level:
|
||||
/// its ray (`0x4812c8`) and the world cursor stays grey.
|
||||
///
|
||||
/// The object arm is `0x6e6460`'s GameObject leg: `word & 0x4800`, the lock predicate `0x5f8260`,
|
||||
/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit, world-item and
|
||||
/// corpse legs are unreachable here: a unit-target spell never enters targeting mode.
|
||||
/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit leg is
|
||||
/// [`SpellTargeting::can_target_unit`] and its corpse leg [`SpellTargeting::can_target_corpse`],
|
||||
/// over a corpse the pick admitted under a word in `0x8600`. The world-item leg is not built.
|
||||
///
|
||||
/// Every seam shows the `Cast` kind, so this reads the whole-word [`SpellTargeting::spell`].
|
||||
pub(crate) fn drive_targeting_cursor(
|
||||
targeting: Res<SpellTargeting>,
|
||||
checks: super::BindChecks,
|
||||
occlusion: Res<PickOcclusion>,
|
||||
hovered: Res<crate::target::Hovered>,
|
||||
hovered_object: Res<crate::target::HoveredObject>,
|
||||
@@ -102,8 +105,8 @@ pub(crate) fn drive_targeting_cursor(
|
||||
};
|
||||
let row = range_row(spells.as_deref(), spell_id);
|
||||
let me = self_tf.single().ok().map(|tf| tf.translation);
|
||||
// "A GameObject is the nearest pick" is the same test the click uses
|
||||
// ([`super::world::commit_object_cast_on_click`]).
|
||||
// The arm the click would take ([`super::world::commit_object_cast_on_click`]): a GameObject
|
||||
// word's nearest GameObject, else the picked unit or corpse, else the terrain.
|
||||
let able = if targeting.wants(TargetingWants::GameObject)
|
||||
&& crate::target::go_is_nearest(&hovered, &hovered_object)
|
||||
{
|
||||
@@ -116,6 +119,16 @@ pub(crate) fn drive_targeting_cursor(
|
||||
row,
|
||||
me,
|
||||
)
|
||||
} else if let Some(unit) = hovered
|
||||
.target
|
||||
.filter(|_| targeting.wants(TargetingWants::Unit))
|
||||
{
|
||||
targeting.can_target_unit(unit, &checks)
|
||||
} else if let Some(corpse) = hovered
|
||||
.corpse
|
||||
.filter(|_| targeting.wants(TargetingWants::Corpse))
|
||||
{
|
||||
targeting.can_target_corpse(corpse, &checks)
|
||||
} else if targeting.wants(TargetingWants::Location) {
|
||||
// `0x4820f0`. No ground hit (sky, mouselook) is state 0, UnableCast.
|
||||
match (occlusion.point, me) {
|
||||
@@ -289,6 +302,7 @@ mod tests {
|
||||
world.init_resource::<crate::go_templates::GameObjectTemplates>();
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.insert_resource(PickOcclusion {
|
||||
distance: 10.0,
|
||||
point,
|
||||
@@ -341,4 +355,162 @@ mod tests {
|
||||
// A lock word that also carries DEST (`0x4840`) keeps its terrain handler off a GameObject.
|
||||
assert!(verdict(0x4840, Some(Vec3::ZERO), None));
|
||||
}
|
||||
|
||||
/// `0x6e6460`'s unit leg over a hovered unit: the relation checks, then min² ≤ d² ≤ max²
|
||||
/// (`6e677c`–`6e6802`). Row 5 is 0 to 30 yd and row 114 is 8 to 35 yd, both padded by the two
|
||||
/// 1.5 combat reaches: 0 to 33 and 11 to 38.
|
||||
#[test]
|
||||
fn the_unit_leg_is_grey_out_of_range_or_off_relation() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const HEAL: u32 = 2050;
|
||||
const SHOT: u32 = 75;
|
||||
let verdict_for = |spell: u32, word: u16, distance: f32| {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<WorldCursor>();
|
||||
world.init_resource::<SpellTargeting>();
|
||||
world.init_resource::<crate::target::HoveredObject>();
|
||||
world.init_resource::<crate::go_templates::GameObjectTemplates>();
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.init_resource::<PickOcclusion>();
|
||||
let display = |range_index| benilla_formats::SpellDisplay {
|
||||
range_index,
|
||||
..Default::default()
|
||||
};
|
||||
let row = |min, max| SpellRange { min, max, flags: 0 };
|
||||
let mut spells = Spells::empty_for_tests();
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([
|
||||
(HEAL, display(5)),
|
||||
(SHOT, display(114)),
|
||||
]));
|
||||
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([
|
||||
(5, row(0.0, 30.0)),
|
||||
(114, row(8.0, 35.0)),
|
||||
]));
|
||||
world.insert_resource(spells);
|
||||
let empty = || crate::net::ObjectStore(benilla_protocol::ObjectFields::default());
|
||||
world.spawn((
|
||||
SelfPlayer,
|
||||
Transform::default(),
|
||||
GlobalTransform::default(),
|
||||
empty(),
|
||||
));
|
||||
let unit = world
|
||||
.spawn((
|
||||
GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)),
|
||||
empty(),
|
||||
))
|
||||
.id();
|
||||
world.insert_resource(crate::target::Hovered {
|
||||
target: Some(unit),
|
||||
guid: Some(0xF130_0000_0000_0001),
|
||||
distance: 5.0,
|
||||
..Default::default()
|
||||
});
|
||||
world.resource_mut::<SpellTargeting>().enter(
|
||||
spell,
|
||||
crate::spell::CastCommit::Spell,
|
||||
word,
|
||||
);
|
||||
world
|
||||
.run_system_once(drive_targeting_cursor)
|
||||
.expect("the targeting cursor drives");
|
||||
!world.resource::<WorldCursor>().unable
|
||||
};
|
||||
let verdict = |word, distance| verdict_for(HEAL, word, distance);
|
||||
|
||||
// `TARGET_FLAG_UNIT` (0x2) binds any unit, so only range decides.
|
||||
assert!(verdict(0x0002, 10.0), "a valid unit in range → Cast");
|
||||
assert!(
|
||||
!verdict(0x0002, 40.0),
|
||||
"the same unit out of range → UnableCast"
|
||||
);
|
||||
// An assist word over a unit with no faction catalog: neutral, not assistable.
|
||||
assert!(
|
||||
!verdict(0x0100, 10.0),
|
||||
"a unit that fails the relation → UnableCast"
|
||||
);
|
||||
// Inside the minimum is out of range too.
|
||||
assert!(verdict_for(SHOT, 0x0002, 20.0), "inside the band → Cast");
|
||||
assert!(
|
||||
!verdict_for(SHOT, 0x0002, 5.0),
|
||||
"inside the minimum → UnableCast"
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x6e6460`'s corpse leg over a hovered corpse: bones, a hostile corpse under the ally bit
|
||||
/// and a corpse out of range are grey. The range pads a corpse by the caster's reach twice
|
||||
/// (`6e3605`–`6e361e`): Resurrection's 30 yd with a 3.0 reach is 36.
|
||||
#[test]
|
||||
fn the_corpse_leg_is_grey_for_bones_a_hostile_corpse_or_out_of_range() {
|
||||
use super::super::corpse_fixture as fx;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let verdict = |word: u16, corpse: crate::net::ObjectStore, distance: f32| {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<WorldCursor>();
|
||||
world.init_resource::<SpellTargeting>();
|
||||
world.init_resource::<crate::target::HoveredObject>();
|
||||
world.init_resource::<crate::go_templates::GameObjectTemplates>();
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.init_resource::<PickOcclusion>();
|
||||
world.insert_resource(fx::spells());
|
||||
world.insert_resource(fx::factions());
|
||||
world.spawn((
|
||||
SelfPlayer,
|
||||
Transform::default(),
|
||||
GlobalTransform::default(),
|
||||
fx::caster(3.0),
|
||||
));
|
||||
let body = world
|
||||
.spawn((
|
||||
GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)),
|
||||
corpse,
|
||||
))
|
||||
.id();
|
||||
world.insert_resource(crate::target::Hovered {
|
||||
corpse: Some(body),
|
||||
corpse_guid: Some(fx::CORPSE),
|
||||
distance: 5.0,
|
||||
..Default::default()
|
||||
});
|
||||
world.resource_mut::<SpellTargeting>().enter(
|
||||
fx::RESURRECTION,
|
||||
crate::spell::CastCommit::Spell,
|
||||
word,
|
||||
);
|
||||
world
|
||||
.run_system_once(drive_targeting_cursor)
|
||||
.expect("the targeting cursor drives");
|
||||
!world.resource::<WorldCursor>().unable
|
||||
};
|
||||
let friend = || fx::corpse(fx::HUMAN, false);
|
||||
|
||||
assert!(verdict(0x8000, friend(), 10.0), "a friend's corpse → Cast");
|
||||
assert!(
|
||||
!verdict(0x8000, fx::corpse(fx::HUMAN, true), 10.0),
|
||||
"bones → UnableCast"
|
||||
);
|
||||
assert!(
|
||||
!verdict(0x8000, fx::corpse(fx::ORC, false), 10.0),
|
||||
"a hostile corpse under the ally bit → UnableCast"
|
||||
);
|
||||
assert!(
|
||||
verdict(0x0200, fx::corpse(fx::ORC, false), 10.0),
|
||||
"which the enemy bit takes"
|
||||
);
|
||||
// Past the bare 30 and past 30 + 3.0 + 1.5, inside 30 + 2 × 3.0.
|
||||
assert!(verdict(0x8000, friend(), 35.0), "inside the padded range");
|
||||
assert!(
|
||||
!verdict(0x8000, friend(), 37.0),
|
||||
"out of range → UnableCast"
|
||||
);
|
||||
// A word outside `0x8600` has no corpse leg: the heal word over a corpse is state 0.
|
||||
assert!(!verdict(0x0002, friend(), 10.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,22 @@
|
||||
//! word and each click seam asks it its own mask test ([`TargetingWants`]).
|
||||
//!
|
||||
//! - [`cursor`]: the hover verdict per seam. Terrain is `0x4820f0`'s `CheckGroundPointInRange
|
||||
//! 0x6e6810`, a GameObject is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate `0x5f8260`,
|
||||
//! then range), and a word no seam handles is UnableCast.
|
||||
//! 0x6e6810`, a GameObject, a unit or a corpse is `0x4828d0`'s `0x6e6460` (the spell-vs-lock
|
||||
//! predicate `0x5f8260`, [`SpellTargeting::can_target_unit`] or
|
||||
//! [`SpellTargeting::can_target_corpse`], then range), and a word no seam handles is UnableCast.
|
||||
//! - [`world`]: the world-click dispatcher `0x492ce0`, its terrain leg (`0x492580` → `BindLocation
|
||||
//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`).
|
||||
//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`),
|
||||
//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit`
|
||||
//! ([`drain_spell_target_unit`]), and whose corpse arm is [`corpse`]'s.
|
||||
//! - [`item`]: the bag click (`PickupContainerItem 0x4f9b30`) and the paper-doll click
|
||||
//! (`0x4c7300`), both `IsTargeting`, `TargetingWantsItem 0x6e6330`, then `0x495d60`, whose
|
||||
//! confirm popups park the clicked guid (`0xb4e3c0`) with the word still standing.
|
||||
//!
|
||||
//! Every seam commits through [`crate::spell::CastLadder::commit_targeted`] (`SendCast 0x6e54f0`).
|
||||
//! Neither world leg has a range gate: the click sends and the server judges range, and
|
||||
//! `CheckGroundPointInRange` only colours the cursor. While targeting, the pick flags come from
|
||||
//! the word alone, so a click over a unit with a dest-only word commits on the ground behind it.
|
||||
//! Only the unit and corpse arms have a range gate ([`merge`]): the terrain and GameObject clicks
|
||||
//! send and the server judges range, and `CheckGroundPointInRange` only colours the cursor. While
|
||||
//! targeting, the pick flags come from the word alone, so a click over a unit with a dest-only
|
||||
//! word commits on the ground behind it.
|
||||
//!
|
||||
//! Cancels: ESC through `UIParent.lua:1490` ([`feed_targeting_to_vm`], [`drain_stop_targeting`]),
|
||||
//! the right-button down edge ([`cancel_targeting_on_right_press`]), a new spell's press, which
|
||||
@@ -22,10 +26,14 @@
|
||||
//! spell (`UseAction 0x4e5ee0`). A cancel clears the word and sends nothing; movement never
|
||||
//! cancels (`0x515090`).
|
||||
|
||||
mod corpse;
|
||||
mod cursor;
|
||||
mod item;
|
||||
mod world;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use corpse::fixture as corpse_fixture;
|
||||
pub(crate) use corpse::{corpse_pick_admits, publish_corpse_pick, CorpsePick};
|
||||
pub(crate) use cursor::{drive_targeting_cursor, ground_cast_radius};
|
||||
pub(crate) use item::{commit_item_cast_on_pick, EnchantConfirmItem};
|
||||
pub(crate) use world::{commit_ground_cast_on_click, commit_object_cast_on_click};
|
||||
@@ -34,11 +42,137 @@ use bevy::prelude::*;
|
||||
|
||||
use benilla_world::interact::WorldRightPress;
|
||||
|
||||
/// The inputs of `BindTarget 0x6e5b40`'s unit and corpse arms and of their read-only mirror
|
||||
/// `0x6e6460` ([`SpellTargeting::can_target_unit`], [`bind_target_unit`],
|
||||
/// [`SpellTargeting::can_target_corpse`], [`corpse::bind_target_corpse`]): the relation checks the
|
||||
/// cast arm's selection bind runs ([`super::cast_target::unit_word_binds`]), the corpse's two
|
||||
/// facts, and the pre-send range gate's inputs ([`super::cast_target::RangeInputs`]), read as
|
||||
/// [`super::cast_target::CastTargeting`] reads them.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct BindChecks<'w, 's> {
|
||||
stores: Query<'w, 's, &'static crate::net::ObjectStore>,
|
||||
index: Option<Res<'w, crate::net::GuidIndex>>,
|
||||
self_q: Query<
|
||||
'w,
|
||||
's,
|
||||
(Entity, Option<&'static crate::net::ObjectStore>),
|
||||
With<crate::net::SelfPlayer>,
|
||||
>,
|
||||
factions: Option<Res<'w, crate::target::Factions>>,
|
||||
reputations: Res<'w, crate::net::Reputations>,
|
||||
/// The range leg's positions: the pose the hover picks against, as last frame propagated it,
|
||||
/// so the VM feed, the cursor and both binds read one pose. Never the camera's, which
|
||||
/// `publish_camera_pose` rewrites mid-frame.
|
||||
poses: Query<'w, 's, &'static GlobalTransform, Without<benilla_world::view::WorldCamera>>,
|
||||
spells: Option<Res<'w, crate::ui_action::Spells>>,
|
||||
}
|
||||
|
||||
impl BindChecks<'_, '_> {
|
||||
fn is_self(&self, entity: Entity) -> bool {
|
||||
self.self_q
|
||||
.iter()
|
||||
.next()
|
||||
.is_some_and(|(me, _)| me == entity)
|
||||
}
|
||||
|
||||
/// `BindTarget 0x6e5b40`'s relation checks: whether this unit clears the whole word.
|
||||
fn relations_clear(&self, word: u16, entity: Entity) -> bool {
|
||||
let target_store = self.stores.get(entity).ok();
|
||||
let target_owner_store = target_store
|
||||
.and_then(|store| {
|
||||
store
|
||||
.0
|
||||
.unit_owner(benilla_protocol::messages::OwnerFallback::CreatedBy)
|
||||
})
|
||||
.and_then(|guid| self.index.as_ref()?.0.get(&guid).copied())
|
||||
.and_then(|owner| self.stores.get(owner).ok());
|
||||
let rel = super::cast_target::TargetRelations {
|
||||
target_store,
|
||||
target_owner_store,
|
||||
self_store: self.self_q.iter().next().and_then(|(_, store)| store),
|
||||
factions: self.factions.as_deref(),
|
||||
reputations: &self.reputations,
|
||||
};
|
||||
super::cast_target::unit_word_binds(word, self.is_self(entity), &rel)
|
||||
}
|
||||
|
||||
/// The spell's min/max range against this unit, through the one compare the pre-send gate
|
||||
/// asks ([`super::cast_target::RangeInputs::refusal`]); our own body is distance 0. An
|
||||
/// unknown spell or a missing row passes.
|
||||
fn range_refusal(&self, spell_id: u32, entity: Entity) -> Option<u8> {
|
||||
let mut range = self.range_inputs(entity);
|
||||
range.target_reach = self
|
||||
.stores
|
||||
.get(entity)
|
||||
.ok()
|
||||
.map(|s| s.0.unit_combat_reach());
|
||||
self.refusal(spell_id, range)
|
||||
}
|
||||
|
||||
/// The same compare against a corpse. `GetMinMaxRange 0x6e3480` pads a corpse's bounds as a
|
||||
/// unit's (`6e35fe`), but with no unit to read the second reach from it reads the caster's
|
||||
/// again (`6e3605`–`6e361e`).
|
||||
fn corpse_range_refusal(&self, spell_id: u32, entity: Entity) -> Option<u8> {
|
||||
let mut range = self.range_inputs(entity);
|
||||
range.target_reach = Some(range.self_reach);
|
||||
self.refusal(spell_id, range)
|
||||
}
|
||||
|
||||
/// Our position and reach and the candidate's position, each position the pose the hover
|
||||
/// picks against.
|
||||
fn range_inputs(&self, entity: Entity) -> super::cast_target::RangeInputs {
|
||||
let me = self.self_q.iter().next();
|
||||
let mut range = super::cast_target::RangeInputs {
|
||||
self_pos: me
|
||||
.and_then(|(e, _)| self.poses.get(e).ok())
|
||||
.map(GlobalTransform::translation),
|
||||
target_pos: self
|
||||
.poses
|
||||
.get(entity)
|
||||
.ok()
|
||||
.map(GlobalTransform::translation),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some((_, Some(store))) = me {
|
||||
range.self_reach = store.0.unit_combat_reach();
|
||||
}
|
||||
range
|
||||
}
|
||||
|
||||
fn refusal(&self, spell_id: u32, range: super::cast_target::RangeInputs) -> Option<u8> {
|
||||
let spells = self.spells.as_deref()?;
|
||||
let def = spells.catalog.get(spell_id)?;
|
||||
range.refusal(def, spells.ranges.get(def.range_index))
|
||||
}
|
||||
|
||||
/// What the corpse legs read of this corpse: `CORPSE_FLAG_BONES` and the reaction gate
|
||||
/// `0x6067d0`. A corpse whose store has not streamed binds nothing.
|
||||
fn corpse_facts(&self, entity: Entity) -> Option<corpse::CorpseFacts> {
|
||||
Some(corpse::CorpseFacts::of(
|
||||
self.stores.get(entity).ok()?,
|
||||
self.factions.as_deref(),
|
||||
self.self_q.iter().next().and_then(|(_, s)| s),
|
||||
))
|
||||
}
|
||||
|
||||
/// The caster under a spell with `AttributesEx & 0x80000` (`6e6507`).
|
||||
fn excluded_caster(&self, spell_id: u32, entity: Entity) -> bool {
|
||||
self.is_self(entity)
|
||||
&& self
|
||||
.spells
|
||||
.as_deref()
|
||||
.and_then(|s| s.catalog.get(spell_id))
|
||||
.is_some_and(|d| d.excludes_caster())
|
||||
}
|
||||
}
|
||||
|
||||
/// A click seam's mask test on the flag_word `0xcecac0`, one per reference predicate:
|
||||
///
|
||||
/// - `Location`: `TargetingWantsLocation 0x6e6320`, `word & 0x60`, the terrain click.
|
||||
/// - `Item`: `TargetingWantsItem 0x6e6330`, `word & 0x4010`, the bag and paper-doll clicks.
|
||||
/// - `GameObject`: `TargetingWantsGameObject 0x6e62d0`, `word & 0x4800`, the world object click.
|
||||
/// - `Unit`: `SpellCanTargetUnit 0x6e6460`, the unit arm of the world click.
|
||||
/// - `Corpse`: `0x6e6230`, `word & 0x8600`, the pick's corpse flag `0x40` ([`corpse`]).
|
||||
///
|
||||
/// The masks overlap on `TARGET_FLAG_LOCKED`, so a lock spell answers both the item and the
|
||||
/// GameObject seam; the reference settles it only at the click, where `BindTarget 0x6e5b40` picks
|
||||
@@ -48,10 +182,12 @@ pub(crate) enum TargetingWants {
|
||||
Location,
|
||||
Item,
|
||||
GameObject,
|
||||
Unit,
|
||||
Corpse,
|
||||
}
|
||||
|
||||
/// The unit-shaped bits of the flag_word, what `SpellCanTargetUnit` tests. The resolver binds or
|
||||
/// refuses a unit word before it reaches the cursor, so none is set today.
|
||||
/// The unit-shaped bits of the flag_word, the first gate `SpellCanTargetUnit` tests before it runs
|
||||
/// the candidate through the unit arm's relation and liveness checks.
|
||||
const UNIT_WORD_BITS: u16 = 0x0002 | 0x0004 | 0x0008 | 0x0080 | 0x0100 | 0x0200 | 0x0400 | 0x8000;
|
||||
|
||||
impl TargetingWants {
|
||||
@@ -60,11 +196,17 @@ impl TargetingWants {
|
||||
Self::Location => 0x0060,
|
||||
Self::Item => 0x4010,
|
||||
Self::GameObject => 0x4800,
|
||||
Self::Unit => UNIT_WORD_BITS,
|
||||
Self::Corpse => 0x8600,
|
||||
};
|
||||
word & mask != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The word bits `0x6e61a0` lets the world pick take the local player for: unit, raid, party,
|
||||
/// assist, the explicit gate and the ally corpse, never enemy (`0x80`) or enemy corpse (`0x200`).
|
||||
const SELF_PICK_BITS: u16 = 0x850e;
|
||||
|
||||
/// The targeting mode, `Some` while the flag_word is nonzero. Entered by the cast-send path
|
||||
/// ([`super::cast_target::CastWireTarget::Targeting`]), cleared by a commit or a cancel.
|
||||
#[derive(Resource, Default)]
|
||||
@@ -96,6 +238,36 @@ impl SpellTargeting {
|
||||
self.0.as_ref().is_some_and(|t| wants.matches(t.word))
|
||||
}
|
||||
|
||||
/// `SpellCanTargetUnit`'s predicate `0x6e6460`, its unit leg, with the range flag both callers
|
||||
/// pass (the world hover `0x4828d0` at `48290b`, the Lua `SpellCanTargetUnit 0x6e6d00`):
|
||||
/// `BindTarget`'s relation checks, the caster refused under `AttributesEx & 0x80000`
|
||||
/// (`6e6507`), then min² ≤ d² ≤ max² (`6e677c`–`6e6802`), so a unit inside the minimum is out
|
||||
/// too. There is no line-of-sight test.
|
||||
pub(crate) fn can_target_unit(&self, entity: Entity, checks: &BindChecks) -> bool {
|
||||
self.0.as_ref().is_some_and(|t| {
|
||||
checks.relations_clear(t.word, entity)
|
||||
&& !checks.excluded_caster(t.spell_id, entity)
|
||||
&& checks.range_refusal(t.spell_id, entity).is_none()
|
||||
})
|
||||
}
|
||||
|
||||
/// `0x6e61a0`: whether the world pick takes the local player, the pick flag `0x20` that
|
||||
/// `0x480610` tests at `48062c`. Never outside targeting (`480638`); while targeting, a word in
|
||||
/// `0x850e` whose spell lacks `AttributesEx & 0x80000` (`6e61cf`). An unknown spell passes.
|
||||
pub(crate) fn picks_self(&self, spells: Option<&crate::ui_action::Spells>) -> bool {
|
||||
self.0.as_ref().is_some_and(|t| {
|
||||
t.word & SELF_PICK_BITS != 0
|
||||
&& !spells
|
||||
.and_then(|s| s.catalog.get(t.spell_id))
|
||||
.is_some_and(|d| d.excludes_caster())
|
||||
})
|
||||
}
|
||||
|
||||
/// The pending cast and its standing word, whatever seam it wants.
|
||||
fn pending(&self) -> Option<(u32, super::cast_send::CastCommit, u16)> {
|
||||
self.0.as_ref().map(|t| (t.spell_id, t.commit, t.word))
|
||||
}
|
||||
|
||||
/// The pending spell when the standing word answers `wants`. A surface that draws or binds for
|
||||
/// one seam reads this, never [`Self::spell`], or an armed lockpick gets an AoE reticle.
|
||||
pub(crate) fn spell_for(&self, wants: TargetingWants) -> Option<u32> {
|
||||
@@ -163,6 +335,24 @@ pub(crate) fn cancel_targeting_on_right_press(
|
||||
targeting.clear();
|
||||
}
|
||||
|
||||
/// [`SpellTargeting::picks_self`] as the frame began, which the world pick reads
|
||||
/// ([`crate::target`]'s hover): the word the VM's `SpellIsTargeting` answers from, in a declared
|
||||
/// order against every cast drain that arms or ends the cursor after the input pass.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PicksSelf(pub(crate) bool);
|
||||
|
||||
/// Publish [`PicksSelf`] before the input pass.
|
||||
pub(crate) fn publish_picks_self(
|
||||
targeting: Res<SpellTargeting>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
mut picks: ResMut<PicksSelf>,
|
||||
) {
|
||||
let now = targeting.picks_self(spells.as_deref());
|
||||
if picks.0 != now {
|
||||
picks.0 = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the targeting state into the VM each frame, before the input pass, so a word armed last
|
||||
/// frame stands for this frame's clicks: the ESC chain's word, the bag and doll pickup reroute's
|
||||
/// item half, and `CURRENT_SPELL_CAST_CHANGED` on each edge. That event hides the enchant confirm
|
||||
@@ -171,6 +361,9 @@ pub(crate) fn cancel_targeting_on_right_press(
|
||||
/// and re-arm in one frame still counts.
|
||||
pub(crate) fn feed_targeting_to_vm(
|
||||
targeting: Res<SpellTargeting>,
|
||||
checks: BindChecks,
|
||||
tokens: crate::ui_unit::UnitTokens,
|
||||
selection: Res<crate::target::Selection>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
) {
|
||||
@@ -178,13 +371,12 @@ pub(crate) fn feed_targeting_to_vm(
|
||||
let last = last.get(&script);
|
||||
script.set_spell_targeting(targeting.active());
|
||||
script.set_item_pick_armed(targeting.wants(TargetingWants::Item));
|
||||
// `SpellCanTargetUnit`, `0x6e6460`'s unit leg, read off the word.
|
||||
script.set_spell_can_target_unit(
|
||||
targeting
|
||||
.0
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.word & UNIT_WORD_BITS != 0),
|
||||
);
|
||||
// `SpellCanTargetUnit(unit)`: resolve each token and ask `0x6e6460`'s unit leg.
|
||||
script.set_spell_targetable_units(crate::ui_unit::reach_tokens().filter(|token| {
|
||||
tokens
|
||||
.resolve(token, &selection)
|
||||
.is_some_and(|(entity, _)| targeting.can_target_unit(entity, &checks))
|
||||
}));
|
||||
if *last != targeting.spell() {
|
||||
*last = targeting.spell();
|
||||
script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
|
||||
@@ -207,9 +399,312 @@ pub(crate) fn drain_stop_targeting(
|
||||
}
|
||||
}
|
||||
|
||||
/// `BindTarget 0x6e5b40`'s unit arm, for the world click (`0x493540` at `4935d5`) and
|
||||
/// `SpellTargetUnit` (`0x6e5b10`): the caster under `AttributesEx & 0x80000` (`6e5bf7`) or a unit
|
||||
/// failing the relation checks binds nothing and the cursor stays up; otherwise the merge. The
|
||||
/// player's selection never moves.
|
||||
fn bind_target_unit(
|
||||
ladder: &mut crate::spell::CastLadder,
|
||||
checks: &BindChecks,
|
||||
entity: Entity,
|
||||
guid: u64,
|
||||
) {
|
||||
let Some((spell_id, commit, word)) = ladder.ground.pending() else {
|
||||
return;
|
||||
};
|
||||
if checks.excluded_caster(spell_id, entity) || !checks.relations_clear(word, entity) {
|
||||
return;
|
||||
}
|
||||
let range = checks.range_refusal(spell_id, entity);
|
||||
merge(
|
||||
ladder,
|
||||
spell_id,
|
||||
commit,
|
||||
range,
|
||||
super::cast_send::TargetedBind::Unit(guid),
|
||||
);
|
||||
}
|
||||
|
||||
/// `BindTarget`'s merge (`6e602c`–`6e60d7`): a bind whose mask is in `0x8202`, a unit or a corpse,
|
||||
/// runs the range test `0x6e47b0` (`6e6063`). Out of range raises "Out of range." or "Target too
|
||||
/// close" and binds nothing; otherwise the emptied word commits (`SendCast 0x6e54f0`). The
|
||||
/// reference's range failure also aborts the cast (`6e6089`), which sends CMSG_CANCEL_CAST and
|
||||
/// strands the cursor (`6e5b93`) until a cancel: a bug, not copied; targeting stays armed and
|
||||
/// working.
|
||||
fn merge(
|
||||
ladder: &mut crate::spell::CastLadder,
|
||||
spell_id: u32,
|
||||
commit: super::cast_send::CastCommit,
|
||||
range: Option<u8>,
|
||||
bound: super::cast_send::TargetedBind,
|
||||
) {
|
||||
if let Some(reason) = range {
|
||||
debug!("ui_action: cast {spell_id} not bound at {bound:x?} — range ({reason:#x})");
|
||||
ladder.cast_errors.push_local(spell_id, reason);
|
||||
return;
|
||||
}
|
||||
debug!("ui_action: cast {spell_id} committed at {bound:x?}");
|
||||
ladder.commit_targeted(spell_id, commit, bound);
|
||||
}
|
||||
|
||||
/// Drain `SpellTargetUnit(unit)` (`0x6e6d90`) after the UI pass. The binding already raised the
|
||||
/// usage and unknown-token errors and dropped a call made while not targeting. Here a token that
|
||||
/// names no unit raises "Out of range." (0x59) and ends targeting, as the reference's abort
|
||||
/// clears the word, and a unit goes to [`bind_target_unit`].
|
||||
pub(crate) fn drain_spell_target_unit(
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
tokens: crate::ui_unit::UnitTokens,
|
||||
selection: Res<crate::target::Selection>,
|
||||
checks: BindChecks,
|
||||
mut ladder: crate::spell::CastLadder,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
for token in script.take_spell_target_unit() {
|
||||
// An earlier token this frame may have bound or ended the cast: not targeting, no-op.
|
||||
let Some(spell_id) = ladder.ground.spell() else {
|
||||
continue;
|
||||
};
|
||||
match tokens.resolve(&token, &selection) {
|
||||
Some((entity, guid)) => bind_target_unit(&mut ladder, &checks, entity, guid),
|
||||
None => {
|
||||
debug!("ui_action: SpellTargetUnit({token}) names no unit — cast {spell_id} ends");
|
||||
ladder
|
||||
.cast_errors
|
||||
.push_local(spell_id, super::validator::ERR_OUT_OF_RANGE);
|
||||
ladder.ground.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
|
||||
use benilla_ui::script::UiScript;
|
||||
use crossbeam_channel::Receiver;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const HEAL: u32 = 2050;
|
||||
/// A spell that excludes its caster (`AttributesEx & 0x80000`).
|
||||
const NOT_SELF: u32 = 2051;
|
||||
/// A spell on row 114, 8 to 35 yd, which the reaches pad to 11 to 38.
|
||||
const BANDED: u32 = 75;
|
||||
const ME: u64 = 0x10;
|
||||
const ALLY: u64 = 0xF130_0000_0000_0001;
|
||||
|
||||
/// The cast ladder, the unit-token resolver and a VM, with us at the origin and an ally
|
||||
/// selected at `distance`. [`HEAL`] and [`NOT_SELF`] use row 5, 0 to 30 yd, which both 1.5
|
||||
/// combat reaches pad to 33.
|
||||
fn unit_world(distance: f32) -> (World, Receiver<ClientCommand>, Entity) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut world = World::new();
|
||||
world.insert_resource(NetCommands(tx));
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.init_resource::<crate::spell::PendingCast>();
|
||||
world.init_resource::<crate::spell::QueuedMeleeSpell>();
|
||||
world.init_resource::<crate::spell::Cooldowns>();
|
||||
world.init_resource::<crate::spell::SpellModifiers>();
|
||||
world.init_resource::<crate::ui_action::CastErrors>();
|
||||
world.init_resource::<crate::spell::AutoRepeatActive>();
|
||||
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
|
||||
world.init_resource::<SpellTargeting>();
|
||||
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
|
||||
world.init_resource::<crate::ui_party::GroupState>();
|
||||
let mut spells = crate::ui_action::Spells::empty_for_tests();
|
||||
let row5 = |attributes_ex| benilla_formats::SpellDisplay {
|
||||
range_index: 5,
|
||||
attributes_ex,
|
||||
..Default::default()
|
||||
};
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([
|
||||
(HEAL, row5(0)),
|
||||
(NOT_SELF, row5(0x0008_0000)),
|
||||
(
|
||||
BANDED,
|
||||
benilla_formats::SpellDisplay {
|
||||
range_index: 114,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]));
|
||||
let row = |min, max| benilla_formats::SpellRange { min, max, flags: 0 };
|
||||
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([
|
||||
(5, row(0.0, 30.0)),
|
||||
(114, row(8.0, 35.0)),
|
||||
]));
|
||||
world.insert_resource(spells);
|
||||
let empty = || ObjectStore(benilla_protocol::ObjectFields::default());
|
||||
world.spawn((SelfPlayer, Guid(ME), GlobalTransform::default(), empty()));
|
||||
let ally = world
|
||||
.spawn((
|
||||
Guid(ALLY),
|
||||
GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)),
|
||||
empty(),
|
||||
))
|
||||
.id();
|
||||
world.insert_resource(crate::target::Selection {
|
||||
target: Some(ally),
|
||||
guid: Some(ALLY),
|
||||
});
|
||||
let mut script = UiScript::new().expect("a VM");
|
||||
script.set_spell_targeting(true);
|
||||
world.insert_non_send_resource(script);
|
||||
(world, rx, ally)
|
||||
}
|
||||
|
||||
fn arm(world: &mut World, spell: u32, word: u16) {
|
||||
world.resource_mut::<SpellTargeting>().enter(
|
||||
spell,
|
||||
super::super::cast_send::CastCommit::Spell,
|
||||
word,
|
||||
);
|
||||
}
|
||||
|
||||
/// Queue `SpellTargetUnit(token)` in the VM, then run the host's drain.
|
||||
fn spell_target_unit(world: &mut World, token: &str) {
|
||||
world
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run(&format!("SpellTargetUnit({token:?})"))
|
||||
.expect("a known token");
|
||||
world
|
||||
.run_system_cached(drain_spell_target_unit)
|
||||
.expect("the drain runs");
|
||||
}
|
||||
|
||||
fn errors(world: &mut World) -> Vec<crate::ui_action::CastFail> {
|
||||
std::mem::take(&mut world.resource_mut::<crate::ui_action::CastErrors>().0)
|
||||
}
|
||||
|
||||
/// `0x6e6d90` past the binding's checks: a token naming no unit ends the cast with "Out of
|
||||
/// range."; a unit goes to `BindTarget`, which waits on a relation failure, raises "Out of
|
||||
/// range." for a unit out of range with the cursor still up, and commits otherwise. The
|
||||
/// selection never moves.
|
||||
#[test]
|
||||
fn spell_target_unit_ends_waits_or_binds_as_the_reference() {
|
||||
let fail = |reason| vec![crate::ui_action::CastFail::local(HEAL, reason)];
|
||||
|
||||
// The VM queued a token, but the host is no longer targeting: nothing.
|
||||
let (mut world, rx, _) = unit_world(10.0);
|
||||
spell_target_unit(&mut world, "target");
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(errors(&mut world).is_empty());
|
||||
|
||||
// A known token naming no unit: "Out of range." and targeting ends.
|
||||
let (mut world, rx, _) = unit_world(10.0);
|
||||
arm(&mut world, HEAL, 0x0002);
|
||||
spell_target_unit(&mut world, "party1");
|
||||
assert!(rx.try_recv().is_err(), "no send");
|
||||
assert_eq!(errors(&mut world), fail(0x59));
|
||||
assert!(
|
||||
!world.resource::<SpellTargeting>().active(),
|
||||
"the cast ends"
|
||||
);
|
||||
|
||||
// A unit the relation refuses (assist, neutral with no catalog): silent, still armed.
|
||||
let (mut world, rx, _) = unit_world(10.0);
|
||||
arm(&mut world, HEAL, 0x0100);
|
||||
spell_target_unit(&mut world, "target");
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(errors(&mut world).is_empty(), "no error");
|
||||
assert!(world.resource::<SpellTargeting>().active(), "still armed");
|
||||
|
||||
// Ourselves under a spell that excludes its caster: silent, still armed.
|
||||
let (mut world, rx, _) = unit_world(10.0);
|
||||
arm(&mut world, NOT_SELF, 0x0002);
|
||||
spell_target_unit(&mut world, "player");
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"never ourselves under AttributesEx 0x80000"
|
||||
);
|
||||
assert!(errors(&mut world).is_empty(), "no error");
|
||||
assert!(world.resource::<SpellTargeting>().active(), "still armed");
|
||||
|
||||
// In relation but out of range: "Out of range." and still armed.
|
||||
let (mut world, rx, _) = unit_world(40.0);
|
||||
arm(&mut world, HEAL, 0x0002);
|
||||
spell_target_unit(&mut world, "target");
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(errors(&mut world), fail(0x59));
|
||||
assert!(world.resource::<SpellTargeting>().active(), "still armed");
|
||||
|
||||
// In range: the cast goes to the unit and the cursor comes down.
|
||||
let (mut world, rx, ally) = unit_world(10.0);
|
||||
arm(&mut world, HEAL, 0x0002);
|
||||
spell_target_unit(&mut world, "target");
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEAL,
|
||||
target: Some(ALLY),
|
||||
})
|
||||
));
|
||||
assert!(errors(&mut world).is_empty());
|
||||
assert!(!world.resource::<SpellTargeting>().active());
|
||||
let selection = world.resource::<crate::target::Selection>();
|
||||
assert_eq!((selection.target, selection.guid), (Some(ally), Some(ALLY)));
|
||||
}
|
||||
|
||||
/// `SpellCanTargetUnit` asks `0x6e6460`'s unit leg per token: nil out of range, inside the
|
||||
/// minimum included, and nil for ourselves under a spell that excludes its caster.
|
||||
#[test]
|
||||
fn spell_can_target_unit_is_nil_out_of_range() {
|
||||
let can = |distance: f32, spell: u32, token: &str| {
|
||||
let (mut world, _rx, _) = unit_world(distance);
|
||||
arm(&mut world, spell, 0x0002);
|
||||
world
|
||||
.run_system_cached(feed_targeting_to_vm)
|
||||
.expect("the feed runs");
|
||||
world
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<bool>(&format!("return SpellCanTargetUnit({token:?}) == true"))
|
||||
.expect("a boolean")
|
||||
};
|
||||
assert!(can(10.0, HEAL, "target"), "in range");
|
||||
assert!(!can(40.0, HEAL, "target"), "out of range answers nil");
|
||||
assert!(can(40.0, HEAL, "player"), "ourselves at distance 0");
|
||||
assert!(
|
||||
!can(10.0, NOT_SELF, "player"),
|
||||
"never ourselves under AttributesEx 0x80000"
|
||||
);
|
||||
assert!(can(10.0, NOT_SELF, "target"));
|
||||
assert!(can(20.0, BANDED, "target"), "inside the band");
|
||||
assert!(
|
||||
!can(5.0, BANDED, "target"),
|
||||
"inside the minimum answers nil"
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x6e61a0`: the pick takes us only while targeting, for a word in `0x850e` whose spell
|
||||
/// lacks `AttributesEx & 0x80000`; [`PicksSelf`] carries it to the picker.
|
||||
#[test]
|
||||
fn the_pick_takes_us_only_for_a_friendly_word_that_admits_the_caster() {
|
||||
let (mut world, _rx, _) = unit_world(10.0);
|
||||
let picks = |world: &mut World| {
|
||||
world
|
||||
.run_system_cached(publish_picks_self)
|
||||
.expect("the flag publishes");
|
||||
world.resource::<PicksSelf>().0
|
||||
};
|
||||
world.init_resource::<PicksSelf>();
|
||||
assert!(!picks(&mut world), "never outside targeting");
|
||||
for word in [0x0002, 0x0004, 0x0008, 0x0100, 0x0400, 0x8000, 0x0102] {
|
||||
arm(&mut world, HEAL, word);
|
||||
assert!(picks(&mut world), "{word:#06x} takes us");
|
||||
}
|
||||
for word in [0x0080, 0x0200, 0x0040, 0x0010, 0x4800] {
|
||||
arm(&mut world, HEAL, word);
|
||||
assert!(!picks(&mut world), "{word:#06x} never takes us");
|
||||
}
|
||||
arm(&mut world, NOT_SELF, 0x0100);
|
||||
assert!(!picks(&mut world), "a spell that excludes its caster");
|
||||
world.resource_mut::<SpellTargeting>().clear();
|
||||
assert!(!picks(&mut world), "and off again when the cursor ends");
|
||||
}
|
||||
|
||||
/// A terrain click while a poison is armed must not ship a DEST block for an item spell.
|
||||
#[test]
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
//! the pick flags (`0x481050`) come only from the word `0xcecac0`, so the word picks the leg:
|
||||
//!
|
||||
//! - terrain (`0x492c90` → `0x492580` → `BindLocation 0x6e60f0`): [`commit_ground_cast_on_click`]
|
||||
//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`):
|
||||
//! [`commit_object_cast_on_click`]
|
||||
//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), whose GameObject,
|
||||
//! unit or corpse arm the picked object's type chooses: [`commit_object_cast_on_click`]
|
||||
//!
|
||||
//! A location-only word yields pick flags 3, whose `& 0x7c == 0` disables the object pick, so an
|
||||
//! AoE reticle clicks through a chest. Neither leg gates on range, validity or the lock: the
|
||||
//! server judges and its `SMSG_CAST_RESULT` is the refusal.
|
||||
//! AoE reticle clicks through a chest. The terrain and GameObject arms gate on nothing, not range,
|
||||
//! validity or the lock: the server judges and its `SMSG_CAST_RESULT` is the refusal. The unit and
|
||||
//! corpse arms run `BindTarget`'s relation and range checks ([`super::bind_target_unit`],
|
||||
//! [`super::corpse::bind_target_corpse`]).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -68,21 +70,22 @@ pub(crate) fn commit_ground_cast_on_click(
|
||||
/// 0x493540`, whose first act while targeting is `BindTarget 0x6e5b40` and return, so the click
|
||||
/// never changes the player's target and never reaches the GameObject's unselectable check.
|
||||
///
|
||||
/// `BindTarget` picks its arm by the clicked object's typemask; the GameObject arm tests the word's
|
||||
/// `0x4800`, writes wire bit `TARGET_FLAG_GAMEOBJECT` (`0x800`), clears those `0x4800` word bits,
|
||||
/// parks the guid (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`. A unit click
|
||||
/// under a lock word binds nothing, since every unit arm tests a bit the word lacks; this is
|
||||
/// [`go_is_nearest`] here.
|
||||
///
|
||||
/// There is no gate before the send, not range nor the lock: the refusal is the server's
|
||||
/// `SMSG_CAST_RESULT`. The right-click path ([`crate::target::click`], `0x5f33e0`) does resolve the
|
||||
/// lock and can refuse locally.
|
||||
/// `BindTarget` picks its arm by the clicked object's typemask. The GameObject arm, when a word in
|
||||
/// `0x4800` puts GameObjects in the pick and one is the nearest hit ([`go_is_nearest`]), writes
|
||||
/// wire bit `TARGET_FLAG_GAMEOBJECT` (`0x800`), clears those `0x4800` word bits, parks the guid
|
||||
/// (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`, with no gate before the send,
|
||||
/// not range nor the lock: the refusal is the server's `SMSG_CAST_RESULT`. The right-click path
|
||||
/// ([`crate::target::click`], `0x5f33e0`) does resolve the lock and can refuse locally. Otherwise
|
||||
/// the picked unit goes to the unit arm ([`super::bind_target_unit`]) and the picked corpse to the
|
||||
/// corpse arm ([`super::corpse::bind_target_corpse`]); a unit click under a lock word binds nothing
|
||||
/// there, since every unit arm tests a bit the word lacks.
|
||||
///
|
||||
/// Runs after `select_on_click`, as the terrain commit does.
|
||||
pub(crate) fn commit_object_cast_on_click(
|
||||
mut clicks: MessageReader<WorldClick>,
|
||||
// The press's pick: the object the gesture started on.
|
||||
press: Res<crate::target::PressPick>,
|
||||
checks: super::BindChecks,
|
||||
mut ladder: crate::spell::CastLadder,
|
||||
) {
|
||||
let (hovered, hovered_object) = (press.hovered, press.object);
|
||||
@@ -94,14 +97,20 @@ pub(crate) fn commit_object_cast_on_click(
|
||||
if clicks.read().last().is_none() {
|
||||
return;
|
||||
}
|
||||
// `TargetingWantsGameObject 0x6e62d0`: a poison's bare `0x10` word binds nothing here.
|
||||
// `TargetingWantsGameObject 0x6e62d0`: without it the pick holds no GameObject.
|
||||
if !(ladder.ground.wants(TargetingWants::GameObject)
|
||||
&& go_is_nearest(&hovered, &hovered_object))
|
||||
{
|
||||
if let Some((entity, guid)) = hovered.target.zip(hovered.guid) {
|
||||
super::bind_target_unit(&mut ladder, &checks, entity, guid);
|
||||
} else if let Some((entity, guid)) = hovered.corpse.zip(hovered.corpse_guid) {
|
||||
super::corpse::bind_target_corpse(&mut ladder, &checks, entity, guid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::GameObject) else {
|
||||
return;
|
||||
};
|
||||
// The object leg only wins when a GameObject is the nearest hit.
|
||||
if !go_is_nearest(&hovered, &hovered_object) {
|
||||
return;
|
||||
}
|
||||
let Some(guid) = hovered_object.guid else {
|
||||
return;
|
||||
};
|
||||
@@ -130,6 +139,7 @@ mod tests {
|
||||
world.insert_resource(NetCommands(tx));
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.init_resource::<crate::spell::PendingCast>();
|
||||
world.init_resource::<crate::spell::QueuedMeleeSpell>();
|
||||
world.init_resource::<crate::spell::Cooldowns>();
|
||||
@@ -266,6 +276,271 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A unit word waits for a valid unit click and sends that unit in the ordinary
|
||||
/// `TARGET_FLAG_UNIT` spell shape. An assist word does not bind a neutral unit.
|
||||
#[test]
|
||||
fn a_click_on_a_hovered_unit_commits_the_hand_cursor_cast() {
|
||||
const HEAL: u32 = 2050;
|
||||
const ALLY: u64 = 0xF130_0000_0000_0001;
|
||||
let (mut world, rx, id) = fixture();
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
HEAL,
|
||||
crate::spell::cast_send::CastCommit::Spell,
|
||||
0x0002,
|
||||
);
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(Entity::from_raw_u32(1).unwrap()),
|
||||
guid: Some(ALLY),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
click(&mut world, id);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEAL,
|
||||
target: Some(ALLY),
|
||||
})
|
||||
));
|
||||
assert!(
|
||||
!world.resource::<super::super::SpellTargeting>().active(),
|
||||
"the unit bind clears the hand cursor"
|
||||
);
|
||||
|
||||
// With no faction catalog the target's reaction is neutral (3): an ASSIST word must stay
|
||||
// armed and send nothing, exactly as when that unit was selected before the cast.
|
||||
let (mut world, rx, id) = fixture();
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
HEAL,
|
||||
crate::spell::cast_send::CastCommit::Spell,
|
||||
0x0100,
|
||||
);
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(Entity::from_raw_u32(1).unwrap()),
|
||||
guid: Some(ALLY),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
click(&mut world, id);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"a neutral unit must not receive the heal"
|
||||
);
|
||||
assert!(
|
||||
world.resource::<super::super::SpellTargeting>().active(),
|
||||
"an invalid unit click leaves the targeting word standing"
|
||||
);
|
||||
|
||||
// A unit word puts no GameObject in the pick, so a chest nearer than the unit is no
|
||||
// obstacle: the unit arm still binds.
|
||||
let (mut world, rx, id) = fixture();
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
HEAL,
|
||||
crate::spell::cast_send::CastCommit::Spell,
|
||||
0x0002,
|
||||
);
|
||||
hover_go(&mut world, 2.0);
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(Entity::from_raw_u32(1).unwrap()),
|
||||
guid: Some(ALLY),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
click(&mut world, id);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEAL,
|
||||
target: Some(ALLY),
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
/// `BindTarget`'s range leg (`6e6063`): a unit the relation checks accept but out of the
|
||||
/// spell's range raises "Out of range." and the cursor stays armed; a click in range commits.
|
||||
/// Row 5 is 0 to 30 yd, padded by both 1.5 combat reaches to 33.
|
||||
#[test]
|
||||
fn an_out_of_range_unit_click_raises_and_keeps_the_cursor() {
|
||||
use std::collections::HashMap;
|
||||
const HEAL: u32 = 2050;
|
||||
const ALLY: u64 = 0xF130_0000_0000_0001;
|
||||
let (mut world, rx, id) = fixture();
|
||||
let mut spells = crate::ui_action::Spells::empty_for_tests();
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([(
|
||||
HEAL,
|
||||
benilla_formats::SpellDisplay {
|
||||
range_index: 5,
|
||||
..Default::default()
|
||||
},
|
||||
)]));
|
||||
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([(
|
||||
5,
|
||||
benilla_formats::SpellRange {
|
||||
min: 0.0,
|
||||
max: 30.0,
|
||||
flags: 0,
|
||||
},
|
||||
)]));
|
||||
world.insert_resource(spells);
|
||||
world.spawn((crate::net::SelfPlayer, GlobalTransform::default()));
|
||||
let ally = world
|
||||
.spawn(GlobalTransform::from_translation(Vec3::new(40.0, 0.0, 0.0)))
|
||||
.id();
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
HEAL,
|
||||
crate::spell::cast_send::CastCommit::Spell,
|
||||
0x0002,
|
||||
);
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(ally),
|
||||
guid: Some(ALLY),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
|
||||
click(&mut world, id);
|
||||
assert!(rx.try_recv().is_err(), "out of range sends nothing");
|
||||
assert_eq!(
|
||||
std::mem::take(&mut world.resource_mut::<crate::ui_action::CastErrors>().0),
|
||||
vec![crate::ui_action::CastFail::local(HEAL, 0x59)],
|
||||
"\"Out of range.\""
|
||||
);
|
||||
assert!(
|
||||
world.resource::<super::super::SpellTargeting>().active(),
|
||||
"the cursor stays armed"
|
||||
);
|
||||
|
||||
world
|
||||
.entity_mut(ally)
|
||||
.insert(GlobalTransform::from_translation(Vec3::new(10.0, 0.0, 0.0)));
|
||||
click(&mut world, id);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEAL,
|
||||
target: Some(ALLY),
|
||||
})
|
||||
));
|
||||
assert!(world
|
||||
.resource::<crate::ui_action::CastErrors>()
|
||||
.0
|
||||
.is_empty());
|
||||
assert!(!world.resource::<super::super::SpellTargeting>().active());
|
||||
}
|
||||
|
||||
/// `BindTarget`'s corpse arm from a world click on a released player's corpse: the ally bit on
|
||||
/// a friend's corpse commits with `0x8000` and the corpse guid, the enemy bit on a hostile one
|
||||
/// with `0x200`; bones, a hostile corpse under the ally bit and a word the bit does not empty
|
||||
/// wait silently; out of range raises "Out of range." and waits. An item's corpse cast (Goblin
|
||||
/// Jumper Cables) commits as `CMSG_USE_ITEM` with the same block.
|
||||
#[test]
|
||||
fn a_click_on_a_released_corpse_binds_the_corpse_arm() {
|
||||
use super::super::corpse_fixture as fx;
|
||||
use benilla_protocol::messages::{CorpseTarget, UseItemTarget};
|
||||
let commit = crate::spell::cast_send::CastCommit::Spell;
|
||||
|
||||
let clicked = |word: u16,
|
||||
commit: crate::spell::cast_send::CastCommit,
|
||||
corpse: crate::net::ObjectStore,
|
||||
distance: f32| {
|
||||
let (mut world, rx, id) = fixture();
|
||||
world.insert_resource(fx::spells());
|
||||
world.insert_resource(fx::factions());
|
||||
world.spawn((
|
||||
crate::net::SelfPlayer,
|
||||
GlobalTransform::default(),
|
||||
fx::caster(1.5),
|
||||
));
|
||||
let body = world
|
||||
.spawn((
|
||||
GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)),
|
||||
corpse,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
corpse: Some(body),
|
||||
corpse_guid: Some(fx::CORPSE),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
fx::RESURRECTION,
|
||||
commit,
|
||||
word,
|
||||
);
|
||||
click(&mut world, id);
|
||||
let sent = rx.try_recv().ok();
|
||||
let errors =
|
||||
std::mem::take(&mut world.resource_mut::<crate::ui_action::CastErrors>().0);
|
||||
let armed = world.resource::<super::super::SpellTargeting>().active();
|
||||
(sent, errors, armed)
|
||||
};
|
||||
let friend = || fx::corpse(fx::HUMAN, false);
|
||||
let foe = || fx::corpse(fx::ORC, false);
|
||||
|
||||
let (sent, errors, armed) = clicked(0x8000, commit, friend(), 10.0);
|
||||
assert!(
|
||||
matches!(
|
||||
sent,
|
||||
Some(ClientCommand::CastSpellCorpse {
|
||||
spell_id: fx::RESURRECTION,
|
||||
target: CorpseTarget::Ally,
|
||||
corpse_guid: fx::CORPSE,
|
||||
})
|
||||
),
|
||||
"{sent:?}"
|
||||
);
|
||||
assert!(errors.is_empty() && !armed, "the bind ends the cursor");
|
||||
|
||||
let (sent, _, _) = clicked(0x0200, commit, foe(), 10.0);
|
||||
assert!(matches!(
|
||||
sent,
|
||||
Some(ClientCommand::CastSpellCorpse {
|
||||
target: CorpseTarget::Enemy,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// Silent refusals: nothing sent, no error, the cursor still up (`6e6026`).
|
||||
for (word, corpse, what) in [
|
||||
(0x8000, fx::corpse(fx::HUMAN, true), "bones"),
|
||||
(0x8000, foe(), "a hostile corpse under the ally bit"),
|
||||
(0x8002, friend(), "a word the corpse bit does not empty"),
|
||||
(0x0002, friend(), "a word with no corpse bit"),
|
||||
] {
|
||||
let (sent, errors, armed) = clicked(word, commit, corpse, 10.0);
|
||||
assert!(sent.is_none() && errors.is_empty() && armed, "{what}");
|
||||
}
|
||||
|
||||
// Out of range (30 + 2 × 1.5 = 33): "Out of range." and the cursor stays.
|
||||
let (sent, errors, armed) = clicked(0x8000, commit, friend(), 40.0);
|
||||
assert!(sent.is_none() && armed);
|
||||
assert_eq!(
|
||||
errors,
|
||||
vec![crate::ui_action::CastFail::local(fx::RESURRECTION, 0x59)]
|
||||
);
|
||||
|
||||
// An item's cast: the same corpse block under `CMSG_USE_ITEM`.
|
||||
let cables = crate::spell::cast_send::CastCommit::Item {
|
||||
bag_index: 255,
|
||||
slot: 24,
|
||||
entry: 7148,
|
||||
spell_index: 0,
|
||||
on_object: None,
|
||||
};
|
||||
let (sent, _, _) = clicked(0x8000, cables, friend(), 10.0);
|
||||
assert!(
|
||||
matches!(
|
||||
sent,
|
||||
Some(ClientCommand::UseItem {
|
||||
target: UseItemTarget::Corpse(CorpseTarget::Ally, fx::CORPSE),
|
||||
..
|
||||
})
|
||||
),
|
||||
"{sent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every way the object click must not commit.
|
||||
#[test]
|
||||
fn the_object_commit_holds_its_fire() {
|
||||
|
||||
@@ -83,6 +83,68 @@ pub(super) struct PickPose<'w, 's> {
|
||||
rigs: Query<'w, 's, &'static benilla_world::rig_palette::RigSkin>,
|
||||
}
|
||||
|
||||
/// The local identity and what of it and of the corpses the pick takes, one [`SystemParam`] under
|
||||
/// the picker's param limit.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(super) struct TargetHoverState<'w, 's> {
|
||||
/// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause.
|
||||
self_guid: Res<'w, crate::net::SelfGuid>,
|
||||
/// `0x6e61a0`'s pick flag `0x20`, as the frame began.
|
||||
picks_self: Res<'w, crate::spell::PicksSelf>,
|
||||
/// The corpse leg's word, as the frame began.
|
||||
corpse_pick: Res<'w, crate::spell::CorpsePick>,
|
||||
/// The corpse reaction gate's inputs (`0x6067d0`).
|
||||
factions: Option<Res<'w, super::Factions>>,
|
||||
self_store: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
|
||||
}
|
||||
|
||||
impl TargetHoverState<'_, '_> {
|
||||
/// Whether an object of this kind is a candidate of this pick, the posed pass and the box
|
||||
/// fallback alike: every unit and player, and a corpse (`0x480816`) always outside targeting,
|
||||
/// whose pick flags carry `0x40`, and while targeting only one `0x6e6260` passes. A corpse
|
||||
/// whose store has not streamed is refused then.
|
||||
fn is_candidate(&self, kind: EntityKind, store: Option<&ObjectStore>) -> bool {
|
||||
match kind {
|
||||
EntityKind::Unit | EntityKind::Player => true,
|
||||
EntityKind::Corpse => {
|
||||
let Some(word) = self.corpse_pick.0 else {
|
||||
return true;
|
||||
};
|
||||
store.is_some_and(|store| {
|
||||
crate::spell::targeting::corpse_pick_admits(
|
||||
word,
|
||||
store,
|
||||
self.factions.as_deref(),
|
||||
self.self_store.iter().next(),
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The posed pick's geometry under one model root: its skinned parts. Ours are dropped while
|
||||
/// hidden: first person hides them ([`crate::player::apply_self_model_fade`] at alpha 0), where the
|
||||
/// reference's camera fade clears its player-visible flag `[0xc4d74c]` (`0x482c41`) and
|
||||
/// `ShouldRender 0x5ec8f0` refuses the active player, so it is never picked.
|
||||
#[allow(clippy::type_complexity)] // the picker's own part query, borrowed as-is
|
||||
fn posed_parts<'a>(
|
||||
kids: &Children,
|
||||
parts: &'a Query<(
|
||||
&Mesh3d,
|
||||
&benilla_world::rig_palette::RigPart,
|
||||
Option<&InheritedVisibility>,
|
||||
)>,
|
||||
is_self: bool,
|
||||
) -> Vec<(&'a Mesh3d, &'a benilla_world::rig_palette::RigPart)> {
|
||||
kids.iter()
|
||||
.filter_map(|c| parts.get(c).ok())
|
||||
.filter(|(_, _, drawn)| !is_self || drawn.is_none_or(|v| v.get()))
|
||||
.map(|(mesh, part, _)| (mesh, part))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The unit under the cursor, as the reference's pick (`0x7089c0`) finds it. Broad phase: the ray
|
||||
/// against the playing sequence's bounds sphere. Pass 1: the ray against the posed render mesh,
|
||||
/// nearest wins. Pass 2, the mouse pick's retry when pass 1 hit nothing anywhere: every vertex
|
||||
@@ -102,8 +164,8 @@ pub(super) fn update_hover(
|
||||
mut hovered: ResMut<Hovered>,
|
||||
mesh_assets: Res<Assets<Mesh>>,
|
||||
pose: PickPose,
|
||||
// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause.
|
||||
self_guid: Res<crate::net::SelfGuid>,
|
||||
// Our own body joins the pick only while a targeting word takes it (`0x6e61a0`).
|
||||
target_state: TargetHoverState,
|
||||
// Last frame's pick, which outranks everything in pass 2 (the reference's anti-flicker cache).
|
||||
mut last_pick: Local<Option<Entity>>,
|
||||
// Unit roots. An undrawn body is not in the reference's draw list, so it takes no mouseover,
|
||||
@@ -111,6 +173,7 @@ pub(super) fn update_hover(
|
||||
roots: Query<
|
||||
(
|
||||
Entity,
|
||||
Has<SelfPlayer>,
|
||||
&GlobalTransform,
|
||||
&NetEntity,
|
||||
Option<&ModelAnimations>,
|
||||
@@ -121,11 +184,15 @@ pub(super) fn update_hover(
|
||||
Option<&InheritedVisibility>,
|
||||
Option<&HeldAttached>,
|
||||
),
|
||||
(With<Guid>, Without<SelfPlayer>),
|
||||
With<Guid>,
|
||||
>,
|
||||
// The part children of every pick model root: body, worn items and mount.
|
||||
child_sets: Query<&Children>,
|
||||
parts: Query<(&Mesh3d, &benilla_world::rig_palette::RigPart)>,
|
||||
parts: Query<(
|
||||
&Mesh3d,
|
||||
&benilla_world::rig_palette::RigPart,
|
||||
Option<&InheritedVisibility>,
|
||||
)>,
|
||||
// The box fallback: every `CreaturePickPart`, the fallback cube included.
|
||||
meshes: Query<
|
||||
(
|
||||
@@ -137,7 +204,7 @@ pub(super) fn update_hover(
|
||||
With<CreaturePickPart>,
|
||||
>,
|
||||
// The pick's guid and kind; the kind decides which slot of `Hovered` a hit lands in.
|
||||
units: Query<(&Guid, &NetEntity), Without<SelfPlayer>>,
|
||||
units: Query<(&Guid, &NetEntity)>,
|
||||
) {
|
||||
hovered.target = None;
|
||||
hovered.guid = None;
|
||||
@@ -178,13 +245,15 @@ pub(super) fn update_hover(
|
||||
let mut candidates: Vec<(Entity, u8, Vec<AssetId<Mesh>>, Vec<Mat4>)> = Vec::new();
|
||||
// Units with skinned parts: out of the box fallback even when the broad phase rejects them.
|
||||
let mut faithful: HashSet<Entity> = HashSet::new();
|
||||
for (entity, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots {
|
||||
let picks_self = target_state.picks_self.0;
|
||||
for (entity, is_self, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots
|
||||
{
|
||||
if is_self && !picks_self {
|
||||
continue;
|
||||
}
|
||||
// Units, players and corpses: the reference picks every CGObject in one trace and
|
||||
// switches on type at the end, below.
|
||||
if !matches!(
|
||||
net.kind,
|
||||
EntityKind::Unit | EntityKind::Player | EntityKind::Corpse
|
||||
) {
|
||||
if !target_state.is_candidate(net.kind, store) {
|
||||
continue;
|
||||
}
|
||||
// Not drawn, not clickable. Checked before `faithful.insert`, so an undrawn unit is out by
|
||||
@@ -241,7 +310,7 @@ pub(super) fn update_hover(
|
||||
let Ok(kids) = child_sets.get(root) else {
|
||||
continue;
|
||||
};
|
||||
let sk: Vec<_> = kids.iter().filter_map(|c| parts.get(c).ok()).collect();
|
||||
let sk = posed_parts(kids, &parts, is_self);
|
||||
let Some(palette) = palette_of(&sk) else {
|
||||
continue;
|
||||
};
|
||||
@@ -280,15 +349,16 @@ pub(super) fn update_hover(
|
||||
if faithful.contains(&parent) {
|
||||
continue; // posed-mesh-tested above
|
||||
}
|
||||
// The same kinds as the posed pick. A bone pile lands here: its corpse model has no
|
||||
if !picks_self && roots.get(parent).is_ok_and(|root| root.1) {
|
||||
continue;
|
||||
}
|
||||
// The same candidates as the posed pick. A bone pile lands here: its corpse model has no
|
||||
// skeleton, so only this box test can pick it.
|
||||
let Ok((_, parent_net)) = units.get(parent) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(
|
||||
parent_net.kind,
|
||||
EntityKind::Unit | EntityKind::Player | EntityKind::Corpse
|
||||
) {
|
||||
let store = roots.get(parent).ok().and_then(|root| root.6);
|
||||
if !target_state.is_candidate(parent_net.kind, store) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) {
|
||||
@@ -331,13 +401,13 @@ pub(super) fn update_hover(
|
||||
if net.kind == EntityKind::Corpse {
|
||||
hovered.corpse = Some(entity);
|
||||
hovered.corpse_guid = Some(guid.0);
|
||||
} else if selectable_pick(entity, &roots, self_guid.0) {
|
||||
} else if selectable_pick(entity, &roots, target_state.self_guid.0) {
|
||||
hovered.target = Some(entity);
|
||||
hovered.guid = Some(guid.0);
|
||||
} else {
|
||||
hovered.refused = true;
|
||||
}
|
||||
} else if selectable_pick(entity, &roots, self_guid.0) {
|
||||
} else if selectable_pick(entity, &roots, target_state.self_guid.0) {
|
||||
hovered.target = Some(entity);
|
||||
} else {
|
||||
hovered.refused = true;
|
||||
@@ -364,6 +434,7 @@ fn selectable_pick(
|
||||
roots: &Query<
|
||||
(
|
||||
Entity,
|
||||
Has<SelfPlayer>,
|
||||
&GlobalTransform,
|
||||
&NetEntity,
|
||||
Option<&ModelAnimations>,
|
||||
@@ -374,11 +445,11 @@ fn selectable_pick(
|
||||
Option<&InheritedVisibility>,
|
||||
Option<&HeldAttached>,
|
||||
),
|
||||
(With<Guid>, Without<SelfPlayer>),
|
||||
With<Guid>,
|
||||
>,
|
||||
self_guid: Option<u64>,
|
||||
) -> bool {
|
||||
let store = roots.get(entity).ok().and_then(|r| r.5);
|
||||
let store = roots.get(entity).ok().and_then(|r| r.6);
|
||||
super::relations::is_selectable(store, self_guid)
|
||||
}
|
||||
|
||||
@@ -766,6 +837,203 @@ mod tests {
|
||||
assert_eq!(resolved[4], net, "the net entity resolves to itself");
|
||||
}
|
||||
|
||||
/// First person hides our own parts (`Visibility::Hidden`), and the posed pick drops them, as
|
||||
/// `ShouldRender 0x5ec8f0` refuses the active player then; anyone else's stay the root's call.
|
||||
#[test]
|
||||
fn our_hidden_parts_leave_the_posed_pick() {
|
||||
let mut world = World::new();
|
||||
let root = world.spawn_empty().id();
|
||||
let rig = world.spawn_empty().id();
|
||||
let part = |world: &mut World, drawn| {
|
||||
world
|
||||
.spawn((
|
||||
ChildOf(root),
|
||||
Mesh3d(Handle::default()),
|
||||
benilla_world::rig_palette::RigPart(rig),
|
||||
drawn,
|
||||
))
|
||||
.id()
|
||||
};
|
||||
let shown = part(&mut world, InheritedVisibility::VISIBLE);
|
||||
part(&mut world, InheritedVisibility::HIDDEN);
|
||||
let count = move |is_self: bool| {
|
||||
move |kids: Query<&Children>,
|
||||
parts: Query<(
|
||||
&Mesh3d,
|
||||
&benilla_world::rig_palette::RigPart,
|
||||
Option<&InheritedVisibility>,
|
||||
)>| {
|
||||
posed_parts(kids.get(root).expect("the parts"), &parts, is_self).len()
|
||||
}
|
||||
};
|
||||
assert_eq!(world.run_system_once(count(false)).unwrap(), 2);
|
||||
assert_eq!(
|
||||
world.run_system_once(count(true)).unwrap(),
|
||||
1,
|
||||
"ours: only the drawn part"
|
||||
);
|
||||
world.entity_mut(shown).insert(InheritedVisibility::HIDDEN);
|
||||
assert_eq!(
|
||||
world.run_system_once(count(true)).unwrap(),
|
||||
0,
|
||||
"first person: nothing of ours to pick"
|
||||
);
|
||||
}
|
||||
|
||||
/// The picker's inputs with a camera ten yards up the Z axis, looking down it through a
|
||||
/// 100-pixel square with the cursor at its centre, over whatever stands at the origin.
|
||||
fn pick_world() -> World {
|
||||
use bevy::camera::RenderTargetInfo;
|
||||
use bevy::window::WindowResolution;
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<CameraControl>();
|
||||
world.init_resource::<PointerOverUi>();
|
||||
world.insert_resource(PickOcclusion {
|
||||
distance: f32::INFINITY,
|
||||
point: None,
|
||||
});
|
||||
world.init_resource::<crate::vplates::PlateHover>();
|
||||
world.init_resource::<Hovered>();
|
||||
world.init_resource::<Assets<Mesh>>();
|
||||
world.init_resource::<benilla_world::rig_palette::RigPalettes>();
|
||||
world.insert_resource(crate::net::SelfGuid(Some(1)));
|
||||
world.init_resource::<crate::spell::PicksSelf>();
|
||||
world.init_resource::<crate::spell::CorpsePick>();
|
||||
let mut camera = Camera::default();
|
||||
camera.computed.clip_from_view = Mat4::perspective_infinite_reverse_rh(1.0, 1.0, 0.1);
|
||||
camera.computed.target_info = Some(RenderTargetInfo {
|
||||
physical_size: UVec2::splat(100),
|
||||
scale_factor: 1.0,
|
||||
});
|
||||
world.spawn((
|
||||
camera,
|
||||
GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)),
|
||||
WorldCamera,
|
||||
));
|
||||
let mut window = Window {
|
||||
resolution: WindowResolution::new(100, 100),
|
||||
..Default::default()
|
||||
};
|
||||
window.set_cursor_position(Some(Vec2::splat(50.0)));
|
||||
world.spawn((window, PrimaryWindow));
|
||||
world
|
||||
}
|
||||
|
||||
/// One drawn pick box around the origin, the box fallback's geometry for a partless body.
|
||||
fn pick_box(world: &mut World, body: Entity) {
|
||||
world.spawn((
|
||||
ChildOf(body),
|
||||
CreaturePickPart,
|
||||
Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)),
|
||||
GlobalTransform::default(),
|
||||
InheritedVisibility::VISIBLE,
|
||||
));
|
||||
}
|
||||
|
||||
/// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes
|
||||
/// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts.
|
||||
#[test]
|
||||
fn our_body_joins_the_pick_only_while_the_word_takes_it() {
|
||||
let mut world = pick_world();
|
||||
// Us, at the origin under the cursor, with one drawn pick box.
|
||||
let me = world
|
||||
.spawn((
|
||||
SelfPlayer,
|
||||
Guid(1),
|
||||
NetEntity {
|
||||
kind: EntityKind::Player,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
GlobalTransform::default(),
|
||||
InheritedVisibility::VISIBLE,
|
||||
))
|
||||
.id();
|
||||
pick_box(&mut world, me);
|
||||
let hover = |world: &mut World, picks: bool| {
|
||||
world.resource_mut::<crate::spell::PicksSelf>().0 = picks;
|
||||
world
|
||||
.run_system_cached(update_hover)
|
||||
.expect("the pick runs");
|
||||
world.resource::<Hovered>().target
|
||||
};
|
||||
|
||||
assert_eq!(hover(&mut world, false), None, "not ours to pick");
|
||||
assert_eq!(hover(&mut world, true), Some(me), "a word that takes us");
|
||||
assert_eq!(hover(&mut world, false), None, "and not once it ends");
|
||||
}
|
||||
|
||||
/// A corpse is a candidate outside targeting (`0x5c` carries flag `0x40`); while targeting,
|
||||
/// only under a word in `0x8600` and only one `0x6e6260` passes, as
|
||||
/// [`crate::spell::CorpsePick`] carries the standing word to the picker.
|
||||
#[test]
|
||||
fn a_corpse_joins_the_pick_as_the_word_admits_it() {
|
||||
use crate::spell::targeting::corpse_fixture as fx;
|
||||
|
||||
let hover = |corpse: ObjectStore, word: Option<u16>| {
|
||||
let mut world = pick_world();
|
||||
world.insert_resource(fx::factions());
|
||||
world.init_resource::<crate::spell::SpellTargeting>();
|
||||
// Us, a human, away from the ray: the reaction gate's other side.
|
||||
world.spawn((
|
||||
SelfPlayer,
|
||||
Guid(1),
|
||||
GlobalTransform::from_translation(Vec3::new(50.0, 0.0, 0.0)),
|
||||
fx::caster(1.5),
|
||||
));
|
||||
let body = world
|
||||
.spawn((
|
||||
Guid(fx::CORPSE),
|
||||
NetEntity {
|
||||
kind: EntityKind::Corpse,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
GlobalTransform::default(),
|
||||
InheritedVisibility::VISIBLE,
|
||||
corpse,
|
||||
))
|
||||
.id();
|
||||
pick_box(&mut world, body);
|
||||
if let Some(word) = word {
|
||||
world.resource_mut::<crate::spell::SpellTargeting>().enter(
|
||||
fx::RESURRECTION,
|
||||
crate::spell::CastCommit::Spell,
|
||||
word,
|
||||
);
|
||||
}
|
||||
world
|
||||
.run_system_cached(crate::spell::targeting::publish_corpse_pick)
|
||||
.expect("the word publishes");
|
||||
world
|
||||
.run_system_cached(update_hover)
|
||||
.expect("the pick runs");
|
||||
let hovered = *world.resource::<Hovered>();
|
||||
assert_eq!(hovered.target, None, "a corpse is never the unit slot");
|
||||
hovered.corpse.inspect(|&e| assert_eq!(e, body)).is_some()
|
||||
};
|
||||
let friend = || fx::corpse(fx::HUMAN, false);
|
||||
let foe = || fx::corpse(fx::ORC, false);
|
||||
let bones = || fx::corpse(fx::HUMAN, true);
|
||||
|
||||
// Not targeting: every corpse, bones and foes included.
|
||||
for (corpse, what) in [(friend(), "friend"), (foe(), "foe"), (bones(), "bones")] {
|
||||
assert!(hover(corpse, None), "{what} outside targeting");
|
||||
}
|
||||
// Resurrection's word: a friend's corpse, never a foe's or bones.
|
||||
assert!(hover(friend(), Some(0x8000)));
|
||||
assert!(!hover(foe(), Some(0x8000)), "a hostile corpse");
|
||||
assert!(!hover(bones(), Some(0x8000)), "bones");
|
||||
// The enemy bit takes the other side.
|
||||
assert!(hover(foe(), Some(0x0200)));
|
||||
assert!(!hover(friend(), Some(0x0200)));
|
||||
// A heal word sets no flag `0x40`: the corpse is out of the pick, so a unit behind it
|
||||
// is reachable.
|
||||
assert!(!hover(friend(), Some(0x0002)));
|
||||
assert!(!hover(friend(), Some(0x0040)), "a ground word");
|
||||
}
|
||||
|
||||
/// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe,
|
||||
/// which is its whole pick surface.
|
||||
#[test]
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(crate) use price_discount::vendor_price_discount;
|
||||
pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR};
|
||||
// The attack-with-no-target request, and the same nearest-enemy core called synchronously for the
|
||||
// pet bar's Attack, whose order must leave in the frame it was pressed.
|
||||
pub(crate) use relations::{can_assist, can_attack, can_interact};
|
||||
pub(crate) use relations::{can_assist, can_attack, can_interact, corpse_friendly};
|
||||
pub(crate) use scan::{attack_order_target, AttackNearestRequest, TargetScan};
|
||||
// The chat layer's by-name asks (`/target`, `/assist`).
|
||||
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
|
||||
@@ -310,13 +310,18 @@ impl Plugin for TargetPlugin {
|
||||
crate::spell::targeting::commit_object_cast_on_click,
|
||||
click::act_on_right_click,
|
||||
click::clear_target_requests,
|
||||
// The unit-token asks (`TargetUnit`, `AssistUnit`, `TargetLastEnemy`: one
|
||||
// drain, as the reference has one `0x489a40`) and `DropItemOnUnit`'s pet leg,
|
||||
// independent of each other.
|
||||
// The UI's unit-token drains. `SpellTargetUnit` first: it binds the cursor's
|
||||
// cast and never moves the selection. Then the selection asks (`TargetUnit`,
|
||||
// `AssistUnit`, `TargetLastEnemy`: one drain, as the reference has one
|
||||
// `0x489a40`) and `DropItemOnUnit`'s pet leg, independent of each other.
|
||||
(
|
||||
click::selection_requests,
|
||||
crate::ui_action::drop_item::drop_item_on_unit,
|
||||
),
|
||||
crate::spell::targeting::drain_spell_target_unit,
|
||||
(
|
||||
click::selection_requests,
|
||||
crate::ui_action::drop_item::drop_item_on_unit,
|
||||
),
|
||||
)
|
||||
.chain(),
|
||||
// The by-name asks: `/target`, the Lua `TargetByName` and `/assist` commit
|
||||
// through `scan::commit`; `/follow` hands its subject to `crate::player`.
|
||||
(
|
||||
|
||||
@@ -119,8 +119,9 @@ mod tests {
|
||||
(5, UNIT_FLAG_PVP, 7, D15),
|
||||
(5, UNIT_FLAG_PVP, 8, D20),
|
||||
(5, UNIT_FLAG_PVP, 19, D20),
|
||||
// Revered and up pass the same signed `cmp eax,5`; Neutral does not.
|
||||
(7, UNIT_FLAG_PVP, 19, D20),
|
||||
// Revered, which Exalted reads too (`0x606439`), passes the same signed `cmp eax,5`;
|
||||
// Neutral does not.
|
||||
(6, UNIT_FLAG_PVP, 19, D20),
|
||||
(3, UNIT_FLAG_PVP, 19, D10),
|
||||
// Every other flag bit set but the PvP one: no honor step.
|
||||
(5, !UNIT_FLAG_PVP, 19, D10),
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! The first two live in [`super::ring`] beside the reaction directions they turn on:
|
||||
//! `0x6061e0(this = player)` answers a reputation faction with the at-war bit,
|
||||
//! `0x6061e0(this = unit)` with the standing, and the two often disagree.
|
||||
//!
|
||||
//! A corpse has its own reaction gate, `0x6067d0` ([`corpse_friendly`]).
|
||||
|
||||
use benilla_protocol::messages::{ObjectType, OwnerFallback};
|
||||
|
||||
@@ -84,6 +86,25 @@ pub(crate) fn is_selectable(store: Option<&ObjectStore>, self_guid: Option<u64>)
|
||||
|| (self_guid.is_some() && store.0.unit_created_by() == self_guid)
|
||||
}
|
||||
|
||||
/// `0x6067d0(player, corpse)`, the corpse's reaction gate: `0x6064c0` compares the player's
|
||||
/// template (`UNIT_FIELD_FACTIONTEMPLATE`) toward the dead player's race's (`0x5d7120`: the race
|
||||
/// byte of `CORPSE_FIELD_BYTES_1`, then `ChrRaces.dbc` column 2) with the template comparator
|
||||
/// `0x606640` alone, no reputation, and passes at friendly (`setge` 4). A missing row on either
|
||||
/// side is neutral (`0x60651b`), so it fails, as does a missing catalog or player.
|
||||
pub(crate) fn corpse_friendly(
|
||||
corpse: &ObjectStore,
|
||||
factions: Option<&Factions>,
|
||||
self_store: Option<&ObjectStore>,
|
||||
) -> bool {
|
||||
use benilla_formats::Reaction;
|
||||
let reaction = (|| {
|
||||
let catalog = factions?.catalog();
|
||||
let own = catalog.template(self_store?.0.unit_faction_template()?)?;
|
||||
Some(own.reaction_toward(catalog.race_template(corpse.0.corpse_race())?))
|
||||
})();
|
||||
reaction.unwrap_or(Reaction::Neutral) >= Reaction::Friendly
|
||||
}
|
||||
|
||||
/// `UNIT_FLAG_PVP`, bit 12 (vmangos `UnitDefines.h:557`), what `IsPvP 0x605ff0` tests on the
|
||||
/// unit's owner.
|
||||
const UNIT_FLAG_PVP: u32 = 0x1000;
|
||||
@@ -128,3 +149,56 @@ pub(crate) fn can_assist(
|
||||
let pvp_flags = owned.as_ref().map_or(flags, |o| o.0.unit_flags());
|
||||
pvp_flags & UNIT_FLAG_PVP != 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::spell::targeting::corpse_fixture as fx;
|
||||
|
||||
/// `0x6067d0`: the caster's template toward the corpse's race's, friendly or better; every gap
|
||||
/// is neutral (`0x60651b`) and fails.
|
||||
#[test]
|
||||
fn the_corpse_gate_reads_the_race_template() {
|
||||
let factions = fx::factions();
|
||||
let me = fx::caster(1.5);
|
||||
let gate = |corpse: &ObjectStore| corpse_friendly(corpse, Some(&factions), Some(&me));
|
||||
assert!(
|
||||
gate(&fx::corpse(fx::HUMAN, false)),
|
||||
"a human toward a human"
|
||||
);
|
||||
assert!(!gate(&fx::corpse(fx::ORC, false)), "a human toward an orc");
|
||||
assert!(!gate(&fx::corpse(0, false)), "race 0 has no ChrRaces row");
|
||||
assert!(
|
||||
!gate(&ObjectStore(Default::default())),
|
||||
"no BYTES_1 reads race 0"
|
||||
);
|
||||
let human = fx::corpse(fx::HUMAN, false);
|
||||
assert!(!corpse_friendly(&human, None, Some(&me)), "no catalog");
|
||||
assert!(!corpse_friendly(&human, Some(&factions), None), "no player");
|
||||
let no_template = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(35, 999)]));
|
||||
assert!(
|
||||
!corpse_friendly(&human, Some(&factions), Some(&no_template)),
|
||||
"a template the table lacks"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same gate on the shipped `ChrRaces.dbc` and `FactionTemplate.dbc`: a human (template 1)
|
||||
/// stands friendly toward every Alliance race's corpse and not toward a Horde one's.
|
||||
#[test]
|
||||
fn the_corpse_gate_splits_the_sides_on_the_shipped_tables() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let factions = Factions::from_catalog(
|
||||
benilla_formats::load_faction_catalog(&mut chain).expect("faction tables"),
|
||||
);
|
||||
let me = fx::caster(1.5);
|
||||
let gate = |race| corpse_friendly(&fx::corpse(race, false), Some(&factions), Some(&me));
|
||||
for race in [1, 3, 4, 7] {
|
||||
assert!(gate(race), "Alliance race {race}");
|
||||
}
|
||||
for race in [2, 5, 6, 8] {
|
||||
assert!(!gate(race), "Horde race {race}");
|
||||
}
|
||||
assert!(!gate(0) && !gate(10), "a race with no row");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +118,12 @@ impl RingVariant {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selector's branch (`0x605960`) on the raw reaction rank `0..=7`. A player never grays: rank
|
||||
/// 0-1 reads red, standing in for the reference's attackability matrix, else green when PvP-flagged
|
||||
/// and blue when not, paler for a party member; self is not in the party table. An NPC reads gray
|
||||
/// when dead, else the rank palette. The reference takes the player branch on `UNIT_FIELD_FLAGS`
|
||||
/// bit 3 (`0x605baa`), which a player's pet carries too; callers pass the object type.
|
||||
/// The selector's branch (`0x605960`) on [`ring_reaction`]'s rank `0..=6`. A player never grays:
|
||||
/// rank 0-1 reads red, standing in for the reference's attackability matrix, else green when
|
||||
/// PvP-flagged and blue when not, paler for a party member; self is not in the party table. An NPC
|
||||
/// reads gray when dead, else the rank palette. The reference takes the player branch on
|
||||
/// `UNIT_FIELD_FLAGS` bit 3 (`0x605baa`), which a player's pet carries too; callers pass the object
|
||||
/// type.
|
||||
pub(crate) fn ring_variant(
|
||||
rank: u8,
|
||||
is_player: bool,
|
||||
@@ -400,13 +401,14 @@ pub(super) fn push_ring(
|
||||
batch.tris();
|
||||
}
|
||||
|
||||
/// The target's reaction toward our player as a raw rank `0..=7`, the direction every NPC colour
|
||||
/// The target's reaction toward our player as a rank `0..=6`, the direction every NPC colour
|
||||
/// uses (`0x605960` and `0x7cbaa0` ask `UnitReaction 0x6061e0` with the unit as `this`). When
|
||||
/// both carry `UNIT_FIELD_FLAGS` bit 3, a duel (`0x606296`) or mutual FFA decides first. Then
|
||||
/// `0x606530`: a faction with a reputation slot (`0x605fc0`) answers with our reputation rank
|
||||
/// (`0x4d63a0`), even in GM mode; any other goes to the template comparator (`0x606640`). Neutral
|
||||
/// when anything is missing. Not applied: the party rung (`0x6062b0`), the contested guard,
|
||||
/// forced reactions and the summon tail.
|
||||
/// when anything is missing. `0x606439` caps that answer at 6, so Revered and Exalted both read 6.
|
||||
/// Not applied: the party rung (`0x6062b0`), the contested guard, forced reactions and the summon
|
||||
/// tail.
|
||||
pub(crate) fn ring_reaction(
|
||||
factions: Option<&Factions>,
|
||||
reputations: &Reputations,
|
||||
@@ -439,9 +441,13 @@ pub(crate) fn ring_reaction(
|
||||
let self_tpl = catalog.template(self_store.0.unit_faction_template()?)?;
|
||||
Some(target_tpl.reaction_toward(self_tpl) as u8)
|
||||
})();
|
||||
resolved.unwrap_or(Reaction::Neutral as u8)
|
||||
// `0x606439` (`cmp esi,6` / `jl` / `mov esi,6`) caps `0x606530`'s answer: Exalted's 7 reads 6.
|
||||
resolved.map_or(Reaction::Neutral as u8, |rank| rank.min(REACTION_CAP))
|
||||
}
|
||||
|
||||
/// Revered, the highest rank `UnitReaction`'s generic leg answers (`0x606439`).
|
||||
const REACTION_CAP: u8 = 6;
|
||||
|
||||
/// `UNIT_FIELD_FLAGS` bit 3, player-controlled in behaviour: players and their pets carry it, wild
|
||||
/// creatures do not. `UnitReaction` needs it on both sides before any player-vs-player rung
|
||||
/// (`0x606217`, `0x60622f`), and `CanAttack` picks its reaction arm by it (`0x606a13`).
|
||||
@@ -858,6 +864,49 @@ mod tests {
|
||||
assert_eq!(rank(&unit(12), &quiet), 4);
|
||||
}
|
||||
|
||||
/// The reputation leg walks the ladder up to Revered and stops: `0x606439` caps the rank at 6,
|
||||
/// so a Stormwind unit reads the same to a Revered and an Exalted human, while the ladder
|
||||
/// itself, which the reputation pane reads, still reaches 7.
|
||||
#[test]
|
||||
fn the_reputation_rank_is_capped_at_revered() {
|
||||
use crate::net::ObjectStore;
|
||||
use crate::target::{stormwind_fixture, HUMAN_WARRIOR};
|
||||
use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE;
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
/// `UNIT_FIELD_BYTES_0`, absolute descriptor index.
|
||||
const BYTES_0: u16 = 36;
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)]));
|
||||
// Both sides of the Honored, Revered and Exalted thresholds, and the wire's own ceiling.
|
||||
for (total, want) in [
|
||||
(8_999, 4),
|
||||
(9_000, 5),
|
||||
(20_999, 5),
|
||||
(21_000, 6),
|
||||
(41_999, 6),
|
||||
(42_000, 6),
|
||||
(42_999, 6),
|
||||
] {
|
||||
let (factions, template, reps) = stormwind_fixture(&mut chain, total);
|
||||
let unit = ObjectStore(ObjectFields::from_pairs(&[(
|
||||
FIELD_UNIT_FACTIONTEMPLATE,
|
||||
template,
|
||||
)]));
|
||||
assert_eq!(
|
||||
ring_reaction(Some(&factions), &reps, Some(&unit), Some(&me)),
|
||||
want,
|
||||
"standing {total}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
benilla_formats::reputation_rank(42_000),
|
||||
7,
|
||||
"the ladder is not capped"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cenarion Circle, faction 609 with reputation slot 36: not at war, its NPCs are neither
|
||||
/// attackable nor, without a service bit, interactable, while the rank stays neutral.
|
||||
#[test]
|
||||
|
||||
@@ -202,7 +202,7 @@ pub(super) fn old_target_cleared(previous: Option<u64>, now: Option<u64>) -> boo
|
||||
/// The old-target clear's `PetStopAttack` (`0x493910`, at `0x493a18` when `0x5ee5a0` finds a
|
||||
/// possessed unit at `0x493a0f`). It sits before the notify branch, so every selection writer runs
|
||||
/// it; a re-select runs no clear (`0x493540`).
|
||||
pub(super) fn pet_stop_on_old_target_clear(
|
||||
pub(crate) fn pet_stop_on_old_target_clear(
|
||||
selection: Res<Selection>,
|
||||
mut previous: Local<Option<u64>>,
|
||||
mut bar: ResMut<PetBar>,
|
||||
|
||||
@@ -19,7 +19,8 @@ mod net;
|
||||
mod unit;
|
||||
|
||||
use bar::feed_pet_bar;
|
||||
use drain::{drain_pet_actions, pet_stop_on_old_target_clear};
|
||||
use drain::drain_pet_actions;
|
||||
pub(crate) use drain::pet_stop_on_old_target_clear;
|
||||
use menu::{drain_pet_menu, feed_pet_menu};
|
||||
use unit::feed_pet_unit;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The wire standing excludes the race/class base, so the pane's total is base plus wire: vmangos
|
||||
//! stores `standing - BaseRep` (`ReputationMgr.cpp:261`) and reports the sum
|
||||
//! (`ReputationMgr.cpp:82`). The rank is [`benilla_formats::reputation_rank`], shared with the
|
||||
//! unit-reaction decode so the pane and the nameplate agree.
|
||||
//! unit-reaction decode, which caps it at 6, Revered (`0x606439`); the pane shows it uncapped.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
|
||||
@@ -834,6 +834,22 @@ fn pvp_icon_follows_the_three_branch_law() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The target frame's name plate tint, `TargetFrameNameBackground`'s vertex colour as drawn.
|
||||
fn plate_color(s: &mut UiScript) -> [f32; 4] {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.into_iter()
|
||||
.find_map(|q| match q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(p),
|
||||
color: Some(c),
|
||||
..
|
||||
} if p.contains("LevelBackground") => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.expect("target name-plate quad present")
|
||||
}
|
||||
|
||||
/// A friendly player's plate is green when PvP-flagged, else blue (`TargetFrame.lua:163-172`).
|
||||
#[test]
|
||||
fn flagged_friendly_player_plate_is_green() {
|
||||
@@ -842,20 +858,6 @@ fn flagged_friendly_player_plate_is_green() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_unit_frames(&s);
|
||||
|
||||
let plate_color = |s: &mut UiScript| -> [f32; 4] {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.into_iter()
|
||||
.find_map(|q| match q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(p),
|
||||
color: Some(c),
|
||||
..
|
||||
} if p.contains("LevelBackground") => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.expect("target name-plate quad present")
|
||||
};
|
||||
let friendly_player = |pvp: bool| UnitState {
|
||||
exists: true,
|
||||
name: Some("Guildmate".into()),
|
||||
@@ -889,6 +891,65 @@ fn flagged_friendly_player_plate_is_green() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// An NPC of a faction we are Exalted with reads as Revered: `0x606439` caps the rank at 6 and the
|
||||
/// binding adds one (`0x51683e`), so `UnitReaction` answers 7 and stock `TargetFrame_CheckFaction`
|
||||
/// finds the last of `UnitReactionColor`'s seven entries (`TargetFrame.lua:6-14`, `:183`).
|
||||
#[test]
|
||||
fn an_exalted_npc_target_reads_revered_and_its_plate_is_green() {
|
||||
use crate::net::ObjectStore;
|
||||
use crate::target::{stormwind_fixture, HUMAN_WARRIOR};
|
||||
use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE;
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
/// `UNIT_FIELD_BYTES_0`, absolute descriptor index.
|
||||
const BYTES_0: u16 = 36;
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_unit_frames(&s);
|
||||
let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)]));
|
||||
|
||||
for total in [21_000, 42_000] {
|
||||
let (factions, template, reps) = stormwind_fixture(&mut chain, total);
|
||||
let guard = ObjectStore(ObjectFields::from_pairs(&[(
|
||||
FIELD_UNIT_FACTIONTEMPLATE,
|
||||
template,
|
||||
)]));
|
||||
let reaction = crate::ui_unit::unit_reaction(Some(&factions), &reps, &guard, Some(&me));
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
name: Some("Stormwind Guard".into()),
|
||||
health: 100,
|
||||
max_health: 100,
|
||||
level: 55,
|
||||
reaction,
|
||||
is_connected: true,
|
||||
..UnitState::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"standing {total}: script errors: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitReaction("target", "player")"#)
|
||||
.unwrap(),
|
||||
7,
|
||||
"standing {total}"
|
||||
);
|
||||
let green = plate_color(&mut s);
|
||||
assert!(
|
||||
green[0].abs() < 1e-6 && (green[1] - 1.0).abs() < 1e-6 && green[2].abs() < 1e-6,
|
||||
"standing {total}: the plate is UnitReactionColor[7], green, got {green:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock `TargetFrame_CheckClassification` (`TargetFrame.lua:205-218`), asserted on the drawn
|
||||
/// quads: elite, rare-elite and world boss share the Elite art (1.12 ships no rare-elite border),
|
||||
/// and `UNIT_CLASSIFICATION_CHANGED` alone repaints it.
|
||||
|
||||
@@ -12,13 +12,11 @@ use benilla_ui::strings::Arg;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
|
||||
use crate::target::{
|
||||
go_is_nearest, ring_reaction, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC,
|
||||
};
|
||||
use crate::target::{go_is_nearest, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC};
|
||||
use crate::ui_action::{PlayerActions, Spells};
|
||||
use crate::ui_script::UiFeed;
|
||||
use crate::ui_trainer::{TrainerFeed, TrainerTooltipSubjects};
|
||||
use crate::ui_unit::{enrich_unit, snapshot, UnitFeed};
|
||||
use crate::ui_unit::{enrich_unit, snapshot, unit_reaction, UnitFeed};
|
||||
|
||||
pub struct UiTooltipPlugin;
|
||||
|
||||
@@ -629,12 +627,7 @@ fn drive_mouseover_tooltip(
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
let reaction = ring_reaction(
|
||||
rx.factions.as_deref(),
|
||||
&rx.reputations,
|
||||
Some(store),
|
||||
self_store,
|
||||
) + 1;
|
||||
let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store);
|
||||
let mut s = snapshot(store, name, reaction, chr);
|
||||
enrich_unit(
|
||||
&mut s,
|
||||
|
||||
@@ -624,7 +624,7 @@ impl UnitTokens<'_, '_> {
|
||||
}
|
||||
|
||||
/// Every token [`UnitTokens`] resolves, `"player"` included (the reference answers d² = 0).
|
||||
fn reach_tokens() -> impl Iterator<Item = &'static str> {
|
||||
pub(crate) fn reach_tokens() -> impl Iterator<Item = &'static str> {
|
||||
["player", "target", "targettarget", "mouseover", "pet"]
|
||||
.into_iter()
|
||||
.chain(crate::ui_party::PARTY_TOKENS)
|
||||
@@ -743,8 +743,20 @@ pub(crate) struct UnitStores<'w, 's> {
|
||||
edges: MessageReader<'w, 's, FieldChanged>,
|
||||
}
|
||||
|
||||
/// `UnitReaction(unit, "player")` (`0x5167e0`): [`ring_reaction`] plus one (`0x51683e`), so
|
||||
/// `1..=7`, Hated to Revered, and Exalted reads 7 too (`0x606439`). Stock `UnitReactionColor`
|
||||
/// has seven entries (`TargetFrame.lua:6-14`), one per value.
|
||||
pub(crate) fn unit_reaction(
|
||||
factions: Option<&Factions>,
|
||||
reputations: &Reputations,
|
||||
store: &ObjectStore,
|
||||
self_store: Option<&ObjectStore>,
|
||||
) -> u8 {
|
||||
ring_reaction(factions, reputations, Some(store), self_store) + 1
|
||||
}
|
||||
|
||||
/// Build a unit snapshot from a streamed descriptor, its cached name and its `UnitReaction`
|
||||
/// (`1..8`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column.
|
||||
/// (`1..=7`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column.
|
||||
pub(crate) fn snapshot(
|
||||
store: &ObjectStore,
|
||||
name: Option<String>,
|
||||
@@ -1193,13 +1205,12 @@ fn feed_units(
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
// `UnitReaction` (1..8) is the selection ring's 0..7 rank plus one.
|
||||
let reaction = ring_reaction(
|
||||
let reaction = unit_reaction(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
Some(store),
|
||||
store,
|
||||
self_pair.map(|(s, _)| s),
|
||||
) + 1;
|
||||
);
|
||||
let mut s = snapshot(store, name, reaction, chr);
|
||||
s.guid = guid;
|
||||
s.is_connected = true;
|
||||
@@ -1238,12 +1249,12 @@ fn feed_units(
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
let reaction = ring_reaction(
|
||||
let reaction = unit_reaction(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
Some(store),
|
||||
store,
|
||||
self_pair.map(|(s, _)| s),
|
||||
) + 1;
|
||||
);
|
||||
let mut s = snapshot(store, name, reaction, chr);
|
||||
s.guid = guid;
|
||||
s.is_connected = true;
|
||||
@@ -1306,12 +1317,12 @@ fn feed_units(
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
let reaction = ring_reaction(
|
||||
let reaction = unit_reaction(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
Some(store),
|
||||
store,
|
||||
self_pair.map(|(s, _)| s),
|
||||
) + 1;
|
||||
);
|
||||
let mut s = snapshot(store, name, reaction, chr);
|
||||
s.guid = guid;
|
||||
s.is_connected = true;
|
||||
|
||||
@@ -6,20 +6,23 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Stamp the commit this binary was built from into the calling package as four `rustc-env`
|
||||
/// vars, which its `main.rs` reads back with `env!` into a `BuildId`:
|
||||
/// Stamp the commit this binary was built from into the calling package as five `rustc-env`
|
||||
/// vars, which its `main.rs` reads back with `env!` into a `BuildId`, beside its own
|
||||
/// `CARGO_PKG_VERSION`:
|
||||
///
|
||||
/// - `BENILLA_GIT_SHA`: the full sha.
|
||||
/// - `BENILLA_GIT_SHORT`: git's own abbreviation of it.
|
||||
/// - `BENILLA_GIT_DATE`: the commit date (`%cs`, `YYYY-MM-DD`), not the build date.
|
||||
/// - `BENILLA_GIT_DESCRIBE`: `git describe --tags --long` against the nearest release tag
|
||||
/// (`v0.2.0-12-gb17be27`), which says how far past its release the commit is.
|
||||
/// - `BENILLA_PROFILE`: the profile directory's name, which tells `ship` from `release` where
|
||||
/// cargo's `PROFILE` does not.
|
||||
///
|
||||
/// The git vars are empty when git cannot answer (no `.git`, no `git` on `PATH`), which the
|
||||
/// runtime reports as an unknown build. The rerun triggers are `HEAD` and the ref it names,
|
||||
/// resolved with `git rev-parse --git-path` so a linked worktree resolves too; only paths that
|
||||
/// exist are emitted, since cargo reruns a build script whose watched path is missing on every
|
||||
/// build.
|
||||
/// The git vars are empty when git cannot answer (no `.git`, no `git` on `PATH`, no tag in
|
||||
/// reach), which the runtime reports as an unknown commit or the bare version. The rerun
|
||||
/// triggers are `HEAD`, the ref it names, `packed-refs` and the tags, resolved with
|
||||
/// `git rev-parse --git-path` so a linked worktree resolves too; only paths that exist are
|
||||
/// emitted, since cargo reruns a build script whose watched path is missing on every build.
|
||||
pub fn emit() {
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR");
|
||||
let git = |args: &[&str]| -> Option<String> {
|
||||
@@ -44,10 +47,12 @@ pub fn emit() {
|
||||
};
|
||||
watch(git(&["rev-parse", "--git-path", "HEAD"]));
|
||||
if let Some(git_ref) = git(&["symbolic-ref", "-q", "HEAD"]) {
|
||||
// The loose ref, plus `packed-refs`: a fresh clone's branch has no loose file.
|
||||
watch(git(&["rev-parse", "--git-path", &git_ref]));
|
||||
watch(git(&["rev-parse", "--git-path", "packed-refs"]));
|
||||
}
|
||||
// A fresh clone's refs live only in `packed-refs`; a fetched or new tag lands in either, and
|
||||
// moves the describe without moving `HEAD`. Cargo scans a watched directory whole.
|
||||
watch(git(&["rev-parse", "--git-path", "packed-refs"]));
|
||||
watch(git(&["rev-parse", "--git-path", "refs/tags"]));
|
||||
// An edit to the rule restamps: cargo reruns a build script when `build.rs` or one of its
|
||||
// build-dependencies changes.
|
||||
println!("cargo::rerun-if-changed=build.rs");
|
||||
@@ -56,6 +61,10 @@ pub fn emit() {
|
||||
("BENILLA_GIT_SHA", &["rev-parse", "HEAD"][..]),
|
||||
("BENILLA_GIT_SHORT", &["rev-parse", "--short", "HEAD"]),
|
||||
("BENILLA_GIT_DATE", &["log", "-1", "--format=%cs"]),
|
||||
(
|
||||
"BENILLA_GIT_DESCRIBE",
|
||||
&["describe", "--tags", "--long", "--match", "v[0-9]*"],
|
||||
),
|
||||
] {
|
||||
println!(
|
||||
"cargo::rustc-env={var}={}",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! `Cfg_Categories.dbc`, the realm list's category tabs. The reference builds its category set
|
||||
//! once per process (`0x46e300`, from `CGlueMgr::Initialize`) out of every row whose column 1
|
||||
//! equals the client Region ([`crate::client_region`]), in file order: column 0 is the id a
|
||||
//! realm's wire category byte is matched against, and the row's localized name is the tab's
|
||||
//! label (`GetRealmCategories`, `0x46f238`).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{parse, str_at, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const CFG_CATEGORIES: &str = "DBFilesClient\\Cfg_Categories.dbc";
|
||||
|
||||
/// One realm category of the client's Region.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RealmCategory {
|
||||
/// Column 0, compared for equality with a realm's category byte (`0x46e60b`).
|
||||
pub id: u32,
|
||||
/// The enUS `Name_Lang` slot, the column the reference reads on an enUS or enGB client.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// `ID`, `Region`, eight locale names and their flags: the 11 columns × 0x2c bytes the loader
|
||||
/// (`0x5411b0`) gates on.
|
||||
fn cfg_categories_schema() -> Schema {
|
||||
let mut s = Schema::new("Cfg_Categories");
|
||||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new("Region", FieldType::UInt32));
|
||||
for i in 0..8 {
|
||||
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
|
||||
}
|
||||
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
|
||||
s
|
||||
}
|
||||
|
||||
/// The categories of `region`, in file order (`0x46e3b1`: a row joins when its column 1 equals
|
||||
/// the Region). A Region no row names yields none.
|
||||
pub fn load_realm_categories(chain: &mut Chain, region: u32) -> Result<Vec<RealmCategory>> {
|
||||
let bytes = chain
|
||||
.read_file(CFG_CATEGORIES)
|
||||
.with_context(|| format!("reading {CFG_CATEGORIES}"))?;
|
||||
let rs = parse(&bytes, cfg_categories_schema(), "Cfg_Categories")?;
|
||||
Ok(rs
|
||||
.records()
|
||||
.iter()
|
||||
.filter(|r| u32_at(r, 1) == Some(region))
|
||||
.filter_map(|r| {
|
||||
Some(RealmCategory {
|
||||
id: u32_at(r, 0)?,
|
||||
// An empty name is pushed as the empty string; only a missing row reads "UNKNOWN".
|
||||
name: str_at(&rs, r, 2).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rows(region: u32) -> Option<Vec<(u32, String)>> {
|
||||
let data = crate::wow_data_or_skip!(None);
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cats = load_realm_categories(&mut chain, region).expect("Cfg_Categories.dbc");
|
||||
Some(cats.into_iter().map(|c| (c.id, c.name)).collect())
|
||||
}
|
||||
|
||||
/// Region 1 is the US client's: "United States" and "Oceanic", ids 1 and 5, in file order.
|
||||
#[test]
|
||||
fn the_us_region_is_united_states_then_oceanic() {
|
||||
let Some(us) = rows(1) else { return };
|
||||
assert_eq!(
|
||||
us,
|
||||
[(1, "United States".to_string()), (5, "Oceanic".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
/// Region 3 is Europe's four language tabs; the ids are not contiguous, so a category is
|
||||
/// matched by id, never by position.
|
||||
#[test]
|
||||
fn the_european_region_is_four_tabs_with_a_gap_in_the_ids() {
|
||||
let Some(eu) = rows(3) else { return };
|
||||
let ids: Vec<u32> = eu.iter().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids, [1, 2, 3, 5]);
|
||||
assert_eq!(eu[0].1, "English");
|
||||
assert_eq!(eu[3].1, "Spanish");
|
||||
}
|
||||
|
||||
/// No row carries Region 0, the value a client without a `Wow.ini` Region reads.
|
||||
#[test]
|
||||
fn region_zero_has_no_categories() {
|
||||
let Some(none) = rows(0) else { return };
|
||||
assert!(none.is_empty(), "{none:?}");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use crate::Chain;
|
||||
const FACTION_TEMPLATE: &str = "DBFilesClient\\FactionTemplate.dbc";
|
||||
const FACTION: &str = "DBFilesClient\\Faction.dbc";
|
||||
const FACTION_GROUP: &str = "DBFilesClient\\FactionGroup.dbc";
|
||||
const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc";
|
||||
|
||||
/// One `FactionTemplate.dbc` row: the fields the reaction comparator reads.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -36,7 +37,7 @@ pub struct FactionTemplate {
|
||||
}
|
||||
|
||||
/// A unit's base reaction toward another on the reference's scale: `0x606640` returns these three,
|
||||
/// and the reputation path widens the scale to 0..7, hence the gaps.
|
||||
/// and the reputation path widens the scale to 0..=6, hence the gaps.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Reaction {
|
||||
Hostile = 1,
|
||||
@@ -154,9 +155,9 @@ impl FactionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Total standing (base + wire) to rank 0..=7, hated to exalted, which is also the reference's
|
||||
/// extended reaction scale. Widths from −42000: 36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000
|
||||
/// (vmangos `PointsInRank`).
|
||||
/// Total standing (base + wire) to rank 0..=7, hated to exalted, the reference's ladder
|
||||
/// (`0x4d63a0`); the unit reaction takes it capped at 6, Revered (`0x606439`). Widths from −42000:
|
||||
/// 36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000 (vmangos `PointsInRank`).
|
||||
pub fn reputation_rank(total_standing: i32) -> u8 {
|
||||
match total_standing {
|
||||
i32::MIN..=-6001 => 0, // hated
|
||||
@@ -204,9 +205,13 @@ impl Reaction {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FactionTemplate.dbc`, `Faction.dbc` and `FactionGroup.dbc` as id-to-row maps.
|
||||
/// `FactionTemplate.dbc`, `Faction.dbc` and `FactionGroup.dbc` as id-to-row maps, and each
|
||||
/// `ChrRaces.dbc` race's template.
|
||||
pub struct FactionCatalog {
|
||||
templates: HashMap<u32, FactionTemplate>,
|
||||
/// Race id to `ChrRaces.dbc` column 2, `FactionID`, a template id: what a corpse's reaction
|
||||
/// (`0x5d7120`) and a unit's PvP team (`0x5efe00`) read for a race.
|
||||
race_templates: HashMap<u8, u32>,
|
||||
factions: HashMap<u32, FactionInfo>,
|
||||
/// `1 << MaskID` to the localized group name, `GetZonePVPInfo`'s territory line (`0x48d540`).
|
||||
group_names: HashMap<u32, String>,
|
||||
@@ -220,11 +225,39 @@ pub struct FactionCatalog {
|
||||
}
|
||||
|
||||
impl FactionCatalog {
|
||||
/// A catalog of these template rows and race templates alone. Fixture constructor for tests;
|
||||
/// the live path is [`load_faction_catalog`].
|
||||
pub fn from_rows(
|
||||
templates: HashMap<u32, FactionTemplate>,
|
||||
race_templates: HashMap<u8, u32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
templates,
|
||||
race_templates,
|
||||
factions: HashMap::new(),
|
||||
group_names: HashMap::new(),
|
||||
group_internal_names: HashMap::new(),
|
||||
names: HashMap::new(),
|
||||
descriptions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The template row for a `UNIT_FIELD_FACTIONTEMPLATE` id.
|
||||
pub fn template(&self, id: u32) -> Option<&FactionTemplate> {
|
||||
self.templates.get(&id)
|
||||
}
|
||||
|
||||
/// Every race with a `ChrRaces.dbc` row, and its template id.
|
||||
pub fn race_templates(&self) -> impl Iterator<Item = (u8, u32)> + '_ {
|
||||
self.race_templates.iter().map(|(&race, &id)| (race, id))
|
||||
}
|
||||
|
||||
/// A race's template row: its `ChrRaces.dbc` `FactionID`, then `FactionTemplate.dbc`. `None`
|
||||
/// for a race with no row or a template that is not in the table, both the reference's null.
|
||||
pub fn race_template(&self, race: u8) -> Option<&FactionTemplate> {
|
||||
self.template(*self.race_templates.get(&race)?)
|
||||
}
|
||||
|
||||
/// A `Faction.dbc` id's reputation identity, `Some` only with a reputation slot: the
|
||||
/// reference's `FactionHasReputation` (`0x605fc0`), tested before the comparator (`0x606530`).
|
||||
pub fn reputation_faction(&self, faction_id: u32) -> Option<&FactionInfo> {
|
||||
@@ -344,7 +377,21 @@ fn faction_group_schema() -> Schema {
|
||||
s
|
||||
}
|
||||
|
||||
/// Load the three faction tables off the patch chain.
|
||||
/// `ChrRaces.dbc`: 29 fields in build 5875. Only the id and `FactionID` (column 2) are read; the
|
||||
/// string columns are declared so the field count matches.
|
||||
fn chr_races_schema() -> Schema {
|
||||
let mut s = Schema::new("ChrRaces");
|
||||
for i in 0..29 {
|
||||
let ty = match i {
|
||||
15 | 26 | 27 | 28 => FieldType::String,
|
||||
_ => FieldType::UInt32,
|
||||
};
|
||||
s.add_field(SchemaField::new(format!("f{i}"), ty));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Load the three faction tables and the race templates off the patch chain.
|
||||
pub fn load_faction_catalog(chain: &mut Chain) -> Result<FactionCatalog> {
|
||||
let bytes = chain
|
||||
.read_file(FACTION_TEMPLATE)
|
||||
@@ -418,8 +465,19 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result<FactionCatalog> {
|
||||
group_names.insert(1u32 << mask_id, name);
|
||||
}
|
||||
|
||||
let bytes = chain
|
||||
.read_file(CHR_RACES)
|
||||
.with_context(|| format!("reading {CHR_RACES}"))?;
|
||||
let rs = parse(&bytes, chr_races_schema(), "ChrRaces")?;
|
||||
let race_templates = rs
|
||||
.records()
|
||||
.iter()
|
||||
.filter_map(|r| Some((u8::try_from(u32_at(r, 0)?).ok()?, u32_at(r, 2)?)))
|
||||
.collect();
|
||||
|
||||
Ok(FactionCatalog {
|
||||
templates,
|
||||
race_templates,
|
||||
factions,
|
||||
group_names,
|
||||
group_internal_names,
|
||||
|
||||
@@ -51,6 +51,10 @@ mod gm_ticket_category;
|
||||
pub use gm_ticket_category::{
|
||||
load_gm_ticket_categories, GmTicketCategory, GmTicketCategoryCatalog,
|
||||
};
|
||||
mod cfg_categories;
|
||||
pub use cfg_categories::{load_realm_categories, RealmCategory};
|
||||
mod wow_ini;
|
||||
pub use wow_ini::client_region;
|
||||
mod itembagfamily;
|
||||
pub use itembagfamily::{load_item_bag_families, ItemBagFamilyCatalog};
|
||||
mod itemclass;
|
||||
|
||||
@@ -6,45 +6,23 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::dbc::{parse, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc";
|
||||
|
||||
/// Each `ChrRaces.dbc` row's team digit by race id; a race without a row (-1) is absent.
|
||||
pub fn load_race_pvp_teams(chain: &mut Chain) -> Result<HashMap<u8, i8>> {
|
||||
let bytes = chain
|
||||
.read_file(CHR_RACES)
|
||||
.with_context(|| format!("reading {CHR_RACES}"))?;
|
||||
// The string columns are declared only so the field count matches; none is read.
|
||||
let mut schema = Schema::new("ChrRaces");
|
||||
for i in 0..29 {
|
||||
let ty = match i {
|
||||
15 | 26 | 27 | 28 => FieldType::String,
|
||||
_ => FieldType::UInt32,
|
||||
};
|
||||
schema.add_field(SchemaField::new(format!("f{i}"), ty));
|
||||
}
|
||||
let rs = parse(&bytes, schema, "ChrRaces")?;
|
||||
let factions = crate::load_faction_catalog(chain)?;
|
||||
let mut out = HashMap::new();
|
||||
for r in rs.records() {
|
||||
let (Some(race), Some(template)) = (u32_at(r, 0), u32_at(r, 2)) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(race) = u8::try_from(race) else {
|
||||
continue;
|
||||
};
|
||||
// The Horde bit is tested first (`0x5efe42`), so a row with both bits reads Horde.
|
||||
let team = match factions.template(template).map(|t| t.group_mask) {
|
||||
Some(m) if m & 4 != 0 => 0,
|
||||
Some(m) if m & 2 != 0 => 1,
|
||||
_ => -1,
|
||||
};
|
||||
out.insert(race, team);
|
||||
}
|
||||
Ok(out)
|
||||
Ok(factions
|
||||
.race_templates()
|
||||
.map(|(race, template)| {
|
||||
// The Horde bit is tested first (`0x5efe42`), so a row with both bits reads Horde.
|
||||
let team = match factions.template(template).map(|t| t.group_mask) {
|
||||
Some(m) if m & 4 != 0 => 0,
|
||||
Some(m) if m & 2 != 0 => 1,
|
||||
_ => -1,
|
||||
};
|
||||
(race, team)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -366,6 +366,12 @@ impl SpellDisplay {
|
||||
self.attributes_ex3 & ATTR_EX3_NO_CHANNEL_BAR != 0
|
||||
}
|
||||
|
||||
/// `AttributesEx & 0x80000`: the targeting cursor never takes the caster, neither the world
|
||||
/// pick (`0x6e61a0` at `6e61cf`) nor `SpellCanTargetUnit`'s unit leg (`0x6e6460` at `6e6507`).
|
||||
pub fn excludes_caster(&self) -> bool {
|
||||
self.attributes_ex & ATTR_EX_EXCLUDE_CASTER != 0
|
||||
}
|
||||
|
||||
/// `AttributesEx & 0x2000_0000` (`0x6e759a`): the channel bar shows the spell's own name
|
||||
/// (`0x6e75a9`), else `CHANNELING` (`0x6e75bc`); nine channels set it, Fishing among them.
|
||||
pub fn channel_bar_own_name(&self) -> bool {
|
||||
|
||||
@@ -197,6 +197,9 @@ const ATTR_DO_NOT_DISPLAY: u32 = 0x80;
|
||||
pub const SPELL_ATTR_IS_TRADESKILL: u32 = 0x20;
|
||||
/// `SPELL_ATTR_EX_NO_AURA_ICON`: hidden from the aura bar, as all three warrior stances are.
|
||||
const ATTR_EX_NO_AURA_ICON: u32 = 0x1000_0000;
|
||||
/// `SPELL_ATTR_EX_EXCLUDE_CASTER` (vmangos `SpellDefines.h:851`): the targeting cursor never takes
|
||||
/// the caster (`0x6e61cf`, `0x6e6507`).
|
||||
const ATTR_EX_EXCLUDE_CASTER: u32 = 0x0008_0000;
|
||||
|
||||
/// `SPELL_EFFECT_ATTACK`: only 6603 "Attack" has it, but the client tests the effect, not the id.
|
||||
const SPELL_EFFECT_ATTACK: u32 = 78;
|
||||
|
||||
@@ -92,6 +92,11 @@ impl SpellRangeCatalog {
|
||||
self.ranges.get(&index)
|
||||
}
|
||||
|
||||
/// Fixture constructor for tests; the live path is [`load_spell_ranges`].
|
||||
pub fn from_rows(ranges: HashMap<u32, SpellRange>) -> Self {
|
||||
Self { ranges }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.ranges.len()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! `Wow.ini`, the client's `[WoW Config]` section. The reference loads it through the archive
|
||||
//! chain at startup (`0x40249d`, `SFileLoadFile`), and a stock install ships it in
|
||||
//! `interface.MPQ`; `Region` picks the realm categories ([`crate::load_realm_categories`]).
|
||||
|
||||
use crate::Chain;
|
||||
|
||||
const WOW_INI: &str = "Wow.ini";
|
||||
const SECTION: &str = "WoW Config";
|
||||
|
||||
/// The client Region, `[WoW Config] Region` (`0x4024bd` into `[0x882730]`). 0 when the file is
|
||||
/// absent (`0x4024a4` skips the read, leaving the zero-initialised `.data` word) or has no such
|
||||
/// key (`0x44542c` stores 0 before the lookup).
|
||||
pub fn client_region(chain: &Chain) -> u32 {
|
||||
chain
|
||||
.read(WOW_INI)
|
||||
.ok()
|
||||
.and_then(|bytes| {
|
||||
ini_value(&String::from_utf8_lossy(&bytes), SECTION, "Region").map(ini_int)
|
||||
})
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
/// The value of `key` in `[section]`. Both names compare case-insensitively (`SStrCmpI` at
|
||||
/// `0x445356` and `0x4453cb`).
|
||||
fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> {
|
||||
let mut in_section = false;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
in_section = name.trim().eq_ignore_ascii_case(section);
|
||||
} else if let (true, Some((k, v))) = (in_section, line.split_once('=')) {
|
||||
if k.trim().eq_ignore_ascii_case(key) {
|
||||
return Some(v.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An integer value as `0x445470` reads one: a quoted run of up to four characters packed
|
||||
/// big-endian, else `SStrToInt` (`0x64ac60`), an optional `-` then decimal digits up to the first
|
||||
/// non-digit, 0 when there are none.
|
||||
fn ini_int(value: &str) -> i32 {
|
||||
if let Some(quoted) = value.strip_prefix('\'') {
|
||||
return quoted
|
||||
.bytes()
|
||||
.take_while(|&b| b != b'\'')
|
||||
.take(4)
|
||||
.fold(0i32, |acc, b| (acc << 8) | i32::from(b));
|
||||
}
|
||||
let (negative, digits) = match value.strip_prefix('-') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, value),
|
||||
};
|
||||
let n = digits
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_digit)
|
||||
.fold(0i32, |acc, d| {
|
||||
acc.wrapping_mul(10).wrapping_add(i32::from(d - b'0'))
|
||||
});
|
||||
if negative {
|
||||
n.wrapping_neg()
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_region_is_read_from_the_config_section_only() {
|
||||
let ini = "[Other]\r\nRegion=7\r\n[WoW Config]\r\nVersion=1\r\nregion=3\r\n";
|
||||
assert_eq!(ini_value(ini, SECTION, "Region"), Some("3"));
|
||||
assert_eq!(
|
||||
ini_value("[wow config]\nRegion = 1\n", SECTION, "Region"),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
ini_value("[WoW Config]\nVersion=1\n", SECTION, "Region"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_parses_as_the_reference_parses_it() {
|
||||
assert_eq!(ini_int("3"), 3);
|
||||
assert_eq!(ini_int("101"), 101);
|
||||
assert_eq!(ini_int("-2"), -2);
|
||||
assert_eq!(ini_int("12abc"), 12, "digits up to the first non-digit");
|
||||
assert_eq!(ini_int("abc"), 0);
|
||||
assert_eq!(ini_int("'AB'"), 0x4142, "a quoted run packs its bytes");
|
||||
}
|
||||
|
||||
/// The install carries a `Wow.ini` in its archives, and its Region names categories.
|
||||
#[test]
|
||||
fn the_install_names_a_region_with_categories() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
assert!(chain.contains(WOW_INI), "Wow.ini is not in the patch chain");
|
||||
let region = client_region(&chain);
|
||||
assert_ne!(region, 0, "the shipped Wow.ini names a Region");
|
||||
let categories =
|
||||
crate::load_realm_categories(&mut chain, region).expect("Cfg_Categories.dbc");
|
||||
assert!(!categories.is_empty(), "Region {region} has no categories");
|
||||
}
|
||||
}
|
||||
@@ -461,6 +461,9 @@ pub enum UseItemTarget {
|
||||
/// `TARGET_FLAG_SOURCE_LOCATION` and three `f32` coords (`BindLocation`, `0x6e60f0`); only
|
||||
/// items carrying spell 265, such as Martin Fury, use it. vmangos reads it before the dest.
|
||||
Source([f32; 3]),
|
||||
/// A corpse bit and the corpse's packed guid: Goblin Jumper Cables' Defibrillate
|
||||
/// (`Targets 0x8000`) on a released player's corpse.
|
||||
Corpse(super::spells::CorpseTarget, u64),
|
||||
}
|
||||
|
||||
/// Body of `CMSG_USE_ITEM` (opcode 171): bag index (a bag's player-array slot 19-22, or
|
||||
@@ -487,6 +490,10 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget)
|
||||
body.extend_from_slice(&TARGET_FLAG_ITEM.to_le_bytes());
|
||||
guid
|
||||
}
|
||||
UseItemTarget::Corpse(corpse, guid) => {
|
||||
body.extend_from_slice(&corpse.target_flag().to_le_bytes());
|
||||
guid
|
||||
}
|
||||
UseItemTarget::Dest(dest) => {
|
||||
body.extend_from_slice(&TARGET_FLAG_DEST_LOCATION.to_le_bytes());
|
||||
for c in dest {
|
||||
|
||||
@@ -223,8 +223,9 @@ pub use social::{
|
||||
};
|
||||
pub use spellbook::SpellCooldown;
|
||||
pub use spells::{
|
||||
cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_at_source, cast_spell_gameobject,
|
||||
cast_spell_item, CastOutcome, SpellCastTargets, SpellChainTargets, SpellGo, SpellStart,
|
||||
cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_at_source, cast_spell_corpse,
|
||||
cast_spell_gameobject, cast_spell_item, CastOutcome, CorpseTarget, SpellCastTargets,
|
||||
SpellChainTargets, SpellGo, SpellStart,
|
||||
};
|
||||
pub use stable::{
|
||||
buy_stable_slot, list_stabled_pets, stable_pet, stable_result, stable_swap_pet, unstable_pet,
|
||||
|
||||
@@ -304,6 +304,38 @@ pub fn cast_spell_gameobject(spell_id: u32, go_guid: u64) -> Vec<u8> {
|
||||
body
|
||||
}
|
||||
|
||||
/// The corpse bit a corpse cast carries: `BindTarget 0x6e5b40`'s corpse arm writes `0x8000` for a
|
||||
/// corpse its caster's reaction toward is friendly (`0x6e5fc7`), `0x200` for one it is not
|
||||
/// (`0x6e600c`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CorpseTarget {
|
||||
/// `TARGET_FLAG_CORPSE_ALLY`, a resurrection's.
|
||||
Ally,
|
||||
/// `TARGET_FLAG_CORPSE_ENEMY`.
|
||||
Enemy,
|
||||
}
|
||||
|
||||
impl CorpseTarget {
|
||||
/// The wire mask bit, which is also the flag-word bit the bind clears.
|
||||
pub const fn target_flag(self) -> u16 {
|
||||
match self {
|
||||
Self::Ally => TARGET_FLAG_CORPSE_ALLY,
|
||||
Self::Enemy => TARGET_FLAG_CORPSE_ENEMY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of `CMSG_CAST_SPELL` at a corpse: the corpse bit and the corpse's packed guid. The client's
|
||||
/// targets writer `0x7e4e10` puts the guid for any mask bit in `0x8a02`; vmangos reads it for
|
||||
/// either corpse bit (`SpellCastTargetsInfo.cpp:156-157`).
|
||||
pub fn cast_spell_corpse(spell_id: u32, target: CorpseTarget, corpse_guid: u64) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(16);
|
||||
body.extend_from_slice(&spell_id.to_le_bytes());
|
||||
body.extend_from_slice(&target.target_flag().to_le_bytes());
|
||||
crate::wire::write_packed_guid(corpse_guid, &mut body).expect("vec write");
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_CAST_SPELL` at an item, such as an enchant or poison: `TARGET_FLAG_ITEM` and the
|
||||
/// item's packed guid (`SpellCastTargetsInfo.cpp:159-160`).
|
||||
pub fn cast_spell_item(spell_id: u32, item_guid: u64) -> Vec<u8> {
|
||||
@@ -363,6 +395,28 @@ mod tests {
|
||||
assert_eq!(cast_spell(1, None), [0x01, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||
}
|
||||
|
||||
/// Resurrection (2006) at a released player's corpse, `HIGHGUID_CORPSE` 0xF101
|
||||
/// (vmangos `ObjectGuid.h:76`), counter 0x2A.
|
||||
#[test]
|
||||
fn cast_spell_corpse_body_golden() {
|
||||
const CORPSE: u64 = 0xF101_0000_0000_002A;
|
||||
assert_eq!(
|
||||
cast_spell_corpse(2006, CorpseTarget::Ally, CORPSE),
|
||||
[
|
||||
0xD6, 0x07, 0x00, 0x00, // spell id 2006
|
||||
0x00, 0x80, // TARGET_FLAG_CORPSE_ALLY
|
||||
0xC1, 0x2A, 0x01, 0xF1, // packed guid: bytes 0, 6 and 7
|
||||
],
|
||||
"CMSG_CAST_SPELL (ally corpse) body"
|
||||
);
|
||||
// The enemy arm differs only in the mask.
|
||||
assert_eq!(
|
||||
cast_spell_corpse(2006, CorpseTarget::Enemy, CORPSE),
|
||||
[0xD6, 0x07, 0x00, 0x00, 0x00, 0x02, 0xC1, 0x2A, 0x01, 0xF1],
|
||||
"CMSG_CAST_SPELL (enemy corpse) body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_spell_at_dest_body_golden() {
|
||||
// Spell 10 is Blizzard.
|
||||
|
||||
@@ -437,6 +437,11 @@ impl ObjectFields {
|
||||
fn corpse_bytes_2(&self) -> Option<u32> {
|
||||
self.get_u32(33)
|
||||
}
|
||||
/// The dead player's race, `CORPSE_FIELD_BYTES_1` byte 1, the one corpse field the reaction
|
||||
/// gate reads (`0x5d7120`); 0, which no `ChrRaces` row names, when absent.
|
||||
pub fn corpse_race(&self) -> u8 {
|
||||
self.corpse_bytes_1().map_or(0, |b1| (b1 >> 8) as u8)
|
||||
}
|
||||
/// The corpse's appearance, `None` in a delta that lacks either BYTES word.
|
||||
pub fn corpse_look(&self) -> Option<CorpseLook> {
|
||||
let (b1, b2) = (self.corpse_bytes_1()?, self.corpse_bytes_2()?);
|
||||
|
||||
@@ -32,6 +32,20 @@ impl WorldWriter {
|
||||
)
|
||||
}
|
||||
|
||||
/// `CMSG_CAST_SPELL` at a corpse: a corpse bit and the corpse's packed guid, a resurrection on
|
||||
/// a released player. The server resurrects the corpse's owner (`Spell.cpp:3055-3064`).
|
||||
pub fn cast_spell_corpse(
|
||||
&mut self,
|
||||
spell_id: u32,
|
||||
target: messages::CorpseTarget,
|
||||
corpse_guid: u64,
|
||||
) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_CAST_SPELL,
|
||||
&messages::cast_spell_corpse(spell_id, target, corpse_guid),
|
||||
)
|
||||
}
|
||||
|
||||
/// `CMSG_CAST_SPELL` with `TARGET_FLAG_ITEM` and the item's packed guid: an enchant on the item
|
||||
/// picked in the craft frame.
|
||||
pub fn cast_spell_item(&mut self, spell_id: u32, item_guid: u64) -> Result<()> {
|
||||
|
||||
@@ -78,6 +78,19 @@ fn item_query_wire() {
|
||||
hx("ff18001000c3cdab50f1")
|
||||
);
|
||||
|
||||
// A corpse target (Goblin Jumper Cables' Defibrillate, `Targets 0x8000`): the corpse bit and
|
||||
// the packed guid, the block `cast_spell_corpse` writes; vmangos reads the guid for either
|
||||
// corpse bit (`SpellCastTargetsInfo.cpp:156-157`). 0xF101_0000_0000_002A packs to mask 0xC1.
|
||||
assert_eq!(
|
||||
messages::use_item(
|
||||
255,
|
||||
24,
|
||||
0,
|
||||
messages::UseItemTarget::Corpse(messages::CorpseTarget::Ally, 0xF101_0000_0000_002A)
|
||||
),
|
||||
hx("ff18000080c12a01f1")
|
||||
);
|
||||
|
||||
// CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot.
|
||||
assert_eq!(messages::auto_equip_item(255, 25), hx("ff19"));
|
||||
|
||||
|
||||
@@ -403,9 +403,10 @@ impl super::UiScript {
|
||||
self.model_mut().item_pick_armed = armed;
|
||||
}
|
||||
|
||||
/// Push what `SpellCanTargetUnit` answers: whether the armed targeting cast can take a unit.
|
||||
pub fn set_spell_can_target_unit(&mut self, can: bool) {
|
||||
self.model_mut().spell_can_target_unit = can;
|
||||
/// Push the unit tokens for which the armed word's unit checks all pass.
|
||||
pub fn set_spell_targetable_units<'a>(&mut self, tokens: impl IntoIterator<Item = &'a str>) {
|
||||
self.model_mut().spell_targetable_units =
|
||||
tokens.into_iter().map(str::to_ascii_lowercase).collect();
|
||||
}
|
||||
|
||||
/// Drain the `(bag, slot)` picks since the last call; a paper-doll pick reports as
|
||||
|
||||
@@ -243,6 +243,8 @@ pub(crate) struct Model {
|
||||
pub(crate) target_clear: bool,
|
||||
/// `DropItemOnUnit` tokens (`0x48d960`), gated by the app; a refusal silently keeps the item.
|
||||
pub(crate) drop_item_on_unit: Vec<String>,
|
||||
/// `SpellTargetUnit` tokens: a unit frame binds the spell currently waiting on the cursor.
|
||||
pub(crate) spell_target_unit: Vec<String>,
|
||||
|
||||
/// Channels the server confirmed, in join order: the numbers `GetChannelName` answers.
|
||||
pub(crate) joined_channels: Vec<Option<String>>,
|
||||
@@ -432,9 +434,9 @@ pub(crate) struct Model {
|
||||
/// Spell targeting is on (`SpellIsTargeting`, `0x6e6cd0`); it gates `SpellStopTargeting()`,
|
||||
/// whose nil the ESC chain falls through on (`UIParent.lua:1490`).
|
||||
pub(crate) spell_targeting: bool,
|
||||
/// `SpellCanTargetUnit` (`0x6e6d00`, `0x6e6460`): false while targeting models only location,
|
||||
/// item and object words; a unit-target spell resolves or refuses without entering targeting.
|
||||
pub(crate) spell_can_target_unit: bool,
|
||||
/// Tokens for which `SpellCanTargetUnit`'s armed unit word clears fully, used by stock unit
|
||||
/// frames before they call `SpellTargetUnit`.
|
||||
pub(crate) spell_targetable_units: HashSet<String>,
|
||||
/// `SpellStopTargeting()` fired while targeting: the ESC targeting cancel.
|
||||
pub(crate) spell_stop_targeting: bool,
|
||||
|
||||
@@ -1061,6 +1063,7 @@ impl Model {
|
||||
target_nearest_friend_requests: Vec::new(),
|
||||
target_by_name_requests: Vec::new(),
|
||||
drop_item_on_unit: Vec::new(),
|
||||
spell_target_unit: Vec::new(),
|
||||
target_clear: false,
|
||||
joined_channels: Vec::new(),
|
||||
party: party::PartyState::default(),
|
||||
@@ -1148,7 +1151,7 @@ impl Model {
|
||||
casting: false,
|
||||
spell_stop: false,
|
||||
spell_targeting: false,
|
||||
spell_can_target_unit: false,
|
||||
spell_targetable_units: HashSet::new(),
|
||||
spell_stop_targeting: false,
|
||||
talents: super::talent::TalentUiState::default(),
|
||||
talent_learns: Vec::new(),
|
||||
|
||||
@@ -596,14 +596,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the targeting
|
||||
// word can take the unit. The token is not read: no word benilla can arm (location, item,
|
||||
// gameobject) takes a unit, and unit words are not built.
|
||||
// SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the standing
|
||||
// word clears against that resolved unit.
|
||||
g.set(
|
||||
"SpellCanTargetUnit",
|
||||
lua.create_function(|lua, _unit: Option<String>| {
|
||||
lua.create_function(|lua, unit: Option<String>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
if model.spell_can_target_unit {
|
||||
let can = unit.is_some_and(|unit| {
|
||||
model
|
||||
.spell_targetable_units
|
||||
.contains(&unit.to_ascii_lowercase())
|
||||
});
|
||||
if can {
|
||||
Ok(Value::Boolean(true))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
|
||||
@@ -1079,12 +1079,31 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SpellTargetUnit(unit): a no-op. A unit-target spell never enters targeting mode here, and for
|
||||
// the location, item and gameobject modes this client arms, the reference's `BindTarget`
|
||||
// (`0x6e5b40`) rejects a unit too. Stock `PetFrame_OnClick` calls it, so it must exist.
|
||||
// SpellTargetUnit(unit) (`0x6e6d90`): an argument that is not a string or a number raises
|
||||
// `Usage:`; while not targeting the call is a silent no-op, before the token is read; an
|
||||
// unknown token then raises `Unknown unit name`. The token queues for the host, which ends
|
||||
// targeting with "Out of range." when it names no unit and otherwise runs `BindTarget`
|
||||
// (`0x6e5b10` → `0x6e5b40`).
|
||||
g.set(
|
||||
"SpellTargetUnit",
|
||||
lua.create_function(|_, _token: Option<String>| Ok(()))?,
|
||||
lua.create_function(|lua, token: Value| {
|
||||
let token = crate::script::binding_abi::string_arg(
|
||||
lua,
|
||||
token,
|
||||
r#"Usage: SpellTargetUnit("unit")"#,
|
||||
)?;
|
||||
if !lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.spell_targeting
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
check_unit_token(&Some(token.clone()))?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.spell_target_unit.push(token);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ClearTarget(): 1 when it cleared a target, nil when there was none, which `ToggleGameMenu`'s
|
||||
|
||||
@@ -91,8 +91,9 @@ pub struct UnitState {
|
||||
pub charmed: bool,
|
||||
/// `UnitIsGhost`: `PLAYER_FLAGS` bit `0x10`, so players only.
|
||||
pub ghost: bool,
|
||||
/// The reaction toward the player on `UnitReaction`'s scale, 1 hated to 8 exalted, 0 unknown
|
||||
/// (nil). Fed for the tokens naming another unit; `"player"` and `"pet"` stay 0.
|
||||
/// The reaction toward the player on `UnitReaction`'s scale, 1 hated to 7 revered, which
|
||||
/// Exalted reads too (`0x606439` caps the rank), 0 unknown (nil). Fed for the tokens naming
|
||||
/// another unit; `"player"` and `"pet"` stay 0.
|
||||
pub reaction: u8,
|
||||
/// `UnitRace`'s localized first return, from `UNIT_FIELD_BYTES_0` byte 0; `None` answers
|
||||
/// `nil, nil`.
|
||||
@@ -411,6 +412,11 @@ impl super::UiScript {
|
||||
pub fn take_drop_item_on_unit(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.model_mut().drop_item_on_unit)
|
||||
}
|
||||
|
||||
/// Drain `SpellTargetUnit` calls for the host's pending unit-target spell binder.
|
||||
pub fn take_spell_target_unit(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.model_mut().spell_target_unit)
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-`"player"` token of a two-unit call such as `UnitIsEnemy(a, b)`, which the snapshot
|
||||
|
||||
@@ -23,6 +23,56 @@ fn player() -> UnitState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x6e6d90`'s order: the `Usage:` check, the not-targeting no-op, the token check, the queue.
|
||||
#[test]
|
||||
fn spell_target_unit_checks_in_the_references_order() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let usage = |s: &mut UiScript, call: &str| {
|
||||
let err = s.run(call).expect_err(call).to_string();
|
||||
assert!(
|
||||
err.contains(r#"Usage: SpellTargetUnit("unit")"#),
|
||||
"{call}: {err}"
|
||||
);
|
||||
};
|
||||
|
||||
// Not targeting: the argument is still type-checked, then nothing, not even the token check.
|
||||
usage(&mut s, "SpellTargetUnit({})");
|
||||
usage(&mut s, "SpellTargetUnit()");
|
||||
s.run(r#"SpellTargetUnit("not-a-unit") SpellTargetUnit("player")"#)
|
||||
.expect("a bad token is no error while not targeting");
|
||||
assert!(s.take_spell_target_unit().is_empty(), "and nothing queues");
|
||||
|
||||
// Targeting: an unknown token raises, a known one queues, resolved or not.
|
||||
s.set_spell_targeting(true);
|
||||
usage(&mut s, "SpellTargetUnit(nil)");
|
||||
let err = s
|
||||
.run(r#"SpellTargetUnit("not-a-unit")"#)
|
||||
.expect_err("an unknown token while targeting")
|
||||
.to_string();
|
||||
assert!(err.contains("Unknown unit name: not-a-unit"), "{err}");
|
||||
s.run(r#"SpellTargetUnit("player") SpellTargetUnit("party1")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_spell_target_unit(),
|
||||
vec!["player".to_string(), "party1".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spell_can_target_unit_answers_per_validated_token() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_spell_targetable_units(["player", "party1"]);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return SpellCanTargetUnit("player") == true"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return SpellCanTargetUnit("PARTY1") == true"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return SpellCanTargetUnit("target") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_reaction_reports_the_scale_value_or_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
//! Which build is running: the commit the binary was built from, stamped at compile time by the
|
||||
//! launcher shims' build script (`benilla-buildstamp`) and passed to `run` as the [`BuildId`]
|
||||
//! resource. It shows in the startup log line ([`banner`]) and in the debug panel's footer.
|
||||
//! Which build is running: the release and the commit the binary was built from, stamped at
|
||||
//! compile time by the launcher shims' build script (`benilla-buildstamp`) and passed to `run` as
|
||||
//! the [`BuildId`] resource. It shows in the startup log line ([`banner`]), in crash reports and in
|
||||
//! the debug panel's footer.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The launcher shim's compile-time git stamp. The git fields are empty when the build had no
|
||||
/// checkout or no `git` on `PATH`, which [`BuildId::summary`] reports as an unknown build.
|
||||
/// The launcher shim's compile-time stamp. The git fields are empty when the build had no
|
||||
/// checkout or no `git` on `PATH`, which [`BuildId::summary`] reports as an unknown commit.
|
||||
#[derive(Resource, Clone, Copy)]
|
||||
pub struct BuildId {
|
||||
/// The workspace version from `Cargo.toml`, which every release bumps.
|
||||
pub version: &'static str,
|
||||
/// `git describe --tags --long` against the nearest `v*` tag (`v0.2.0-12-gb17be27`); empty
|
||||
/// when no release tag is in reach, as in a source archive or a shallow clone.
|
||||
pub describe: &'static str,
|
||||
/// The full sha, which the debug panel copies.
|
||||
pub sha: &'static str,
|
||||
/// Git's own abbreviation of [`sha`](Self::sha).
|
||||
@@ -19,17 +25,106 @@ pub struct BuildId {
|
||||
}
|
||||
|
||||
impl BuildId {
|
||||
/// The one-line build id: short sha, commit date and profile.
|
||||
pub fn summary(&self) -> String {
|
||||
if self.short.is_empty() {
|
||||
format!("unknown (built without a git checkout) · {}", self.profile)
|
||||
} else {
|
||||
format!("{} · {} · {}", self.short, self.date, self.profile)
|
||||
/// The release this build is: the version, and `+N` when the commit is N past that version's
|
||||
/// tag. With no tag for this version in reach (none fetched, or the release commit before its
|
||||
/// tag exists), the version alone.
|
||||
pub fn release(&self) -> String {
|
||||
match commits_past_tag(self.describe, self.version) {
|
||||
Some(n) if n > 0 => format!("{}+{n}", self.version),
|
||||
_ => self.version.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-line build id: release, short sha, commit date and profile.
|
||||
pub fn summary(&self) -> String {
|
||||
if self.short.is_empty() {
|
||||
format!(
|
||||
"{} · unknown commit (built without a git checkout) · {}",
|
||||
self.release(),
|
||||
self.profile
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} · {} · {} · {}",
|
||||
self.release(),
|
||||
self.short,
|
||||
self.date,
|
||||
self.profile
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many commits past `v{version}` a `--long` describe (`<tag>-<n>-g<hash>`) is, or `None`
|
||||
/// when the describe is empty, malformed or names another version's tag.
|
||||
fn commits_past_tag(describe: &str, version: &str) -> Option<u32> {
|
||||
let mut parts = describe.rsplitn(3, '-');
|
||||
parts.next().filter(|hash| hash.starts_with('g'))?;
|
||||
let n: u32 = parts.next()?.parse().ok()?;
|
||||
let tag = parts.next()?;
|
||||
(tag.strip_prefix('v')? == version).then_some(n)
|
||||
}
|
||||
|
||||
/// Log the build id once at startup, not env-gated, so every pasted log carries it.
|
||||
pub fn banner(build: Res<BuildId>) {
|
||||
info!("benilla build {}", build.summary());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build(version: &'static str, describe: &'static str, short: &'static str) -> BuildId {
|
||||
BuildId {
|
||||
version,
|
||||
describe,
|
||||
sha: "",
|
||||
short,
|
||||
date: "2026-09-27",
|
||||
profile: "release",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tagged_release_reads_as_its_version() {
|
||||
let b = build("0.2.0", "v0.2.0-0-gb17be27", "b17be27");
|
||||
assert_eq!(b.summary(), "0.2.0 · b17be27 · 2026-09-27 · release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_past_the_release_counts_its_distance() {
|
||||
assert_eq!(
|
||||
build("0.2.0", "v0.2.0-12-gb17be27", "b17be27").release(),
|
||||
"0.2.0+12"
|
||||
);
|
||||
// A pre-release tag's own hyphens stay in the tag.
|
||||
assert_eq!(
|
||||
build("1.0.0-rc1", "v1.0.0-rc1-3-gabc1234", "abc1234").release(),
|
||||
"1.0.0-rc1+3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_versions_tag_or_none_reads_as_the_version_alone() {
|
||||
// The release commit itself, before its tag exists: the nearest tag is the previous one.
|
||||
assert_eq!(
|
||||
build("0.2.0", "v0.1.0-450-gb17be27", "b17be27").release(),
|
||||
"0.2.0"
|
||||
);
|
||||
// No release tag in reach: a source archive or a shallow clone.
|
||||
assert_eq!(build("0.2.0", "", "b17be27").release(), "0.2.0");
|
||||
assert_eq!(
|
||||
build("0.2.0", "not-a-describe", "b17be27").release(),
|
||||
"0.2.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_without_git_still_names_its_release() {
|
||||
let b = build("0.2.0", "", "");
|
||||
assert_eq!(
|
||||
b.summary(),
|
||||
"0.2.0 · unknown commit (built without a git checkout) · release"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use benilla_world::build_id::BuildId;
|
||||
|
||||
fn main() -> benilla_world::AppExit {
|
||||
benilla_world::worldview::run(BuildId {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
describe: env!("BENILLA_GIT_DESCRIBE"),
|
||||
sha: env!("BENILLA_GIT_SHA"),
|
||||
short: env!("BENILLA_GIT_SHORT"),
|
||||
date: env!("BENILLA_GIT_DATE"),
|
||||
|
||||
@@ -5,6 +5,8 @@ use benilla_app::BuildId;
|
||||
|
||||
fn main() -> benilla_app::AppExit {
|
||||
benilla_app::run(BuildId {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
describe: env!("BENILLA_GIT_DESCRIBE"),
|
||||
sha: env!("BENILLA_GIT_SHA"),
|
||||
short: env!("BENILLA_GIT_SHORT"),
|
||||
date: env!("BENILLA_GIT_DATE"),
|
||||
|
||||
@@ -9,8 +9,10 @@ fork, and forks are welcome.
|
||||
|
||||
The [issues](https://github.com/samwhosung/benilla/issues) the maintainers file are checked
|
||||
against 1.12.1 first: each says what 1.12.1 does, what benilla does instead, and where in the
|
||||
code, and any of them is a place to start. Before you start one, look for an open pull request
|
||||
that already covers it, and say `Fixes #N` in yours.
|
||||
code, and any of them is a place to start. One labelled `in progress` is taken: someone is
|
||||
working on it or a pull request is open for it, so pick another. Once you start one, open a draft
|
||||
pull request that says `Fixes #N` as soon as you have a first commit, so the issue shows it is
|
||||
taken.
|
||||
|
||||
## What gets in
|
||||
|
||||
@@ -71,8 +73,10 @@ out of scope is closed with the reason.
|
||||
`localhost:3724`). A scripted run has no default account: `WOW_USER`, `WOW_PASS` and
|
||||
`WOW_CHAR` name a test account on your server whose login kicks nobody, all three, either in
|
||||
the environment or in a `.probe-identity` file at the repo root (one per line, never
|
||||
committed), and `scripts/smoke.sh` (the live login gate) refuses without them. The probes
|
||||
drive the body with GM commands, so give that account the top GM level.
|
||||
committed), and `scripts/smoke.sh` (the live login gate) refuses without them. `WOW_CHAR`
|
||||
must already be on the account, since the client creates a character only when asked:
|
||||
`WOW_PROBE_CHARCREATE=<name> WOW_PROBE_CHARCREATE_KEEP=1`, run without `WOW_CHAR`, makes it.
|
||||
The probes drive the body with GM commands, so give that account the top GM level.
|
||||
- **Running it unattended.** The rules are `docs/METHOD.md`, "The local server"; these are the
|
||||
switches.
|
||||
- `WOW_UNATTENDED=1` reconnects instead of waiting at a dialog, and exits non-zero on a login
|
||||
|
||||
+2
-2
@@ -104,7 +104,7 @@
|
||||
- **benilla-adt** (lib.rs): combined_alpha
|
||||
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select character_shadow chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim cursor cvars death debug_panel dev doodad_events dynamic_interior entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap monkey_gfx name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot shaders shadow_core shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput torch_shadow transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_hide ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video volumetric_fog vplates weapon_trail world_backdrop world_shadow world_state world_state_ui
|
||||
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace water_depth wdt wmo world_assets
|
||||
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions fire_light footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons room_claim server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui zone_map
|
||||
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions fire_light footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons room_claim server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
|
||||
- **benilla-m2** (lib.rs): error model skin tests track
|
||||
- **benilla-mpq** (lib.rs): crypto
|
||||
- **benilla-protocol** (lib.rs): auth events guid messages wire world
|
||||
@@ -192,7 +192,7 @@
|
||||
- `WOW_CAPTURE_UI` — benilla-app/src/capture/scenarios.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/video.rs
|
||||
- `WOW_CAPTURE_WATER_T` — benilla-world/src/liquid/surface.rs, benilla-world/src/liquid/waves.rs
|
||||
- `WOW_CAST_TRACE` — benilla-app/src/net.rs
|
||||
- `WOW_CHAR` — benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/net/io.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs
|
||||
- `WOW_CHAR` — benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs
|
||||
- `WOW_CHARCREATE_DIALS` — benilla-app/src/char_create/mod.rs
|
||||
- `WOW_CHARCREATE_NAME` — benilla-app/src/char_create/mod.rs
|
||||
- `WOW_CHARCREATE_PICK` — benilla-app/src/capture/mod.rs, benilla-app/src/char_create/mod.rs
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
channel = "1.98.1"
|
||||
components = ["clippy", "rustfmt"]
|
||||
|
||||
Reference in New Issue
Block a user