creatures keep their first corner, and characters never share files
- a creature walking a path no longer cuts its first corner: the path's first waypoint was dropped at decode (2342) - two characters whose names differ only in accented or non-Latin letters no longer share macros, bindings and SavedVariables; a file that failed to load is not overwritten at logout (2342) - custom chat channels survive a logout, a collapsed quest header keeps its quests watched, and a trackpad swipe scrolls as far as it adds up to (2342) - a hearth off a zeppelin no longer lands back on its deck, and an opened lockbox with loot left in it unlocks (2342) - every snapshot runs on Vulkan, in a container, before it ships (2344) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1812,6 +1812,7 @@ fn seat_a_session(script: &mut UiScript) {
|
||||
script.set_quest_log(QuestLogState {
|
||||
entries: vec![header, in_progress, done],
|
||||
num_quests: 2,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
//! on the confirmed switch back to general — the reference's own semantics.
|
||||
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
use bevy::input::mouse::AccumulatedMouseScroll;
|
||||
use bevy::input::mouse::{AccumulatedMouseScroll, MouseScrollUnit};
|
||||
use bevy::input::ButtonState;
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -434,6 +434,51 @@ fn sync_dispatch(script: Option<NonSendMut<UiScript>>, mut dispatch: ResMut<Bind
|
||||
script.fire_event("UPDATE_BINDINGS", vec![]);
|
||||
}
|
||||
|
||||
/// A wheel delta in **lines** — notches — whatever unit the OS reported it in. A mouse wheel
|
||||
/// arrives as `Line`; a macOS trackpad or Magic Mouse arrives as `Pixel`, many small deltas a
|
||||
/// frame apart for one gesture, and Bevy's own conversion factor is the one normalisation.
|
||||
/// Shared by every wheel reader so none of them keeps its own copy of the constant.
|
||||
pub(crate) fn wheel_lines(unit: MouseScrollUnit, dy: f32) -> f32 {
|
||||
match unit {
|
||||
MouseScrollUnit::Line => dy,
|
||||
MouseScrollUnit::Pixel => dy / MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Whole notches out of a stream of line deltas**, carrying the fraction between frames.
|
||||
///
|
||||
/// For the consumers that act per NOTCH rather than per unit of travel — the UI's
|
||||
/// `OnMouseWheel` (stock `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call and
|
||||
/// `ChatFrame_OnMouseWheel` a line, both off the sign alone) and the realm list's row step. Fed a
|
||||
/// trackpad's per-frame pixel trickle raw, each of those fired once per frame, so a gentle swipe
|
||||
/// slammed a pane to its end; through this, ten frames of a tenth of a line are one notch.
|
||||
///
|
||||
/// A reversal drops the carried fraction: travel the other way is a new gesture, and it should
|
||||
/// not first have to undo the remainder of the last one.
|
||||
///
|
||||
/// A `Resource` for the UI's feed (a fact about the input device's gesture, which outlives any one
|
||||
/// VM), a `Local` for the realm list's.
|
||||
#[derive(Default, Resource)]
|
||||
pub(crate) struct WheelNotches {
|
||||
carry: f32,
|
||||
}
|
||||
|
||||
impl WheelNotches {
|
||||
/// Add `lines` of travel; returns the signed whole notches it completes (usually 0 or ±1).
|
||||
pub(crate) fn feed(&mut self, lines: f32) -> i32 {
|
||||
if lines == 0.0 || !lines.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
if self.carry != 0.0 && self.carry.signum() != lines.signum() {
|
||||
self.carry = 0.0;
|
||||
}
|
||||
self.carry += lines;
|
||||
let whole = self.carry.trunc();
|
||||
self.carry -= whole;
|
||||
whole as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// The dispatch pass — see the module doc. Runs right after the UI key feed (same frame's
|
||||
/// capture gate), before `WorldStage::Input` (a bound key must act this frame, once).
|
||||
fn latch_and_dispatch(
|
||||
@@ -686,14 +731,9 @@ fn latch_and_dispatch(
|
||||
// `RunCommand 0x4b7b50` no-op: `UP + !runOnUp` returns without running anything). Before this
|
||||
// the notch was a press with no release, which quietly made every press+release command a
|
||||
// dead wheel binding. Over UI the wheel belongs to the hovered frame.
|
||||
// Trackpads report pixel deltas — normalized to line-equivalents so the zoom consumer's
|
||||
// feel is unchanged from when it read the scroll itself.
|
||||
let wheel = match scroll.unit {
|
||||
bevy::input::mouse::MouseScrollUnit::Line => scroll.delta.y,
|
||||
bevy::input::mouse::MouseScrollUnit::Pixel => {
|
||||
scroll.delta.y / bevy::input::mouse::MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
|
||||
}
|
||||
};
|
||||
// Trackpads report pixel deltas — normalized to line-equivalents ([`wheel_lines`]) so the
|
||||
// zoom consumer's feel is unchanged from when it read the scroll itself.
|
||||
let wheel = wheel_lines(scroll.unit, scroll.delta.y);
|
||||
// **Over CHROME**, not over any UI at all: the wheel still zooms with the cursor on a
|
||||
// nameplate, which is a mouse-enabled widget and not a panel (`PointerOverUiPanel`). Plates sit
|
||||
// over exactly the things you look at, so the raw flag silently killed scroll-zoom wherever one
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! The split is the reference's own. `0x629b60` is the display dispatcher: one classification of
|
||||
//! both endpoints, then a branch per outcome into a formatter that emits *text*. The floating
|
||||
//! number comes off a different path entirely (`0x625010`, the world-anchored spawn), gated by
|
||||
//! number comes off a different path entirely (`0x629d30`, which fires `COMBAT_TEXT_UPDATE`), gated by
|
||||
//! different CVars, with a different source law — which is why our two live in different files
|
||||
//! rather than in one arm with two tails. Everything here composes; the law it composes with is
|
||||
//! [`crate::ui_chat::combat`].
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! handler table since 2323, moved out of the drain's `combat_chat` and `combat_log` arm files).
|
||||
//! Every combat packet has two consumers, and the reference emits both from one handler: the
|
||||
//! **chat line** ([`chat`], the `0x629b60` display dispatcher's text) and the **floating
|
||||
//! number** with the portrait's `UNIT_COMBAT` flash ([`text`], the `0x625010` world-anchored
|
||||
//! spawn). They are gated by different CVars and classify by different laws, which is why the
|
||||
//! number** with the portrait's `UNIT_COMBAT` flash ([`text`], `0x629d30` →
|
||||
//! `COMBAT_TEXT_UPDATE`; `0x625010` is the melee blood spurt, not this). They are gated by different CVars and classify by different laws, which is why the
|
||||
//! legs are two files; they answer one packet, which is why the handler is one — the line first,
|
||||
//! the number second, as the drain's match ran them.
|
||||
//!
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
//! written workaround). The registry never applies anything itself: it has no knob, and no arm.
|
||||
//!
|
||||
//! - **The latch** ([`Row::pending`]): a write to a latched row is staged, `GetCVar` keeps
|
||||
//! answering the applied value, and nothing fires until [`Cvars::commit_latched`] — which is
|
||||
//! the reference's `CVar::Update 0x63e060`, called for the `gx*` rows from inside `RestartGx`
|
||||
//! (the video window's Okay). A staged value that is never committed is dropped at exit, as
|
||||
//! answering the applied value, and nothing fires until [`Cvars::commit_latched`] — the
|
||||
//! reference's `0x639ec0`, `CVar::Commit 0x63e060` on the fourteen gx records, called from
|
||||
//! inside `RestartGx` (the video window's Okay). A staged value that is never committed is dropped at exit, as
|
||||
//! the reference's is (`SaveConfig` writes `rec+0x20`, the applied value).
|
||||
//!
|
||||
//! - **Boot**: read `benilla-config/config.toml` ([`crate::local_state`]) into the registry and
|
||||
@@ -1433,14 +1433,26 @@ impl Cvars {
|
||||
true
|
||||
}
|
||||
|
||||
/// **The latch boundary** — the reference's `CVar::Update 0x63e060`: every staged value
|
||||
/// becomes the applied one, fires, persists, and reaches the mirror. Returns how many moved.
|
||||
/// Called for the `gx*` rows from `RestartGx` ([`crate::video`]); a row nothing ever commits
|
||||
/// (`SoundBufferSize`, `gxApi`) holds its stage until exit, and loses it there, as the
|
||||
/// reference does.
|
||||
/// **The latch boundary `RestartGx` crosses** — the reference's `0x639ec0`, fourteen
|
||||
/// `CVar::Commit 0x63e060` calls on fourteen gx records (`[0xc4ea90]` … `[0xc4eab4]`,
|
||||
/// `0x639ec0..0x639f5a`): each of THOSE staged values becomes the applied one, fires,
|
||||
/// persists, and reaches the mirror. Returns how many moved.
|
||||
///
|
||||
/// **Only the `gx*` rows.** A latched row outside the video set is not on that list and
|
||||
/// nothing else commits it: `SoundBufferSize`'s register site (`0x4571ca`) discards the
|
||||
/// record pointer `0x63db90` returns, so no global holds it for any commit to name. It
|
||||
/// holds its stage until exit and loses it there, as the reference's does. Until 2343 this
|
||||
/// walked every row, and a console `SoundBufferSize` rode the next video Okay into the file.
|
||||
///
|
||||
/// The reference runs a gx CVar's change callback at `SetCVar` time (`0x63df50`), and the
|
||||
/// commit fires nothing; our observers fire here instead. Nothing reads the difference
|
||||
/// today — the appliers are the device rebuild's, and that runs after this boundary.
|
||||
pub(crate) fn commit_latched(&mut self) -> usize {
|
||||
let mut moved = 0;
|
||||
for row in &mut self.rows {
|
||||
if !row.name.starts_with("gx") {
|
||||
continue;
|
||||
}
|
||||
let Some(staged) = row.pending.take() else {
|
||||
continue;
|
||||
};
|
||||
@@ -3283,6 +3295,15 @@ mod tests {
|
||||
assert_eq!(cvars.set("gxVSync", "0"), SetOutcome::Unchanged);
|
||||
assert_eq!(cvars.row("gxVSync").unwrap().pending, None);
|
||||
assert_eq!(cvars.commit_latched(), 0);
|
||||
// A latched row outside the gx set is not the video restart's to commit (2343).
|
||||
assert_eq!(cvars.set("SoundBufferSize", "200"), SetOutcome::Staged);
|
||||
assert_eq!(cvars.commit_latched(), 0);
|
||||
assert_eq!(cvars.get("SoundBufferSize"), Some("100"));
|
||||
assert_eq!(
|
||||
cvars.row("SoundBufferSize").unwrap().pending.as_deref(),
|
||||
Some("200")
|
||||
);
|
||||
assert!(!cvars.compose().contains_key("SoundBufferSize"));
|
||||
// An unlatched row applies at once, fires, and dirties.
|
||||
assert_eq!(cvars.set("MusicVolume", "0.7"), SetOutcome::Changed);
|
||||
assert_eq!(cvars.get("MusicVolume"), Some("0.7"));
|
||||
|
||||
@@ -754,13 +754,13 @@ pub(crate) mod schedule_tests {
|
||||
/// writers commute, and a drain's order against a writer is one frame of latency, never
|
||||
/// a loss. (2283's ten pairs were all this shape: a new verb drain against its siblings
|
||||
/// over the chat and error sinks.)
|
||||
/// - **An exclusive edge** — a pair bevy reports with NO component list: one side is a
|
||||
/// build-inserted `ApplyDeferred` sync point or an exclusive system, which conflicts on
|
||||
/// `World` itself. Its order against a system it shares nothing with is immaterial by
|
||||
/// construction (the build already placed the barrier after the commands it flushes and
|
||||
/// before their dependents), and there is nothing to declare it against. Until 2304 an
|
||||
/// empty list read as "VM only" vacuously, so that class rose and fell with the barrier
|
||||
/// count; now it is named.
|
||||
/// - **Not an exclusive edge.** A pair bevy reports with NO component list has an exclusive
|
||||
/// system on one side (`bevy_ecs` `node.rs:625`: it reports every unordered pair with an
|
||||
/// exclusive system and never reads its access). 2304 explained that class as the
|
||||
/// build's barriers; the ratchet reads the declared graph since 2337, which has none, so
|
||||
/// what it held was only the hand-written exclusive systems — the net drain, whose 143
|
||||
/// unordered partners read this frame's packets or the last frame's by coin, and
|
||||
/// `finish_colliders`. Those are undeclared orders like any other: actionable (2343).
|
||||
/// - **A random stream** — `SoundKits`, which every sound system holds to play a kit: a
|
||||
/// decode cache, a per-kit last-variation memory and one xorshift stream. Any
|
||||
/// interleaving of draws is a valid draw, and that is the reference's own contract for
|
||||
@@ -855,8 +855,10 @@ pub(crate) mod schedule_tests {
|
||||
|
||||
/// Which class explains this pair — `None` if it is actionable.
|
||||
pub fn class_of(&self, what: &[ComponentId]) -> Option<&'static str> {
|
||||
// An exclusive system on one side: bevy cannot see its access, so nothing here can
|
||||
// argue the pair away (see the doc above: not an exclusive edge).
|
||||
if what.is_empty() {
|
||||
return Some("exclusive");
|
||||
return None;
|
||||
}
|
||||
if !what.iter().all(|id| self.explains(*id)) {
|
||||
return None;
|
||||
@@ -1013,7 +1015,11 @@ pub(crate) mod schedule_tests {
|
||||
/// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with
|
||||
/// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is
|
||||
/// about a resource that commutes by construction, the claim belongs in [`Classes`].
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 5_063;
|
||||
/// **5,608 (decision 2343)** — not a raise: a re-measurement. The 556 pairs with an
|
||||
/// exclusive system on one side were read as "explained" by a class argued for the build's
|
||||
/// barriers, which the declared graph (2337) no longer has; 413 are `finish_colliders`', 143
|
||||
/// the net drain's. Same tree, the class dropped, nothing else moved: 5,052 + 556.
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 5_608;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -327,13 +327,22 @@ pub(crate) fn fps_journal_path() -> Option<PathBuf> {
|
||||
diagnostics_dir().map(|d| d.join("fps-journal.csv"))
|
||||
}
|
||||
|
||||
/// Make an arbitrary realm/character name safe as one path component: anything outside
|
||||
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
|
||||
/// Make an arbitrary realm/character name safe as one path component: anything that is not a
|
||||
/// letter or digit becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
|
||||
/// escape the folder or collide with the path separator.
|
||||
///
|
||||
/// **Letters are any script's, kept as they are** — the reference names its per-character folder
|
||||
/// with the raw name (`WTF/Account/<ACC>/<REALM>/<CHAR>/`). This was ASCII-only, which folded every
|
||||
/// other letter to `_`: vmangos accepts extended-Latin, Cyrillic and East-Asian names by default
|
||||
/// (`StrictPlayerNames = 0`), so `Zoë` and `Zoé` — or any two Cyrillic names of one length — shared
|
||||
/// one set of macros, bindings, addon state and SavedVariables, and each login saved over the
|
||||
/// other's. A character name is letters only (vmangos `isValidString`), so keeping every letter
|
||||
/// makes the token one-to-one for them; an ASCII name maps exactly as before, so no existing file
|
||||
/// moves.
|
||||
fn file_token(s: &str) -> String {
|
||||
let t: String = s
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '_' })
|
||||
.collect();
|
||||
if t.is_empty() {
|
||||
"unknown".into()
|
||||
@@ -346,13 +355,19 @@ fn file_token(s: &str) -> String {
|
||||
/// a crash mid-write leaves the old file intact, never a truncated one. The one write path for
|
||||
/// every resident of the folder.
|
||||
pub(crate) fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
write_atomic_bytes(path, contents.as_bytes())
|
||||
}
|
||||
|
||||
/// [`write_atomic`] for a resident that is bytes, not text — the saved-variables files, which
|
||||
/// carry a Lua byte string's bytes as they are.
|
||||
pub(crate) fn write_atomic_bytes(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(contents.as_bytes())?;
|
||||
f.write_all(contents)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
@@ -394,6 +409,28 @@ mod tests {
|
||||
use super::test_env::{EnvGuard, ENV_LOCK};
|
||||
use super::*;
|
||||
|
||||
/// **Two characters never share a file.** Every per-character resident keys on this token, so
|
||||
/// a collision is one character's macros, bindings and SavedVariables loaded and then saved
|
||||
/// over by another. ASCII names — every file already on disk — keep their exact paths.
|
||||
#[test]
|
||||
fn distinct_names_never_share_a_token() {
|
||||
assert_ne!(file_token("Вася"), file_token("Петя"));
|
||||
assert_ne!(file_token("Zoë"), file_token("Zoé"));
|
||||
assert_eq!(file_token("Zoë"), "Zoë");
|
||||
assert_eq!(file_token("Onehunter"), "Onehunter");
|
||||
assert_eq!(file_token("Hydraxian Waterlords"), "Hydraxian_Waterlords");
|
||||
assert_eq!(
|
||||
file_token("a/b\\c:d"),
|
||||
"a_b_c_d",
|
||||
"separators never survive"
|
||||
);
|
||||
assert_eq!(
|
||||
file_token("realm-name"),
|
||||
"realm_name",
|
||||
"the key's own `-` stays unforgeable"
|
||||
);
|
||||
}
|
||||
|
||||
/// The residents' layout, exercised through the override — which is the *only* step of
|
||||
/// [`home`] whose answer a test can state, since the other two are the machine's project
|
||||
/// folder and the test runner's own exe directory. Those two get their own tests below; what
|
||||
|
||||
@@ -118,7 +118,21 @@ impl PendingItemOps {
|
||||
if !matched {
|
||||
return self.clear_all();
|
||||
}
|
||||
self.clear_by_guid(item_guid)
|
||||
}
|
||||
|
||||
/// **`UnlockItem 0x495420`** — clear every entry naming `item_guid`, and nothing else: a guid
|
||||
/// no entry recorded (a corpse, a chest, 0) unlocks nothing, exactly as the reference's
|
||||
/// resolve-as-ITEM (typemask 2) finds nothing to clear for a non-item. The loot close is the
|
||||
/// caller that needs it: an opened lockbox/clam is locked at the `CMSG_OPEN_ITEM` send (0916)
|
||||
/// and, closed with loot left, never changes its slot — so neither [`Self::resolve`] nor a
|
||||
/// failure ever clears it (wow-re `ui/ledger.tsv` `0x48f200` @ `48f299`). Returns the
|
||||
/// deduplicated `(bag, slot)` pairs that unlocked.
|
||||
pub(crate) fn clear_by_guid(&mut self, item_guid: u64) -> Vec<(i64, u32)> {
|
||||
let mut unlocked = Vec::new();
|
||||
if item_guid == 0 {
|
||||
return unlocked;
|
||||
}
|
||||
self.entries.retain(|entry| {
|
||||
let hit = entry.iter().any(|&(_, (guid, _))| guid == item_guid);
|
||||
if hit {
|
||||
@@ -151,9 +165,10 @@ impl PendingItemOps {
|
||||
}
|
||||
}
|
||||
|
||||
/// `(bag, slot)` pairs whose app-lock cleared this frame, from EITHER clear: the server failure
|
||||
/// (`net/apply/loot.rs::inventory_failure`, which owns the wire event but has no `UiScript` to
|
||||
/// fire `ITEM_LOCK_CHANGED` through) and the resolving field-update watch
|
||||
/// `(bag, slot)` pairs whose app-lock cleared this frame, from ANY clear: the server failure
|
||||
/// (`ui_items::net::inventory_failure`, which owns the wire event but has no `UiScript` to
|
||||
/// fire `ITEM_LOCK_CHANGED` through), the loot close's item unlock (`ui_loot::net`, the same
|
||||
/// shape) and the resolving field-update watch
|
||||
/// (`ui_items::feed::resolve_item_locks`, which runs ahead of every feed so that no feed can push
|
||||
/// a `locked` the clear has already invalidated). `feed_containers` drains this and fires — the
|
||||
/// exact shape `ui_items::EquipErrors` already uses for the same reason.
|
||||
@@ -253,6 +268,24 @@ mod tests {
|
||||
assert!(p.contains(1, 3), "op B untouched — a different guid");
|
||||
}
|
||||
|
||||
/// Unlike the failure clear, the guid clear has no clear-all fallback: an unmatched or zero
|
||||
/// guid (a corpse's release) must leave every other lock standing.
|
||||
#[test]
|
||||
fn clear_by_guid_clears_only_the_named_item() {
|
||||
let mut p = PendingItemOps::default();
|
||||
p.add([(0, 3, 100, 1)]); // the opened lockbox
|
||||
p.add([(1, 2, 200, 1)]); // an unrelated move
|
||||
|
||||
assert!(p.clear_by_guid(999).is_empty(), "an unrecorded guid");
|
||||
assert!(p.clear_by_guid(0).is_empty(), "the zero guid fires nothing");
|
||||
assert!(p.contains(0, 3) && p.contains(1, 2));
|
||||
|
||||
let epoch = p.epoch();
|
||||
assert_eq!(p.clear_by_guid(100), vec![(0, 3)]);
|
||||
assert!(!p.contains(0, 3) && p.contains(1, 2));
|
||||
assert!(p.epoch() > epoch, "the feed's gate sees the lock set move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_by_failure_clears_everything_on_zero_or_unmatched_guid() {
|
||||
let mut p = PendingItemOps::default();
|
||||
|
||||
@@ -560,6 +560,8 @@ pub(super) fn control(
|
||||
if !player.server_riding {
|
||||
movement_net::park_mover(&net.0 .0, &mut player);
|
||||
}
|
||||
// After the park, so a fear's ack carries the stopped word the park just reported.
|
||||
movement_net::ack_speeds_undriven(&net.0 .0, &player, &speed_acks);
|
||||
return;
|
||||
}
|
||||
// This frame's netted movement axes, the mouselook/turn modes they imply, and the autorun
|
||||
@@ -1182,22 +1184,8 @@ pub(super) fn control(
|
||||
// jump/fall lifecycle, and a ~500 ms heartbeat, each carrying the live `MovementInfo` (decisions
|
||||
// 0052 + 0053). vmangos relays it to nearby players, who extrapolate from the flags. See the
|
||||
// [`movement_net`] module (the outbound mirror of `net::motion`'s remote integration).
|
||||
// The rider's local pose for the wire's ON_TRANSPORT tail: `bevy_to_wow` is a pure basis
|
||||
// rotation, so the boat-local Bevy vector converts directly, and the local orientation is
|
||||
// `face_yaw − boat_yaw` (the GetAbsoluteFacing law in reverse), normalized like any wire
|
||||
// orientation.
|
||||
let wire_transport = player.ride.as_ref().map(|r| {
|
||||
let local = benilla_assets::coords::bevy_to_wow(r.local_pos);
|
||||
benilla_protocol::TransportPose {
|
||||
guid: r.guid,
|
||||
pos: benilla_protocol::wire::Vector3d {
|
||||
x: local[0],
|
||||
y: local[1],
|
||||
z: local[2],
|
||||
},
|
||||
orientation: (player.face_yaw - r.boat_yaw).rem_euclid(std::f32::consts::TAU),
|
||||
}
|
||||
});
|
||||
// The rider's local pose for the wire's ON_TRANSPORT tail.
|
||||
let wire_transport = movement_net::wire_transport(&player);
|
||||
// The skipped-time clock (decision 1935): a held frame is a frame of movement simulation
|
||||
// we advanced through without integrating, and the mover we name is the one we are
|
||||
// actually driving — a possessed unit while we hold its reins. Read out before the call
|
||||
|
||||
@@ -580,6 +580,64 @@ pub(super) fn park_mover(sender: &Sender<ClientCommand>, player: &mut Player) {
|
||||
player.last_facing = facing;
|
||||
}
|
||||
|
||||
/// The rider's boat-local pose for the wire's `ON_TRANSPORT` tail, or `None` off a deck.
|
||||
/// `bevy_to_wow` is a pure basis rotation, so the boat-local Bevy vector converts directly, and the
|
||||
/// local orientation is `face_yaw − boat_yaw` (the GetAbsoluteFacing law in reverse), normalized
|
||||
/// like any wire orientation.
|
||||
pub(super) fn wire_transport(player: &Player) -> Option<TransportPose> {
|
||||
player.ride.as_ref().map(|r| {
|
||||
let local = bevy_to_wow(r.local_pos);
|
||||
TransportPose {
|
||||
guid: r.guid,
|
||||
pos: benilla_protocol::wire::Vector3d {
|
||||
x: local[0],
|
||||
y: local[1],
|
||||
z: local[2],
|
||||
},
|
||||
orientation: (player.face_yaw - r.boat_yaw).rem_euclid(std::f32::consts::TAU),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// **The forced-speed acks owed on a frame the controller does not drive** — a server spline
|
||||
/// (`server_riding`), a fear (`control_lost`) or a mover hand-off (`reseat`). Those frames return
|
||||
/// before [`stream_self_movement`], which is where a controlled frame's acks go out, so until this
|
||||
/// the change was applied locally and never answered: vmangos holds an unacked change for
|
||||
/// `PendingAckResponseTime` (4 s) before enforcing it, counts it in `OnFailedToAckChange` (a kick
|
||||
/// under the default anticheat penalty) and blocks the graveyard repop while one is pending. The
|
||||
/// reference acks from its per-mover drain whoever is driving (`0x616142`/`0x61812d`).
|
||||
///
|
||||
/// The payload is the honest state we last reported: our streamed flags (a ride's deliberate
|
||||
/// FORWARD included) with the transport tail when we are on a deck, minus the airborne pair — no
|
||||
/// arc is being integrated on these frames, so there is no jump tail to send with them.
|
||||
pub(super) fn ack_speeds_undriven(
|
||||
sender: &Sender<ClientCommand>,
|
||||
player: &Player,
|
||||
acks: &[crate::net::SpeedChangeMessage],
|
||||
) {
|
||||
let transport = wire_transport(player);
|
||||
let mut flags =
|
||||
player.move_flags & OUTBOUND_FLAG_MASK & !(move_flags::FALLING | move_flags::FALLING_FAR);
|
||||
if transport.is_none() {
|
||||
flags &= !move_flags::ON_TRANSPORT; // flag and tail travel together
|
||||
}
|
||||
for ack in acks {
|
||||
let _ = sender.send(ClientCommand::ForceSpeedAck {
|
||||
kind: ack.kind,
|
||||
guid: ack.guid,
|
||||
counter: ack.counter,
|
||||
speed: ack.speed,
|
||||
flags,
|
||||
pos: bevy_to_wow(player.pos),
|
||||
orientation: player.face_yaw.rem_euclid(std::f32::consts::TAU),
|
||||
pitch: 0.0,
|
||||
fall_time: 0,
|
||||
jump: None,
|
||||
transport: transport.filter(|_| flags & move_flags::ON_TRANSPORT != 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A frame that integrated everything it was given — the skipped-time report's no-op input,
|
||||
@@ -1440,6 +1498,73 @@ mod tests {
|
||||
assert_eq!(player.move_flags, 0, "bookkeeping is zeroed after parking");
|
||||
}
|
||||
|
||||
/// **A forced speed change is acked on a frame nobody is driving** — a fear, a server spline,
|
||||
/// a mover hand-off. Those frames return before the stream, which is where a controlled
|
||||
/// frame's acks go out, so the change was applied and never answered (vmangos enforces it 4 s
|
||||
/// late, counts the miss toward its anticheat kick, and blocks the graveyard repop meanwhile).
|
||||
/// The payload is the honest reported word: the ride's FORWARD kept, the airborne pair dropped
|
||||
/// (no arc tail exists here), and `ON_TRANSPORT` only with the tail that must travel with it.
|
||||
#[test]
|
||||
fn a_speed_change_is_acked_while_not_driving() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let ack = crate::net::SpeedChangeMessage {
|
||||
guid: 7,
|
||||
kind: benilla_protocol::SpeedKind::Run,
|
||||
counter: 3,
|
||||
speed: 3.5,
|
||||
};
|
||||
|
||||
// A spline ride reporting FORWARD, off any deck, with a stale ON_TRANSPORT bit.
|
||||
let player = Player {
|
||||
move_flags: move_flags::FORWARD | move_flags::FALLING | move_flags::ON_TRANSPORT,
|
||||
face_yaw: -1.0,
|
||||
..Default::default()
|
||||
};
|
||||
ack_speeds_undriven(&tx, &player, &[ack]);
|
||||
let Ok(ClientCommand::ForceSpeedAck {
|
||||
counter,
|
||||
speed,
|
||||
flags,
|
||||
orientation,
|
||||
transport,
|
||||
jump,
|
||||
..
|
||||
}) = rx.try_recv()
|
||||
else {
|
||||
panic!("the change is acked");
|
||||
};
|
||||
assert_eq!((counter, speed), (3, 3.5), "the ack echoes the change");
|
||||
assert_eq!(
|
||||
flags,
|
||||
move_flags::FORWARD,
|
||||
"FORWARD kept; FALLING and a tailless ON_TRANSPORT dropped"
|
||||
);
|
||||
assert!(transport.is_none() && jump.is_none());
|
||||
assert!((0.0..TAU).contains(&orientation));
|
||||
assert!(rx.try_recv().is_err(), "one ack per change");
|
||||
|
||||
// On a deck: the transport bit rides with its tail.
|
||||
let player = Player {
|
||||
move_flags: move_flags::ON_TRANSPORT,
|
||||
ride: Some(super::super::state::PlayerRide {
|
||||
entity: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
guid: 0x1F,
|
||||
local_pos: bevy::math::Vec3::ZERO,
|
||||
boat_yaw: 0.0,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
ack_speeds_undriven(&tx, &player, &[ack]);
|
||||
let Ok(ClientCommand::ForceSpeedAck {
|
||||
flags, transport, ..
|
||||
}) = rx.try_recv()
|
||||
else {
|
||||
panic!("the change is acked");
|
||||
};
|
||||
assert_eq!(flags, move_flags::ON_TRANSPORT);
|
||||
assert_eq!(transport.map(|t| t.guid), Some(0x1F));
|
||||
}
|
||||
|
||||
/// **A knockback launch acks, and sends no `MSG_MOVE_JUMP`** (decision 1702).
|
||||
///
|
||||
/// Two laws in one frame, and they are the same law read from both ends. `MSG_MOVE_JUMP` has a
|
||||
|
||||
@@ -299,23 +299,7 @@ pub(super) fn apply_server_moves(
|
||||
// Same-map teleport (the bridge only emits ours). Snap + echo the ack — without it the server
|
||||
// freezes our movement until relog.
|
||||
for t in teleports.read() {
|
||||
player.pos = wow_to_bevy(t.position);
|
||||
player.face_yaw = t.orientation;
|
||||
cam.yaw = t.orientation;
|
||||
// Stop any in-progress walk — server now sees us at the new spot.
|
||||
player.move_flags = 0;
|
||||
player.airborne_since = None; // a snap ends any in-progress jump arc (no phantom FALL_LAND)
|
||||
player.settling = true; // hold (gravity off) until the destination's ground/buildings load
|
||||
player.settle_since = time.elapsed_secs();
|
||||
player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT;
|
||||
// A far same-map teleport relocates us over ground that has not streamed either — the
|
||||
// tiles we were standing on are still the resident ones. See [`Player::world_stale`].
|
||||
player.world_stale = true;
|
||||
// The relocation voids any in-progress self server-ride (the taxi flight-end teleport
|
||||
// beats our own spline end by ~latency): `drive_self_ride` takes this flag next frame
|
||||
// and drops the ride instead of mirroring the stale flight pose back over this snap
|
||||
// (decision 0501 — the 4-yd hover + full-6s settle at every taxi landing).
|
||||
player.ride_abort = true;
|
||||
snap_near_teleport(player, &mut cam.yaw, t, time.elapsed_secs());
|
||||
// The echo goes now, and it is the WHOLE near-teleport handshake (decision 1340). The
|
||||
// real client echoes on the very next movement tick (wow-re: the 0xC7 drain applies the
|
||||
// snap, then `0x60e0a0` sends guid+counter+time) and sends nothing else — no
|
||||
@@ -523,6 +507,34 @@ pub(super) fn apply_server_moves(
|
||||
speed_acks
|
||||
}
|
||||
|
||||
/// **Apply one same-map teleport's snap** (`MSG_MOVE_TELEPORT_ACK` inbound) — everything but the
|
||||
/// echo, which the caller sends. `cam_yaw` is the fly-cam's yaw, carried to the new facing.
|
||||
fn snap_near_teleport(player: &mut Player, cam_yaw: &mut f32, t: &TeleportMessage, now: f32) {
|
||||
// A near teleport always arrives detached: vmangos takes this path only with no `m_transport`
|
||||
// (`Player::TeleportTo`, after `RemovePassenger`), and the reference's apply (`0x6186b0`)
|
||||
// re-parents to the packet's transport guid — zero here. A ride left standing let this same
|
||||
// frame's `ride::carry` compose the old deck pose back over the snap, and the airborne settle
|
||||
// kept it attached, so a hearth from a zeppelin or a lift landed us back on the deck.
|
||||
player.ride = None;
|
||||
player.pos = wow_to_bevy(t.position);
|
||||
player.face_yaw = t.orientation;
|
||||
*cam_yaw = t.orientation;
|
||||
// Stop any in-progress walk — server now sees us at the new spot.
|
||||
player.move_flags = 0;
|
||||
player.airborne_since = None; // a snap ends any in-progress jump arc (no phantom FALL_LAND)
|
||||
player.settling = true; // hold (gravity off) until the destination's ground/buildings load
|
||||
player.settle_since = now;
|
||||
player.settle_deadline = now + SETTLE_TIMEOUT;
|
||||
// A far same-map teleport relocates us over ground that has not streamed either — the tiles we
|
||||
// were standing on are still the resident ones. See [`Player::world_stale`].
|
||||
player.world_stale = true;
|
||||
// The relocation voids any in-progress self server-ride (the taxi flight-end teleport beats our
|
||||
// own spline end by ~latency): `drive_self_ride` takes this flag next frame and drops the ride
|
||||
// instead of mirroring the stale flight pose back over this snap (decision 0501 — the 4-yd
|
||||
// hover + full-6s settle at every taxi landing).
|
||||
player.ride_abort = true;
|
||||
}
|
||||
|
||||
/// Merge a server-authored packet's `MOVEMENTFLAGS` into our own — the reference's masked merge
|
||||
/// (`0x618c30 @0x618deb`: `new = old ^ ((old ^ wire) & 0x75a07dff)`), not an assignment. Pure, so
|
||||
/// the omission that actually bites is pinned by test: `ON_TRANSPORT` sits **outside** the mask, so
|
||||
@@ -983,3 +995,38 @@ mod control_tests {
|
||||
assert_eq!(control_verdict(ME, true, None), ControlVerdict::Granted(ME));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod teleport_tests {
|
||||
use super::*;
|
||||
|
||||
/// **A near teleport lands us where the server says, not back on the deck.** Hearthing off a
|
||||
/// zeppelin or a lift is a same-map teleport, and vmangos removes the passenger before it
|
||||
/// takes that path; the snap has to drop the ride too, or the same frame's platform carry
|
||||
/// recomposes the old deck pose over it.
|
||||
#[test]
|
||||
fn a_near_teleport_leaves_the_transport() {
|
||||
let mut player = Player {
|
||||
active: true,
|
||||
ride: Some(super::super::state::PlayerRide {
|
||||
entity: Entity::PLACEHOLDER,
|
||||
guid: 0x1F,
|
||||
local_pos: Vec3::new(1.0, 0.0, 2.0),
|
||||
boat_yaw: 0.5,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut cam_yaw = 0.0;
|
||||
let t = TeleportMessage {
|
||||
guid: 0x45,
|
||||
counter: 1,
|
||||
position: [-8_913.0, -136.0, 81.0],
|
||||
orientation: 1.25,
|
||||
};
|
||||
snap_near_teleport(&mut player, &mut cam_yaw, &t, 10.0);
|
||||
assert!(player.ride.is_none(), "the snap deboards");
|
||||
assert_eq!(player.pos, wow_to_bevy(t.position));
|
||||
assert_eq!((player.face_yaw, cam_yaw), (1.25, 1.25));
|
||||
assert!(player.settling && player.ride_abort && player.world_stale);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use bevy::input::mouse::MouseWheel;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::bindings::WheelNotches;
|
||||
use crate::net::{RealmChoice, RealmRequest};
|
||||
use crate::sound::GlueSound;
|
||||
|
||||
@@ -72,7 +73,7 @@ pub(super) fn clicks(
|
||||
/// `RealmList_OnKeyDown` (ESCAPE / ENTER), plus arrow-key row cycling and the wheel.
|
||||
pub(super) fn keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut wheel: MessageReader<MouseWheel>,
|
||||
(mut wheel, mut wheel_carry): (MessageReader<MouseWheel>, Local<WheelNotches>),
|
||||
mut realms: ResMut<Realms>,
|
||||
choice: Res<RealmChoice>,
|
||||
mut sounds: MessageWriter<GlueSound>,
|
||||
@@ -111,10 +112,7 @@ pub(super) fn keys(
|
||||
// The wheel scrolls the window over the list, in the reference's own 16 px steps translated
|
||||
// back to rows (`RealmListScrollFrame_OnVerticalScroll` divides the bar value by
|
||||
// `REALM_BUTTON_HEIGHT`, so one notch is one row).
|
||||
let mut notches = 0i32;
|
||||
for ev in wheel.read() {
|
||||
notches -= ev.y.signum() as i32;
|
||||
}
|
||||
let notches = wheel_rows(&mut wheel_carry, wheel.read());
|
||||
if notches != 0 {
|
||||
let max = rows.len().saturating_sub(MAX_ROWS);
|
||||
let next_off = (realms.offset as i32 + notches).clamp(0, max as i32) as usize;
|
||||
@@ -122,6 +120,20 @@ pub(super) fn keys(
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows this frame's wheel messages scroll the list by — positive is DOWN the list, one row
|
||||
/// per whole notch ([`WheelNotches`]). Each message is normalised to lines in its own unit first:
|
||||
/// a trackpad sends a gesture as a trickle of `Pixel` messages, and stepping a row per message
|
||||
/// ran the list to its end on a gentle swipe.
|
||||
fn wheel_rows<'a>(
|
||||
carry: &mut WheelNotches,
|
||||
wheel: impl IntoIterator<Item = &'a MouseWheel>,
|
||||
) -> i32 {
|
||||
wheel
|
||||
.into_iter()
|
||||
.map(|ev| -carry.feed(crate::bindings::wheel_lines(ev.unit, ev.y)))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The realm on a given **screen** row, honouring the scroll offset.
|
||||
fn realm_at(realms: &Realms, row: usize) -> Option<String> {
|
||||
let rows = realms.rows();
|
||||
@@ -193,3 +205,49 @@ fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWrit
|
||||
realms.hide();
|
||||
realms.enter(choice, name);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::input::mouse::MouseScrollUnit;
|
||||
|
||||
fn ev(unit: MouseScrollUnit, y: f32) -> MouseWheel {
|
||||
MouseWheel {
|
||||
unit,
|
||||
x: 0.0,
|
||||
y,
|
||||
window: Entity::PLACEHOLDER,
|
||||
}
|
||||
}
|
||||
|
||||
/// **A trackpad trickle scrolls the rows it adds up to, not a row per message** — ten
|
||||
/// `Pixel` messages of a tenth of a line apiece are one notch, so at most one row.
|
||||
#[test]
|
||||
fn a_trackpad_trickle_scrolls_only_the_rows_it_adds_up_to() {
|
||||
let mut carry = WheelNotches::default();
|
||||
let step = MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR / 10.0;
|
||||
let trickle: Vec<MouseWheel> = (0..10).map(|_| ev(MouseScrollUnit::Pixel, -step)).collect();
|
||||
let rows: i32 = trickle
|
||||
.iter()
|
||||
.map(|e| wheel_rows(&mut carry, std::iter::once(e)))
|
||||
.sum();
|
||||
assert!(
|
||||
(0..=1).contains(&rows),
|
||||
"one line of travel moved {rows} rows"
|
||||
);
|
||||
}
|
||||
|
||||
/// A mouse wheel's notch is one `Line` message and one row, as it always was.
|
||||
#[test]
|
||||
fn a_line_notch_scrolls_one_row() {
|
||||
let mut carry = WheelNotches::default();
|
||||
assert_eq!(
|
||||
wheel_rows(&mut carry, &[ev(MouseScrollUnit::Line, -1.0)]),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
wheel_rows(&mut carry, &[ev(MouseScrollUnit::Line, 1.0)]),
|
||||
-1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,8 +327,9 @@ fn send_spell_cast(
|
||||
ground.clear();
|
||||
}
|
||||
// The cast classes at this seam. A ranged/auto-repeat shot (Auto Shot, wand Shoot, Throw) is
|
||||
// not a cast-bar cast — it runs the ranged-stance / `AutoRepeatArmed` path, outside the
|
||||
// in-flight guard. An on-next-swing spell (`Attributes & 0x404` — Heroic Strike, Cleave)
|
||||
// not a cast-bar cast — it runs the ranged-stance / `AutoRepeatArmed` path, and its own
|
||||
// record does not guard (1601), though it is refused like any press while a guarding cast
|
||||
// holds. An on-next-swing spell (`Attributes & 0x404` — Heroic Strike, Cleave)
|
||||
// queues on the server's melee slot: it arms [`crate::spell::QueuedMeleeSpell`], never the
|
||||
// in-flight guard, so a queued strike cannot block the next cast (the ref's `6e4d97`
|
||||
// exemption on the inflight rec's 0x404 bits — wow-re `wave-cast.md`).
|
||||
@@ -340,11 +341,15 @@ fn send_spell_cast(
|
||||
debug!("ui_action: cast {spell_id} suppressed — already queued on next swing");
|
||||
return;
|
||||
}
|
||||
if (normal_cast || on_next_swing) && pending.in_flight(now) {
|
||||
if pending.in_flight(now) {
|
||||
// The ref's already-casting refusal: the same spell bails silently (`6e4d43`); a
|
||||
// different one errors reason 0x61 "Another action is in progress" (`6e4d97` →
|
||||
// `HandleCastFailed`) — the inflight rec here is always an ordinary cast, so even an
|
||||
// on-next-swing press is refused while it holds.
|
||||
// `HandleCastFailed`). The gate tests the **inflight** rec's `Attributes & 0x404`, never
|
||||
// the pressed spell's (wow-re `combat-feel-law.md` §B1, VERIFIED byte-exact), and a
|
||||
// guarding record here is always an ordinary cast or an item use — so EVERY press class
|
||||
// is refused while it holds: an on-next-swing strike, and a ranged/auto-repeat shot too.
|
||||
// Exempting the shot let it commit and its non-guarding arm overwrite the running cast's
|
||||
// record, leaving that cast unguarded once the server refused the shot.
|
||||
if pending.current(now) != Some(spell_id) {
|
||||
cast_errors.push_local(spell_id, 0x61);
|
||||
}
|
||||
@@ -1072,6 +1077,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A shot pressed mid-cast is refused, and the cast keeps its guard.** The reference's
|
||||
/// already-casting gate (`6e4d97`, wow-re `combat-feel-law.md` §B1, VERIFIED byte-exact)
|
||||
/// tests the **inflight** rec's `Attributes & 0x404`, never the pressed spell's — so a wand
|
||||
/// Shoot / Auto Shot pressed during a Frostbolt meets the same `0x61` as any other press.
|
||||
/// Ours exempted the ranged class at the gate, so the shot went out, its non-guarding arm
|
||||
/// overwrote the Frostbolt's record, vmangos refused the shot `SPELL_IN_PROGRESS`, and the
|
||||
/// still-running Frostbolt was left unguarded for the next mashed key.
|
||||
#[test]
|
||||
fn a_shot_pressed_mid_cast_is_refused_and_the_cast_keeps_its_guard() {
|
||||
const FROSTBOLT: u32 = 116;
|
||||
let (mut world, rx) = combat_world(false);
|
||||
world
|
||||
.resource_mut::<crate::spell::PendingCast>()
|
||||
.arm(FROSTBOLT, Instant::now(), true);
|
||||
|
||||
send_at(&mut world, AUTO_SHOT, MOB);
|
||||
|
||||
assert!(rx.try_recv().is_err(), "nothing goes out on the wire");
|
||||
assert_eq!(
|
||||
world.resource::<CastErrors>().0,
|
||||
vec![CastFail::local(AUTO_SHOT, 0x61)],
|
||||
"a different spell mid-cast is \"Another action is in progress\", whatever its class"
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.resource::<crate::spell::PendingCast>()
|
||||
.current(Instant::now()),
|
||||
Some(FROSTBOLT),
|
||||
"the Frostbolt still holds the guard"
|
||||
);
|
||||
assert_eq!(
|
||||
world.resource::<AutoRepeatActive>().0,
|
||||
None,
|
||||
"and the refused shot armed no repeat"
|
||||
);
|
||||
}
|
||||
|
||||
/// [`world`] plus the catalog and the self player the commit tail needs — `engaged` is our
|
||||
/// server-echoed mirror of the ref's attack lock `[+0xc48]`.
|
||||
fn combat_world(engaged: bool) -> (World, Receiver<ClientCommand>) {
|
||||
|
||||
@@ -101,8 +101,8 @@ fn on_removed_notification(In(ev): In<SessionEvent>, mut auction: ResMut<Auction
|
||||
|
||||
/// An open auction house dies with the socket (decision 1511): every auction command
|
||||
/// re-validates the auctioneer server-side, so a session that survived a reconnect would be a
|
||||
/// window whose every button silently failed. A listener on the session end, which the drain's
|
||||
/// dispatch match still owns (a second handler on the kind, after the bridge's own teardown).
|
||||
/// window whose every button silently failed. A listener on the session end (a second handler
|
||||
/// on `Disconnected`, after the bridge's own teardown, `net::session::on_disconnected`).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
|
||||
auction.clear_session();
|
||||
}
|
||||
|
||||
@@ -176,6 +176,24 @@ pub(crate) struct ChannelState {
|
||||
/// drift from it; the saver refuses to compose a file from `None` for the same reason
|
||||
/// (writing `ZONECHANNELS 0` is the damage 2120 repaired).
|
||||
pub zone_mask: Option<u32>,
|
||||
/// **The custom channels the character is in** — the chat cache header's `CHANNELS` list, the
|
||||
/// names the next login re-joins. Durable state, for exactly [`Self::zone_mask`]'s reason: the
|
||||
/// reference writes that list from its live slot array (`0x499b90`-`0x499bb1`: every slot
|
||||
/// with a number, state 0 and no DBC id) at the chat teardown, when its slots are still
|
||||
/// there — ours are cleared by the session end *before* the flush (the
|
||||
/// `Disconnected{LoggedOut}` twin runs in `Update`, a frame ahead of `OnExit(InWorld)`), so a
|
||||
/// list read off [`Self::joined`] at write time was empty on every logout and no custom
|
||||
/// channel was ever re-joined (2184 §6).
|
||||
///
|
||||
/// So it moves where the slot array's custom half moves, minus the session end: seated from
|
||||
/// the file by `restore_chat_looks` (an overwrite, like the mask), grown on a
|
||||
/// server-confirmed join of a channel with no DBC id ([`Self::note_custom_channel_joined`]),
|
||||
/// shrunk on an explicit leave ([`Self::note_custom_channel_left`]), and nothing else.
|
||||
///
|
||||
/// **Named divergence:** a channel whose re-join the server refuses (a ban, a changed
|
||||
/// password) or that the player is kicked from never takes a confirmed slot in the reference
|
||||
/// and so drops out of its file; here it stays listed until the player `/leave`s it.
|
||||
pub custom: Vec<String>,
|
||||
}
|
||||
|
||||
/// One joined-channel record — the reference's `[0xb4fe04] + n*0xa0` slot, in the fields this
|
||||
@@ -275,6 +293,25 @@ impl ChannelState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A server-confirmed join of a **custom** channel (no `ChatChannels.dbc` id) enters the
|
||||
/// durable re-join list ([`Self::custom`]) — the slot the reference's writer would list at
|
||||
/// teardown. A zone channel is a no-op here; it travels as its mask bit instead.
|
||||
pub(crate) fn note_custom_channel_joined(&mut self, name: &str) {
|
||||
if self.channels.zone_channel_id(name) != 0
|
||||
|| self.custom.iter().any(|c| c.eq_ignore_ascii_case(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.custom.push(name.to_string());
|
||||
}
|
||||
|
||||
/// An **explicit** leave drops a custom channel from the re-join list — the one thing
|
||||
/// besides the file that shrinks it ([`Self::custom`]). Case-insensitive, as the server's
|
||||
/// channel names are.
|
||||
pub(crate) fn note_custom_channel_left(&mut self, name: &str) {
|
||||
self.custom.retain(|c| !c.eq_ignore_ascii_case(name));
|
||||
}
|
||||
|
||||
/// Does the mask carry `id`'s bit — is this `ChatChannels.dbc` row one the walk joins? The
|
||||
/// reference's live predicate `0x49a494` (decision 2144). `None` (not seated) answers false.
|
||||
pub(crate) fn zone_row_wanted(&self, id: u32) -> bool {
|
||||
|
||||
@@ -704,6 +704,9 @@ pub(super) fn deliver(
|
||||
// (decision 2120). Outside the slot claim below because it is not about slots — a
|
||||
// re-confirmation of a channel we already number still owns the bit.
|
||||
channels.note_zone_channel_joined(&event.channel);
|
||||
// …and a custom channel's confirmed join is what enters the durable re-join list the
|
||||
// chat cache writes (`ChannelState::custom`), the same "server agreed" edge.
|
||||
channels.note_custom_channel_joined(&event.channel);
|
||||
// …and the slot the walk RENAMED is already numbered, so the claim below skips it — but
|
||||
// its state still has to come back to `Joined`, or the next notice on that row reads as
|
||||
// another rename. A no-op for a channel we hold no slot for, which is the claim's case.
|
||||
|
||||
@@ -989,12 +989,13 @@ pub(super) fn drain_chat_input(
|
||||
// `LeaveChannelByName` (`0x4a0000` → `0x49ee70`): the VM composed a
|
||||
// shortcut or passed a custom name; a number names a confirmed slot here
|
||||
// or the call is a no-op. The mask clear is this path's and no other's
|
||||
// (decisions 2120, 2144).
|
||||
// (decisions 2120, 2144), and so is the custom re-join list's.
|
||||
let Some(name) = channels.leave_target(&name) else {
|
||||
continue;
|
||||
};
|
||||
manual_join_or_leave(&channels, &mut script, &name);
|
||||
channels.note_zone_channel_left(&name);
|
||||
channels.note_custom_channel_left(&name);
|
||||
ClientCommand::LeaveChannel { name }
|
||||
}
|
||||
C::List { name } => ClientCommand::ChannelList { name },
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
//! the local player's own `PLAYER_GUILDID` and acts on the wire:
|
||||
//!
|
||||
//! - **guilded** ⇒ leave-by-name `"GuildRecruitment - City"` (`0x49eb17` → `0x49ee70`):
|
||||
//! `CMSG_LEAVE_CHANNEL`, the channel stripped from every chat window's list, its `ZONECHANNELS`
|
||||
//! bit cleared, then `UPDATE_CHAT_WINDOWS`. **No already-a-member check.**
|
||||
//! `CMSG_LEAVE_CHANNEL` and its `ZONECHANNELS` bit cleared, then `UPDATE_CHAT_WINDOWS`. **No
|
||||
//! already-a-member check.** The window entry the join registered **stays**: the leave's strip
|
||||
//! key is the full name, which no window carries (wow-re `leavechannelbyname-contract.md` §6/§9).
|
||||
//! - **unguilded, in a capital** (`AreaTable Flags & 0x100`) ⇒ join `"GuildRecruitment"`
|
||||
//! (`0x49eb55` → `0x49eb70`): a slot, the composed name into chat window 1's list,
|
||||
//! `CMSG_JOIN_CHANNEL`, then `UPDATE_CHAT_WINDOWS`. **No already-joined check.**
|
||||
@@ -193,13 +194,7 @@ pub(super) fn guild_recruitment_cascade(
|
||||
match action {
|
||||
Cascade::Leave => {
|
||||
info!("chat: guild recruitment cascade — guilded, leaving {name:?}");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::LeaveChannel { name: name.clone() });
|
||||
// `0x49ee70`'s other two halves: the strip from all ten windows, keyed on the
|
||||
// Shortcut (§6), and the mask bit (`0x49f10a`/`0x49f11a`).
|
||||
script.strip_chat_window_channel(&row.shortcut);
|
||||
channels.note_zone_channel_left(&name);
|
||||
cascade_leave(&mut channels, &commands, name);
|
||||
}
|
||||
Cascade::Join => {
|
||||
info!("chat: guild recruitment cascade — unguilded in a capital, joining {name:?}");
|
||||
@@ -229,6 +224,22 @@ pub(super) fn guild_recruitment_cascade(
|
||||
state.pending = false;
|
||||
}
|
||||
|
||||
/// The cascade's LEAVE arm — leave-by-name `0x49ee70` over the composed name
|
||||
/// `"GuildRecruitment - City"`: the packet and the mask bit (`0x49f10a`/`0x49f11a`).
|
||||
///
|
||||
/// **No window strip.** `0x49ee70` does strip every window's list, but keyed on the DBC Shortcut
|
||||
/// only when its *argument* matched a shortcut — else on the argument verbatim (`0x49eff6` /
|
||||
/// `0x49f017`, wow-re `leavechannelbyname-contract.md` §6, VERIFIED). The full name matches no
|
||||
/// shortcut, and the join arm registered window 1's entry under the Shortcut
|
||||
/// `"GuildRecruitment"`, so the scan misses in every window and the entry survives (§9). Stripping
|
||||
/// by the Shortcut here removed an entry the reference keeps.
|
||||
fn cascade_leave(channels: &mut ChannelState, commands: &NetCommands, name: String) {
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::LeaveChannel { name: name.clone() });
|
||||
channels.note_zone_channel_left(&name);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -252,6 +263,49 @@ mod tests {
|
||||
assert_eq!(cascade(true, 0, false), Some(Cascade::Deferred));
|
||||
}
|
||||
|
||||
/// **The cascade's own leave cannot strip its window entry** (wow-re
|
||||
/// `leavechannelbyname-contract.md` §6/§9, VERIFIED). The join arm registered window 1's entry
|
||||
/// under the Shortcut `"GuildRecruitment"`; the leave's strip key is its argument, the full
|
||||
/// `"GuildRecruitment - City"`, which matches no DBC shortcut and so is used verbatim — and
|
||||
/// equals no window entry. The packet goes out and the mask bit clears; the entry stays.
|
||||
#[test]
|
||||
fn the_cascades_leave_keeps_the_windows_entry() {
|
||||
const GUILD_RECRUITMENT: u32 = 25;
|
||||
let mut script = UiScript::new().expect("a VM");
|
||||
assert!(script.register_chat_window_channel(0, "GuildRecruitment", GUILD_RECRUITMENT));
|
||||
let mut channels = ChannelState {
|
||||
channels: benilla_formats::ChatChannelsCatalog::from_rows(vec![
|
||||
benilla_formats::ChatChannelRow {
|
||||
id: GUILD_RECRUITMENT,
|
||||
flags: 0x32,
|
||||
pattern: "GuildRecruitment - %s".into(),
|
||||
shortcut: "GuildRecruitment".into(),
|
||||
},
|
||||
]),
|
||||
zone_mask: Some(1 << (GUILD_RECRUITMENT - 1)),
|
||||
..Default::default()
|
||||
};
|
||||
channels.claim_slot("GuildRecruitment - City");
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
|
||||
cascade_leave(
|
||||
&mut channels,
|
||||
&NetCommands(tx),
|
||||
"GuildRecruitment - City".into(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(ClientCommand::LeaveChannel { name }) if name == "GuildRecruitment - City"),
|
||||
"the CMSG_LEAVE_CHANNEL goes out"
|
||||
);
|
||||
assert_eq!(channels.zone_mask, Some(0), "the mask bit clears");
|
||||
assert_eq!(
|
||||
script.chat_window_looks()[0].channels,
|
||||
vec![("GuildRecruitment".to_string(), GUILD_RECRUITMENT)],
|
||||
"window 1 still carries the entry the join registered"
|
||||
);
|
||||
}
|
||||
|
||||
/// The watcher: the first sight of the guild id is a change (the player-create trigger), a
|
||||
/// repeat is not, a move is, and the session end forgets — so the next login's first sight is
|
||||
/// a change again.
|
||||
|
||||
@@ -191,20 +191,21 @@ impl Default for Parsed {
|
||||
|
||||
/// Render the file exactly as the writer does (§1), window order.
|
||||
///
|
||||
/// `joined` is the client's current channel roster and supplies the header's `CHANNELS` names —
|
||||
/// **the custom ones only** (`id == 0`): a zone channel is never written as a name, it travels as
|
||||
/// its bit (wow-re `chat-cache-grammar.md` §1.1, which is why all 34 stock files have an empty
|
||||
/// per-window `CHANNELS` block beside a non-zero `ZONECHANNELS`).
|
||||
/// `custom` is [`super::edit::ChannelState::custom`] and supplies the header's `CHANNELS` names —
|
||||
/// **the custom channels only**: a zone channel is never written as a name, it travels as its bit
|
||||
/// (wow-re `chat-cache-grammar.md` §1.1, which is why all 34 stock files have an empty per-window
|
||||
/// `CHANNELS` block beside a non-zero `ZONECHANNELS`). Durable, not the live roster, for the
|
||||
/// mask's own reason: the session end clears the roster before the flush reads it.
|
||||
///
|
||||
/// `zone_mask` is [`super::edit::ChannelState::zone_mask`], written raw into the header
|
||||
/// (`0x499c19`) and ANDed with each window's own bits for its block (`0x49a133`/`0x49a138`). It is
|
||||
/// **passed in rather than derived from `joined`** — decision 2120, and the whole bug: derived, it
|
||||
/// **passed in rather than derived from the live roster** — decision 2120, and the whole bug: derived, it
|
||||
/// was 0 on any save taken while the roster was empty, and `window AND 0` erased the window's
|
||||
/// channel list for good.
|
||||
fn render(
|
||||
looks: &[ChatWindowLook],
|
||||
colors: &[ChatTypeColor],
|
||||
joined: &[(String, u32)],
|
||||
custom: &[String],
|
||||
zone_mask: u32,
|
||||
guild_recruitment_auto: bool,
|
||||
) -> String {
|
||||
@@ -222,11 +223,9 @@ fn render(
|
||||
out.push_str(&format!(
|
||||
"\nVERSION 2\n\nADDEDVERSION 2\n\nOPTION_GUILD_RECRUITMENT_CHANNEL {recruitment}\n\nCHANNELS\n"
|
||||
));
|
||||
for (name, id) in joined {
|
||||
if *id == 0 {
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
}
|
||||
for name in custom {
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!("END\n\nZONECHANNELS {zone_mask}\n\nCOLORS\n"));
|
||||
for c in colors {
|
||||
@@ -494,15 +493,6 @@ fn shortcut_rows(channels: &super::edit::ChannelState) -> Vec<(u32, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The client's channel roster as `(name, zone id)` — what the writer's header and masks are made
|
||||
/// of.
|
||||
fn roster(channels: &super::edit::ChannelState) -> Vec<(String, u32)> {
|
||||
channels
|
||||
.iter_names()
|
||||
.map(|name| (name.to_string(), channels.channels.zone_channel_id(name)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Restore the file into a fresh VM — once per character per VM — and fire the loader's two
|
||||
/// events, file or no file.
|
||||
///
|
||||
@@ -632,6 +622,9 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) {
|
||||
// `Some` is the reference's "chat system ready" flag (`0x499a18`): the walk and the
|
||||
// guild-recruitment cascade both hold until this line has run (decision 2144).
|
||||
channels.zone_mask = Some(mask);
|
||||
// The custom re-join list is durable on the same terms: seated from the file here, then
|
||||
// moved only by a confirmed join and an explicit leave (`ChannelState::custom`).
|
||||
channels.custom = parsed.joined.clone();
|
||||
}
|
||||
script.set_guild_recruitment_mode(u8::from(parsed.guild_recruitment_auto));
|
||||
script.set_chat_colors(parsed.colors);
|
||||
@@ -691,7 +684,7 @@ fn write(script: &UiScript, channels: &super::edit::ChannelState, path: &std::pa
|
||||
let body = render(
|
||||
&script.chat_window_looks(),
|
||||
&script.chat_colors(),
|
||||
&roster(channels),
|
||||
&channels.custom,
|
||||
zone_mask,
|
||||
script.guild_recruitment_mode() != 0,
|
||||
);
|
||||
@@ -886,14 +879,9 @@ mod tests {
|
||||
named,
|
||||
];
|
||||
let colors = colors(&[("SAY", [1, 2, 3]), ("CHANNEL7", [4, 5, 6])]);
|
||||
let joined = vec![
|
||||
("General - Elwynn Forest".to_string(), 1),
|
||||
("Trade - City".to_string(), 2),
|
||||
("MyChan".to_string(), 0),
|
||||
];
|
||||
// The mask is the character's durable one now (2120), passed in rather than derived —
|
||||
// here, the two zone channels the roster holds.
|
||||
let text = render(&looks, &colors, &joined, 0b11, true);
|
||||
// The mask and the custom list are the character's durable state (2120), passed in rather
|
||||
// than derived from the live roster.
|
||||
let text = render(&looks, &colors, &["MyChan".to_string()], 0b11, true);
|
||||
assert!(
|
||||
text.contains("\nCHANNELS\nMyChan\nEND\n\nZONECHANNELS 3\n"),
|
||||
"{text}"
|
||||
@@ -966,13 +954,7 @@ mod tests {
|
||||
w.channels = vec![("General".into(), 1), ("Trade".into(), 2)];
|
||||
// The mask holds General alone — Trade was explicitly left, so the window's own Trade
|
||||
// bit is ANDed away on the way out.
|
||||
let text = render(
|
||||
&[w],
|
||||
&[],
|
||||
&[("General - Elwynn Forest".to_string(), 1)],
|
||||
1,
|
||||
true,
|
||||
);
|
||||
let text = render(&[w], &[], &[], 1, true);
|
||||
let windows: Vec<&str> = text.split("WINDOW 1").collect();
|
||||
assert!(windows[1].contains("ZONECHANNELS 1\n"), "{text}");
|
||||
}
|
||||
@@ -1074,6 +1056,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The custom re-join list moves on the same two edges as the mask — a confirmed join of a
|
||||
/// channel with no DBC id, and an explicit leave — and a zone channel never enters it.
|
||||
#[test]
|
||||
fn the_custom_list_moves_on_join_and_explicit_leave_only() {
|
||||
let mut state = super::super::edit::ChannelState {
|
||||
channels: benilla_formats::ChatChannelsCatalog::from_rows(vec![
|
||||
benilla_formats::ChatChannelRow {
|
||||
id: 1,
|
||||
flags: 0x11,
|
||||
pattern: "General - %s".into(),
|
||||
shortcut: "General".into(),
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
state.note_custom_channel_joined("General - Elwynn Forest");
|
||||
assert!(state.custom.is_empty(), "a zone channel travels as its bit");
|
||||
state.note_custom_channel_joined("MyChan");
|
||||
state.note_custom_channel_joined("mychan");
|
||||
assert_eq!(state.custom, vec!["MyChan".to_string()], "once, by name");
|
||||
state.note_custom_channel_left("MYCHAN");
|
||||
assert!(state.custom.is_empty(), "an explicit leave drops it");
|
||||
}
|
||||
|
||||
/// **The teardown write is unconditional; only the debounce reads the dirty flag** (decision
|
||||
/// 2144). A `/leave General` moves host state alone — the mask — and the reference persists
|
||||
/// it because its teardown saver has no dirty flag; ours gated the same write on a Lua-side
|
||||
@@ -1272,34 +1278,7 @@ mod tests {
|
||||
world.init_resource::<super::super::edit::ChannelState>();
|
||||
world.init_resource::<ChatWindowFile>();
|
||||
crate::ui_script::setup_script(&mut world);
|
||||
world.insert_resource(crate::char_select::Roster::with_pending_pick(
|
||||
vec![benilla_protocol::Character {
|
||||
guid: 1,
|
||||
name: "Reloadprobe".into(),
|
||||
race: 1, // Human → Alliance
|
||||
class: 1, // Warrior
|
||||
gender: 0,
|
||||
level: 60,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
zone: 0,
|
||||
map: 0,
|
||||
position: benilla_protocol::wire::Vector3d {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
flags: 0,
|
||||
equipment: [benilla_protocol::CharEnumItem::default(); 19],
|
||||
pet_display_id: 0,
|
||||
pet_level: 0,
|
||||
pet_family: 0,
|
||||
}],
|
||||
1,
|
||||
));
|
||||
world.insert_resource(probe_roster("Reloadprobe"));
|
||||
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
|
||||
|
||||
let path = world
|
||||
@@ -1346,4 +1325,130 @@ mod tests {
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// A roster with one character picked — what `load_ingame_ui_on_world_entry` keys the
|
||||
/// character's chat cache path on.
|
||||
fn probe_roster(name: &str) -> crate::char_select::Roster {
|
||||
crate::char_select::Roster::with_pending_pick(
|
||||
vec![benilla_protocol::Character {
|
||||
guid: 1,
|
||||
name: name.into(),
|
||||
race: 1, // Human → Alliance
|
||||
class: 1, // Warrior
|
||||
gender: 0,
|
||||
level: 60,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
zone: 0,
|
||||
map: 0,
|
||||
position: benilla_protocol::wire::Vector3d {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
flags: 0,
|
||||
equipment: [benilla_protocol::CharEnumItem::default(); 19],
|
||||
pet_display_id: 0,
|
||||
pet_level: 0,
|
||||
pet_family: 0,
|
||||
}],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
/// **A custom channel survives `/logout`** (2184 §6, the question it left open). The header's
|
||||
/// `CHANNELS` list is the character's custom channels to re-join at the next login. It was
|
||||
/// composed from the live roster, and the logout clears that roster first: the
|
||||
/// `Disconnected{LoggedOut}` that arrives in the same net drain as the logout runs
|
||||
/// `end_session_channels_on_disconnect` in `Update`, a frame before `OnExit(InWorld)` reaches
|
||||
/// `end_ui_session`'s flush. So the file was written with an empty list and the channel was
|
||||
/// never re-joined. The same order is driven here, by the same functions.
|
||||
#[test]
|
||||
fn a_custom_channel_survives_a_logout() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-chat-custom-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("benilla-config")).expect("hermetic home");
|
||||
let _capture = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let _home = crate::local_state::test_env::EnvGuard::set(
|
||||
"BENILLA_HOME",
|
||||
tmp.join("benilla-config")
|
||||
.to_str()
|
||||
.expect("utf-8 temp path"),
|
||||
);
|
||||
|
||||
let roster = probe_roster("Channelprobe");
|
||||
let id = crate::ui_macro::identity(&roster).expect("a picked character");
|
||||
let path = crate::local_state::chat_character_path(&id.0, &id.1).expect("a cache path");
|
||||
std::fs::create_dir_all(path.parent().expect("a parent")).expect("the character dir");
|
||||
// A file this writer made (the marker), listing one custom channel in the header.
|
||||
let file = format!(
|
||||
"{WRITER_GENERATION}\nVERSION 2\n\nCHANNELS\nMyChan\nEND\n\nZONECHANNELS 3\n\n\
|
||||
COLORS\nEND\n\nWINDOW 1\nSIZE 0\n\nMESSAGES\nSAY\nEND\n\nCHANNELS\nEND\n\n\
|
||||
ZONECHANNELS 3\n\nEND\n"
|
||||
);
|
||||
std::fs::write(&path, file).expect("seed the file");
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<crate::ui_script::AddOnIdentity>();
|
||||
world.init_resource::<crate::minimap::MinimapZoom>();
|
||||
world.init_resource::<crate::ui_script::ReloadUiPending>();
|
||||
world.init_resource::<super::super::edit::ChannelState>();
|
||||
world.init_resource::<super::super::channels::ZoneChannelWalk>();
|
||||
world.init_resource::<super::super::recruitment::GuildRecruitmentCascade>();
|
||||
world.init_resource::<Messages<crate::net::DisconnectedMessage>>();
|
||||
world.init_resource::<ChatWindowFile>();
|
||||
crate::ui_script::setup_script(&mut world);
|
||||
world.insert_resource(roster);
|
||||
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
|
||||
|
||||
// The login's re-join is confirmed: the server's YOU_JOINED, through the real feed arm.
|
||||
world.resource_scope(
|
||||
|world, mut channels: Mut<super::super::edit::ChannelState>| {
|
||||
let mut script = world.non_send_resource_mut::<UiScript>();
|
||||
let mut windows = super::super::frames::ChatWindows::default();
|
||||
let mut e = super::super::event::ChatEvent::text_only(
|
||||
super::super::event::ChatEventKind::ChannelNotice,
|
||||
String::new(),
|
||||
);
|
||||
e.channel = "MyChan".into();
|
||||
e.notice = "2".into(); // YOU_JOINED
|
||||
super::super::feed::deliver(&mut script, &mut windows, &mut channels, &mut e);
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.resource::<super::super::edit::ChannelState>()
|
||||
.number_of("MyChan"),
|
||||
Some(1),
|
||||
"precondition: the channel is joined and numbered"
|
||||
);
|
||||
|
||||
// `/logout`: the disconnect lands in `Update` first…
|
||||
world.write_message(crate::net::DisconnectedMessage::new(
|
||||
"logged out".into(),
|
||||
benilla_protocol::SessionEnd::LoggedOut,
|
||||
));
|
||||
world
|
||||
.run_system_once(super::super::channels::end_session_channels_on_disconnect)
|
||||
.expect("the disconnect twin runs");
|
||||
// …and `OnExit(InWorld)` flushes the dying VM's cache a frame later.
|
||||
crate::ui_script::end_ui_session(&mut world);
|
||||
|
||||
let on_disk = std::fs::read_to_string(&path).expect("the flush wrote the file");
|
||||
assert!(
|
||||
on_disk.contains("\nCHANNELS\nMyChan\nEND\n\nZONECHANNELS"),
|
||||
"the custom channel is still in the header's re-join list; file was:\n{on_disk}"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,9 +563,10 @@ pub(super) fn drain_container_uses(
|
||||
// the lock setter `0x4953e0` at `0x5edcd9` and only then ships `CMSG_OPEN_ITEM`
|
||||
// (wow-re `inventory-change-failure-display.md` §8, decision 0916). So a clam,
|
||||
// lockbox or loot bag greys the instant you right-click it and stays grey until the
|
||||
// server answers — the loot landing (a resolving field update) or a refusal
|
||||
// (`EQUIP_ERR_ITEM_LOCKED` on a still-locked junkbox), both of which
|
||||
// `PendingItemOps` already clears on.
|
||||
// open resolves — the emptied item vanishing (a resolving field update), a refusal
|
||||
// (`EQUIP_ERR_ITEM_LOCKED` on a still-locked junkbox), or the window closed with loot
|
||||
// left, whose `SMSG_LOOT_RELEASE_RESPONSE` unlocks it by guid (`ui_loot::net`'s
|
||||
// `loot_release_response`, the reference's `UnlockItem` at `48f299`).
|
||||
//
|
||||
// Deliberately NOT armed on the gift-unwrap arm above, which sends the same opcode:
|
||||
// its emitter `0x5edd60` contains neither call — no lock setter and no latch write.
|
||||
|
||||
@@ -10,6 +10,7 @@ use bevy::prelude::*;
|
||||
|
||||
use super::{LootLatch, LootState};
|
||||
use crate::net::{ClientCommand, NetCommands, NetHandlerApp, SelfGuid};
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use crate::ui_action::{UiError, UiErrorKeys};
|
||||
|
||||
/// Register the loot handlers — called from [`super::UiLootPlugin`]. One per kind, plus the
|
||||
@@ -52,9 +53,15 @@ fn on_error(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut errors: ResMut<UiErrorKeys>,
|
||||
mut latch: ResMut<LootLatch>,
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
mut lock_cleared: ResMut<LockTransitions>,
|
||||
) {
|
||||
if let SessionEvent::LootError { guid, error } = ev {
|
||||
loot_error(guid, error, &mut errors, &mut latch);
|
||||
let unlock = ItemUnlock {
|
||||
pending: &mut pending,
|
||||
lock_cleared: &mut lock_cleared,
|
||||
};
|
||||
loot_error(guid, error, &mut errors, &mut latch, unlock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +87,30 @@ fn on_release_response(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut loot: ResMut<LootState>,
|
||||
mut latch: ResMut<LootLatch>,
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
mut lock_cleared: ResMut<LockTransitions>,
|
||||
) {
|
||||
if let SessionEvent::LootReleaseResponse { guid } = ev {
|
||||
loot_release_response(guid, &mut loot, &mut latch);
|
||||
let unlock = ItemUnlock {
|
||||
pending: &mut pending,
|
||||
lock_cleared: &mut lock_cleared,
|
||||
};
|
||||
loot_release_response(guid, &mut loot, &mut latch, unlock);
|
||||
}
|
||||
}
|
||||
|
||||
/// **`UnlockItem 0x495420`** as the loot closes call it — on the loot guid, which unlocks
|
||||
/// something only when that guid is an item we locked (a lockbox, clam or loot bag, locked at its
|
||||
/// `CMSG_OPEN_ITEM` send — decision 0916). The unlocked slots queue in [`LockTransitions`] for the
|
||||
/// container feed to fire `ITEM_LOCK_CHANGED`, like the inventory failure's clear.
|
||||
struct ItemUnlock<'a> {
|
||||
pending: &'a mut PendingItemOps,
|
||||
lock_cleared: &'a mut LockTransitions,
|
||||
}
|
||||
|
||||
impl ItemUnlock<'_> {
|
||||
fn unlock(self, guid: u64) {
|
||||
self.lock_cleared.0.extend(self.pending.clear_by_guid(guid));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +334,12 @@ fn loot_refusal(reason: u8) -> LootRefusal {
|
||||
/// localization. Before this, benilla composed its own eight sentences here and six of them said
|
||||
/// something the client never says.
|
||||
///
|
||||
/// **Two gaps this deliberately does not close** (surfaced by the wow-re §5, named here rather
|
||||
/// The arms that release run the reference's release tail `0x5ebac2`, which also calls
|
||||
/// `UnlockItem 0x495420` on the packet's guid (wow-re `loot-anim-leg.md` §7.1): a refused lockbox
|
||||
/// open drops the item's pending lock. For a corpse or chest guid there is no item, and nothing
|
||||
/// unlocks.
|
||||
///
|
||||
/// **A gap this deliberately does not close** (surfaced by the wow-re §5, named here rather
|
||||
/// than left to a later bug report):
|
||||
///
|
||||
/// 1. **A guid-MISMATCHED error takes a different arm in the reference.** The error leg is
|
||||
@@ -315,11 +348,13 @@ fn loot_refusal(reason: u8) -> LootRefusal {
|
||||
/// unconditionally, shows **nothing**, and (because `lootType == 0`) sends nothing. benilla has
|
||||
/// no admission gate on the error shape — it displays the line and keeps the latch. Reachable
|
||||
/// only under the corpse-switch race, and it belongs with the gate (1477), not with the text.
|
||||
/// 2. **`UnlockItem` fires `ITEM_LOCK_CHANGED` (188)** on the way through the tail, clearing
|
||||
/// `[item+0x314]` bit 0. That matters only when the loot source is an ITEM guid — a lockbox —
|
||||
/// where a refused open should drop the item's pending lock; for a corpse or chest guid there
|
||||
/// is no item to unlock. benilla does not touch [`LockTransitions`] here.
|
||||
fn loot_error(guid: u64, error: u8, errors: &mut UiErrorKeys, latch: &mut LootLatch) {
|
||||
fn loot_error(
|
||||
guid: u64,
|
||||
error: u8,
|
||||
errors: &mut UiErrorKeys,
|
||||
latch: &mut LootLatch,
|
||||
unlock: ItemUnlock,
|
||||
) {
|
||||
let LootRefusal { key, releases } = loot_refusal(error);
|
||||
debug!("net: loot error {error} on {guid:#x} → {key} (releases: {releases})");
|
||||
errors.0.push(UiError::key(key));
|
||||
@@ -327,6 +362,7 @@ fn loot_error(guid: u64, error: u8, errors: &mut UiErrorKeys, latch: &mut LootLa
|
||||
// The latch armed at the `CMSG_LOOT` send drops (guid-matched — see [`LootLatch`]), or the
|
||||
// character would kneel forever at a corpse whose window never opened (decision 0515).
|
||||
latch.clear_for(guid);
|
||||
unlock.unlock(guid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,10 +391,22 @@ fn loot_clear_money(loot: &mut LootState) {
|
||||
/// Idempotent — a client-side close already cleared. The latch clear is **guid-matched**: under
|
||||
/// the corpse-switch race (loot B requested while A was open) the old window's release response
|
||||
/// must not drop the latch the new request just armed (decision 0515).
|
||||
fn loot_release_response(guid: u64, loot: &mut LootState, latch: &mut LootLatch) {
|
||||
///
|
||||
/// **An item loot unlocks here.** The handler (`0x5ec090`) ends in `0x48f200(cl=0, dl=0)`, whose
|
||||
/// `48f299` leg calls `UnlockItem 0x495420` when the loot object is an ITEM (wow-re
|
||||
/// `ui/ledger.tsv` `0x48f200`). It is the only clear an opened lockbox closed with loot left
|
||||
/// ever gets: vmangos' `DoLootRelease` destroys the item only once it is fully looted, so its
|
||||
/// slot never changes and [`PendingItemOps::resolve`] has nothing to see.
|
||||
fn loot_release_response(
|
||||
guid: u64,
|
||||
loot: &mut LootState,
|
||||
latch: &mut LootLatch,
|
||||
unlock: ItemUnlock,
|
||||
) {
|
||||
debug!("net: loot released {guid:#x}");
|
||||
loot.clear();
|
||||
latch.clear_for(guid);
|
||||
unlock.unlock(guid);
|
||||
}
|
||||
|
||||
/// The master-loot candidate list (`SMSG_LOOT_MASTER_LIST`, decision 1675) — who the master looter
|
||||
@@ -416,6 +464,19 @@ fn item_push_result(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
|
||||
/// An [`ItemUnlock`] over an empty lock set — for the tests whose loot source is no item.
|
||||
macro_rules! no_item {
|
||||
() => {
|
||||
ItemUnlock {
|
||||
pending: &mut PendingItemOps::default(),
|
||||
lock_cleared: &mut LockTransitions::default(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Both fish-verdict keys queue as **yellow** (type-1 / `UI_INFO_MESSAGE`) entries — the
|
||||
/// byte-verified arm, wow-re `fish-msg-handlers.md` — and both resolve to the exact 1.12
|
||||
@@ -564,14 +625,14 @@ mod tests {
|
||||
for code in [4u8, 5, 6, 7, 8, 9, 11, 0, 1, 2, 3, 15, 16, 200, 255] {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
let mut latch = LootLatch(Some(CORPSE));
|
||||
loot_error(CORPSE, code, &mut errors, &mut latch);
|
||||
loot_error(CORPSE, code, &mut errors, &mut latch, no_item!());
|
||||
assert_eq!(latch.0, None, "code {code} should release");
|
||||
assert_eq!(errors.0.len(), 1, "code {code} shows exactly one line");
|
||||
}
|
||||
for code in [10u8, 12, 13, 14] {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
let mut latch = LootLatch(Some(CORPSE));
|
||||
loot_error(CORPSE, code, &mut errors, &mut latch);
|
||||
loot_error(CORPSE, code, &mut errors, &mut latch, no_item!());
|
||||
assert_eq!(
|
||||
latch.0,
|
||||
Some(CORPSE),
|
||||
@@ -598,7 +659,7 @@ mod tests {
|
||||
fn the_error_legs_clear_is_guid_matched() {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
let mut latch = LootLatch(Some(CHEST));
|
||||
loot_error(CORPSE, 8, &mut errors, &mut latch);
|
||||
loot_error(CORPSE, 8, &mut errors, &mut latch, no_item!());
|
||||
assert_eq!(latch.0, Some(CHEST));
|
||||
}
|
||||
|
||||
@@ -637,7 +698,7 @@ mod tests {
|
||||
|
||||
// The ordinary close path still ends it — the latch is guid-matched, and a chest guid is
|
||||
// no different from a corpse one there.
|
||||
loot_release_response(CHEST, &mut loot, &mut latch);
|
||||
loot_release_response(CHEST, &mut loot, &mut latch, no_item!());
|
||||
assert_eq!(latch.0, None, "the release ends the session");
|
||||
}
|
||||
|
||||
@@ -692,6 +753,93 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A lockbox's item guid (HIGHGUID_ITEM is 0x4000 in the high word).
|
||||
const LOCKBOX: u64 = 0x4000_0000_0000_0007;
|
||||
|
||||
/// A World with what the release / error handlers write, and the lockbox's open-item lock
|
||||
/// already armed at bag 0 slot 3 — `ui_items::drain`'s `CMSG_OPEN_ITEM` arm (0916).
|
||||
fn opened_lockbox_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<LootState>();
|
||||
world.insert_resource(LootLatch(Some(LOCKBOX)));
|
||||
world.init_resource::<UiErrorKeys>();
|
||||
world.init_resource::<LockTransitions>();
|
||||
let mut pending = PendingItemOps::default();
|
||||
pending.add([(0, 3, LOCKBOX, 1)]);
|
||||
world.insert_resource(pending);
|
||||
world
|
||||
}
|
||||
|
||||
/// **The release unlocks an opened lockbox.** `SMSG_LOOT_RELEASE_RESPONSE` → `0x5ec090` →
|
||||
/// `0x48f200(cl=0, dl=0)`, whose `48f299` leg calls `UnlockItem 0x495420` when the loot
|
||||
/// object is an ITEM (wow-re `ui/ledger.tsv` `0x48f200`). Closing a lockbox's window with
|
||||
/// loot left destroys nothing server-side (vmangos `DoLootRelease` destroys only a fully
|
||||
/// looted item), so no field update ever resolves the lock — the release is the clear.
|
||||
#[test]
|
||||
fn a_release_for_the_opened_item_unlocks_its_slot() {
|
||||
let mut world = opened_lockbox_world();
|
||||
world
|
||||
.run_system_once_with(
|
||||
on_release_response,
|
||||
SessionEvent::LootReleaseResponse { guid: LOCKBOX },
|
||||
)
|
||||
.expect("the handler runs as a one-shot system");
|
||||
assert!(
|
||||
!world.resource::<PendingItemOps>().contains(0, 3),
|
||||
"the lockbox is no longer grey and locked"
|
||||
);
|
||||
assert_eq!(
|
||||
world.resource::<LockTransitions>().0,
|
||||
vec![(0, 3)],
|
||||
"…and the container feed fires ITEM_LOCK_CHANGED for it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The control: a release naming some other object (a corpse closing) leaves the lockbox's
|
||||
/// lock exactly where it was.
|
||||
#[test]
|
||||
fn a_release_for_another_guid_leaves_the_lock() {
|
||||
let mut world = opened_lockbox_world();
|
||||
world
|
||||
.run_system_once_with(
|
||||
on_release_response,
|
||||
SessionEvent::LootReleaseResponse { guid: CORPSE },
|
||||
)
|
||||
.expect("the handler runs as a one-shot system");
|
||||
assert!(world.resource::<PendingItemOps>().contains(0, 3));
|
||||
assert!(world.resource::<LockTransitions>().0.is_empty());
|
||||
}
|
||||
|
||||
/// The error leg's release tail `0x5ebac2` calls the same `UnlockItem 0x495420` on the
|
||||
/// packet's guid (wow-re `loot-anim-leg.md` §7.1) — on the arms that release, and only those.
|
||||
#[test]
|
||||
fn a_releasing_loot_error_on_the_opened_item_unlocks_it() {
|
||||
let mut world = opened_lockbox_world();
|
||||
// PLAYER_NOT_FOUND (10) does not reach the tail: nothing unlocks.
|
||||
world
|
||||
.run_system_once_with(
|
||||
on_error,
|
||||
SessionEvent::LootError {
|
||||
guid: LOCKBOX,
|
||||
error: 10,
|
||||
},
|
||||
)
|
||||
.expect("the handler runs as a one-shot system");
|
||||
assert!(world.resource::<PendingItemOps>().contains(0, 3));
|
||||
// TOO_FAR (4) does.
|
||||
world
|
||||
.run_system_once_with(
|
||||
on_error,
|
||||
SessionEvent::LootError {
|
||||
guid: LOCKBOX,
|
||||
error: 4,
|
||||
},
|
||||
)
|
||||
.expect("the handler runs as a one-shot system");
|
||||
assert!(!world.resource::<PendingItemOps>().contains(0, 3));
|
||||
assert_eq!(world.resource::<LockTransitions>().0, vec![(0, 3)]);
|
||||
}
|
||||
|
||||
const ME: u64 = 0x0000_0000_0000_002A;
|
||||
const THEM: u64 = 0x0000_0000_0000_00FF;
|
||||
|
||||
|
||||
@@ -82,8 +82,8 @@ fn on_next_mail_time(In(ev): In<SessionEvent>, mut pending: ResMut<MailPending>)
|
||||
|
||||
/// An open mailbox dies with the socket, and the arrival countdown is login-scoped (decision
|
||||
/// 0544 P3): a fresh login re-queries `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing
|
||||
/// carries over across a reconnect. A listener on the session end, which the drain's dispatch
|
||||
/// match still owns (a second handler on the kind, after the bridge's own teardown).
|
||||
/// carries over across a reconnect. A listener on the session end (a second handler on
|
||||
/// `Disconnected`, after the bridge's own teardown, `net::session::on_disconnected`).
|
||||
fn on_session_end(
|
||||
In(_): In<SessionEvent>,
|
||||
mut mail: ResMut<MailOpen>,
|
||||
|
||||
@@ -736,6 +736,11 @@ fn feed_quest_log(
|
||||
let mut entries: Vec<QuestLogEntryView> = Vec::new();
|
||||
let mut entry_slots: Vec<Option<u8>> = Vec::new();
|
||||
let mut header_keys: Vec<Option<String>> = Vec::new();
|
||||
// The quests folded under a collapsed header: out of the visible list, still in the log. The
|
||||
// engine's watch prune counts them, as the reference's does (`0x4de7a7`–`0x4de80f` scans the
|
||||
// whole row array, hidden rows included — wow-re `questlog-list-rebuild.md` §7/§8); leaving
|
||||
// them out made every collapse drop its quests' watches for good.
|
||||
let mut hidden_quest_ids: Vec<u32> = Vec::new();
|
||||
for (_, name, row_idxs) in &groups {
|
||||
let collapsed = quest_log.collapsed.contains(name);
|
||||
entries.push(QuestLogEntryView {
|
||||
@@ -756,7 +761,9 @@ fn feed_quest_log(
|
||||
for &ri in row_idxs {
|
||||
let r = &rows[ri];
|
||||
if collapsed {
|
||||
continue; // folded: the quest stays in the log, just not in the visible list
|
||||
// Folded: the quest stays in the log, just not in the visible list.
|
||||
hidden_quest_ids.push(r.quest_id);
|
||||
continue;
|
||||
}
|
||||
let (title, level, tag, pushable, objectives, detail) =
|
||||
match quest_log.template(r.quest_id, &commands) {
|
||||
@@ -846,6 +853,7 @@ fn feed_quest_log(
|
||||
let fresh = QuestLogState {
|
||||
entries,
|
||||
num_quests: rows.len() as u32,
|
||||
hidden_quest_ids,
|
||||
};
|
||||
if fresh == *last {
|
||||
return;
|
||||
@@ -1725,6 +1733,7 @@ mod tests {
|
||||
QuestLogState {
|
||||
num_quests: entries.iter().filter(|e| !e.is_header).count() as u32,
|
||||
entries,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,9 @@ impl Plugin for UiSavedPlugin {
|
||||
///
|
||||
/// Called from the in-game UI load ([`crate::ui_script`]) at the reference's own seam — after the
|
||||
/// XML, before anything consumes it. A missing file is the normal first-run case (defaults stand);
|
||||
/// a malformed one warns and is left on disk untouched, so a hand edit that fails to parse costs
|
||||
/// this session's settings and not the file.
|
||||
/// a malformed or unreadable one warns and is **held** ([`UiScript::hold_saved_file`]), so a hand
|
||||
/// edit that fails to parse costs this session's settings and not the file. Read as **bytes**
|
||||
/// (1193): the writer keeps a Lua byte string's bytes, so the file need not be UTF-8.
|
||||
///
|
||||
/// **`host_settings` is the third store's turn** (decision 2132). A handful of the reference's
|
||||
/// `RegisterForSave` globals are settings benilla persists in `config.toml` instead — the
|
||||
@@ -62,19 +63,24 @@ pub(crate) fn load_saved_variables(
|
||||
) {
|
||||
// `None` = hermetic capture, or no install — session-only state, and the event below still fires.
|
||||
if let Some(path) = crate::local_state::saved_variables_path() {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => {
|
||||
if let Err(e) = script.run(&text) {
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = script.run_chunk(&bytes) {
|
||||
warn!(
|
||||
"saved variables: {} did not load ({e}) — running on defaults",
|
||||
"saved variables: {} did not load ({e}) — running on defaults, and \
|
||||
leaving the file as it is",
|
||||
path.display()
|
||||
);
|
||||
script.hold_saved_file(&path);
|
||||
} else {
|
||||
info!("saved variables: loaded {}", path.display());
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => warn!("saved variables: cannot read {}: {e}", path.display()),
|
||||
Err(e) => {
|
||||
warn!("saved variables: cannot read {}: {e}", path.display());
|
||||
script.hold_saved_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
host_settings(script);
|
||||
@@ -98,11 +104,19 @@ pub(crate) fn save(script: &mut UiScript) {
|
||||
let Some(path) = crate::local_state::saved_variables_path() else {
|
||||
return;
|
||||
};
|
||||
let body = script.saved_variables_text();
|
||||
if script.saved_file_held(&path) {
|
||||
warn!(
|
||||
"saved variables: {} did not load this session — left as it is",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut body = HEADER.as_bytes().to_vec();
|
||||
body.extend(script.saved_variables_bytes());
|
||||
for w in script.take_warnings() {
|
||||
warn!("saved variables: {w}");
|
||||
}
|
||||
match crate::local_state::write_atomic(&path, &format!("{HEADER}{body}")) {
|
||||
match crate::local_state::write_atomic_bytes(&path, &body) {
|
||||
Ok(()) => info!(
|
||||
"saved variables: wrote {} ({} names)",
|
||||
path.display(),
|
||||
@@ -173,6 +187,40 @@ mod tests {
|
||||
load_saved_variables(&mut broken, |_| {});
|
||||
assert_eq!(broken.eval::<i64>("return KEPT").unwrap(), 1);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "KEPT = = 3\n");
|
||||
// …and the shutdown write leaves it alone too. This is the half that used to fail: the
|
||||
// load kept its promise, and the save then wrote this session's default over the file.
|
||||
save(&mut broken);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
"KEPT = = 3\n",
|
||||
"a file that did not load is not replaced by the defaults the session ran on"
|
||||
);
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// **The bytes survive the folder.** A value that is not UTF-8 is written raw and read back
|
||||
/// raw — the load reads bytes, so the file it now writes is one it can always read (a
|
||||
/// `read_to_string` here would call it unreadable and the next save would replace it).
|
||||
#[test]
|
||||
fn a_byte_string_survives_the_file() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-svbytes-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
|
||||
|
||||
let mut s = script("string.char(65, 233, 255)");
|
||||
save(&mut s);
|
||||
let mut fresh = script("'x'");
|
||||
load_saved_variables(&mut fresh, |_| {});
|
||||
assert!(fresh
|
||||
.eval::<bool>(
|
||||
"return string.len(KEPT) == 3 and string.byte(KEPT, 2) == 233 \
|
||||
and string.byte(KEPT, 3) == 255"
|
||||
)
|
||||
.unwrap());
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -1051,9 +1051,19 @@ pub(super) fn save_addon_variables(script: &mut UiScript, identity: Option<&(Str
|
||||
continue;
|
||||
}
|
||||
let Some(dir) = dir else { continue };
|
||||
let body = script.saved_variables_text_for(names);
|
||||
let path = dir.join(format!("{name}.lua"));
|
||||
match crate::local_state::write_atomic(&path, &format!("{SAVED_HEADER}{body}")) {
|
||||
if script.saved_file_held(&path) {
|
||||
// It failed to load this session: its globals are the addon's defaults, and
|
||||
// writing them would replace the player's file with them.
|
||||
warn!(
|
||||
"ui_script: {} did not load this session — left as it is",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut body = SAVED_HEADER.as_bytes().to_vec();
|
||||
body.extend(script.saved_variables_bytes_for(names));
|
||||
match crate::local_state::write_atomic_bytes(&path, &body) {
|
||||
Ok(()) => info!("ui_script: wrote {}", path.display()),
|
||||
Err(e) => warn!("ui_script: cannot write {}: {e}", path.display()),
|
||||
}
|
||||
|
||||
@@ -650,16 +650,10 @@ pub(super) fn tick_script(
|
||||
{
|
||||
let _span = bevy::log::info_span!("ui_script: tick").entered();
|
||||
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
|
||||
// A resize re-runs the bottom-stack manage pass (decision 1499). Anchors follow the new
|
||||
// screen rect by themselves; what does not is a seat somebody COMPUTED from the old
|
||||
// height — the open-bag stack starts a fresh column when the current one would run off
|
||||
// the top, and that decision is made from `GetScreenHeight()` at layout time. Without
|
||||
// this, dragging the window smaller leaves the bag columns wrapped for the old height
|
||||
// until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
|
||||
// which is in-game UI, and this system also runs on the glue screens.
|
||||
// A resize re-runs everything the interface COMPUTED from the old screen size — see
|
||||
// [`super::manifest::on_screen_resized`].
|
||||
if resized {
|
||||
let _ = script
|
||||
.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
|
||||
super::manifest::on_screen_resized(&script);
|
||||
}
|
||||
script.tick(time.delta_secs());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use bevy::window::PrimaryWindow;
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::{CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, UiKeyboardCapture};
|
||||
use crate::bindings::WheelNotches;
|
||||
use crate::textinput::{self, keymap, HostClipboard};
|
||||
|
||||
/// The pointer-side state [`feed_ui_input`] reads and writes, as one
|
||||
@@ -58,6 +59,28 @@ impl PointerFeed<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's wheel travel into the pane under the cursor's `OnMouseWheel` — **one call per whole
|
||||
/// notch**, `arg1 = ±1`.
|
||||
///
|
||||
/// The travel is normalised to lines first ([`crate::bindings::wheel_lines`]) and the fraction
|
||||
/// carried in `notches`: a trackpad reports a gesture as a `Pixel` trickle across many frames, and
|
||||
/// passing each frame's raw delta fired the handler once per frame — the stock handlers act on
|
||||
/// the sign alone (`ScrollFrameTemplate_OnMouseWheel` scrolls half a pane per call), so a gentle
|
||||
/// swipe slammed the pane to its end. A mouse wheel's `Line` notch still fires once, as before.
|
||||
fn feed_wheel(
|
||||
script: &mut UiScript,
|
||||
notches: &mut WheelNotches,
|
||||
x: f32,
|
||||
y: f32,
|
||||
scroll: &AccumulatedMouseScroll,
|
||||
) {
|
||||
let whole = notches.feed(crate::bindings::wheel_lines(scroll.unit, scroll.delta.y));
|
||||
let step = whole.signum() as f32;
|
||||
for _ in 0..whole.unsigned_abs() {
|
||||
script.mouse_wheel(x, y, step);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed the window's cursor + buttons + wheel + keyboard into the UI engine (after
|
||||
/// [`super::extract::tick_script`] has resolved this frame's rects), firing
|
||||
/// OnEnter/OnLeave/OnClick/OnMouseWheel and the EditBox
|
||||
@@ -73,7 +96,9 @@ pub(super) fn feed_ui_input(
|
||||
// winit has actually created the surface.
|
||||
window: Query<(&Window, Option<&bevy::window::RawHandleWrapper>), With<PrimaryWindow>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
scroll: Res<AccumulatedMouseScroll>,
|
||||
// The frame's wheel travel, and the fraction of a notch carried between frames
|
||||
// ([`feed_wheel`]) — one param for clippy's argument ceiling.
|
||||
(scroll, mut notches): (Res<AccumulatedMouseScroll>, ResMut<WheelNotches>),
|
||||
// One [`PointerFeed`] (clippy's argument ceiling): the hover + click-consumed outputs this
|
||||
// pass writes, the world pick that routes the world-click payload legs (decision 0571), and
|
||||
// the payload-held mirror written for the Send-side world-click consumers.
|
||||
@@ -219,9 +244,7 @@ pub(super) fn feed_ui_input(
|
||||
script.mouse_button(x, y, name, false);
|
||||
}
|
||||
}
|
||||
if scroll.delta.y != 0.0 {
|
||||
script.mouse_wheel(x, y, scroll.delta.y);
|
||||
}
|
||||
feed_wheel(&mut script, &mut notches, x, y, &scroll);
|
||||
} else if !synthetic {
|
||||
// The OS pointer left the window: leave whatever frame was hovered (once, on the
|
||||
// Some→None transition) and — every frame it stays outside — clear any armed press/drag
|
||||
@@ -417,3 +440,93 @@ pub(super) fn feed_ui_input(
|
||||
// bare-key window toggles — moved into the command registry (`crate::bindings::commands`,
|
||||
// decision 0997): one chord→command table, rebindable, with 0585's modifier law enforced once
|
||||
// in the dispatch instead of per branch here.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::input::mouse::MouseScrollUnit;
|
||||
|
||||
/// A bare VM with one wheel-enabled pane at the screen's centre whose `OnMouseWheel` counts
|
||||
/// its calls and sums its `arg1`s.
|
||||
fn wheel_pane() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "WheelProbe")
|
||||
f:SetWidth(200) f:SetHeight(200) f:SetPoint("CENTER", nil, "CENTER", 0, 0)
|
||||
f:EnableMouseWheel(true)
|
||||
f:SetScript("OnMouseWheel", function()
|
||||
WheelCalls = (WheelCalls or 0) + 1
|
||||
WheelSum = (WheelSum or 0) + arg1
|
||||
end)
|
||||
f:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
fn calls(s: &UiScript) -> (i64, f64) {
|
||||
s.eval::<(Option<i64>, Option<f64>)>("return WheelCalls, WheelSum")
|
||||
.map(|(c, v)| (c.unwrap_or(0), v.unwrap_or(0.0)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn frame_of(unit: MouseScrollUnit, dy: f32) -> AccumulatedMouseScroll {
|
||||
AccumulatedMouseScroll {
|
||||
unit,
|
||||
delta: Vec2::new(0.0, dy),
|
||||
}
|
||||
}
|
||||
|
||||
/// **A gentle trackpad swipe is not a notch a frame.** Ten frames of a small `Pixel` delta —
|
||||
/// a tenth of a line apiece at Bevy's factor, one line in total — must fire the pane's
|
||||
/// `OnMouseWheel` at most the ONE notch they add up to. Fed raw, it fired ten times, and a
|
||||
/// stock `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call off the sign alone.
|
||||
#[test]
|
||||
fn a_trackpad_trickle_fires_only_the_notches_it_adds_up_to() {
|
||||
let mut s = wheel_pane();
|
||||
let mut notches = WheelNotches::default();
|
||||
let step = MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR / 10.0;
|
||||
for _ in 0..10 {
|
||||
feed_wheel(
|
||||
&mut s,
|
||||
&mut notches,
|
||||
512.0,
|
||||
384.0,
|
||||
&frame_of(MouseScrollUnit::Pixel, step),
|
||||
);
|
||||
}
|
||||
let (n, sum) = calls(&s);
|
||||
assert!(
|
||||
n <= 1,
|
||||
"ten frames adding up to one line fired {n} wheel calls (sum {sum})"
|
||||
);
|
||||
assert!(sum <= 1.0, "…and they may move at most one notch: {sum}");
|
||||
}
|
||||
|
||||
/// A mouse wheel's notch arrives as one `Line` delta and fires exactly once, `arg1 = 1`.
|
||||
#[test]
|
||||
fn a_line_notch_fires_once() {
|
||||
let mut s = wheel_pane();
|
||||
let mut notches = WheelNotches::default();
|
||||
feed_wheel(
|
||||
&mut s,
|
||||
&mut notches,
|
||||
512.0,
|
||||
384.0,
|
||||
&frame_of(MouseScrollUnit::Line, 1.0),
|
||||
);
|
||||
assert_eq!(calls(&s), (1, 1.0));
|
||||
feed_wheel(
|
||||
&mut s,
|
||||
&mut notches,
|
||||
512.0,
|
||||
384.0,
|
||||
&frame_of(MouseScrollUnit::Line, -1.0),
|
||||
);
|
||||
assert_eq!(calls(&s), (2, 0.0), "and the other way is a notch down");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use bevy::prelude::*;
|
||||
use benilla_assets::LockRecover;
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::manifest::silenced_ui_load;
|
||||
use super::{
|
||||
load_font_registry, load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock,
|
||||
UiKeyboardCapture,
|
||||
@@ -584,43 +585,54 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
// through its whole load edge and only converged a frame later off whatever poll the caller
|
||||
// had written to survive it.
|
||||
seat_raster_seam_for_load(world, &mut script);
|
||||
let _ = load_ingame_ui(&mut script, identity.as_ref(), &roster, version_check);
|
||||
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
|
||||
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
|
||||
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
|
||||
// only: from here the widget's index is the live truth and `Minimap:SetZoom` writes the CVar
|
||||
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
|
||||
let zoom = world.resource::<crate::minimap::MinimapZoom>();
|
||||
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
|
||||
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
|
||||
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
|
||||
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
|
||||
// always win and nothing can ever be remembered.
|
||||
//
|
||||
// **And the chat cache restores inside it, between `VARIABLES_LOADED` and `PLAYER_LOGIN`** —
|
||||
// the reference's own slot for the reader's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst
|
||||
// (`0x4900d6`, after `0x4900b2` and before `0x490959`; decisions 2119 and 2125). It is the
|
||||
// sole firer of `UPDATE_CHAT_WINDOWS`, which is the only thing that registers a chat frame for
|
||||
// any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273) — as an `Update` system it landed after
|
||||
// the session's first chat had already been routed, and the login MOTD went to a window
|
||||
// registered for nothing.
|
||||
// The plate pair is read out of the world FIRST: the second closure borrows `world` for the
|
||||
// chat-cache restore, and a `Copy` of two bools costs nothing next to fighting that borrow.
|
||||
// Absent in a bare test world, where "both off" is also the resource's own default.
|
||||
// The minimap zoom pair and the plate pair are read out of the world FIRST: the bracket's body
|
||||
// borrows `world` for the chat-cache restore, and two `Copy` pairs cost nothing next to
|
||||
// fighting that borrow. The plates are absent in a bare test world, where "both off" is also
|
||||
// the resource's own default.
|
||||
let zoom = {
|
||||
let z = world.resource::<crate::minimap::MinimapZoom>();
|
||||
(z.outdoor, z.inside)
|
||||
};
|
||||
let plates = world
|
||||
.get_resource::<crate::vplates::VPlateMode>()
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
finish_ui_load_with(
|
||||
&mut script,
|
||||
// `NAMEPLATES_ON` / `FRIENDNAMEPLATES_ON` (2132). This seat, and not the `Update` feed
|
||||
// beside it, is what fixes the bug: `UIParent_OnEvent`'s first `UpdateNameplates()` runs
|
||||
// inside the `VARIABLES_LOADED` fired at the end of this very call.
|
||||
|script| crate::vplates::push_plate_globals(script, plates),
|
||||
|script| {
|
||||
crate::ui_chat::restore_chat_looks(world, script);
|
||||
},
|
||||
);
|
||||
// **The whole load edge is silent** — the TOC walk, every addon, the saved variables and
|
||||
// `PLAYER_LOGIN`, the span `0x48fbf0` brackets itself across (`0x48fbfa` → `0x49016d`). Both
|
||||
// login and `ReloadUI` come through here; see [`silenced_ui_load`].
|
||||
silenced_ui_load(&mut script, |script| {
|
||||
let _ = load_ingame_ui(script, identity.as_ref(), &roster, version_check);
|
||||
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two
|
||||
// live zoom indices from the persisted CVars now, before anything reads them — the
|
||||
// reference's own minimap reset path copying each CVar object's int into its live index
|
||||
// (decision 1131). Once only: from here the widget's index is the live truth and
|
||||
// `Minimap:SetZoom` writes the CVar back. Startup always precedes this state edge (1038),
|
||||
// so the knob is already loaded.
|
||||
script.set_minimap_zoom(zoom.0, zoom.1);
|
||||
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults,
|
||||
// before any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own
|
||||
// load order (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the
|
||||
// defaults always win and nothing can ever be remembered.
|
||||
//
|
||||
// **And the chat cache restores inside it, between `VARIABLES_LOADED` and `PLAYER_LOGIN`**
|
||||
// — the reference's own slot for the reader's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR`
|
||||
// burst (`0x4900d6`, after `0x4900b2` and before `0x490959`; decisions 2119 and 2125). It
|
||||
// is the sole firer of `UPDATE_CHAT_WINDOWS`, which is the only thing that registers a chat
|
||||
// frame for any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273) — as an `Update` system it
|
||||
// landed after the session's first chat had already been routed, and the login MOTD went to
|
||||
// a window registered for nothing.
|
||||
finish_ui_load_with(
|
||||
script,
|
||||
// `NAMEPLATES_ON` / `FRIENDNAMEPLATES_ON` (2132). This seat, and not the `Update`
|
||||
// feed beside it, is what fixes the bug: `UIParent_OnEvent`'s first
|
||||
// `UpdateNameplates()` runs inside the `VARIABLES_LOADED` fired at the end of this
|
||||
// very call.
|
||||
|script| crate::vplates::push_plate_globals(script, plates),
|
||||
|script| {
|
||||
crate::ui_chat::restore_chat_looks(world, script);
|
||||
},
|
||||
);
|
||||
});
|
||||
// **Say it out loud when an addon didn't load** (decision 1495). Every failure the walk found
|
||||
// is retained now, but a log nobody knows to open does not fix silence — and silence is the
|
||||
// actual defect B293 reports: *"there are a lot of addons that still doesn't work"*, with
|
||||
@@ -961,7 +973,7 @@ pub(crate) fn end_ui_session(world: &mut World) {
|
||||
// that lands in an engine-side store that survives), before the VM is replaced. The next
|
||||
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
|
||||
crate::cvars::fold_dying_vm_cvars(world);
|
||||
// The chat cache, on the same terms and for the same reason (decision NNNN): it composes the
|
||||
// The chat cache, on the same terms and for the same reason (decision 2184): it composes the
|
||||
// player's file out of the DYING VM, and `/reload` never crosses the `OnExit(InWorld)` edge
|
||||
// its flush used to hang on — so a window moved in the last second before a reload was
|
||||
// written nowhere and re-read stale from disk.
|
||||
|
||||
@@ -150,6 +150,69 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// **What a screen-size change re-runs** — called from `extract::tick_script`'s resize arm, the
|
||||
/// frame [`UiScript::set_screen_size`] reports a change on. Anchors follow the new screen rect by
|
||||
/// themselves; what does not is a size or seat somebody COMPUTED from the old one.
|
||||
///
|
||||
/// The bottom-stack manage pass (decision 1499): the open-bag stack starts a fresh column when
|
||||
/// the current one would run off the top, and that decision is made from `GetScreenHeight()` at
|
||||
/// layout time. Without this, dragging the window smaller leaves the bag columns wrapped for the
|
||||
/// old height until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
|
||||
/// which is in-game UI, and the resize arm also runs on the glue screens.
|
||||
///
|
||||
/// **And the three full-screen quads the stock files size once** — a stated repair of a reference
|
||||
/// gap, in the same posture as [`install_durability_reseat`]: nothing of Blizzard's is edited.
|
||||
/// `WorldMapFrame_OnLoad` sizes `BlackoutWorld` and `CinematicFrame_OnLoad` sizes
|
||||
/// `UpperBlackBar`/`LowerBlackBar` from `GetScreenWidth()`/`GetScreenHeight()` at load, and no
|
||||
/// stock handler touches them again (no `DISPLAY_SIZE_CHANGED` listener) — the reference could
|
||||
/// afford that because its screen changed only across a restart. 2242 fixed the load edge; this
|
||||
/// is the other edge: after a window resize or a `uiScale` change the blackout stopped short of
|
||||
/// the new screen and the world showed through beside the map. [`FULLSCREEN_QUADS_RESEAT`] is the
|
||||
/// two OnLoads' own arithmetic re-run, so a quad after a resize is exactly what a fresh load at
|
||||
/// the new size would have made. A stateless chunk rather than an installed hook, so a `ReloadUI`
|
||||
/// has nothing to re-install and nothing can stack.
|
||||
pub(super) fn on_screen_resized(script: &UiScript) {
|
||||
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
|
||||
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
|
||||
error!("ui_script: full-screen quad re-seat: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `WorldMapFrame.lua` `WorldMapFrame_OnLoad` l.19-28 and `CinematicFrame.lua`
|
||||
/// `CinematicFrame_OnLoad` l.5-19, verbatim in their arithmetic, each existence-guarded (the
|
||||
/// glue screens have neither). One addition to the letterbox: the stock OnLoad does nothing below
|
||||
/// 4:3, which leaves the bars at `CinematicFrame.xml`'s declared `1024 x 128` — so a resize to
|
||||
/// below 4:3 puts that declared size back, rather than keeping a wide screen's recompute.
|
||||
const FULLSCREEN_QUADS_RESEAT: &str = r#"
|
||||
if BlackoutWorld then
|
||||
local width = GetScreenWidth()
|
||||
local height = GetScreenHeight()
|
||||
if ( width / height < 4 / 3 ) then
|
||||
width = width * 1.25
|
||||
height = height * 1.25
|
||||
end
|
||||
BlackoutWorld:SetWidth( width )
|
||||
BlackoutWorld:SetHeight( height )
|
||||
end
|
||||
if UpperBlackBar and LowerBlackBar then
|
||||
local width = GetScreenWidth()
|
||||
local height = GetScreenHeight()
|
||||
local barWidth, blackBarHeight = 1024, 128
|
||||
if ( width / height > 4 / 3 ) then
|
||||
local desiredHeight = width / 2
|
||||
if ( desiredHeight > height ) then
|
||||
desiredHeight = height
|
||||
end
|
||||
barWidth = width
|
||||
blackBarHeight = ( height - desiredHeight ) / 2
|
||||
end
|
||||
UpperBlackBar:SetHeight( blackBarHeight )
|
||||
UpperBlackBar:SetWidth( barWidth )
|
||||
LowerBlackBar:SetHeight( blackBarHeight )
|
||||
LowerBlackBar:SetWidth( barWidth )
|
||||
end
|
||||
"#;
|
||||
|
||||
/// **A stated divergence from the reference, installed rather than edited in** (1751 window 4).
|
||||
///
|
||||
/// The reference seats `DurabilityFrame` 20 further in when one of its three side glyphs is up
|
||||
@@ -186,6 +249,35 @@ if DurabilityFrame and not DurabilityFrame.benillaReseat then
|
||||
end
|
||||
"#;
|
||||
|
||||
/// **The UI load's sound-suppression bracket — the one site both load paths go through.**
|
||||
///
|
||||
/// `CGGameUI::Initialize 0x48fbf0` brackets ITSELF in the counted suppression scope —
|
||||
/// `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on exit — across the TOC walk,
|
||||
/// Bindings.xml, the AddOns, the saved variables and the world-enter cascade it calls at
|
||||
/// `0x490168` (`PLAYER_LOGIN`), so both of its callers (login `0x48f681` and `/reloadui`
|
||||
/// `0x495669`) load without a sound. The only reader of that depth is `PlaySoundByName
|
||||
/// 0x458030`, which drops the call outright (wow-re
|
||||
/// `system/ui/scratch/framexml-load-sound-suppression.md`).
|
||||
///
|
||||
/// This is the mechanism, not a workaround for one noisy handler: stock `TargetFrame_OnHide`
|
||||
/// really does fire at load (the frame ships shown and its OnLoad hides it) and really does call
|
||||
/// `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`. The engine throws it away. Decision 1033 reached
|
||||
/// the right rule from the director's ear; this is the binary agreeing.
|
||||
///
|
||||
/// One function rather than a push/pop pair at each caller because the production edge
|
||||
/// ([`super::load_ingame_ui_on_world_entry`]) spent its life unbracketed while the tests' whole-
|
||||
/// manifest load was — every `/reload` played the lost-target click. Generic over the borrow so
|
||||
/// the `&UiScript` test loader and the owning production edge share it.
|
||||
pub(super) fn silenced_ui_load<S: std::borrow::Borrow<UiScript>, R>(
|
||||
script: &mut S,
|
||||
body: impl FnOnce(&mut S) -> R,
|
||||
) -> R {
|
||||
script.borrow().push_sound_suppression();
|
||||
let out = body(script);
|
||||
script.borrow().pop_sound_suppression();
|
||||
out
|
||||
}
|
||||
|
||||
/// Load benilla's own default UI — every file [`MANIFEST`] names — through the engine-free loader.
|
||||
/// This is our content (MIT/Apache), committed and **compiled into the binary**
|
||||
/// ([`super::content`], decision 1175); a dev build still prefers the copy on disk, so editing a
|
||||
@@ -219,20 +311,12 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
|
||||
// have been registered here with real consumers since 1493/1502; the probes simply never had
|
||||
// them, and 218 tests found that out the hour this row went on the manifest.
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
// **Silent, the way the client's own load is.** `0x48fbf0` brackets ITSELF in the counted
|
||||
// sound-suppression scope — `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on
|
||||
// exit — across the TOC walk, Bindings.xml and the AddOns, so both of its callers (login
|
||||
// `0x48f681` and `/reloadui` `0x495669`) load without a sound. The only reader of that depth
|
||||
// is `PlaySoundByName 0x458030`, which drops the call outright.
|
||||
//
|
||||
// This is the mechanism, not a workaround for one noisy handler: stock `TargetFrame_OnHide`
|
||||
// really does fire at load (the frame ships shown and its OnLoad hides it) and really does
|
||||
// call `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`. The engine throws it away. Decision 1033
|
||||
// reached the right rule from the director's ear; this is the binary agreeing.
|
||||
script.push_sound_suppression();
|
||||
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
|
||||
failures.extend(bootstrap_positions(script));
|
||||
script.pop_sound_suppression();
|
||||
// Silent, the way the client's own load is — see [`silenced_ui_load`].
|
||||
let mut failures = silenced_ui_load(&mut &*script, |script| {
|
||||
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
|
||||
failures.extend(bootstrap_positions(script));
|
||||
failures
|
||||
});
|
||||
// **A handler that raised DURING the walk is a load failure.** The loader's own report holds
|
||||
// the raises it dispatched itself (a `<Script file=>` chunk, an OnLoad); a raise one call
|
||||
// deeper — an OnLoad that `Show()`s a frame whose OnShow indexes a global its file has not
|
||||
|
||||
@@ -457,6 +457,7 @@ impl Plugin for UiScriptPlugin {
|
||||
// the hover recorder is armed; the producer owns the resource so any minimal app that
|
||||
// runs the UI pass (the extract tests) has it.
|
||||
.init_resource::<UiFrameCost>()
|
||||
.init_resource::<crate::bindings::WheelNotches>()
|
||||
.init_resource::<UiCostWanted>()
|
||||
.init_resource::<PointerOverUi>()
|
||||
.init_resource::<SyntheticPointer>()
|
||||
|
||||
@@ -2981,7 +2981,7 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
s.run("BenillaOptionsFrameContainerBodyCombatRowHonorGainedCheck:Click()")
|
||||
.unwrap();
|
||||
|
||||
let saved = s.saved_variables_text();
|
||||
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
|
||||
assert!(
|
||||
saved.contains("COMBAT_TEXT_SHOW_HONOR_GAINED = \"0\""),
|
||||
"the toggle is in the saved text:\n{saved}"
|
||||
@@ -3531,7 +3531,7 @@ fn what_the_interface_page_writes_survives_a_restart() {
|
||||
s.run("BenillaOptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:Click()")
|
||||
.unwrap();
|
||||
|
||||
let saved = s.saved_variables_text();
|
||||
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
|
||||
assert!(
|
||||
saved.contains("AUTO_QUEST_WATCH = \"0\""),
|
||||
"the toggle is in the saved text under the reference's name:\n{saved}"
|
||||
@@ -3653,7 +3653,7 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
|
||||
// Restart: the fresh tree comes up on the shipped geometry, the chunk replaces the value, and
|
||||
// VARIABLES_LOADED is what puts the bar where the value says.
|
||||
let saved = s.saved_variables_text();
|
||||
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
|
||||
assert!(
|
||||
saved.contains("SHOW_BUFF_DURATIONS = \"1\""),
|
||||
"the switch is in the saved text:\n{saved}"
|
||||
|
||||
@@ -71,6 +71,7 @@ fn log(entries: Vec<QuestLogEntryView>) -> QuestLogState {
|
||||
QuestLogState {
|
||||
num_quests: entries.len() as u32,
|
||||
entries,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ fn eight_entries() -> QuestLogState {
|
||||
QuestLogState {
|
||||
num_quests: 8,
|
||||
entries,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1065,6 +1066,7 @@ fn overflowing_entry() -> QuestLogState {
|
||||
.collect();
|
||||
QuestLogState {
|
||||
num_quests: 1,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
entries: vec![QuestLogEntryView {
|
||||
quest_id: 1,
|
||||
title: "A Very Long Quest".into(),
|
||||
|
||||
@@ -810,7 +810,7 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
|
||||
0,
|
||||
"the click must write the SAVED global, not just the engine mask"
|
||||
);
|
||||
let text = s.saved_variables_text_for(&info().saved_variables);
|
||||
let text = String::from_utf8(s.saved_variables_bytes_for(&info().saved_variables)).unwrap();
|
||||
assert!(
|
||||
text.contains("TRAINER_FILTER_UNAVAILABLE = 0"),
|
||||
"the file carries the toggle: {text}"
|
||||
|
||||
@@ -1722,3 +1722,63 @@ fn the_entry_load_seeds_a_record_the_feed_cannot_take_away() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
// ─────────────────── The production load is silent (the UI-load sound bracket) ───────────────────
|
||||
|
||||
/// Every kit name the live VM has queued since the last drain.
|
||||
fn taken_kit_names(world: &mut World) -> Vec<String> {
|
||||
world
|
||||
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
|
||||
.expect("in-world VM")
|
||||
.take_sounds()
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
benilla_ui::script::SoundRequest::KitName(n) => Some(n),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **A login and a `/reload` load without a sound** — `0x48fbf0` brackets itself in the counted
|
||||
/// suppression scope (`0x48fbfa` → `0x49016d`) across the TOC walk, the addons, the saved
|
||||
/// variables and the login cascade, and both of its callers (login `0x48f681`, `/reloadui`
|
||||
/// `0x495669`) go through it (wow-re `system/ui/scratch/framexml-load-sound-suppression.md`).
|
||||
///
|
||||
/// Stock `TargetFrame_OnLoad` → `TargetFrame_Update` → `Hide()` → `TargetFrame_OnHide` really does
|
||||
/// call `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")` at load; the engine drops it. Before the
|
||||
/// production edge carried the bracket only the tests' whole-manifest load did, and every
|
||||
/// `/reload` played the lost-target click.
|
||||
#[test]
|
||||
fn a_login_and_a_reload_load_without_the_lost_target_sound() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("load-silent");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
assert!(
|
||||
frame_exists(&world, "TargetFrame"),
|
||||
"the stock target frame loaded"
|
||||
);
|
||||
let at_login = taken_kit_names(&mut world);
|
||||
assert!(
|
||||
!at_login
|
||||
.iter()
|
||||
.any(|n| n == "INTERFACESOUND_LOSTTARGETUNIT"),
|
||||
"the login load must be silent — the lost-target click was queued: {at_login:?}"
|
||||
);
|
||||
|
||||
reload(&mut world, crate::char_select::ClientState::InWorld);
|
||||
let at_reload = taken_kit_names(&mut world);
|
||||
assert!(
|
||||
!at_reload
|
||||
.iter()
|
||||
.any(|n| n == "INTERFACESOUND_LOSTTARGETUNIT"),
|
||||
"the /reload load must be silent — the lost-target click was queued: {at_reload:?}"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
@@ -915,3 +915,52 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() {
|
||||
assert_eq!(saved[0], row(), "unchanged through the round trip");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The full-screen quads follow a resize** — `BlackoutWorld` behind the map, and the cinematic
|
||||
/// letterbox bars. Stock `WorldMapFrame_OnLoad` and `CinematicFrame_OnLoad` size them ONCE from
|
||||
/// `GetScreenWidth()`/`GetScreenHeight()` and nothing in FrameXML touches them again (no
|
||||
/// `DISPLAY_SIZE_CHANGED` listener); 2242 fixed the load edge, and this is the other edge. A window
|
||||
/// grown after the load left the quad at the old size and the world showed through beside it.
|
||||
#[test]
|
||||
fn the_fullscreen_quads_follow_a_resize() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// A player exists by the time the manifest loads (decision 1848; see the test above).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
let width = |s: &UiScript, f: &str| s.eval::<f64>(&format!("return {f}:GetWidth()")).unwrap();
|
||||
assert_eq!(width(&s, "BlackoutWorld"), 1600.0, "the load edge sized it");
|
||||
|
||||
// Grown, still wider than 4:3 so every one of the three takes its recompute branch.
|
||||
assert!(s.set_screen_size(1920.0, 1080.0), "the size changed");
|
||||
super::manifest::on_screen_resized(&s);
|
||||
s.resolve();
|
||||
|
||||
let screen = s.eval::<f64>("return GetScreenWidth()").unwrap();
|
||||
assert_eq!(screen, 1920.0);
|
||||
for quad in ["BlackoutWorld", "UpperBlackBar", "LowerBlackBar"] {
|
||||
assert_eq!(
|
||||
width(&s, quad),
|
||||
screen,
|
||||
"{quad} still spans the OLD screen after a resize — the world shows through beside it"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return BlackoutWorld:GetHeight()").unwrap(),
|
||||
1080.0,
|
||||
"…and the blackout is the new height too"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ fn on_trade_status_extended(In(ev): In<SessionEvent>, mut trade: ResMut<TradeSes
|
||||
}
|
||||
|
||||
/// An open trade dies with the socket (decision 0592) — the reconnect starts with no trade. A
|
||||
/// listener on the session end, which the drain's dispatch match still owns
|
||||
/// (a second handler on the kind, after the bridge's own teardown).
|
||||
/// listener on the session end (a second handler on `Disconnected`, after the bridge's own
|
||||
/// teardown, `net::session::on_disconnected`).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut trade: ResMut<TradeSession>) {
|
||||
trade.clear_session();
|
||||
}
|
||||
|
||||
@@ -1045,8 +1045,10 @@ fn feed_unit_reach(
|
||||
pub(crate) struct UnitStores<'w, 's> {
|
||||
/// Every streamed object's descriptor, read by guid → entity.
|
||||
all: Query<'w, 's, &'static ObjectStore>,
|
||||
/// Whose descriptor moved this frame — the feed's dirty gate.
|
||||
changed: Query<'w, 's, (), Changed<ObjectStore>>,
|
||||
/// Whose descriptor moved this frame — the feed's dirty gate. Not an item's: items carry an
|
||||
/// `ObjectStore` since 2334, and a durability tick or a stack count naming no unit token
|
||||
/// would open the gate for nothing (2343).
|
||||
changed: Query<'w, 's, (), (Changed<ObjectStore>, Without<crate::items::ItemObject>)>,
|
||||
/// Whose object left the manager — the other half of that gate, and cleared every run.
|
||||
removed: RemovedComponents<'w, 's, ObjectStore>,
|
||||
/// The per-field edges this run (decision 2297) — [`fire_transitions`]' three mirror-diff
|
||||
|
||||
@@ -92,18 +92,14 @@ pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result
|
||||
let duration_ms = read_u32_le(r)?;
|
||||
let flying = spline_flags & SPLINE_FLAG_FLYING != 0;
|
||||
let run_mode = spline_flags & SPLINE_FLAG_RUNMODE != 0;
|
||||
// The decoded waypoints *after* the start (see [`read_monster_move_spline`]): a ground path's
|
||||
// first packed point re-encodes `start` (quantized), so drop it and anchor the path with the
|
||||
// exact wire `start`; a flying path sends only post-start points, kept whole. The result is the
|
||||
// full travel-order polyline `[start, …waypoints…, endpoint]`.
|
||||
// The decoded waypoints *after* the start (see [`read_monster_move_spline`]) — both layouts
|
||||
// ship only post-start points, so the wire `start` anchors the full travel-order polyline
|
||||
// `[start, …waypoints…, endpoint]`.
|
||||
let tail = read_monster_move_spline(r, flying)?;
|
||||
let path = if tail.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let skip = usize::from(!flying && tail.len() >= 2);
|
||||
std::iter::once(start)
|
||||
.chain(tail.into_iter().skip(skip))
|
||||
.collect()
|
||||
std::iter::once(start).chain(tail).collect()
|
||||
};
|
||||
ServerPacket::MonsterMove {
|
||||
guid,
|
||||
@@ -124,18 +120,22 @@ pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result
|
||||
/// travel order (the point *after* the start … the endpoint). Two wire layouts, keyed by `catmull_rom`
|
||||
/// (the `Mask_CatmullRom` = `Flying` spline flag), both from vmangos `PacketBuilder`:
|
||||
///
|
||||
/// - **Ground (linear, `WriteLinearPath`):** a `u32` count, then the endpoint as an absolute `Vector3d`,
|
||||
/// then `count − 1` packed `i32` offsets — `endpoint − waypoint` per point (see [`packed_to_vector3d`]),
|
||||
/// in travel order. We invert each (`waypoint = endpoint − offset`) and append the endpoint, so the
|
||||
/// returned list is `[waypoint₀ ≈ start, waypoint₁, …, endpoint]`. `waypoint₀` re-encodes the packet's
|
||||
/// `start` (¼-yd quantized); the caller drops it in favour of the exact `start`.
|
||||
/// **`count == 2` carries no offsets at all**: vmangos guards the offset loop with `last_idx > 1`
|
||||
/// (`packet_builder.cpp:92`) while still writing `last_idx + 1` as the count, so a plain two-point
|
||||
/// `MoveTo` announces 2 points and ships only the destination. Reading the phantom offset anyway
|
||||
/// over-runs the body and the whole packet is skipped — i.e. that creature stops dead until its next
|
||||
/// move, the same freeze decision 0708 is about. Never observed on this deploy (its pathfinder emits
|
||||
/// 3+ point paths, verified over four live runs: no `nodes=2` in any `csp`/`mmv` trace), but a map
|
||||
/// without mmaps would hit it on every step.
|
||||
/// - **Ground (linear, `WriteLinearPath`):** a `u32` count of the points **after** the start, then the
|
||||
/// endpoint as an absolute `Vector3d`, then `count − 1` packed `i32` offsets — `endpoint − waypoint`
|
||||
/// for each intermediate waypoint (see [`packed_to_vector3d`]), in travel order. We invert each
|
||||
/// (`waypoint = endpoint − offset`) and append the endpoint, so the returned list is
|
||||
/// `[waypoint₁, …, endpoint]`; the start is the packet head's.
|
||||
/// **The start is not among the offsets.** vmangos calls the builder with `firstPoint = 1`
|
||||
/// (`packet_builder.h:34`'s default, `MoveSplineInit.cpp:169`) over a spline laid out
|
||||
/// `[phantom, c₀ … cₙ₋₁, cₙ₋₁]` — the Catmull-Rom initializer even for a linear path
|
||||
/// (`spline.cpp:52`) — so `last_idx = n − 1`, the count is `n − 1`, and the offset loop runs
|
||||
/// `c₁ … cₙ₋₂`; the head's start is `getPoint(first())` = `c₀`. The reference decoder
|
||||
/// (`0x6018f0`, wow-re `net/scratch/rf49-inbound-wire-parses.md` rows 9–11) reads the same
|
||||
/// `count − 1` intermediates, and its curve is `[start, points…]` (`curvemath/…/rf52-curve-construction.md`).
|
||||
/// Until the sweep that found it, this read assumed `firstPoint = 0` (0097, 0708): it treated the
|
||||
/// first offset as a re-encoded start and dropped it — every ground path lost its first corner —
|
||||
/// and read none at `count == 2`, leaving the one real waypoint unread (the decode-length
|
||||
/// instrument's `SMSG_MONSTER_MOVE … left 4 trailing byte(s)` on a live run).
|
||||
/// - **Flying (Catmull-Rom, `WriteCatmullRomPath`):** a `u32` count, then `count` **absolute** `Vector3d`s
|
||||
/// (the control points from `getPoint(2)` on — the post-start waypoints through the endpoint). Returned
|
||||
/// verbatim. (Reading these as packed offsets — the old single-layout path — mis-sized the body and
|
||||
@@ -162,10 +162,9 @@ fn read_monster_move_spline(r: &mut &[u8], catmull_rom: bool) -> io::Result<Vec<
|
||||
}
|
||||
let endpoint = Vector3d::read(r)?;
|
||||
let mut points = Vec::with_capacity(capacity_hint(count, 0xFFFF));
|
||||
// `count == 2` ⇒ the producer skipped its offset loop entirely (`last_idx > 1`); the destination is
|
||||
// the whole payload, and the caller pairs it with the packet's exact `start`.
|
||||
let offsets = if count > 2 { count - 1 } else { 0 };
|
||||
for _ in 0..offsets {
|
||||
// `count == 1` ⇒ a straight hop: the producer's `last_idx > 1` guard skips the loop, and
|
||||
// `count − 1` is already zero.
|
||||
for _ in 1..count {
|
||||
let off = packed_to_vector3d(read_i32_le(r)?);
|
||||
points.push(Vector3d {
|
||||
x: endpoint.x - off.x,
|
||||
@@ -180,7 +179,7 @@ fn read_monster_move_spline(r: &mut &[u8], catmull_rom: bool) -> io::Result<Vec<
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::messages::{opcode, parse_server};
|
||||
use crate::messages::{opcode, parse_server, parse_server_with_tail};
|
||||
use crate::wire::write_packed_guid;
|
||||
|
||||
/// The fixed head of a `SMSG_MONSTER_MOVE`: packed guid, start pos, splineId, moveType.
|
||||
@@ -195,22 +194,25 @@ mod tests {
|
||||
b
|
||||
}
|
||||
|
||||
/// The producer's linear encoder, transcribed from vmangos `PacketBuilder::WriteLinearPath`:
|
||||
/// count = number of points, the **endpoint** written absolute, then each earlier point as a packed
|
||||
/// `endpoint − point` offset in travel order. `path` is the full `[start, …mids…, endpoint]`.
|
||||
/// The `last_idx > 1` guard is transcribed too: a **two**-point path writes the count and the
|
||||
/// destination and stops — no offsets at all, not even for the start.
|
||||
/// The producer's linear encoder, transcribed from vmangos `PacketBuilder::WriteLinearPath` as
|
||||
/// `WriteMonsterMove` calls it (`firstPoint = 1`). `path` is the full `[c₀ = start, …, cₙ₋₁]`
|
||||
/// (the head carries `c₀` separately); over the spline's `[phantom, c₀ … cₙ₋₁, cₙ₋₁]` layout
|
||||
/// `last_idx = n − 1`, so it writes count `n − 1`, the **endpoint** absolute, then — under
|
||||
/// `if (last_idx > 1)` — `c₁ … cₙ₋₂` as packed `endpoint − point` offsets. The start is never
|
||||
/// among them.
|
||||
fn append_linear_path(body: &mut Vec<u8>, path: &[[f32; 3]]) {
|
||||
let (&endpoint, leading) = path.split_last().expect("a path has an endpoint");
|
||||
body.extend_from_slice(&(path.len() as u32).to_le_bytes());
|
||||
let last_idx = path.len() - 1;
|
||||
body.extend_from_slice(&(last_idx as u32).to_le_bytes()); // last_idx − start + 1
|
||||
for f in endpoint {
|
||||
body.extend_from_slice(&f.to_le_bytes());
|
||||
}
|
||||
if path.len() <= 2 {
|
||||
if last_idx <= 1 {
|
||||
return; // vmangos `packet_builder.cpp:92` — `if (last_idx > 1)`
|
||||
}
|
||||
// `leading` = [start, …mids…]; each packed as endpoint − point (¼-yd quantized).
|
||||
for &p in leading {
|
||||
// `for (i = start; i < last_idx; ++i)` with `start = 1`: the intermediates, packed as
|
||||
// endpoint − point (¼-yd quantized).
|
||||
for &p in &leading[1..] {
|
||||
let pack = |v: f32, shift: u32, mask: i32| ((v * 4.0).round() as i32 & mask) << shift;
|
||||
let off = [endpoint[0] - p[0], endpoint[1] - p[1], endpoint[2] - p[2]];
|
||||
let packed = pack(off[0], 0, 0x7FF) | pack(off[1], 11, 0x7FF) | pack(off[2], 22, 0x3FF);
|
||||
@@ -314,17 +316,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain two-point hop — a `MoveTo` with no intermediate waypoints. vmangos announces `count = 2`
|
||||
/// but ships **only** the destination (its offset loop is guarded by `last_idx > 1`), so a decoder
|
||||
/// that trusts the count and reads `count − 1` offsets over-runs the body and the packet is skipped
|
||||
/// entirely — the creature freezes where it stands. Encoded here by the transcribed producer above.
|
||||
/// The plain two-point hop — a `MoveTo` with no intermediate waypoints: `count = 1`, the
|
||||
/// destination, no offsets. Encoded here by the transcribed producer above.
|
||||
#[test]
|
||||
fn monster_move_two_point_path_carries_no_offsets() {
|
||||
let mut body = head(0x77, [4.0, 8.0, 0.0], 0);
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground
|
||||
body.extend_from_slice(&1_000u32.to_le_bytes()); // duration
|
||||
append_linear_path(&mut body, &[[4.0, 8.0, 0.0], [12.0, 8.0, 0.0]]);
|
||||
let p = parse_server(opcode::SMSG_MONSTER_MOVE, &body).expect("a two-point hop parses");
|
||||
let (p, tail) = parse_server_with_tail(opcode::SMSG_MONSTER_MOVE, &body)
|
||||
.expect("a two-point hop parses");
|
||||
assert_eq!(tail, 0, "the body is exactly the count and the destination");
|
||||
match p {
|
||||
ServerPacket::MonsterMove { path, .. } => {
|
||||
assert_eq!(path.len(), 2, "start + destination, got {path:?}");
|
||||
@@ -334,12 +336,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The one-corner path, byte for byte as vmangos writes it** — hand-assembled, not through the
|
||||
/// transcribed encoder, so the two cannot share a misreading. A pathfinder route `c₀ → c₁ → c₂`
|
||||
/// is `count = 2`, the endpoint `c₂`, and ONE packed offset `c₂ − c₁`. The read this replaced
|
||||
/// took `count == 2` for an offset-free hop: it read nothing, cut the corner (`[c₀, c₂]`) and
|
||||
/// left four bytes unread — the live run's `SMSG_MONSTER_MOVE … left 4 trailing byte(s)`.
|
||||
#[test]
|
||||
fn monster_move_one_corner_path_keeps_its_corner() {
|
||||
let mut body = head(0x77, [0.0, 0.0, 0.0], 0); // c₀ in the head
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground
|
||||
body.extend_from_slice(&2_000u32.to_le_bytes()); // duration
|
||||
body.extend_from_slice(&2u32.to_le_bytes()); // count = last_idx − start + 1 = 2
|
||||
for f in [10.0f32, 10.0, 0.0] {
|
||||
body.extend_from_slice(&f.to_le_bytes()); // endpoint c₂
|
||||
}
|
||||
// c₂ − c₁ = (0, 10, 0): x 0, y 10·4 = 40 at bit 11, z 0 (`appendPackXYZ`'s ¼-yd fields).
|
||||
body.extend_from_slice(&(40i32 << 11).to_le_bytes());
|
||||
let (p, tail) =
|
||||
parse_server_with_tail(opcode::SMSG_MONSTER_MOVE, &body).expect("a corner path parses");
|
||||
assert_eq!(tail, 0, "the one offset is read, not left behind");
|
||||
match p {
|
||||
ServerPacket::MonsterMove { path, .. } => {
|
||||
let got: Vec<[f32; 3]> = path.iter().map(|v| [v.x, v.y, v.z]).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]]
|
||||
);
|
||||
}
|
||||
_ => panic!("expected MonsterMove"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monster_move_ground_path_decodes_every_waypoint() {
|
||||
// A four-waypoint ground patrol, encoded exactly as vmangos `WriteLinearPath` ships it, must
|
||||
// round-trip to the full travel-order polyline — not collapse to `start → endpoint`. Points are
|
||||
// ¼-yd multiples so the packed quantization is exact. The start is anchored from the wire `start`
|
||||
// (the redundant quantized first packed point is dropped).
|
||||
// round-trip to the full travel-order polyline — not collapse to `start → endpoint`, and not
|
||||
// lose its first corner. Points are ¼-yd multiples so the packed quantization is exact.
|
||||
let want = [
|
||||
[0.0f32, 0.0, 0.0], // start
|
||||
[10.0, 0.0, 0.0], // corner east
|
||||
|
||||
@@ -42,10 +42,12 @@ fn read_force_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result<ServerPack
|
||||
/// packet, but silence here is what cost days: an unhandled inner opcode has to be loud. Only the
|
||||
/// `MSG_MOVE_*` relays vmangos routes through `ObjectViewersMovementDeliverer` can appear, and we
|
||||
/// model all of them (`the_batch_carries_every_relayed_move_opcode`).
|
||||
fn read_compressed_moves(r: &mut impl Read) -> io::Result<Vec<ServerPacket>> {
|
||||
fn read_compressed_moves(r: &mut &[u8]) -> io::Result<Vec<ServerPacket>> {
|
||||
let uncompressed = read_u32_le(r)? as usize;
|
||||
let mut buf = Vec::with_capacity(uncompressed.min(64 * 1024));
|
||||
flate2::read::ZlibDecoder::new(r).read_to_end(&mut buf)?;
|
||||
// `bufread`, not `read`: see the compressed update object's arm — the cursor must stop at
|
||||
// the stream's end for the tail to see what follows it.
|
||||
flate2::bufread::ZlibDecoder::new(r).read_to_end(&mut buf)?;
|
||||
let mut rest = buf.as_slice();
|
||||
let mut packets = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
@@ -353,7 +355,12 @@ fn parse_server_body(
|
||||
// as unconsumed — 803 bytes on the decode-length instrument's first live login
|
||||
// (2266 §B1). The tail that means drift here is the inflated stream's, reported
|
||||
// through `inner_tail`.
|
||||
let mut decoder = flate2::read::ZlibDecoder::new(&mut r);
|
||||
//
|
||||
// And through `bufread`, not `read` (2343): `read::ZlibDecoder` wraps its source in a
|
||||
// 32 KiB buffer and fills it from the slice, so the outer cursor always ended at the
|
||||
// BODY's end and a byte after the zlib stream could never show as a tail. The
|
||||
// `bufread` decoder reads the slice as its own buffer and consumes exactly the stream.
|
||||
let mut decoder = flate2::bufread::ZlibDecoder::new(&mut r);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
drop(decoder);
|
||||
@@ -1785,6 +1792,17 @@ mod tests {
|
||||
parse_server_with_tail(opcode::SMSG_COMPRESSED_UPDATE_OBJECT, &body(&longer))
|
||||
.expect("a trailing inflated byte is not a failure");
|
||||
assert_eq!(tail, 1, "the inflated stream's leftover is the tail");
|
||||
|
||||
// A byte AFTER the zlib stream, in the packet body: the outer tail (2343). The `read`
|
||||
// decoder's 32 KiB buffer swallowed it and this read 0.
|
||||
let mut outer = body(&empty);
|
||||
outer.extend([0xAB, 0xCD]);
|
||||
let (_, tail) = parse_server_with_tail(opcode::SMSG_COMPRESSED_UPDATE_OBJECT, &outer)
|
||||
.expect("bytes after the stream are not a failure");
|
||||
assert_eq!(
|
||||
tail, 2,
|
||||
"the body's bytes after the zlib stream are the tail"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown opcode consumes nothing by definition; reporting its whole body as a tail would
|
||||
|
||||
@@ -717,8 +717,18 @@ fn load_saved_variables(lua: &Lua, i: usize) {
|
||||
}
|
||||
let Some(dir) = dir else { continue };
|
||||
let path = dir.join(format!("{name}.lua"));
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue; // absent is the first-run case
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, // the first-run case
|
||||
Err(e) => {
|
||||
// Present but unreadable is not "absent": hold it, like a file that will not run.
|
||||
log_error(lua, &format!("{}: {e}", path.display()));
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
if !model.held_saved_files.contains(&path) {
|
||||
model.held_saved_files.push(path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = run_chunk(
|
||||
lua,
|
||||
@@ -726,8 +736,14 @@ fn load_saved_variables(lua: &Lua, i: usize) {
|
||||
&crate::script::addon_chunk_name(&name.to_string(), &format!("{name}.lua")),
|
||||
) {
|
||||
// The reference fails this silently. We do not: a settings file that stopped parsing
|
||||
// is exactly the thing a player needs told, and the file is left on disk untouched.
|
||||
// is exactly the thing a player needs told, and the file is left on disk untouched —
|
||||
// held, so the shutdown write does not replace it with the defaults this session is
|
||||
// running on (`UiScript::hold_saved_file`).
|
||||
log_error(lua, &format!("{}: {e}", path.display()));
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
if !model.held_saved_files.contains(&path) {
|
||||
model.held_saved_files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -839,13 +855,17 @@ fn run_chunk(lua: &Lua, bytes: &[u8], name: &str) -> mlua::Result<()> {
|
||||
.exec()
|
||||
}
|
||||
|
||||
/// Record a load error where the host drains it — the same channel a handler error uses, so a
|
||||
/// demand-load failure surfaces the way every other script error does rather than vanishing.
|
||||
/// Record a demand-load failure the way the startup walk records its own
|
||||
/// ([`super::UiScript::report_script_error`]): retained as a Load row, and dispatched to the
|
||||
/// player's error handler — plus the host drain the instruments read. It went to that drain alone
|
||||
/// until now, so a `LoadAddOn` whose file raised surfaced as a terminal line and nothing a player
|
||||
/// could see.
|
||||
fn log_error(lua: &Lua, msg: &str) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.errors
|
||||
.push(format!("LoadAddOn: {msg}"));
|
||||
let msg = format!("LoadAddOn: {msg}");
|
||||
super::diagnostics::record_load_failure(lua, &msg);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.pending_error_dispatch.push(msg.clone());
|
||||
model.errors.push(msg);
|
||||
}
|
||||
|
||||
/// The host's push: the discovered registry and the root its files live under.
|
||||
|
||||
@@ -1353,8 +1353,7 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
|
||||
{
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
// **A nameplate's click is the engine's too** (decision 2148): the reference's plate overrides
|
||||
// the button click slot (`0x7cb910`) *and* chains the base, so its unit is selected whether the
|
||||
|
||||
@@ -450,11 +450,6 @@ impl super::UiScript {
|
||||
.register_window_channel(window, name.to_string(), id)
|
||||
}
|
||||
|
||||
/// Host-side [`Model::strip_window_channel`] — the cascade's leave arm.
|
||||
pub fn strip_chat_window_channel(&mut self, key: &str) -> bool {
|
||||
self.model_mut().strip_window_channel(key)
|
||||
}
|
||||
|
||||
/// Seed the per-window looks from the host's persisted store — the load path, so it queues no
|
||||
/// change (an echo would re-dirty the file it was just read from; [`Self::set_cvar_host`]'s
|
||||
/// reason, one store over).
|
||||
|
||||
@@ -325,8 +325,7 @@ fn fire_color_select(lua: &Lua, this: &Table, r: f64, g: f64, b: f64) -> mlua::R
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ pub struct CursorMacro {
|
||||
/// Also derives `ACTIONBAR_SHOWGRID`/`ACTIONBAR_HIDEGRID` (decision 0216 §7) off
|
||||
/// [`Model::cursor_grid_shown`]'s mirror against the CURRENT `model.cursor` (already the
|
||||
/// post-transition state at every call site — every caller mutates `model.cursor` before calling
|
||||
/// this): a None→Some edge (any payload arm, any surface — bags/doll/actions alike) shows the
|
||||
/// this): a None→Some edge (any bar-droppable arm, any surface — bags/doll/actions alike) shows the
|
||||
/// bar's drop grid, Some→None hides it, Some→Some (the action hop) touches neither, so one
|
||||
/// gesture never churns HIDE+SHOW. This is the one seam every pickup/place/clear already routes
|
||||
/// through, so no call site needs to know about grid events at all.
|
||||
@@ -271,13 +271,17 @@ pub(crate) fn queue_cursor_update(model: &mut Model) {
|
||||
// different places and the payload spaces do not overlap (decision 1010):
|
||||
//
|
||||
// - the ACTION bar's grid follows "is anything held that could land there" — every arm except
|
||||
// the pet one, which `PlaceAction` refuses outright;
|
||||
// the pet one, which `PlaceAction` refuses outright, and the vendor row (mode 5): its grab
|
||||
// goes through the shared setter `0x4950f0`, whose `ACTIONBAR_SHOWGRID` branch is taken for
|
||||
// mode 7 alone (`0x495106`/`0x49513a`, wow-re `merchant-cursor-law.md` §5);
|
||||
// - the PET bar's grid follows the pet payload alone. The reference fires `PET_BAR_SHOWGRID`
|
||||
// from inside the pet-action pickup builder itself (`0x494f28`), not from a shared cursor
|
||||
// transition — so a spell on the cursor lights the action bar's empty slots and leaves the
|
||||
// pet bar alone, which is also the only honest answer: you cannot drop it there.
|
||||
let pet_held = matches!(model.cursor, Some(CursorPayload::PetAction(_)));
|
||||
let bar_held = model.cursor.is_some() && !pet_held;
|
||||
let bar_held = model.cursor.is_some()
|
||||
&& !pet_held
|
||||
&& !matches!(model.cursor, Some(CursorPayload::Merchant(_)));
|
||||
if bar_held != model.cursor_grid_shown {
|
||||
model.cursor_grid_shown = bar_held;
|
||||
let event = if bar_held {
|
||||
|
||||
@@ -883,13 +883,10 @@ fn install_video_verbs(lua: &Lua) -> mlua::Result<()> {
|
||||
// format record and can veto the write outright; the handler validates that record, applies it
|
||||
// with `DeviceSetFormat`, and then COMMITS the fourteen latched CVars so `GetCVar` catches up.
|
||||
//
|
||||
// **benilla has no such latch and no such device rebuild.** Its video settings apply as they
|
||||
// are written — the departure `benilla_app::video`'s module doc already states for `gxVSync`
|
||||
// ("wgpu reconfigures the surface on the next frame") and for the display mode ("ours takes
|
||||
// effect on the click"). So the verb's postcondition is already true when it is called, which
|
||||
// is not the same as the verb having nothing to do: the caller is asking for the settings to be
|
||||
// in effect *now*, and the honest answer is to make the host re-assert them against the window
|
||||
// rather than wait for its change detection to notice something it may already have seen.
|
||||
// **benilla's latch is the host's** (decision 2303): a gx write stages in the host's CVar
|
||||
// registry, and this verb is the host's cue to commit the gx stages (the fourteen-record
|
||||
// commit, `Cvars::commit_latched`) and re-assert them against the window. The one difference
|
||||
// left is when a change callback runs — the reference's at `SetCVar`, ours at the commit.
|
||||
//
|
||||
// A counted request the host drains (`take_restart_gx_asks`), the shape `Screenshot()` uses and
|
||||
// for the same reason: no payload, and two calls in a frame are two requests. **It is not a
|
||||
|
||||
@@ -337,8 +337,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
|
||||
if let Err(e) = on_char {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
mark_text_changed(lua, h);
|
||||
if fire_space {
|
||||
@@ -606,8 +605,7 @@ fn fire_script(lua: &Lua, id: u32, name: &str) {
|
||||
if let Err(e) = event::fire_widget_handler(lua, id, name, Vec::new()) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,27 +27,13 @@ use crate::widget::{ButtonState, FrameHandle};
|
||||
|
||||
/// Fire `event` at every frame registered for it (the engine-internal twin of
|
||||
/// `UiScript::fire_event`, for engine code holding only the Lua context — the compare drive's
|
||||
/// `SHOW_COMPARE_TOOLTIP`), in registration order with per-step live re-reads (the FIFO law —
|
||||
/// see `fire_event`'s doc). Handler errors land in [`Model::errors`].
|
||||
/// `SHOW_COMPARE_TOOLTIP`), in registration order. **The same walk, not a second one**: this was
|
||||
/// an index walk over the live list, so a handler that unregistered *itself* (the hook-once idiom
|
||||
/// on `ADDON_LOADED`) shifted its successor into the slot the walk had just left, and that
|
||||
/// listener never heard the event — the 1324 bug, fixed in [`super::tick::fire_event_into`] and
|
||||
/// left standing here.
|
||||
pub(super) fn fire_global(lua: &Lua, event: &str, args: &[ScriptValue]) {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(&h) = model.event_to_frames.get(event).and_then(|l| l.get(i)) else {
|
||||
break;
|
||||
};
|
||||
model.frame_id(h)
|
||||
};
|
||||
if let Err(e) = fire_event_handler(lua, id, event, args) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
fire_all_event_listeners(lua, event, args);
|
||||
super::tick::fire_event_into(lua, event, args.to_vec());
|
||||
}
|
||||
|
||||
/// The `RegisterAllEvents()` half of one dispatch: every frame that asked for the whole stream,
|
||||
@@ -81,8 +67,7 @@ pub(super) fn fire_all_event_listeners(lua: &Lua, event: &str, args: &[ScriptVal
|
||||
if let Err(e) = fire_event_handler(lua, id, event, args) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
at = next;
|
||||
@@ -161,8 +146,7 @@ pub(super) fn fire_size_changes(lua: &Lua) {
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,16 +197,14 @@ fn fire(lua: &Lua, id: u32, script: &str, arg: &str) {
|
||||
Err(e) => {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
return;
|
||||
}
|
||||
});
|
||||
if let Err(e) = event::fire_widget_handler(lua, id, script, vec![val]) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -382,12 +382,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
}
|
||||
|
||||
// From here every leg clears the cursor and answers nothing. Holding a mode-5 payload
|
||||
// and calling this again is the toggle-off, which is the same clear.
|
||||
// and calling this again is the toggle-off, which is the same clear. It is the REAL
|
||||
// `ClearCursor(1,1)` (`0x4fb82d`/`0x4fb83f` on a refusal, `0x49510b` inside the grab
|
||||
// setter — wow-re `merchant-cursor-law.md` §4/§5), not a bare payload drop: a spell
|
||||
// or action dropped on the vendor window fires `CURSOR_UPDATE` and hides the bar's
|
||||
// grid, and an armed gift wrap is cancelled (`0x5edf10`, `ClearCursor`'s opening act).
|
||||
let held = match &model.cursor {
|
||||
Some(CursorPayload::Merchant(m)) => Some(m.row),
|
||||
_ => None,
|
||||
};
|
||||
model.cursor = None;
|
||||
crate::script::cursor::clear_cursor(&mut model);
|
||||
|
||||
let Some(index) = index else { return Ok(()) };
|
||||
// `_ftol` truncates toward zero; the bound is then applied signed, so a negative or
|
||||
@@ -419,7 +423,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
row: row0,
|
||||
texture: item.texture.clone(),
|
||||
});
|
||||
// The grab's own transition (`0x495159` `SignalEvent(CURSOR_UPDATE)`); mode 5 skips
|
||||
// the mode-7 `ACTIONBAR_SHOWGRID` branch, which the shared seam derives from the arm.
|
||||
model.cursor = Some(payload);
|
||||
crate::script::cursor::queue_cursor_update(&mut model);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -1153,4 +1160,93 @@ mod tests {
|
||||
s.run("PickupContainerItem(0, 5)").unwrap();
|
||||
assert_eq!(s.take_merchant_slot_buys(), vec![(0, 5, 159)]);
|
||||
}
|
||||
|
||||
/// Count the cursor's three events from Lua, the way a stock handler would see them.
|
||||
fn listen_cursor_events(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
updates, shows, hides = 0, 0, 0
|
||||
local f = CreateFrame("Frame", "MerchantCursorListener")
|
||||
f:RegisterEvent("CURSOR_UPDATE")
|
||||
f:RegisterEvent("ACTIONBAR_SHOWGRID")
|
||||
f:RegisterEvent("ACTIONBAR_HIDEGRID")
|
||||
f:SetScript("OnEvent", function()
|
||||
if event == "CURSOR_UPDATE" then updates = updates + 1 end
|
||||
if event == "ACTIONBAR_SHOWGRID" then shows = shows + 1 end
|
||||
if event == "ACTIONBAR_HIDEGRID" then hides = hides + 1 end
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// `(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID)` delivered so far.
|
||||
fn cursor_event_counts(s: &mut UiScript) -> (i64, i64, i64) {
|
||||
s.tick(0.01);
|
||||
let n = |g: &str| s.eval::<i64>(&format!("return {g}")).unwrap();
|
||||
(n("updates"), n("shows"), n("hides"))
|
||||
}
|
||||
|
||||
/// The grab writes through the real cursor setter (`0x4950f0`, wow-re `merchant-cursor-law.md`
|
||||
/// §5): `CURSOR_UPDATE` at `0x495159`, and — mode 5 not being mode 7 — **no**
|
||||
/// `ACTIONBAR_SHOWGRID`: a vendor row cannot land on the bar, so its empty slots stay dark.
|
||||
#[test]
|
||||
fn a_vendor_grab_fires_cursor_update_but_not_the_bar_grid() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
listen_cursor_events(&mut s);
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
assert_eq!(
|
||||
cursor_event_counts(&mut s),
|
||||
(1, 0, 0),
|
||||
"(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID) after a vendor grab"
|
||||
);
|
||||
// …and its toggle-off is a plain clear: one more CURSOR_UPDATE, and no HIDEGRID either.
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
assert_eq!(cursor_event_counts(&mut s), (2, 0, 0));
|
||||
}
|
||||
|
||||
/// Every non-sell leg opens with `ClearCursor(1,1)` (`0x4fb82d`/`0x4fb83f` on a refusal,
|
||||
/// `0x49510b` inside the grab — `merchant-cursor-law.md` §4/§5), so a spell held over the
|
||||
/// vendor window and dropped there — stock `MerchantFrame.xml`'s `OnMouseUp`,
|
||||
/// `PickupMerchantItem(0)` — is a real clear: the bar's grid hides and `CURSOR_UPDATE` fires.
|
||||
#[test]
|
||||
fn a_spell_dropped_on_the_vendor_is_a_real_clear() {
|
||||
use crate::script::cursor::{self, CursorPayload, CursorSpell};
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
listen_cursor_events(&mut s);
|
||||
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
|
||||
book_slot: 1,
|
||||
book_type: "spell".into(),
|
||||
spell_id: 133,
|
||||
texture: None,
|
||||
passive: false,
|
||||
}));
|
||||
cursor::queue_cursor_update(&mut s.model_mut()); // the pickup's own transition
|
||||
assert_eq!(cursor_event_counts(&mut s), (1, 1, 0), "the spell pickup");
|
||||
|
||||
s.run("PickupMerchantItem(0)").unwrap();
|
||||
assert!(s.cursor_payload().is_none());
|
||||
assert_eq!(
|
||||
cursor_event_counts(&mut s),
|
||||
(2, 1, 1),
|
||||
"(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID) after the vendor drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ClearCursor 0x495190` opens with the gift-wrap cancel (`0x5edf10`, decision 1934), and the
|
||||
/// grab's first act is that clear (`0x49510b`) — so taking a vendor row disarms a wrap.
|
||||
#[test]
|
||||
fn a_vendor_grab_cancels_an_armed_gift_wrap() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
s.arm_gift_wrap(0, 1);
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
assert_eq!(
|
||||
s.gift_wrap_armed(),
|
||||
None,
|
||||
"the grab's ClearCursor cancels the wrap"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,6 +839,10 @@ pub(crate) struct Model {
|
||||
/// The globals `RegisterForSave` declared, in registration order — the saved-variables set the
|
||||
/// host writes out at logout/exit and re-executes at load (decision 1128, [`super::saved`]).
|
||||
pub(crate) saved_names: Vec<String>,
|
||||
/// The saved-variables files that failed to load this session ([`super::saved`]'s
|
||||
/// `hold_saved_file`) — the shutdown write leaves each one on disk rather than replacing a
|
||||
/// settings file it never read with the defaults it ran on instead.
|
||||
pub(crate) held_saved_files: Vec<std::path::PathBuf>,
|
||||
|
||||
/// The key-binding table (decision 0997, [`super::keybind`]) — the chord→command store the
|
||||
/// Key Bindings window edits, plus its stored account/character sets. The CVar table's twin:
|
||||
@@ -1077,7 +1081,9 @@ pub(crate) struct Model {
|
||||
///
|
||||
/// The **pet** payload arm is excluded (decision 1010): `PlaceAction` refuses it, so lighting
|
||||
/// the action bar's empty slots for a payload that cannot land there would be an invitation to
|
||||
/// a no-op. It drives [`Self::pet_grid_shown`] instead.
|
||||
/// a no-op. It drives [`Self::pet_grid_shown`] instead. So is the **vendor row** (mode 5): its
|
||||
/// grab setter `0x4950f0` fires `ACTIONBAR_SHOWGRID` for mode 7 alone (wow-re
|
||||
/// `merchant-cursor-law.md` §5).
|
||||
pub(crate) cursor_grid_shown: bool,
|
||||
/// The same mirror for the PET bar's grid — `PET_BAR_SHOWGRID`/`PET_BAR_HIDEGRID`, which the
|
||||
/// reference fires from inside the pet-action pickup builder itself (`0x494f28`) rather than
|
||||
@@ -2038,6 +2044,7 @@ impl Model {
|
||||
video_caps: super::cvars::VideoCaps::default(),
|
||||
restart_gx_asks: 0,
|
||||
saved_names: Vec::new(),
|
||||
held_saved_files: Vec::new(),
|
||||
keybinds: super::keybind::KeybindState::default(),
|
||||
actions: HashMap::new(),
|
||||
action_states: HashMap::new(),
|
||||
|
||||
@@ -163,22 +163,36 @@ pub type QuestLogQuestItem = QuestItemView;
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct QuestLogState {
|
||||
/// The list rows, in quest-log (descriptor slot) order. 1-based indexing on the Lua side.
|
||||
/// A collapsed header's quests are OMITTED here (the app filters) — indexes are visible rows.
|
||||
/// A collapsed header's quests are OMITTED here (the app filters) — indexes are visible rows;
|
||||
/// their ids ride [`Self::hidden_quest_ids`].
|
||||
pub entries: Vec<QuestLogEntryView>,
|
||||
/// The total quest count INCLUDING quests hidden under collapsed headers — the "Quests: N/20"
|
||||
/// pill must not shrink when a header collapses (`GetNumQuestLogEntries` return 2).
|
||||
pub num_quests: u32,
|
||||
/// The ids of the quests folded under a collapsed header — in the log, absent from
|
||||
/// [`Self::entries`]. No getter indexes them; they exist for the **watch prune** alone, which
|
||||
/// in the reference scans the whole row array, hidden rows included (`0x4de7a7`–`0x4de80f`;
|
||||
/// a collapsed-group quest is still a row there, sorted past the visible window — wow-re
|
||||
/// `system/ui/scratch/questlog-list-rebuild.md` §7). Without them a collapse would read as the
|
||||
/// quest leaving the log and silently drop its watch.
|
||||
pub hidden_quest_ids: Vec<u32>,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push the quest-log snapshot (the app calls this whenever slots/templates/selection change).
|
||||
/// Also prunes the watch set: a watched quest that left the log (abandon/turn-in) drops its
|
||||
/// watch, exactly as the real client's RemoveQuestWatch-on-removal does.
|
||||
/// watch — the rebuild's prune `0x4de7a7`–`0x4de80f`, which keeps a watch while ANY non-header
|
||||
/// row carries its id, visible or [hidden](QuestLogState::hidden_quest_ids). A collapse is
|
||||
/// not a removal: the reference's `0x4ded30` re-sorts and recounts and never prunes.
|
||||
pub fn set_quest_log(&mut self, state: QuestLogState) {
|
||||
let mut model = self.model_mut();
|
||||
model
|
||||
.quest_log_watched
|
||||
.retain(|id| state.entries.iter().any(|e| e.quest_id == *id));
|
||||
model.quest_log_watched.retain(|id| {
|
||||
state
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_header && e.quest_id == *id)
|
||||
|| state.hidden_quest_ids.contains(id)
|
||||
});
|
||||
model.quest_log = state;
|
||||
}
|
||||
|
||||
@@ -850,6 +864,7 @@ mod tests {
|
||||
fn two_quests() -> QuestLogState {
|
||||
QuestLogState {
|
||||
num_quests: 2,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
entries: vec![
|
||||
QuestLogEntryView {
|
||||
quest_id: 783,
|
||||
@@ -1266,6 +1281,53 @@ mod tests {
|
||||
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// A header's collapse folds its quests out of the visible list but NOT out of the log, and
|
||||
/// the watch prune counts them — the reference's prune (`0x4de7a7`–`0x4de80f`) scans the whole
|
||||
/// row array for a non-header row with the watched id, and a collapsed-group quest is still a
|
||||
/// row there, just sorted past the visible window; the collapse itself (`0x4ded30`) re-sorts
|
||||
/// and recounts only (wow-re `system/ui/scratch/questlog-list-rebuild.md` §7/§8). So watch →
|
||||
/// collapse → expand keeps the watch, and a quest that genuinely LEFT the log still drops it.
|
||||
#[test]
|
||||
fn a_collapsed_header_keeps_its_quests_watched() {
|
||||
let header = |collapsed| QuestLogEntryView {
|
||||
title: "Elwynn Forest".into(),
|
||||
is_header: true,
|
||||
collapsed,
|
||||
..Default::default()
|
||||
};
|
||||
let expanded = || {
|
||||
let mut state = two_quests();
|
||||
state.entries.insert(0, header(false));
|
||||
state
|
||||
};
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_quest_log(expanded());
|
||||
s.run("AddQuestWatch(3)").unwrap(); // quest 7
|
||||
assert_eq!(s.quest_log_watched(), vec![7]);
|
||||
|
||||
// The app's collapsed push: the header alone is visible, both quests are hidden under it.
|
||||
s.set_quest_log(QuestLogState {
|
||||
entries: vec![header(true)],
|
||||
num_quests: 2,
|
||||
hidden_quest_ids: vec![783, 7],
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetNumQuestWatches()").unwrap(),
|
||||
1,
|
||||
"a collapse must not prune the watch of a quest still in the log"
|
||||
);
|
||||
s.set_quest_log(expanded());
|
||||
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 1);
|
||||
assert_eq!(s.eval::<i64>("return GetQuestIndexForWatch(1)").unwrap(), 3);
|
||||
|
||||
// Quest 7 leaves the log (turn-in) — neither visible nor hidden — and its watch goes.
|
||||
let mut gone = expanded();
|
||||
gone.entries.retain(|e| e.quest_id != 7);
|
||||
gone.num_quests = 1;
|
||||
s.set_quest_log(gone);
|
||||
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The countdown trio (decisions 1150/1154). One deadline stamp in the snapshot, one clock
|
||||
/// pushed beside it, and every read is a live subtraction — so advancing the clock alone (no
|
||||
/// re-push of the log) makes the number fall, which is precisely what the reference's
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::c_void;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
@@ -42,6 +41,27 @@ use super::Model;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
impl super::UiScript {
|
||||
/// **Hold a saved-variables file that did not load** — the shutdown write must leave it alone.
|
||||
///
|
||||
/// A file that fails as a chunk (a hand edit with a typo; a database past Lua's 262,143
|
||||
/// constants per chunk) leaves its globals at the defaults the code assigned, and the write at
|
||||
/// logout would then replace the player's whole file with those defaults. The reference does
|
||||
/// exactly that — it discards an unparseable file and writes whole from live values (wow-re
|
||||
/// `system/ui/scratch/savedvariables-protocol.md`) — and both load sites here already promised
|
||||
/// otherwise ("left on disk untouched"); this is what makes the promise true. The cost is this
|
||||
/// session's changes to that one file, which is the trade the promise named.
|
||||
pub fn hold_saved_file(&self, path: &std::path::Path) {
|
||||
let mut model = self.model_mut();
|
||||
if !model.held_saved_files.iter().any(|p| p == path) {
|
||||
model.held_saved_files.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// Did this session's load fail on `path`? ([`Self::hold_saved_file`].)
|
||||
pub fn saved_file_held(&self, path: &std::path::Path) -> bool {
|
||||
self.model_ref().held_saved_files.iter().any(|p| p == path)
|
||||
}
|
||||
|
||||
/// The registered names, in registration order — what the host writes out (the reference's
|
||||
/// own emission order, and stable across runs because the load order is).
|
||||
pub fn saved_variable_names(&self) -> Vec<String> {
|
||||
@@ -55,16 +75,22 @@ impl super::UiScript {
|
||||
/// rather than written as something that would fail to load; the reference loses the same set,
|
||||
/// silently. `nil` IS written (`NAME = nil`), which is how the reference records a toggle that
|
||||
/// has never been touched.
|
||||
pub fn saved_variables_text(&self) -> String {
|
||||
self.saved_variables_text_for(&self.saved_variable_names())
|
||||
///
|
||||
/// **Bytes, not text**: a Lua string is a byte string, and the file is executed as a chunk
|
||||
/// that reads bytes (1193), so the writer is the one place that could change a value — and it
|
||||
/// did. Values and keys went through `to_string_lossy`, so a string an addon cut mid-codepoint
|
||||
/// or packed with high bytes (a compression library's output) came back from the next login as
|
||||
/// U+FFFD, and two such keys could fold into one entry. The reference writes the bytes raw.
|
||||
pub fn saved_variables_bytes(&self) -> Vec<u8> {
|
||||
self.saved_variables_bytes_for(&self.saved_variable_names())
|
||||
}
|
||||
|
||||
/// [`UiScript::saved_variables_text`] over an explicit name list — an addon's own
|
||||
/// [`UiScript::saved_variables_bytes`] over an explicit name list — an addon's own
|
||||
/// `## SavedVariables` set (1188 phase 3), which is declared in its manifest rather than
|
||||
/// through `RegisterForSave`. Same grammar, same skip rules; only the source of the names
|
||||
/// differs, which is exactly the difference between the reference's two mechanisms.
|
||||
pub fn saved_variables_text_for(&self, names: &[String]) -> String {
|
||||
let mut out = String::new();
|
||||
pub fn saved_variables_bytes_for(&self, names: &[String]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut unwritable = Vec::new();
|
||||
for name in names {
|
||||
let value: Value = match self.lua().globals().get(name.as_str()) {
|
||||
@@ -77,7 +103,10 @@ impl super::UiScript {
|
||||
let mut seen = HashSet::new();
|
||||
match serialize(&value, 1, &mut seen) {
|
||||
Some(text) => {
|
||||
let _ = writeln!(out, "{name} = {text}");
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(b" = ");
|
||||
out.extend_from_slice(&text);
|
||||
out.push(b'\n');
|
||||
}
|
||||
None => unwritable.push(name.clone()),
|
||||
}
|
||||
@@ -98,13 +127,13 @@ impl super::UiScript {
|
||||
/// starts at 1); `seen` carries the table identities on the current path — a repeat is a cycle and
|
||||
/// drops that entry. Note it is the *path*, not every table ever visited: a shared subtable is
|
||||
/// legitimately written twice (as it must be, since the file has no way to express aliasing).
|
||||
fn serialize(v: &Value, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
|
||||
fn serialize(v: &Value, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<Vec<u8>> {
|
||||
match v {
|
||||
Value::Nil => Some("nil".to_string()),
|
||||
Value::Boolean(b) => Some(b.to_string()),
|
||||
Value::Integer(i) => Some(i.to_string()),
|
||||
Value::Number(n) => number(*n),
|
||||
Value::String(s) => Some(quote(&s.to_string_lossy())),
|
||||
Value::Nil => Some(b"nil".to_vec()),
|
||||
Value::Boolean(b) => Some(b.to_string().into_bytes()),
|
||||
Value::Integer(i) => Some(i.to_string().into_bytes()),
|
||||
Value::Number(n) => number(*n).map(String::into_bytes),
|
||||
Value::String(s) => Some(quote(&s.as_bytes())),
|
||||
Value::Table(t) => table(t, depth, seen),
|
||||
// Functions, threads, userdata (every widget reference is one — a frame's Lua value is a
|
||||
// table whose `[0]` is a lightuserdata handle, RF-0023) cannot be written as a literal.
|
||||
@@ -127,21 +156,22 @@ fn number(n: f64) -> Option<String> {
|
||||
|
||||
/// A quoted Lua string. The reference escapes exactly four characters (`\000`, `\n`, `\"`, `\\`)
|
||||
/// and writes CR and every high byte raw; we add `\r` (raw CR in a quoted string is not something
|
||||
/// its own loader could read back) and keep high bytes raw, so localized text stays legible.
|
||||
fn quote(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\0' => out.push_str("\\000"),
|
||||
c => out.push(c),
|
||||
/// its own loader could read back) and keep every other byte raw, so localized text stays legible
|
||||
/// and a byte string that is not UTF-8 round-trips unchanged.
|
||||
fn quote(s: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(s.len() + 2);
|
||||
out.push(b'"');
|
||||
for &b in s {
|
||||
match b {
|
||||
b'\\' => out.extend_from_slice(b"\\\\"),
|
||||
b'"' => out.extend_from_slice(b"\\\""),
|
||||
b'\n' => out.extend_from_slice(b"\\n"),
|
||||
b'\r' => out.extend_from_slice(b"\\r"),
|
||||
0 => out.extend_from_slice(b"\\000"),
|
||||
b => out.push(b),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out.push(b'"');
|
||||
out
|
||||
}
|
||||
|
||||
@@ -158,12 +188,12 @@ fn quote(s: &str) -> String {
|
||||
/// part that `next` walks ascending. Our vendored parser was restored to that placement in decision
|
||||
/// 2111 — before it, this exact file shape came back keyed 1..n in the *hash* part and Bagnon's
|
||||
/// keyring drew first.
|
||||
fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
|
||||
fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<Vec<u8>> {
|
||||
if depth > MAX_DEPTH || !seen.insert(t.to_pointer()) {
|
||||
return None;
|
||||
}
|
||||
let mut ints: Vec<(i64, String)> = Vec::new();
|
||||
let mut strs: Vec<(String, String)> = Vec::new();
|
||||
let mut ints: Vec<(i64, Vec<u8>)> = Vec::new();
|
||||
let mut strs: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
|
||||
for pair in t.pairs::<Value, Value>() {
|
||||
let Ok((k, v)) = pair else { continue };
|
||||
let Some(value) = serialize(&v, depth + 1, seen) else {
|
||||
@@ -173,25 +203,34 @@ fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<S
|
||||
Value::Integer(i) => ints.push((i, value)),
|
||||
// A float key that is integral is the same slot as the integer in Lua 5.1.
|
||||
Value::Number(n) if n.fract() == 0.0 => ints.push((n as i64, value)),
|
||||
Value::String(s) => strs.push((s.to_string_lossy(), value)),
|
||||
Value::String(s) => strs.push((s.as_bytes().to_vec(), value)),
|
||||
_ => continue, // a table/bool/function key cannot be written as a literal
|
||||
}
|
||||
}
|
||||
seen.remove(&t.to_pointer());
|
||||
ints.sort_by_key(|(k, _)| *k);
|
||||
// Byte order — the same order `str`'s `Ord` gave every UTF-8 key before.
|
||||
strs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let indent = "\t".repeat(depth);
|
||||
let close = "\t".repeat(depth.saturating_sub(1));
|
||||
let mut out = String::from("{\n");
|
||||
let mut out = b"{\n".to_vec();
|
||||
let mut entry = |key: &[u8], value: &[u8]| {
|
||||
out.extend_from_slice(indent.as_bytes());
|
||||
out.push(b'[');
|
||||
out.extend_from_slice(key);
|
||||
out.extend_from_slice(b"] = ");
|
||||
out.extend_from_slice(value);
|
||||
out.extend_from_slice(b",\n");
|
||||
};
|
||||
for (k, v) in ints {
|
||||
let _ = writeln!(out, "{indent}[{k}] = {v},");
|
||||
entry(k.to_string().as_bytes(), &v);
|
||||
}
|
||||
for (k, v) in strs {
|
||||
let _ = writeln!(out, "{indent}[{}] = {v},", quote(&k));
|
||||
entry("e(&k), &v);
|
||||
}
|
||||
out.push_str(&close);
|
||||
out.push('}');
|
||||
out.extend_from_slice(close.as_bytes());
|
||||
out.push(b'}');
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -254,13 +293,14 @@ mod tests {
|
||||
};
|
||||
assert_eq!(walk(&s), "0,1,2,3,4,-2,", "the live table, before any save");
|
||||
|
||||
let text = s.saved_variables_text();
|
||||
let text = s.saved_variables_bytes();
|
||||
let fresh = UiScript::new().unwrap();
|
||||
fresh.run(&text).unwrap();
|
||||
fresh.run_chunk(&text).unwrap();
|
||||
assert_eq!(
|
||||
walk(&fresh),
|
||||
"0,1,2,3,4,-2,",
|
||||
"the restart must walk the same order the live session did; wrote:\n{text}"
|
||||
"the restart must walk the same order the live session did; wrote:\n{}",
|
||||
String::from_utf8_lossy(&text)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,7 +339,8 @@ mod tests {
|
||||
],
|
||||
"registration order, and a re-register is not a second entry"
|
||||
);
|
||||
let text = s.saved_variables_text();
|
||||
let bytes = s.saved_variables_bytes();
|
||||
let text = String::from_utf8(bytes).expect("every value here is UTF-8");
|
||||
// The grammar: one statement per line, `nil` written out, integral numbers bare, keys
|
||||
// bracketed and SORTED (integers ascending, then strings), tab-indented, trailing comma.
|
||||
assert_eq!(
|
||||
@@ -365,13 +406,42 @@ mod tests {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let text = s.saved_variables_text();
|
||||
let text = s.saved_variables_bytes();
|
||||
// The cycle's own entry drops; the table itself still writes (empty here).
|
||||
assert_eq!(text, "KEPT = 7\nCYCLE = {\n}\n", "got:\n{text}");
|
||||
assert_eq!(text, b"KEPT = 7\nCYCLE = {\n}\n");
|
||||
let warns = s.take_warnings();
|
||||
assert_eq!(warns.len(), 1, "one line, not one per name: {warns:?}");
|
||||
assert!(warns[0].contains("A_FUNCTION") && warns[0].contains("NOT_A_NUMBER"));
|
||||
// And what it wrote is loadable.
|
||||
UiScript::new().unwrap().run(&text).unwrap();
|
||||
UiScript::new().unwrap().run_chunk(&text).unwrap();
|
||||
}
|
||||
|
||||
/// **A byte string comes back as the same bytes.** A Lua string is bytes; an addon that cuts
|
||||
/// a UTF-8 name mid-codepoint with `string.sub`, or packs data with `string.char(≥128)` (a
|
||||
/// compression library's output), stores something that is not UTF-8. The writer used to
|
||||
/// decode lossily, so the next login read U+FFFD — and two such keys folded into one entry.
|
||||
#[test]
|
||||
fn a_byte_string_that_is_not_utf8_round_trips_unchanged() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
PACKED = string.char(65, 200, 255, 0, 66)
|
||||
KEYS = { [string.char(200)] = 1, [string.char(201)] = 2 }
|
||||
RegisterForSave("PACKED")
|
||||
RegisterForSave("KEYS")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let fresh = UiScript::new().unwrap();
|
||||
fresh.run_chunk(&s.saved_variables_bytes()).unwrap();
|
||||
assert!(fresh
|
||||
.eval::<bool>(
|
||||
"return string.len(PACKED) == 5 and string.byte(PACKED, 2) == 200 \
|
||||
and string.byte(PACKED, 3) == 255 and string.byte(PACKED, 4) == 0"
|
||||
)
|
||||
.unwrap());
|
||||
assert!(fresh
|
||||
.eval::<bool>("return KEYS[string.char(200)] == 1 and KEYS[string.char(201)] == 2")
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,8 +403,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -428,8 +427,7 @@ fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> mlua::Resul
|
||||
{
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -267,8 +267,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -269,8 +269,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -296,8 +295,7 @@ pub(super) fn fire_engine_value_changed(lua: &Lua, bar: crate::widget::FrameHand
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,54 @@ fn a_chosen_error_handler_hears_engine_caught_errors_and_the_default_does_not_du
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every handler path reaches the chosen handler, not only `OnEvent`.** The widget scripts —
|
||||
/// `OnClick`, `OnValueChanged`, the edit box's and keyboard's, the tooltip's — pushed their errors
|
||||
/// onto the host channel alone, so an addon's broken click handler was a terminal line and never
|
||||
/// the dialog, while the same error from `OnEnter` did pop it (decision 1305's "every
|
||||
/// engine-caught script error", which these paths had never joined).
|
||||
#[test]
|
||||
fn a_widget_handler_error_reaches_the_chosen_error_handler() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
"caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \
|
||||
local b = CreateFrame('Button') \
|
||||
b:SetScript('OnClick', function() error('click boom') end) \
|
||||
b:Click() \
|
||||
local sl = CreateFrame('Slider') \
|
||||
sl:SetMinMaxValues(0, 10) \
|
||||
sl:SetScript('OnValueChanged', function() error('slide boom') end) \
|
||||
sl:SetValue(5)",
|
||||
)
|
||||
.unwrap();
|
||||
s.dispatch_script_errors_to_handler();
|
||||
let caught = s
|
||||
.eval::<String>("return table.concat(caught, ' | ')")
|
||||
.unwrap();
|
||||
assert!(caught.contains("click boom"), "OnClick: {caught}");
|
||||
assert!(caught.contains("slide boom"), "OnValueChanged: {caught}");
|
||||
}
|
||||
|
||||
/// **A listener that unregisters itself does not rob the next one** — on the engine-internal
|
||||
/// dispatch too (`fire_global`: `ADDON_LOADED` for a `LoadAddOn`, `UPDATE_FACTION` from the
|
||||
/// faction-header verbs). That walk stepped an index over the live list, so the hook-once idiom
|
||||
/// (`this:UnregisterEvent(event)` inside the handler) shifted the successor into the slot just
|
||||
/// visited and it never heard the event — the 1324 bug, fixed in `fire_event` and not here.
|
||||
#[test]
|
||||
fn a_self_unregistering_listener_does_not_rob_its_successor_on_the_internal_dispatch() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"log = ""
|
||||
local a = CreateFrame("Frame") a:RegisterEvent("UPDATE_FACTION")
|
||||
a:SetScript("OnEvent", function() log = log .. "a" this:UnregisterEvent("UPDATE_FACTION") end)
|
||||
local b = CreateFrame("Frame") b:RegisterEvent("UPDATE_FACTION")
|
||||
b:SetScript("OnEvent", function() log = log .. "b" end)
|
||||
ExpandFactionHeader(0)"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return log").unwrap(), "ab");
|
||||
}
|
||||
|
||||
/// **A handler that itself raises cannot recurse the error path** — its failure is recorded on
|
||||
/// the host channel only, never re-queued, and the batch stops on the first handler failure.
|
||||
#[test]
|
||||
|
||||
@@ -350,8 +350,7 @@ pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) {
|
||||
if let Err(e) = event::fire_widget_handler(lua, id, "OnTooltipCleared", Vec::new()) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,8 +168,7 @@ fn fire_add_money(lua: &Lua, h: crate::widget::FrameHandle, copper: u64) {
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,8 +284,7 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user