crcbl_input/trace.rs
1//! Why an input did what it did: the resolution trace an input inspector
2//! shows, which is the answer to "input eaten mysteriously" by the context
3//! stack.
4//!
5//! # Opt in, and bounded
6//!
7//! Nothing is recorded until [`ActionMap::set_tracing`] turns it on, and an
8//! input then costs the map nothing more than the check of that switch. On,
9//! the map keeps the most recent [`RESOLUTION_TRACE_CAP`] entries and drops
10//! the oldest — a ring of recent inputs rather than one frame's, because a
11//! frame's list is empty again before anyone has read it. An input that
12//! repeats the newest entry exactly, input and outcome both, counts on that
13//! entry ([`TraceEntry::count`]) instead of pushing another: a touchpad's
14//! scroll is dozens of wheel events a second, and would otherwise push every
15//! key press out of the ring before it was read.
16//!
17//! # What is traced
18//!
19//! **Each press**: a key going down (an OS auto-repeat of a key already down
20//! is not a new press), a pointer button going down, a wheel turn, an
21//! on-screen button pressed or an on-screen stick leaving centre, a pad
22//! button going down, and a pad stick or trigger pushed out past
23//! [`PAD_ACTIVITY_THRESHOLD`](crate::PAD_ACTIVITY_THRESHOLD) — the same
24//! travel that makes the pad the [last device](ActionMap::last_device).
25//!
26//! **Not traced**: releases, whose route is the press's; pointer motion and
27//! the pointer's position, which arrive every frame the mouse moves and would
28//! fill the ring with themselves; and a stick or trigger moving while already
29//! out, which is a held level, not a new press.
30//!
31//! # What an entry says
32//!
33//! Each [`TraceEntry`] names the input and its [`Outcome`], read off the same
34//! routes resolution reads (`context.rs`): the context that owns the input and
35//! every binding in it that read the input, with the action it drives
36//! ([`Outcome::Read`]) — an empty list there is an input the context owns and
37//! no binding of a live action read, a chord's key pressed without its
38//! modifier, say; that the owner withholds it until it is released
39//! ([`Outcome::Withheld`]); that a modal context stopped it on its way down
40//! to a context that binds it ([`Outcome::Blocked`]); or that no active
41//! context binds it ([`Outcome::Unbound`]). Something that takes an input
42//! before it reaches the map — the engine loop's reserved keys, a menu over
43//! the game — says so with [`ActionMap::trace_claimed`]
44//! ([`Outcome::Claimed`]).
45//!
46//! # It never changes resolution
47//!
48//! An entry is explained after the input has resolved, by reading the routes,
49//! the withheld inputs and the raw state, and nothing in this module writes
50//! any of them. `tests::resolution_is_identical_with_tracing_on_and_off`
51//! drives two maps through one script, one tracing and one not, and compares
52//! every action after every step.
53
54use core::fmt;
55use std::collections::VecDeque;
56
57use crcbl_core::input::{KeyCode, PointerButton};
58
59use crate::binding_text::{stick_name, trigger_name};
60use crate::context::View;
61use crate::gamepad::{pad_trigger, radial_deadzone};
62use crate::{ActionKind, ActionMap, ActionSlot, Binding, PadButton, Stick, Trigger};
63
64/// The most entries [`ActionMap::trace`] holds: the inputs of the last few
65/// seconds of play, and as many rows as an inspector panel can show beside
66/// the rest of the input section.
67pub const RESOLUTION_TRACE_CAP: usize = 16;
68
69/// One input the trace records — see the [module docs](self) for which.
70#[derive(Clone, Debug, PartialEq)]
71pub enum TracedInput {
72 /// A key went down.
73 Key(KeyCode),
74 /// A pointer button went down.
75 MouseButton(PointerButton),
76 /// The wheel turned.
77 Wheel,
78 /// An on-screen button was pressed — [`ActionMap::virtual_button`].
79 Control(String),
80 /// An on-screen stick left centre — [`ActionMap::virtual_stick`].
81 ControlStick(String),
82 /// A pad button went down, on any pad.
83 PadButton(PadButton),
84 /// A pad stick was pushed out past the activity threshold.
85 PadStick(Stick),
86 /// A pad trigger was pulled past the activity threshold.
87 PadTrigger(Trigger),
88}
89
90/// Spelled as [`Binding`]'s text form spells the binding that reads it —
91/// `Space`, `Mouse:Left`, `Pad:South`, `Virtual:jump` — so a trace row and the
92/// binding beside it read alike. A stick or a trigger has no dead zone of its
93/// own, so it is spelled without one: `PadStick:Left`.
94impl fmt::Display for TracedInput {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 match self {
97 Self::Key(key) => f.write_str(key.as_str()),
98 Self::MouseButton(button) => Binding::MouseButton(*button).fmt(f),
99 Self::Wheel => Binding::MouseScroll.fmt(f),
100 Self::Control(id) => write!(f, "Virtual:{id}"),
101 Self::ControlStick(id) => write!(f, "Virtual:{id} (stick)"),
102 Self::PadButton(button) => Binding::PadButton(*button).fmt(f),
103 Self::PadStick(stick) => write!(f, "PadStick:{}", stick_name(*stick)),
104 Self::PadTrigger(trigger) => write!(f, "PadTrigger:{}", trigger_name(*trigger)),
105 }
106 }
107}
108
109/// One binding that read a traced input, and the action it drives.
110#[derive(Clone, Debug, PartialEq)]
111pub struct TracedRead {
112 /// The action.
113 pub action: String,
114 /// Its binding that read the input.
115 pub binding: Binding,
116}
117
118/// Where a traced input went — see the [module docs](self).
119#[derive(Clone, Debug, PartialEq)]
120pub enum Outcome {
121 /// `context` owns the input, and each of `reads` read it. Empty when the
122 /// context owns it and no binding of a live action in it read it.
123 Read {
124 /// The context that consumed the input.
125 context: String,
126 /// Every binding that read it, in declaration order.
127 reads: Vec<TracedRead>,
128 },
129 /// `context` owns the input and withholds it until it is released, since
130 /// the stack changed under it or a suppress asked — see `context.rs`.
131 Withheld {
132 /// The context that would read it.
133 context: String,
134 },
135 /// The modal context `modal` stopped the input before it reached a
136 /// context beneath that binds it.
137 Blocked {
138 /// The modal context.
139 modal: String,
140 },
141 /// No active context binds the input.
142 Unbound,
143 /// Something took the input before it reached the map, by its own account
144 /// — see [`ActionMap::trace_claimed`].
145 Claimed {
146 /// What took it, as the claimant names itself.
147 by: String,
148 },
149}
150
151/// `ui: ui_accept (Space)`, `gameplay: nothing read it`, `withheld by
152/// gameplay until released`, `blocked by modal inventory`, `unbound`,
153/// `claimed by the loop: pause`.
154impl fmt::Display for Outcome {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 match self {
157 Self::Read { context, reads } => {
158 write!(f, "{context}: ")?;
159 if reads.is_empty() {
160 return f.write_str("nothing read it");
161 }
162 for (index, read) in reads.iter().enumerate() {
163 if index > 0 {
164 f.write_str(", ")?;
165 }
166 write!(f, "{} ({})", read.action, read.binding)?;
167 }
168 Ok(())
169 }
170 Self::Withheld { context } => write!(f, "withheld by {context} until released"),
171 Self::Blocked { modal } => write!(f, "blocked by modal {modal}"),
172 Self::Unbound => f.write_str("unbound"),
173 Self::Claimed { by } => write!(f, "claimed by {by}"),
174 }
175 }
176}
177
178/// One row of the trace: an input, where it went, and how many times in a
179/// row it went there.
180#[derive(Clone, Debug, PartialEq)]
181pub struct TraceEntry {
182 /// The input.
183 pub input: TracedInput,
184 /// Where it went.
185 pub outcome: Outcome,
186 /// How many consecutive inputs this entry stands for — see the
187 /// [module docs](self). At least one.
188 pub count: u32,
189}
190
191impl ActionMap {
192 /// Start or stop recording the resolution trace — see the
193 /// [module docs](self). Stopping drops what was recorded.
194 pub fn set_tracing(&mut self, on: bool) {
195 if !on {
196 self.trace = None;
197 } else if self.trace.is_none() {
198 self.trace = Some(VecDeque::with_capacity(RESOLUTION_TRACE_CAP));
199 }
200 }
201
202 /// Whether the resolution trace is being recorded.
203 #[must_use]
204 pub const fn is_tracing(&self) -> bool {
205 self.trace.is_some()
206 }
207
208 /// The recorded trace, oldest first; empty while tracing is off.
209 pub fn trace(&self) -> impl DoubleEndedIterator<Item = &TraceEntry> {
210 self.trace.iter().flatten()
211 }
212
213 /// Records that `by` took `input` before it reached this map — the engine
214 /// loop's reserved keys, a menu drawn over the game — so the trace shows
215 /// where an input that never arrived went. Nothing happens while tracing
216 /// is off, and the map's state is not touched either way.
217 pub fn trace_claimed(&mut self, input: TracedInput, by: &str) {
218 if self.trace.is_some() {
219 self.push_trace(input, Outcome::Claimed { by: by.to_owned() });
220 }
221 }
222
223 /// Records where `input` went, if tracing is on. Called after the input
224 /// resolved; `input` is built only when it is recorded.
225 pub(crate) fn traced(&mut self, input: impl FnOnce() -> TracedInput) {
226 if self.trace.is_none() {
227 return;
228 }
229 let input = input();
230 let outcome = self.explain(&input);
231 self.push_trace(input, outcome);
232 }
233
234 /// Appends an entry, or counts it on the newest one it repeats, and drops
235 /// the oldest past [`RESOLUTION_TRACE_CAP`].
236 fn push_trace(&mut self, input: TracedInput, outcome: Outcome) {
237 let Some(trace) = &mut self.trace else {
238 return;
239 };
240 if let Some(newest) = trace.back_mut()
241 && newest.input == input
242 && newest.outcome == outcome
243 {
244 newest.count = newest.count.saturating_add(1);
245 return;
246 }
247 if trace.len() == RESOLUTION_TRACE_CAP {
248 trace.pop_front();
249 }
250 trace.push_back(TraceEntry {
251 input,
252 outcome,
253 count: 1,
254 });
255 }
256
257 /// Where `input` goes as the map stands now.
258 fn explain(&self, input: &TracedInput) -> Outcome {
259 let Some(context) = self.routes.owner(input, self.held_pad_buttons) else {
260 return match self.blocking_modal(|binding| claims(binding, input)) {
261 Some(modal) => Outcome::Blocked {
262 modal: self.contexts[modal].clone(),
263 },
264 None => Outcome::Unbound,
265 };
266 };
267 let name = self.contexts[context].clone();
268 if self.suppressed.holds(input) {
269 return Outcome::Withheld { context: name };
270 }
271 let view = &self.view(context);
272 let reads = self
273 .slots
274 .iter()
275 .enumerate()
276 .filter(|&(idx, slot)| slot.context == context && self.is_live(idx))
277 .flat_map(|(_, slot)| {
278 slot.decl
279 .bindings
280 .iter()
281 .filter(move |binding| self.reads(view, slot, binding, input))
282 .map(|binding| TracedRead {
283 action: slot.decl.name.clone(),
284 binding: binding.clone(),
285 })
286 })
287 .collect();
288 Outcome::Read {
289 context: name,
290 reads,
291 }
292 }
293
294 /// What `context` can see of the raw input — the [`View`] resolution
295 /// reads through.
296 fn view(&self, context: usize) -> View<'_> {
297 View {
298 context,
299 routes: &self.routes,
300 suppressed: &self.suppressed,
301 held_keys: &self.held_keys,
302 held_buttons: &self.held_buttons,
303 held_controls: &self.held_controls,
304 held_pad_buttons: self.held_pad_buttons,
305 }
306 }
307
308 /// Whether `binding`, on `slot`'s action, read `input` — the questions
309 /// `ActionMap::resolve_slot` asks of each binding, narrowed to the one
310 /// input. A binding an action of its kind never reads (a
311 /// [`Binding::Key`] on an [`ActionKind::Axis2`]) reads nothing, as it
312 /// resolves to nothing; neither does any binding on a 1-D axis whose
313 /// [`Binding::PointerPosition`] has a position to replace them with; and
314 /// a level reads only once it is past the binding's own dead zone or
315 /// threshold.
316 /// `tests::a_binding_is_traced_as_reading_exactly_when_its_action_moves`
317 /// holds the two to the same answers.
318 fn reads(
319 &self,
320 view: &View<'_>,
321 slot: &ActionSlot,
322 binding: &Binding,
323 input: &TracedInput,
324 ) -> bool {
325 use ActionKind::{Axis1, Axis2, Button};
326 let kind = slot.kind();
327 let absolute = kind == Axis1
328 && self.pointer.is_some()
329 && view.pointer()
330 && slot
331 .decl
332 .bindings
333 .iter()
334 .any(|bound| matches!(bound, Binding::PointerPosition { .. }));
335 if absolute {
336 return false;
337 }
338 match (input, binding) {
339 (TracedInput::Key(key), Binding::Key(bound)) => {
340 kind != Axis2 && bound == key && view.key(*key)
341 }
342 (
343 TracedInput::Key(key),
344 Binding::Chord {
345 modifier,
346 key: bound,
347 },
348 ) => kind != Axis2 && bound == key && view.chord(*modifier, *key),
349 (TracedInput::Key(key), Binding::KeyAxis { .. }) => {
350 kind != Axis2 && binding.owns_key(*key) && view.key(*key)
351 }
352 (TracedInput::Key(key), Binding::Wasd { .. }) => {
353 kind != Axis1 && binding.owns_key(*key) && view.key(*key)
354 }
355 (TracedInput::MouseButton(button), Binding::MouseButton(bound)) => {
356 kind == Button && bound == button && view.button(*button)
357 }
358 (
359 TracedInput::MouseButton(button),
360 Binding::ButtonChord {
361 modifier,
362 button: bound,
363 },
364 ) => kind == Button && bound == button && view.button_chord(*modifier, *button),
365 (TracedInput::Wheel, Binding::MouseScroll) => kind == Axis1 && view.scroll(),
366 (TracedInput::Wheel, Binding::ScrollChord { held }) => {
367 kind == Axis1 && view.scroll_chord(*held)
368 }
369 (TracedInput::Control(id), Binding::Virtual(bound)) => {
370 kind == Button && bound == id && view.control(id)
371 }
372 (TracedInput::ControlStick(id), Binding::Virtual(bound)) => {
373 kind == Axis2
374 && bound == id
375 && view.stick(id)
376 && self
377 .control_sticks
378 .get(id.as_str())
379 .is_some_and(|&deflection| deflection != (0.0, 0.0))
380 }
381 (TracedInput::PadButton(button), Binding::PadButton(bound)) => {
382 kind != Axis2 && bound == button && view.pad_button(*button)
383 }
384 (
385 TracedInput::PadButton(button),
386 Binding::PadChord {
387 modifier,
388 button: bound,
389 },
390 ) => kind != Axis2 && bound == button && view.pad_chord(*modifier, *button),
391 (TracedInput::PadButton(button), Binding::PadDpad) => {
392 kind != Axis1 && PadButton::DPAD.contains(button) && view.pad_button(*button)
393 }
394 (
395 TracedInput::PadStick(stick),
396 Binding::PadStick {
397 stick: bound,
398 deadzone,
399 },
400 ) => {
401 kind == Axis2
402 && bound == stick
403 && view.pad_stick(*stick)
404 && self
405 .pads
406 .values()
407 .any(|pad| radial_deadzone(pad.stick(*stick), *deadzone) != (0.0, 0.0))
408 }
409 (
410 TracedInput::PadTrigger(trigger),
411 Binding::PadTrigger {
412 trigger: bound,
413 threshold,
414 },
415 ) => {
416 kind != Axis2
417 && bound == trigger
418 && view.trigger(*trigger)
419 && pad_trigger(&self.pads, *trigger) > *threshold
420 }
421 _ => false,
422 }
423 }
424}
425
426/// Whether `binding` claims `input` for its context, whether or not it reads
427/// it now — what makes a context the owner in `Routes::build`, so a context
428/// binding the input beneath a modal one is what [`Outcome::Blocked`] means.
429fn claims(binding: &Binding, input: &TracedInput) -> bool {
430 match input {
431 TracedInput::Key(key) => binding.owns_key(*key),
432 TracedInput::MouseButton(button) => binding.mouse_button() == Some(*button),
433 TracedInput::Wheel => binding.reads_wheel(),
434 TracedInput::Control(id) | TracedInput::ControlStick(id) => {
435 matches!(binding, Binding::Virtual(bound) if bound == id)
436 }
437 TracedInput::PadButton(button) => match binding {
438 Binding::PadButton(bound) | Binding::PadChord { button: bound, .. } => bound == button,
439 Binding::PadDpad => PadButton::DPAD.contains(button),
440 _ => false,
441 },
442 TracedInput::PadStick(stick) => {
443 matches!(binding, Binding::PadStick { stick: bound, .. } if bound == stick)
444 }
445 TracedInput::PadTrigger(trigger) => {
446 matches!(binding, Binding::PadTrigger { trigger: bound, .. } if bound == trigger)
447 }
448 }
449}
450
451#[cfg(test)]
452mod tests;