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:
@@ -838,7 +838,16 @@ pub(crate) mod schedule_tests {
|
||||
/// 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;
|
||||
/// +8 for `target::by_name::feed_named_targets`, the `TargetByName` pre-resolve. All eight are
|
||||
/// its `&Transform` read — the distance tiebreak among same-named units — against the frame's
|
||||
/// `Transform` writers: the two booth/portrait syncs, the billboard facing, the quest-marker
|
||||
/// scale and the four transport-rider systems. The same class of pair `UnitStores::positions`
|
||||
/// already carries for the same reason, and its own note applies unchanged: the order can only
|
||||
/// decide whether a rank is computed against this frame's position or last frame's, and the
|
||||
/// unit it picks is then committed **by GUID**, so a stale yard or two cannot select something
|
||||
/// else. The pairs that would matter — its order against the script tick that reads what it
|
||||
/// publishes — are declared (`UnitFeed`, `.before(UnitFeedDispatch)`).
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 4_972;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -247,6 +247,47 @@ impl ByNameScan<'_, '_> {
|
||||
}
|
||||
|
||||
/// Resolve a name to a unit through [`scan_units`], the mode argument deciding the filter.
|
||||
/// Resolve many `TargetByName` queries in one walk of the candidates, each `(query, exact)`
|
||||
/// scored by the same [`Rank`] ladder [`scan_units`] uses — exact, then the longer prefix,
|
||||
/// then the nearest — so a query answered here picks the unit a full resolve would have.
|
||||
///
|
||||
/// Typemask 8 and filter mode 0, which is what `TargetByName` passes: no range, facing,
|
||||
/// reaction, liveness, visibility or self test.
|
||||
fn resolve_many(&self, queries: &[(String, bool)]) -> Vec<((String, bool), Entity, u64)> {
|
||||
let Some(origin) = self.origin() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut best: Vec<Option<(Entity, u64, Rank)>> = vec![None; queries.len()];
|
||||
for (entity, g, tf, _) in self.units.iter() {
|
||||
let guid = g.0;
|
||||
if !NameSearch::AnyUnit.accepts(guid) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = self.names.peek(guid) else {
|
||||
continue;
|
||||
};
|
||||
let dist2 = tf.translation.distance_squared(origin);
|
||||
for (slot, (query, exact)) in queries.iter().enumerate() {
|
||||
let mode = if *exact {
|
||||
Match::ExactOnly
|
||||
} else {
|
||||
Match::PrefixOk
|
||||
};
|
||||
let Some(r) = rank(query, name, dist2, mode) else {
|
||||
continue;
|
||||
};
|
||||
if best[slot].is_none_or(|(_, _, b)| r.beats(b)) {
|
||||
best[slot] = Some((entity, guid, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
queries
|
||||
.iter()
|
||||
.zip(best)
|
||||
.filter_map(|(q, b)| b.map(|(e, g, _)| (q.clone(), e, g)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -342,6 +383,69 @@ fn scan_units<'a>(
|
||||
best.map(|(e, g, _, name)| (e, g, name))
|
||||
}
|
||||
|
||||
/// Everything [`feed_named_targets`] needs to build a unit snapshot, bundled: the same inputs
|
||||
/// `ui_unit`'s own token build reads, so a named unit and a token naming the same unit cannot
|
||||
/// disagree.
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct NamedSnapshot<'w, 's> {
|
||||
names: Res<'w, NameCache>,
|
||||
commands: Res<'w, crate::net::NetCommands>,
|
||||
factions: Option<Res<'w, super::Factions>>,
|
||||
reputations: Res<'w, crate::net::Reputations>,
|
||||
self_q: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
|
||||
classes: Option<Res<'w, crate::chr_classes::ChrClassTable>>,
|
||||
group: Res<'w, crate::ui_party::GroupState>,
|
||||
stores: Query<'w, 's, &'static ObjectStore>,
|
||||
positions: Query<'w, 's, &'static Transform, With<Guid>>,
|
||||
}
|
||||
|
||||
/// Keep every `TargetByName` query a script has asked for resolved **a frame ahead**, so the
|
||||
/// binding can select inside the call as `0x489d60` does (`UiScript::set_named_targets`, whose
|
||||
/// doc carries the why).
|
||||
///
|
||||
/// One candidate walk for the whole asked set rather than one per query: the [`NameCache`] peek
|
||||
/// dominates and happens once per unit either way, leaving a short string compare per query. With
|
||||
/// no script having ever called the verb — every session without a rare scanner — nothing is
|
||||
/// walked at all.
|
||||
///
|
||||
/// Deliberately silent, where [`scan_units`] logs: this runs every frame against names that are
|
||||
/// by design almost never present, and the selection it leads to is logged where it is committed.
|
||||
pub(super) fn feed_named_targets(
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
scan: ByNameScan,
|
||||
ctx: NamedSnapshot,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let asked = script.by_name_asked();
|
||||
if asked.is_empty() {
|
||||
return;
|
||||
}
|
||||
let snap = crate::ui_unit::SnapshotCtx {
|
||||
names: &ctx.names,
|
||||
commands: &ctx.commands,
|
||||
factions: ctx.factions.as_deref(),
|
||||
reputations: &ctx.reputations,
|
||||
self_store: ctx.self_q.single().ok(),
|
||||
classes: ctx.classes.as_deref().map(|t| &t.0),
|
||||
group: &ctx.group,
|
||||
};
|
||||
let mut resolved = std::collections::HashMap::new();
|
||||
for (query, entity, guid) in scan.resolve_many(&asked) {
|
||||
let Ok(store) = ctx.stores.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
let position = ctx
|
||||
.positions
|
||||
.get(entity)
|
||||
.ok()
|
||||
.map(|tf| (tf.translation.x, tf.translation.y, tf.translation.z));
|
||||
resolved.insert(query, snap.full(guid, store, position));
|
||||
}
|
||||
script.set_named_targets(resolved);
|
||||
}
|
||||
|
||||
/// The player-only name search for a caller that holds the [`NameCache`] mutably and so cannot
|
||||
/// take [`ByNameScan`]: `DoEmote`'s target (`0x49fdb1`).
|
||||
#[derive(SystemParam)]
|
||||
|
||||
@@ -1033,8 +1033,9 @@ pub(super) fn selection_requests(
|
||||
// `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>,
|
||||
// whatever its reaction, and `None` means deselect rather than do nothing. `ResMut` because
|
||||
// this drain stamps it too, per request — see the `note` call in the loop.
|
||||
mut last_target: ResMut<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>,
|
||||
@@ -1054,12 +1055,29 @@ pub(super) fn selection_requests(
|
||||
return;
|
||||
}
|
||||
for request in requests {
|
||||
// Catch the memory up to the selection as it stands *now*, before this request moves it.
|
||||
// The per-frame sampler cannot: two verbs in one Lua frame both run inside this loop, and
|
||||
// unitscan's probe is exactly that pair — target the watched name, then `TargetLastTarget`
|
||||
// back. The reference stamps inside the selection itself, so its memory is always the
|
||||
// thing being left behind; this is where ours earns the same.
|
||||
last_target.note(commit.selection.guid);
|
||||
match request {
|
||||
SelectionRequest::Unit(token) => {
|
||||
if let Some((entity, guid)) = tokens.resolve(&token, &commit.selection) {
|
||||
commit.commit(entity, guid);
|
||||
}
|
||||
}
|
||||
// A `TargetByName` that resolved inside its own call: the GUID *is* the answer, so
|
||||
// there is nothing left to resolve and the optimistic `"target"` the binding published
|
||||
// and the unit committed here are the same by construction. A GUID that stopped
|
||||
// streaming between the call and this drain is a silent no-op, as a by-name miss is.
|
||||
SelectionRequest::ByGuid(guid) => match tokens.held(guid) {
|
||||
Some((entity, guid)) => commit.commit(entity, guid),
|
||||
None => info!(
|
||||
"TargetByName: {guid:#x} stopped streaming before the commit; \
|
||||
target left untouched"
|
||||
),
|
||||
},
|
||||
// `0x489ba9 call 0x515940(token)`, then the shared tail. An unresolvable token is
|
||||
// silent here; the reference shows message `0xb8` `ERR_GENERIC_NO_TARGET` (`0x489c0e`).
|
||||
SelectionRequest::Assist(token) => match tokens.resolve(&token, &commit.selection) {
|
||||
@@ -2110,4 +2128,160 @@ mod tests {
|
||||
.is_none());
|
||||
assert!(rx.try_iter().next().is_none());
|
||||
}
|
||||
|
||||
/// unitscan's probe-and-restore, end to end, which is the sequence the whole synchronous
|
||||
/// `TargetByName` exists for:
|
||||
///
|
||||
/// ```lua
|
||||
/// prev = UnitName("target")
|
||||
/// TargetByName(name, true)
|
||||
/// found = UnitName("target")
|
||||
/// if found and prev ~= found then TargetLastTarget() end
|
||||
/// ```
|
||||
///
|
||||
/// Two things have to hold inside that one Lua frame. The read after the call must see the new
|
||||
/// unit — a frame late, `prev` has already become the mob and the restore never fires, which
|
||||
/// is "unitscan steals my target". And `TargetLastTarget` must swap back to what was held
|
||||
/// *before the probe*, not to whatever the per-frame sampler last recorded, which is why the
|
||||
/// drain stamps the memory itself per request.
|
||||
#[test]
|
||||
fn a_by_name_probe_and_the_restore_right_after_it_both_land_in_one_frame() {
|
||||
use crate::net::NetCommands;
|
||||
use benilla_ui::script::{UiScript, UnitState};
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
const ME: u64 = 1;
|
||||
const BOAR: u64 = 0xB0A2;
|
||||
const TIMMY: u64 = 0x7144;
|
||||
const OLDER: u64 = 0x0105;
|
||||
let store =
|
||||
|pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs));
|
||||
let named = |guid: u64, name: &str| UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
guid,
|
||||
name: Some(name.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
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 timmy = world
|
||||
.spawn((Guid(TIMMY), store(&[(22, 100), (28, 100)])))
|
||||
.id();
|
||||
let index = &mut world.resource_mut::<crate::net::GuidIndex>().0;
|
||||
index.insert(BOAR, boar);
|
||||
index.insert(TIMMY, timmy);
|
||||
|
||||
// The boar is selected, and the memory is a frame stale: it still names what the boar
|
||||
// replaced. A restore that trusted it would land on `OLDER`.
|
||||
world.resource_mut::<Selection>().target = Some(boar);
|
||||
world.resource_mut::<Selection>().guid = Some(BOAR);
|
||||
{
|
||||
// Through the real stamp, two frames of it: something older, then the boar.
|
||||
let mut last = world.resource_mut::<scan::LastTarget>();
|
||||
last.note(Some(OLDER));
|
||||
last.note(Some(BOAR));
|
||||
assert_eq!(last.prev, Some(OLDER));
|
||||
}
|
||||
{
|
||||
let mut script = world.non_send_resource_mut::<UiScript>();
|
||||
script.set_unit("target", Some(named(BOAR, "Grizzled Boar")));
|
||||
// What the app's pre-resolve publishes: the watched name, already resolved.
|
||||
script.set_named_targets(
|
||||
[(
|
||||
("timmy the cruel".to_string(), true),
|
||||
named(TIMMY, "Timmy the Cruel"),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
let (prev, found): (String, String) = world
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.eval(
|
||||
r#"
|
||||
local prev = UnitName("target")
|
||||
TargetByName("Timmy the Cruel", true)
|
||||
local found = UnitName("target")
|
||||
if found and prev ~= found then TargetLastTarget() end
|
||||
return prev, found
|
||||
"#,
|
||||
)
|
||||
.expect("the probe runs");
|
||||
assert_eq!(prev, "Grizzled Boar");
|
||||
assert_eq!(
|
||||
found, "Timmy the Cruel",
|
||||
"the read on the next line sees the pick, as it does in the reference"
|
||||
);
|
||||
|
||||
world
|
||||
.run_system_once(selection_requests)
|
||||
.expect("the drain runs as a one-shot system");
|
||||
assert_eq!(
|
||||
world.resource::<Selection>().guid,
|
||||
Some(BOAR),
|
||||
"the restore swaps back to what the probe displaced, not to the stale memory"
|
||||
);
|
||||
// The frame's own stamp still runs after the drain, and the two compose: the restore is
|
||||
// the last selection change, so the memory ends up naming the probe and the verb keeps
|
||||
// toggling.
|
||||
world
|
||||
.run_system_once(scan::remember_last_target)
|
||||
.expect("the per-frame stamp runs");
|
||||
assert_eq!(
|
||||
world.resource::<scan::LastTarget>().prev,
|
||||
Some(TIMMY),
|
||||
"and the memory now names the probe, so the verb still toggles"
|
||||
);
|
||||
}
|
||||
|
||||
/// The first ask for a query has nothing resolved yet, so it keeps the one-frame-late path:
|
||||
/// the by-name queue takes it and no selection is queued. That ask is also what tells the app
|
||||
/// to start resolving the query, so the next one is synchronous.
|
||||
#[test]
|
||||
fn a_query_the_app_has_not_resolved_yet_falls_back_to_the_by_name_queue() {
|
||||
use benilla_ui::script::{SelectionRequest, UiScript};
|
||||
|
||||
let mut script = UiScript::new().expect("a bare VM");
|
||||
script
|
||||
.eval::<()>(r#"TargetByName("Timmy the Cruel", 1)"#)
|
||||
.expect("the binding runs");
|
||||
assert_eq!(
|
||||
script.take_target_by_name_requests(),
|
||||
vec![("Timmy the Cruel".to_string(), true)]
|
||||
);
|
||||
assert_eq!(
|
||||
script.take_selection_requests(),
|
||||
Vec::<SelectionRequest>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
script.by_name_asked(),
|
||||
vec![("timmy the cruel".to_string(), true)],
|
||||
"lowercased and keyed with the exact flag, for the app to resolve ahead of next time"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,17 @@ impl Plugin for TargetPlugin {
|
||||
.add_message::<TargetByNameRequest>()
|
||||
.add_message::<AssistRequest>()
|
||||
.add_message::<click::DeselectGuid>()
|
||||
// The `TargetByName` pre-resolve, in the **unit feed**: the whole point is that a
|
||||
// binding called during this frame's Lua tick finds its answer already published, and
|
||||
// the tick runs `.after(UnitFeed)`. It holds the VM, so it declares its side of the
|
||||
// tick (`game_plugins.rs`'s
|
||||
// `every_vm_holder_in_update_declares_its_side_of_the_tick`).
|
||||
.add_systems(
|
||||
Update,
|
||||
by_name::feed_named_targets
|
||||
.in_set(crate::ui_unit::UnitFeed)
|
||||
.before(crate::ui_unit::UnitFeedDispatch),
|
||||
)
|
||||
.add_systems(
|
||||
Startup,
|
||||
(
|
||||
|
||||
@@ -826,16 +826,31 @@ pub(crate) struct LastTarget {
|
||||
held: Option<u64>,
|
||||
}
|
||||
|
||||
impl LastTarget {
|
||||
/// Catch the memory up to `current`: if the selection has moved since the last look, the one
|
||||
/// we are leaving becomes what the verb swaps to.
|
||||
pub(crate) fn note(&mut self, current: Option<u64>) {
|
||||
if self.held != current {
|
||||
self.prev = self.held;
|
||||
self.held = current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// The sampling has one blind spot, which [`crate::target::click::selection_requests`] closes by
|
||||
/// calling [`LastTarget::note`] itself before each request: **two selection verbs in one Lua
|
||||
/// frame**. The reference stamps inside the selection, so `TargetByName` then `TargetLastTarget`
|
||||
/// — unitscan's probe and restore — swaps back to what was held before the probe. Sampled once a
|
||||
/// frame, the memory would still name whatever preceded *that*, and the restore would land on the
|
||||
/// wrong unit.
|
||||
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;
|
||||
}
|
||||
last.note(selection.guid);
|
||||
}
|
||||
|
||||
/// `TargetLastEnemy`'s memory, the reference's last-attackable guid `[0xb4e2e8]`, read at
|
||||
|
||||
@@ -1228,6 +1228,31 @@ pub(crate) struct Model {
|
||||
/// SERVER's wall clock: the two compose, and neither is correct without the other.
|
||||
pub(crate) local_utc_offset_secs: i32,
|
||||
pub(crate) mouseover_override: Option<u64>,
|
||||
/// Every `(lowercased query, exactMatch)` a script has passed to `TargetByName`, so the app
|
||||
/// can hold each one's resolved unit ready *before* the next call ([`Self::named_targets`]).
|
||||
///
|
||||
/// Capped at [`BY_NAME_ASKED_CAP`]: an addon's watch list is a fixed set, but nothing stops
|
||||
/// one feeding this a new string every frame, and each entry costs the app a candidate rank
|
||||
/// per streamed unit per frame. Past the cap a query simply keeps the one-frame-late path it
|
||||
/// has always had.
|
||||
pub(crate) by_name_asked: std::collections::BTreeSet<(String, bool)>,
|
||||
/// Those queries resolved against the streamed units, pushed once per frame by the app
|
||||
/// (`benilla-app`'s `target::by_name::feed_named_targets`) and **not** written here.
|
||||
pub(crate) named_targets: HashMap<(String, bool), UnitState>,
|
||||
/// The unit a `TargetByName` selected during this Lua frame: `"target"`'s answer until the
|
||||
/// app's own push replaces it, cleared at the top of every tick.
|
||||
///
|
||||
/// The reference selects inside the call — `TargetByName 0x489d60` runs the resolver and
|
||||
/// `CGGameUI::Target` before it returns — so an addon can read `UnitName("target")` on the
|
||||
/// very next line and see what it just picked. Here the selection is a request the app drains
|
||||
/// later in the frame, and the token snapshots were pushed *before* Lua ran, so without this
|
||||
/// that read answers the previous target. One frame is the whole window: unitscan probes for
|
||||
/// a rare by targeting its name, reading the name back, and calling `TargetLastTarget` to put
|
||||
/// the old target back only `if prevTarget ~= foundTarget` (`unitscan.lua:112-130`). A frame
|
||||
/// late, the probing frame sees no change and the frame that *does* see the mob has already
|
||||
/// re-read `prevTarget` as the mob — so the restore never fires and the scan keeps your
|
||||
/// target. That is the "unitscan steals my target" report, and it is this field's whole job.
|
||||
pub(crate) target_override: Option<UnitState>,
|
||||
/// ClassicAPI's `FocusUnit(unit)`/`ClearFocus()` (`compat/classic_api/focus.rs`): the focus
|
||||
/// unit's GUID, the persistent secondary selection 1.12 has no verb and no token for. Stored
|
||||
/// as a GUID for the same reason [`Self::mouseover_override`] is — the `"focus"` token must
|
||||
@@ -1391,6 +1416,15 @@ impl Model {
|
||||
return self.unit_by_guid(guid);
|
||||
}
|
||||
}
|
||||
// A `TargetByName` that resolved during this very tick, for the reason above: the
|
||||
// reference's selection is synchronous and ours is a queued request, so the call's own
|
||||
// read has to see it. Dropped at the top of the next tick, so it never outlives the Lua
|
||||
// frame that made it.
|
||||
if lower == "target" {
|
||||
if let Some(state) = &self.target_override {
|
||||
return Some(state);
|
||||
}
|
||||
}
|
||||
// ClassicAPI's focus (`compat/classic_api/focus.rs`), both its tokens. Checked before the
|
||||
// token map for the reason `"mouseover"` is: the app pushes both snapshots off
|
||||
// [`Self::focus_guid`], so a `FocusUnit`/`ClearFocus` run *this* frame has to win over
|
||||
@@ -1903,6 +1937,9 @@ impl Model {
|
||||
cursor_pos: (0.0, 0.0),
|
||||
local_utc_offset_secs: 0,
|
||||
mouseover_override: None,
|
||||
by_name_asked: std::collections::BTreeSet::new(),
|
||||
named_targets: HashMap::new(),
|
||||
target_override: None,
|
||||
focus_guid: None,
|
||||
clickthrough_corpses: false,
|
||||
race_info: std::collections::BTreeMap::new(),
|
||||
|
||||
@@ -101,6 +101,10 @@ impl super::UiScript {
|
||||
// `WOW_UI_TICK=<secs>`: the phase split ([`super::tick_prof`]). Inert when off, and every
|
||||
// phase below marks, so the row sums to the `[ui-cost] tick=` figure.
|
||||
let mut split = Split::open(&self.lua);
|
||||
// A `TargetByName` answers `"target"` for the rest of the Lua frame it ran in and no
|
||||
// longer: by the next tick the app has drained the selection and pushed the real token
|
||||
// snapshot, so an override left standing could only contradict it.
|
||||
self.model_mut().target_override = None;
|
||||
let clock = {
|
||||
let g = self.lua.globals();
|
||||
let now: f64 = g.get("__benilla_now").unwrap_or(0.0);
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user