Files
benilla-octo/crates/benilla-app/src/query_cache.rs
T
Sam SungandClaude Opus 5.5 7a1d799cf7 Comments and strings: the passes' leftovers, and a region's template matched as one name (#391)
The comment and string passes' last leftovers, and one lookup made to match the reference.

**Comments that stated a wrong reference fact**, now stating the right one:
- A cast failure clears the GCD, and a failed cooldown-on-event spell also drops its parked record (`0x6e73cc`).
- `SetUnit` is `0x505d70`; `0x476cb0` is the component clone it reaches.
- Interior sound follows the character, where the listener sits, not the camera.
- Template inheritance re-runs `LoadXML` on the one object against each template (`0x76985c`), and reads `name` and `id` from the instance alone (`0x769770`).
- On the ghost-to-alive edge the reference clears its corpse cache and sends nothing (`0x5ee990`, `0x491f50`).
- The name cache has no high-bit miss. An empty-name answer evicts the record in the reference (`0x55f6f0`).
- The combat-log deferred queue (`0xc4e208`) waits only on an item name. A unit name never waits.
- A flying spline's segments are paced by curve length (`0x454320`, `0x453760`), not by chord length.
- A stop within 3 yd of its stop point keeps the arrow nocked (`0x6019e4`).
- A missile's sound id is at `CMissile+0x40`.
- A nil or unresolved `AssistUnit` token is a gap against `0xb8` ERR_GENERIC_NO_TARGET, not a deviation.

**Strings:**
- Test, log and tool messages lose leftover history wording and placeholder labels.
- The extractor's scan summaries describe the current tint and sequence-clock paths.
- The smoke script's failure text carries no private numbers.

**Line continuations:** twelve log, panic and assert strings get back the `\` continuations they had lost. Each printed a run of spaces mid-message. Fixes #106.

**One lookup:** a region's `inherits=` is looked up as one verbatim name, matched case-insensitively (`0x6ee6f0`, `_strnicmp` at `0x6ee747`), as `framexml::expand` already did for frames. The gate before it split on commas and matched case-sensitively, so a texture or font string whose `inherits=` spelled its template in another case never received the template. `a_region_takes_a_mis_cased_template` fails before this change and passes after.

**Tests:** the quest tests stop loading three FrameXML files that no quest file uses.

**In game:** nothing changes for the stock UI. Log lines read as one sentence, and an addon texture that names its template in another case now gets it.

Verified with `scripts/check.sh` on the changed crates, plus the loader and quest tests with the install present.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 08:57:47 +07:00

221 lines
7.1 KiB
Rust

//! `QueryCache<K, V>`, the ask-once cache: the reference's `DBCache<T>` (vtable `0x80912c`). A
//! miss sends the query once, a second lookup of a pending key does not re-send, the response
//! lands the record, and eviction is explicit only.
//!
//! The read that asks takes `&self` (the in-flight set is behind a lock), so read-only systems
//! share the owning resource and its change detection means an answer landed.
//!
//! A negative answer is cached as `None` and never re-asked: an integer-keyed store's high-bit miss
//! (`0x556e67`), or a player's empty name.
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use std::sync::Mutex;
use benilla_assets::LockRecover;
use bevy::prelude::*;
use crate::net::EnteredWorldMessage;
pub(crate) struct QueryCache<K, V> {
answered: HashMap<K, Option<V>>,
/// The keys in flight, locked so the read that asks is `&self`; uncontended on the main
/// thread.
pending: Mutex<HashSet<K>>,
/// Bumped by every landing or eviction, the reference's `DBCACHECALLBACK` redisplay.
generation: u64,
}
impl<K: Copy + Eq + Hash, V> Default for QueryCache<K, V> {
fn default() -> Self {
Self {
answered: HashMap::new(),
pending: Mutex::new(HashSet::new()),
generation: 0,
}
}
}
impl<K: Copy + Eq + Hash, V> QueryCache<K, V> {
/// The answer for `key`; a miss runs `ask` once per key per connection. `None` for a miss
/// and a cached negative alike.
pub(crate) fn get_or_ask(&self, key: K, ask: impl FnOnce()) -> Option<&V> {
match self.answered.get(&key) {
Some(answer) => answer.as_ref(),
None => {
if self.pending.lock_recover().insert(key) {
ask();
}
None
}
}
}
/// The answer if it is already here, never asking.
pub(crate) fn get(&self, key: K) -> Option<&V> {
self.answered.get(&key)?.as_ref()
}
/// The answer, mutably, for a record the wire patches in place; never asking.
pub(crate) fn get_mut(&mut self, key: K) -> Option<&mut V> {
self.answered.get_mut(&key)?.as_mut()
}
/// Whether the server answered `key`, with a record or "unknown".
pub(crate) fn answered(&self, key: K) -> bool {
self.answered.contains_key(&key)
}
/// Whether the server answered "unknown", as opposed to a still-pending ask.
pub(crate) fn answered_unknown(&self, key: K) -> bool {
self.answered.get(&key).is_some_and(Option::is_none)
}
#[cfg(test)]
/// Whether an ask for `key` is in flight.
pub(crate) fn is_pending(&self, key: K) -> bool {
self.pending.lock_recover().contains(&key)
}
/// Lands an answer; `None` is the server's negative.
pub(crate) fn insert(&mut self, key: K, answer: Option<V>) {
self.pending
.get_mut()
.unwrap_or_else(|p| p.into_inner())
.remove(&key);
self.answered.insert(key, answer);
self.generation = self.generation.wrapping_add(1);
}
/// The reference's explicit eviction (`SMSG_INVALIDATE_PLAYER` for a player's name): the next
/// read re-asks. Returns whether there was anything to evict.
pub(crate) fn evict(&mut self, key: K) -> bool {
self.pending
.get_mut()
.unwrap_or_else(|p| p.into_inner())
.remove(&key);
let was = self.answered.remove(&key).is_some();
if was {
self.generation = self.generation.wrapping_add(1);
}
was
}
/// Forgets the in-flight asks, which a disconnect or a send before the writer existed may
/// have dropped; [`register`] runs it on every world entry.
pub(crate) fn clear_pending(&mut self) {
self.pending
.get_mut()
.unwrap_or_else(|p| p.into_inner())
.clear();
}
/// Drops everything, answers included (a session-scoped cache on disconnect).
pub(crate) fn clear(&mut self) {
self.answered.clear();
self.clear_pending();
}
pub(crate) fn generation(&self) -> u64 {
self.generation
}
/// Every answered key with its answer, for a persisted cache's save.
pub(crate) fn iter(&self) -> impl Iterator<Item = (&K, &Option<V>)> {
self.answered.iter()
}
pub(crate) fn len(&self) -> usize {
self.answered.len()
}
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
self.answered.is_empty()
}
}
/// A resource that owns one or more [`QueryCache`]s: what to forget on world entry.
pub(crate) trait AskOnce: Resource {
fn clear_pending(&mut self);
}
/// Registers a cache owner to clear its in-flight asks on world entry, so a key asked before the
/// writer existed, or across a disconnect, is asked again.
pub(crate) fn register<T: AskOnce>(app: &mut App) {
app.add_systems(
Update,
release_on_enter::<T>
.in_set(benilla_world::schedule::WorldStage::Net)
.after(crate::net::apply_net_updates),
);
}
fn release_on_enter<T: AskOnce>(
mut entered: MessageReader<EnteredWorldMessage>,
mut cache: ResMut<T>,
) {
if entered.read().count() == 0 {
return;
}
cache.clear_pending();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_miss_asks_once_and_a_landing_answers_it() {
let mut cache: QueryCache<u32, &str> = QueryCache::default();
let mut asks = 0;
assert!(cache.get_or_ask(7, || asks += 1).is_none());
assert!(cache.get_or_ask(7, || asks += 1).is_none());
assert_eq!(
asks, 1,
"the second lookup of a pending key does not re-send"
);
assert!(cache.is_pending(7));
cache.insert(7, Some("seven"));
assert_eq!(cache.get_or_ask(7, || asks += 1), Some(&"seven"));
assert_eq!(asks, 1);
assert!(!cache.is_pending(7));
assert_eq!(cache.generation(), 1);
}
#[test]
fn a_negative_is_cached_and_never_re_asked() {
let mut cache: QueryCache<u32, &str> = QueryCache::default();
let mut asks = 0;
cache.get_or_ask(1, || asks += 1);
cache.insert(1, None);
assert!(cache.get_or_ask(1, || asks += 1).is_none());
assert_eq!(asks, 1);
assert!(cache.answered_unknown(1));
assert!(!cache.answered_unknown(2));
}
#[test]
fn clearing_pending_lets_a_dropped_ask_go_again_and_keeps_the_answers() {
let mut cache: QueryCache<u32, &str> = QueryCache::default();
let mut asks = 0;
cache.get_or_ask(1, || asks += 1);
cache.insert(2, Some("two"));
cache.clear_pending();
cache.get_or_ask(1, || asks += 1);
assert_eq!(asks, 2);
assert_eq!(cache.get(2), Some(&"two"));
}
#[test]
fn eviction_re_asks() {
let mut cache: QueryCache<u32, &str> = QueryCache::default();
cache.insert(3, Some("three"));
assert!(cache.evict(3));
assert!(!cache.evict(3));
let mut asks = 0;
assert!(cache.get_or_ask(3, || asks += 1).is_none());
assert_eq!(asks, 1);
}
}