1use std::fmt::Write as _;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use shuyi_ui::log::LOG_LINES;
22use shuyi_ui::perf::{FrameRecord, HeapSnapshot, PERF_FRAMES};
23
24use super::perf::{self, PerfSummary};
25use super::session::{
26 self, AddedSound, Event, LogEntry, ModuleEntry, Scene, Session, SessionInput, SessionPlayed,
27 Sound, from_record, measured_start,
28};
29use super::{Answer, Op, codes};
30use crate::frame_png::{self, Script, Step};
31use crate::game::{GameArg, GameOption, GameTools, check_start, code_of, game_arg};
32
33pub const MAX_IDLE_FRAMES: u32 = 100_000;
35
36#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
38#[serde(deny_unknown_fields, default)]
39#[non_exhaustive]
42pub struct RunInput {
43 pub start: Vec<GameArg>,
46 pub script: Script,
48 pub frames: u32,
50 pub perf: bool,
52 pub perf_out: Option<String>,
55 pub session: Option<SessionInput>,
60 pub path: Option<String>,
63 pub shape: bool,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
70#[serde(deny_unknown_fields)]
71#[non_exhaustive]
73pub struct RunOutput {
74 pub frames: u32,
76 pub top_screen: String,
78 pub screens: Vec<String>,
80 pub quit: Option<u32>,
82 pub scene: Option<Scene>,
84 pub frame: String,
86 pub events: Vec<Event>,
88 pub sounds: Vec<Sound>,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub sounds_added: Vec<AddedSound>,
94 pub log: Vec<LogEntry>,
96 pub modules: Vec<ModuleEntry>,
99 pub perf: Option<PerfSummary>,
102 pub session: Option<SessionPlayed>,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109#[non_exhaustive]
111pub struct SceneOutput {
112 pub frames: u32,
114 pub top_screen: String,
116 pub scene: Option<Scene>,
119 pub log: Vec<LogEntry>,
121 pub session: Option<SessionPlayed>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub value: Option<serde_json::Value>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub shape: Option<serde_json::Value>,
130}
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
135pub struct SceneAsk<'a> {
136 pub path: Option<&'a str>,
138 pub shape: bool,
140}
141
142impl RunInput {
143 pub fn check(&self) -> Result<(), String> {
149 frame_png::check(&self.script)?;
150 if self.session.is_some() && !self.start.is_empty() {
151 return Err(
152 "session: a record starts as the game really starts, with no start options"
153 .to_owned(),
154 );
155 }
156 if self.frames > MAX_IDLE_FRAMES {
157 return Err(format!(
158 "frames: at most {MAX_IDLE_FRAMES}, not {}",
159 self.frames
160 ));
161 }
162 Ok(())
163 }
164
165 pub fn measures(&self) -> bool {
167 self.perf || self.perf_out.is_some()
168 }
169}
170
171struct Ran {
174 session: Box<dyn Session>,
175 first: Option<HeapSnapshot>,
176 record: Option<SessionPlayed>,
177 limits: Vec<String>,
179 differs: bool,
181}
182
183fn played<T>(
189 op: Op,
190 game: &dyn GameTools,
191 root: &Path,
192 input: &RunInput,
193) -> Result<Ran, Answer<T>> {
194 input
195 .check()
196 .map_err(|e| Answer::failed(op, codes::WRONG_ARGUMENTS, e))?;
197 if op == Op::Run && (input.path.is_some() || input.shape) {
198 let error = "path and shape are `scene`'s: `run` answers the whole scene";
199 return Err(Answer::failed(op, codes::WRONG_ARGUMENTS, error));
200 }
201 check_start(game.frame_options(), &input.start)
202 .map_err(|e| Answer::failed(op, codes::WRONG_ARGUMENTS, e))?;
203 let start = || match &input.session {
204 Some(record) => {
205 from_record(game, root, record).map(|(session, played)| (session, Some(played)))
206 }
207 None => game
208 .session(&input.start)
209 .map(|session| (session, None))
210 .map_err(|e| (code_of(&e), e)),
211 };
212 let started = if input.measures() {
213 measured_start(start)
214 } else {
215 start()
216 };
217 let (mut session, played) = started.map_err(|(code, e)| Answer::failed(op, code, e))?;
218 let differs = played
219 .as_ref()
220 .is_some_and(|played| played.first_difference.is_some());
221 let mut first = None;
222 if !differs {
223 check_actions(&*session, &input.script)
224 .map_err(|e| Answer::failed(op, codes::WRONG_ARGUMENTS, e))?;
225 if input.measures() {
226 session.set_perf(true);
227 first = session.heap_snapshot();
228 }
229 session.play(&input.script);
230 session.idle(input.frames);
231 }
232 let after = played.and_then(|_| session.play_record());
234 let (record, limits) = match (&input.session, after) {
235 (Some(file), Some(played)) => (Some(SessionPlayed::of(&file.file, &played)), played.limits),
236 _ => (None, Vec::new()),
237 };
238 Ok(Ran {
239 session,
240 first,
241 record,
242 limits,
243 differs,
244 })
245}
246
247pub fn check_actions(session: &dyn Session, script: &Script) -> Result<(), String> {
252 let mut actions = None;
253 for (i, step) in script.steps.iter().enumerate() {
254 let Step::Act(name) = step else {
255 continue;
256 };
257 let actions = actions.get_or_insert_with(|| session.actions());
258 if !actions.contains(name) {
259 let theirs = if actions.is_empty() {
260 "the game has none".to_owned()
261 } else {
262 format!("the game's are {}", actions.join(", "))
263 };
264 return Err(format!(
265 "step {}: act: no action `{name}` with a key or a button; {theirs}",
266 i + 1
267 ));
268 }
269 }
270 Ok(())
271}
272
273pub fn limits_of(
277 frames: u32,
278 quit: Option<u32>,
279 open: bool,
280 no_scene: Option<&str>,
281) -> Vec<String> {
282 let mut limits = Vec::new();
283 if let Some(quit) = quit {
284 let after = frames.saturating_sub(quit);
285 limits.push(if after == 0 {
286 format!("the game asked to quit on frame {quit}, the last")
287 } else {
288 format!(
289 "the game asked to quit on frame {quit}: the {after} frame(s) after it changed \
290 nothing"
291 )
292 });
293 }
294 if !open {
295 limits.push("no screen is open: the frame is the last one drawn".to_owned());
296 }
297 if let Some(why) = no_scene {
298 limits.push(format!("no scene: {why}"));
299 }
300 limits
301}
302
303fn limits(session: &dyn Session, scene: &Result<Scene, String>) -> Vec<String> {
306 let mut limits = limits_of(
307 session.frame_count(),
308 session.quit_frame(),
309 !session.screens().is_empty(),
310 scene.as_ref().err().map(String::as_str),
311 );
312 let dropped = session.log_dropped();
313 if dropped > 0 {
314 limits.push(format!(
315 "the log keeps the last {LOG_LINES} lines: {dropped} earlier line(s) were dropped"
316 ));
317 }
318 limits
319}
320
321pub fn run_output(session: &dyn Session) -> (RunOutput, Vec<String>) {
324 let scene = session.scene();
325 let limits = limits(session, &scene);
326 let screens = session.screens();
327 let output = RunOutput {
328 frames: session.frame_count(),
329 top_screen: screens.last().cloned().unwrap_or_default(),
330 screens,
331 quit: session.quit_frame(),
332 scene: scene.ok(),
333 frame: session.snapshot(),
334 events: session.events(),
335 sounds: session.sounds(),
336 sounds_added: session.sounds_added(),
337 log: session.log(),
338 modules: session.modules(),
339 perf: None,
340 session: None,
341 };
342 (output, limits)
343}
344
345type Snapshots = (HeapSnapshot, HeapSnapshot);
347
348fn perf_of(
352 session: &dyn Session,
353 heap: Option<&Snapshots>,
354) -> (Option<PerfSummary>, Vec<FrameRecord>, Vec<String>) {
355 let read = session.perf(0, u32::try_from(PERF_FRAMES).unwrap_or(u32::MAX));
356 let mut limits = vec![perf::TIMES.to_owned()];
357 if !read.measured {
358 limits.push(perf::unmeasured_limit(session.frame_count()));
359 return (None, Vec::new(), limits);
360 }
361 if read.dropped > 0 {
362 limits.push(perf::dropped_limit(read.dropped));
363 }
364 limits.extend(perf::heap_limits(
365 &read.records,
366 session.counts_process_heap(),
367 perf::other_threads(),
368 ));
369 if heap.is_none() && read.records.iter().any(|r| r.heap.is_some()) {
370 limits.push(perf::HEAP_UNTAGGED.to_owned());
371 }
372 let summary = perf::summary(&read.records).map(|mut summary| {
373 summary.dropped = read.dropped;
374 if let Some((first, last)) = heap {
375 perf::add_heap(&mut summary, first, last);
376 }
377 summary
378 });
379 (summary, read.records, limits)
380}
381
382pub fn perf_out_path(root: &Path, path: &str) -> PathBuf {
385 let path = root.join(path);
386 std::path::absolute(&path).unwrap_or(path)
387}
388
389fn write_trace(
392 path: &Path,
393 records: &[FrameRecord],
394 heap: Option<&Snapshots>,
395) -> std::io::Result<()> {
396 if let Some(dir) = path.parent() {
397 fs::create_dir_all(dir)?;
398 }
399 let mut trace = perf::lines(records);
400 if let Some((first, last)) = heap {
401 trace.push_str(&perf::heap_lines(first, last));
402 }
403 fs::write(path, trace)
404}
405
406pub fn scene_output(session: &dyn Session) -> (SceneOutput, Vec<String>) {
408 let scene = session.scene();
409 let limits = limits(session, &scene);
410 let screens = session.screens();
411 let output = SceneOutput {
412 frames: session.frame_count(),
413 top_screen: screens.last().cloned().unwrap_or_default(),
414 scene: scene.ok(),
415 log: session.log(),
416 session: None,
417 value: None,
418 shape: None,
419 };
420 (output, limits)
421}
422
423pub fn ask_scene(
428 output: &mut SceneOutput,
429 limits: &mut Vec<String>,
430 ask: SceneAsk<'_>,
431) -> Result<(), (codes::Code, String)> {
432 if ask.path.is_none() && !ask.shape {
433 return Ok(());
434 }
435 let Some(scene) = output.scene.take() else {
436 return Ok(());
437 };
438 if let Some(path) = ask.path {
439 let value = session::at_pointer(&scene.data, path).map_err(|e| (codes::NO_VALUE, e))?;
440 output.value = Some(value.clone());
441 }
442 if ask.shape {
443 match scene.shape {
444 Some(shape) => output.shape = Some(shape.to_value()),
445 None => limits.push(NO_SHAPE.to_owned()),
446 }
447 }
448 Ok(())
449}
450
451pub const NO_SHAPE: &str = "no shape: the game built its scene's data with no `Scene::with_data`";
453
454pub fn scene_answer(
458 op: Op,
459 session: &dyn Session,
460 ask: SceneAsk<'_>,
461 record: Option<SessionPlayed>,
462 differs: bool,
463) -> Answer<SceneOutput> {
464 let (mut output, mut limits) = scene_output(session);
465 output.session = record;
466 let ok = output.scene.is_some() && !differs;
467 if let Err((code, error)) = ask_scene(&mut output, &mut limits, ask) {
468 return Answer::failed(op, code, error);
469 }
470 Answer::new(op, ok, output, limits)
471}
472
473pub fn run(game: &dyn GameTools, input: &RunInput) -> Answer<RunOutput> {
476 run_at(game, Path::new("."), input)
477}
478
479pub fn run_at(game: &dyn GameTools, root: &Path, input: &RunInput) -> Answer<RunOutput> {
482 let Ran {
483 mut session,
484 first,
485 record,
486 limits: record_limits,
487 differs,
488 } = match played(Op::Run, game, root, input) {
489 Ok(played) => played,
490 Err(answer) => return answer,
491 };
492 let heap = first.zip(session.heap_snapshot());
494 let (mut output, mut limits) = run_output(&*session);
495 output.session = record;
496 limits.extend(record_limits);
497 if input.measures() {
498 let (mut summary, records, perf_limits) = perf_of(&*session, heap.as_ref());
499 limits.extend(perf_limits);
500 if let (Some(file), Some(summary)) = (&input.perf_out, &mut summary) {
501 let path = perf_out_path(root, file);
502 if let Err(e) = write_trace(&path, &records, heap.as_ref()) {
503 return Answer::failed(Op::Run, codes::FILE, format!("--perf-out {file}: {e}"));
504 }
505 summary.file = Some(path.display().to_string());
506 }
507 output.perf = summary;
508 }
509 Answer::new(Op::Run, !differs, output, limits)
510}
511
512pub fn scene(game: &dyn GameTools, input: &RunInput) -> Answer<SceneOutput> {
515 scene_at(game, Path::new("."), input)
516}
517
518pub fn scene_at(game: &dyn GameTools, root: &Path, input: &RunInput) -> Answer<SceneOutput> {
522 match played(Op::Scene, game, root, input) {
523 Ok(ran) => {
524 let ask = SceneAsk {
525 path: input.path.as_deref(),
526 shape: input.shape,
527 };
528 let mut answer = scene_answer(Op::Scene, &*ran.session, ask, ran.record, ran.differs);
529 answer.limits.extend(ran.limits);
530 answer
531 }
532 Err(answer) => answer,
533 }
534}
535
536pub fn usage(command: &str, op: Op, options: &[GameOption]) -> String {
539 let mut out = format!(
540 "usage: {command} {} [steps] [--frames N] [options] [--json]\n\n\
541 steps (each may repeat, played in order):\n \
542 --script \"Enter Up\" press and release each key chord in turn (or --keys)\n \
543 --pad \"South\" press and release each controller button in turn\n \
544 --act Confirm press the game's action by its key, or else its button\n \
545 --wait 0.5 let that many seconds pass (60 frames a second, rounded up)\n\n\
546 --frames N then run N frames (1/60 s each) with no input\n\n\
547 --session <file> start from a session record, from the project's folder,\n \
548 played to its end or the first frame that differs; no start options\n\
549 --any-stamp play a record another build or other content made too\n",
550 op.name()
551 );
552 if op == Op::Scene {
553 out.push_str(
554 "\n--path <pointer> answer only the value at that JSON Pointer in the scene's\n \
555 data (`/hero/x`)\n\
556 --shape answer the JSON Schema of the scene's data\n",
557 );
558 }
559 if op == Op::Run {
560 out.push_str(
561 "\n--perf measure each frame, and answer a summary of the times\n\
562 --perf-out <file> also write each kept frame as a JSON line, from the\n \
563 project's folder (implies --perf)\n\
564 --watch alone: the game in its window instead, its files on\n \
565 disk reloading as they change (a debug build)\n",
566 );
567 }
568 if !options.is_empty() {
569 out.push_str("\nthe game's options:\n");
570 for option in options {
571 let name = match option.value {
572 Some(value) => format!("{} {value}", option.flag),
573 None => option.flag.to_owned(),
574 };
575 let help = option.help.lines().next().unwrap_or_default();
576 let _ = writeln!(out, " {name:<20} {help}");
578 }
579 }
580 out
581}
582
583pub fn parse_args(args: &[String], options: &[GameOption]) -> Result<RunInput, String> {
586 let mut input = RunInput::default();
587 let mut any_stamp = false;
588 let mut iter = args.iter();
589 while let Some(arg) = iter.next() {
590 let mut value = || {
591 iter.next()
592 .ok_or_else(|| format!("{arg} needs a value"))
593 .cloned()
594 };
595 if matches!(
596 arg.as_str(),
597 "--script" | "--keys" | "--pad" | "--act" | "--wait"
598 ) {
599 let text = value()?;
600 if let Some(step) = frame_png::parse_step(arg, &text) {
601 input.script.steps.push(step?);
602 }
603 continue;
604 }
605 if arg == "--session" {
606 input.session = Some(SessionInput::new(value()?));
607 continue;
608 }
609 if arg == "--any-stamp" {
610 any_stamp = true;
611 continue;
612 }
613 if arg == "--perf" {
614 input.perf = true;
615 continue;
616 }
617 if arg == "--path" {
618 input.path = Some(value()?);
619 continue;
620 }
621 if arg == "--shape" {
622 input.shape = true;
623 continue;
624 }
625 if arg == "--perf-out" {
626 input.perf_out = Some(value()?);
627 input.perf = true;
628 continue;
629 }
630 if arg == "--frames" {
631 let text = value()?;
632 input.frames = text
633 .parse()
634 .ok()
635 .filter(|n| *n <= MAX_IDLE_FRAMES)
636 .ok_or_else(|| {
637 format!("--frames: a whole number 0 to {MAX_IDLE_FRAMES}, not {text}")
638 })?;
639 continue;
640 }
641 input.start.push(game_arg(arg, options, value)?);
642 }
643 match &mut input.session {
644 Some(session) => session.any_stamp = any_stamp,
645 None if any_stamp => return Err("--any-stamp needs --session".to_owned()),
646 None => {}
647 }
648 Ok(input)
649}
650
651fn event_text(event: &Event) -> String {
653 match event {
654 Event::Opened { frame, screen } => format!("frame {frame}: opened {screen}"),
655 Event::Closed { frame, screen } => format!("frame {frame}: closed {screen}"),
656 Event::Music {
657 frame,
658 command,
659 cue,
660 } => format!("frame {frame}: music {command} {cue}"),
661 Event::Voice { frame, line } => format!("frame {frame}: voice {line}"),
662 Event::Quit { frame } => format!("frame {frame}: quit"),
663 Event::Game { text } => format!("game: {text}"),
664 }
665}
666
667fn log_text(out: &mut String, log: &[LogEntry]) {
669 let serious = |entry: &&LogEntry| matches!(entry.level.as_str(), "warn" | "error");
670 if !log.iter().any(|entry| serious(&entry)) {
671 return;
672 }
673 out.push_str("log (warnings and errors; every line with --json):\n");
674 for entry in log.iter().filter(serious) {
675 let _ = writeln!(
676 out,
677 " frame {}: {} {}: {}",
678 entry.frame, entry.level, entry.category, entry.text
679 );
680 }
681}
682
683fn scene_text(out: &mut String, scene: Option<&Scene>) {
685 if let Some(scene) = scene {
686 let _ = writeln!(out, "scene ({}, on {}):", scene.kind, scene.screen);
687 out.push_str(&scene.text);
688 if !scene.text.ends_with('\n') {
689 out.push('\n');
690 }
691 if !scene.data.is_null() {
692 let _ = writeln!(out, "{}", scene.data);
693 }
694 }
695}
696
697fn session_text(out: &mut String, session: Option<&SessionPlayed>) {
699 let Some(session) = session else {
700 return;
701 };
702 let _ = writeln!(
703 out,
704 "session {}: played {} of {} frame(s)",
705 session.file, session.frames, session.of
706 );
707 if let Some(difference) = &session.first_difference {
708 let _ = writeln!(
709 out,
710 "it first differs at frame {}: the record says {}, this build {}",
711 difference.frame, difference.recorded, difference.played
712 );
713 }
714}
715
716pub fn text(output: &RunOutput) -> String {
718 let mut out = format!(
719 "run: {} frame(s); top screen: {} ({})\n",
720 output.frames,
721 if output.top_screen.is_empty() {
722 "none"
723 } else {
724 &output.top_screen
725 },
726 output.screens.join(" > ")
727 );
728 session_text(&mut out, output.session.as_ref());
729 if !output.events.is_empty() {
730 out.push_str("events:\n");
731 for event in &output.events {
732 let _ = writeln!(out, " {}", event_text(event));
733 }
734 }
735 log_text(&mut out, &output.log);
736 if !output.modules.is_empty() {
737 let modules: Vec<String> = output
738 .modules
739 .iter()
740 .map(|m| format!("{} ({})", m.name, m.hooks.join(", ")))
741 .collect();
742 let _ = writeln!(out, "modules: {}", modules.join(", "));
743 }
744 if !output.sounds.is_empty() {
745 let sounds: Vec<String> = output
746 .sounds
747 .iter()
748 .map(|s| format!("{} (frame {})", s.cue, s.frame))
749 .collect();
750 let _ = writeln!(out, "sounds: {}", sounds.join(", "));
751 }
752 if !output.sounds_added.is_empty() {
753 let added: Vec<String> = output
754 .sounds_added
755 .iter()
756 .map(|s| {
757 format!(
758 "{} ({} samples a second, {} channel(s), {} frames, {} milliseconds)",
759 s.cue, s.rate, s.channels, s.frames, s.millis
760 )
761 })
762 .collect();
763 let _ = writeln!(out, "sounds added: {}", added.join(", "));
764 }
765 if let Some(summary) = &output.perf {
766 out.push_str(&perf::text(summary));
767 }
768 scene_text(&mut out, output.scene.as_ref());
769 let glyphs = output
771 .frame
772 .split_once("--- ")
773 .map_or(output.frame.as_str(), |(glyphs, _)| glyphs);
774 out.push_str("frame (its colours with --json):\n");
775 out.push_str(glyphs);
776 if !glyphs.ends_with('\n') {
777 out.push('\n');
778 }
779 out
780}
781
782pub fn scene_output_text(output: &SceneOutput) -> String {
784 let mut out = format!(
785 "scene: after {} frame(s), top screen {}\n",
786 output.frames, output.top_screen
787 );
788 session_text(&mut out, output.session.as_ref());
789 log_text(&mut out, &output.log);
790 scene_text(&mut out, output.scene.as_ref());
791 if let Some(value) = &output.value {
792 let _ = writeln!(out, "value: {value}");
793 }
794 if let Some(shape) = &output.shape {
795 let pretty = serde_json::to_string_pretty(shape).unwrap_or_default();
796 let _ = writeln!(out, "shape:\n{pretty}");
797 }
798 out
799}
800
801pub fn keys(script: &str) -> Script {
803 Script::new(vec![Step::Keys(script.to_owned())])
804}
805
806#[cfg(test)]
807mod tests;