Merge graphics-programme (upstream v0.2.0 sync) into main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-27 17:53:47 +01:00
co-authored by Claude Opus 5.5
67 changed files with 4080 additions and 434 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>"]
+15 -4
View File
@@ -130,11 +130,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
+6
View File
@@ -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
+3 -1
View File
@@ -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;
}
+9 -1
View File
@@ -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
+3 -1
View File
@@ -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",
+1 -9
View File
@@ -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.
+9 -3
View File
@@ -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
@@ -706,8 +711,9 @@ pub(crate) mod schedule_tests {
/// readers; the sky clock and tier before the consume set; the grass benders). What remains:
/// `skybox::animate_skyboxes` against the `MatAnimTable` allocators (it writes only its own
/// rows) and one pair each of the new systems against the two exclusive systems. Upstream
/// 5,463 + ours = 5,488, read off the merged tree (upstream b17be271).
const UPDATE_ACTIONABLE_CEILING: usize = 5_488;
/// 5,481 + ours = 5,509, read off the merged tree (upstream v0.2.0 ba7fe6e2; three more of ours
/// pair with upstream's new v0.2.0 systems).
const UPDATE_ACTIONABLE_CEILING: usize = 5_509;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
+24 -1
View File
@@ -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 {} \
+7 -1
View File
@@ -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,
+14 -44
View File
@@ -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?}"
);
}
+179 -13
View File
@@ -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");
}
}
+6 -2
View File
@@ -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);
}
+77 -65
View File
@@ -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.
+374 -10
View File
@@ -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"]);
}
}
+17 -12
View File
@@ -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,
+120 -37
View File
@@ -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,
+16 -3
View File
@@ -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));
}
}
+510 -15
View File
@@ -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]
+293 -18
View File
@@ -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() {
+288 -20
View File
@@ -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]
+12 -7
View File
@@ -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");
}
}
+58 -9
View File
@@ -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]
+1 -1
View File
@@ -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>,
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -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.
+3 -10
View File
@@ -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,
+23 -12
View File
@@ -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;
+18 -9
View File
@@ -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:?}");
}
}
+64 -6
View File
@@ -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,
+4
View File
@@ -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;
+13 -35
View File
@@ -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 {
+3
View File
@@ -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()
}
+107
View File
@@ -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 {
+3 -2
View File
@@ -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<()> {
+13
View File
@@ -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"));
+4 -3
View File
@@ -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
+7 -4
View File
@@ -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(),
+9 -5
View File
@@ -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)
+23 -4
View File
@@ -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
+8 -2
View File
@@ -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();
+106 -11
View File
@@ -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"
);
}
}
+2
View File
@@ -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"),
+2
View File
@@ -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"),
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "stable"
channel = "1.98.1"
components = ["clippy", "rustfmt"]