target: TargetByName selects inside the call, as the reference's does

unitscan probes for a watched rare by targeting its name, reading the name back,
and calling TargetLastTarget to put the old target back — but only
`if prevTarget ~= foundTarget` (unitscan.lua:112-130). Two things had to be true
inside that one Lua frame and neither was, so the restore never fired and every
scan kept your target. TargetLastTarget itself was fine and was never even
reached.

**The read after the call.** `TargetByName 0x489d60` runs the resolver and
selects before it returns, so the next line sees the pick. Ours queued a request
the app drained later in the frame, and the token snapshots had been pushed
*before* Lua ran — so the probing frame saw no change at all, and by the frame
that did see the mob, `prevTarget` had already been re-read as the mob.

The app now keeps each query a script has asked for resolved a frame ahead
(`by_name::feed_named_targets`, one candidate walk for the whole asked set, and
nothing walked at all when no script uses the verb). With the unit already in
hand the binding answers `"target"` for the rest of the Lua frame and queues the
selection *by GUID*, so the optimistic answer and the committed selection cannot
name different units. The override is dropped at the top of the next tick. A
query asked for the very first time keeps the old one-frame-late path, and that
ask is what starts it being resolved.

**The order of two selection verbs in one frame.** `TargetLastTarget`'s memory
was sampled once a frame, so mid-frame it still named whatever preceded the
*previous* selection: the restore would have landed on the wrong unit even with a
synchronous read. The reference stamps inside the selection itself, so its memory
is always the thing being left behind; `selection_requests` now calls
`LastTarget::note` per request, and the per-frame sampler stays as the catch-all
for selection changes that are not requests (a stream-out, a death clear, a
click-off). The two compose: the test walks the whole probe-and-restore sequence
and checks both the mid-frame pick and the memory the frame ends with.

Fixes the `/target X` then `UnitName("target")` macro idiom generally, not just
unitscan — SuperCleveRoidMacros is built on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-17 08:40:59 -06:00
co-authored by Claude Opus 5
parent 0515274afc
commit bbe2e3aaa9
9 changed files with 423 additions and 8 deletions
+23 -1
View File
@@ -1155,7 +1155,29 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Some(_) => true,
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.target_by_name_requests.push((name, exact));
// The reference resolves and selects before returning, so the caller's own
// `UnitName("target")` on the next line sees the pick. Ours is a queued request, so
// the app keeps each asked query's unit resolved a frame ahead and the hit is
// answered here: `"target"` is overridden for the rest of this Lua frame and the
// selection rides the shared queue, in call order with whatever else the frame does
// (unitscan's own `TargetLastTarget` right after, for one). A query asked for the
// first time has nothing resolved yet and takes the old, one-frame-late path.
let key = (name.to_ascii_lowercase(), exact);
if model.by_name_asked.len() < super::BY_NAME_ASKED_CAP
|| model.by_name_asked.contains(&key)
{
model.by_name_asked.insert(key.clone());
}
match model.named_targets.get(&key) {
Some(state) => {
let guid = state.guid;
model.target_override = Some(state.clone());
model
.selection_requests
.push(SelectionRequest::ByGuid(guid));
}
None => model.target_by_name_requests.push((name, exact)),
}
Ok(())
})?,
)?;
+39
View File
@@ -2,10 +2,18 @@
//! [`UiScript::set_unit`], and the globals read that plain data, which keeps this crate free of
//! the ECS. Every predicate answers the number `1` or nil, never a Lua boolean.
use std::collections::HashMap;
use mlua::Lua;
use super::Model;
/// How many distinct `TargetByName` queries the app keeps resolved ahead of the call
/// ([`Model::by_name_asked`]). Each costs one candidate rank per streamed unit per frame, so this
/// is a budget, not a limit on what works: a query past it resolves a frame late, as every query
/// did before.
pub(crate) const BY_NAME_ASKED_CAP: usize = 32;
/// A selection ask from Lua, queued in call order for the app to resolve and commit. The reference
/// routes `TargetUnit`, `AssistUnit` and `TargetLastEnemy` through one helper (`0x489a40`: commit,
/// else the group roster, else a silent return), and a macro can observe their order.
@@ -13,6 +21,12 @@ use super::Model;
pub enum SelectionRequest {
/// `TargetUnit(unit)` (`0x4899d0`): the unit the token names.
Unit(String),
/// A `TargetByName` that resolved **inside the call**, naming the unit it picked.
///
/// Not a 1.12 verb of its own — it is `TargetByName`'s own selection, carried on this queue
/// rather than the by-name one so that it keeps its place among the other selection verbs a
/// script calls in the same frame. See [`UiScript::set_named_targets`].
ByGuid(u64),
/// `AssistUnit(unit)` (`0x489b80`): the token's own `UNIT_FIELD_TARGET`; a token with no
/// target is a silent no-op that sends nothing.
Assist(String),
@@ -517,10 +531,35 @@ impl super::UiScript {
}
/// Drain the `TargetByName(name, exactMatch)` calls for the app's by-name resolver.
///
/// Only the ones this VM could **not** answer itself: a query whose unit was already resolved
/// into [`Self::set_named_targets`] selects inside the call and rides
/// [`SelectionRequest::ByGuid`] instead, so it keeps its order among the frame's other
/// selection verbs. What is left here is a query's first-ever ask, and a human `/target`.
pub fn take_target_by_name_requests(&mut self) -> Vec<(String, bool)> {
std::mem::take(&mut self.model_mut().target_by_name_requests)
}
/// The `(lowercased query, exactMatch)` pairs a script has asked `TargetByName` for, for the
/// app to resolve ahead of the next call. Grows to a cap and is never pruned: an addon's
/// watch list is the same set every tick, and an entry whose unit is absent simply resolves
/// to nothing.
pub fn by_name_asked(&self) -> Vec<(String, bool)> {
self.model_ref().by_name_asked.iter().cloned().collect()
}
/// Publish those queries' resolved units, replacing the last push.
///
/// This is what makes `TargetByName` synchronous, as the reference's is: with the unit already
/// in hand the binding can answer `"target"` for the rest of the Lua frame
/// (`Model::target_override`) and queue the selection by GUID. The app owns the resolution
/// because the matching rule — exact, then longest prefix, then nearest — lives there with
/// the object list, and because the *same* resolved GUID is then what gets selected: the
/// optimistic answer and the committed selection cannot name different units.
pub fn set_named_targets(&mut self, resolved: HashMap<(String, bool), UnitState>) {
self.model_mut().named_targets = resolved;
}
/// Drain `ClearTarget()`: true if it fired with a live target; the app deselects (guid 0).
pub fn take_target_clear(&mut self) -> bool {
std::mem::take(&mut self.model_mut().target_clear)