From efc6e0c21bbe3b4d9fddecf3701bf3306a6afeda Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 30 Sep 2026 17:35:04 +0200 Subject: [PATCH 1/4] screen_loader_lines counts the panel's left edge, where the firmware's logo never is screen_loader_lines was red on main's nightlies 36550208853 (7e151819e) and 36696295750 (ace064f9d), and on the branch nightlies 36600425263 and 36709239346, every time with the same numbers: the panel carried 26 rows at the GOP query and 41 at the loader's last line, a growth of 15, where the loader printed 14 lines between them, 14 rows at 240 columns It was green on 36400924827 (main a7cd32765) and 36496779560, both "26 to 42, for 16 line(s) printed". The loader is not at fault: a growth of fifteen is a panel that kept drawing. The instrument is. OVMF's PlatformBootManagerAfterConsole draws the TianoCore logo after the console's last ClearScreen (edk2 BootLogoEnableLogo: Logo.bmp, 193x58, centred, so x 863..1055 and y 511..568 on the 1920x1080 panel), and the graphics console then draws its 8x19 cells from the top-left corner, overwriting only the cells a line reaches. text_row_bands scanned the whole width: a row printed short over the logo joined it into one band taller than the pitch and was dropped, and a row printed across the logo's columns, 107 to 131, wiped the logo out of its own cell row, freeing the rows beside it. The GOP line lands on row 26 (y 502..520), the logo's first. At the GOP query the logo is whole beneath it and the GOP row is dropped with it: 26 counted for 27 printed. 1090cf6ea (#583) removed the loader's 128-column "RTC zone" line, so the 165-column "Black box" line now lands on row 27 and wipes the logo there; at the last line the GOP row counts again, and the growth is one more than the lines printed. Before it, the RTC line stopped four cells short of the logo's right edge, the remnant kept rows 26 and 27 joined, and the root bridges' 410-column dump (26ad88cfe, the same PR) gave two rows of slack. The oracle is edk2's own source: GraphicsConsole.c (the text origin, whole cells blitted, the wrap), LaffStd.c (the 8x19 font), BootLogoLib.c with Logo.c and Logo.bmp (the logo and its place, and the cursor it turns off), modelled and fed the nightlies' console lines, then counted by text_row_bands. It gives 26 and 41 for the red lines and 26 and 42 for the green ones, every number recorded; without the logo it gives 27, 41 and 44, none of them. Taking the RTC line alone out of the green console turns it red (26 to 43 for 15 lines on 16 rows); taking the root-bridge line alone out leaves it green. - text_row_bands reads the panel's first EDGE_CELLS cells only: every console row starts there, and a logo centred on the panel is not there. The same model counts 27, 41, 27 and 44 there, the rows printed each time. - The test boots once, to the loader's last line, and holds the panel to exactly the rows the console printed since the firmware's last ESC[2J; screen::edge_rows computes them by the same edge rule. The GOP-query dump goes: an equality needs no baseline, and that dump raced the loader, which goes on printing while it is taken (the 23 against 24 rows recorded in issues/build/parallel-tests-red-under-other-suites.md). A panel the query stopped carries the rows up to the query and no more, and the red names that number beside the one it wanted. - firmware_rows_at_the_edge, a harness check, stages a panel in edk2's geometry with a centred block that a long row cuts and two short rows join, and holds text_row_bands and edge_rows to its 15 rows. Counted across the whole width it is 13: that mutation of text_row_bands reds it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- tests/checks.rs | 5 ++ tests/checks/screen.rs | 72 +++++++++++++++++++++++ tests/common/screen.rs | 49 +++++++++++++--- tests/toyos.rs | 129 ++++++++++++++++------------------------- 4 files changed, 167 insertions(+), 88 deletions(-) diff --git a/tests/checks.rs b/tests/checks.rs index 0eada7d8b95..98f00d7358f 100644 --- a/tests/checks.rs +++ b/tests/checks.rs @@ -685,6 +685,11 @@ mod checks { screen_checks::self_test(); } + #[test] + fn firmware_rows_at_the_edge() -> Result<(), String> { + screen_checks::edge_self_test() + } + #[test] fn metal_audio_judges() -> Result<(), String> { audio_checks::judges_verdict() diff --git a/tests/checks/screen.rs b/tests/checks/screen.rs index 604592ea5b6..e63fc2c4eb8 100644 --- a/tests/checks/screen.rs +++ b/tests/checks/screen.rs @@ -97,3 +97,75 @@ fn console_self_test() { console test could pass on a screen the console never touched" ); } + +/// [`Ppm::text_row_bands`] against a panel drawn the way edk2's graphics +/// console draws one: 8x19 cells from the top-left corner, every cell a line +/// reaches blitted whole, over a logo centred behind the text. A short row +/// over the logo's top is cut free of it by the long row below, and two short +/// rows over the rest join it into one band, so a count across the whole width +/// is two rows short where one at the edge is exact. +pub fn edge_self_test() -> Result<(), String> { + const COLUMNS: usize = 80; + const ROWS: usize = 20; + const EFI_GLYPH_HEIGHT: usize = 19; + let (width, height) = (COLUMNS * EFI_GLYPH_WIDTH, ROWS * EFI_GLYPH_HEIGHT); + let (logo_w, logo_h) = (160, 58); + let long = "Black box: 0x8000000 armed, and the kernel is told so on its line"; + let wide = "Slot A: signed header 168fd26e79d8b062c82902c22b585649e13f6ff487b5a4079d8d21fbaef273ca verifies"; + let spaced = format!("{}{}", &wide[..COLUMNS], " ".repeat(20)); + let lines = [ + "BdsDxe: loading Boot0002", + "ToyOS Bootloader 1.0", + "Kernel: 3476976 bytes", + "Loading kernel elf...", + "Kernel stack size: 8388608", + "Kernel memory size: 12783616", + "Applied 5053 relocations", + "", + "GOP: mode 640x380", + long, + "Starting kernel...", + "Boot map: root", + spaced.as_str(), + wide, + "Loader log: the kernel handoff begins", + ]; + + let (text, logo) = ([0x98u8; 3], [0xFFu8; 3]); + let mut pixels = vec![[0u8; 3]; width * height]; + let (lx, ly) = ((width - logo_w) / 2, (height - logo_h) / 2); + for y in ly..ly + logo_h { + pixels[y * width + lx..y * width + lx + logo_w].fill(logo); + } + let mut row = 0; + for line in lines { + let chars: Vec = line.chars().collect(); + let wrapped: Vec<&[char]> = if chars.is_empty() { vec![&[]] } else { chars.chunks(COLUMNS).collect() }; + for cells in wrapped { + for (col, c) in cells.iter().enumerate() { + for gy in 0..EFI_GLYPH_HEIGHT { + for gx in 0..EFI_GLYPH_WIDTH { + let lit = !c.is_whitespace() && (3..=17).contains(&gy) && (1..=6).contains(&gx); + let (x, y) = (col * EFI_GLYPH_WIDTH + gx, row * EFI_GLYPH_HEIGHT + gy); + pixels[y * width + x] = if lit { text } else { [0; 3] }; + } + } + } + row += 1; + } + } + + let mut ppm = format!("P6\n{width} {height}\n255\n").into_bytes(); + for p in &pixels { + ppm.extend_from_slice(p); + } + let counted = Ppm::parse(&ppm).text_row_bands()?; + let expected = edge_rows(lines, COLUMNS); + if (counted, expected) != (15, 15) { + return Err(format!( + "the staged panel carries 15 rows at its edge; text_row_bands counted {counted} and \ + edge_rows expected {expected}" + )); + } + Ok(()) +} diff --git a/tests/common/screen.rs b/tests/common/screen.rs index 4ebfb20c044..60836902ca0 100644 --- a/tests/common/screen.rs +++ b/tests/common/screen.rs @@ -173,20 +173,25 @@ impl Ppm { None } - /// How many rows of text are on the panel, counted without decoding a - /// glyph: no firmware font is committed here, so a row one drew is - /// invisible to [`Ppm::text`]. + /// How many rows of a firmware console's text the panel's first + /// [`EDGE_CELLS`] cells carry, counted without decoding a glyph: no firmware + /// font is committed here, so a row one drew is invisible to [`Ppm::text`]. + /// [`edge_rows`] is the count a console's lines put there. /// - /// A band taller than the pitch is a logo or two rows that touch, and - /// neither is one row of text; the pitch is the median distance between - /// band tops, because a stray scanline sets a minimum of one and drags a - /// mean as well. A panel with fewer than two bands has no pitch to take, - /// and is refused rather than counted as none. + /// A band taller than the pitch is two rows that touch, and not one row of + /// text; the pitch is the median distance between band tops, because a + /// stray scanline sets a minimum of one and drags a mean as well. A panel + /// with fewer than two bands has no pitch to take, and is refused rather + /// than counted as none. pub fn text_row_bands(&self) -> Result { + let edge = EDGE_CELLS * EFI_GLYPH_WIDTH; + if self.width < edge { + return Err(format!("the panel is {} pixels wide, narrower than its {edge}-pixel edge", self.width)); + } let mut bands: Vec<(usize, usize)> = Vec::new(); let mut top = None; for y in 0..self.height { - let lit = (0..self.width) + let lit = (0..edge) .any(|x| self.pixels[y * self.width + x].iter().any(|c| *c >= FG_THRESHOLD)); match (lit, top) { (true, None) => top = Some(y), @@ -238,6 +243,32 @@ impl Ppm { } } +/// A firmware console's glyph cell is this wide: UEFI's `EFI_GLYPH_WIDTH`. +pub const EFI_GLYPH_WIDTH: usize = 8; + +/// The cells at the panel's left edge that [`Ppm::text_row_bands`] reads. +/// Firmware starts every console row there and centres its boot logo (edk2's +/// `BootLogoEnableLogo`) behind the text, so across the whole width a short row +/// over the logo joins it into one band too tall to count, and a long row cuts +/// it into pieces short enough to count. +pub const EDGE_CELLS: usize = 8; + +/// The rows [`Ppm::text_row_bands`] counts where a firmware console printed +/// `lines` from its top-left cell at `columns` a row: a line takes a row per +/// `columns` characters it fills, and a row is counted when one of its first +/// [`EDGE_CELLS`] characters draws. +pub fn edge_rows<'a>(lines: impl IntoIterator, columns: usize) -> usize { + lines + .into_iter() + .map(|line| { + let line: Vec = line.chars().collect(); + line.chunks(columns) + .filter(|row| row.iter().take(EDGE_CELLS).any(|c| !c.is_whitespace())) + .count() + }) + .sum() +} + /// Cells of the console's font, in the alpha values it blits. const CELL: usize = GLYPH_W * GLYPH_H; diff --git a/tests/toyos.rs b/tests/toyos.rs index 0966fa01db3..08284d0b37b 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -3627,103 +3627,74 @@ fn run_screen_test( // An EXCLUSIVE open of `GraphicsOutput` calls `Stop` on the // firmware's graphics console, so with one the panel stops at the // GOP query and every later loader line is on serial alone. - let dump_at = |marker: &'static str| -> Result<(usize, String), String> { - let options = BootOptions { - profile, - qmp: true, - ready_marker: marker, - ..Default::default() - }; - metal_sim_argv_check(&qemu::profile_argv(&options))?; - let mut qemu = - QemuInstance::boot_with_options(test_config, c_bins, rust_bins, options); - let console = qemu.boot_log().to_string(); - let dump = qemu.screendump(); - // A row that decodes in the kernel's font is a row the kernel - // drew. Refused rather than counted: its rows are not the - // loader's and would only push the growth below green. - if dump - .rows() - .iter() - .any(|row| !row.trim().is_empty() && !row.contains(screen::UNKNOWN)) - { - return Err(format!( - "the kernel had already repainted the panel at {marker:?}, so these are \ - its rows and not the loader's\ndecoded screen:\n{}", - dump.text() - )); - } - Ok((dump.text_row_bands()?, console)) - }; - - let (before, at_query) = dump_at(bootlog::LOADER_GOP_LINE)?; - let (after, console) = dump_at(bootlog::LOADER_LAST_LINE)?; - - // The growth below subtracts one boot's rows from the other's, and - // says nothing unless the two printed the same number of lines - // before the query. The lines themselves are not compared: each - // boot builds its own image, so two of them carry partition GUIDs - // drawn fresh. - let upto = |text: &str| { - text.lines().take_while(|line| !line.contains(bootlog::LOADER_GOP_LINE)).count() + let options = BootOptions { + profile, + qmp: true, + ready_marker: bootlog::LOADER_LAST_LINE, + ..Default::default() }; - if upto(&at_query) != upto(&console) { + metal_sim_argv_check(&qemu::profile_argv(&options))?; + let mut qemu = QemuInstance::boot_with_options(test_config, c_bins, rust_bins, options); + let console = qemu.boot_log().to_string(); + let dump = qemu.screendump(); + // A row that decodes in the kernel's font is a row the kernel + // drew. Refused rather than counted: its rows are not the loader's. + if dump.rows().iter().any(|row| !row.trim().is_empty() && !row.contains(screen::UNKNOWN)) { return Err(format!( - "one boot printed {} lines before the GOP query and the other {}, so their \ - row counts are not each other's baseline\n--- first\n{at_query}\n--- \ - second\n{console}", - upto(&at_query), - upto(&console) + "the kernel had already repainted the panel at the loader's last line, so \ + these are its rows and not the loader's\ndecoded screen:\n{}", + dump.text() )); } - // What the loader printed after the query, off its own console. - let lines: Vec<&str> = console.lines().collect(); - let at = |line: &str| { - lines - .iter() - .position(|seen| seen.contains(line)) - .ok_or_else(|| format!("the loader never printed {line:?}\n{console}")) - }; - let (query, last) = - (at(bootlog::LOADER_GOP_LINE)?, at(bootlog::LOADER_LAST_LINE)?); - if last <= query { + // `ClearScreen` blanks the whole panel and puts `ESC[2J` on the + // serial, so every line after the last one is on the panel too. + let Some((_, shown)) = console.rsplit_once("\x1b[2J") else { return Err(format!( - "the console carries {:?} at line {last} and {:?} at line {query}, so there \ - is nothing between them", - bootlog::LOADER_LAST_LINE, - bootlog::LOADER_GOP_LINE + "the console carries no clear screen, so nothing says which of its lines \ + are on the panel\n{console}" )); - } - let printed = last - query; - // The rows those lines take on the firmware's console, whose glyph is - // eight pixels wide (UEFI 2.11 §12.9, `EFI_GLYPH_WIDTH`): a line - // wider than the mode's columns — the root bridges' descriptor dump - // is one — wraps onto a row per width it fills. + }; + // A CSI sequence moves the cursor or sets a colour and draws nothing. + let drawn = |line: &str| { + let mut text = String::new(); + let mut chars = line.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' && chars.clone().next() == Some('[') { + chars.by_ref().skip(1).find(|c| ('\x40'..='\x7e').contains(c)); + } else { + text.push(c); + } + } + text + }; + let lines: Vec = shown.lines().map(drawn).collect(); + let query = lines + .iter() + .position(|line| line.contains(bootlog::LOADER_GOP_LINE)) + .ok_or_else(|| format!("the loader never printed {:?}\n{console}", bootlog::LOADER_GOP_LINE))?; let columns = lines[query] .split("GOP: mode ") .nth(1) .and_then(|mode| mode.split('x').next()) .and_then(|width| width.parse::().ok()) - .map(|width| width / 8) + .map(|width| width / screen::EFI_GLYPH_WIDTH) .filter(|&columns| columns > 0) .ok_or_else(|| format!("the GOP line names no mode width: {:?}", lines[query]))?; - let rows: usize = lines[query + 1..=last].iter().map(|line| line.len().div_ceil(columns).max(1)).sum(); - - // A range and not an equality: each panel is dumped after its marker - // reached the console, so a line drawn in between is on the panel - // and not in the count. - let grew = after as i64 - before as i64; - if !(1..=rows as i64).contains(&grew) { + let rows = screen::edge_rows(lines.iter().map(String::as_str), columns); + let upto_query = screen::edge_rows(lines[..=query].iter().map(String::as_str), columns); + let counted = dump.text_row_bands()?; + if counted != rows { return Err(format!( - "the panel carried {before} rows at the GOP query and {after} at the loader's \ - last line, a growth of {grew}, where the loader printed {printed} lines \ - between them, {rows} rows at {columns} columns\n{console}" + "the panel carries {counted} rows where the console printed {rows} since the \ + firmware last cleared it at {columns} columns, {upto_query} of them up to the \ + GOP query, which are all a panel the query stopped carries\n{console}" )); } eprintln!( - " [screen] the panel grew {grew} row(s) across the GOP query, {before} to \ - {after}, for {printed} line(s) printed" + " [screen] the panel carries all {rows} rows the console printed, {} of them after \ + the GOP query", + rows - upto_query ); Ok(()) } From 92d4b113f97f561aea0c3dcfa20ef480760d6e51 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 30 Sep 2026 18:54:25 +0200 Subject: [PATCH 2/4] screen_loader_lines reads the firmware's text mode off the panel The orchestrator's guest runs of efc6e0c21 went red with "the panel carries 0 band(s) of lit scanlines" three times out of three: alone, under the exclusive-GOP control, and in the whole suite. The first 8 cells of the panel were dark because the firmware this host boots does not draw its text there. What the panel holds depends on the firmware. edk2's graphics console centres its text mode on the panel (InitializeGraphicsConsoleTextMode's DeltaX and DeltaY), wraps a line when its row is full, and scrolls the text area once the cursor passes the last row (GraphicsConsoleConOutOutputString). Which mode it draws in is the firmware build's choice: - CI boots Debian's ovmf-generic, which draws in 240x56: text from x=0 and y=8, and the loader's 41 rows fit without scrolling. - The dev host boots Homebrew QEMU 11.1.1's own edk2-x86_64-code.fd, which draws in 80x50: text from x=640 and y=65. At 80 columns the console fills 58 rows, so the panel scrolls and keeps the last 49. These modes were not read off a screendump. They come from an edk2 pixel model: that source's cells, wrap, scroll and Logo.bmp, fed the red nightly's console and counted with main's whole-width counter. In 80x50 it counts 37 rows at the GOP query and 47 at the last line, which are main's local passes exactly ("37 to 47"). In 240x56 it counts CI's 26 and 41. No other mode edk2 offers on a 1920x1080 panel gives either pair. 100x31, 128x40 and 160x42 have too few rows for 47, and 80x25 has too few for either. Only 80x50 starts its text past the first 8 cells. So the counter can no longer assume a margin, a column count or the absence of a scroll: - Ppm::firmware_text_mode picks the mode off the panel, out of the modes edk2 offers there (TextMode::offered). It must be the one whose first cell column holds the leftmost lit pixel, where every row starts. Its rows must also hold everything lit in its first 8 cells, and that is what separates 80x25 from 80x50. - Ppm::edge_rows reads each of that mode's rows in its first 8 cells, cell by cell. A glyph never leaves its cell, and a centred logo narrower than 512 pixels never reaches those cells. Bands, pitches and their refusals are gone. - TextMode::panel is the same row read off the console: each line since the last ESC[2J, wrapped at the mode's columns. A line that fills its last row leaves a blank row after it. The text scrolls on a line feed at the last row. - The test requires the two to agree row by row, and prints both maps and their counts when they do not. The GOP line's width no longer decides the columns. The exclusive-GOP control now reaches this judgement: its panel stops before the GOP line while the model expects every row. firmware_rows_at_the_edge stages both real geometries with a pixel-level edk2 renderer: cells blitted whole over a centred 193x58 logo, wrap, and a scroll that moves the text area's pixels. It checks the mode found and the rows against counts derived by hand: 46 lit rows in 80x50 after four scrolled-off rows, and 48 in 240x56. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- tests/checks/screen.rs | 129 ++++++++++++++++------------ tests/common/screen.rs | 186 ++++++++++++++++++++++++++--------------- tests/toyos.rs | 38 +++++---- 3 files changed, 213 insertions(+), 140 deletions(-) diff --git a/tests/checks/screen.rs b/tests/checks/screen.rs index e63fc2c4eb8..ad3106cc1cf 100644 --- a/tests/checks/screen.rs +++ b/tests/checks/screen.rs @@ -98,74 +98,97 @@ fn console_self_test() { ); } -/// [`Ppm::text_row_bands`] against a panel drawn the way edk2's graphics -/// console draws one: 8x19 cells from the top-left corner, every cell a line -/// reaches blitted whole, over a logo centred behind the text. A short row -/// over the logo's top is cut free of it by the long row below, and two short -/// rows over the rest join it into one band, so a count across the whole width -/// is two rows short where one at the edge is exact. +/// [`Ppm::firmware_text_mode`], [`Ppm::edge_rows`] and [`TextMode::panel`] +/// against the T14's 1920x1080 panel drawn the way edk2's graphics console +/// draws it, in the two text modes firmware has chosen there: 80x50, whose +/// text starts 640 pixels in and which these lines scroll, and 240x56, whose +/// text starts at the edge and which they do not. pub fn edge_self_test() -> Result<(), String> { - const COLUMNS: usize = 80; - const ROWS: usize = 20; - const EFI_GLYPH_HEIGHT: usize = 19; - let (width, height) = (COLUMNS * EFI_GLYPH_WIDTH, ROWS * EFI_GLYPH_HEIGHT); - let (logo_w, logo_h) = (160, 58); - let long = "Black box: 0x8000000 armed, and the kernel is told so on its line"; - let wide = "Slot A: signed header 168fd26e79d8b062c82902c22b585649e13f6ff487b5a4079d8d21fbaef273ca verifies"; - let spaced = format!("{}{}", &wide[..COLUMNS], " ".repeat(20)); - let lines = [ - "BdsDxe: loading Boot0002", - "ToyOS Bootloader 1.0", - "Kernel: 3476976 bytes", - "Loading kernel elf...", - "Kernel stack size: 8388608", - "Kernel memory size: 12783616", - "Applied 5053 relocations", - "", - "GOP: mode 640x380", - long, - "Starting kernel...", - "Boot map: root", - spaced.as_str(), - wide, - "Loader log: the kernel handoff begins", - ]; + let short: Vec = (0..45).map(|n| format!("Kernel memory size: {n}")).collect(); + let wide = format!("Slot A: signed header {} verifies", "0123456789abcdef".repeat(9)); + let lines: Vec<&str> = short + .iter() + .map(String::as_str) + .chain([ + wide.as_str(), + "Boot attempts: this image has had the machine 0 time(s) without reporting; now 1", + "", + " Kernel memory located past the edge", + "Loader log: the kernel handoff begins", + ]) + .collect(); + // Counted by hand: at 80 columns the lines take 45 + 3 + 2 + 1 + 1 + 1 = 53 + // rows and the first four scroll off; at 240 they take 50. + for (staged, lit) in [ + (TextMode { columns: 80, rows: 50, left: 640, top: 65 }, 41 + 3 + 1 + 1), + (TextMode { columns: 240, rows: 56, left: 0, top: 8 }, 45 + 1 + 1 + 1), + ] { + let dump = edk2_panel(1920, 1080, staged, &lines); + let mode = dump.firmware_text_mode()?; + let (carried, printed) = (dump.edge_rows(mode), mode.panel(lines.iter().copied())); + let count = |rows: &[bool]| rows.iter().filter(|&&row| row).count(); + if (mode, count(&carried), count(&printed)) != (staged, lit, lit) || carried != printed { + return Err(format!( + "a panel staged in {staged:?} with {lit} rows lit at its edge was read as {mode:?}, \ + carrying {} where the console put {}", + count(&carried), + count(&printed) + )); + } + } + Ok(()) +} +/// `lines` printed in `mode` on a cleared `width` x `height` panel, over a +/// block the size of edk2's `Logo.bmp` centred on it. Every cell a line +/// reaches is blitted whole, a glyph lighting the pixels an 8x19 capital does; +/// a line wraps once its row is full; and a line feed on the last row moves +/// the text area's pixels, the logo's with them, up a row. +fn edk2_panel(width: usize, height: usize, mode: TextMode, lines: &[&str]) -> Ppm { let (text, logo) = ([0x98u8; 3], [0xFFu8; 3]); + let (logo_w, logo_h) = (193, 58); let mut pixels = vec![[0u8; 3]; width * height]; let (lx, ly) = ((width - logo_w) / 2, (height - logo_h) / 2); for y in ly..ly + logo_h { - pixels[y * width + lx..y * width + lx + logo_w].fill(logo); + pixels[y * width + lx..][..logo_w].fill(logo); } - let mut row = 0; + let area = mode.columns * EFI_GLYPH_WIDTH; + let line_feed = |pixels: &mut Vec<[u8; 3]>, row: &mut usize| { + if *row + 1 < mode.rows { + *row += 1; + return; + } + for y in mode.top..mode.top + (mode.rows - 1) * EFI_GLYPH_HEIGHT { + let from = (y + EFI_GLYPH_HEIGHT) * width + mode.left; + pixels.copy_within(from..from + area, y * width + mode.left); + } + for y in mode.top + (mode.rows - 1) * EFI_GLYPH_HEIGHT..mode.top + mode.rows * EFI_GLYPH_HEIGHT { + pixels[y * width + mode.left..][..area].fill([0; 3]); + } + }; + let (mut row, mut column) = (0, 0); for line in lines { - let chars: Vec = line.chars().collect(); - let wrapped: Vec<&[char]> = if chars.is_empty() { vec![&[]] } else { chars.chunks(COLUMNS).collect() }; - for cells in wrapped { - for (col, c) in cells.iter().enumerate() { - for gy in 0..EFI_GLYPH_HEIGHT { - for gx in 0..EFI_GLYPH_WIDTH { - let lit = !c.is_whitespace() && (3..=17).contains(&gy) && (1..=6).contains(&gx); - let (x, y) = (col * EFI_GLYPH_WIDTH + gx, row * EFI_GLYPH_HEIGHT + gy); - pixels[y * width + x] = if lit { text } else { [0; 3] }; - } + for c in line.chars() { + for gy in 0..EFI_GLYPH_HEIGHT { + for gx in 0..EFI_GLYPH_WIDTH { + let lit = !c.is_whitespace() && (3..=14).contains(&gy) && gx <= 6; + let (x, y) = (mode.left + column * EFI_GLYPH_WIDTH + gx, mode.top + row * EFI_GLYPH_HEIGHT + gy); + pixels[y * width + x] = if lit { text } else { [0; 3] }; } } - row += 1; + column += 1; + if column == mode.columns { + column = 0; + line_feed(&mut pixels, &mut row); + } } + column = 0; + line_feed(&mut pixels, &mut row); } let mut ppm = format!("P6\n{width} {height}\n255\n").into_bytes(); for p in &pixels { ppm.extend_from_slice(p); } - let counted = Ppm::parse(&ppm).text_row_bands()?; - let expected = edge_rows(lines, COLUMNS); - if (counted, expected) != (15, 15) { - return Err(format!( - "the staged panel carries 15 rows at its edge; text_row_bands counted {counted} and \ - edge_rows expected {expected}" - )); - } - Ok(()) + Ppm::parse(&ppm) } diff --git a/tests/common/screen.rs b/tests/common/screen.rs index 60836902ca0..ed211290879 100644 --- a/tests/common/screen.rs +++ b/tests/common/screen.rs @@ -173,56 +173,53 @@ impl Ppm { None } - /// How many rows of a firmware console's text the panel's first - /// [`EDGE_CELLS`] cells carry, counted without decoding a glyph: no firmware - /// font is committed here, so a row one drew is invisible to [`Ppm::text`]. - /// [`edge_rows`] is the count a console's lines put there. - /// - /// A band taller than the pitch is two rows that touch, and not one row of - /// text; the pitch is the median distance between band tops, because a - /// stray scanline sets a minimum of one and drags a mean as well. A panel - /// with fewer than two bands has no pitch to take, and is refused rather - /// than counted as none. - pub fn text_row_bands(&self) -> Result { - let edge = EDGE_CELLS * EFI_GLYPH_WIDTH; - if self.width < edge { - return Err(format!("the panel is {} pixels wide, narrower than its {edge}-pixel edge", self.width)); - } - let mut bands: Vec<(usize, usize)> = Vec::new(); - let mut top = None; - for y in 0..self.height { - let lit = (0..edge) - .any(|x| self.pixels[y * self.width + x].iter().any(|c| *c >= FG_THRESHOLD)); - match (lit, top) { - (true, None) => top = Some(y), - (false, Some(from)) => { - bands.push((from, y)); - top = None; - } - _ => {} - } - } - if let Some(from) = top { - bands.push((from, self.height)); - } - let mut gaps: Vec = bands.windows(2).map(|pair| pair[1].0 - pair[0].0).collect(); - if gaps.is_empty() { - return Err(format!( - "the panel carries {} band(s) of lit scanlines, too few to take a row pitch from", - bands.len() - )); - } - gaps.sort_unstable(); - let pitch = gaps[gaps.len() / 2]; - let rows = bands.iter().filter(|(from, to)| to - from <= pitch).count(); - if rows == 0 { - return Err(format!( - "every one of the panel's {} band(s) is taller than the {pitch}-pixel pitch, so \ - none of them is a row of text", - bands.len() - )); + /// The firmware text mode this panel's text was drawn in, found without + /// decoding a glyph: no firmware font is committed here, so a row one drew + /// is invisible to [`Ppm::text`]. Of the modes [`TextMode::offered`], it is + /// the one whose first column of cells holds the panel's leftmost lit + /// pixel, where every row of its text starts, and whose rows hold all that + /// is lit in its first [`EDGE_CELLS`] cells. + pub fn firmware_text_mode(&self) -> Result { + let leftmost = (0..self.width) + .find(|&x| (0..self.height).any(|y| self.bit(x, y))) + .ok_or("the panel has no lit pixel, so no text mode drew on it")?; + let fits: Vec = TextMode::offered(self.width, self.height) + .into_iter() + .filter(|mode| (mode.left..mode.left + EFI_GLYPH_WIDTH).contains(&leftmost)) + .filter(|mode| { + let area = mode.top..mode.top + mode.rows * EFI_GLYPH_HEIGHT; + (0..self.height).all(|y| area.contains(&y) || !self.edge_lit(*mode, y)) + }) + .collect(); + match fits[..] { + [mode] => Ok(mode), + _ => Err(format!( + "the panel's text starts at x={leftmost}, and {} of the text modes edk2 offers on \ + a {}x{} panel, not one, start there and hold it: {fits:?}", + fits.len(), + self.width, + self.height + )), } - Ok(rows) + } + + /// Each of `mode`'s rows, `true` where a pixel of its first [`EDGE_CELLS`] + /// cells is lit. A firmware console blits a glyph's whole cell and nothing + /// outside it, and edk2 centres its boot logo as it centres `mode`, so a + /// logo narrower than 512 pixels never reaches these cells of a mode 80 or + /// more columns wide. + pub fn edge_rows(&self, mode: TextMode) -> Vec { + (0..mode.rows) + .map(|row| { + let top = mode.top + row * EFI_GLYPH_HEIGHT; + (top..top + EFI_GLYPH_HEIGHT).any(|y| self.edge_lit(mode, y)) + }) + .collect() + } + + /// Whether scanline `y` is lit in `mode`'s first [`EDGE_CELLS`] cells. + fn edge_lit(&self, mode: TextMode, y: usize) -> bool { + (mode.left..mode.left + EDGE_CELLS * EFI_GLYPH_WIDTH).any(|x| self.bit(x, y)) } /// The fill colour, read from the bottom-right pixel. The renderer paints @@ -243,30 +240,81 @@ impl Ppm { } } -/// A firmware console's glyph cell is this wide: UEFI's `EFI_GLYPH_WIDTH`. +/// A firmware console's glyph cell: UEFI's `EFI_GLYPH_WIDTH` by `EFI_GLYPH_HEIGHT`. pub const EFI_GLYPH_WIDTH: usize = 8; +pub const EFI_GLYPH_HEIGHT: usize = 19; -/// The cells at the panel's left edge that [`Ppm::text_row_bands`] reads. -/// Firmware starts every console row there and centres its boot logo (edk2's -/// `BootLogoEnableLogo`) behind the text, so across the whole width a short row -/// over the logo joins it into one band too tall to count, and a long row cuts -/// it into pieces short enough to count. +/// The cells at the start of a firmware console's row that [`Ppm::edge_rows`] +/// reads: every row of its text starts there, and its boot logo never reaches them. pub const EDGE_CELLS: usize = 8; -/// The rows [`Ppm::text_row_bands`] counts where a firmware console printed -/// `lines` from its top-left cell at `columns` a row: a line takes a row per -/// `columns` characters it fills, and a row is counted when one of its first -/// [`EDGE_CELLS`] characters draws. -pub fn edge_rows<'a>(lines: impl IntoIterator, columns: usize) -> usize { - lines - .into_iter() - .map(|line| { - let line: Vec = line.chars().collect(); - line.chunks(columns) - .filter(|row| row.iter().take(EDGE_CELLS).any(|c| !c.is_whitespace())) - .count() - }) - .sum() +/// A text mode of edk2's graphics console: `columns` by `rows` cells centred on +/// the panel, the first of them `left` pixels in and `top` down. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TextMode { + pub columns: usize, + pub rows: usize, + pub left: usize, + pub top: usize, +} + +impl TextMode { + /// The text modes edk2's graphics console offers on a `width` x `height` + /// panel, each once and each centred (`InitializeGraphicsConsoleTextMode`): + /// UEFI's 80x25 and 80x50, then `mGraphicsConsoleModeData`'s, the last of + /// them the whole panel, of all these the ones that fit it. + pub fn offered(width: usize, height: usize) -> Vec { + let whole = (width / EFI_GLYPH_WIDTH, height / EFI_GLYPH_HEIGHT); + let mut modes: Vec = Vec::new(); + for (columns, rows) in [(80, 25), (80, 50), (100, 31), (128, 40), (160, 42), (240, 56), whole] { + let (w, h) = (columns * EFI_GLYPH_WIDTH, rows * EFI_GLYPH_HEIGHT); + if w > width || h > height { + continue; + } + let mode = TextMode { columns, rows, left: (width - w) >> 1, top: (height - h) >> 1 }; + if !modes.contains(&mode) { + modes.push(mode); + } + } + modes + } + + /// The rows `lines` take in this mode, each `true` where one of its first + /// [`EDGE_CELLS`] characters draws. A line wraps every `columns` + /// characters, and one that fills its last row wraps before its own line + /// feed moves the cursor again, which leaves a blank row + /// (`GraphicsConsoleConOutOutputString`). + pub fn printed<'a>(self, lines: impl IntoIterator) -> Vec { + let mut rows = Vec::new(); + for line in lines { + let chars: Vec = line.chars().collect(); + rows.extend( + chars.chunks(self.columns).map(|row| row.iter().take(EDGE_CELLS).any(|c| !c.is_whitespace())), + ); + if chars.len() % self.columns == 0 { + rows.push(false); + } + } + rows + } + + /// Each of this mode's rows as [`TextMode::printed`] marks it, once `lines` + /// are printed onto a cleared panel: a line feed on the last row scrolls + /// the text up a row and blanks the last. + pub fn panel<'a>(self, lines: impl IntoIterator) -> Vec { + let mut panel = vec![false; self.rows]; + let mut cursor = 0; + for row in self.printed(lines) { + panel[cursor] = row; + if cursor + 1 == self.rows { + panel.remove(0); + panel.push(false); + } else { + cursor += 1; + } + } + panel + } } /// Cells of the console's font, in the alpha values it blits. diff --git a/tests/toyos.rs b/tests/toyos.rs index 08284d0b37b..53cde17ada9 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -3673,28 +3673,30 @@ fn run_screen_test( .iter() .position(|line| line.contains(bootlog::LOADER_GOP_LINE)) .ok_or_else(|| format!("the loader never printed {:?}\n{console}", bootlog::LOADER_GOP_LINE))?; - let columns = lines[query] - .split("GOP: mode ") - .nth(1) - .and_then(|mode| mode.split('x').next()) - .and_then(|width| width.parse::().ok()) - .map(|width| width / screen::EFI_GLYPH_WIDTH) - .filter(|&columns| columns > 0) - .ok_or_else(|| format!("the GOP line names no mode width: {:?}", lines[query]))?; - let rows = screen::edge_rows(lines.iter().map(String::as_str), columns); - let upto_query = screen::edge_rows(lines[..=query].iter().map(String::as_str), columns); - let counted = dump.text_row_bands()?; - if counted != rows { + let mode = dump.firmware_text_mode()?; + let (carried, printed) = (dump.edge_rows(mode), mode.panel(lines.iter().map(String::as_str))); + let count = |rows: &[bool]| rows.iter().filter(|&&lit| lit).count(); + if carried != printed { + let map = |rows: &[bool]| rows.iter().map(|&lit| if lit { '#' } else { '.' }).collect::(); return Err(format!( - "the panel carries {counted} rows where the console printed {rows} since the \ - firmware last cleared it at {columns} columns, {upto_query} of them up to the \ - GOP query, which are all a panel the query stopped carries\n{console}" + "the panel's first {} cells carry {} rows of text where the console put {} there \ + in its {}x{} text mode, row by row\npanel: {}\nconsole: {}\n{console}", + screen::EDGE_CELLS, + count(&carried), + count(&printed), + mode.columns, + mode.rows, + map(&carried), + map(&printed) )); } eprintln!( - " [screen] the panel carries all {rows} rows the console printed, {} of them after \ - the GOP query", - rows - upto_query + " [screen] the panel carries all {} rows the console put at its edge in its {}x{} \ + text mode, {} of them after the GOP query", + count(&printed), + mode.columns, + mode.rows, + count(&mode.printed(lines[query + 1..].iter().map(String::as_str))) ); Ok(()) } From 382919c5bb43289fdbe6d3de54c7a0dc728e4304 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 30 Sep 2026 18:58:35 +0200 Subject: [PATCH 3/4] TextMode::printed spells its full-row test as is_multiple_of `cargo run -- --ci host` went red at 92d4b113f on one step, clippy's manual_is_multiple_of in TextMode::printed. This spells it the way clippy asks. 92d4b113f's message gets two sentences wrong: - "Only 80x50 starts its text past the first 8 cells" is false. Every mode edk2 offers on a 1920x1080 panel except 240x56 starts past them. What singles out 80x50 is the row counts, as the sentences before it say. - Main's local passes were not matched "exactly". They counted 37 or 38 rows at the GOP query and 47 at the last line. The model counts 37 and 47 when fed the CI console, which is not the console those local boots printed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- tests/common/screen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common/screen.rs b/tests/common/screen.rs index ed211290879..aec008d1ab2 100644 --- a/tests/common/screen.rs +++ b/tests/common/screen.rs @@ -291,7 +291,7 @@ impl TextMode { rows.extend( chars.chunks(self.columns).map(|row| row.iter().take(EDGE_CELLS).any(|c| !c.is_whitespace())), ); - if chars.len() % self.columns == 0 { + if chars.len().is_multiple_of(self.columns) { rows.push(false); } } From 6e0d7da82ee42bf938fb3235848f459070161d8e Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 30 Sep 2026 21:03:28 +0200 Subject: [PATCH 4/4] screen_loader_lines judges the panel once two dumps in a row agree The one screendump was taken as the loader's last line reached the serial. uefi-services' println! sends that line's CRLF as its own OutputString, and edk2's graphics console scrolls on it when the cursor is on the last row, so a ConSplitter that writes the terminal before the graphics console lets the dump land before the scroll, one row off everywhere. The review asked for a dump after a later event; the kernel's first 16550 record is not one: arm() commits `panic console: armed` and repaints the whole panel before serial::init makes the 16550 a backend, so every dump after that record would meet the repaint refusal. The test now dumps the panel back to back until two dumps carry the same text mode and row map, and judges that map. A dump the kernel has repainted is refused as before; a panel still changing when a 30-second liveness ceiling (budget-scaled) passes fails with both maps. Review notes: - firmware_rows_at_the_edge stages, in both modes, the exclusive-GOP control's panel: one that stopped after the 45 short lines. It must be read in the staged mode and must not carry the console's rows. - The CSI stripper goes: after the last ESC[2J the only sequence is the leading cursor-home, which changes no row in either measured console. - TextMode::printed is folded into TextMode::panel. Its one other reader was the success line's "N of them after the GOP query", which no judge reads and which goes with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- tests/checks/screen.rs | 16 +++++++++- tests/common/screen.rs | 47 +++++++++++----------------- tests/toyos.rs | 70 +++++++++++++++++++++++------------------- 3 files changed, 72 insertions(+), 61 deletions(-) diff --git a/tests/checks/screen.rs b/tests/checks/screen.rs index ad3106cc1cf..74d855a9e99 100644 --- a/tests/checks/screen.rs +++ b/tests/checks/screen.rs @@ -102,7 +102,9 @@ fn console_self_test() { /// against the T14's 1920x1080 panel drawn the way edk2's graphics console /// draws it, in the two text modes firmware has chosen there: 80x50, whose /// text starts 640 pixels in and which these lines scroll, and 240x56, whose -/// text starts at the edge and which they do not. +/// text starts at the edge and which they do not. In each, a panel that +/// stopped before the console's last lines, as the exclusive-GOP control's +/// does, must not read as carrying them. pub fn edge_self_test() -> Result<(), String> { let short: Vec = (0..45).map(|n| format!("Kernel memory size: {n}")).collect(); let wide = format!("Slot A: signed header {} verifies", "0123456789abcdef".repeat(9)); @@ -135,6 +137,18 @@ pub fn edge_self_test() -> Result<(), String> { count(&printed) )); } + let stopped = edk2_panel(1920, 1080, staged, &lines[..short.len()]); + let read = stopped.firmware_text_mode()?; + if read != staged || stopped.edge_rows(read) == printed { + return Err(format!( + "a panel staged in {staged:?} that stopped after {} of the console's {} lines was \ + read as {read:?}, carrying {} where the console put {}", + short.len(), + lines.len(), + count(&stopped.edge_rows(read)), + count(&printed) + )); + } } Ok(()) } diff --git a/tests/common/screen.rs b/tests/common/screen.rs index aec008d1ab2..c28874c1b3e 100644 --- a/tests/common/screen.rs +++ b/tests/common/screen.rs @@ -279,38 +279,27 @@ impl TextMode { modes } - /// The rows `lines` take in this mode, each `true` where one of its first - /// [`EDGE_CELLS`] characters draws. A line wraps every `columns` - /// characters, and one that fills its last row wraps before its own line - /// feed moves the cursor again, which leaves a blank row - /// (`GraphicsConsoleConOutOutputString`). - pub fn printed<'a>(self, lines: impl IntoIterator) -> Vec { - let mut rows = Vec::new(); - for line in lines { - let chars: Vec = line.chars().collect(); - rows.extend( - chars.chunks(self.columns).map(|row| row.iter().take(EDGE_CELLS).any(|c| !c.is_whitespace())), - ); - if chars.len().is_multiple_of(self.columns) { - rows.push(false); - } - } - rows - } - - /// Each of this mode's rows as [`TextMode::printed`] marks it, once `lines` - /// are printed onto a cleared panel: a line feed on the last row scrolls - /// the text up a row and blanks the last. + /// Each of this mode's rows once `lines` are printed onto a cleared panel, + /// `true` where one of its first [`EDGE_CELLS`] characters draws. A line + /// wraps every `columns` characters, and one that fills its last row wraps + /// before its own line feed moves the cursor again, which leaves a blank + /// row (`GraphicsConsoleConOutOutputString`); a line feed on the last row + /// scrolls the text up a row and blanks the last. pub fn panel<'a>(self, lines: impl IntoIterator) -> Vec { let mut panel = vec![false; self.rows]; let mut cursor = 0; - for row in self.printed(lines) { - panel[cursor] = row; - if cursor + 1 == self.rows { - panel.remove(0); - panel.push(false); - } else { - cursor += 1; + for line in lines { + let chars: Vec = line.chars().collect(); + let blank = chars.len().is_multiple_of(self.columns).then_some(false); + let rows = chars.chunks(self.columns).map(|row| row.iter().take(EDGE_CELLS).any(|c| !c.is_whitespace())); + for row in rows.chain(blank) { + panel[cursor] = row; + if cursor + 1 == self.rows { + panel.remove(0); + panel.push(false); + } else { + cursor += 1; + } } } panel diff --git a/tests/toyos.rs b/tests/toyos.rs index bab1c23c1cf..bd95116a8de 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -3758,16 +3758,6 @@ fn run_screen_test( metal_sim_argv_check(&qemu::profile_argv(&options))?; let mut qemu = QemuInstance::boot_with_options(test_config, c_bins, rust_bins, options); let console = qemu.boot_log().to_string(); - let dump = qemu.screendump(); - // A row that decodes in the kernel's font is a row the kernel - // drew. Refused rather than counted: its rows are not the loader's. - if dump.rows().iter().any(|row| !row.trim().is_empty() && !row.contains(screen::UNKNOWN)) { - return Err(format!( - "the kernel had already repainted the panel at the loader's last line, so \ - these are its rows and not the loader's\ndecoded screen:\n{}", - dump.text() - )); - } // `ClearScreen` blanks the whole panel and puts `ESC[2J` on the // serial, so every line after the last one is on the panel too. @@ -3777,29 +3767,48 @@ fn run_screen_test( are on the panel\n{console}" )); }; - // A CSI sequence moves the cursor or sets a colour and draws nothing. - let drawn = |line: &str| { - let mut text = String::new(); - let mut chars = line.chars(); - while let Some(c) = chars.next() { - if c == '\x1b' && chars.clone().next() == Some('[') { - chars.by_ref().skip(1).find(|c| ('\x40'..='\x7e').contains(c)); - } else { - text.push(c); + let lines: Vec<&str> = shown.lines().collect(); + if !lines.iter().any(|line| line.contains(bootlog::LOADER_GOP_LINE)) { + return Err(format!("the loader never printed {:?}\n{console}", bootlog::LOADER_GOP_LINE)); + } + + // The serial line can end before the panel's own line feed has + // scrolled it, so the panel is read until two dumps in a row carry + // the same rows. The ceiling is liveness alone: past the loader's + // last line only the kernel draws, and its repaint is refused. + let map = |rows: &[bool]| rows.iter().map(|&lit| if lit { '#' } else { '.' }).collect::(); + let ceiling = Instant::now() + qemu.budget(Duration::from_secs(30)); + let mut before: Option<(screen::TextMode, Vec)> = None; + let (mode, carried) = loop { + let dump = qemu.screendump(); + // A row that decodes in the kernel's font is a row the kernel + // drew. Refused rather than counted: its rows are not the loader's. + if dump.rows().iter().any(|row| !row.trim().is_empty() && !row.contains(screen::UNKNOWN)) { + return Err(format!( + "the kernel had already repainted the panel after the loader's last \ + line, so these are its rows and not the loader's\ndecoded screen:\n{}", + dump.text() + )); + } + let mode = dump.firmware_text_mode()?; + let now = (mode, dump.edge_rows(mode)); + match before.replace(now.clone()) { + Some(was) if was == now => break now, + Some((was, rows)) if Instant::now() >= ceiling => { + return Err(format!( + "the panel still changed between its last two dumps when the ceiling \ + passed\nbefore: {was:?} {}\nafter: {mode:?} {}\n{console}", + map(&rows), + map(&now.1) + )); } + _ => {} } - text }; - let lines: Vec = shown.lines().map(drawn).collect(); - let query = lines - .iter() - .position(|line| line.contains(bootlog::LOADER_GOP_LINE)) - .ok_or_else(|| format!("the loader never printed {:?}\n{console}", bootlog::LOADER_GOP_LINE))?; - let mode = dump.firmware_text_mode()?; - let (carried, printed) = (dump.edge_rows(mode), mode.panel(lines.iter().map(String::as_str))); + + let printed = mode.panel(lines.iter().copied()); let count = |rows: &[bool]| rows.iter().filter(|&&lit| lit).count(); if carried != printed { - let map = |rows: &[bool]| rows.iter().map(|&lit| if lit { '#' } else { '.' }).collect::(); return Err(format!( "the panel's first {} cells carry {} rows of text where the console put {} there \ in its {}x{} text mode, row by row\npanel: {}\nconsole: {}\n{console}", @@ -3814,11 +3823,10 @@ fn run_screen_test( } eprintln!( " [screen] the panel carries all {} rows the console put at its edge in its {}x{} \ - text mode, {} of them after the GOP query", + text mode", count(&printed), mode.columns, - mode.rows, - count(&mode.printed(lines[query + 1..].iter().map(String::as_str))) + mode.rows ); Ok(()) }