target: TargetLastTarget, a 1.12 verb we never registered
`TargetLastTarget` is in the reference's global table (`reference/1.12-globals.tsv:17678`, beside
`TargetLastEnemy`) and was simply missing. unitscan is what noticed: 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 every scan tick, and is what it was reported as.
Its memory is the selection held **before** the current one, any unit rather than only an
attackable one, so it is a separate stamp from `TargetLastEnemy`'s `[0xb4e2e8]` — sampled per
frame for the reason that one is, so that every writer is covered including the ones that are not
`SetSelection` calls here. The latch lives in the resource rather than a system `Local`: it is
part of the memory, and a `Local` is unreachable from a test that drives the system directly.
An empty or stale memory **deselects**. That is the one arm on this queue that does — the
reference's `0x489b00` reaches `0x493540(0, 0)` where `TargetLastEnemy`'s shim tests nothing and
returns, a difference `click.rs` already had written down — and it is what makes the verb a
toggle: probe something with nothing selected, and this puts you back to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<scan::LastEnemy>,
|
||||
// `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<scan::LastTarget>,
|
||||
// The SetSelection tail shared with `/target` and `/assist`, classification included.
|
||||
mut commit: super::by_name::SelectCommit,
|
||||
mut assist_by_name: MessageWriter<super::by_name::AssistRequest>,
|
||||
@@ -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::<super::AssistAttack>();
|
||||
world.init_resource::<Selection>();
|
||||
world.init_resource::<scan::LastEnemy>();
|
||||
world.init_resource::<scan::LastTarget>();
|
||||
world.init_resource::<Messages<super::by_name::AssistRequest>>();
|
||||
// UnitXP_SP3's `nearestEnemy` rides this out of the drain to `scan::target_nearest_enemy`.
|
||||
world.init_resource::<Messages<super::scan::TargetNearestRequest>>();
|
||||
@@ -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::<crate::spell::QueuedMeleeSpell>();
|
||||
world.init_resource::<crate::spell::AutoRepeatActive>();
|
||||
world.init_resource::<crate::ui_loot::LootState>();
|
||||
world.init_resource::<crate::ui_loot::LootLatch>();
|
||||
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
|
||||
world.init_resource::<crate::ui_party::GroupState>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.init_resource::<crate::net::Reputations>();
|
||||
world.init_resource::<super::AssistAttack>();
|
||||
world.init_resource::<Selection>();
|
||||
world.init_resource::<scan::LastEnemy>();
|
||||
world.init_resource::<scan::LastTarget>();
|
||||
world.init_resource::<Messages<super::by_name::AssistRequest>>();
|
||||
world.init_resource::<Messages<super::scan::TargetNearestRequest>>();
|
||||
world.init_resource::<Messages<super::scan::AttackTargetRequest>>();
|
||||
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::<crate::net::GuidIndex>().0;
|
||||
index.insert(BOAR, boar);
|
||||
index.insert(WOLF, wolf);
|
||||
|
||||
let set = |world: &mut World, target: Option<(Entity, u64)>| {
|
||||
let mut sel = world.resource_mut::<Selection>();
|
||||
sel.target = target.map(|(e, _)| e);
|
||||
sel.guid = target.map(|(_, g)| g);
|
||||
};
|
||||
let run = |world: &mut World, lua: &str| {
|
||||
world
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.eval::<()>(lua)
|
||||
.expect("the binding runs");
|
||||
world
|
||||
.run_system_once(selection_requests)
|
||||
.expect("the drain runs as a one-shot system");
|
||||
world.resource::<Selection>().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::<scan::LastTarget>().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::<scan::LastTarget>().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::<scan::LastTarget>().prev = Some(GONE);
|
||||
assert_eq!(run(&mut world, "TargetLastTarget()"), None);
|
||||
}
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore};
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
|
||||
@@ -264,6 +264,7 @@ impl Plugin for TargetPlugin {
|
||||
.init_resource::<CombatFlash>()
|
||||
.init_resource::<scan::TabHistory>()
|
||||
.init_resource::<scan::LastEnemy>()
|
||||
.init_resource::<scan::LastTarget>()
|
||||
.add_message::<AttackNearestRequest>()
|
||||
.add_message::<scan::AttackTargetRequest>()
|
||||
.add_message::<scan::TargetNearestRequest>()
|
||||
@@ -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)
|
||||
|
||||
@@ -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<u64>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
/// 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<Selection>, mut last: ResMut<LastTarget>) {
|
||||
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.
|
||||
|
||||
@@ -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::<Model>().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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user