Skip to main content

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;