diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 63201b23..d4909fc2 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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) { diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index 989125f5..2f689e14 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -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> = 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>, + reputations: Res<'w, crate::net::Reputations>, + self_q: Query<'w, 's, &'static ObjectStore, With>, + classes: Option>, + group: Res<'w, crate::ui_party::GroupState>, + stores: Query<'w, 's, &'static ObjectStore>, + positions: Query<'w, 's, &'static Transform, With>, +} + +/// 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>, + 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)] diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 48af2ffc..305b7496 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -1033,8 +1033,9 @@ pub(super) fn selection_requests( // `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, + // 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, // The SetSelection tail shared with `/target` and `/assist`, classification included. mut commit: super::by_name::SelectCommit, mut assist_by_name: MessageWriter, @@ -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::(); + 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 timmy = world + .spawn((Guid(TIMMY), store(&[(22, 100), (28, 100)]))) + .id(); + let index = &mut world.resource_mut::().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::().target = Some(boar); + world.resource_mut::().guid = Some(BOAR); + { + // Through the real stamp, two frames of it: something older, then the boar. + let mut last = world.resource_mut::(); + last.note(Some(OLDER)); + last.note(Some(BOAR)); + assert_eq!(last.prev, Some(OLDER)); + } + { + let mut script = world.non_send_resource_mut::(); + 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::() + .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::().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::().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::::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" + ); + } } diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index b23ca496..fd1dbb2c 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -271,6 +271,17 @@ impl Plugin for TargetPlugin { .add_message::() .add_message::() .add_message::() + // 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, ( diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index 66b07e50..cbb4e07d 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -826,16 +826,31 @@ pub(crate) struct LastTarget { held: Option, } +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) { + 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, mut last: ResMut) { - 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 diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 9b32af12..9a42c5c7 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, + /// 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, /// 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(), diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index ff63a3fb..dbbb1cd0 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -101,6 +101,10 @@ impl super::UiScript { // `WOW_UI_TICK=`: 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); diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 8a693e73..44efd5fe 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -1155,7 +1155,29 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { Some(_) => true, }; let mut model = lua.app_data_mut::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index ce99fbcf..34bc6fd9 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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)