Files
benilla-octo/crates/benilla-ui/src/script/battlefield_queue.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

614 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The battleground list and queue verbs of `BattlefieldFrame.lua` and `Minimap.xml`. They read
//! the reference's instance list (`0xb6e860`, filled by `0x4aa6c0`), its three queue slots
//! (`0xb6e9d0`, written by `0x4aa850`) and the selected instance id (`[0xb6eba0]`); the app pushes
//! the list and the slots, clock values in ms, and the VM owns the selection.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{bool_or_default, flag, number_arg};
use super::Model;
/// One queue slot: the reference's `0x20`-byte slot (`0xb6e9d0`) plus its Map.dbc name.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct BattlefieldQueueSlot {
/// `+0x00`, the Map.dbc row id; 0 is a cleared slot, its name looked up like any other.
pub map_id: u32,
/// The localized map name, or `None` when the id has no row (a real `nil`).
pub map_name: Option<String>,
/// `+0x04`: `0` none, `1` queued, `2` confirm, `3` active; anything else answers `"error"`.
pub status: u32,
/// `+0x10`.
pub instance_id: u32,
/// `+0x08`/`+0x0c`: the bracket-adjusted level pair.
pub min_level: u32,
pub max_level: u32,
/// `GetBattlefieldPortExpiration`: `deadline − now` in ms, 0 when unset or past.
pub port_expiration_ms: u32,
/// `GetBattlefieldEstimatedWaitTime`: the stored value, raw (no clock).
pub estimated_wait_ms: u32,
/// `GetBattlefieldTimeWaited`: `now − stamp` in ms, 0 when unset.
pub time_waited_ms: u32,
}
/// The Map.dbc half of `GetBattlefieldInfo` (`0x4ab0b0`), resolved by the app for the listed map.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BattlefieldMapInfo {
pub name: String,
/// The description for the player's faction. Deviation: `None` answers `nil` where the
/// reference's `-1` faction leg pushes nothing (Lua reads below the tuple), because no
/// playable race reaches that leg.
pub description: Option<String>,
/// The row's raw `MinLevel`/`MaxLevel` (values 3 and 4), not the bracket-adjusted pair.
pub min_level: u32,
pub max_level: u32,
/// Values 5–7: `[row+0x40]` signed, `[row+0x44]` and `[row+0x48]` f32.
pub field_16: i32,
pub field_17: f32,
pub field_18: f32,
}
/// The instance list and the scalars beside it, as the app pushes them.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BattlefieldListView {
/// The instance ids in wire order (`[0xb6e868]`).
pub instances: Vec<u32>,
/// The bracket-adjusted level pair the list handler derived (`[0xb6eba8]`/`[0xb6ebac]`).
pub bracket_min: u32,
pub bracket_max: u32,
/// The listed map's row; `None` when the id has no row, which answers no values.
pub info: Option<BattlefieldMapInfo>,
/// The map row's group-queue flag, `[row+0xa0]`.
pub group_queue: bool,
}
/// The reference's 1-based gate (`dec; cmp; jae`), unsigned: 0 and negatives fail as too large.
fn slot_index(index: i32, n: usize) -> Option<usize> {
usize::try_from(index)
.ok()?
.checked_sub(1)
.filter(|&i| i < n)
}
/// The local-player gate of `GetBattlefieldInfo` and `GetBattlefieldInstanceInfo`, read at call
/// time (`0x468550`, `0x468460` with `ecx = 0x10`): the answer `UnitExists("player")` gives.
fn player_exists(model: &Model) -> bool {
model.unit("player").is_some_and(|u| u.exists)
}
/// The reference's four-entry status jump table with its `"error"` default (`0x4ab604`).
fn status_text(status: u32) -> &'static str {
match status {
0 => "none",
1 => "queued",
2 => "confirm",
3 => "active",
_ => "error",
}
}
impl super::UiScript {
/// Push the instance list. A new list leaves the selection (`[0xb6eba0]`) alone; it only
/// decides whether `GetSelectedBattlefield` still finds it (`0x4ab360`).
pub fn set_battlefield_list(&mut self, list: BattlefieldListView) {
self.model_mut().battlefield_list = list;
}
/// The world-enter reset (`0x4a9db0`) clears the selection with the list.
pub fn reset_battlefield_selection(&mut self) {
self.model_mut().battlefield_selected = 0;
}
/// Push the queue slots and the instance expiration (`[0xb6ebb8]`) every frame, since the
/// clock getters move.
pub fn set_battlefield_queue(
&mut self,
slots: Vec<BattlefieldQueueSlot>,
instance_expiration_ms: u32,
) {
let mut model = self.model_mut();
model.battlefield_slots = slots;
model.battlefield_instance_expiration_ms = instance_expiration_ms;
}
/// `JoinBattlefield` calls since the last drain, `(instance, as group)`; instance 0 is first
/// available. The app adds the map, the opcode and the group-size refusal (`0x4a9f60`).
pub fn take_battlefield_join_requests(&mut self) -> Vec<(u32, bool)> {
std::mem::take(&mut self.model_mut().battlefield_join_requests)
}
/// `ShowBattlefieldList` calls since the last drain: the map ids for `CMSG_BATTLEFIELD_LIST`.
pub fn take_battlefield_list_requests(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().battlefield_list_requests)
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"GetNumBattlefields",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.battlefield_list.instances.len() as i64)
})?,
)?;
// No values without a player or a map row, else nine (`0x4ab0b0`).
g.set(
"GetBattlefieldInfo",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let list = &model.battlefield_list;
let (Some(info), true) = (list.info.as_ref(), player_exists(&model)) else {
return Ok(MultiValue::new());
};
let description = match &info.description {
Some(d) => Value::String(lua.create_string(d)?),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&info.name)?),
description,
Value::Number(f64::from(info.min_level)),
Value::Number(f64::from(info.max_level)),
Value::Number(f64::from(info.field_16)),
Value::Number(f64::from(info.field_17)),
Value::Number(f64::from(info.field_18)),
Value::Number(f64::from(list.bracket_min)),
Value::Number(f64::from(list.bracket_max)),
]))
})?,
)?;
// Raises with `GetBattlefieldInfo`'s usage string, the reference's own mislabel (`0x845c48`).
// No values without a player or out of range, else the instance id pushed signed (`fild`).
g.set(
"GetBattlefieldInstanceInfo",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: GetBattlefieldInfo(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let list = &model.battlefield_list;
if !player_exists(&model) {
return Ok(MultiValue::new());
}
Ok(match slot_index(index, list.instances.len()) {
Some(i) => {
MultiValue::from_vec(vec![Value::Number(f64::from(list.instances[i] as i32))])
}
None => MultiValue::new(),
})
})?,
)?;
// An out-of-range index, 0 included, joins instance 0, the first available (`0x4a9f60`).
g.set(
"JoinBattlefield",
lua.create_function(|lua, (index, as_group): (Value, Value)| {
let index = number_arg(lua, index, "Usage: JoinBattlefield(index)")?;
let as_group = bool_or_default(Some(&as_group), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let instance = slot_index(index, model.battlefield_list.instances.len())
.map_or(0, |i| model.battlefield_list.instances[i]);
model.battlefield_join_requests.push((instance, as_group));
Ok(())
})?,
)?;
// A no-op in the reference (`xor eax,eax; ret`); the stock window calls it on hide.
g.set("CloseBattlefield", lua.create_function(|_, ()| Ok(()))?)?;
// Stores the instance id at that position, 0 when out of range (`0x4ab300`).
g.set(
"SetSelectedBattlefield",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: SetSelectedBattlefield(index)")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.battlefield_selected = slot_index(index, model.battlefield_list.instances.len())
.map_or(0, |i| model.battlefield_list.instances[i]);
Ok(())
})?,
)?;
// The stored id's 1-based position in the current list; 0 on a miss, as for a stored 0.
g.set(
"GetSelectedBattlefield",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let selected = model.battlefield_selected;
Ok(model
.battlefield_list
.instances
.iter()
.position(|&id| id == selected)
.map_or(0, |i| i as i64 + 1))
})?,
)?;
// Five values on every leg (`0x4ab5c7`): `(nil, nil, 0, 0, 0)` off slots 1..3.
g.set(
"GetBattlefieldStatus",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: GetBattlefieldStatus(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(slot) = slot_index(index, 3).and_then(|i| model.battlefield_slots.get(i))
else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Number(0.0),
Value::Number(0.0),
Value::Number(0.0),
]));
};
let name = match &slot.map_name {
Some(n) => Value::String(lua.create_string(n)?),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(status_text(slot.status))?),
name,
Value::Number(f64::from(slot.instance_id)),
Value::Number(f64::from(slot.min_level)),
Value::Number(f64::from(slot.max_level)),
]))
})?,
)?;
// The per-slot clock getters (`0x4ab620`, `0x4ab790`, `0x4ab820`), in ms; 0 off slots 1..3.
for (name, usage, read) in [
(
"GetBattlefieldPortExpiration",
"Usage: GetBattlefieldPortExpiration(index)",
(|s: &BattlefieldQueueSlot| s.port_expiration_ms) as fn(&BattlefieldQueueSlot) -> u32,
),
(
"GetBattlefieldEstimatedWaitTime",
"Usage: GetBattlefieldEstimatedWaitTime(index)",
|s: &BattlefieldQueueSlot| s.estimated_wait_ms,
),
(
"GetBattlefieldTimeWaited",
"Usage: GetBattlefieldTimeWaited(index)",
|s: &BattlefieldQueueSlot| s.time_waited_ms,
),
] {
g.set(
name,
lua.create_function(move |lua, index: Value| {
let index = number_arg(lua, index, usage)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(
slot_index(index, 3)
.and_then(|i| model.battlefield_slots.get(i))
.map_or(0, read),
))
})?,
)?;
}
// `[0xb6ebb8] − now`, 0 when unset or past.
g.set(
"GetBattlefieldInstanceExpiration",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.battlefield_instance_expiration_ms))
})?,
)?;
// Silent unless the slot is queued; fires no event.
g.set(
"ShowBattlefieldList",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: ShowBattlefieldList(index)")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let map_id = slot_index(index, 3)
.and_then(|i| model.battlefield_slots.get(i))
.filter(|s| s.map_id != 0 && s.status == 1)
.map(|s| s.map_id);
if let Some(map_id) = map_id {
model.battlefield_list_requests.push(map_id);
}
Ok(())
})?,
)?;
// `1` or nil (`0x4ac380`); the join-time group-size check reads another column, in the app.
g.set(
"CanJoinBattlefieldAsGroup",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.battlefield_list.group_queue))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
/// A VM with a local player, as every in-world call has.
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_unit(
"player",
Some(crate::script::UnitState {
exists: true,
name: Some("Probe".into()),
..Default::default()
}),
);
s
}
fn list(ids: &[u32]) -> BattlefieldListView {
BattlefieldListView {
instances: ids.to_vec(),
bracket_min: 20,
bracket_max: 29,
info: Some(BattlefieldMapInfo {
name: "Arathi Basin".into(),
description: Some("The Arathi Basin is …".into()),
min_level: 20,
max_level: 60,
field_16: -1,
field_17: 0.0,
field_18: 0.0,
}),
group_queue: true,
}
}
fn slot(map_id: u32, status: u32, instance: u32) -> BattlefieldQueueSlot {
BattlefieldQueueSlot {
map_id,
map_name: Some(format!("Map {map_id}")),
status,
instance_id: instance,
min_level: 20,
max_level: 29,
port_expiration_ms: 60_000,
estimated_wait_ms: 30_000,
time_waited_ms: 5_000,
}
}
#[test]
fn the_index_gate_is_unsigned_and_one_based() {
assert_eq!(slot_index(1, 3), Some(0));
assert_eq!(slot_index(3, 3), Some(2));
assert_eq!(slot_index(4, 3), None);
assert_eq!(slot_index(0, 3), None);
assert_eq!(slot_index(-1, 3), None);
assert_eq!(slot_index(1, 0), None);
}
#[test]
fn get_battlefield_info_answers_nine_or_none() {
let mut s = vm();
assert_eq!(
s.arity("GetBattlefieldInfo()").unwrap(),
0,
"no list at all: the map gate"
);
s.set_battlefield_list(list(&[7, 3]));
s.set_unit("player", None);
assert_eq!(
s.arity("GetBattlefieldInfo()").unwrap(),
0,
"no player: the other gate, read at call time"
);
assert_eq!(
s.arity("GetBattlefieldInstanceInfo(1)").unwrap(),
0,
"…and the instance verb's"
);
s = vm();
s.set_battlefield_list(list(&[7, 3]));
assert_eq!(s.arity("GetBattlefieldInfo()").unwrap(), 9);
let got = s
.eval::<String>(
"local n, d, a, b, c, x, y, lo, hi = GetBattlefieldInfo() \
return n .. '|' .. a .. '|' .. b .. '|' .. c .. '|' .. lo .. '|' .. hi",
)
.unwrap();
assert_eq!(got, "Arathi Basin|20|60|-1|20|29");
}
#[test]
fn get_battlefield_instance_info_raises_with_the_mislabel_and_bails_silently() {
let mut s = vm();
s.set_battlefield_list(list(&[7, 0xFFFF_FFFF]));
assert_eq!(
s.eval::<i64>("return GetBattlefieldInstanceInfo(1)")
.unwrap(),
7
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldInstanceInfo('2')")
.unwrap(),
-1,
"a numeric string passes; the id is pushed signed"
);
assert_eq!(s.arity("GetBattlefieldInstanceInfo(3)").unwrap(), 0);
assert_eq!(s.arity("GetBattlefieldInstanceInfo(0)").unwrap(), 0);
let err = s
.run("GetBattlefieldInstanceInfo(nil)")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: GetBattlefieldInfo(index)"),
"the shipped client's own mislabel: {err}"
);
assert_eq!(s.eval::<i64>("return GetNumBattlefields()").unwrap(), 2);
}
#[test]
fn the_selection_is_an_instance_id_not_a_position() {
let mut s = vm();
s.set_battlefield_list(list(&[7, 3, 11]));
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 0);
s.run("SetSelectedBattlefield(2)").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 2);
s.set_battlefield_list(list(&[3, 7, 11]));
assert_eq!(
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
1,
"instance 3 moved to the front"
);
s.set_battlefield_list(list(&[7, 11]));
assert_eq!(
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
0,
"instance 3 left the list"
);
s.run("SetSelectedBattlefield(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 0);
s.run("SetSelectedBattlefield(9)").unwrap();
assert_eq!(
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
0,
"out of range stores 0"
);
let err = s.run("SetSelectedBattlefield({})").unwrap_err().to_string();
assert!(
err.contains("Usage: SetSelectedBattlefield(index)"),
"{err}"
);
}
#[test]
fn join_battlefield_resolves_the_instance_and_the_group_flag() {
let mut s = vm();
s.set_battlefield_list(list(&[7, 3]));
s.run("JoinBattlefield(0) JoinBattlefield(2, 1) JoinBattlefield(5, true) JoinBattlefield(1, nil)")
.unwrap();
assert_eq!(
s.take_battlefield_join_requests(),
vec![(0, false), (3, true), (0, true), (7, false)]
);
assert!(s.take_battlefield_join_requests().is_empty(), "drained");
let err = s.run("JoinBattlefield()").unwrap_err().to_string();
assert!(err.contains("Usage: JoinBattlefield(index)"), "{err}");
s.run("CloseBattlefield()").unwrap();
}
#[test]
fn get_battlefield_status_answers_five_values_on_every_leg() {
let mut s = UiScript::new().unwrap();
s.set_battlefield_queue(vec![slot(489, 1, 5), slot(0, 0, 0), slot(529, 9, 2)], 0);
let n = |s: &mut UiScript, i: &str| s.arity(&format!("GetBattlefieldStatus({i})")).unwrap();
assert_eq!(n(&mut s, "1"), 5);
assert_eq!(n(&mut s, "0"), 5);
assert_eq!(n(&mut s, "4"), 5);
let got = s
.eval::<String>(
"local st, name, id, lo, hi = GetBattlefieldStatus(1) \
return st .. '|' .. name .. '|' .. id .. '|' .. lo .. '|' .. hi",
)
.unwrap();
assert_eq!(got, "queued|Map 489|5|20|29");
assert_eq!(
s.eval::<String>("return (GetBattlefieldStatus(2))")
.unwrap(),
"none"
);
assert_eq!(
s.eval::<String>("return (GetBattlefieldStatus(3))")
.unwrap(),
"error",
"a status past 3 takes the jump table's default"
);
let off = s
.eval::<String>(
"local st, name, id, lo, hi = GetBattlefieldStatus(4) \
return tostring(st) .. '|' .. tostring(name) .. '|' .. id .. lo .. hi",
)
.unwrap();
assert_eq!(off, "nil|nil|000");
let err = s.run("GetBattlefieldStatus('x')").unwrap_err().to_string();
assert!(err.contains("Usage: GetBattlefieldStatus(index)"), "{err}");
let mut empty = slot(0, 0, 0);
empty.map_name = None;
s.set_battlefield_queue(vec![empty], 0);
assert!(
s.eval::<bool>("local _, name = GetBattlefieldStatus(1) return name == nil")
.unwrap(),
"no row: a real nil"
);
}
#[test]
fn the_time_getters_read_the_pushed_milliseconds() {
let mut s = UiScript::new().unwrap();
s.set_battlefield_queue(vec![slot(489, 1, 5)], 120_000);
assert_eq!(
s.eval::<i64>("return GetBattlefieldPortExpiration(1)")
.unwrap(),
60_000
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldEstimatedWaitTime(1)")
.unwrap(),
30_000
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldTimeWaited(1)").unwrap(),
5_000
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldTimeWaited(2)").unwrap(),
0
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldPortExpiration(0)")
.unwrap(),
0
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldInstanceExpiration()")
.unwrap(),
120_000
);
for (call, usage) in [
(
"GetBattlefieldPortExpiration()",
"Usage: GetBattlefieldPortExpiration(index)",
),
(
"GetBattlefieldEstimatedWaitTime(nil)",
"Usage: GetBattlefieldEstimatedWaitTime(index)",
),
(
"GetBattlefieldTimeWaited({})",
"Usage: GetBattlefieldTimeWaited(index)",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(usage), "{call}: {err}");
}
}
#[test]
fn show_battlefield_list_gates_on_a_queued_slot() {
let mut s = UiScript::new().unwrap();
s.set_battlefield_queue(vec![slot(489, 1, 5), slot(529, 2, 1), slot(0, 1, 0)], 0);
s.run("ShowBattlefieldList(1) ShowBattlefieldList(2) ShowBattlefieldList(3) ShowBattlefieldList(4)")
.unwrap();
assert_eq!(s.take_battlefield_list_requests(), vec![489]);
let err = s.run("ShowBattlefieldList('q')").unwrap_err().to_string();
assert!(err.contains("Usage: ShowBattlefieldList(index)"), "{err}");
assert!(s
.eval::<bool>("return CanJoinBattlefieldAsGroup() == nil")
.unwrap());
s.set_battlefield_list(list(&[]));
assert_eq!(
s.eval::<i64>("return CanJoinBattlefieldAsGroup()").unwrap(),
1
);
}
}