Skip to content

The desktop's eight icons are drawn at build time by ToyOS's own rasterizer, and resvg leaves the shipped programs - #814

Open
Japabu wants to merge 3 commits into
mainfrom
wt/toyos-icons
Open

Japabu wants to merge 3 commits into
mainfrom
wt/toyos-icons

Conversation

@Japabu

@Japabu Japabu commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

The compositor and files used to rasterize the 8 committed Phosphor SVGs at startup through resvg/usvg/tiny-skia, which put 33 third-party crates into each of them. Now the build draws the icons, in src/icons.rs, and ships them as share/icons/<stem>.alpha coverage masks. Each program colours its mask. No shipped program carries a rasterizer any more, and resvg is not in the host build either.

The owner's rulings

  • An own rasterizer, not resvg anywhere. In the dependency audit, the owner chose the option worded "own icon rasterizer replacing resvg (33 crates)" (question tool, 2026-10-09, answer "Cheap wins (Recommended)", among others). This is why resvg does not move into toyos-build's [dependencies] either, unlike fontdue for the console font.
  • The measured differences are accepted. He was shown "35 of 3,836 icon pixels by one antialiasing step (16/255 of opacity) and 245 by 1/255 of colour" and the zoomed screenshots; the measured breakdown below (23 off by 16/255, 4 by 15/255, 8 by 12/255) lies inside that figure, and he was told so afterwards. He answered "Accept the differences".

