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:
2026-10-16 18:45:25 -06:00
co-authored by Claude Opus 5
parent fe00d16ec6
commit d25ef0f1d9
7 changed files with 190 additions and 2 deletions
+10 -1
View File
@@ -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) {
+9
View File
@@ -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
+114
View File
@@ -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;
+9 -1
View File
@@ -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)
+26
View File
@@ -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
+4
View File
@@ -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.