1use std::fmt::Write as _;
23use std::fs;
24use std::io::{BufRead, BufReader, ErrorKind, Write};
25use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs};
26use std::path::Path;
27use std::time::Duration;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use shuyi_platform::link::{
32 self, Answer as Line, LogAnswer, PerfAnswer, Phase, Played, Request, Status,
33};
34use shuyi_platform::script::wait_frames;
35use shuyi_ui::log::LOG_LINES;
36use shuyi_ui::perf::{FrameRecord, HeapSnapshot};
37
38use super::codes::{self, Code};
39use super::perf::{self, PerfSummary};
40use super::run::MAX_IDLE_FRAMES;
41use super::{Answer, Op};
42use crate::frame_png::{self, Step};
43
44const CONNECT: Duration = Duration::from_secs(3);
46
47const ANSWER: Duration = Duration::from_secs(10);
49
50#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
52#[serde(deny_unknown_fields, default)]
53#[non_exhaustive]
56pub struct AttachInput {
57 pub address: String,
59 pub token: String,
61 pub steps: Vec<Step>,
64 pub frames: u32,
66 pub screenshot: Option<String>,
69 pub log_since: u64,
72 pub perf_since: Option<u64>,
76 pub perf_follow: Option<String>,
80 #[schemars(range(min = 1, max = 3600))]
82 pub follow_for: Option<u32>,
83 pub perf_snapshot: bool,
87}
88
89pub const FOLLOW_FOR: u32 = 10;
91
92pub const MAX_FOLLOW_FOR: u32 = 3_600;
94
95pub const FOLLOW_EVERY: Duration = Duration::from_millis(100);
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100#[serde(deny_unknown_fields)]
101#[non_exhaustive]
103pub struct AttachFollow {
104 pub file: String,
106 pub lines: u32,
109 pub dropped: u64,
112 pub closed: bool,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
118#[serde(deny_unknown_fields)]
119#[non_exhaustive]
121pub struct AttachScreenshot {
122 pub path: String,
124 pub width: u32,
126 pub height: u32,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132#[serde(deny_unknown_fields)]
133#[non_exhaustive]
135pub struct AttachLogEntry {
136 pub seq: u64,
139 pub frame: u32,
141 pub level: String,
143 pub category: String,
146 pub text: String,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
152#[serde(deny_unknown_fields)]
153#[non_exhaustive]
155pub struct AttachOutput {
156 pub link: u32,
158 pub game: String,
160 pub frames: u32,
162 pub phase: String,
165 pub top_screen: String,
167 pub screens: Vec<String>,
169 pub suspended: bool,
171 pub focused: bool,
173 pub quit: bool,
175 pub frame: String,
178 pub screenshot: Option<AttachScreenshot>,
180 pub log: Vec<AttachLogEntry>,
182 pub log_next: u64,
184 pub perf: Vec<FrameRecord>,
188 pub perf_next: Option<u64>,
191 pub perf_summary: Option<PerfSummary>,
194 pub perf_snapshot: Option<HeapSnapshot>,
198 pub follow: Option<AttachFollow>,
200}
201
202impl AttachInput {
203 pub fn check(&self) -> Result<(), String> {
206 if self.address.is_empty() {
207 return Err("the build's address is missing: host:port".to_owned());
208 }
209 if self.token.is_empty() {
210 return Err("--token is missing: the build's log line gives it".to_owned());
211 }
212 if self.frames > MAX_IDLE_FRAMES {
213 return Err(format!(
214 "frames: at most {MAX_IDLE_FRAMES}, not {}",
215 self.frames
216 ));
217 }
218 link::check_play(&self.steps)?;
219 if let Some(seconds) = self.follow_for {
220 if self.perf_follow.is_none() {
221 return Err("follow_for: only with perf_follow".to_owned());
222 }
223 if !(1..=MAX_FOLLOW_FOR).contains(&seconds) {
224 return Err(format!(
225 "follow_for: 1 to {MAX_FOLLOW_FOR} seconds, not {seconds}"
226 ));
227 }
228 }
229 if self.perf_follow.as_deref().is_some_and(str::is_empty) {
230 return Err("perf_follow: the file is missing".to_owned());
231 }
232 if self.perf_snapshot && self.perf_since.is_none() && self.perf_follow.is_none() {
233 return Err("perf_snapshot: only with perf_since or perf_follow".to_owned());
234 }
235 Ok(())
236 }
237
238 pub fn link_steps(&self) -> Vec<Step> {
241 let mut steps = self.steps.clone();
242 steps.extend(idle(self.frames));
243 steps
244 }
245
246 fn waits(&self) -> bool {
248 self.frames > 0 || self.steps.iter().any(|s| matches!(s, Step::Wait(_)))
249 }
250}
251
252pub fn idle(frames: u32) -> Vec<Step> {
256 let (seconds, left) = (frames / 60, frames % 60);
257 let mut steps = Vec::new();
258 #[allow(clippy::cast_precision_loss)]
261 let (seconds, left) = (seconds as f32, left as f32);
262 if seconds > 0.0 {
263 steps.push(Step::Wait(seconds));
264 }
265 if left > 0.0 {
266 steps.push(Step::Wait(left / 60.0));
267 }
268 steps
269}
270
271pub fn usage(command: &str) -> String {
273 format!(
274 "usage: {command} attach <host:port> --token <token> [steps] [--frames N] \
275 [--screenshot <file.png>] [--log-since N] [--perf-since N] \
276 [--perf-follow <file.jsonl> [--for S]] [--perf-snapshot] [--json]\n\n\
277 a running debug build of a game with its `link` feature, started with SHUYI_LINK=<port>\n\
278 (0 takes a free one); its log line `shuyi link: listening on <address>, token <token>`\n\
279 gives both.\n\n\
280 steps (each may repeat, played in order, as a player's input):\n \
281 --keys \"Enter Up\" press and release each key chord in turn (or --script)\n \
282 --pad \"South\" press and release each controller button in turn\n \
283 --act Confirm press the game's action by its key, or else its button\n \
284 --wait 0.5 let that many seconds pass, in the build's frames (60 a second)\n\n\
285 --frames N then let N frames pass\n \
286 --screenshot <file> write the window's picture there, as a PNG\n \
287 --log-since N the log's lines from number N (the last answer's log_next)\n \
288 --perf-since N the frames' records from number N (the last answer's perf_next)\n \
289 --perf-follow <file> then write each new frame's record to the file as a JSON line\n \
290 as the build runs, printing nothing until it ends\n \
291 --for S follow for S seconds, 1 to 3600 (10), or until the build quits\n \
292 --perf-snapshot a heap snapshot with the records (following: at its start and end)\n"
293 )
294}
295
296pub fn parse_args(args: &[String]) -> Result<AttachInput, String> {
298 let mut input = AttachInput::default();
299 let mut iter = args.iter();
300 while let Some(arg) = iter.next() {
301 let mut value = || {
302 iter.next()
303 .ok_or_else(|| format!("{arg} needs a value"))
304 .cloned()
305 };
306 if matches!(
307 arg.as_str(),
308 "--keys" | "--script" | "--pad" | "--act" | "--wait" | "--crank"
309 ) {
310 let text = value()?;
311 if let Some(step) = frame_png::parse_step(arg, &text) {
312 input.steps.push(step?);
313 }
314 continue;
315 }
316 match arg.as_str() {
317 "--token" => input.token = value()?,
318 "--screenshot" => input.screenshot = Some(value()?),
319 "--frames" => {
320 let text = value()?;
321 input.frames = text
322 .parse()
323 .ok()
324 .filter(|n| *n <= MAX_IDLE_FRAMES)
325 .ok_or_else(|| {
326 format!("--frames: a whole number 0 to {MAX_IDLE_FRAMES}, not {text}")
327 })?;
328 }
329 "--log-since" => {
330 let text = value()?;
331 input.log_since = text
332 .parse()
333 .map_err(|_| format!("--log-since: a whole number, not {text}"))?;
334 }
335 "--perf-since" => {
336 let text = value()?;
337 input.perf_since = Some(
338 text.parse()
339 .map_err(|_| format!("--perf-since: a whole number, not {text}"))?,
340 );
341 }
342 "--perf-follow" => input.perf_follow = Some(value()?),
343 "--for" => {
344 let text = value()?;
345 input.follow_for = Some(
346 text.parse()
347 .ok()
348 .filter(|n| (1..=MAX_FOLLOW_FOR).contains(n))
349 .ok_or_else(|| {
350 format!("--for: whole seconds, 1 to {MAX_FOLLOW_FOR}, not {text}")
351 })?,
352 );
353 }
354 "--perf-snapshot" => input.perf_snapshot = true,
355 flag if flag.starts_with("--") => return Err(format!("unknown option: {flag}")),
356 address if input.address.is_empty() => address.clone_into(&mut input.address),
357 other => return Err(format!("one address, not also {other}")),
358 }
359 }
360 if input.address.is_empty() {
361 return Err("the build's address is missing: host:port".to_owned());
362 }
363 if input.token.is_empty() {
364 return Err("--token is missing: the build's log line gives it".to_owned());
365 }
366 if input.follow_for.is_some() && input.perf_follow.is_none() {
367 return Err("--for: only with --perf-follow".to_owned());
368 }
369 if input.perf_snapshot && input.perf_since.is_none() && input.perf_follow.is_none() {
370 return Err("--perf-snapshot: only with --perf-since or --perf-follow".to_owned());
371 }
372 link::check_play(&input.steps)?;
373 Ok(input)
374}
375
376pub(crate) type Failed = (Code, String);
378
379pub(crate) struct Connection {
382 reader: BufReader<TcpStream>,
383 writer: TcpStream,
384 address: String,
385}
386
387impl Connection {
388 pub(crate) fn open(address: &str) -> Result<Self, Failed> {
390 let no_build = |why: String| {
391 let error = format!("no build is listening at {address}: {why}");
392 (codes::NO_BUILD, error)
393 };
394 let addresses: Vec<SocketAddr> = address
395 .to_socket_addrs()
396 .map_err(|e| (codes::WRONG_ARGUMENTS, format!("{address}: {e}")))?
397 .collect();
398 let mut last = format!("{address} names no address");
399 for socket in addresses {
400 match TcpStream::connect_timeout(&socket, CONNECT) {
401 Ok(stream) => {
402 let _ = stream.set_nodelay(true);
403 let writer = stream.try_clone().map_err(|e| no_build(e.to_string()))?;
404 return Ok(Self {
405 reader: BufReader::new(stream),
406 writer,
407 address: address.to_owned(),
408 });
409 }
410 Err(e) => last = e.to_string(),
411 }
412 }
413 Err(no_build(last))
414 }
415
416 fn ask(&mut self, request: &Request, wait: Duration) -> Result<Option<Line>, Failed> {
419 let lost = |why: String| (codes::LINK_LOST, why);
420 let line = format!("{}\n", request.to_line());
421 if let Err(e) = self.writer.write_all(line.as_bytes()) {
422 return if closed(e.kind()) {
423 Ok(None)
424 } else {
425 Err(lost(format!("the link to {} broke: {e}", self.address)))
426 };
427 }
428 let _ = self.reader.get_ref().set_read_timeout(Some(wait));
429 let mut answer = String::new();
430 match self.reader.read_line(&mut answer) {
431 Ok(0) => Ok(None),
432 Ok(_) => Line::from_line(&answer).map(Some).map_err(|e| {
433 lost(format!(
434 "the build at {} answered what this shuyi can't read: {e}",
435 self.address
436 ))
437 }),
438 Err(e) if closed(e.kind()) => Ok(None),
439 Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
440 Err(lost(format!(
441 "the build at {} didn't answer in {} s",
442 self.address,
443 wait.as_secs()
444 )))
445 }
446 Err(e) => Err(lost(format!("the link to {} broke: {e}", self.address))),
447 }
448 }
449
450 pub(crate) fn answer(&mut self, request: &Request, wait: Duration) -> Result<Line, Failed> {
452 self.ask(request, wait)?.ok_or_else(|| {
453 let error = format!("the build at {} closed the link", self.address);
454 (codes::LINK_LOST, error)
455 })
456 }
457}
458
459impl Drop for Connection {
460 fn drop(&mut self) {
464 let _ = self.writer.shutdown(Shutdown::Both);
465 }
466}
467
468fn closed(kind: ErrorKind) -> bool {
470 matches!(
471 kind,
472 ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted | ErrorKind::BrokenPipe
473 )
474}
475
476fn unexpected(asked: &str, line: &Line) -> Failed {
478 let error = match line {
479 Line::Error { error } => format!("the build couldn't answer {asked}: {error}"),
480 other => format!("the build answered {asked} with {}", other.to_line()),
481 };
482 (codes::LINK_LOST, error)
483}
484
485pub(crate) struct Seen {
487 pub(crate) played: Option<Played>,
489 pub(crate) status: Status,
491 pub(crate) frame: String,
493 pub(crate) log: LogAnswer,
495 pub(crate) screenshot: Option<(AttachScreenshot, Option<String>)>,
497 pub(crate) perf: Option<PerfSeen>,
499}
500
501pub(crate) struct PerfSeen {
503 records: Vec<FrameRecord>,
505 next: u64,
507 summary: Option<PerfSummary>,
509 snapshot: Option<HeapSnapshot>,
511 follow: Option<AttachFollow>,
513 limits: Vec<String>,
515}
516
517const READ_MAX: u32 = 600;
519
520pub(crate) fn perf_read(
524 link: &mut Connection,
525 since: u64,
526 max: u32,
527 snapshot: bool,
528) -> Result<Option<PerfAnswer>, Failed> {
529 let request = Request::Perf {
530 since,
531 max,
532 snapshot,
533 };
534 match link.ask(&request, ANSWER)? {
535 None => Ok(None),
536 Some(Line::Perf(answer)) => Ok(Some(answer)),
537 Some(other) => Err(unexpected("its frames' records", &other)),
538 }
539}
540
541pub(crate) fn perf_limits(records: &[FrameRecord], measured: bool, frames: u32) -> Vec<String> {
544 let mut limits = vec![perf::TIMES.to_owned()];
545 if !measured {
546 limits.push(perf::unmeasured_limit(frames));
547 }
548 limits.extend(perf::heap_limits(records, false, false));
550 limits
551}
552
553fn perf_once(link: &mut Connection, input: &AttachInput, since: u64) -> Result<PerfSeen, Failed> {
555 let Some(answer) = perf_read(link, since, READ_MAX, input.perf_snapshot)? else {
556 let error = format!("the build at {} closed the link", link.address);
557 return Err((codes::LINK_LOST, error));
558 };
559 let mut limits = perf_limits(&answer.records, answer.measured, answer.frames);
560 if since > answer.next {
561 limits.push(perf::RESET.to_owned());
562 }
563 if answer.dropped > 0 {
564 limits.push(perf::dropped_limit(answer.dropped));
565 }
566 if input.perf_snapshot && answer.snapshot.is_none() {
567 limits.push(perf::HEAP_UNTAGGED.to_owned());
568 }
569 let summary = perf::summary(&answer.records).map(|mut summary| {
570 summary.dropped = answer.dropped;
571 summary
572 });
573 Ok(PerfSeen {
574 records: answer.records,
575 next: answer.next,
576 summary,
577 snapshot: answer.snapshot,
578 follow: None,
579 limits,
580 })
581}
582
583pub(crate) trait Clock {
585 fn now(&mut self) -> Duration;
587 fn sleep(&mut self, time: Duration);
589}
590
591struct Wall(std::time::Instant);
593
594impl Clock for Wall {
595 fn now(&mut self) -> Duration {
596 self.0.elapsed()
597 }
598
599 fn sleep(&mut self, time: Duration) {
600 std::thread::sleep(time);
601 }
602}
603
604#[derive(Debug, Default)]
606pub(crate) struct Followed {
607 pub(crate) records: Vec<FrameRecord>,
609 pub(crate) next: u64,
611 pub(crate) lines: u32,
613 pub(crate) dropped: u64,
615 pub(crate) closed: bool,
617 pub(crate) measured: bool,
619 pub(crate) frames: u32,
621 pub(crate) snapshots: (Option<HeapSnapshot>, Option<HeapSnapshot>),
623}
624
625pub(crate) fn follow(
634 read: &mut impl FnMut(u64, bool) -> Result<Option<PerfAnswer>, Failed>,
635 since: u64,
636 seconds: u32,
637 snapshot: bool,
638 out: &mut impl Write,
639 clock: &mut impl Clock,
640) -> Result<Followed, Failed> {
641 let written = |e: std::io::Error| (codes::FILE, format!("--perf-follow: {e}"));
642 let end = clock.now() + Duration::from_secs(u64::from(seconds));
643 let mut followed = Followed {
644 next: since,
645 ..Followed::default()
646 };
647 let mut first = true;
648 loop {
649 let at = clock.now();
650 let last = at >= end;
651 let snap = snapshot && (first || last);
652 let Some(answer) = read(followed.next, snap)? else {
653 followed.closed = true;
654 break;
655 };
656 if snap && first {
657 followed.snapshots.0.clone_from(&answer.snapshot);
658 } else if snap {
659 followed.snapshots.1.clone_from(&answer.snapshot);
660 }
661 out.write_all(perf::lines(&answer.records).as_bytes())
662 .and_then(|()| out.flush())
663 .map_err(written)?;
664 let full = answer.records.len() >= usize::try_from(READ_MAX).unwrap_or(usize::MAX);
665 followed.lines = followed
666 .lines
667 .saturating_add(u32::try_from(answer.records.len()).unwrap_or(u32::MAX));
668 followed.dropped += answer.dropped;
669 followed.measured |= answer.measured;
670 followed.frames = answer.frames;
671 followed.next = answer.next;
672 followed.records.extend(answer.records);
673 first = false;
674 if last {
675 break;
676 }
677 if !full {
678 let now = clock.now();
679 let due = FOLLOW_EVERY.saturating_sub(now.saturating_sub(at));
680 clock.sleep(due.min(end.saturating_sub(now)));
681 }
682 }
683 if let (Some(first), Some(last)) = &followed.snapshots {
684 out.write_all(perf::heap_lines(first, last).as_bytes())
685 .and_then(|()| out.flush())
686 .map_err(written)?;
687 followed.lines = followed.lines.saturating_add(3);
688 }
689 Ok(followed)
690}
691
692fn perf_follow(
695 link: &mut Connection,
696 input: &AttachInput,
697 path: &str,
698 cwd: &Path,
699) -> Result<PerfSeen, Failed> {
700 let since = match input.perf_since {
701 Some(since) => since,
702 None => perf_read(link, 0, READ_MAX, false)?.map_or(0, |answer| answer.next),
703 };
704 let file = cwd.join(path);
705 let mut out =
706 fs::File::create(&file).map_err(|e| (codes::FILE, format!("--perf-follow {path}: {e}")))?;
707 let seconds = input.follow_for.unwrap_or(FOLLOW_FOR);
708 let mut read = |since, snapshot| perf_read(link, since, READ_MAX, snapshot);
709 let mut clock = Wall(std::time::Instant::now());
710 let followed = follow(
711 &mut read,
712 since,
713 seconds,
714 input.perf_snapshot,
715 &mut out,
716 &mut clock,
717 )?;
718 Ok(followed_seen(followed, input, &file.display().to_string()))
719}
720
721pub(crate) fn followed_seen(followed: Followed, input: &AttachInput, file: &str) -> PerfSeen {
723 let mut limits = perf_limits(&followed.records, followed.measured, followed.frames);
724 if followed.dropped > 0 {
725 limits.push(format!(
726 "{} frame(s) were dropped between reads: the build ran more than {READ_MAX} frames \
727 between two",
728 followed.dropped
729 ));
730 }
731 if followed.closed {
732 limits.push("the build closed the link before the time was up: it quit".to_owned());
733 }
734 let (first, last) = followed.snapshots;
735 if input.perf_snapshot && !followed.closed && (first.is_none() || last.is_none()) {
736 limits.push(perf::HEAP_UNTAGGED.to_owned());
737 }
738 let summary = perf::summary(&followed.records).map(|mut summary| {
739 summary.dropped = followed.dropped;
740 summary.file = Some(file.to_owned());
741 if let (Some(first), Some(last)) = (&first, &last) {
742 perf::add_heap(&mut summary, first, last);
743 }
744 summary
745 });
746 PerfSeen {
747 records: Vec::new(),
748 next: followed.next,
749 summary,
750 snapshot: last,
751 follow: Some(AttachFollow {
752 file: file.to_owned(),
753 lines: followed.lines,
754 dropped: followed.dropped,
755 closed: followed.closed,
756 }),
757 limits,
758 }
759}
760
761pub fn attach(input: &AttachInput, cwd: &Path) -> Answer<AttachOutput> {
764 if let Err(e) = input.check() {
765 return Answer::failed(Op::Attach, codes::WRONG_ARGUMENTS, e);
766 }
767 let seen = |link: &mut Connection| {
768 let game = hello(link, &input.token)?;
769 let mut seen = played(link, input)?;
770 if let Some(path) = &input.screenshot {
771 seen.screenshot = Some(screenshot(link, path, cwd)?);
772 }
773 if let Some(path) = &input.perf_follow {
774 seen.perf = Some(perf_follow(link, input, path, cwd)?);
775 } else if let Some(since) = input.perf_since {
776 seen.perf = Some(perf_once(link, input, since)?);
777 }
778 Ok((game, seen))
779 };
780 match Connection::open(&input.address).and_then(|mut link| seen(&mut link)) {
781 Ok((game, seen)) => {
782 let limits = limits_of(input, &seen);
783 Answer::new(Op::Attach, true, output(game, seen), limits)
784 }
785 Err((code, error)) => Answer::failed(Op::Attach, code, error),
786 }
787}
788
789pub(crate) fn status(link: &mut Connection) -> Result<Status, Failed> {
791 match link.answer(&Request::Status, ANSWER)? {
792 Line::Status(status) => Ok(status),
793 other => Err(unexpected("its status", &other)),
794 }
795}
796
797pub(crate) fn played(link: &mut Connection, input: &AttachInput) -> Result<Seen, Failed> {
800 let steps = input.link_steps();
801 let mut played = None;
802 if !steps.is_empty() && status(link)?.phase != Phase::Failed {
803 let frames: u32 = steps
804 .iter()
805 .map(|s| match s {
806 Step::Wait(seconds) => wait_frames(*seconds),
807 _ => 2,
808 })
809 .sum();
810 let wait = ANSWER + Duration::from_secs(u64::from(frames / 30));
811 match link.answer(&Request::Play { steps }, wait)? {
812 Line::Played(done) => played = Some(done),
813 Line::Error { error } => return Err((codes::WRONG_ARGUMENTS, error)),
814 other => return Err(unexpected("the steps", &other)),
815 }
816 }
817 let status = status(link)?;
818 let frame = match link.answer(&Request::Frame, ANSWER)? {
819 Line::Frame { frame, .. } => frame,
820 other => return Err(unexpected("its frame", &other)),
821 };
822 let since = input.log_since;
823 let log = match link.answer(&Request::Log { since }, ANSWER)? {
824 Line::Log(log) => log,
825 other => return Err(unexpected("its log", &other)),
826 };
827 Ok(Seen {
828 played,
829 status,
830 frame,
831 log,
832 screenshot: None,
833 perf: None,
834 })
835}
836
837pub(crate) fn hello(link: &mut Connection, token: &str) -> Result<String, Failed> {
839 let hello = Request::Hello {
840 token: token.to_owned(),
841 link: link::VERSION,
842 };
843 match link.ask(&hello, ANSWER)? {
844 None => Err((
845 codes::LINK_TOKEN,
846 format!(
847 "the build at {} closed the link at its hello: the token is wrong",
848 link.address
849 ),
850 )),
851 Some(Line::Hello {
852 link: version,
853 game,
854 }) if version == link::VERSION => Ok(game),
855 Some(Line::Hello { link: version, .. }) => Err((
856 codes::LINK_VERSION,
857 format!(
858 "the build at {} speaks link version {version}, and this shuyi {}",
859 link.address,
860 link::VERSION
861 ),
862 )),
863 Some(Line::Busy) => Err((
864 codes::LINK_LOST,
865 format!(
866 "another client is attached to the build at {}",
867 link.address
868 ),
869 )),
870 Some(other) => Err(unexpected("its hello", &other)),
871 }
872}
873
874pub(crate) fn picture(link: &mut Connection) -> Result<link::Screenshot, Failed> {
876 match link.answer(&Request::Screenshot, ANSWER)? {
877 Line::Screenshot(shot) => Ok(shot),
878 other => Err(unexpected("its picture", &other)),
879 }
880}
881
882fn screenshot(
885 link: &mut Connection,
886 path: &str,
887 cwd: &Path,
888) -> Result<(AttachScreenshot, Option<String>), Failed> {
889 let shot = picture(link)?;
890 let png = link::base64::decode(&shot.png).map_err(|e| (codes::LINK_LOST, e))?;
891 fs::write(cwd.join(path), png)
892 .map_err(|e| (codes::FILE, format!("--screenshot {path}: {e}")))?;
893 let written = AttachScreenshot {
894 path: path.to_owned(),
895 width: shot.width,
896 height: shot.height,
897 };
898 Ok((written, shot.limit))
899}
900
901pub(crate) fn limits_of(input: &AttachInput, seen: &Seen) -> Vec<String> {
903 let mut limits = Vec::new();
904 let mut add = |limit: String| {
905 if !limits.contains(&limit) {
906 limits.push(limit);
907 }
908 };
909 match (seen.status.phase, &seen.status.errors) {
910 (Phase::Loading, _) => add("the build shows its loading card: no frame".to_owned()),
911 (Phase::Failed, errors) => add(format!(
912 "the build's game didn't load, so the steps weren't played: {}",
913 errors.as_deref().unwrap_or_default()
914 )),
915 _ => {}
916 }
917 if let Some(played) = &seen.played {
918 if played.waited_loading {
919 add("the build was loading: the steps waited for its game".to_owned());
920 }
921 if played.waited_suspended {
922 add("the build was suspended: the steps waited".to_owned());
923 }
924 }
925 if input.waits() {
926 add("waits are counted in the build's own frames, not in its clock's seconds".to_owned());
927 }
928 if let Some((_, Some(limit))) = &seen.screenshot {
929 add(format!("the picture: {limit}"));
930 }
931 if seen.log.dropped > 0 {
932 add(format!(
933 "the log keeps the last {LOG_LINES} lines: {} line(s) numbered {} or more were \
934 dropped before this read",
935 seen.log.dropped, input.log_since
936 ));
937 }
938 if let Some(perf) = &seen.perf {
939 for limit in &perf.limits {
940 add(limit.clone());
941 }
942 }
943 limits
944}
945
946fn output(game: String, seen: Seen) -> AttachOutput {
948 let status = seen.status;
949 let phase = match status.phase {
950 Phase::Loading => "loading",
951 Phase::Failed => "failed",
952 _ => "running",
953 };
954 let mut output = AttachOutput {
955 link: link::VERSION,
956 game,
957 frames: status.frames,
958 phase: phase.to_owned(),
959 top_screen: status.top_screen,
960 screens: status.screens,
961 suspended: status.suspended,
962 focused: status.focused,
963 quit: status.quit,
964 frame: seen.frame,
965 screenshot: seen.screenshot.map(|(shot, _)| shot),
966 log: seen
967 .log
968 .lines
969 .into_iter()
970 .map(|line| AttachLogEntry {
971 seq: line.seq,
972 frame: line.frame,
973 level: line.level,
974 category: line.category,
975 text: line.text,
976 })
977 .collect(),
978 log_next: seen.log.next,
979 perf_next: seen.perf.as_ref().map(|perf| perf.next),
980 perf: Vec::new(),
981 perf_summary: None,
982 perf_snapshot: None,
983 follow: None,
984 };
985 if let Some(perf) = seen.perf {
986 output.perf = perf.records;
987 output.perf_summary = perf.summary;
988 output.perf_snapshot = perf.snapshot;
989 output.follow = perf.follow;
990 }
991 output
992}
993
994pub fn text(output: &AttachOutput) -> String {
996 let screens = if output.screens.is_empty() {
997 "none".to_owned()
998 } else {
999 output.screens.join(" > ")
1000 };
1001 let mut out = format!(
1002 "attach: {} on frame {}, {}; top screen: {} ({screens})\n",
1003 output.game,
1004 output.frames,
1005 output.phase,
1006 if output.top_screen.is_empty() {
1007 "none"
1008 } else {
1009 &output.top_screen
1010 },
1011 );
1012 if output.suspended || !output.focused || output.quit {
1013 let said = [
1014 (output.suspended, "suspended"),
1015 (!output.focused, "not focused"),
1016 (output.quit, "quit"),
1017 ];
1018 let said: Vec<&str> = said.iter().filter(|(on, _)| *on).map(|(_, s)| *s).collect();
1019 let _ = writeln!(out, "the build is {}", said.join(", "));
1020 }
1021 if let Some(shot) = &output.screenshot {
1022 let _ = writeln!(
1023 out,
1024 "picture: {} ({} x {})",
1025 shot.path, shot.width, shot.height
1026 );
1027 }
1028 let serious = |entry: &&AttachLogEntry| matches!(entry.level.as_str(), "warn" | "error");
1029 if output.log.iter().any(|entry| serious(&entry)) {
1030 out.push_str("log (warnings and errors; every line with --json):\n");
1031 for entry in output.log.iter().filter(serious) {
1032 let _ = writeln!(
1033 out,
1034 " {} frame {}: {} {}: {}",
1035 entry.seq, entry.frame, entry.level, entry.category, entry.text
1036 );
1037 }
1038 }
1039 let _ = writeln!(out, "log: next line {}", output.log_next);
1040 if let Some(follow) = &output.follow {
1041 let _ = writeln!(
1042 out,
1043 "followed: {} line(s){}",
1044 follow.lines,
1045 if follow.closed {
1046 ", until the build quit"
1047 } else {
1048 ""
1049 }
1050 );
1051 }
1052 if let Some(summary) = &output.perf_summary {
1053 out.push_str(&perf::text(summary));
1054 }
1055 if let Some(next) = output.perf_next {
1056 let _ = writeln!(out, "perf: next record {next}");
1057 }
1058 let glyphs = output
1059 .frame
1060 .split_once("--- ")
1061 .map_or(output.frame.as_str(), |(glyphs, _)| glyphs);
1062 if !glyphs.is_empty() {
1063 out.push_str("frame (its colours with --json):\n");
1064 out.push_str(glyphs);
1065 if !glyphs.ends_with('\n') {
1066 out.push('\n');
1067 }
1068 }
1069 out
1070}
1071
1072#[cfg(test)]
1073mod tests;