Decisions

  • Drawn at build time, not at run time. The icons are drawn at three fixed sizes only: cursors at 20 px, title-bar buttons at 14 and files' entries at 32. Nothing scales them, because the compositor has no scale factor (rg -i 'scale_factor|hidpi|buffer_scale' userland toyos toyos-abi finds nothing). A run-time rasterizer would ship about 700 lines in two programs to redraw bytes that never change. The rasterizer lives in src/, the build crate, so the image has none.
  • One declaration of each size. SIZES in src/icons.rs sets each icon's size, and the .alpha header carries it. files lays out by icon.width()/height(). The compositor drops CURSOR_PX and damages the largest cursor's extent.
  • It follows resvg's pixels, not more accurate ones. I first measured exact-area coverage (16×16 samples) against resvg. It differed by up to 45/255 on 597 of the 3836 pixels, and all of that came from tiny-skia's coarser sampling. The desktop's look is not this change's to move, so the rasterizer copies tiny-skia's arithmetic:
    • 4×4 point samples worth 16 each, 63 on a pixel's last row
    • kurbo's arc-to-cubic split at usvg's 0.1 tolerance
    • cubics cut where they turn in y, then split into tiny-skia's chord count
    • a stroke flattened into as many chords as tiny-skia's stroker outline has (offset quads halved to ¼ px, then quad chord counts)
    • source-over between shapes
  • It refuses everything outside the icons' SVG subset, by name. That covers <g>, literal colours, an unread attribute or path command, and a stroke whose cap or join is not round. Each refusal has a test.
  • The SVGs no longer ship, and an icon ships only when git tracks it. root_carries_what_the_repository_declares carries a tracked icons/x-bold.svg and an untracked icons/minus-bold.svg, and expects only share/icons/x-bold.alpha. So the .svg arm's ships guard is tested.
  • A moved pixel says what to do. the_icons_are_the_pixels_compared_with_resvg pins the masks' sha256. Its failure message says to draw the icons against resvg 0.47 out of tree with the differential posted on this pull request, and to ask the owner again on any visible change before pinning the new digest.
  • Two records this change made false are corrected. The NOTICE line that said the SVGs ship as share/icons/*.svg now says what ships. One issue (five-apps-read-the-system-font…) cited files' .svg read and its line numbers, and now matches the code. Both edits are outside the brief's fence.

Oracle: resvg 0.47, a one-off differential outside the tree

The program is posted in a comment on this pull request. It compiles src/icons.rs from the worktree, beside the old Sprite::from_svg_colored copied verbatim, at every size and colour the system draws. "drawn" is Sprite::draw's blend over black, white, 0x808080 and files' background, max per channel. This is its output at cae8344, rebuilt offline from the pre-branch lock:

icon                       px  pixels |   alpha max/n | drawn: alpha same | drawn: alpha moved |        drawn: all
cursor-bold                20     400 |     16/3      |        0/0        |       16/3        |       16/3
arrow-down-right-bold      20     400 |      0/0      |        0/0        |        0/0        |        0/0
crosshair-simple-bold      20     400 |     16/29     |        0/0        |       16/29       |       16/29
minus-bold                 14     196 |      0/0      |        0/0        |        0/0        |        0/0
square-bold                14     196 |      0/0      |        0/0        |        0/0        |        0/0
x-bold                     14     196 |      0/0      |        0/0        |        0/0        |        0/0
folder-bold                32    1024 |     16/1      |        1/127      |       14/1        |       14/128
file-bold                  32    1024 |     16/2      |        1/118      |       13/2        |       13/120
all 8 icons, 3836 pixels: alpha max 16 over 35 pixels; drawn max 16 over 280 pixels
sha256 of the eight .alpha files, in src/icons.rs's order: bc246dcfb81736e4aef449383ea3c666e38122ca47d0b2a2dfead9302653929e
EXIT=0
  • Coverage: 35 pixels differ, by at most one antialiasing sample: 23 by 16/255, 4 by 15/255 (a sample on a pixel's last row) and 8 by 12/255. 29 of them are in the crosshair, each with more coverage than resvg's. The causes are tiny-skia's fixed-point rounding at ties and the outline of its stroker. The other 3801 pixels are equal.
  • Colour: 245 pixels differ by at most 1/255 where coverage is equal. That is resvg's premultiply-then-unpremultiply round trip, which the masks no longer make.

Guest screenshots: before (origin/main d6298c8) and after (a0e5b76)

These are QMP screendumps of the default x86-64 image under TCG, taken at a0e5b76. The masks' digest is the same at a0e5b76, 3b36e81 and cae8344, so the screens are the same at this head. The view is files at /system/share (folder and file icons), with the title-bar buttons and the default and resize cursors on screen.

== files view, cursor at (700,500)
screen                       1280x800: max channel difference 200, differing pixels 1065
title-bar-buttons            89x28:    max 0,  differing 0
files-icons-row              1198x80:  max 13, differing 835
default-cursor               30x30:    max 14, differing 3
taskbar-clock-and-meters     240x32:   max 200, differing 227   (clock and memory text)
== cursor on the resize corner
resize-cursor                30x30:    max 0,  differing 0

Every differing pixel on screen is in the icons row (835), the cursor (3) or the clock and meters (227): 835 + 3 + 227 = 1065. Nothing in the default image asks for the crosshair, so it is measured on the host only. Its coverage, side by side with resvg's, is posted in a comment on this pull request, together with the zoomed crops of the screens. Those were driven by a Python QMP script. A future measurement drives QMP through the harness's Rust (tests/common/qemu.rs, tests/common/screen.rs).

What leaves the image

  • Per program: cargo +toyos tree --offline --locked -p <p> --target x86_64-unknown-toyos -e normal,build
    • compositor: 33 → 0 third-party crates
    • files: 33 → 0
  • Across every program in system.toml plus supervisor: 270 → 247. The 23 that leave are arrayref, arrayvec, base64, bytemuck, data-url, fdeflate, float-cmp, imagesize, kurbo, miniz_oxide 0.8, pico-args, png, resvg, rgb, roxmltree, simplecss, siphasher, strict-num, svgtypes, tiny-skia, tiny-skia-path, usvg and xmlwriter. The other ten are still used elsewhere.
  • Root Cargo.lock: 22 packages removed.
  • Shipped binaries:
    • bin/compositor: 2,581,344 → 917,912 bytes
    • bin/files: 2,393,584 → 727,936 bytes

Gates (head cae8344; origin/main d6298c8 had nothing new to merge)

gate exit
cargo test --lib icons:: 0
cargo test --lib assets:: 0 (6 passed, including root_carries_what_the_repository_declares)
cargo run -- --ci host 0 ("78 step(s), all green"; both tests above pass in it). Load average 8.6 at start, 13.5 at end.
cargo run -- --build-only 0
Mutation: if ships(&path) dropped from the .svg arm root_carries_what_the_repository_declares red, exit 101 (minus-bold.alpha shipped); tree restored
Mutation: twice the chords per cubic in chords the_icons_are_the_pixels_compared_with_resvg red with the new message, exit 101; tree restored
cargo test (whole guest suite), at 3b36e81 0, 41 of 41. Not rerun: cae8344 changes only host tests and a doc comment.
clippy --ci host's cargo clippy --workspace --all-targets -D warnings: clean

Both mutation patches are posted in a comment on this pull request.

Size

  • Branch: +892/−301. Of that, Cargo.lock is −211 net.
  • src/icons.rs: 721 production lines (host build only) and 107 test lines.
  • Shipped userland: −38 lines net.

What I am unsure of

  • Growth: if an icon is ever drawn at a run-time scale, this rasterizer has to move into a shipped crate.
  • Open on hosts, unchanged by this branch: files on a host still reads /system/share/icons and panics there, as it did with the SVGs.
  • The evidence images live on wt/toyos-icons-evidence and are linked only from comments. Those links break when that branch is deleted after landing.

🤖 Generated with Claude Code

https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C

Japabu and others added 2 commits October 9, 2026 18:15
…he image

The compositor and files rasterized eight committed Phosphor SVGs at start
through resvg, usvg and tiny-skia: 33 third-party crates in each program,
inside the server that owns the framebuffer, for icons that never change and
are drawn at three fixed sizes (cursors 20, title-bar buttons 14, files'
entries 32). Nothing in the tree draws at another scale: the compositor has
no scale factor.

So the build draws them, as it already draws the console font: src/icons.rs
reads the SVG subset the icons are written in, refuses anything past it by
name, and ships each as share/icons/<stem>.alpha, a coverage mask the program
colours. No shipped program carries a rasterizer; userland/sprite keeps only
the mask loader and the blit, and the programs lay out by the size the mask
carries, so the size is declared once, beside the icon.

The owner forbids a visible change, so the rasterizer reproduces resvg's
pixels rather than better ones: tiny-skia's 4x4 point samples worth 16 each
(63 on a pixel's last sample row), kurbo's arc-to-cubic split at usvg's 0.1
tolerance, tiny-skia's chord counts for a cubic edge and for the quads its
stroker approximates an offset curve with, and source-over between shapes.
Exact-area coverage was measured first and differed from resvg by up to
45/255 on 597 of 3836 pixels, all of it resvg's coarser sampling; with the
emulation 35 pixels differ by one sample (16/255) and the rest are equal.
A one-off differential against resvg 0.47, outside the tree, gives the
numbers; the host test pins the digest of the pixels it compared.

The SVGs no longer ship. Removed from the root lock: 22 packages; from the
compositor and files: all 33 third-party crates each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
The host gate's clippy pass denied manual midpoints and the tag tuple's complexity; the pixels do not move (the digest test holds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence the body rests on: the one-off resvg differential, the screenshot diff and crop tools, the QMP driver, and the mutation patch. None of it is in the tree. The oracle's src/icons.rs is a symlink to the worktree's file, and its Cargo.lock is a copy of the root lock taken before this branch, so resvg is 0.47.0 with the same dependency versions.

oracle Cargo.toml

[package]
name = "icon-oracle"
version = "0.0.0"
edition = "2021"
publish = false

[dependencies]
resvg = { version = "=0.47.0", default-features = false }
sha2 = "0.10"

[workspace]

oracle src/main.rs (run: icon-oracle <worktree> <outdir>; a third argument dumps one icon's alpha side by side)

//! One-off differential: resvg 0.47 (the code `userland/sprite` ran before this
//! branch, copied verbatim) against the build-time rasterizer in
//! `src/icons.rs` (compiled from the worktree's own file), at every size and
//! colour the system draws each icon with.
//!
//! Usage: icon-oracle <worktree> <outdir>

#[path = "icons.rs"]
#[allow(dead_code)]
mod icons;

use resvg::{tiny_skia, usvg};
use std::fmt::Write as _;

/// The old `Sprite::from_svg_colored`, verbatim.
fn old(svg_bytes: &[u8], size: u32, color: [u8; 3]) -> Vec<u8> {
    let svg_str = String::from_utf8_lossy(svg_bytes);
    let hex = format!("#{:02x}{:02x}{:02x}", color[0], color[1], color[2]);
    let replaced = svg_str.replace("currentColor", &hex);
    let tree = usvg::Tree::from_data(replaced.as_bytes(), &usvg::Options::default()).expect("parse");
    let mut pixmap = tiny_skia::Pixmap::new(size, size).expect("pixmap");
    let svg_size = tree.size();
    let sx = size as f32 / svg_size.width();
    let sy = size as f32 / svg_size.height();
    resvg::render(&tree, tiny_skia::Transform::from_scale(sx, sy), &mut pixmap.as_mut());
    let mut data = pixmap.take();
    for chunk in data.chunks_exact_mut(4) {
        let a = chunk[3] as u16;
        if a > 0 && a < 255 {
            chunk[0] = ((chunk[0] as u16 * 255) / a) as u8;
            chunk[1] = ((chunk[1] as u16 * 255) / a) as u8;
            chunk[2] = ((chunk[2] as u16 * 255) / a) as u8;
        }
    }
    data
}

/// The new `Sprite::from_alpha`: the colour, at the mask's coverage.
fn new(alpha_file: &[u8], color: [u8; 3]) -> (u32, Vec<u8>) {
    let w = u32::from_le_bytes(alpha_file[0..4].try_into().unwrap());
    let mask = &alpha_file[8..];
    (w, mask.iter().flat_map(|&a| [color[0], color[1], color[2], a]).collect())
}

/// `Sprite::draw`'s blend of one straight-alpha pixel over `bg`.
fn over(px: &[u8], bg: [u8; 3]) -> [u8; 3] {
    let a = px[3] as u16;
    if a == 0 {
        return bg;
    }
    let inv = 255 - a;
    std::array::from_fn(|i| ((px[i] as u16 * a + bg[i] as u16 * inv) / 255) as u8)
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let (root, out) = (std::path::Path::new(&args[1]), std::path::Path::new(&args[2]));
    if let Some(stem) = args.get(3) { return dump(stem, args[4].parse().unwrap(), root); }
    std::fs::create_dir_all(out).unwrap();
    // (stem, size, colour, what draws it and over what)
    let white = [255, 255, 255];
    let cases: &[(&str, u32, [u8; 3])] = &[
        ("cursor-bold", 20, white),
        ("arrow-down-right-bold", 20, white),
        ("crosshair-simple-bold", 20, [0, 0, 0]),
        ("minus-bold", 14, white),
        ("square-bold", 14, white),
        ("x-bold", 14, white),
        ("folder-bold", 32, [0xf0, 0xc8, 0x50]),
        ("file-bold", 32, [0xd0, 0xd0, 0xd8]),
    ];
    let backgrounds: &[[u8; 3]] = &[[0, 0, 0], [255, 255, 255], [0x80, 0x80, 0x80], [0x1e, 0x1e, 0x2e]];
    let mut report = String::new();
    writeln!(
        report,
        "{:<24} {:>4} {:>7} | {:>13} | {:>17} | {:>17} | {:>17}",
        "icon", "px", "pixels", "alpha max/n", "drawn: alpha same", "drawn: alpha moved", "drawn: all"
    )
    .unwrap();
    let mut worst = (0u8, 0usize, 0u8, 0usize);
    let mut digest = <sha2::Sha256 as sha2::Digest>::new();
    for &(stem, size, color) in cases {
        let svg = std::fs::read(root.join(format!("assets/icons/{stem}.svg"))).unwrap();
        let reference = old(&svg, size, color);
        let file = icons::rasterize(stem, &svg);
        sha2::Digest::update(&mut digest, &file);
        let (w, mine) = new(&file, color);
        assert_eq!(w, size, "{stem}: built at {w}, drawn at {size}");
        assert_eq!(mine.len(), reference.len());
        let n = (size * size) as usize;
        let (mut amax, mut acount, mut cmax, mut ccount, mut bmax, mut bcount) = (0u8, 0, 0u8, 0, 0u8, 0);
        let mut signed = 0i32;
        for i in 0..n {
            let (r, m) = (&reference[i * 4..][..4], &mine[i * 4..][..4]);
            let ad = r[3].abs_diff(m[3]);
            if ad.max(0) > 0 {
                acount += 1;
            }
            if (m[3] as i32 - r[3] as i32).abs() > signed.abs() {
                signed = m[3] as i32 - r[3] as i32;
            }
            amax = amax.max(ad);
            // Drawn as `Sprite::draw` blends, over each background.
            let mut d = 0u8;
            for &bg in backgrounds {
                let (x, y) = (over(r, bg), over(m, bg));
                d = d.max((0..3).map(|c| x[c].abs_diff(y[c])).max().unwrap());
            }
            if r[3] == m[3] {
                cmax = cmax.max(d);
                ccount += (d > 0) as usize;
            } else {
                bmax = bmax.max(d);
                bcount += (d > 0) as usize;
            }
        }
        writeln!(
            report,
            "{stem:<24} {size:>4} {n:>7} | {amax:>6}/{acount:<6} | {cmax:>8}/{ccount:<8} | {bmax:>8}/{bcount:<8} | {:>8}/{:<8}", cmax.max(bmax), ccount + bcount
        )
        .unwrap();
        let _ = signed;
        worst = (worst.0.max(amax), worst.1 + acount, worst.2.max(cmax.max(bmax)), worst.3 + ccount + bcount);

        // Side by side at 8x: resvg | ours | |difference| x16, over the files background.
        let scale = 8usize;
        let s = size as usize;
        let (pw, ph) = (s * scale * 3 + 2 * scale, s * scale);
        let mut ppm = format!("P6\n{pw} {ph}\n255\n").into_bytes();
        let bg = if color == [0, 0, 0] { [0xff, 0xff, 0xff] } else { [0x1e, 0x1e, 0x2e] };
        for py in 0..ph {
            for px in 0..pw {
                let panel = px / (s * scale + scale);
                let x = (px % (s * scale + scale)) / scale;
                let y = py / scale;
                let pix = if x >= s {
                    [0x50, 0x50, 0x50]
                } else {
                    let i = (y * s + x) * 4;
                    match panel {
                        0 => over(&reference[i..i + 4], bg),
                        1 => over(&mine[i..i + 4], bg),
                        _ => {
                            let (a, b) = (over(&reference[i..i + 4], bg), over(&mine[i..i + 4], bg));
                            let d = (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap();
                            let v = (d as u32 * 16).min(255) as u8;
                            [v, v, v]
                        }
                    }
                };
                ppm.extend(pix);
            }
        }
        std::fs::write(out.join(format!("{stem}-{size}.ppm")), ppm).unwrap();
    }
    writeln!(
        report,
        "all 8 icons, 3836 pixels: alpha max {} over {} pixels; drawn max {} over {} pixels",
        worst.0, worst.1, worst.2, worst.3
    )
    .unwrap();
    let hex: String = sha2::Digest::finalize(digest).iter().map(|b| format!("{b:02x}")).collect();
    writeln!(report, "sha256 of the eight .alpha files, in src/icons.rs's order: {hex}").unwrap();
    print!("{report}");
}

#[allow(dead_code)]
pub fn dump(stem: &str, size: u32, root: &std::path::Path) {
    let svg = match std::env::var("SVG") { Ok(f) => std::fs::read(f).unwrap(), Err(_) => std::fs::read(root.join(format!("assets/icons/{stem}.svg"))).unwrap() };
    let r = old(&svg, size, [255, 255, 255]);
    let m = icons::rasterize(stem, &svg);
    for y in 0..size as usize {
        let a: Vec<String> = (0..size as usize).map(|x| format!("{:3}", r[(y * size as usize + x) * 4 + 3])).collect();
        let b: Vec<String> = (0..size as usize).map(|x| format!("{:3}", m[8 + y * size as usize + x])).collect();
        println!("{}  |  {}", a.join(""), b.join(""));
    }
}

src/bin/shotdiff.rs

//! shotdiff <before.ppm> <after.ppm> <name x y w h>... : per region, max channel
//! difference and differing pixel count; whole screen too.
fn ppm(p: &str) -> (usize, usize, Vec<u8>) {
    let b = std::fs::read(p).unwrap();
    let mut fields = vec![]; let mut i = 0;
    while fields.len() < 4 {
        while b[i].is_ascii_whitespace() { i += 1 }
        let s = i; while !b[i].is_ascii_whitespace() { i += 1 }
        fields.push(String::from_utf8_lossy(&b[s..i]).to_string());
    }
    let (w, h): (usize, usize) = (fields[1].parse().unwrap(), fields[2].parse().unwrap());
    (w, h, b[i + 1..i + 1 + w * h * 3].to_vec())
}
fn main() {
    let a: Vec<String> = std::env::args().collect();
    let (w, h, x) = ppm(&a[1]);
    let (w2, h2, y) = ppm(&a[2]);
    assert_eq!((w, h), (w2, h2));
    let mut regions: Vec<(String, usize, usize, usize, usize)> = vec![("screen".into(), 0, 0, w, h)];
    for c in a[3..].chunks(5) {
        regions.push((c[0].clone(), c[1].parse().unwrap(), c[2].parse().unwrap(), c[3].parse().unwrap(), c[4].parse().unwrap()));
    }
    for (name, rx, ry, rw, rh) in regions {
        let (mut max, mut n) = (0u8, 0usize);
        for py in ry..ry + rh { for px in rx..rx + rw {
            let o = (py * w + px) * 3;
            let d = (0..3).map(|c| x[o + c].abs_diff(y[o + c])).max().unwrap();
            max = max.max(d); n += (d > 0) as usize;
        }}
        println!("{name:<28} x={rx} y={ry} {rw}x{rh}: max channel difference {max}, differing pixels {n}");
    }
}

src/bin/cropzoom.rs

//! cropzoom <before.ppm> <after.ppm> <out.ppm> <x> <y> <w> <h> <scale>: before |
//! after | difference x16, stacked vertically, each scaled up.
fn ppm(p: &str) -> (usize, usize, Vec<u8>) {
    let b = std::fs::read(p).unwrap();
    let mut fields = vec![]; let mut i = 0;
    while fields.len() < 4 {
        while b[i].is_ascii_whitespace() { i += 1 }
        let s = i; while !b[i].is_ascii_whitespace() { i += 1 }
        fields.push(String::from_utf8_lossy(&b[s..i]).to_string());
    }
    let (w, h): (usize, usize) = (fields[1].parse().unwrap(), fields[2].parse().unwrap());
    (w, h, b[i + 1..i + 1 + w * h * 3].to_vec())
}
fn main() {
    let a: Vec<String> = std::env::args().collect();
    let (w, _, x) = ppm(&a[1]);
    let (_, _, y) = ppm(&a[2]);
    let n: Vec<usize> = a[4..9].iter().map(|s| s.parse().unwrap()).collect();
    let (cx, cy, cw, ch, s) = (n[0], n[1], n[2], n[3], n[4]);
    let gap = s;
    let (ow, oh) = (cw * s, (ch * s) * 3 + gap * 2);
    let mut out = format!("P6\n{ow} {oh}\n255\n").into_bytes();
    for oy in 0..oh {
        let panel = oy / (ch * s + gap);
        let py = (oy % (ch * s + gap)) / s;
        for ox in 0..ow {
            let px = ox / s;
            let pix = if py >= ch { [0x80, 0x80, 0x80] } else {
                let o = ((cy + py) * w + cx + px) * 3;
                let (p, q) = ([x[o], x[o + 1], x[o + 2]], [y[o], y[o + 1], y[o + 2]]);
                match panel { 0 => p, 1 => q, _ => { let d = (0..3).map(|c| p[c].abs_diff(q[c])).max().unwrap(); let v = (d as u32 * 16).min(255) as u8; [v, v, v] } }
            };
            out.extend(pix);
        }
    }
    std::fs::write(&a[3], out).unwrap();
}

src/bin/strokedump.rs (prints tiny-skia's stroked circle outline, which the stroke chord model was read from)

use resvg::tiny_skia::{self, PathBuilder, Stroke, LineCap, LineJoin, Transform};
fn main() {
    let r = 96.0f32; let (cx, cy) = (128.0f32, 128.0f32);
    // usvg's circle: four kurbo quarter arcs as cubics.
    let k = 4.0 / 3.0 * (std::f32::consts::FRAC_PI_8).tan() * r;
    let mut pb = PathBuilder::new();
    pb.move_to(cx + r, cy);
    pb.cubic_to(cx + r, cy + k, cx + k, cy + r, cx, cy + r);
    pb.cubic_to(cx - k, cy + r, cx - r, cy + k, cx - r, cy);
    pb.cubic_to(cx - r, cy - k, cx - k, cy - r, cx, cy - r);
    pb.cubic_to(cx + k, cy - r, cx + r, cy - k, cx + r, cy);
    pb.close();
    let path = pb.finish().unwrap();
    let stroke = Stroke { width: 24.0, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
    let ts = Transform::from_scale(20.0 / 256.0, 20.0 / 256.0);
    let res = tiny_skia::PathStroker::compute_resolution_scale(&ts);
    println!("res_scale {res}");
    let out = path.stroke(&stroke, res).unwrap();
    for seg in out.segments() { println!("{seg:?}"); }
}

QMP driver for the screendumps (a scratch instrument, not ToyOS code)

QEMU 11.1.1: qemu-system-x86_64 -nodefaults -cpu qemu64,+rdrand,+smap,+fsgsbase,+x2apic,+smep -machine q35 -smp cores=4 -m 2G, the edk2 x86_64 code and a fresh i386 vars copy as pflash, the image as usb-storage on nec-usb-xhci, nvme.img on nvme, usb-kbd and usb-tablet, -vga std -display none, and -qmp unix:<sock>. The sequence: click the launcher at (15,784), then Files at (32,754); click .. (88,118) four times, system (648,118) twice and share (328,118) twice; move to (700,500) and dump; move to (1237,725) and dump.

# usage: qmp.py <sock> dump <out.ppm> | move <x> <y> | click <x> <y> | key <qcode>
import json, socket, sys, time
sock = sys.argv[1]
s = socket.socket(socket.AF_UNIX); s.connect(sock)
f = s.makefile('rw')
def recv():
    while True:
        m = json.loads(f.readline())
        if 'event' in m: continue
        return m
recv()
def cmd(c, **a):
    f.write(json.dumps({'execute': c, 'arguments': a} if a else {'execute': c}) + '\n'); f.flush()
    r = recv()
    if 'error' in r: raise SystemExit(str(r))
    return r
cmd('qmp_capabilities')
op = sys.argv[2]
W, H = int(sys.argv[-2]) if False else 0, 0
def abs_events(x, y):
    # usb-tablet takes 0..0x7fff across the screen; screen size given by env
    import os
    sw, sh = int(os.environ['SW']), int(os.environ['SH'])
    return [{'type': 'abs', 'data': {'axis': 'x', 'value': int(x * 0x7fff / sw)}},
            {'type': 'abs', 'data': {'axis': 'y', 'value': int(y * 0x7fff / sh)}}]
if op == 'dump':
    cmd('screendump', filename=sys.argv[3])
elif op == 'move':
    cmd('input-send-event', events=abs_events(float(sys.argv[3]), float(sys.argv[4])))
elif op == 'click':
    cmd('input-send-event', events=abs_events(float(sys.argv[3]), float(sys.argv[4])))
    time.sleep(0.2)
    cmd('input-send-event', events=[{'type': 'btn', 'data': {'down': True, 'button': 'left'}}])
    time.sleep(0.15)
    cmd('input-send-event', events=[{'type': 'btn', 'data': {'down': False, 'button': 'left'}}])
elif op == 'quit':
    cmd('quit')
print('ok')

mutation (exit 101, the tree restored)

--- a/src/icons.rs
+++ b/src/icons.rs
@@
-    let shift = ((64 - distance.leading_zeros() as i64) >> 1) + extra;
+    let shift = ((64 - distance.leading_zeros() as i64) >> 1) + extra + 1;

@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #814 at 3b36e81 (round 1)

Net lines (git diff --shortstat origin/main...3b36e8130): 12 files, +884/−301. Cargo.lock is −211 net. Production is src/icons.rs at 721 lines plus about 20 in src/assets.rs/src/lib.rs, and shipped userland is −38. Tests are 101 lines in src/icons.rs. Without the lock, production grows by about +700 lines.

Evidence checked against the logs: --ci host started at 16:50:34Z, after 3b36e81 was committed at 16:50:17Z. It ends "78 step(s), all green", EXIT=0. The SIGABRT and FAILED lines in it are the loom controls doorbell-kick-relaxed/push-fence-relaxed reaching their verdicts. The digest test passes in that run (line 215). The guest suite exits 0. The mutation goes red with exit 101. The oracle and the screendumps were taken at a0e5b76, and the digest is unchanged at head, so they carry over.

BLOCKER

  • src/icons.rs:1-720 — 721 lines of hand-written SVG parsing and a copy of tiny-skia's arithmetic, where resvg as a dependency of toyos-build would ship the same .alpha masks with every pixel equal to before, in about 30 lines. The body justifies build time with "the build already draws the console font the same way (src/assets.rs)". But src/assets.rs:15,114 draws that font with fontdue, a general crate in the build's [dependencies], not with a rasterizer of our own. So the tree's own precedent is the shorter design. Root CLAUDE.md says "one that does our job we write ourselves", and SVG rendering is not ToyOS's job. The copy is also what creates everything the owner is now asked to rule on:

    • the 35 coverage pixels off by 16/255;
    • the 245 colour pixels off by 1/255;
    • a digest test (:767) whose failure message says "compared with resvg again", using an oracle that exists only in a PR comment.

    With resvg in the build, the comparison is zero by construction, and the owner's sign-off is not needed. Close by either:

    • taking resvg (and sha2, already present) in toyos-build, rasterizing in src/assets.rs, deleting src/icons.rs's rasterizer, and stating why the crate is taken; or
    • quoting in the body the owner's ruling that keeps resvg out of the host build as well as out of the shipped programs. The brief cites only "Cheap wins", and the body does not quote any such ruling.

NOTE

  • src/assets.rs:337-342 — the new .svg arm's ships(&path) is no longer tested, because the tracked/untracked test moved from icons/kept.svg to icons/kept.png (:400-446). If if ships(&path) were dropped from that arm, nothing would go red. (This disappears if the arm becomes a resvg call with the same guard and the test keeps an .svg.)
  • PR comment, "QMP driver" — the screendumps were driven by a new Python script. The harness already sends QMP screendump and input-send-event in Rust (tests/common/qemu.rs, tests/common/screen.rs), and root CLAUDE.md says ToyOS writes no Python of its own. It is not in the tree, so this is not a blocker. The next measurement should use the harness.
  • PR body, "Guest screenshots" — the crosshair carries 29 of the 35 differing coverage pixels, but the owner has no image of it. The oracle wrote crosshair-simple-bold-20.ppm side by side, and it was not posted. Without it, the evidence does not let him judge the worst icon. (Moot if the first BLOCKER is closed with resvg.)
  • wt/toyos-icons-evidence — an orphan branch of about 1 MB of PNGs, linked from a body that becomes main's merge record. Every git fetch of origin pulls it into the shared object store. Deleting it later breaks the record's links, and keeping it leaves permanent clutter outside main. Screenshots that only serve the owner's decision belong in the PR conversation or a gist. The merge record should keep the numbers and no links into a branch that gets deleted.
  • PR body — "after (this branch)": the screendumps are of a0e5b76 (commit-evidence.txt), not of 3b36e81. The unchanged digest covers the difference; the body should say so.

Judgements asked in the brief

  • Comparison method: a one-off resvg 0.47 differential on the pre-branch lock, plus a pinned digest, is a sound independent oracle for these eight icons at these sizes. Its weakness is that it cannot be re-run from the tree, which is part of the BLOCKER.
  • Build time vs run time: build time is right. Nothing scales the icons, and no shipped program should carry a rasterizer. The open question is only which rasterizer the build uses.
  • The two edits outside the fence: acceptable. The branch itself made both NOTICE:188 and the issue's files citation false, and the new line numbers (:59, :62-63) match userland/files/src/main.rs at head.
  • T14 reading: not owed. The change does not target hardware. The pixels the compositor composes are a deterministic function of the masks, measured on the host and in the guest. The hardware-cursor upload path is unchanged code and receives a sprite of the same 20×20 size.

SEND BACK

…ow to re-measure it

The asset test carries a tracked and an untracked committed icon again, so
dropping the `.svg` arm's `ships` guard goes red: the untracked icon would
ship as `minus-bold.alpha`. The digest test's failure message now says what
to do (draw the icons against resvg 0.47 out of tree with the differential
posted on #814 and ask the owner again on any visible change) instead of
naming an oracle that exists only in a comment, and the module header no
longer claims the pixels are resvg's exactly: they follow resvg's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

The crosshair, side by side (round 2)

The crosshair has 29 of the 35 coverage pixels that differ, and it had no image. Below are its coverage values, drawn at 20 px in black, as it is used. resvg 0.47 is on the left and the build's mask (head cae8344, digest bc246dcf… unchanged since a0e5b76) is on the right.

Image, 8x, over white: resvg | the build | difference x16: https://github.com/ToyOSOrg/ToyOS/blob/431c97a9e84a945ad34926b150b628dd0db609df/zoom-crosshair-8x.png?raw=true

These links point into wt/toyos-icons-evidence, as do the screendump links in the comment above, and they break when that branch is deleted after landing.

Every one of the 29 is more coverage on our side: 17 by 16/255, 4 by 15 (a pixel's last sample row is worth 15), 8 by 12. All 35 differing pixels over the eight icons, as signed alpha (ours − resvg):

cursor-bold:            -16 x2, +16 x1
crosshair-simple-bold:  +16 x17, +15 x4, +12 x8
folder-bold:            -16 x1
file-bold:              -16 x2
arrow-down-right-bold, minus-bold, square-bold, x-bold: none
                     resvg 0.47                                                   the build
  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  |    0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
  0  0  0  0  0  0  0  0 48123123 48  0  0  0  0  0  0  0  0  |    0  0  0  0  0  0  0  0 48135135 48  0  0  0  0  0  0  0  0
  0  0  0  0  0 16159255255255255255255159 16  0  0  0  0  0  |    0  0  0  0  0 16159255255255255255255159 16  0  0  0  0  0
  0  0  0  0 64239255240192255255192240255239 64  0  0  0  0  |    0  0  0  0 80239255255192255255192255255239 80  0  0  0  0
  0  0  0 64255255144 16  0255255  0 16144255255 64  0  0  0  |    0  0  0 80255255144 16  0255255  0 16144255255 80  0  0  0
  0  0 16239255 80  0  0  0255255  0  0  0 80255239 16  0  0  |    0  0 16239255 80  0  0  0255255  0  0  0 80255239 16  0  0
  0  0160255144  0  0  0  0 80 80  0  0  0  0144255160  0  0  |    0  0160255144  0  0  0  0 80 80  0  0  0  0144255160  0  0
  0  0255240 16  0  0  0  0  0  0  0  0  0  0 16240255  0  0  |    0  0255255 16  0  0  0  0  0  0  0  0  0  0 16255255  0  0
  0 48255192  0  0  0  0  0  0  0  0  0  0  0  0192255 32  0  |    0 48255192  0  0  0  0  0  0  0  0  0  0  0  0192255 48  0
  0124255255255255 80  0  0  0  0  0  0 80255255255255124  0  |    0136255255255255 80  0  0  0  0  0  0 80255255255255136  0
  0124255255255255 80  0  0  0  0  0  0 80255255255255124  0  |    0136255255255255 80  0  0  0  0  0  0 80255255255255136  0
  0 48255192  0  0  0  0  0  0  0  0  0  0  0  0192255 32  0  |    0 48255192  0  0  0  0  0  0  0  0  0  0  0  0192255 48  0
  0  0255239 16  0  0  0  0  0  0  0  0  0  0 16239255  0  0  |    0  0255255 16  0  0  0  0  0  0  0  0  0  0 16255255  0  0
  0  0160255144  0  0  0  0 80 80  0  0  0  0144255160  0  0  |    0  0160255144  0  0  0  0 80 80  0  0  0  0144255160  0  0
  0  0 16240255 80  0  0  0255255  0  0  0 80255224 16  0  0  |    0  0 16240255 80  0  0  0255255  0  0  0 80255240 16  0  0
  0  0  0 64255255143 16  0255255  0 16143255255 64  0  0  0  |    0  0  0 80255255143 16  0255255  0 16143255255 80  0  0  0
  0  0  0  0 64240255239191255255191239255224 64  0  0  0  0  |    0  0  0  0 80240255255191255255191255255240 80  0  0  0  0
  0  0  0  0  0 16160255255255255255255160 16  0  0  0  0  0  |    0  0  0  0  0 16160255255255255255255160 16  0  0  0  0  0
  0  0  0  0  0  0  0  0 48124124 32  0  0  0  0  0  0  0  0  |    0  0  0  0  0  0  0  0 48136136 48  0  0  0  0  0  0  0  0
  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  |    0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0

This was made with the differential posted above, rebuilt out of tree offline from the same pre-branch lock. Two additions:

  • dump takes the drawing colour.
  • src/bin/ppm2png.rs writes the PNG with png =0.18.1, which is already in that lock.

The table it prints is the same at this head as at a0e5b76.

--- a/src/main.rs
+++ b/src/main.rs
@@ -167,7 +167,8 @@
 #[allow(dead_code)]
 pub fn dump(stem: &str, size: u32, root: &std::path::Path) {
     let svg = match std::env::var("SVG") { Ok(f) => std::fs::read(f).unwrap(), Err(_) => std::fs::read(root.join(format!("assets/icons/{stem}.svg"))).unwrap() };
-    let r = old(&svg, size, [255, 255, 255]);
+    let color = if std::env::var("BLACK").is_ok() { [0, 0, 0] } else { [255, 255, 255] };
+    let r = old(&svg, size, color);
     let m = icons::rasterize(stem, &svg);
     for y in 0..size as usize {
         let a: Vec<String> = (0..size as usize).map(|x| format!("{:3}", r[(y * size as usize + x) * 4 + 3])).collect();
--- a/Cargo.toml
+++ b/Cargo.toml
@@
 sha2 = "0.10"
+png = "=0.18.1"
// src/bin/ppm2png.rs
//! ppm2png <in.ppm> <out.png>: a binary P6 PPM, 8-bit, to an RGB PNG.
fn main() {
    let a: Vec<String> = std::env::args().collect();
    let b = std::fs::read(&a[1]).unwrap();
    let (mut fields, mut i) = (vec![], 0);
    while fields.len() < 4 {
        while b[i].is_ascii_whitespace() { i += 1 }
        let s = i;
        while !b[i].is_ascii_whitespace() { i += 1 }
        fields.push(String::from_utf8_lossy(&b[s..i]).to_string());
    }
    assert_eq!((fields[0].as_str(), fields[3].as_str()), ("P6", "255"));
    let (w, h): (u32, u32) = (fields[1].parse().unwrap(), fields[2].parse().unwrap());
    let mut enc = png::Encoder::new(std::io::BufWriter::new(std::fs::File::create(&a[2]).unwrap()), w, h);
    enc.set_color(png::ColorType::Rgb);
    enc.set_depth(png::BitDepth::Eight);
    enc.write_header().unwrap().write_image_data(&b[i + 1..i + 1 + (w * h * 3) as usize]).unwrap();
}

The guest screendumps in the comment above are of a0e5b76 (QEMU 11.1.1, TCG). The masks have the same digest at 3b36e81 and at cae8344, so the screens are the same at this head. No new guest measurement was taken this round. The next one drives QMP screendump and input-send-event through the harness's Rust (tests/common/qemu.rs, tests/common/screen.rs), not the Python driver above.

Mutations, round 2 (each applied with git apply --check, run, and reverted in the same script; tree clean after)

if ships(&path) dropped from the .svg arm, so root_carries_what_the_repository_declares sees the untracked minus-bold.svg ship: exit 101.

--- a/src/assets.rs
+++ b/src/assets.rs
@@ -335,11 +335,9 @@ pub fn collect(dirs: &[String], programs: &BTreeSet<&str>) -> Vec<(String, Vec<u
                 } else if !system_font && path.extension().is_some_and(|e| e == "ttf" || e == "jpg") {
                     continue;
                 } else if path.extension().is_some_and(|e| e == "svg") {
-                    if ships(&path) {
                         let stem = path.file_stem().unwrap().to_str().unwrap();
                         let svg = fs::read(&path).unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display()));
                         files.push((format!("{prefix}{stem}.alpha"), crate::icons::rasterize(stem, &svg)));
-                    }
                 } else if ships(&path) {
                     let name = path.file_name().unwrap().to_str().unwrap().to_lowercase();
                     let data = fs::read(&path).unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display()));

Twice the chords per cubic in chords, so the_icons_are_the_pixels_compared_with_resvg fails with the new message: exit 101.

--- a/src/icons.rs
+++ b/src/icons.rs
@@ -547,7 +547,7 @@ fn stroked(c: &[Point; 4], r: f64) -> Vec<Point> {
 fn chords(dx: i64, dy: i64, extra: i64) -> usize {
     let (dx, dy) = (dx.abs(), dy.abs());
     let distance = (dx.max(dy) + (dx.min(dy) >> 1) + 16) >> 5;
-    let shift = ((64 - distance.leading_zeros() as i64) >> 1) + extra;
+    let shift = ((64 - distance.leading_zeros() as i64) >> 1) + extra + 1;
     1 << shift.clamp(1, 6)
 }
 

@Japabu Japabu changed the title The desktop's eight icons are drawn at build time, and resvg's 33 crates leave the compositor and files The desktop's eight icons are drawn at build time by ToyOS's own rasterizer, and resvg leaves the shipped programs Oct 9, 2026
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #814 at cae8344 (round 2)

Net lines (git diff --shortstat origin/main...cae834426): 12 files, +892/−301. Cargo.lock is −211 net. Production: src/icons.rs 721 lines, plus 7 in src/assets.rs and src/lib.rs. Shipped userland: −38. Tests: 107 lines in src/icons.rs, and src/assets.rs's fixture reworked. Since 3b36e81, only the test modules and doc comments of src/assets.rs and src/icons.rs changed.

Round 1 BLOCKER

  • src/icons.rs:1-720, an own rasterizer where resvg in toyos-build would do: CLOSED by the owner's ruling. The body now quotes the option he chose, "own icon rasterizer replacing resvg (33 crates)", and his "Accept the differences". A rasterizer of our own rules out resvg in the build as well, so the ruling is not stated more broadly than he gave it.
    • The correction does not move what he accepted. He was shown 35 pixels off by 16/255. The oracle at cae8344 (oracle.txt, rebuilt from the pre-branch lock, digest bc246dcf…, the same as the test pins) gives the same 35 pixels: 23 off by 16, 4 by 15 and 8 by 12. The comment's signed list sums to the same counts (3+17+1+2 = 23). Every one is at or under the 16/255 bound he accepted, so the correction only shrinks 12 of them. Drawn colour is still at most 16 over 280 pixels, and the 1/255 drift is still on 245.

Round 1 NOTEs

  • src/assets.rs:337-342, the .svg arm's ships guard untested: CLOSED. The fixture carries a tracked x-bold.svg and an untracked minus-bold.svg.
    • Dropping if ships(&path) turns the test red with exit 101, and the log shows share/icons/minus-bold.alpha shipped (mutation-ships.log).
    • Doubling the chords (mutation-chords.log) turns the_icons_are_the_pixels_compared_with_resvg red with the new message.
    • Both test bodies are the head's own: the assertion messages appear only at cae8344.
  • The Python QMP driver: CLOSED. It is not in the tree, and the body says the next measurement goes through tests/common/qemu.rs and tests/common/screen.rs.
  • The crosshair had no image: CLOSED. Its coverage table, side by side, is in the round-2 comment, and the PNG is linked.
  • wt/toyos-icons-evidence links: CLOSED for the merge record. The body links into no branch; images are linked only from comments. The branch is still on origin (431c97a), and the orchestrator deletes it after landing.
  • "after (this branch)": CLOSED. The body names a0e5b76 and the unchanged digest.

Evidence at cae8344

  • cargo run -- --ci host: started 17:36:07Z. cae8344 was committed at 17:36:01Z, so the run is at this head. It ends "78 step(s), all green", EXIT=0.
    • root_carries_what_the_repository_declares passes in it (line 131), and so does the_icons_are_the_pixels_compared_with_resvg (line 212).
  • --build-only: EXIT=0.
  • Guest suite: green at 3b36e81, 41 of 41. Not owed again: the diff since that head touches only #[cfg(test)] code and doc comments, which no image carries.

NOTE

  • PR body, "The owner's rulings" — "He was shown the numbers below (… off by up to one antialiasing sample …)": he was shown the round-1 figure, 35 pixels off by 16/255, not the corrected breakdown. The corrected breakdown stays inside that figure, but the record should say what he was actually shown.

LAND

@Japabu
Japabu marked this pull request as ready for review October 9, 2026 17:42
@Japabu

Japabu commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI at cae834426, read from each job's log: host 79 steps all green; toolchain restored (llvm a2cc063281d9f279, compiler 6c76c19e5ffcc869, freestanding 0f5a5faab7bbc78c, sysroot fcaf4949f5310fa6); guest suite 41/41 ok. Merges clean with main 198a9d38e. Queued.

@Japabu
Japabu added this pull request to the merge queue Oct 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 9, 2026
@Japabu

Japabu commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

CI at cae834426, read from each job's log: host 79 steps all green; toolchain toolchain restored; guest suite 41/41 ok. Merges clean with main 9b691ddce. Queued.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant