Skip to main content

shuyi_cli/contract/
session.rs

1//! A game running headless, for the operations that play it (`run`,
2//! `scene`) and the MCP server's session ([`crate::mcp`]): [`Session`],
3//! which any game gives as a [`Headless`] over its [`Harness`].
4//!
5//! The engine drives the session and reads it; the game says only how its
6//! Harness starts and, if it has them, what its scene is
7//! ([`Headless::with_scene`]), what happened in its rules
8//! ([`Headless::with_events`]) and how its frame looks
9//! ([`Headless::with_picture`]). The modules its game installed are read
10//! from the game itself.
11//!
12//! With perf on ([`Session::set_perf`]), a [`Headless`] measures each
13//! frame with this machine's clock and keeps its record, read with
14//! [`Session::perf`] (ADR-0046). In a debug build of the tool program
15//! each record has the heap's counts too, from the program's own heap
16//! counter (`shuyi_alloc_count::global`), which counts the whole program,
17//! and the game's frames, spans and asset decodes are tagged in it
18//! (`shuyi_alloc_count::tags`): [`Session::heap_snapshot`] takes what holds
19//! the heap by tag. Start a session with [`measured_start`] to tag its
20//! start, the game's load, `load`.
21
22use std::fs;
23use std::path::Path;
24use std::sync::OnceLock;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::time::Instant;
27
28use schemars::generate::SchemaSettings;
29use schemars::{JsonSchema, Schema};
30use serde::{Deserialize, Serialize};
31use shuyi_alloc_count::{global, tags};
32use shuyi_assets::format::{DecodeWatch, Decoded, watch_decodes};
33use shuyi_platform::Platform as _;
34use shuyi_platform::harness::{FRAME_DT, Harness};
35use shuyi_platform::heap;
36use shuyi_platform::script::pressable;
37use shuyi_platform::session::{Played, SessionRecord, Stamps};
38use shuyi_ui::GameData;
39use shuyi_ui::audio::{AudioRequest, MusicCommand};
40use shuyi_ui::log::LogLine;
41use shuyi_ui::module::InstalledModule;
42use shuyi_ui::perf::{HeapCounts, HeapSnapshot, MemoryScopes, PerfClock, TagClass};
43
44use super::codes::{self, Code};
45use super::perf::PerfRead;
46use crate::frame_png::{Image, Script};
47use crate::game::{GameTools, code_of, unsupported};
48
49/// The scene a stage shows, as text and as data.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51#[serde(deny_unknown_fields)]
52// A view a game builds with `Scene::new`: it may grow (ADR-0039).
53#[non_exhaustive]
54pub struct Scene {
55    /// The screen it is on.
56    pub screen: String,
57    /// What kind of scene: `grid map`, or the game's own word.
58    pub kind: String,
59    /// The scene as text: for a grid map, its tiles, units and cursor in
60    /// the scene's text form (`MapScene::to_text`).
61    pub text: String,
62    /// What the game says of it as data: serde's tree of the value the game
63    /// names ([`with_data`](Self::with_data)), by default its stage's state.
64    pub data: serde_json::Value,
65    /// The JSON Schema of [`data`](Self::data), in serialize mode, if the
66    /// game gave it with [`with_data`](Self::with_data): answered only when
67    /// asked for (`scene --shape`).
68    #[serde(skip)]
69    pub shape: Option<Schema>,
70}
71
72impl Scene {
73    /// The scene of kind `kind` on the screen `screen`, as the text `text`,
74    /// with no data (`null`; give it with [`with_data`](Self::with_data)).
75    pub fn new(
76        screen: impl Into<String>,
77        kind: impl Into<String>,
78        text: impl Into<String>,
79    ) -> Self {
80        Self {
81            screen: screen.into(),
82            kind: kind.into(),
83            text: text.into(),
84            data: serde_json::Value::Null,
85            shape: None,
86        }
87    }
88
89    /// This scene, its data `value`'s serde tree and its shape the JSON
90    /// Schema of `T` in serialize mode (ADR-0084 §2, §8); or why `value`
91    /// doesn't serialize (a map with keys that aren't strings).
92    pub fn with_data<T: Serialize + JsonSchema>(mut self, value: &T) -> Result<Self, String> {
93        self.data = serde_json::to_value(value).map_err(|e| format!("the scene's data: {e}"))?;
94        let generator = SchemaSettings::default().for_serialize().into_generator();
95        self.shape = Some(generator.into_root_schema_for::<T>());
96        Ok(self)
97    }
98}
99
100/// The value at JSON Pointer `pointer` (RFC 6901) in `data`; or, if it
101/// names nothing, why: the pointer, and the keys of the deepest object it
102/// reached.
103pub fn at_pointer<'a>(
104    data: &'a serde_json::Value,
105    pointer: &str,
106) -> Result<&'a serde_json::Value, String> {
107    if let Some(value) = data.pointer(pointer) {
108        return Ok(value);
109    }
110    if !pointer.is_empty() && !pointer.starts_with('/') {
111        return Err(format!(
112            "`{pointer}` is no JSON Pointer: one is empty, or starts with `/` (`/hero/x`)"
113        ));
114    }
115    // Walk it as far as it goes, keeping the deepest object reached.
116    let (mut here, mut reached, mut object) = (data, String::new(), None);
117    for token in pointer.split('/').skip(1) {
118        if let serde_json::Value::Object(map) = here {
119            object = Some((reached.clone(), map));
120        }
121        let key = token.replace("~1", "/").replace("~0", "~");
122        let next = match here {
123            serde_json::Value::Object(map) => map.get(&key),
124            serde_json::Value::Array(items) => key.parse::<usize>().ok().and_then(|i| items.get(i)),
125            _ => None,
126        };
127        let Some(next) = next else {
128            break;
129        };
130        here = next;
131        reached.push('/');
132        reached.push_str(token);
133    }
134    let keys = match object {
135        Some((at, map)) => {
136            let at = if at.is_empty() { "the data" } else { &at };
137            let keys: Vec<&str> = map.keys().map(String::as_str).collect();
138            if keys.is_empty() {
139                format!("{at} has no keys")
140            } else {
141                format!("{at} has the keys {}", keys.join(", "))
142            }
143        }
144        None => format!("the data is {}", kind_of(data)),
145    };
146    Err(format!("no value at `{pointer}`: {keys}"))
147}
148
149/// What kind of JSON value `value` is, in a few words.
150fn kind_of(value: &serde_json::Value) -> &'static str {
151    match value {
152        serde_json::Value::Null => "null (the game gives no data)",
153        serde_json::Value::Bool(_) => "a boolean",
154        serde_json::Value::Number(_) => "a number",
155        serde_json::Value::String(_) => "a string",
156        serde_json::Value::Array(_) => "an array",
157        serde_json::Value::Object(_) => "an object",
158    }
159}
160
161/// Something that happened while a session ran, with its frame (frames
162/// count from 1).
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
165pub enum Event {
166    /// A screen opened.
167    Opened {
168        /// The frame.
169        frame: u32,
170        /// Its name.
171        screen: String,
172    },
173    /// A screen closed.
174    Closed {
175        /// The frame.
176        frame: u32,
177        /// Its name.
178        screen: String,
179    },
180    /// A music command to the platform.
181    Music {
182        /// The frame.
183        frame: u32,
184        /// `load`, `start`, `gain` or `stop`.
185        command: String,
186        /// The track's cue.
187        cue: String,
188    },
189    /// A voiced line asked to play.
190    Voice {
191        /// The frame.
192        frame: u32,
193        /// The line's id.
194        line: String,
195    },
196    /// The game asked to quit.
197    Quit {
198        /// The frame.
199        frame: u32,
200    },
201    /// Something in the game's rules, in the game's words (it gives no
202    /// frame).
203    Game {
204        /// What happened.
205        text: String,
206    },
207}
208
209/// A sound cue asked to play.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
211#[serde(deny_unknown_fields)]
212pub struct Sound {
213    /// The frame.
214    pub frame: u32,
215    /// The cue.
216    pub cue: String,
217}
218
219/// A sound the game made at run time
220/// ([`AudioQueue::add_sound`](shuyi_ui::audio::AudioQueue::add_sound)) and
221/// hasn't removed: its cue and the shape of its samples, never the samples.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224// An answer's, read field by field: it may grow (ADR-0039).
225#[non_exhaustive]
226pub struct AddedSound {
227    /// The cue it plays as.
228    pub cue: String,
229    /// Its rate, in samples a second.
230    pub rate: u32,
231    /// Its channels: 1 or 2.
232    pub channels: u16,
233    /// How many frames it holds (a sample of each channel).
234    pub frames: u64,
235    /// How long it plays, in milliseconds, rounded down.
236    pub millis: u64,
237}
238
239/// A line of the game's log.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
241#[serde(deny_unknown_fields)]
242// An answer's, read field by field: it may grow (ADR-0039).
243#[non_exhaustive]
244pub struct LogEntry {
245    /// The frame it was given in (frames count from 1; 0 is before the
246    /// first).
247    pub frame: u32,
248    /// How serious it is: `error`, `warn`, `info` or `debug`.
249    pub level: String,
250    /// What it is about: `assets`, `audio`, `input`, `screens`, `storage`,
251    /// `platform` or `game`.
252    pub category: String,
253    /// What it says, in English.
254    pub text: String,
255}
256
257impl From<&LogLine> for LogEntry {
258    fn from(line: &LogLine) -> Self {
259        Self {
260            frame: line.frame,
261            level: line.level.name().to_owned(),
262            category: line.category.name().to_owned(),
263            text: line.text.clone(),
264        }
265    }
266}
267
268/// A module installed in the game, with what it declared (ADR-0040).
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270#[serde(deny_unknown_fields)]
271// An answer's, read field by field: it may grow (ADR-0039).
272#[non_exhaustive]
273pub struct ModuleEntry {
274    /// Its name.
275    pub name: String,
276    /// The hooks the game calls on it, in the frame loop: `before_update`
277    /// (before the screens' update), `draw_over` (over their drawing).
278    pub hooks: Vec<String>,
279    /// The types of the state it put on the context.
280    pub states: Vec<String>,
281    /// Its entries' names on the debug menu, as they read at install, in
282    /// the order declared; the menu lists them after the game's.
283    pub debug_entries: Vec<String>,
284}
285
286impl From<&InstalledModule> for ModuleEntry {
287    fn from(module: &InstalledModule) -> Self {
288        Self {
289            name: module.name.to_owned(),
290            hooks: module.hooks.iter().map(|h| h.name().to_owned()).collect(),
291            states: module.states.iter().map(|&s| s.to_owned()).collect(),
292            debug_entries: module.debug_entries.clone(),
293        }
294    }
295}
296
297/// A session record to play, in place of a game's start options.
298#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
299#[serde(deny_unknown_fields)]
300// An input: built with `SessionInput::new` and its fields assigned, it may
301// grow (ADR-0039).
302#[non_exhaustive]
303pub struct SessionInput {
304    /// The record's file, from the project's root.
305    pub file: String,
306    /// Play a record stamped by another build or with other content too
307    /// (`--any-stamp`), which is otherwise refused; it may then differ for
308    /// that reason alone.
309    #[serde(default)]
310    pub any_stamp: bool,
311}
312
313impl SessionInput {
314    /// The record in the file `file`, refused if stamped by another build.
315    pub fn new(file: impl Into<String>) -> Self {
316        Self {
317            file: file.into(),
318            any_stamp: false,
319        }
320    }
321}
322
323/// How far a session record played.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
325#[serde(deny_unknown_fields)]
326// An answer's, read field by field: it may grow (ADR-0039).
327#[non_exhaustive]
328pub struct SessionPlayed {
329    /// The record's file, as given.
330    pub file: String,
331    /// The record's frames played.
332    pub frames: u32,
333    /// The frames the record holds.
334    pub of: u32,
335    /// The first frame that played differently: the playing stopped there,
336    /// and neither the script nor `frames` played.
337    pub first_difference: Option<SessionDifference>,
338}
339
340/// The first frame of a session record that played differently.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342#[serde(deny_unknown_fields)]
343// An answer's, read field by field: it may grow (ADR-0039).
344#[non_exhaustive]
345pub struct SessionDifference {
346    /// The frame, counted from 1.
347    pub frame: u32,
348    /// The hash the record holds for it (sixteen hex digits).
349    pub recorded: String,
350    /// The hash it played with.
351    pub played: String,
352}
353
354impl SessionPlayed {
355    /// `played` of the record in `file`.
356    pub(crate) fn of(file: &str, played: &Played) -> Self {
357        Self {
358            file: file.to_owned(),
359            frames: played.frames,
360            of: played.of,
361            first_difference: played.first_difference.as_ref().map(|difference| {
362                SessionDifference {
363                    frame: difference.frame,
364                    recorded: difference.recorded.to_string(),
365                    played: difference.played.to_string(),
366                }
367            }),
368        }
369    }
370}
371
372/// The session of `game` made ready to play the record `input` names, from
373/// `root`, and played: or the problem code and why not. A record another
374/// build stamped is refused unless `input` says, by the game's own stamp
375/// ([`GameTools::made`]) if it gives one.
376pub(crate) fn from_record(
377    game: &dyn GameTools,
378    root: &Path,
379    input: &SessionInput,
380) -> Result<(Box<dyn Session>, Played), (Code, String)> {
381    let file = &input.file;
382    let text =
383        fs::read_to_string(root.join(file)).map_err(|e| (codes::FILE, format!("{file}: {e}")))?;
384    let record =
385        SessionRecord::from_ron(&text).map_err(|e| (codes::FILE, format!("{file}: {e}")))?;
386    if !input.any_stamp
387        && let (Some(made), Some(now)) = (&record.made, game.made())
388        && let Err(mismatch) = made.check(&now)
389    {
390        let error = format!(
391            "{file} was {mismatch}: make it again with this build, or play it with --any-stamp"
392        );
393        return Err((codes::OTHER_STAMP, error));
394    }
395    let stamps = if input.any_stamp {
396        Stamps::Ignore
397    } else {
398        Stamps::Check
399    };
400    let mut session = game
401        .session_from(&record, stamps)
402        .map_err(|e| (code_of(&e), e))?;
403    let played = session.play_record().ok_or_else(|| {
404        let error = unsupported("run --session and scene --session", "session_from");
405        (codes::NOT_OFFERED, error)
406    })?;
407    Ok((session, played))
408}
409
410/// A game running headless.
411pub trait Session {
412    /// Plays `script`'s steps, in order.
413    fn play(&mut self, script: &Script);
414
415    /// Runs `frames` frames with no input.
416    fn idle(&mut self, frames: u32);
417
418    /// The game's actions that have a key or a button now, in its action
419    /// set's order: those a script's `act` step can press (none by
420    /// default).
421    fn actions(&self) -> Vec<String> {
422        Vec::new()
423    }
424
425    /// Plays the session record it was made ready for
426    /// ([`GameTools::session_from`]) to
427    /// its end, or its first frame that plays differently, and says how far
428    /// it has got. Asked again, it plays nothing more: its limits then say
429    /// what a script's frames took over from it. `None` for a session
430    /// started from options, the default.
431    fn play_record(&mut self) -> Option<Played> {
432        None
433    }
434
435    /// How many frames have run.
436    fn frame_count(&self) -> u32;
437
438    /// The open screens' names, bottom first.
439    fn screens(&self) -> Vec<String>;
440
441    /// The frame the game asked to quit in, if it has.
442    fn quit_frame(&self) -> Option<u32>;
443
444    /// The current frame in the snapshot text format.
445    fn snapshot(&self) -> String;
446
447    /// What happened, in frame order: screens opening and closing, music,
448    /// voices, the quit, then the game's own events.
449    fn events(&self) -> Vec<Event>;
450
451    /// The sound cues asked for, in order.
452    fn sounds(&self) -> Vec<Sound>;
453
454    /// The sounds the game added at run time and hasn't removed, in cue
455    /// order (none by default).
456    fn sounds_added(&self) -> Vec<AddedSound> {
457        Vec::new()
458    }
459
460    /// The scene on screen, or why there is none.
461    fn scene(&self) -> Result<Scene, String>;
462
463    /// The game's log, oldest first: its last lines (none by default).
464    fn log(&self) -> Vec<LogEntry> {
465        Vec::new()
466    }
467
468    /// How many older lines the log dropped (0 by default).
469    fn log_dropped(&self) -> u32 {
470        0
471    }
472
473    /// The modules installed in the game, in install order (none by
474    /// default).
475    fn modules(&self) -> Vec<ModuleEntry> {
476        Vec::new()
477    }
478
479    /// Measures each frame from now on, or stops (`false`). By default a
480    /// session measures nothing.
481    fn set_perf(&mut self, on: bool) {
482        let _ = on;
483    }
484
485    /// Whether its records' heap counts are this tool program's own heap
486    /// counter's, which counts the whole program (every thread's
487    /// allocations), not a fake's. By default not.
488    fn counts_process_heap(&self) -> bool {
489        false
490    }
491
492    /// The frames' records kept, numbered `since` or more, the first `max`
493    /// of them ([`PerfRead`]). By default nothing, measured.
494    fn perf(&self, since: u64, max: u32) -> PerfRead {
495        let _ = max;
496        PerfRead::none(since)
497    }
498
499    /// What holds the heap now, by memory tag, at the frames run: a fake
500    /// memory's, or with perf on ([`set_perf`](Self::set_perf)) this
501    /// program's heap counter's, in a build that counts it. By default
502    /// none.
503    fn heap_snapshot(&mut self) -> Option<HeapSnapshot> {
504        None
505    }
506
507    /// The current frame as the game paints it, each console pixel
508    /// `scale` × `scale` (the game's choice if `None`). By default the
509    /// game offers none.
510    fn picture(&self, scale: Option<u32>) -> Result<Image, String> {
511        let _ = scale;
512        Err(unsupported(
513            "a picture of a session's frame",
514            "session, with Headless::with_picture",
515        ))
516    }
517}
518
519/// What a game says its scene is, from its Harness.
520type SceneOf<G> = Box<dyn Fn(&Harness<G>) -> Result<Scene, String>>;
521
522/// How a game paints its Harness's frame, at a scale if one is given.
523type PictureOf<G> = Box<dyn Fn(&Harness<G>, Option<u32>) -> Result<Image, String>>;
524
525/// What a game says happened in its rules, from its Harness.
526type EventsOf<G> = Box<dyn Fn(&Harness<G>) -> Vec<String>>;
527
528/// A [`Session`] over a game's [`Harness`].
529pub struct Headless<G: GameData> {
530    harness: Harness<G>,
531    scene: SceneOf<G>,
532    events: EventsOf<G>,
533    picture: Option<PictureOf<G>>,
534    /// Whether [`Session::set_perf`] gave the Harness its clock.
535    perf_clock: bool,
536    /// Whether [`Session::set_perf`] gave the Harness this program's heap
537    /// counter.
538    perf_memory: bool,
539    /// Whether [`Session::set_perf`] gave the Harness this program's memory
540    /// tags.
541    perf_scopes: bool,
542}
543
544/// Whether [`Session::set_perf`] gives a [`Headless`] a fake clock from
545/// now on ([`measure_with_a_fake_clock`]).
546static FAKE_CLOCK: AtomicBool = AtomicBool::new(false);
547
548/// Has every [`Headless`] measure with a fake clock from now on: `bench`
549/// counts instructions, which a real clock's readings could change from
550/// run to run, and reads no time.
551pub(crate) fn measure_with_a_fake_clock() {
552    FAKE_CLOCK.store(true, Ordering::Relaxed);
553}
554
555/// The clock a [`Headless`] measures with: this machine's, unless
556/// [`measure_with_a_fake_clock`] was called.
557fn perf_clock() -> PerfClock {
558    if FAKE_CLOCK.load(Ordering::Relaxed) {
559        PerfClock::fake()
560    } else {
561        PerfClock::Real(now_us)
562    }
563}
564
565/// The microseconds since this machine's clock was first read here: the
566/// real clock a [`Headless`] measures with.
567fn now_us() -> u64 {
568    static START: OnceLock<Instant> = OnceLock::new();
569    let start = START.get_or_init(Instant::now);
570    u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX)
571}
572
573/// This program's heap counter's reading: the real memory a [`Headless`]
574/// reads, handed over only in a build that counts (zeros otherwise).
575///
576/// It is also `bench`'s frame marker: a measured frame reads the heap
577/// once, at its end, so Callgrind dumps its count before each call
578/// (`crate::bench`'s `MARKER`). Never inlined, so it keeps its name.
579#[inline(never)]
580pub(crate) fn process_heap() -> HeapCounts {
581    global::reading().map_or_else(HeapCounts::default, |counts| {
582        HeapCounts::new(counts.live_bytes, counts.peak_bytes, counts.allocs)
583    })
584}
585
586/// This program's heap counter's snapshot by tag: the real snapshots a
587/// [`Headless`] takes, handed over only in a build that counts (an empty
588/// one otherwise).
589fn process_snapshot() -> HeapSnapshot {
590    let (counts, rows) = (global::reading(), global::rows());
591    match (counts, rows) {
592        (Some(counts), Some(rows)) => heap::snapshot(counts, &rows, tags::overflowed()),
593        _ => HeapSnapshot::default(),
594    }
595}
596
597/// A session started by `start` with perf on: in a build that counts the
598/// heap, what the start allocates (the game's load) counts under the
599/// `load` phase, and each asset decode under its kind, as in a game's
600/// window. Use it where a session will be measured, so its snapshots say
601/// what the load holds.
602pub fn measured_start<T>(start: impl FnOnce() -> T) -> T {
603    let Some(scopes) = memory_scopes() else {
604        return start();
605    };
606    tag_decodes();
607    let before = scopes.enter(TagClass::Phase, "load");
608    let started = start();
609    scopes.leave(before);
610    started
611}
612
613/// This program's heap counter's memory tags (`shuyi_alloc_count::tags`),
614/// in a build that counts the heap (ADR-0046 §6).
615fn memory_scopes() -> Option<MemoryScopes> {
616    global::COUNTING.then(|| MemoryScopes::new(tags::tag, tags::enter, tags::leave))
617}
618
619/// In a build that counts the heap, tags what each asset decode allocates
620/// with its kind ([`TagClass::Asset`]). Set once for the program: another
621/// watch set first stays.
622fn tag_decodes() {
623    if global::COUNTING {
624        let _ = watch_decodes(DecodeWatch::new(decode_before, decode_after));
625    }
626}
627
628/// Enters the tag of the asset kind `kind`: the token is the tag before.
629fn decode_before(kind: &'static str) -> u32 {
630    u32::from(tags::enter(tags::tag(TagClass::Asset as u8, kind)))
631}
632
633/// Leaves the tag [`decode_before`] entered.
634fn decode_after(token: u32, _: &Decoded<'_>) {
635    tags::leave(u16::try_from(token).unwrap_or(tags::OTHER));
636}
637
638impl<G: GameData> Headless<G> {
639    /// A session of `harness`, with no scene and no events of the game's.
640    pub fn new(harness: Harness<G>) -> Self {
641        Self {
642            harness,
643            scene: Box::new(|h| {
644                Err(format!(
645                    "the game's tools give no scene (the top screen is `{}`)",
646                    h.top_screen()
647                ))
648            }),
649            events: Box::new(|_| Vec::new()),
650            picture: None,
651            perf_clock: false,
652            perf_memory: false,
653            perf_scopes: false,
654        }
655    }
656
657    /// This session, its scene from `scene`.
658    #[must_use]
659    pub fn with_scene(
660        mut self,
661        scene: impl Fn(&Harness<G>) -> Result<Scene, String> + 'static,
662    ) -> Self {
663        self.scene = Box::new(scene);
664        self
665    }
666
667    /// This session, the game's events from `events`.
668    #[must_use]
669    pub fn with_events(mut self, events: impl Fn(&Harness<G>) -> Vec<String> + 'static) -> Self {
670        self.events = Box::new(events);
671        self
672    }
673
674    /// This session, its frame painted by `picture` (most simply the
675    /// game's `frame-png` painter over the Harness's frame).
676    #[must_use]
677    pub fn with_picture(
678        mut self,
679        picture: impl Fn(&Harness<G>, Option<u32>) -> Result<Image, String> + 'static,
680    ) -> Self {
681        self.picture = Some(Box::new(picture));
682        self
683    }
684
685    /// The Harness it drives.
686    pub fn harness(&self) -> &Harness<G> {
687        &self.harness
688    }
689
690    /// Whether the game has loaded: a record stopped before its load, or a
691    /// load that failed, leaves none to read.
692    fn loaded(&self) -> bool {
693        self.harness.app().game().is_some()
694    }
695}
696
697/// The screens that closed and opened between stacks `before` and
698/// `after` (bottom first): those above what they share, the closed from
699/// the top down, then the opened from the bottom up.
700fn changes(before: &[&str], after: &[&str]) -> (Vec<String>, Vec<String>) {
701    let shared = before.iter().zip(after).take_while(|(a, b)| a == b).count();
702    let closed = before[shared..].iter().rev().map(|s| (*s).to_owned());
703    let opened = after[shared..].iter().map(|s| (*s).to_owned());
704    (closed.collect(), opened.collect())
705}
706
707/// A music command's name and cue.
708fn music(command: &MusicCommand) -> (&'static str, &str) {
709    match command {
710        MusicCommand::Load { cue } => ("load", cue),
711        MusicCommand::Start { cue, .. } => ("start", cue),
712        MusicCommand::Gain { cue, .. } => ("gain", cue),
713        MusicCommand::Stop { cue } => ("stop", cue),
714    }
715}
716
717impl<G: GameData> Session for Headless<G> {
718    fn play(&mut self, script: &Script) {
719        script.play(&mut self.harness);
720    }
721
722    fn idle(&mut self, frames: u32) {
723        for _ in 0..frames {
724            self.harness.frame(&[], FRAME_DT);
725        }
726    }
727
728    fn actions(&self) -> Vec<String> {
729        let game = self.harness.app().game();
730        game.map(|game| pressable(game.input().keymap()))
731            .unwrap_or_default()
732    }
733
734    fn play_record(&mut self) -> Option<Played> {
735        let mut played = self.harness.play_all().clone();
736        if let Some(errors) = self.harness.app().errors() {
737            played
738                .limits
739                .push(format!("the game didn't load: {errors}"));
740        } else if !self.loaded() {
741            played
742                .limits
743                .push("the record stopped before the game loaded".to_owned());
744        }
745        Some(played)
746    }
747
748    fn frame_count(&self) -> u32 {
749        self.harness.frame_count()
750    }
751
752    fn screens(&self) -> Vec<String> {
753        self.harness
754            .screens()
755            .into_iter()
756            .map(str::to_owned)
757            .collect()
758    }
759
760    fn quit_frame(&self) -> Option<u32> {
761        self.harness.quit_frame()
762    }
763
764    fn snapshot(&self) -> String {
765        if self.loaded() {
766            self.harness.snapshot()
767        } else {
768            String::new()
769        }
770    }
771
772    fn events(&self) -> Vec<Event> {
773        let mut timed: Vec<(u32, Event)> = Vec::new();
774        for pair in self.harness.screen_log().windows(2) {
775            let ((_, before), (frame, after)) = (&pair[0], &pair[1]);
776            let frame = *frame;
777            let (closed, opened) = changes(before, after);
778            timed.extend(
779                closed
780                    .into_iter()
781                    .map(|screen| (frame, Event::Closed { frame, screen })),
782            );
783            timed.extend(
784                opened
785                    .into_iter()
786                    .map(|screen| (frame, Event::Opened { frame, screen })),
787            );
788        }
789        for (frame, command) in self.harness.music_log() {
790            let (command, cue) = music(command);
791            timed.push((
792                frame,
793                Event::Music {
794                    frame,
795                    command: command.to_owned(),
796                    cue: cue.to_owned(),
797                },
798            ));
799        }
800        for (frame, request) in self.harness.audio_log() {
801            if let AudioRequest::PlayVoice { line, .. } = request {
802                let line = line.to_string();
803                timed.push((frame, Event::Voice { frame, line }));
804            }
805        }
806        if let Some(frame) = self.harness.quit_frame() {
807            timed.push((frame, Event::Quit { frame }));
808        }
809        // A stable sort: within a frame, screens, then music, voices and
810        // the quit.
811        timed.sort_by_key(|(frame, _)| *frame);
812        let game = if self.loaded() {
813            (self.events)(&self.harness)
814        } else {
815            Vec::new()
816        };
817        let game = game.into_iter().map(|text| Event::Game { text });
818        timed
819            .into_iter()
820            .map(|(_, event)| event)
821            .chain(game)
822            .collect()
823    }
824
825    fn sounds(&self) -> Vec<Sound> {
826        self.harness
827            .audio_log()
828            .into_iter()
829            .filter_map(|(frame, request)| match request {
830                AudioRequest::PlaySound { cue, .. } | AudioRequest::StartSound { cue, .. } => {
831                    Some(Sound {
832                        frame,
833                        cue: cue.clone(),
834                    })
835                }
836                _ => None,
837            })
838            .collect()
839    }
840
841    fn sounds_added(&self) -> Vec<AddedSound> {
842        let speaker = self.harness.platform().speaker();
843        speaker
844            .added_sounds()
845            .iter()
846            .map(|(cue, samples)| AddedSound {
847                cue: cue.clone(),
848                rate: samples.rate(),
849                channels: samples.channels(),
850                frames: samples.frames(),
851                millis: samples.millis(),
852            })
853            .collect()
854    }
855
856    fn scene(&self) -> Result<Scene, String> {
857        if !self.loaded() {
858            return Err("the game hasn't loaded".to_owned());
859        }
860        (self.scene)(&self.harness)
861    }
862
863    fn log(&self) -> Vec<LogEntry> {
864        self.harness.log().lines().map(LogEntry::from).collect()
865    }
866
867    fn log_dropped(&self) -> u32 {
868        self.harness.log().dropped()
869    }
870
871    fn modules(&self) -> Vec<ModuleEntry> {
872        let Some(game) = self.harness.app().game() else {
873            return Vec::new();
874        };
875        game.modules().iter().map(ModuleEntry::from).collect()
876    }
877
878    /// Gives the Harness this machine's clock, and in a build that counts
879    /// the heap this program's heap counter and its memory tags (and tags
880    /// the asset decodes), each unless its platform has one already (a
881    /// test's fake, kept so its records stay the same); `false` takes away
882    /// what it gave. Before the game has loaded it does nothing.
883    fn set_perf(&mut self, on: bool) {
884        if !self.loaded() {
885            return;
886        }
887        let platform = self.harness.platform();
888        let (clock, memory) = (platform.clock().is_none(), !platform.has_memory());
889        let scopes = platform.memory_scopes().is_none();
890        if on {
891            if !self.perf_clock && clock {
892                self.harness.set_clock(Some(perf_clock()));
893                self.perf_clock = true;
894            }
895            if !self.perf_memory && memory && global::COUNTING {
896                self.harness.set_memory(Some(process_heap));
897                self.harness.set_heap_snapshots(Some(process_snapshot));
898                self.perf_memory = true;
899            }
900            if !self.perf_scopes
901                && scopes
902                && let Some(scopes) = memory_scopes()
903            {
904                self.harness.set_scopes(Some(scopes));
905                tag_decodes();
906                self.perf_scopes = true;
907            }
908            return;
909        }
910        if core::mem::take(&mut self.perf_clock) {
911            self.harness.set_clock(None);
912        }
913        if core::mem::take(&mut self.perf_memory) {
914            self.harness.set_memory(None);
915            self.harness.set_heap_snapshots(None);
916        }
917        if core::mem::take(&mut self.perf_scopes) {
918            self.harness.set_scopes(None);
919        }
920    }
921
922    fn counts_process_heap(&self) -> bool {
923        self.perf_memory
924    }
925
926    fn perf(&self, since: u64, max: u32) -> PerfRead {
927        PerfRead::of(self.harness.perf(), since, max)
928    }
929
930    fn heap_snapshot(&mut self) -> Option<HeapSnapshot> {
931        self.harness.heap_snapshot()
932    }
933
934    fn picture(&self, scale: Option<u32>) -> Result<Image, String> {
935        if !self.loaded() {
936            return Err("the game hasn't loaded".to_owned());
937        }
938        match &self.picture {
939            Some(picture) => picture(&self.harness, scale),
940            None => Err(unsupported(
941                "a picture of a session's frame",
942                "session, with Headless::with_picture",
943            )),
944        }
945    }
946}
947
948#[cfg(test)]
949mod tests;