1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51#[serde(deny_unknown_fields)]
52#[non_exhaustive]
54pub struct Scene {
55 pub screen: String,
57 pub kind: String,
59 pub text: String,
62 pub data: serde_json::Value,
65 #[serde(skip)]
69 pub shape: Option<Schema>,
70}
71
72impl Scene {
73 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 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
100pub 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 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
149fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
165pub enum Event {
166 Opened {
168 frame: u32,
170 screen: String,
172 },
173 Closed {
175 frame: u32,
177 screen: String,
179 },
180 Music {
182 frame: u32,
184 command: String,
186 cue: String,
188 },
189 Voice {
191 frame: u32,
193 line: String,
195 },
196 Quit {
198 frame: u32,
200 },
201 Game {
204 text: String,
206 },
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
211#[serde(deny_unknown_fields)]
212pub struct Sound {
213 pub frame: u32,
215 pub cue: String,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224#[non_exhaustive]
226pub struct AddedSound {
227 pub cue: String,
229 pub rate: u32,
231 pub channels: u16,
233 pub frames: u64,
235 pub millis: u64,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
241#[serde(deny_unknown_fields)]
242#[non_exhaustive]
244pub struct LogEntry {
245 pub frame: u32,
248 pub level: String,
250 pub category: String,
253 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270#[serde(deny_unknown_fields)]
271#[non_exhaustive]
273pub struct ModuleEntry {
274 pub name: String,
276 pub hooks: Vec<String>,
279 pub states: Vec<String>,
281 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
299#[serde(deny_unknown_fields)]
300#[non_exhaustive]
303pub struct SessionInput {
304 pub file: String,
306 #[serde(default)]
310 pub any_stamp: bool,
311}
312
313impl SessionInput {
314 pub fn new(file: impl Into<String>) -> Self {
316 Self {
317 file: file.into(),
318 any_stamp: false,
319 }
320 }
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
325#[serde(deny_unknown_fields)]
326#[non_exhaustive]
328pub struct SessionPlayed {
329 pub file: String,
331 pub frames: u32,
333 pub of: u32,
335 pub first_difference: Option<SessionDifference>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342#[serde(deny_unknown_fields)]
343#[non_exhaustive]
345pub struct SessionDifference {
346 pub frame: u32,
348 pub recorded: String,
350 pub played: String,
352}
353
354impl SessionPlayed {
355 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
372pub(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
410pub trait Session {
412 fn play(&mut self, script: &Script);
414
415 fn idle(&mut self, frames: u32);
417
418 fn actions(&self) -> Vec<String> {
422 Vec::new()
423 }
424
425 fn play_record(&mut self) -> Option<Played> {
432 None
433 }
434
435 fn frame_count(&self) -> u32;
437
438 fn screens(&self) -> Vec<String>;
440
441 fn quit_frame(&self) -> Option<u32>;
443
444 fn snapshot(&self) -> String;
446
447 fn events(&self) -> Vec<Event>;
450
451 fn sounds(&self) -> Vec<Sound>;
453
454 fn sounds_added(&self) -> Vec<AddedSound> {
457 Vec::new()
458 }
459
460 fn scene(&self) -> Result<Scene, String>;
462
463 fn log(&self) -> Vec<LogEntry> {
465 Vec::new()
466 }
467
468 fn log_dropped(&self) -> u32 {
470 0
471 }
472
473 fn modules(&self) -> Vec<ModuleEntry> {
476 Vec::new()
477 }
478
479 fn set_perf(&mut self, on: bool) {
482 let _ = on;
483 }
484
485 fn counts_process_heap(&self) -> bool {
489 false
490 }
491
492 fn perf(&self, since: u64, max: u32) -> PerfRead {
495 let _ = max;
496 PerfRead::none(since)
497 }
498
499 fn heap_snapshot(&mut self) -> Option<HeapSnapshot> {
504 None
505 }
506
507 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
519type SceneOf<G> = Box<dyn Fn(&Harness<G>) -> Result<Scene, String>>;
521
522type PictureOf<G> = Box<dyn Fn(&Harness<G>, Option<u32>) -> Result<Image, String>>;
524
525type EventsOf<G> = Box<dyn Fn(&Harness<G>) -> Vec<String>>;
527
528pub struct Headless<G: GameData> {
530 harness: Harness<G>,
531 scene: SceneOf<G>,
532 events: EventsOf<G>,
533 picture: Option<PictureOf<G>>,
534 perf_clock: bool,
536 perf_memory: bool,
539 perf_scopes: bool,
542}
543
544static FAKE_CLOCK: AtomicBool = AtomicBool::new(false);
547
548pub(crate) fn measure_with_a_fake_clock() {
552 FAKE_CLOCK.store(true, Ordering::Relaxed);
553}
554
555fn perf_clock() -> PerfClock {
558 if FAKE_CLOCK.load(Ordering::Relaxed) {
559 PerfClock::fake()
560 } else {
561 PerfClock::Real(now_us)
562 }
563}
564
565fn 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#[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
586fn 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
597pub 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
613fn memory_scopes() -> Option<MemoryScopes> {
616 global::COUNTING.then(|| MemoryScopes::new(tags::tag, tags::enter, tags::leave))
617}
618
619fn tag_decodes() {
623 if global::COUNTING {
624 let _ = watch_decodes(DecodeWatch::new(decode_before, decode_after));
625 }
626}
627
628fn decode_before(kind: &'static str) -> u32 {
630 u32::from(tags::enter(tags::tag(TagClass::Asset as u8, kind)))
631}
632
633fn decode_after(token: u32, _: &Decoded<'_>) {
635 tags::leave(u16::try_from(token).unwrap_or(tags::OTHER));
636}
637
638impl<G: GameData> Headless<G> {
639 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 #[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 #[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 #[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 pub fn harness(&self) -> &Harness<G> {
687 &self.harness
688 }
689
690 fn loaded(&self) -> bool {
693 self.harness.app().game().is_some()
694 }
695}
696
697fn 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
707fn 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 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 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;