diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index a594c4d3..63201b23 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -829,7 +829,16 @@ pub(crate) mod schedule_tests { /// and nothing in it. It is the sole writer of a booth target's size, and it reaches for /// `get_mut` only when the size actually changes, so in every other frame it writes nothing at /// all for an order to matter to. - const UPDATE_ACTIONABLE_CEILING: usize = 4_962; + /// +2 for `target::scan::remember_last_target`, `TargetLastTarget`'s memory. It is a second + /// per-frame sampler of `Selection` in the same chain as the first (`remember_last_enemy`), + /// so it inherits that one's shape: the two pairs are against a pet-action drain and a + /// collider finisher, both of which can move the selection. The order against those cannot + /// change what it records, only which frame it records it in — it stamps the *outgoing* + /// selection whenever the current one differs from what it last saw, so a change seen a + /// frame later is still seen exactly once, with the same two guids. Its order against the + /// systems that do matter — the enemy stamp and the ring's death clear — is declared by the + /// chain it sits in. + const UPDATE_ACTIONABLE_CEILING: usize = 4_964; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index 2c6d56f5..989125f5 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -542,6 +542,15 @@ pub(crate) struct SelectCommit<'w, 's> { } impl SelectCommit<'_, '_> { + /// Drop the selection, `TargetLastTarget`'s empty-memory arm: the reference reaches + /// `0x493540(0, 0)` there and deselects, where every other verb on this queue treats a miss + /// as a no-op. Routed through [`super::click::clear`] so the loot close, the + /// `CMSG_SET_SELECTION 0` and the melee stop are the ones a click-off or an Esc would do. + pub(super) fn deselect(&mut self) { + let engaged = self.me.iter().next().is_some_and(|(_, _, engaged)| engaged); + super::click::clear(&mut self.selection, &mut self.seam, engaged); + } + /// The shared assist tail (`0x489bb2`-`0x489c07`, byte-identical in `AssistByName` at /// `0x489cae`-`0x489d07`): select the basis unit's `UNIT_FIELD_TARGET` if it is streamed. /// Every miss is silent and deselects nothing: no basis, a basis targeting nothing, or an diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 3862cccb..48af2ffc 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -1032,6 +1032,9 @@ pub(super) fn selection_requests( tokens: crate::ui_unit::UnitTokens, // `TargetLastEnemy`'s memory (`[0xb4e2e8]`), stamped by `scan::remember_last_enemy`. last_enemy: Res, + // `TargetLastTarget`'s, stamped by `scan::remember_last_target`: the previous selection + // whatever its reaction, and `None` means deselect rather than do nothing. + last_target: Res, // The SetSelection tail shared with `/target` and `/assist`, classification included. mut commit: super::by_name::SelectCommit, mut assist_by_name: MessageWriter, @@ -1082,6 +1085,16 @@ pub(super) fn selection_requests( ), } } + // `0x489b00`. The one verb on this queue that deselects: an empty memory reaches + // `0x493540(0, 0)`, and a stale one does the same, because the memory is a guid and + // a guid that no longer streams names nothing to swap to. + SelectionRequest::LastTarget => match last_target.prev.and_then(|g| tokens.held(g)) { + Some((entity, guid)) => commit.commit(entity, guid), + None => { + info!("TargetLastTarget: no previous selection is streamed; deselecting"); + commit.deselect(); + } + }, // UnitXP_SP3's `UnitXP("target", "nearestEnemy")`: handed to the scan's own system. SelectionRequest::NearestEnemy => { nearest.write(scan::TargetNearestRequest); @@ -1703,6 +1716,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::>(); // UnitXP_SP3's `nearestEnemy` rides this out of the drain to `scan::target_nearest_enemy`. world.init_resource::>(); @@ -1763,6 +1777,106 @@ mod tests { set(&mut world, None); assert_eq!(run(&mut world, "TargetLastEnemy()"), None); } + + /// `TargetLastTarget` (`0x489b00`), the one verb on this queue that deselects. Its memory is + /// the selection held *before* the current one — any unit, not just an attackable one — and + /// an empty or stale memory reaches `0x493540(0, 0)` rather than the bare `ret` every other + /// arm takes. That is what makes it a toggle, and what unitscan leans on: it targets a + /// watched name to see whether the rare is in range, then calls this to put the old target + /// back (`unitscan.lua:84`). With the global missing the probe's target simply stayed. + #[test] + fn target_last_target_swaps_back_and_deselects_when_its_memory_is_empty() { + use crate::net::NetCommands; + use benilla_ui::script::UiScript; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 1; + const BOAR: u64 = 0xB0A2; + const WOLF: u64 = 0xC0DE; + const GONE: u64 = 0x6017; + let store = + |pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs)); + + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut world = World::new(); + world.insert_resource(NetCommands(tx)); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.insert_non_send_resource(UiScript::new().expect("a bare VM")); + + world.spawn((SelfPlayer, Guid(ME), store(&[(22, 100), (28, 100)]))); + let boar = world + .spawn((Guid(BOAR), store(&[(22, 100), (28, 100)]))) + .id(); + let wolf = world + .spawn((Guid(WOLF), store(&[(22, 100), (28, 100)]))) + .id(); + let index = &mut world.resource_mut::().0; + index.insert(BOAR, boar); + index.insert(WOLF, wolf); + + let set = |world: &mut World, target: Option<(Entity, u64)>| { + let mut sel = world.resource_mut::(); + sel.target = target.map(|(e, _)| e); + sel.guid = target.map(|(_, g)| g); + }; + let run = |world: &mut World, lua: &str| { + world + .non_send_resource_mut::() + .eval::<()>(lua) + .expect("the binding runs"); + world + .run_system_once(selection_requests) + .expect("the drain runs as a one-shot system"); + world.resource::().guid + }; + // The memory is the *outgoing* selection, stamped by sampling the change. + let remember = |world: &mut World| { + world + .run_system_once(scan::remember_last_target) + .expect("the stamp runs"); + world.resource::().prev + }; + + // Nothing selected yet, then the boar: the memory is what we left, which is nothing. + assert_eq!(remember(&mut world), None); + set(&mut world, Some((boar, BOAR))); + assert_eq!(remember(&mut world), None, "we came from no selection"); + // Boar → wolf remembers the boar. + set(&mut world, Some((wolf, WOLF))); + assert_eq!(remember(&mut world), Some(BOAR)); + + // …and the verb swaps back to it. + assert_eq!(run(&mut world, "TargetLastTarget()"), Some(BOAR)); + + // An empty memory deselects, where `TargetLastEnemy` would have done nothing. + set(&mut world, Some((wolf, WOLF))); + world.resource_mut::().prev = None; + assert_eq!(run(&mut world, "TargetLastTarget()"), None); + assert_eq!( + run(&mut world, "TargetLastEnemy()"), + None, + "the sibling verb still never deselects, and had nothing to select" + ); + + // A guid that no longer streams names nothing to swap to: the same deselect. + set(&mut world, Some((wolf, WOLF))); + world.resource_mut::().prev = Some(GONE); + assert_eq!(run(&mut world, "TargetLastTarget()"), None); + } use crate::net::{ClientCommand, NetCommands, ObjectStore}; use bevy::ecs::system::RunSystemOnce; diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index fa654bf8..ed452e35 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -264,6 +264,7 @@ impl Plugin for TargetPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -347,7 +348,14 @@ impl Plugin for TargetPlugin { // The last-enemy stamp before the ring's death-clear, so a hostile that dies // selected is still remembered (the reference's `TargetLastEnemy` has no // liveness gate). - (scan::remember_last_enemy, ring::update_ring).chain(), + ( + // Before the enemy stamp and the death clear alike: this one wants the + // *outgoing* selection, so it has to see the frame's change first. + scan::remember_last_target, + scan::remember_last_enemy, + ring::update_ring, + ) + .chain(), ) .chain() .in_set(TargetUpdate) diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index 36724c88..53e1ee1c 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -803,6 +803,32 @@ pub(super) fn acquire_and_attack( seam.start(guid, false, false); } +/// `TargetLastTarget`'s memory: the selection held immediately **before** the current one, +/// whatever its reaction, where [`LastEnemy`] keeps the last *attackable* one. `None` is a real +/// answer, not an absent one — the verb spends it as a deselect (`0x489b00` reaches +/// `0x493540(0, 0)`), which is what makes it swap back and forth rather than stick. +#[derive(Resource, Default)] +pub(crate) struct LastTarget { + /// What the verb swaps to. + pub(crate) prev: Option, + /// What the selection was when the stamp last ran, so a change can be noticed. It lives here + /// rather than in a system `Local` because it is part of the memory, not of one system + /// instance — and a `Local` is unreachable from a test that drives the system directly. + held: Option, +} + +/// Stamp [`LastTarget`] on every selection change: the outgoing guid becomes the memory. +/// +/// Sampled per frame rather than written at each selection site, as [`remember_last_enemy`] is +/// and for the same reason — every writer is covered, including the ones that are not +/// `SetSelection` calls here (a stream-out, a death clear, a click-off). +pub(super) fn remember_last_target(selection: Res, mut last: ResMut) { + if last.held != selection.guid { + last.prev = last.held; + last.held = selection.guid; + } +} + /// `TargetLastEnemy`'s memory, the reference's last-attackable guid `[0xb4e2e8]`, read at /// `0x489b45` and written at `0x49377d` in `SetSelection`. Never cleared: an unresolved guid is a /// bare `ret` in the select helper `0x489a40`, not a deselect. diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index a08ed5b9..8a693e73 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -1097,6 +1097,24 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // TargetLastTarget() (`0x489b00`): swap back to the selection held before this one. Its + // memory is every selection's predecessor, not just the attackable ones, and an empty one + // **deselects** (`0x493540(0, 0)`) where `TargetLastEnemy`'s shim tests nothing and returns — + // that difference is the whole verb: it toggles. + // + // unitscan is the consumer that found this missing. It probes for a watched rare by + // targeting the name and reading `UnitName("target")` back, then calls this to put the old + // target back (`unitscan.lua:84`); with the global absent the probe's target just stayed, + // which looks exactly like the addon stealing your target on every scan tick. + g.set( + "TargetLastTarget", + lua.create_function(|lua, ()| { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.selection_requests.push(SelectionRequest::LastTarget); + Ok(()) + })?, + )?; + // TargetNearestFriend([reverse]) (`0x489aa0`): the Tab cycler `0x493f60` in mode 2 (enemy is // 1), whose filter (`0x493eca`) wants `CanAssist` and health above 0. Argument 1 reverses // (`0x6f1c10`, absent is 0); stock `Bindings.xml` says "1 (or "true")", so a number or a diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 493413ae..ce99fbcf 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -21,6 +21,10 @@ pub enum SelectionRequest { AssistByName(String), /// `TargetLastEnemy()`: the last attackable unit committed. LastEnemy, + /// `TargetLastTarget()` (`0x489b00`): the selection held *before* the current one, whatever + /// its reaction. Unlike [`Self::LastEnemy`], an empty memory is **not** a no-op — the verb + /// reaches `0x493540(0, 0)` and deselects, which is what makes it a toggle. + LastTarget, /// `AttackTarget()` (`0x489b50`): start swinging at whatever is already selected. Not a /// selection change at all — it rides this queue because this is the Lua→app channel the /// unit verbs share, and the app's drain is where a request earns the target context.