Skip to main content

shuyi_platform/
session.rs

1//! Session records: everything the platform gave a game in one run, frame
2//! by frame, and its playback (ADR-0072). A record is taken where the
3//! platform hands things to [`App`] ([`App::record`]), so no game code
4//! records anything: the clock, the system's events, the input, the system
5//! keyboard's answers, the music's position, the display, the platform's
6//! facts and the store as it was handed over. Each frame also holds a hash
7//! of what happened in it, never of its picture.
8//!
9//! [`Playback`] plays a record on the [`HeadlessPlatform`], from the game's
10//! real [`Start`], and says at which frame the game first did something
11//! else. Frames count [`App::frame`] calls from 1, as
12//! [`App::frame_number`] does.
13//!
14//! A game's rules records (a seed and commands, a hash per step) are one
15//! level up, and stay the game's: playing a session runs the same rules, so
16//! a tick game's steps and a turn game's commands come out of it.
17//!
18//! A record's text is RON, one frame per line, so two records diff frame by
19//! frame ([`SessionRecord::to_ron`]). The suggested file name is
20//! `*.session.ron`. A record is a run's log, not an asset, so it has no
21//! `Format`, and it is never migrated: a field added later is optional and
22//! leaves [`FORMAT_VERSION`] alone, and a change to what a field means
23//! raises it.
24
25use crate::alloc_prelude::*;
26use alloc::collections::BTreeMap;
27use alloc::format;
28use core::fmt;
29
30use ron::ser::PrettyConfig;
31use serde::{Deserialize, Serialize};
32use shuyi_core::StateHash;
33use shuyi_core::made::{Made, Mismatch};
34use shuyi_ui::frame_cap::Pacing;
35use shuyi_ui::storage::Storage;
36use shuyi_ui::system_keyboard::TextAnswer;
37use shuyi_ui::{GameData, PlatformFacts, RawInputEvent};
38
39use crate::app::{App, Start};
40use crate::headless::HeadlessPlatform;
41use crate::platform::{DisplayInfo, Millis, SystemEvent};
42
43/// The version of a record's text this engine reads and writes.
44pub const FORMAT_VERSION: u32 = 1;
45
46/// The most frames one record holds: an hour at 60 frames a second.
47/// Recording stops there ([`SessionRecord::truncated`]).
48pub const MAX_SESSION_FRAMES: usize = 216_000;
49
50/// One run of a game, as the platform gave it ([module docs](self)). `App`
51/// fills one in ([`App::session`]); [`from_ron`](Self::from_ron) reads one
52/// back.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55#[non_exhaustive]
56pub struct SessionRecord {
57    /// The version of its text ([`FORMAT_VERSION`]).
58    pub format_version: u32,
59    /// The build that made it ([`Start::made`]).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub made: Option<Made>,
62    /// The display the game's load was given.
63    pub display: DisplayInfo,
64    /// Where the game ran: what the game's load was given
65    /// ([`Platform::facts`](crate::Platform::facts)).
66    pub platform: PlatformFacts,
67    /// Every key and value in the store when it was handed over.
68    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69    pub storage: BTreeMap<String, String>,
70    /// Whether all of the store was read: false if its keys or a value
71    /// couldn't be, or it hasn't been handed over.
72    pub storage_complete: bool,
73    /// The text of the voice manifest a backend put into the game's
74    /// [`Setup`](crate::Setup), which `App` can't see: the backend gives it
75    /// with [`App::session_mut`].
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub voice_manifest: Option<String>,
78    /// The time of the first frame on the backend's clock.
79    pub start: u64,
80    /// The first frame whose [`changed_files`](crate::Platform::changed_files)
81    /// named files: their new bytes aren't in the record, so frames after it
82    /// may play differently.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub reloaded_at: Option<u32>,
85    /// Whether recording stopped early: at [`MAX_SESSION_FRAMES`], or on an
86    /// [`App::frame_after`], whose time isn't whole milliseconds.
87    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
88    pub truncated: bool,
89    /// Its frames, in order.
90    pub frames: Vec<FrameRecord>,
91}
92
93impl SessionRecord {
94    /// A record of no frames, of a game not loaded yet.
95    pub fn new() -> Self {
96        Self {
97            format_version: FORMAT_VERSION,
98            made: None,
99            display: DisplayInfo::new(0, 0),
100            platform: PlatformFacts::default(),
101            storage: BTreeMap::new(),
102            storage_complete: false,
103            voice_manifest: None,
104            start: 0,
105            reloaded_at: None,
106            truncated: false,
107            frames: Vec::new(),
108        }
109    }
110
111    /// The record's text: RON, one frame per line.
112    pub fn to_ron(&self) -> String {
113        // Depth 2 is the record's fields and the frames' list: each frame
114        // stays on its line. RON's line ending otherwise follows the OS, and
115        // a record's text is the same everywhere.
116        let config = PrettyConfig::new()
117            .depth_limit(2)
118            .indentor(" ")
119            .new_line("\n");
120        ron::ser::to_string_pretty(self, config)
121            .unwrap_or_else(|error| panic!("a session record has no value RON refuses: {error}"))
122    }
123
124    /// The record `text` holds. Another version is refused, whatever else
125    /// its text holds: a record is never migrated.
126    pub fn from_ron(text: &str) -> Result<Self, SessionError> {
127        /// What every version has in common.
128        #[derive(Deserialize)]
129        struct Head {
130            format_version: u32,
131        }
132
133        let refused = |version| Err(SessionError::Version(version));
134        match ron::from_str::<Self>(text) {
135            Ok(record) if record.format_version == FORMAT_VERSION => Ok(record),
136            Ok(record) => refused(record.format_version),
137            Err(error) => match ron::from_str::<Head>(text) {
138                Ok(head) if head.format_version != FORMAT_VERSION => refused(head.format_version),
139                _ => Err(SessionError::Text(error.to_string())),
140            },
141        }
142    }
143
144    /// What a playback of this record can't promise, one line each: frames
145    /// it doesn't hold, a store it doesn't hold all of, files it doesn't
146    /// hold the new bytes of.
147    pub fn limits(&self) -> Vec<String> {
148        let mut limits = Vec::new();
149        if self.truncated {
150            limits.push("the record stopped early: it doesn't hold the run's end".to_owned());
151        }
152        if !self.storage_complete {
153            limits.push("the record doesn't hold all of the store".to_owned());
154        }
155        if let Some(frame) = self.reloaded_at {
156            limits.push(format!(
157                "files changed at frame {frame}: their new bytes aren't in the record, so frames after it may differ"
158            ));
159        }
160        limits
161    }
162}
163
164impl Default for SessionRecord {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170/// One frame of a run, in the order `App` reads the platform: empty lists
171/// and unchanged values are left out, so an idle frame is its `dt` alone, or
172/// its `dt` and `music` while music sounds.
173#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175#[non_exhaustive]
176pub struct FrameRecord {
177    /// This frame's time minus the last one's, in milliseconds: signed,
178    /// since a clock may go back and `App` must see that again; 0 for the
179    /// first frame ([`SessionRecord::start`]).
180    pub dt: i64,
181    /// The system's events.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub system: Vec<SystemEvent>,
184    /// The frame's one poll of the input, kept or dropped as `App` did.
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub input: Vec<RawInputEvent>,
187    /// The system keyboard's part in the frame.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub keyboard: Option<KeyboardFrame>,
190    /// The position [`Audio::music_playing`](crate::Audio::music_playing)
191    /// answered, in seconds.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub music: Option<f64>,
194    /// The cue of the track sounding, when it changes: an empty cue for
195    /// silence.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub track: Option<String>,
198    /// The file of the voice clip
199    /// [`Audio::voice_playing`](crate::Audio::voice_playing) answered, when
200    /// it changes: an empty file for none.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub voice: Option<String>,
203    /// The pacing the game was handed, when it changes
204    /// ([`App::set_pacing`]).
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub pacing: Option<Pacing>,
207    /// The frame's hash, when it differs from the last frame's.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub hash: Option<u64>,
210}
211
212/// The system keyboard's part in one frame.
213#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215#[non_exhaustive]
216pub struct KeyboardFrame {
217    /// Whether the system keyboard is there, when that changes (it isn't,
218    /// until said).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub present: Option<bool>,
221    /// The answer its poll gave.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub answer: Option<TextAnswer>,
224    /// What its `open` answered after a screen's ask.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub opened: Option<bool>,
227}
228
229/// Why a record can't be played.
230#[derive(Debug, Clone, PartialEq, Eq)]
231#[non_exhaustive]
232pub enum SessionError {
233    /// Its text can't be read: RON's error, with its line and column.
234    Text(String),
235    /// Its text is of another version.
236    Version(u32),
237    /// Another build made it.
238    Made(Mismatch),
239}
240
241impl fmt::Display for SessionError {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        match self {
244            Self::Text(text) => write!(f, "a session record that can't be read: {text}"),
245            Self::Version(version) => write!(
246                f,
247                "a session record of version {version}; this engine reads version {FORMAT_VERSION}"
248            ),
249            Self::Made(mismatch) => write!(f, "{mismatch}"),
250        }
251    }
252}
253
254impl core::error::Error for SessionError {}
255
256/// Whether a playback checks that the record's build is this one.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
258pub enum Stamps {
259    /// Refuse a record another build made.
260    #[default]
261    Check,
262    /// Play it anyway: it can differ without a bug in either build.
263    Ignore,
264}
265
266/// `start` made ready to play `record`: a record of another version refused,
267/// the record's stamp checked against the start's as `stamps` says, and the
268/// record's voice manifest put in its setup. Beside it, what the playback
269/// can't promise, one line each.
270pub fn prepare<G: GameData>(
271    start: Start<G>,
272    record: &SessionRecord,
273    stamps: Stamps,
274) -> Result<(Start<G>, Vec<String>), SessionError> {
275    if record.format_version != FORMAT_VERSION {
276        return Err(SessionError::Version(record.format_version));
277    }
278    let mut limits = record.limits();
279    match (&record.made, &start.made) {
280        (Some(made), Some(now)) => {
281            if stamps == Stamps::Check {
282                made.check(now).map_err(SessionError::Made)?;
283            }
284        }
285        (None, _) => limits.push("the record has no stamp: its build isn't checked".to_owned()),
286        (_, None) => limits.push("the start has no stamp: the build isn't checked".to_owned()),
287    }
288    if stamps == Stamps::Ignore {
289        limits.push("the stamps were ignored: another build may play differently".to_owned());
290    }
291    let start = match record.voice_manifest.clone() {
292        Some(manifest) => start.before_load(move |setup| setup.voice_manifest = Some(manifest)),
293        None => start,
294    };
295    Ok((start, limits))
296}
297
298/// The first frame that played differently from its record.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300#[non_exhaustive]
301pub struct FrameDifference {
302    /// The frame, counted from 1.
303    pub frame: u32,
304    /// The hash the record holds for it.
305    pub recorded: StateHash,
306    /// The hash it played with.
307    pub played: StateHash,
308}
309
310/// How far the test Harness has played its record (`Harness::play_to`).
311#[derive(Debug, Clone, PartialEq, Eq, Default)]
312#[non_exhaustive]
313pub struct Played {
314    /// The record's frames played so far.
315    pub frames: u32,
316    /// The frames the record holds.
317    pub of: u32,
318    /// The first frame that played differently: the playing stopped there.
319    pub first_difference: Option<FrameDifference>,
320    /// What the playing can't promise, one line each ([`prepare`]), and,
321    /// once a script's frame has run, what no longer comes from the record.
322    pub limits: Vec<String>,
323}
324
325/// A record playing, one frame at a time: on an [`App`] made from the game's
326/// [`Start`] ([`prepare`]), on a platform from
327/// [`HeadlessPlatform::from_session`]. It needs no Harness, so a running
328/// build can replay a record too.
329#[derive(Debug, Clone)]
330pub struct Playback {
331    /// The frames still to play.
332    frames: alloc::vec::IntoIter<FrameRecord>,
333    /// The time of the last frame played, or the record's start.
334    now: u64,
335    /// The last hash the record wrote.
336    hash: Option<u64>,
337}
338
339impl Playback {
340    /// The playback of `record`, from its first frame.
341    pub fn new(record: &SessionRecord) -> Self {
342        Self {
343            frames: record.frames.clone().into_iter(),
344            now: record.start,
345            hash: None,
346        }
347    }
348
349    /// Plays the next frame at its recorded time, and answers its number,
350    /// or how its hash differs; a frame with no hash has the last one
351    /// written. `None` once the record is played: from its last frame on,
352    /// `platform`'s music and keyboard are its own again.
353    pub fn next<G: GameData>(
354        &mut self,
355        app: &mut App<G>,
356        platform: &mut HeadlessPlatform,
357    ) -> Option<Result<u32, FrameDifference>> {
358        let frame = self.frames.next()?;
359        app.hash_frames(true);
360        platform.push_frame(&frame);
361        if let Some(pacing) = frame.pacing {
362            app.set_pacing(pacing);
363        }
364        self.now = self.now.wrapping_add_signed(frame.dt);
365        self.hash = frame.hash.or(self.hash);
366        let _ = app.frame(platform, Millis(self.now));
367        if self.is_done() {
368            platform.end_session();
369        }
370        let number = app.frame_number();
371        let (Some(recorded), Some(played)) = (self.hash.map(StateHash), app.frame_hash()) else {
372            return Some(Ok(number));
373        };
374        Some(if recorded == played {
375            Ok(number)
376        } else {
377            Err(FrameDifference {
378                frame: number,
379                recorded,
380                played,
381            })
382        })
383    }
384
385    /// Whether every frame has played.
386    pub fn is_done(&self) -> bool {
387        self.frames.as_slice().is_empty()
388    }
389
390    /// The time of the last frame played, for a driver that goes on from
391    /// the record's end; before the first, the record's start.
392    pub fn now(&self) -> Millis {
393        Millis(self.now)
394    }
395}
396
397/// What `App` holds while it records: the record, the frame being noted, and
398/// the values a frame writes only when they change.
399#[derive(Debug)]
400pub(crate) struct Recorder {
401    pub(crate) record: SessionRecord,
402    /// Whether it still records: it stops for good ([`stop`](Self::stop)).
403    on: bool,
404    frame: FrameRecord,
405    /// The last frame's time.
406    now: u64,
407    present: bool,
408    track: String,
409    voice: String,
410    pacing: Option<Pacing>,
411    hash: Option<u64>,
412}
413
414impl Recorder {
415    pub(crate) fn new(made: Option<Made>) -> Self {
416        let mut record = SessionRecord::new();
417        record.made = made;
418        Self {
419            record,
420            on: true,
421            frame: FrameRecord::default(),
422            now: 0,
423            present: false,
424            track: String::new(),
425            voice: String::new(),
426            pacing: None,
427            hash: None,
428        }
429    }
430
431    pub(crate) fn is_on(&self) -> bool {
432        self.on
433    }
434
435    /// Stops recording, early: the record says so.
436    pub(crate) fn stop(&mut self) {
437        if self.on {
438            self.on = false;
439            self.record.truncated = true;
440        }
441    }
442
443    /// A frame starts at `now` on the backend's clock.
444    pub(crate) fn begin(&mut self, now: u64) {
445        if self.record.frames.len() >= MAX_SESSION_FRAMES {
446            self.stop();
447        }
448        if !self.on {
449            return;
450        }
451        self.frame = FrameRecord::default();
452        if self.record.frames.is_empty() {
453            self.record.start = now;
454        } else {
455            self.frame.dt = now.wrapping_sub(self.now).cast_signed();
456        }
457        self.now = now;
458    }
459
460    /// The system's events the frame read.
461    pub(crate) fn system(&mut self, events: &[SystemEvent]) {
462        if self.on {
463            self.frame.system.extend_from_slice(events);
464        }
465    }
466
467    /// What the game's load was handed.
468    pub(crate) fn handed_over(
469        &mut self,
470        display: DisplayInfo,
471        storage: &dyn Storage,
472        facts: &PlatformFacts,
473    ) {
474        if self.on {
475            self.record.display = display;
476            self.record.platform = facts.clone();
477            self.record.storage_complete = read_store(storage, &mut self.record.storage);
478        }
479    }
480
481    /// The files the platform said changed reached the game in `frame`.
482    pub(crate) fn reloaded(&mut self, frame: u32) {
483        if self.on {
484            self.record.reloaded_at.get_or_insert(frame);
485        }
486    }
487
488    /// The pacing the game was handed.
489    pub(crate) fn handed_pacing(&mut self, pacing: Pacing) {
490        if self.on && self.pacing != Some(pacing) {
491            self.pacing = Some(pacing);
492            self.frame.pacing = Some(pacing);
493        }
494    }
495
496    fn keyboard(&mut self) -> &mut KeyboardFrame {
497        self.frame.keyboard.get_or_insert_default()
498    }
499
500    /// Whether the system keyboard is there.
501    pub(crate) fn keyboard_present(&mut self, present: bool) {
502        if self.on && self.present != present {
503            self.present = present;
504            self.keyboard().present = Some(present);
505        }
506    }
507
508    /// The answer the system keyboard's poll gave.
509    pub(crate) fn keyboard_answer(&mut self, answer: &TextAnswer) {
510        if self.on {
511            self.keyboard().answer = Some(answer.clone());
512        }
513    }
514
515    /// What the system keyboard's `open` answered.
516    pub(crate) fn keyboard_opened(&mut self, opened: bool) {
517        if self.on {
518            self.keyboard().opened = Some(opened);
519        }
520    }
521
522    /// What the sound device said is sounding.
523    pub(crate) fn music(&mut self, playing: Option<(&str, f64)>) {
524        if !self.on {
525            return;
526        }
527        let cue = playing.map_or("", |(cue, _)| cue);
528        if cue != self.track {
529            cue.clone_into(&mut self.track);
530            self.frame.track = Some(cue.to_owned());
531        }
532        self.frame.music = playing.map(|(_, position)| position);
533    }
534
535    /// The voice clip the sound device said is sounding.
536    pub(crate) fn voice(&mut self, playing: Option<&str>) {
537        let file = playing.unwrap_or("");
538        if self.on && file != self.voice {
539            file.clone_into(&mut self.voice);
540            self.frame.voice = Some(file.to_owned());
541        }
542    }
543
544    /// The frame ends: its poll of the input, and its hash.
545    pub(crate) fn end(&mut self, input: &[RawInputEvent], hash: StateHash) {
546        if !self.on {
547            return;
548        }
549        let mut frame = core::mem::take(&mut self.frame);
550        frame.input.extend_from_slice(input);
551        if self.hash != Some(hash.0) {
552            self.hash = Some(hash.0);
553            frame.hash = Some(hash.0);
554        }
555        self.record.frames.push(frame);
556    }
557}
558
559/// Reads all of `storage` into `into`, and says whether it could.
560fn read_store(storage: &dyn Storage, into: &mut BTreeMap<String, String>) -> bool {
561    let Ok(keys) = storage.list() else {
562        return false;
563    };
564    let mut complete = true;
565    for key in keys {
566        if let Ok(Some(value)) = storage.read(&key) {
567            into.insert(key, value);
568        } else {
569            complete = false;
570        }
571    }
572    complete
573}
574
575#[cfg(test)]
576mod tests;