321b2280
Merge branch 'gpu-render-testing'
a73x 2026-04-17 16:45
Commit message
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -6,3 +6,11 @@ zig-out/ | |||
| 6 | bench.log | 6 | bench.log |
| 7 | perf.data | 7 | perf.data |
| 8 | flamegraph.svg | 8 | flamegraph.svg |
| 9 | tests/golden/output/ | ||
| 10 | |||
| 11 | # Scratch test binaries (ad-hoc compilations) | ||
| 12 | /test_io | ||
| 13 | /test_io2 | ||
| 14 | /test_io3 | ||
| 15 | /test_sig | ||
| 16 | /test_timer | ||
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,7 @@ ZIG ?= zig | |||
| 2 | FLAMEGRAPH ?= flamegraph.pl | 2 | FLAMEGRAPH ?= flamegraph.pl |
| 3 | STACKCOLLAPSE ?= stackcollapse-perf.pl | 3 | STACKCOLLAPSE ?= stackcollapse-perf.pl |
| 4 | 4 | ||
| 5 | .PHONY: build run test bench profile clean | 5 | .PHONY: build run test bench profile clean test-render golden-update bench-baseline bench-check |
| 6 | 6 | ||
| 7 | build: | 7 | build: |
| 8 | $(ZIG) build | 8 | $(ZIG) build |
| @@ -13,7 +13,7 @@ run: build | |||
| 13 | test: | 13 | test: |
| 14 | $(ZIG) build test | 14 | $(ZIG) build test |
| 15 | 15 | ||
| 16 | zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard shaders/*) | 16 | zig-out/bin/waystty: $(wildcard src/*.zig) $(wildcard src/tools/*.zig) $(wildcard shaders/*) |
| 17 | $(ZIG) build | 17 | $(ZIG) build |
| 18 | 18 | ||
| 19 | bench: zig-out/bin/waystty | 19 | bench: zig-out/bin/waystty |
| @@ -32,5 +32,17 @@ profile: | |||
| 32 | @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)" | 32 | @grep -A 12 "waystty frame timing" bench.log || echo "(no timing data found)" |
| 33 | xdg-open flamegraph.svg | 33 | xdg-open flamegraph.svg |
| 34 | 34 | ||
| 35 | test-render: | ||
| 36 | $(ZIG) build test-render | ||
| 37 | |||
| 38 | golden-update: | ||
| 39 | WAYSTTY_GOLDEN_UPDATE=1 $(ZIG) build test-render | ||
| 40 | |||
| 41 | bench-baseline: | ||
| 42 | $(ZIG) build bench-baseline | ||
| 43 | |||
| 44 | bench-check: | ||
| 45 | $(ZIG) build bench-check | ||
| 46 | |||
| 35 | clean: | 47 | clean: |
| 36 | rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg | 48 | rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,12 @@ pub fn build(b: *std.Build) void { | |||
| 15 | .optimize = optimize, | 15 | .optimize = optimize, |
| 16 | }); | 16 | }); |
| 17 | 17 | ||
| 18 | const bench_stats_mod = b.createModule(.{ | ||
| 19 | .root_source_file = b.path("src/bench_stats.zig"), | ||
| 20 | .target = target, | ||
| 21 | .optimize = optimize, | ||
| 22 | }); | ||
| 23 | |||
| 18 | // Lazy-fetch the ghostty dependency. On the first invocation this | 24 | // Lazy-fetch the ghostty dependency. On the first invocation this |
| 19 | // materializes the package; subsequent builds use the local cache. | 25 | // materializes the package; subsequent builds use the local cache. |
| 20 | const ghostty_dep = b.lazyDependency("ghostty", .{}); | 26 | const ghostty_dep = b.lazyDependency("ghostty", .{}); |
| @@ -79,6 +85,7 @@ pub fn build(b: *std.Build) void { | |||
| 79 | exe_mod.addImport("wayland-client", wayland_mod); | 85 | exe_mod.addImport("wayland-client", wayland_mod); |
| 80 | exe_mod.addImport("config", config_mod); | 86 | exe_mod.addImport("config", config_mod); |
| 81 | exe_mod.addImport("frame_loop", frame_loop_mod); | 87 | exe_mod.addImport("frame_loop", frame_loop_mod); |
| 88 | exe_mod.addImport("bench_stats", bench_stats_mod); | ||
| 82 | 89 | ||
| 83 | const exe = b.addExecutable(.{ | 90 | const exe = b.addExecutable(.{ |
| 84 | .name = "waystty", | 91 | .name = "waystty", |
| @@ -130,6 +137,15 @@ pub fn build(b: *std.Build) void { | |||
| 130 | }); | 137 | }); |
| 131 | test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step); | 138 | test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step); |
| 132 | 139 | ||
| 140 | // Test bench_stats.zig | ||
| 141 | const bench_stats_test_mod = b.createModule(.{ | ||
| 142 | .root_source_file = b.path("src/bench_stats.zig"), | ||
| 143 | .target = target, | ||
| 144 | .optimize = optimize, | ||
| 145 | }); | ||
| 146 | const bench_stats_tests = b.addTest(.{ .root_module = bench_stats_test_mod }); | ||
| 147 | test_step.dependOn(&b.addRunArtifact(bench_stats_tests).step); | ||
| 148 | |||
| 133 | // Test frame_loop.zig | 149 | // Test frame_loop.zig |
| 134 | const frame_loop_test_mod = b.createModule(.{ | 150 | const frame_loop_test_mod = b.createModule(.{ |
| 135 | .root_source_file = b.path("src/frame_loop.zig"), | 151 | .root_source_file = b.path("src/frame_loop.zig"), |
| @@ -169,6 +185,7 @@ pub fn build(b: *std.Build) void { | |||
| 169 | main_test_mod.addImport("vt", vt_mod); | 185 | main_test_mod.addImport("vt", vt_mod); |
| 170 | main_test_mod.addImport("wayland-client", wayland_mod); | 186 | main_test_mod.addImport("wayland-client", wayland_mod); |
| 171 | main_test_mod.addImport("config", config_mod); | 187 | main_test_mod.addImport("config", config_mod); |
| 188 | main_test_mod.addImport("bench_stats", bench_stats_mod); | ||
| 172 | const main_tests = b.addTest(.{ | 189 | const main_tests = b.addTest(.{ |
| 173 | .root_module = main_test_mod, | 190 | .root_module = main_test_mod, |
| 174 | }); | 191 | }); |
| @@ -268,4 +285,114 @@ pub fn build(b: *std.Build) void { | |||
| 268 | .root_module = renderer_test_mod, | 285 | .root_module = renderer_test_mod, |
| 269 | }); | 286 | }); |
| 270 | test_step.dependOn(&b.addRunArtifact(renderer_tests).step); | 287 | test_step.dependOn(&b.addRunArtifact(renderer_tests).step); |
| 288 | |||
| 289 | // png module — vendored minimal RGBA8 PNG codec | ||
| 290 | const png_mod = b.createModule(.{ | ||
| 291 | .root_source_file = b.path("src/png.zig"), | ||
| 292 | .target = target, | ||
| 293 | .optimize = optimize, | ||
| 294 | }); | ||
| 295 | exe_mod.addImport("png", png_mod); | ||
| 296 | |||
| 297 | const png_test_mod = b.createModule(.{ | ||
| 298 | .root_source_file = b.path("src/png.zig"), | ||
| 299 | .target = target, | ||
| 300 | .optimize = optimize, | ||
| 301 | }); | ||
| 302 | const png_tests = b.addTest(.{ .root_module = png_test_mod }); | ||
| 303 | test_step.dependOn(&b.addRunArtifact(png_tests).step); | ||
| 304 | |||
| 305 | // cell_instance module — shared appendCellInstances / glyphTopOffset helpers | ||
| 306 | const cell_instance_mod = b.createModule(.{ | ||
| 307 | .root_source_file = b.path("src/cell_instance.zig"), | ||
| 308 | .target = target, | ||
| 309 | .optimize = optimize, | ||
| 310 | }); | ||
| 311 | cell_instance_mod.addImport("renderer", renderer_mod); | ||
| 312 | cell_instance_mod.addImport("font", font_mod); | ||
| 313 | cell_instance_mod.addImport("vt", vt_mod); | ||
| 314 | exe_mod.addImport("cell_instance", cell_instance_mod); | ||
| 315 | main_test_mod.addImport("cell_instance", cell_instance_mod); | ||
| 316 | |||
| 317 | // capture module — --capture mode (render a VT script to PNG) | ||
| 318 | const capture_mod = b.createModule(.{ | ||
| 319 | .root_source_file = b.path("src/capture.zig"), | ||
| 320 | .target = target, | ||
| 321 | .optimize = optimize, | ||
| 322 | .link_libc = true, | ||
| 323 | }); | ||
| 324 | capture_mod.addImport("vt", vt_mod); | ||
| 325 | capture_mod.addImport("pty", pty_mod); | ||
| 326 | capture_mod.addImport("wayland-client", wayland_mod); | ||
| 327 | capture_mod.addImport("renderer", renderer_mod); | ||
| 328 | capture_mod.addImport("font", font_mod); | ||
| 329 | capture_mod.addImport("config", config_mod); | ||
| 330 | capture_mod.addImport("png", png_mod); | ||
| 331 | capture_mod.addImport("vulkan", vulkan_module); | ||
| 332 | capture_mod.addImport("cell_instance", cell_instance_mod); | ||
| 333 | exe_mod.addImport("capture", capture_mod); | ||
| 334 | |||
| 335 | // imgdiff — standalone PNG comparison tool | ||
| 336 | const imgdiff_mod = b.createModule(.{ | ||
| 337 | .root_source_file = b.path("src/tools/imgdiff.zig"), | ||
| 338 | .target = target, | ||
| 339 | .optimize = optimize, | ||
| 340 | }); | ||
| 341 | imgdiff_mod.addImport("png", png_mod); | ||
| 342 | const imgdiff_exe = b.addExecutable(.{ | ||
| 343 | .name = "imgdiff", | ||
| 344 | .root_module = imgdiff_mod, | ||
| 345 | }); | ||
| 346 | b.installArtifact(imgdiff_exe); | ||
| 347 | |||
| 348 | const imgdiff_test_mod = b.createModule(.{ | ||
| 349 | .root_source_file = b.path("src/tools/imgdiff.zig"), | ||
| 350 | .target = target, | ||
| 351 | .optimize = optimize, | ||
| 352 | }); | ||
| 353 | imgdiff_test_mod.addImport("png", png_mod); | ||
| 354 | const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod }); | ||
| 355 | test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step); | ||
| 356 | |||
| 357 | const test_render_mod = b.createModule(.{ | ||
| 358 | .root_source_file = b.path("src/tools/test_render.zig"), | ||
| 359 | .target = target, | ||
| 360 | .optimize = optimize, | ||
| 361 | }); | ||
| 362 | const test_render_exe = b.addExecutable(.{ | ||
| 363 | .name = "test-render", | ||
| 364 | .root_module = test_render_mod, | ||
| 365 | }); | ||
| 366 | b.installArtifact(test_render_exe); | ||
| 367 | |||
| 368 | const test_render_step = b.step("test-render", "Run all golden VT scripts and diff against references"); | ||
| 369 | test_render_step.dependOn(b.getInstallStep()); | ||
| 370 | const test_render_run = b.addRunArtifact(test_render_exe); | ||
| 371 | test_render_run.step.dependOn(b.getInstallStep()); | ||
| 372 | test_render_step.dependOn(&test_render_run.step); | ||
| 373 | |||
| 374 | // bench-baseline / bench-check — frame-timing regression guard | ||
| 375 | const bench_baseline_mod = b.createModule(.{ | ||
| 376 | .root_source_file = b.path("src/tools/bench_baseline.zig"), | ||
| 377 | .target = target, | ||
| 378 | .optimize = optimize, | ||
| 379 | }); | ||
| 380 | bench_baseline_mod.addImport("bench_stats", bench_stats_mod); | ||
| 381 | const bench_baseline_exe = b.addExecutable(.{ | ||
| 382 | .name = "bench-baseline", | ||
| 383 | .root_module = bench_baseline_mod, | ||
| 384 | }); | ||
| 385 | b.installArtifact(bench_baseline_exe); | ||
| 386 | |||
| 387 | const bench_baseline_step = b.step("bench-baseline", "Save current frame-timing profile to tests/bench/baseline.json"); | ||
| 388 | const bench_baseline_run = b.addRunArtifact(bench_baseline_exe); | ||
| 389 | bench_baseline_run.addArg("save"); | ||
| 390 | bench_baseline_run.step.dependOn(b.getInstallStep()); | ||
| 391 | bench_baseline_step.dependOn(&bench_baseline_run.step); | ||
| 392 | |||
| 393 | const bench_check_step = b.step("bench-check", "Compare current frame timings against baseline"); | ||
| 394 | const bench_check_run = b.addRunArtifact(bench_baseline_exe); | ||
| 395 | bench_check_run.addArg("check"); | ||
| 396 | bench_check_run.step.dependOn(b.getInstallStep()); | ||
| 397 | bench_check_step.dependOn(&bench_check_run.step); | ||
| 271 | } | 398 | } |
src/bench_stats.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,267 @@ | |||
| 1 | const std = @import("std"); | ||
| 2 | |||
| 3 | pub const FrameTiming = struct { | ||
| 4 | snapshot_us: u32 = 0, | ||
| 5 | row_rebuild_us: u32 = 0, | ||
| 6 | atlas_upload_us: u32 = 0, | ||
| 7 | instance_upload_us: u32 = 0, | ||
| 8 | gpu_submit_us: u32 = 0, | ||
| 9 | |||
| 10 | pub fn total(self: FrameTiming) u32 { | ||
| 11 | return self.snapshot_us + | ||
| 12 | self.row_rebuild_us + | ||
| 13 | self.atlas_upload_us + | ||
| 14 | self.instance_upload_us + | ||
| 15 | self.gpu_submit_us; | ||
| 16 | } | ||
| 17 | }; | ||
| 18 | |||
| 19 | pub const FrameTimingRing = struct { | ||
| 20 | pub const capacity = 256; | ||
| 21 | |||
| 22 | entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity, | ||
| 23 | head: usize = 0, | ||
| 24 | count: usize = 0, | ||
| 25 | |||
| 26 | pub fn push(self: *FrameTimingRing, timing: FrameTiming) void { | ||
| 27 | const idx = if (self.count < capacity) self.count else self.head; | ||
| 28 | self.entries[idx] = timing; | ||
| 29 | if (self.count < capacity) { | ||
| 30 | self.count += 1; | ||
| 31 | } else { | ||
| 32 | self.head = (self.head + 1) % capacity; | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Return a slice of valid entries in insertion order. | ||
| 37 | /// Caller must provide a scratch buffer of `capacity` entries. | ||
| 38 | pub fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming { | ||
| 39 | if (self.count < capacity) { | ||
| 40 | return self.entries[0..self.count]; | ||
| 41 | } | ||
| 42 | // Ring has wrapped — copy from head..end then 0..head | ||
| 43 | const tail_len = capacity - self.head; | ||
| 44 | @memcpy(buf[0..tail_len], self.entries[self.head..capacity]); | ||
| 45 | @memcpy(buf[tail_len..capacity], self.entries[0..self.head]); | ||
| 46 | return buf[0..capacity]; | ||
| 47 | } | ||
| 48 | }; | ||
| 49 | |||
| 50 | pub const SectionStats = struct { | ||
| 51 | min: u32 = 0, | ||
| 52 | avg: u32 = 0, | ||
| 53 | p99: u32 = 0, | ||
| 54 | max: u32 = 0, | ||
| 55 | }; | ||
| 56 | |||
| 57 | pub const FrameTimingStats = struct { | ||
| 58 | snapshot: SectionStats = .{}, | ||
| 59 | row_rebuild: SectionStats = .{}, | ||
| 60 | atlas_upload: SectionStats = .{}, | ||
| 61 | instance_upload: SectionStats = .{}, | ||
| 62 | gpu_submit: SectionStats = .{}, | ||
| 63 | total: SectionStats = .{}, | ||
| 64 | frame_count: usize = 0, | ||
| 65 | }; | ||
| 66 | |||
| 67 | pub fn computeSectionStats(values: []u32) SectionStats { | ||
| 68 | if (values.len == 0) return .{}; | ||
| 69 | std.mem.sort(u32, values, {}, std.sort.asc(u32)); | ||
| 70 | var sum: u64 = 0; | ||
| 71 | for (values) |v| sum += v; | ||
| 72 | const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100; | ||
| 73 | return .{ | ||
| 74 | .min = values[0], | ||
| 75 | .avg = @intCast(sum / values.len), | ||
| 76 | .p99 = values[p99_idx], | ||
| 77 | .max = values[values.len - 1], | ||
| 78 | }; | ||
| 79 | } | ||
| 80 | |||
| 81 | pub fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats { | ||
| 82 | if (ring.count == 0) return .{}; | ||
| 83 | |||
| 84 | var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined; | ||
| 85 | const entries = ring.orderedSlice(&ordered_buf); | ||
| 86 | const n = entries.len; | ||
| 87 | |||
| 88 | var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 89 | var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 90 | var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 91 | var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 92 | var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 93 | var total_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 94 | |||
| 95 | for (entries, 0..) |e, i| { | ||
| 96 | snapshot_vals[i] = e.snapshot_us; | ||
| 97 | row_rebuild_vals[i] = e.row_rebuild_us; | ||
| 98 | atlas_upload_vals[i] = e.atlas_upload_us; | ||
| 99 | instance_upload_vals[i] = e.instance_upload_us; | ||
| 100 | gpu_submit_vals[i] = e.gpu_submit_us; | ||
| 101 | total_vals[i] = e.total(); | ||
| 102 | } | ||
| 103 | |||
| 104 | return .{ | ||
| 105 | .snapshot = computeSectionStats(snapshot_vals[0..n]), | ||
| 106 | .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]), | ||
| 107 | .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]), | ||
| 108 | .instance_upload = computeSectionStats(instance_upload_vals[0..n]), | ||
| 109 | .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]), | ||
| 110 | .total = computeSectionStats(total_vals[0..n]), | ||
| 111 | .frame_count = n, | ||
| 112 | }; | ||
| 113 | } | ||
| 114 | |||
| 115 | pub fn printFrameStats(stats: FrameTimingStats) void { | ||
| 116 | const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n"; | ||
| 117 | std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count}); | ||
| 118 | std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" }); | ||
| 119 | std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max }); | ||
| 120 | std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max }); | ||
| 121 | std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max }); | ||
| 122 | std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max }); | ||
| 123 | std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max }); | ||
| 124 | std.debug.print("----------------------------------------------------\n", .{}); | ||
| 125 | std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max }); | ||
| 126 | } | ||
| 127 | |||
| 128 | test "FrameTiming.total sums all sections" { | ||
| 129 | const ft: FrameTiming = .{ | ||
| 130 | .snapshot_us = 10, | ||
| 131 | .row_rebuild_us = 20, | ||
| 132 | .atlas_upload_us = 30, | ||
| 133 | .instance_upload_us = 40, | ||
| 134 | .gpu_submit_us = 50, | ||
| 135 | }; | ||
| 136 | try std.testing.expectEqual(@as(u32, 150), ft.total()); | ||
| 137 | } | ||
| 138 | |||
| 139 | test "FrameTimingRing records and wraps correctly" { | ||
| 140 | var ring = FrameTimingRing{}; | ||
| 141 | try std.testing.expectEqual(@as(usize, 0), ring.count); | ||
| 142 | |||
| 143 | ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 }); | ||
| 144 | try std.testing.expectEqual(@as(usize, 1), ring.count); | ||
| 145 | try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us); | ||
| 146 | |||
| 147 | // Fill to capacity | ||
| 148 | for (1..FrameTimingRing.capacity) |i| { | ||
| 149 | ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); | ||
| 150 | } | ||
| 151 | try std.testing.expectEqual(FrameTimingRing.capacity, ring.count); | ||
| 152 | |||
| 153 | // One more wraps around — overwrites entries[0], head advances to 1 | ||
| 154 | ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); | ||
| 155 | try std.testing.expectEqual(FrameTimingRing.capacity, ring.count); | ||
| 156 | // Newest entry is at (head + capacity - 1) % capacity = 0 | ||
| 157 | try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us); | ||
| 158 | // head has advanced past the overwritten slot | ||
| 159 | try std.testing.expectEqual(@as(usize, 1), ring.head); | ||
| 160 | } | ||
| 161 | |||
| 162 | test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" { | ||
| 163 | var ring = FrameTimingRing{}; | ||
| 164 | // Push capacity + 3 entries so the ring wraps | ||
| 165 | for (0..FrameTimingRing.capacity + 3) |i| { | ||
| 166 | ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); | ||
| 167 | } | ||
| 168 | var buf: [FrameTimingRing.capacity]FrameTiming = undefined; | ||
| 169 | const ordered = ring.orderedSlice(&buf); | ||
| 170 | try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len); | ||
| 171 | // First entry should be the 4th pushed (index 3), last should be capacity+2 | ||
| 172 | try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us); | ||
| 173 | try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us); | ||
| 174 | } | ||
| 175 | |||
| 176 | test "FrameTimingStats computes min/avg/p99/max correctly" { | ||
| 177 | var ring = FrameTimingRing{}; | ||
| 178 | // Push 100 frames with snapshot_us = 1..100 | ||
| 179 | for (0..100) |i| { | ||
| 180 | ring.push(.{ | ||
| 181 | .snapshot_us = @intCast(i + 1), | ||
| 182 | .row_rebuild_us = 0, | ||
| 183 | .atlas_upload_us = 0, | ||
| 184 | .instance_upload_us = 0, | ||
| 185 | .gpu_submit_us = 0, | ||
| 186 | }); | ||
| 187 | } | ||
| 188 | const stats = computeFrameStats(&ring); | ||
| 189 | try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min); | ||
| 190 | try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max); | ||
| 191 | try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg); | ||
| 192 | // p99 of 1..100 = value at index 98 (0-based) = 99 | ||
| 193 | try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99); | ||
| 194 | try std.testing.expectEqual(@as(usize, 100), stats.frame_count); | ||
| 195 | } | ||
| 196 | |||
| 197 | test "FrameTimingStats handles empty ring" { | ||
| 198 | var ring = FrameTimingRing{}; | ||
| 199 | const stats = computeFrameStats(&ring); | ||
| 200 | try std.testing.expectEqual(@as(usize, 0), stats.frame_count); | ||
| 201 | try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min); | ||
| 202 | } | ||
| 203 | |||
| 204 | pub const BaselineRecord = struct { | ||
| 205 | workload_sha: []const u8, | ||
| 206 | zig_version: []const u8, | ||
| 207 | waystty_sha: []const u8, | ||
| 208 | frame_count: usize, | ||
| 209 | sections: struct { | ||
| 210 | snapshot: SectionStats, | ||
| 211 | row_rebuild: SectionStats, | ||
| 212 | atlas_upload: SectionStats, | ||
| 213 | instance_upload: SectionStats, | ||
| 214 | gpu_submit: SectionStats, | ||
| 215 | }, | ||
| 216 | }; | ||
| 217 | |||
| 218 | /// Serialize `rec` to JSON and return an owned slice. Caller must free. | ||
| 219 | pub fn writeBaselineJson(alloc: std.mem.Allocator, rec: BaselineRecord) ![]u8 { | ||
| 220 | var out: std.Io.Writer.Allocating = .init(alloc); | ||
| 221 | errdefer out.deinit(); | ||
| 222 | try std.json.Stringify.value(rec, .{ .whitespace = .indent_2 }, &out.writer); | ||
| 223 | return out.toOwnedSlice(); | ||
| 224 | } | ||
| 225 | |||
| 226 | pub fn readBaselineJson(alloc: std.mem.Allocator, bytes: []const u8) !BaselineRecord { | ||
| 227 | var parsed = try std.json.parseFromSlice(BaselineRecord, alloc, bytes, .{}); | ||
| 228 | defer parsed.deinit(); | ||
| 229 | return .{ | ||
| 230 | .workload_sha = try alloc.dupe(u8, parsed.value.workload_sha), | ||
| 231 | .zig_version = try alloc.dupe(u8, parsed.value.zig_version), | ||
| 232 | .waystty_sha = try alloc.dupe(u8, parsed.value.waystty_sha), | ||
| 233 | .frame_count = parsed.value.frame_count, | ||
| 234 | .sections = parsed.value.sections, | ||
| 235 | }; | ||
| 236 | } | ||
| 237 | |||
| 238 | test "baseline JSON round-trip" { | ||
| 239 | const alloc = std.testing.allocator; | ||
| 240 | const rec = BaselineRecord{ | ||
| 241 | .workload_sha = "abcdef", | ||
| 242 | .zig_version = "0.15.0", | ||
| 243 | .waystty_sha = "123abc", | ||
| 244 | .frame_count = 256, | ||
| 245 | .sections = .{ | ||
| 246 | .snapshot = .{ .min = 1, .avg = 2, .p99 = 3, .max = 4 }, | ||
| 247 | .row_rebuild = .{ .min = 10, .avg = 20, .p99 = 30, .max = 40 }, | ||
| 248 | .atlas_upload = .{ .min = 0, .avg = 0, .p99 = 0, .max = 0 }, | ||
| 249 | .instance_upload = .{ .min = 5, .avg = 6, .p99 = 7, .max = 8 }, | ||
| 250 | .gpu_submit = .{ .min = 9, .avg = 9, .p99 = 9, .max = 9 }, | ||
| 251 | }, | ||
| 252 | }; | ||
| 253 | |||
| 254 | const json_bytes = try writeBaselineJson(alloc, rec); | ||
| 255 | defer alloc.free(json_bytes); | ||
| 256 | |||
| 257 | const parsed = try readBaselineJson(alloc, json_bytes); | ||
| 258 | defer { | ||
| 259 | alloc.free(parsed.workload_sha); | ||
| 260 | alloc.free(parsed.zig_version); | ||
| 261 | alloc.free(parsed.waystty_sha); | ||
| 262 | } | ||
| 263 | |||
| 264 | try std.testing.expectEqual(@as(usize, 256), parsed.frame_count); | ||
| 265 | try std.testing.expectEqual(@as(u32, 30), parsed.sections.row_rebuild.p99); | ||
| 266 | try std.testing.expectEqualStrings("abcdef", parsed.workload_sha); | ||
| 267 | } | ||
src/capture.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,360 @@ | |||
| 1 | //! `--capture <script> <output.png>` mode. | ||
| 2 | //! | ||
| 3 | //! Renders a VT script to a single PNG frame for golden-image testing. | ||
| 4 | //! | ||
| 5 | //! 1. Stand up a Wayland window + Vulkan context at a forced 80x24 grid, | ||
| 6 | //! buffer scale = 1 (so renders are deterministic across multi-monitor | ||
| 7 | //! setups). | ||
| 8 | //! 2. Wait up to 3s for the window to become visible. | ||
| 9 | //! 3. Pipe the script through a PTY via `/bin/cat`, then drain remaining | ||
| 10 | //! output after cat exits. | ||
| 11 | //! 4. Snapshot the terminal, build a flat Instance list for every cell, | ||
| 12 | //! render a single frame to an offscreen VkImage, read the BGRA bytes | ||
| 13 | //! back, convert to RGBA and write a PNG. | ||
| 14 | //! | ||
| 15 | //! The window itself is never committed/presented — the offscreen target | ||
| 16 | //! is its own framebuffer. We still need the Wayland surface so Vulkan | ||
| 17 | //! can allocate a swapchain (required by the current Context.init path) | ||
| 18 | //! and so the compositor hands us a real configure event. | ||
| 19 | |||
| 20 | const std = @import("std"); | ||
| 21 | const vt = @import("vt"); | ||
| 22 | const pty = @import("pty"); | ||
| 23 | const wayland_client = @import("wayland-client"); | ||
| 24 | const renderer = @import("renderer"); | ||
| 25 | const font = @import("font"); | ||
| 26 | const config = @import("config"); | ||
| 27 | const png = @import("png"); | ||
| 28 | const vk = @import("vulkan"); | ||
| 29 | |||
| 30 | pub const CaptureError = error{ | ||
| 31 | MissingArgs, | ||
| 32 | ScriptNotFound, | ||
| 33 | OutputPathUnwritable, | ||
| 34 | WindowNotVisible, | ||
| 35 | WindowSizeMismatch, | ||
| 36 | PngEncodeFailed, | ||
| 37 | }; | ||
| 38 | |||
| 39 | const cell_instance = @import("cell_instance"); | ||
| 40 | const appendCellInstances = cell_instance.appendCellInstances; | ||
| 41 | const glyphTopOffset = cell_instance.glyphTopOffset; | ||
| 42 | |||
| 43 | const CAPTURE_COLS: u16 = 80; | ||
| 44 | const CAPTURE_ROWS: u16 = 24; | ||
| 45 | const VISIBILITY_TIMEOUT_NS: i128 = 3 * std.time.ns_per_s; | ||
| 46 | |||
| 47 | /// Entry point. `argv[0]` is `--capture`; argv[1] = script path, argv[2] = out path. | ||
| 48 | pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void { | ||
| 49 | if (argv.len < 3) { | ||
| 50 | std.debug.print("usage: waystty --capture <script.vt> <output.png>\n", .{}); | ||
| 51 | return CaptureError.MissingArgs; | ||
| 52 | } | ||
| 53 | const script_path = argv[1]; | ||
| 54 | const out_path = argv[2]; | ||
| 55 | |||
| 56 | // Probe script path up-front so we fail fast with a clean error rather | ||
| 57 | // than having cat silently print a "No such file" diagnostic onto the | ||
| 58 | // captured image. | ||
| 59 | std.fs.cwd().access(script_path, .{}) catch |err| { | ||
| 60 | std.debug.print("capture: cannot read script {s}: {t}\n", .{ script_path, err }); | ||
| 61 | return CaptureError.ScriptNotFound; | ||
| 62 | }; | ||
| 63 | |||
| 64 | // === font + cell metrics (scale=1, same lookup as runTerminal) === | ||
| 65 | var font_lookup = try font.lookupConfiguredFont(alloc); | ||
| 66 | defer font_lookup.deinit(alloc); | ||
| 67 | |||
| 68 | const font_size: u32 = config.font_size_px; | ||
| 69 | var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, font_size); | ||
| 70 | defer face.deinit(); | ||
| 71 | |||
| 72 | const cell_w: u32 = face.cellWidth(); | ||
| 73 | const cell_h: u32 = face.cellHeight(); | ||
| 74 | const baseline: u32 = face.baseline(); | ||
| 75 | |||
| 76 | const px_w: u32 = @as(u32, CAPTURE_COLS) * cell_w; | ||
| 77 | const px_h: u32 = @as(u32, CAPTURE_ROWS) * cell_h; | ||
| 78 | |||
| 79 | // === wayland === | ||
| 80 | const conn = try wayland_client.Connection.init(alloc); | ||
| 81 | defer conn.deinit(); | ||
| 82 | |||
| 83 | const window = try conn.createWindow(alloc, "waystty-capture"); | ||
| 84 | defer window.deinit(); | ||
| 85 | |||
| 86 | window.width = px_w; | ||
| 87 | window.height = px_h; | ||
| 88 | _ = conn.display.roundtrip(); | ||
| 89 | |||
| 90 | // === vulkan context (swapchain matches requested px size) === | ||
| 91 | var ctx = try renderer.Context.init( | ||
| 92 | alloc, | ||
| 93 | @ptrCast(conn.display), | ||
| 94 | @ptrCast(window.surface), | ||
| 95 | px_w, | ||
| 96 | px_h, | ||
| 97 | ); | ||
| 98 | defer ctx.deinit(); | ||
| 99 | |||
| 100 | // === offscreen render target (separate framebuffer; renders don't present) === | ||
| 101 | var offscreen = try renderer.createOffscreen( | ||
| 102 | ctx.vki, | ||
| 103 | ctx.vkd, | ||
| 104 | ctx.physical_device, | ||
| 105 | ctx.device, | ||
| 106 | ctx.render_pass, | ||
| 107 | ctx.swapchain_format, | ||
| 108 | px_w, | ||
| 109 | px_h, | ||
| 110 | ); | ||
| 111 | defer renderer.destroyOffscreen(ctx.vkd, ctx.device, offscreen); | ||
| 112 | |||
| 113 | // === glyph atlas + printable ASCII warm-up (matches runTerminal) === | ||
| 114 | var atlas = try font.Atlas.init(alloc, 1024, 1024); | ||
| 115 | defer atlas.deinit(); | ||
| 116 | |||
| 117 | for (32..127) |cp| { | ||
| 118 | _ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) { | ||
| 119 | error.AtlasFull => break, | ||
| 120 | else => return err, | ||
| 121 | }; | ||
| 122 | } | ||
| 123 | try ctx.uploadAtlas(atlas.pixels); | ||
| 124 | atlas.last_uploaded_y = atlas.cursor_y; | ||
| 125 | atlas.needs_full_upload = false; | ||
| 126 | atlas.dirty = false; | ||
| 127 | |||
| 128 | // === terminal === | ||
| 129 | var term = try vt.Terminal.init(alloc, .{ | ||
| 130 | .cols = CAPTURE_COLS, | ||
| 131 | .rows = CAPTURE_ROWS, | ||
| 132 | .max_scrollback = 1000, | ||
| 133 | }); | ||
| 134 | defer term.deinit(); | ||
| 135 | term.setReportedSize(.{ | ||
| 136 | .rows = CAPTURE_ROWS, | ||
| 137 | .columns = CAPTURE_COLS, | ||
| 138 | .cell_width = cell_w, | ||
| 139 | .cell_height = cell_h, | ||
| 140 | }); | ||
| 141 | |||
| 142 | // === visibility wait + size check === | ||
| 143 | try waitUntilVisible(conn, window); | ||
| 144 | |||
| 145 | if (window.width != px_w or window.height != px_h) { | ||
| 146 | std.debug.print( | ||
| 147 | "capture: window size mismatch (got {d}x{d}, expected {d}x{d})\n", | ||
| 148 | .{ window.width, window.height, px_w, px_h }, | ||
| 149 | ); | ||
| 150 | return CaptureError.WindowSizeMismatch; | ||
| 151 | } | ||
| 152 | |||
| 153 | // === play script through /bin/cat === | ||
| 154 | try playScript(term, script_path); | ||
| 155 | |||
| 156 | // === snapshot + build instances === | ||
| 157 | try term.snapshot(); | ||
| 158 | |||
| 159 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; | ||
| 160 | defer instances.deinit(alloc); | ||
| 161 | |||
| 162 | try buildInstancesForSnapshot( | ||
| 163 | alloc, | ||
| 164 | &instances, | ||
| 165 | term, | ||
| 166 | &face, | ||
| 167 | &atlas, | ||
| 168 | cell_w, | ||
| 169 | cell_h, | ||
| 170 | baseline, | ||
| 171 | ); | ||
| 172 | |||
| 173 | // If the script needed glyphs that weren't in the ASCII warm-up set, | ||
| 174 | // the atlas pixels are newer than the GPU copy. Re-upload the full | ||
| 175 | // atlas so the render samples valid texels. | ||
| 176 | if (atlas.dirty) { | ||
| 177 | try ctx.uploadAtlas(atlas.pixels); | ||
| 178 | atlas.dirty = false; | ||
| 179 | atlas.last_uploaded_y = atlas.cursor_y; | ||
| 180 | } | ||
| 181 | |||
| 182 | // === render one frame to offscreen === | ||
| 183 | const push = renderer.PushConstants{ | ||
| 184 | .viewport_size = .{ @floatFromInt(px_w), @floatFromInt(px_h) }, | ||
| 185 | .cell_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) }, | ||
| 186 | .coverage_params = renderer.coverageVariantParams(.baseline), | ||
| 187 | }; | ||
| 188 | |||
| 189 | try ctx.renderToOffscreen(&offscreen, instances.items, push); | ||
| 190 | |||
| 191 | // === readback BGRA->RGBA === | ||
| 192 | const rgba = try alloc.alloc(u8, @as(usize, px_w) * px_h * 4); | ||
| 193 | defer alloc.free(rgba); | ||
| 194 | try ctx.readbackOffscreen(&offscreen, rgba); | ||
| 195 | |||
| 196 | // === encode PNG === | ||
| 197 | try writePng(alloc, out_path, px_w, px_h, rgba); | ||
| 198 | |||
| 199 | std.debug.print("capture: wrote {s} ({d}x{d})\n", .{ out_path, px_w, px_h }); | ||
| 200 | } | ||
| 201 | |||
| 202 | /// Wait up to VISIBILITY_TIMEOUT_NS for the Wayland compositor to `configure` | ||
| 203 | /// the surface. For `--capture` we don't need the surface to actually be | ||
| 204 | /// mapped onto an output (which would require committing a presentable | ||
| 205 | /// buffer via the swapchain — we deliberately skip that since rendering is | ||
| 206 | /// offscreen). A configured surface is enough to know our fixed 80x24 | ||
| 207 | /// geometry was accepted. | ||
| 208 | fn waitUntilVisible(conn: *wayland_client.Connection, window: *wayland_client.Window) !void { | ||
| 209 | const deadline = @as(i128, std.time.nanoTimestamp()) + VISIBILITY_TIMEOUT_NS; | ||
| 210 | while (std.time.nanoTimestamp() < deadline) { | ||
| 211 | _ = conn.display.roundtrip(); | ||
| 212 | if (window.state.configured) return; | ||
| 213 | std.Thread.sleep(10 * std.time.ns_per_ms); | ||
| 214 | } | ||
| 215 | std.debug.print( | ||
| 216 | "capture: window never configured within 3s\n", | ||
| 217 | .{}, | ||
| 218 | ); | ||
| 219 | return CaptureError.WindowNotVisible; | ||
| 220 | } | ||
| 221 | |||
| 222 | /// Spawn `/bin/cat <script>` on a PTY; feed all its output into `term`. | ||
| 223 | /// Returns once the child has exited AND two consecutive 20 ms polls | ||
| 224 | /// produce no new bytes (drain). | ||
| 225 | /// | ||
| 226 | /// Note: spawns cat with script as argv rather than piping stdin+^D — | ||
| 227 | /// avoids EOF-signalling races with VT escape sequences. | ||
| 228 | fn playScript( | ||
| 229 | term: *vt.Terminal, | ||
| 230 | script_path: [:0]const u8, | ||
| 231 | ) !void { | ||
| 232 | var p = try pty.Pty.spawn(.{ | ||
| 233 | .cols = CAPTURE_COLS, | ||
| 234 | .rows = CAPTURE_ROWS, | ||
| 235 | .shell = "/bin/cat", | ||
| 236 | .shell_args = &.{script_path}, | ||
| 237 | }); | ||
| 238 | defer p.deinit(); | ||
| 239 | |||
| 240 | var buf: [4096]u8 = undefined; | ||
| 241 | var consecutive_empty: u32 = 0; | ||
| 242 | |||
| 243 | // Loop until child exited AND we saw two empty polls in a row (to make | ||
| 244 | // sure any straggler bytes in the master buffer have been drained). | ||
| 245 | while (true) { | ||
| 246 | var pfd = [_]std.posix.pollfd{ | ||
| 247 | .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 248 | }; | ||
| 249 | _ = std.posix.poll(&pfd, 20) catch 0; | ||
| 250 | |||
| 251 | var saw_bytes = false; | ||
| 252 | while (true) { | ||
| 253 | const n = p.read(&buf) catch |err| switch (err) { | ||
| 254 | error.WouldBlock => break, | ||
| 255 | // EIO on Linux after slave fd closes is the normal signal | ||
| 256 | // that cat exited. Break out — the child reaper below will | ||
| 257 | // notice. | ||
| 258 | error.InputOutput => break, | ||
| 259 | else => return err, | ||
| 260 | }; | ||
| 261 | if (n == 0) break; | ||
| 262 | term.write(buf[0..n]); | ||
| 263 | saw_bytes = true; | ||
| 264 | } | ||
| 265 | |||
| 266 | const alive = p.isChildAlive(); | ||
| 267 | if (saw_bytes) { | ||
| 268 | consecutive_empty = 0; | ||
| 269 | } else if (!alive) { | ||
| 270 | consecutive_empty += 1; | ||
| 271 | if (consecutive_empty >= 2) break; | ||
| 272 | } | ||
| 273 | } | ||
| 274 | |||
| 275 | // VT parser settle — give any delayed effects (timers, etc) a beat. | ||
| 276 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 277 | } | ||
| 278 | |||
| 279 | /// Build a flat Instance list covering every cell in the current snapshot. | ||
| 280 | /// Does not do dirty tracking — this is a one-shot full rebuild. Mirrors | ||
| 281 | /// the per-cell logic in `main.zig:rebuildRowInstances`, minus the | ||
| 282 | /// selection/cursor overlay. | ||
| 283 | fn buildInstancesForSnapshot( | ||
| 284 | alloc: std.mem.Allocator, | ||
| 285 | instances: *std.ArrayListUnmanaged(renderer.Instance), | ||
| 286 | term: *vt.Terminal, | ||
| 287 | face: *font.Face, | ||
| 288 | atlas: *font.Atlas, | ||
| 289 | cell_w: u32, | ||
| 290 | cell_h: u32, | ||
| 291 | baseline: u32, | ||
| 292 | ) !void { | ||
| 293 | const default_bg = term.backgroundColor(); | ||
| 294 | const bg_uv = atlas.cursorUV(); | ||
| 295 | |||
| 296 | const term_rows = term.render_state.row_data.items(.cells); | ||
| 297 | var row_idx: u32 = 0; | ||
| 298 | while (row_idx < term_rows.len) : (row_idx += 1) { | ||
| 299 | const row_cells = term_rows[row_idx]; | ||
| 300 | const raw_cells = row_cells.items(.raw); | ||
| 301 | var col_idx: u32 = 0; | ||
| 302 | while (col_idx < raw_cells.len) : (col_idx += 1) { | ||
| 303 | const cp = raw_cells[col_idx].codepoint(); | ||
| 304 | const colors = term.cellColors(row_cells.get(col_idx)); | ||
| 305 | const glyph_uv = if (cp == 0 or cp == ' ') | ||
| 306 | null | ||
| 307 | else | ||
| 308 | atlas.getOrInsert(face, @intCast(cp)) catch null; | ||
| 309 | |||
| 310 | try appendCellInstances( | ||
| 311 | alloc, | ||
| 312 | instances, | ||
| 313 | row_idx, | ||
| 314 | col_idx, | ||
| 315 | cell_w, | ||
| 316 | cell_h, | ||
| 317 | baseline, | ||
| 318 | glyph_uv, | ||
| 319 | bg_uv, | ||
| 320 | colors, | ||
| 321 | default_bg, | ||
| 322 | ); | ||
| 323 | } | ||
| 324 | } | ||
| 325 | } | ||
| 326 | |||
| 327 | /// Encode `rgba` as a PNG to a brand-new file at `path`. Buffers the full | ||
| 328 | /// encoded byte stream in memory (fine for 80x24@16px: under 200 KB) and | ||
| 329 | /// writes it in one shot. | ||
| 330 | fn writePng( | ||
| 331 | alloc: std.mem.Allocator, | ||
| 332 | path: [:0]const u8, | ||
| 333 | width: u32, | ||
| 334 | height: u32, | ||
| 335 | rgba: []u8, | ||
| 336 | ) !void { | ||
| 337 | var buf: std.ArrayList(u8) = .empty; | ||
| 338 | defer buf.deinit(alloc); | ||
| 339 | |||
| 340 | const img: png.Image = .{ | ||
| 341 | .width = width, | ||
| 342 | .height = height, | ||
| 343 | .pixels = rgba, | ||
| 344 | }; | ||
| 345 | png.encode(alloc, img, buf.writer(alloc)) catch |err| { | ||
| 346 | std.debug.print("capture: PNG encode failed: {t}\n", .{err}); | ||
| 347 | return CaptureError.PngEncodeFailed; | ||
| 348 | }; | ||
| 349 | |||
| 350 | const file = std.fs.cwd().createFile(path, .{ .truncate = true }) catch |err| { | ||
| 351 | std.debug.print("capture: cannot open output {s}: {t}\n", .{ path, err }); | ||
| 352 | return CaptureError.OutputPathUnwritable; | ||
| 353 | }; | ||
| 354 | defer file.close(); | ||
| 355 | |||
| 356 | file.writeAll(buf.items) catch |err| { | ||
| 357 | std.debug.print("capture: write failed for {s}: {t}\n", .{ path, err }); | ||
| 358 | return CaptureError.OutputPathUnwritable; | ||
| 359 | }; | ||
| 360 | } | ||
src/cell_instance.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,58 @@ | |||
| 1 | //! Shared cell-instance helpers used by both the live renderer (main.zig) | ||
| 2 | //! and the capture renderer (capture.zig). | ||
| 3 | |||
| 4 | const std = @import("std"); | ||
| 5 | const renderer = @import("renderer"); | ||
| 6 | const font = @import("font"); | ||
| 7 | const vt = @import("vt"); | ||
| 8 | |||
| 9 | /// Appends 0-2 `renderer.Instance` entries for a single terminal cell: | ||
| 10 | /// - a filled-background quad when the cell's bg differs from the terminal | ||
| 11 | /// default bg (so transparent cells don't draw a quad at all); | ||
| 12 | /// - a glyph quad when `glyph_uv` is non-null (i.e. the cell has a | ||
| 13 | /// printable codepoint that was found in the atlas). | ||
| 14 | pub fn appendCellInstances( | ||
| 15 | alloc: std.mem.Allocator, | ||
| 16 | instances: *std.ArrayListUnmanaged(renderer.Instance), | ||
| 17 | row_idx: u32, | ||
| 18 | col_idx: u32, | ||
| 19 | cell_w: u32, | ||
| 20 | cell_h: u32, | ||
| 21 | baseline: u32, | ||
| 22 | glyph_uv: ?font.GlyphUV, | ||
| 23 | bg_uv: font.GlyphUV, | ||
| 24 | colors: vt.CellColors, | ||
| 25 | default_bg: [4]f32, | ||
| 26 | ) !void { | ||
| 27 | if (!std.meta.eql(colors.bg, default_bg)) { | ||
| 28 | try instances.append(alloc, .{ | ||
| 29 | .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) }, | ||
| 30 | .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) }, | ||
| 31 | .glyph_bearing = .{ 0, 0 }, | ||
| 32 | .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 }, | ||
| 33 | .fg = colors.bg, | ||
| 34 | .bg = colors.bg, | ||
| 35 | }); | ||
| 36 | } | ||
| 37 | |||
| 38 | const uv = glyph_uv orelse return; | ||
| 39 | try instances.append(alloc, .{ | ||
| 40 | .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) }, | ||
| 41 | .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) }, | ||
| 42 | .glyph_bearing = .{ | ||
| 43 | @floatFromInt(uv.bearing_x), | ||
| 44 | glyphTopOffset(baseline, uv.bearing_y), | ||
| 45 | }, | ||
| 46 | .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 }, | ||
| 47 | .fg = colors.fg, | ||
| 48 | .bg = colors.bg, | ||
| 49 | }); | ||
| 50 | } | ||
| 51 | |||
| 52 | /// Returns the number of pixels from the top of the cell to the top of the | ||
| 53 | /// glyph bitmap, given the cell `baseline` (pixels from cell top to the | ||
| 54 | /// typographic baseline) and the glyph's `bearing_y` (pixels from baseline | ||
| 55 | /// to the top of the glyph bitmap, positive = up). | ||
| 56 | pub fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 { | ||
| 57 | return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y)); | ||
| 58 | } | ||
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,17 @@ const renderer = @import("renderer"); | |||
| 7 | const font = @import("font"); | 7 | const font = @import("font"); |
| 8 | const config = @import("config"); | 8 | const config = @import("config"); |
| 9 | const vk = @import("vulkan"); | 9 | const vk = @import("vulkan"); |
| 10 | const bench_stats = @import("bench_stats"); | ||
| 11 | const cell_instance = @import("cell_instance"); | ||
| 12 | const appendCellInstances = cell_instance.appendCellInstances; | ||
| 13 | const glyphTopOffset = cell_instance.glyphTopOffset; | ||
| 14 | const FrameTiming = bench_stats.FrameTiming; | ||
| 15 | const FrameTimingRing = bench_stats.FrameTimingRing; | ||
| 16 | const SectionStats = bench_stats.SectionStats; | ||
| 17 | const FrameTimingStats = bench_stats.FrameTimingStats; | ||
| 18 | const computeSectionStats = bench_stats.computeSectionStats; | ||
| 19 | const computeFrameStats = bench_stats.computeFrameStats; | ||
| 20 | const printFrameStats = bench_stats.printFrameStats; | ||
| 10 | 21 | ||
| 11 | const c = @cImport({ | 22 | const c = @cImport({ |
| 12 | @cInclude("xkbcommon/xkbcommon-keysyms.h"); | 23 | @cInclude("xkbcommon/xkbcommon-keysyms.h"); |
| @@ -59,6 +70,61 @@ fn updateWindowTitle(_: *vt.Terminal, ctx: ?*anyopaque, title: ?[:0]const u8) vo | |||
| 59 | window.setTitle(title); | 70 | window.setTitle(title); |
| 60 | } | 71 | } |
| 61 | 72 | ||
| 73 | /// If `WAYSTTY_BENCH_JSON` is set, write a BaselineRecord JSON to that path. | ||
| 74 | /// Machine-readable companion to `printFrameStats`. | ||
| 75 | fn writeBenchJson(alloc: std.mem.Allocator, stats: FrameTimingStats, workload: ?[:0]const u8) !void { | ||
| 76 | const path = std.posix.getenv("WAYSTTY_BENCH_JSON") orelse return; | ||
| 77 | |||
| 78 | // sha256 of the bench workload string (empty string if no workload) | ||
| 79 | var digest: [32]u8 = undefined; | ||
| 80 | std.crypto.hash.sha2.Sha256.hash(workload orelse "", &digest, .{}); | ||
| 81 | var sha_hex: [64]u8 = undefined; | ||
| 82 | const hex_lut = "0123456789abcdef"; | ||
| 83 | for (digest, 0..) |b, i| { | ||
| 84 | sha_hex[i * 2] = hex_lut[b >> 4]; | ||
| 85 | sha_hex[i * 2 + 1] = hex_lut[b & 0x0f]; | ||
| 86 | } | ||
| 87 | |||
| 88 | // git HEAD (falls back to "unknown") | ||
| 89 | const git_head = blk: { | ||
| 90 | const r = std.process.Child.run(.{ | ||
| 91 | .allocator = alloc, | ||
| 92 | .argv = &.{ "git", "rev-parse", "HEAD" }, | ||
| 93 | }) catch { | ||
| 94 | break :blk try alloc.dupe(u8, "unknown"); | ||
| 95 | }; | ||
| 96 | defer alloc.free(r.stdout); | ||
| 97 | defer alloc.free(r.stderr); | ||
| 98 | if (r.term != .Exited or r.term.Exited != 0) { | ||
| 99 | break :blk try alloc.dupe(u8, "unknown"); | ||
| 100 | } | ||
| 101 | const trimmed = std.mem.trim(u8, r.stdout, "\n \t"); | ||
| 102 | break :blk try alloc.dupe(u8, trimmed); | ||
| 103 | }; | ||
| 104 | defer alloc.free(git_head); | ||
| 105 | |||
| 106 | const rec = bench_stats.BaselineRecord{ | ||
| 107 | .workload_sha = &sha_hex, | ||
| 108 | .zig_version = @import("builtin").zig_version_string, | ||
| 109 | .waystty_sha = git_head, | ||
| 110 | .frame_count = stats.frame_count, | ||
| 111 | .sections = .{ | ||
| 112 | .snapshot = stats.snapshot, | ||
| 113 | .row_rebuild = stats.row_rebuild, | ||
| 114 | .atlas_upload = stats.atlas_upload, | ||
| 115 | .instance_upload = stats.instance_upload, | ||
| 116 | .gpu_submit = stats.gpu_submit, | ||
| 117 | }, | ||
| 118 | }; | ||
| 119 | |||
| 120 | const json_bytes = try bench_stats.writeBaselineJson(alloc, rec); | ||
| 121 | defer alloc.free(json_bytes); | ||
| 122 | |||
| 123 | const out = try std.fs.cwd().createFile(path, .{}); | ||
| 124 | defer out.close(); | ||
| 125 | try out.writeAll(json_bytes); | ||
| 126 | } | ||
| 127 | |||
| 62 | pub fn main() !void { | 128 | pub fn main() !void { |
| 63 | var gpa: std.heap.DebugAllocator(.{}) = .init; | 129 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| 64 | defer _ = gpa.deinit(); | 130 | defer _ = gpa.deinit(); |
| @@ -95,6 +161,11 @@ pub fn main() !void { | |||
| 95 | return runHiddenFreezeRegression(alloc); | 161 | return runHiddenFreezeRegression(alloc); |
| 96 | } | 162 | } |
| 97 | 163 | ||
| 164 | if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) { | ||
| 165 | const capture = @import("capture"); | ||
| 166 | return capture.run(alloc, args[1..]); | ||
| 167 | } | ||
| 168 | |||
| 98 | return runTerminal(alloc); | 169 | return runTerminal(alloc); |
| 99 | } | 170 | } |
| 100 | 171 | ||
| @@ -625,7 +696,11 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 625 | } | 696 | } |
| 626 | 697 | ||
| 627 | // Dump timing stats on exit | 698 | // Dump timing stats on exit |
| 628 | printFrameStats(computeFrameStats(&frame_ring)); | 699 | const final_stats = computeFrameStats(&frame_ring); |
| 700 | printFrameStats(final_stats); | ||
| 701 | writeBenchJson(alloc, final_stats, bench_script) catch |err| { | ||
| 702 | std.log.warn("bench_json write failed: {s}", .{@errorName(err)}); | ||
| 703 | }; | ||
| 629 | 704 | ||
| 630 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); | 705 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); |
| 631 | } | 706 | } |
| @@ -943,131 +1018,6 @@ fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan | |||
| 943 | } else null; | 1018 | } else null; |
| 944 | } | 1019 | } |
| 945 | 1020 | ||
| 946 | const FrameTiming = struct { | ||
| 947 | snapshot_us: u32 = 0, | ||
| 948 | row_rebuild_us: u32 = 0, | ||
| 949 | atlas_upload_us: u32 = 0, | ||
| 950 | instance_upload_us: u32 = 0, | ||
| 951 | gpu_submit_us: u32 = 0, | ||
| 952 | |||
| 953 | fn total(self: FrameTiming) u32 { | ||
| 954 | return self.snapshot_us + | ||
| 955 | self.row_rebuild_us + | ||
| 956 | self.atlas_upload_us + | ||
| 957 | self.instance_upload_us + | ||
| 958 | self.gpu_submit_us; | ||
| 959 | } | ||
| 960 | }; | ||
| 961 | |||
| 962 | const FrameTimingRing = struct { | ||
| 963 | const capacity = 256; | ||
| 964 | |||
| 965 | entries: [capacity]FrameTiming = [_]FrameTiming{.{}} ** capacity, | ||
| 966 | head: usize = 0, | ||
| 967 | count: usize = 0, | ||
| 968 | |||
| 969 | fn push(self: *FrameTimingRing, timing: FrameTiming) void { | ||
| 970 | const idx = if (self.count < capacity) self.count else self.head; | ||
| 971 | self.entries[idx] = timing; | ||
| 972 | if (self.count < capacity) { | ||
| 973 | self.count += 1; | ||
| 974 | } else { | ||
| 975 | self.head = (self.head + 1) % capacity; | ||
| 976 | } | ||
| 977 | } | ||
| 978 | |||
| 979 | /// Return a slice of valid entries in insertion order. | ||
| 980 | /// Caller must provide a scratch buffer of `capacity` entries. | ||
| 981 | fn orderedSlice(self: *const FrameTimingRing, buf: *[capacity]FrameTiming) []const FrameTiming { | ||
| 982 | if (self.count < capacity) { | ||
| 983 | return self.entries[0..self.count]; | ||
| 984 | } | ||
| 985 | // Ring has wrapped — copy from head..end then 0..head | ||
| 986 | const tail_len = capacity - self.head; | ||
| 987 | @memcpy(buf[0..tail_len], self.entries[self.head..capacity]); | ||
| 988 | @memcpy(buf[tail_len..capacity], self.entries[0..self.head]); | ||
| 989 | return buf[0..capacity]; | ||
| 990 | } | ||
| 991 | }; | ||
| 992 | |||
| 993 | const SectionStats = struct { | ||
| 994 | min: u32 = 0, | ||
| 995 | avg: u32 = 0, | ||
| 996 | p99: u32 = 0, | ||
| 997 | max: u32 = 0, | ||
| 998 | }; | ||
| 999 | |||
| 1000 | const FrameTimingStats = struct { | ||
| 1001 | snapshot: SectionStats = .{}, | ||
| 1002 | row_rebuild: SectionStats = .{}, | ||
| 1003 | atlas_upload: SectionStats = .{}, | ||
| 1004 | instance_upload: SectionStats = .{}, | ||
| 1005 | gpu_submit: SectionStats = .{}, | ||
| 1006 | total: SectionStats = .{}, | ||
| 1007 | frame_count: usize = 0, | ||
| 1008 | }; | ||
| 1009 | |||
| 1010 | fn computeSectionStats(values: []u32) SectionStats { | ||
| 1011 | if (values.len == 0) return .{}; | ||
| 1012 | std.mem.sort(u32, values, {}, std.sort.asc(u32)); | ||
| 1013 | var sum: u64 = 0; | ||
| 1014 | for (values) |v| sum += v; | ||
| 1015 | const p99_idx = if (values.len <= 1) 0 else ((values.len - 1) * 99) / 100; | ||
| 1016 | return .{ | ||
| 1017 | .min = values[0], | ||
| 1018 | .avg = @intCast(sum / values.len), | ||
| 1019 | .p99 = values[p99_idx], | ||
| 1020 | .max = values[values.len - 1], | ||
| 1021 | }; | ||
| 1022 | } | ||
| 1023 | |||
| 1024 | fn computeFrameStats(ring: *const FrameTimingRing) FrameTimingStats { | ||
| 1025 | if (ring.count == 0) return .{}; | ||
| 1026 | |||
| 1027 | var ordered_buf: [FrameTimingRing.capacity]FrameTiming = undefined; | ||
| 1028 | const entries = ring.orderedSlice(&ordered_buf); | ||
| 1029 | const n = entries.len; | ||
| 1030 | |||
| 1031 | var snapshot_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 1032 | var row_rebuild_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 1033 | var atlas_upload_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 1034 | var instance_upload_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 1035 | var gpu_submit_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 1036 | var total_vals: [FrameTimingRing.capacity]u32 = undefined; | ||
| 1037 | |||
| 1038 | for (entries, 0..) |e, i| { | ||
| 1039 | snapshot_vals[i] = e.snapshot_us; | ||
| 1040 | row_rebuild_vals[i] = e.row_rebuild_us; | ||
| 1041 | atlas_upload_vals[i] = e.atlas_upload_us; | ||
| 1042 | instance_upload_vals[i] = e.instance_upload_us; | ||
| 1043 | gpu_submit_vals[i] = e.gpu_submit_us; | ||
| 1044 | total_vals[i] = e.total(); | ||
| 1045 | } | ||
| 1046 | |||
| 1047 | return .{ | ||
| 1048 | .snapshot = computeSectionStats(snapshot_vals[0..n]), | ||
| 1049 | .row_rebuild = computeSectionStats(row_rebuild_vals[0..n]), | ||
| 1050 | .atlas_upload = computeSectionStats(atlas_upload_vals[0..n]), | ||
| 1051 | .instance_upload = computeSectionStats(instance_upload_vals[0..n]), | ||
| 1052 | .gpu_submit = computeSectionStats(gpu_submit_vals[0..n]), | ||
| 1053 | .total = computeSectionStats(total_vals[0..n]), | ||
| 1054 | .frame_count = n, | ||
| 1055 | }; | ||
| 1056 | } | ||
| 1057 | |||
| 1058 | fn printFrameStats(stats: FrameTimingStats) void { | ||
| 1059 | const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n"; | ||
| 1060 | std.debug.print("\n=== waystty frame timing ({d} frames) ===\n", .{stats.frame_count}); | ||
| 1061 | std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" }); | ||
| 1062 | std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max }); | ||
| 1063 | std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max }); | ||
| 1064 | std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max }); | ||
| 1065 | std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max }); | ||
| 1066 | std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max }); | ||
| 1067 | std.debug.print("----------------------------------------------------\n", .{}); | ||
| 1068 | std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max }); | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); | 1021 | var sigusr1_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); |
| 1072 | 1022 | ||
| 1073 | fn sigusr1Handler(_: c_int) callconv(.c) void { | 1023 | fn sigusr1Handler(_: c_int) callconv(.c) void { |
| @@ -1598,48 +1548,6 @@ fn cursorTouchesDirtyRow(dirty_rows: []const bool, cursor: CursorRefreshContext) | |||
| 1598 | return false; | 1548 | return false; |
| 1599 | } | 1549 | } |
| 1600 | 1550 | ||
| 1601 | fn appendCellInstances( | ||
| 1602 | alloc: std.mem.Allocator, | ||
| 1603 | instances: *std.ArrayListUnmanaged(renderer.Instance), | ||
| 1604 | row_idx: u32, | ||
| 1605 | col_idx: u32, | ||
| 1606 | cell_w: u32, | ||
| 1607 | cell_h: u32, | ||
| 1608 | baseline: u32, | ||
| 1609 | glyph_uv: ?font.GlyphUV, | ||
| 1610 | bg_uv: font.GlyphUV, | ||
| 1611 | colors: vt.CellColors, | ||
| 1612 | default_bg: [4]f32, | ||
| 1613 | ) !void { | ||
| 1614 | if (!std.meta.eql(colors.bg, default_bg)) { | ||
| 1615 | try instances.append(alloc, .{ | ||
| 1616 | .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) }, | ||
| 1617 | .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) }, | ||
| 1618 | .glyph_bearing = .{ 0, 0 }, | ||
| 1619 | .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 }, | ||
| 1620 | .fg = colors.bg, | ||
| 1621 | .bg = colors.bg, | ||
| 1622 | }); | ||
| 1623 | } | ||
| 1624 | |||
| 1625 | const uv = glyph_uv orelse return; | ||
| 1626 | try instances.append(alloc, .{ | ||
| 1627 | .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) }, | ||
| 1628 | .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) }, | ||
| 1629 | .glyph_bearing = .{ | ||
| 1630 | @floatFromInt(uv.bearing_x), | ||
| 1631 | glyphTopOffset(baseline, uv.bearing_y), | ||
| 1632 | }, | ||
| 1633 | .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 }, | ||
| 1634 | .fg = colors.fg, | ||
| 1635 | .bg = colors.bg, | ||
| 1636 | }); | ||
| 1637 | } | ||
| 1638 | |||
| 1639 | fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 { | ||
| 1640 | return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y)); | ||
| 1641 | } | ||
| 1642 | |||
| 1643 | fn encodeKeyboardEvent( | 1551 | fn encodeKeyboardEvent( |
| 1644 | term: *const vt.Terminal, | 1552 | term: *const vt.Terminal, |
| 1645 | ev: wayland_client.KeyboardEvent, | 1553 | ev: wayland_client.KeyboardEvent, |
| @@ -3132,82 +3040,6 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" { | |||
| 3132 | ); | 3040 | ); |
| 3133 | } | 3041 | } |
| 3134 | 3042 | ||
| 3135 | test "FrameTiming.total sums all sections" { | ||
| 3136 | const ft: FrameTiming = .{ | ||
| 3137 | .snapshot_us = 10, | ||
| 3138 | .row_rebuild_us = 20, | ||
| 3139 | .atlas_upload_us = 30, | ||
| 3140 | .instance_upload_us = 40, | ||
| 3141 | .gpu_submit_us = 50, | ||
| 3142 | }; | ||
| 3143 | try std.testing.expectEqual(@as(u32, 150), ft.total()); | ||
| 3144 | } | ||
| 3145 | |||
| 3146 | test "FrameTimingRing records and wraps correctly" { | ||
| 3147 | var ring = FrameTimingRing{}; | ||
| 3148 | try std.testing.expectEqual(@as(usize, 0), ring.count); | ||
| 3149 | |||
| 3150 | ring.push(.{ .snapshot_us = 1, .row_rebuild_us = 2, .atlas_upload_us = 3, .instance_upload_us = 4, .gpu_submit_us = 5 }); | ||
| 3151 | try std.testing.expectEqual(@as(usize, 1), ring.count); | ||
| 3152 | try std.testing.expectEqual(@as(u32, 1), ring.entries[0].snapshot_us); | ||
| 3153 | |||
| 3154 | // Fill to capacity | ||
| 3155 | for (1..FrameTimingRing.capacity) |i| { | ||
| 3156 | ring.push(.{ .snapshot_us = @intCast(i + 1), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); | ||
| 3157 | } | ||
| 3158 | try std.testing.expectEqual(FrameTimingRing.capacity, ring.count); | ||
| 3159 | |||
| 3160 | // One more wraps around — overwrites entries[0], head advances to 1 | ||
| 3161 | ring.push(.{ .snapshot_us = 999, .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); | ||
| 3162 | try std.testing.expectEqual(FrameTimingRing.capacity, ring.count); | ||
| 3163 | // Newest entry is at (head + capacity - 1) % capacity = 0 | ||
| 3164 | try std.testing.expectEqual(@as(u32, 999), ring.entries[0].snapshot_us); | ||
| 3165 | // head has advanced past the overwritten slot | ||
| 3166 | try std.testing.expectEqual(@as(usize, 1), ring.head); | ||
| 3167 | } | ||
| 3168 | |||
| 3169 | test "FrameTimingRing.orderedSlice returns entries in insertion order after wrap" { | ||
| 3170 | var ring = FrameTimingRing{}; | ||
| 3171 | // Push capacity + 3 entries so the ring wraps | ||
| 3172 | for (0..FrameTimingRing.capacity + 3) |i| { | ||
| 3173 | ring.push(.{ .snapshot_us = @intCast(i), .row_rebuild_us = 0, .atlas_upload_us = 0, .instance_upload_us = 0, .gpu_submit_us = 0 }); | ||
| 3174 | } | ||
| 3175 | var buf: [FrameTimingRing.capacity]FrameTiming = undefined; | ||
| 3176 | const ordered = ring.orderedSlice(&buf); | ||
| 3177 | try std.testing.expectEqual(FrameTimingRing.capacity, ordered.len); | ||
| 3178 | // First entry should be the 4th pushed (index 3), last should be capacity+2 | ||
| 3179 | try std.testing.expectEqual(@as(u32, 3), ordered[0].snapshot_us); | ||
| 3180 | try std.testing.expectEqual(@as(u32, FrameTimingRing.capacity + 2), ordered[ordered.len - 1].snapshot_us); | ||
| 3181 | } | ||
| 3182 | |||
| 3183 | test "FrameTimingStats computes min/avg/p99/max correctly" { | ||
| 3184 | var ring = FrameTimingRing{}; | ||
| 3185 | // Push 100 frames with snapshot_us = 1..100 | ||
| 3186 | for (0..100) |i| { | ||
| 3187 | ring.push(.{ | ||
| 3188 | .snapshot_us = @intCast(i + 1), | ||
| 3189 | .row_rebuild_us = 0, | ||
| 3190 | .atlas_upload_us = 0, | ||
| 3191 | .instance_upload_us = 0, | ||
| 3192 | .gpu_submit_us = 0, | ||
| 3193 | }); | ||
| 3194 | } | ||
| 3195 | const stats = computeFrameStats(&ring); | ||
| 3196 | try std.testing.expectEqual(@as(u32, 1), stats.snapshot.min); | ||
| 3197 | try std.testing.expectEqual(@as(u32, 100), stats.snapshot.max); | ||
| 3198 | try std.testing.expectEqual(@as(u32, 50), stats.snapshot.avg); | ||
| 3199 | // p99 of 1..100 = value at index 98 (0-based) = 99 | ||
| 3200 | try std.testing.expectEqual(@as(u32, 99), stats.snapshot.p99); | ||
| 3201 | try std.testing.expectEqual(@as(usize, 100), stats.frame_count); | ||
| 3202 | } | ||
| 3203 | |||
| 3204 | test "FrameTimingStats handles empty ring" { | ||
| 3205 | var ring = FrameTimingRing{}; | ||
| 3206 | const stats = computeFrameStats(&ring); | ||
| 3207 | try std.testing.expectEqual(@as(usize, 0), stats.frame_count); | ||
| 3208 | try std.testing.expectEqual(@as(u32, 0), stats.snapshot.min); | ||
| 3209 | } | ||
| 3210 | |||
| 3211 | fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { | 3043 | fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { |
| 3212 | const conn = try wayland_client.Connection.init(alloc); | 3044 | const conn = try wayland_client.Connection.init(alloc); |
| 3213 | defer conn.deinit(); | 3045 | defer conn.deinit(); |
src/png.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,261 @@ | |||
| 1 | const std = @import("std"); | ||
| 2 | |||
| 3 | pub const Image = struct { | ||
| 4 | width: u32, | ||
| 5 | height: u32, | ||
| 6 | pixels: []u8, // RGBA8, row-major, width*height*4 bytes | ||
| 7 | |||
| 8 | pub fn deinit(self: *Image, alloc: std.mem.Allocator) void { | ||
| 9 | alloc.free(self.pixels); | ||
| 10 | self.* = undefined; | ||
| 11 | } | ||
| 12 | }; | ||
| 13 | |||
| 14 | pub const EncodeError = error{ OutOfMemory, WriteFailed }; | ||
| 15 | pub const DecodeError = error{ | ||
| 16 | OutOfMemory, | ||
| 17 | InvalidPng, | ||
| 18 | UnsupportedPng, // only RGBA8 non-interlaced is supported | ||
| 19 | CorruptChunk, | ||
| 20 | }; | ||
| 21 | |||
| 22 | const signature = [_]u8{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; | ||
| 23 | |||
| 24 | fn adler32(data: []const u8) u32 { | ||
| 25 | var a: u32 = 1; | ||
| 26 | var b: u32 = 0; | ||
| 27 | for (data) |byte| { | ||
| 28 | a = (a + byte) % 65521; | ||
| 29 | b = (b + a) % 65521; | ||
| 30 | } | ||
| 31 | return (b << 16) | a; | ||
| 32 | } | ||
| 33 | |||
| 34 | fn writeChunk(writer: anytype, chunk_type: *const [4]u8, payload: []const u8) EncodeError!void { | ||
| 35 | writer.writeInt(u32, @intCast(payload.len), .big) catch return error.WriteFailed; | ||
| 36 | writer.writeAll(chunk_type) catch return error.WriteFailed; | ||
| 37 | writer.writeAll(payload) catch return error.WriteFailed; | ||
| 38 | var crc = std.hash.Crc32.init(); | ||
| 39 | crc.update(chunk_type); | ||
| 40 | crc.update(payload); | ||
| 41 | writer.writeInt(u32, crc.final(), .big) catch return error.WriteFailed; | ||
| 42 | } | ||
| 43 | |||
| 44 | /// Build a zlib stream wrapping the `filtered` data using DEFLATE stored | ||
| 45 | /// blocks (type 0, no compression). This is always valid PNG and avoids | ||
| 46 | /// dependency on the std.compress.flate encoder, which is incomplete in | ||
| 47 | /// Zig 0.15. | ||
| 48 | fn buildZlibStored(alloc: std.mem.Allocator, filtered: []const u8) EncodeError![]u8 { | ||
| 49 | // zlib header: CMF=0x78 (deflate, window=32K), FLG=0x01 (no dict, level=0, | ||
| 50 | // fcheck makes CMF*256+FLG divisible by 31: 0x7801 % 31 == 0). | ||
| 51 | const zlib_header = [_]u8{ 0x78, 0x01 }; | ||
| 52 | |||
| 53 | // DEFLATE stored block layout: | ||
| 54 | // 1 byte: BFINAL | (BTYPE << 1) — BTYPE=00 for stored | ||
| 55 | // 2 bytes: LEN (little-endian u16) | ||
| 56 | // 2 bytes: NLEN (one's complement of LEN, little-endian) | ||
| 57 | // LEN bytes: data | ||
| 58 | // | ||
| 59 | // Maximum single stored block payload is 65535 bytes. | ||
| 60 | const max_block: usize = 65535; | ||
| 61 | const actual_blocks: usize = if (filtered.len == 0) 1 else (filtered.len + max_block - 1) / max_block; | ||
| 62 | // Header per block: 5 bytes. Total deflate stream bytes: | ||
| 63 | const deflate_len = actual_blocks * 5 + filtered.len; | ||
| 64 | |||
| 65 | // Full buffer: zlib_header(2) + deflate + adler32(4) | ||
| 66 | const total = 2 + deflate_len + 4; | ||
| 67 | const buf = alloc.alloc(u8, total) catch return error.OutOfMemory; | ||
| 68 | errdefer alloc.free(buf); | ||
| 69 | |||
| 70 | var pos: usize = 0; | ||
| 71 | buf[pos] = zlib_header[0]; | ||
| 72 | pos += 1; | ||
| 73 | buf[pos] = zlib_header[1]; | ||
| 74 | pos += 1; | ||
| 75 | |||
| 76 | var src_pos: usize = 0; | ||
| 77 | var block_idx: usize = 0; | ||
| 78 | while (block_idx < actual_blocks) : (block_idx += 1) { | ||
| 79 | const remaining = filtered.len - src_pos; | ||
| 80 | const block_len: u16 = @intCast(@min(remaining, max_block)); | ||
| 81 | const is_final = block_idx == actual_blocks - 1; | ||
| 82 | const bfinal: u8 = if (is_final) 0x01 else 0x00; | ||
| 83 | buf[pos] = bfinal; // BFINAL=is_final, BTYPE=00 | ||
| 84 | pos += 1; | ||
| 85 | std.mem.writeInt(u16, buf[pos..][0..2], block_len, .little); | ||
| 86 | pos += 2; | ||
| 87 | const nlen: u16 = ~block_len; | ||
| 88 | std.mem.writeInt(u16, buf[pos..][0..2], nlen, .little); | ||
| 89 | pos += 2; | ||
| 90 | @memcpy(buf[pos..][0..block_len], filtered[src_pos..][0..block_len]); | ||
| 91 | pos += block_len; | ||
| 92 | src_pos += block_len; | ||
| 93 | } | ||
| 94 | |||
| 95 | // Adler-32 of the uncompressed (filtered) data, big-endian | ||
| 96 | std.mem.writeInt(u32, buf[pos..][0..4], adler32(filtered), .big); | ||
| 97 | pos += 4; | ||
| 98 | std.debug.assert(pos == total); | ||
| 99 | |||
| 100 | return buf; | ||
| 101 | } | ||
| 102 | |||
| 103 | pub fn encode(alloc: std.mem.Allocator, img: Image, writer: anytype) EncodeError!void { | ||
| 104 | std.debug.assert(img.pixels.len == @as(usize, img.width) * img.height * 4); | ||
| 105 | |||
| 106 | writer.writeAll(&signature) catch return error.WriteFailed; | ||
| 107 | |||
| 108 | var ihdr: [13]u8 = undefined; | ||
| 109 | std.mem.writeInt(u32, ihdr[0..4], img.width, .big); | ||
| 110 | std.mem.writeInt(u32, ihdr[4..8], img.height, .big); | ||
| 111 | ihdr[8] = 8; // bit depth | ||
| 112 | ihdr[9] = 6; // colour type = RGBA | ||
| 113 | ihdr[10] = 0; // compression method | ||
| 114 | ihdr[11] = 0; // filter method | ||
| 115 | ihdr[12] = 0; // interlace method = none | ||
| 116 | try writeChunk(writer, "IHDR", &ihdr); | ||
| 117 | |||
| 118 | const row_bytes = @as(usize, img.width) * 4; | ||
| 119 | const filtered_len = (row_bytes + 1) * img.height; | ||
| 120 | const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory; | ||
| 121 | defer alloc.free(filtered); | ||
| 122 | |||
| 123 | // Filter type 0 (None) per row | ||
| 124 | var y: u32 = 0; | ||
| 125 | while (y < img.height) : (y += 1) { | ||
| 126 | const src_off = @as(usize, y) * row_bytes; | ||
| 127 | const dst_off = @as(usize, y) * (row_bytes + 1); | ||
| 128 | filtered[dst_off] = 0; // filter byte | ||
| 129 | @memcpy(filtered[dst_off + 1 ..][0..row_bytes], img.pixels[src_off..][0..row_bytes]); | ||
| 130 | } | ||
| 131 | |||
| 132 | const compressed = try buildZlibStored(alloc, filtered); | ||
| 133 | defer alloc.free(compressed); | ||
| 134 | |||
| 135 | try writeChunk(writer, "IDAT", compressed); | ||
| 136 | try writeChunk(writer, "IEND", &.{}); | ||
| 137 | } | ||
| 138 | |||
| 139 | pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) DecodeError!Image { | ||
| 140 | if (bytes.len < signature.len + 8) return error.InvalidPng; | ||
| 141 | if (!std.mem.eql(u8, bytes[0..signature.len], &signature)) return error.InvalidPng; | ||
| 142 | |||
| 143 | var cursor: usize = signature.len; | ||
| 144 | var width: u32 = 0; | ||
| 145 | var height: u32 = 0; | ||
| 146 | var idat_accum: std.ArrayList(u8) = .empty; | ||
| 147 | defer idat_accum.deinit(alloc); | ||
| 148 | var seen_ihdr = false; | ||
| 149 | var seen_iend = false; | ||
| 150 | |||
| 151 | while (cursor + 8 <= bytes.len and !seen_iend) { | ||
| 152 | const len = std.mem.readInt(u32, bytes[cursor..][0..4], .big); | ||
| 153 | cursor += 4; | ||
| 154 | const ctype = bytes[cursor..][0..4]; | ||
| 155 | cursor += 4; | ||
| 156 | if (cursor + len + 4 > bytes.len) return error.CorruptChunk; | ||
| 157 | const payload = bytes[cursor..][0..len]; | ||
| 158 | cursor += len; | ||
| 159 | cursor += 4; // skip CRC | ||
| 160 | |||
| 161 | if (std.mem.eql(u8, ctype, "IHDR")) { | ||
| 162 | if (payload.len != 13) return error.InvalidPng; | ||
| 163 | width = std.mem.readInt(u32, payload[0..4], .big); | ||
| 164 | height = std.mem.readInt(u32, payload[4..8], .big); | ||
| 165 | // bit depth=8, colour type=6 (RGBA), interlace=0 | ||
| 166 | if (payload[8] != 8 or payload[9] != 6 or payload[12] != 0) | ||
| 167 | return error.UnsupportedPng; | ||
| 168 | seen_ihdr = true; | ||
| 169 | } else if (std.mem.eql(u8, ctype, "IDAT")) { | ||
| 170 | if (!seen_ihdr) return error.InvalidPng; | ||
| 171 | idat_accum.appendSlice(alloc, payload) catch return error.OutOfMemory; | ||
| 172 | } else if (std.mem.eql(u8, ctype, "IEND")) { | ||
| 173 | seen_iend = true; | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | if (!seen_ihdr or !seen_iend) return error.InvalidPng; | ||
| 178 | // zlib stream: 2-byte header + deflate body + 4-byte adler32 | ||
| 179 | if (idat_accum.items.len < 6) return error.InvalidPng; | ||
| 180 | // Strip the 2-byte zlib header and 4-byte adler32 footer to get raw deflate | ||
| 181 | const deflate_data = idat_accum.items[2 .. idat_accum.items.len - 4]; | ||
| 182 | |||
| 183 | const row_bytes = @as(usize, width) * 4; | ||
| 184 | const filtered_len = (row_bytes + 1) * @as(usize, height); | ||
| 185 | const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory; | ||
| 186 | defer alloc.free(filtered); | ||
| 187 | |||
| 188 | // Decompress using std.compress.flate.Decompress with the new Zig 0.15 API. | ||
| 189 | // The indirect vtable (used when a window buffer is provided) fills its | ||
| 190 | // internal buffer on each vtable call and returns 0; the caller must loop, | ||
| 191 | // draining the buffer on alternate calls. | ||
| 192 | { | ||
| 193 | var in_reader: std.Io.Reader = .fixed(deflate_data); | ||
| 194 | var decomp_buf: [std.compress.flate.max_window_len]u8 = undefined; | ||
| 195 | var decomp: std.compress.flate.Decompress = .init(&in_reader, .raw, &decomp_buf); | ||
| 196 | |||
| 197 | var dst_writer: std.Io.Writer = .fixed(filtered); | ||
| 198 | var written: usize = 0; | ||
| 199 | while (written < filtered_len) { | ||
| 200 | const n = decomp.reader.stream(&dst_writer, .unlimited) catch |err| switch (err) { | ||
| 201 | error.EndOfStream => break, | ||
| 202 | else => return error.CorruptChunk, | ||
| 203 | }; | ||
| 204 | written += n; | ||
| 205 | if (n == 0 and decomp.reader.seek == decomp.reader.end) break; | ||
| 206 | } | ||
| 207 | if (written != filtered_len) return error.CorruptChunk; | ||
| 208 | } | ||
| 209 | |||
| 210 | const pixels = alloc.alloc(u8, @as(usize, width) * height * 4) catch return error.OutOfMemory; | ||
| 211 | errdefer alloc.free(pixels); | ||
| 212 | |||
| 213 | var row: u32 = 0; | ||
| 214 | while (row < height) : (row += 1) { | ||
| 215 | const dst_off = @as(usize, row) * row_bytes; | ||
| 216 | const src_off = @as(usize, row) * (row_bytes + 1); | ||
| 217 | if (filtered[src_off] != 0) return error.UnsupportedPng; // only filter type 0 | ||
| 218 | @memcpy(pixels[dst_off..][0..row_bytes], filtered[src_off + 1 ..][0..row_bytes]); | ||
| 219 | } | ||
| 220 | |||
| 221 | return .{ .width = width, .height = height, .pixels = pixels }; | ||
| 222 | } | ||
| 223 | |||
| 224 | test "encode then decode roundtrip recovers pixels" { | ||
| 225 | const alloc = std.testing.allocator; | ||
| 226 | var src_pixels = [_]u8{ | ||
| 227 | 0xff, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0xff, | ||
| 228 | 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, | ||
| 229 | }; | ||
| 230 | const src = Image{ .width = 2, .height = 2, .pixels = &src_pixels }; | ||
| 231 | |||
| 232 | var buf: std.ArrayList(u8) = .empty; | ||
| 233 | defer buf.deinit(alloc); | ||
| 234 | try encode(alloc, src, buf.writer(alloc)); | ||
| 235 | |||
| 236 | var decoded = try decode(alloc, buf.items[0..]); | ||
| 237 | defer decoded.deinit(alloc); | ||
| 238 | |||
| 239 | try std.testing.expectEqual(@as(u32, 2), decoded.width); | ||
| 240 | try std.testing.expectEqual(@as(u32, 2), decoded.height); | ||
| 241 | try std.testing.expectEqualSlices(u8, &src_pixels, decoded.pixels); | ||
| 242 | } | ||
| 243 | |||
| 244 | test "decode rejects RGB (non-alpha) PNGs with UnsupportedPng" { | ||
| 245 | const alloc = std.testing.allocator; | ||
| 246 | var bytes: std.ArrayList(u8) = .empty; | ||
| 247 | defer bytes.deinit(alloc); | ||
| 248 | try bytes.appendSlice(alloc, &signature); | ||
| 249 | var ihdr: [13]u8 = undefined; | ||
| 250 | std.mem.writeInt(u32, ihdr[0..4], 1, .big); | ||
| 251 | std.mem.writeInt(u32, ihdr[4..8], 1, .big); | ||
| 252 | ihdr[8] = 8; | ||
| 253 | ihdr[9] = 2; // colour type 2 = RGB (not RGBA) | ||
| 254 | ihdr[10] = 0; | ||
| 255 | ihdr[11] = 0; | ||
| 256 | ihdr[12] = 0; | ||
| 257 | try writeChunk(bytes.writer(alloc), "IHDR", &ihdr); | ||
| 258 | try writeChunk(bytes.writer(alloc), "IEND", &.{}); | ||
| 259 | |||
| 260 | try std.testing.expectError(error.UnsupportedPng, decode(alloc, bytes.items[0..])); | ||
| 261 | } | ||
src/renderer.zig
| Old | New | ||
|---|---|---|---|
| @@ -418,6 +418,125 @@ fn swapchainNeedsRebuild(result: vk.Result) bool { | |||
| 418 | return result == .suboptimal_khr; | 418 | return result == .suboptimal_khr; |
| 419 | } | 419 | } |
| 420 | 420 | ||
| 421 | /// Offscreen color attachment + readback staging buffer. | ||
| 422 | /// The image is allocated with COLOR_ATTACHMENT_BIT | TRANSFER_SRC_BIT so the | ||
| 423 | /// renderer can draw into it and then copy it into the host-visible readback | ||
| 424 | /// buffer. Created via `createOffscreen` and freed with `destroyOffscreen`. | ||
| 425 | pub const OffscreenTarget = struct { | ||
| 426 | width: u32, | ||
| 427 | height: u32, | ||
| 428 | format: vk.Format, | ||
| 429 | image: vk.Image, | ||
| 430 | memory: vk.DeviceMemory, | ||
| 431 | view: vk.ImageView, | ||
| 432 | framebuffer: vk.Framebuffer, | ||
| 433 | readback_buffer: vk.Buffer, | ||
| 434 | readback_memory: vk.DeviceMemory, | ||
| 435 | readback_size: u64, | ||
| 436 | }; | ||
| 437 | |||
| 438 | /// Allocate an offscreen color-attachment image plus matching readback buffer. | ||
| 439 | /// The framebuffer is compatible with `render_pass` — pass the same render pass | ||
| 440 | /// the swapchain uses so the existing pipeline is reusable. | ||
| 441 | pub fn createOffscreen( | ||
| 442 | vki: vk.InstanceWrapper, | ||
| 443 | vkd: vk.DeviceWrapper, | ||
| 444 | physical: vk.PhysicalDevice, | ||
| 445 | device: vk.Device, | ||
| 446 | render_pass: vk.RenderPass, | ||
| 447 | format: vk.Format, | ||
| 448 | width: u32, | ||
| 449 | height: u32, | ||
| 450 | ) !OffscreenTarget { | ||
| 451 | // 1. Color attachment image with TRANSFER_SRC_BIT | COLOR_ATTACHMENT_BIT | ||
| 452 | const image = try vkd.createImage(device, &vk.ImageCreateInfo{ | ||
| 453 | .image_type = .@"2d", | ||
| 454 | .format = format, | ||
| 455 | .extent = .{ .width = width, .height = height, .depth = 1 }, | ||
| 456 | .mip_levels = 1, | ||
| 457 | .array_layers = 1, | ||
| 458 | .samples = .{ .@"1_bit" = true }, | ||
| 459 | .tiling = .optimal, | ||
| 460 | .usage = .{ .color_attachment_bit = true, .transfer_src_bit = true }, | ||
| 461 | .sharing_mode = .exclusive, | ||
| 462 | .initial_layout = .undefined, | ||
| 463 | }, null); | ||
| 464 | errdefer vkd.destroyImage(device, image, null); | ||
| 465 | |||
| 466 | // 2. Device-local memory bound | ||
| 467 | const img_reqs = vkd.getImageMemoryRequirements(device, image); | ||
| 468 | const img_mem_idx = try findMemoryType(vki, physical, img_reqs.memory_type_bits, .{ .device_local_bit = true }); | ||
| 469 | const memory = try vkd.allocateMemory(device, &vk.MemoryAllocateInfo{ | ||
| 470 | .allocation_size = img_reqs.size, | ||
| 471 | .memory_type_index = img_mem_idx, | ||
| 472 | }, null); | ||
| 473 | errdefer vkd.freeMemory(device, memory, null); | ||
| 474 | try vkd.bindImageMemory(device, image, memory, 0); | ||
| 475 | |||
| 476 | // 3. ImageView + Framebuffer (using the provided render_pass) | ||
| 477 | const view = try vkd.createImageView(device, &vk.ImageViewCreateInfo{ | ||
| 478 | .image = image, | ||
| 479 | .view_type = .@"2d", | ||
| 480 | .format = format, | ||
| 481 | .components = .{ .r = .identity, .g = .identity, .b = .identity, .a = .identity }, | ||
| 482 | .subresource_range = .{ | ||
| 483 | .aspect_mask = .{ .color_bit = true }, | ||
| 484 | .base_mip_level = 0, | ||
| 485 | .level_count = 1, | ||
| 486 | .base_array_layer = 0, | ||
| 487 | .layer_count = 1, | ||
| 488 | }, | ||
| 489 | }, null); | ||
| 490 | errdefer vkd.destroyImageView(device, view, null); | ||
| 491 | |||
| 492 | const framebuffer = try vkd.createFramebuffer(device, &vk.FramebufferCreateInfo{ | ||
| 493 | .render_pass = render_pass, | ||
| 494 | .attachment_count = 1, | ||
| 495 | .p_attachments = @ptrCast(&view), | ||
| 496 | .width = width, | ||
| 497 | .height = height, | ||
| 498 | .layers = 1, | ||
| 499 | }, null); | ||
| 500 | errdefer vkd.destroyFramebuffer(device, framebuffer, null); | ||
| 501 | |||
| 502 | // 4. Host-visible readback buffer (TRANSFER_DST, width*height*4 bytes) | ||
| 503 | const readback_size: u64 = @as(u64, width) * @as(u64, height) * 4; | ||
| 504 | const readback = try createHostVisibleBuffer( | ||
| 505 | vki, | ||
| 506 | physical, | ||
| 507 | vkd, | ||
| 508 | device, | ||
| 509 | @intCast(readback_size), | ||
| 510 | .{ .transfer_dst_bit = true }, | ||
| 511 | ); | ||
| 512 | errdefer { | ||
| 513 | vkd.destroyBuffer(device, readback.buffer, null); | ||
| 514 | vkd.freeMemory(device, readback.memory, null); | ||
| 515 | } | ||
| 516 | |||
| 517 | return .{ | ||
| 518 | .width = width, | ||
| 519 | .height = height, | ||
| 520 | .format = format, | ||
| 521 | .image = image, | ||
| 522 | .memory = memory, | ||
| 523 | .view = view, | ||
| 524 | .framebuffer = framebuffer, | ||
| 525 | .readback_buffer = readback.buffer, | ||
| 526 | .readback_memory = readback.memory, | ||
| 527 | .readback_size = readback_size, | ||
| 528 | }; | ||
| 529 | } | ||
| 530 | |||
| 531 | pub fn destroyOffscreen(vkd: vk.DeviceWrapper, device: vk.Device, t: OffscreenTarget) void { | ||
| 532 | vkd.destroyFramebuffer(device, t.framebuffer, null); | ||
| 533 | vkd.destroyImageView(device, t.view, null); | ||
| 534 | vkd.destroyImage(device, t.image, null); | ||
| 535 | vkd.freeMemory(device, t.memory, null); | ||
| 536 | vkd.destroyBuffer(device, t.readback_buffer, null); | ||
| 537 | vkd.freeMemory(device, t.readback_memory, null); | ||
| 538 | } | ||
| 539 | |||
| 421 | pub const Context = struct { | 540 | pub const Context = struct { |
| 422 | alloc: std.mem.Allocator, | 541 | alloc: std.mem.Allocator, |
| 423 | vkb: vk.BaseWrapper, | 542 | vkb: vk.BaseWrapper, |
| @@ -473,6 +592,9 @@ pub const Context = struct { | |||
| 473 | // Dedicated transfer command buffer + fence | 592 | // Dedicated transfer command buffer + fence |
| 474 | atlas_transfer_cb: vk.CommandBuffer, | 593 | atlas_transfer_cb: vk.CommandBuffer, |
| 475 | atlas_transfer_fence: vk.Fence, | 594 | atlas_transfer_fence: vk.Fence, |
| 595 | // Dedicated capture command buffer + fence (used by renderToOffscreen) | ||
| 596 | capture_cmd: vk.CommandBuffer, | ||
| 597 | capture_fence: vk.Fence, | ||
| 476 | 598 | ||
| 477 | pub fn init( | 599 | pub fn init( |
| 478 | alloc: std.mem.Allocator, | 600 | alloc: std.mem.Allocator, |
| @@ -929,6 +1051,19 @@ pub const Context = struct { | |||
| 929 | }, null); | 1051 | }, null); |
| 930 | errdefer vkd.destroyFence(device, atlas_transfer_fence, null); | 1052 | errdefer vkd.destroyFence(device, atlas_transfer_fence, null); |
| 931 | 1053 | ||
| 1054 | // --- Dedicated capture (offscreen render + readback) command buffer + fence --- | ||
| 1055 | var capture_cmd: vk.CommandBuffer = undefined; | ||
| 1056 | try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{ | ||
| 1057 | .command_pool = command_pool, | ||
| 1058 | .level = .primary, | ||
| 1059 | .command_buffer_count = 1, | ||
| 1060 | }, @ptrCast(&capture_cmd)); | ||
| 1061 | |||
| 1062 | const capture_fence = try vkd.createFence(device, &vk.FenceCreateInfo{ | ||
| 1063 | .flags = .{ .signaled_bit = true }, | ||
| 1064 | }, null); | ||
| 1065 | errdefer vkd.destroyFence(device, capture_fence, null); | ||
| 1066 | |||
| 932 | // Bind atlas to descriptor set | 1067 | // Bind atlas to descriptor set |
| 933 | const img_info = vk.DescriptorImageInfo{ | 1068 | const img_info = vk.DescriptorImageInfo{ |
| 934 | .sampler = atlas_sampler, | 1069 | .sampler = atlas_sampler, |
| @@ -991,6 +1126,8 @@ pub const Context = struct { | |||
| 991 | .atlas_staging_memory = atlas_staging.memory, | 1126 | .atlas_staging_memory = atlas_staging.memory, |
| 992 | .atlas_transfer_cb = atlas_transfer_cb, | 1127 | .atlas_transfer_cb = atlas_transfer_cb, |
| 993 | .atlas_transfer_fence = atlas_transfer_fence, | 1128 | .atlas_transfer_fence = atlas_transfer_fence, |
| 1129 | .capture_cmd = capture_cmd, | ||
| 1130 | .capture_fence = capture_fence, | ||
| 994 | }; | 1131 | }; |
| 995 | } | 1132 | } |
| 996 | 1133 | ||
| @@ -1006,6 +1143,7 @@ pub const Context = struct { | |||
| 1006 | self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null); | 1143 | self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null); |
| 1007 | self.vkd.freeMemory(self.device, self.atlas_staging_memory, null); | 1144 | self.vkd.freeMemory(self.device, self.atlas_staging_memory, null); |
| 1008 | self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null); | 1145 | self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null); |
| 1146 | self.vkd.destroyFence(self.device, self.capture_fence, null); | ||
| 1009 | self.vkd.destroyBuffer(self.device, self.instance_buffer, null); | 1147 | self.vkd.destroyBuffer(self.device, self.instance_buffer, null); |
| 1010 | self.vkd.freeMemory(self.device, self.instance_memory, null); | 1148 | self.vkd.freeMemory(self.device, self.instance_memory, null); |
| 1011 | self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null); | 1149 | self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null); |
| @@ -1480,6 +1618,67 @@ pub const Context = struct { | |||
| 1480 | return false; | 1618 | return false; |
| 1481 | } | 1619 | } |
| 1482 | 1620 | ||
| 1621 | /// Shared "bind pipeline + push constants + vertex/instance buffers + | ||
| 1622 | /// dynamic viewport/scissor + drawInstanced" block used by both the | ||
| 1623 | /// swapchain draw path (`drawCells`) and the offscreen draw path | ||
| 1624 | /// (`renderToOffscreen`). | ||
| 1625 | /// | ||
| 1626 | /// The caller is responsible for recording the surrounding | ||
| 1627 | /// `cmdBeginRenderPass`/`cmdEndRenderPass` pair with the appropriate | ||
| 1628 | /// framebuffer and render area. | ||
| 1629 | fn recordDrawCommands( | ||
| 1630 | self: *Context, | ||
| 1631 | cmd: vk.CommandBuffer, | ||
| 1632 | extent: vk.Extent2D, | ||
| 1633 | instance_count: u32, | ||
| 1634 | push: PushConstants, | ||
| 1635 | ) void { | ||
| 1636 | self.vkd.cmdBindPipeline(cmd, .graphics, self.pipeline); | ||
| 1637 | |||
| 1638 | // Dynamic viewport + scissor | ||
| 1639 | const viewport = vk.Viewport{ | ||
| 1640 | .x = 0.0, | ||
| 1641 | .y = 0.0, | ||
| 1642 | .width = @floatFromInt(extent.width), | ||
| 1643 | .height = @floatFromInt(extent.height), | ||
| 1644 | .min_depth = 0.0, | ||
| 1645 | .max_depth = 1.0, | ||
| 1646 | }; | ||
| 1647 | self.vkd.cmdSetViewport(cmd, 0, 1, @ptrCast(&viewport)); | ||
| 1648 | |||
| 1649 | const scissor = vk.Rect2D{ | ||
| 1650 | .offset = .{ .x = 0, .y = 0 }, | ||
| 1651 | .extent = extent, | ||
| 1652 | }; | ||
| 1653 | self.vkd.cmdSetScissor(cmd, 0, 1, @ptrCast(&scissor)); | ||
| 1654 | |||
| 1655 | // Push constants | ||
| 1656 | self.vkd.cmdPushConstants( | ||
| 1657 | cmd, | ||
| 1658 | self.pipeline_layout, | ||
| 1659 | .{ .vertex_bit = true, .fragment_bit = true }, | ||
| 1660 | 0, | ||
| 1661 | @sizeOf(PushConstants), | ||
| 1662 | @ptrCast(&push), | ||
| 1663 | ); | ||
| 1664 | |||
| 1665 | // Bind descriptor set (atlas sampler) | ||
| 1666 | self.vkd.cmdBindDescriptorSets( | ||
| 1667 | cmd, | ||
| 1668 | .graphics, | ||
| 1669 | self.pipeline_layout, | ||
| 1670 | 0, 1, @ptrCast(&self.descriptor_set), | ||
| 1671 | 0, null, | ||
| 1672 | ); | ||
| 1673 | |||
| 1674 | // Bind vertex buffers: binding 0 = quad, binding 1 = instances | ||
| 1675 | const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer }; | ||
| 1676 | const offsets = [_]vk.DeviceSize{ 0, 0 }; | ||
| 1677 | self.vkd.cmdBindVertexBuffers(cmd, 0, 2, &buffers, &offsets); | ||
| 1678 | |||
| 1679 | self.vkd.cmdDraw(cmd, 6, instance_count, 0, 0); | ||
| 1680 | } | ||
| 1681 | |||
| 1483 | /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present. | 1682 | /// Full draw pass: bind pipeline, push constants, vertex + instance buffers, draw, present. |
| 1484 | pub fn drawCells( | 1683 | pub fn drawCells( |
| 1485 | self: *Context, | 1684 | self: *Context, |
| @@ -1525,26 +1724,6 @@ pub const Context = struct { | |||
| 1525 | .p_clear_values = @ptrCast(&clear_value), | 1724 | .p_clear_values = @ptrCast(&clear_value), |
| 1526 | }, .@"inline"); | 1725 | }, .@"inline"); |
| 1527 | 1726 | ||
| 1528 | self.vkd.cmdBindPipeline(self.command_buffer, .graphics, self.pipeline); | ||
| 1529 | |||
| 1530 | // Dynamic viewport + scissor | ||
| 1531 | const viewport = vk.Viewport{ | ||
| 1532 | .x = 0.0, | ||
| 1533 | .y = 0.0, | ||
| 1534 | .width = @floatFromInt(self.swapchain_extent.width), | ||
| 1535 | .height = @floatFromInt(self.swapchain_extent.height), | ||
| 1536 | .min_depth = 0.0, | ||
| 1537 | .max_depth = 1.0, | ||
| 1538 | }; | ||
| 1539 | self.vkd.cmdSetViewport(self.command_buffer, 0, 1, @ptrCast(&viewport)); | ||
| 1540 | |||
| 1541 | const scissor = vk.Rect2D{ | ||
| 1542 | .offset = .{ .x = 0, .y = 0 }, | ||
| 1543 | .extent = self.swapchain_extent, | ||
| 1544 | }; | ||
| 1545 | self.vkd.cmdSetScissor(self.command_buffer, 0, 1, @ptrCast(&scissor)); | ||
| 1546 | |||
| 1547 | // Push constants | ||
| 1548 | const pc = PushConstants{ | 1727 | const pc = PushConstants{ |
| 1549 | .viewport_size = .{ | 1728 | .viewport_size = .{ |
| 1550 | @floatFromInt(self.swapchain_extent.width), | 1729 | @floatFromInt(self.swapchain_extent.width), |
| @@ -1553,30 +1732,7 @@ pub const Context = struct { | |||
| 1553 | .cell_size = cell_size, | 1732 | .cell_size = cell_size, |
| 1554 | .coverage_params = coverage_params, | 1733 | .coverage_params = coverage_params, |
| 1555 | }; | 1734 | }; |
| 1556 | self.vkd.cmdPushConstants( | 1735 | self.recordDrawCommands(self.command_buffer, self.swapchain_extent, instance_count, pc); |
| 1557 | self.command_buffer, | ||
| 1558 | self.pipeline_layout, | ||
| 1559 | .{ .vertex_bit = true, .fragment_bit = true }, | ||
| 1560 | 0, | ||
| 1561 | @sizeOf(PushConstants), | ||
| 1562 | @ptrCast(&pc), | ||
| 1563 | ); | ||
| 1564 | |||
| 1565 | // Bind descriptor set (atlas sampler) | ||
| 1566 | self.vkd.cmdBindDescriptorSets( | ||
| 1567 | self.command_buffer, | ||
| 1568 | .graphics, | ||
| 1569 | self.pipeline_layout, | ||
| 1570 | 0, 1, @ptrCast(&self.descriptor_set), | ||
| 1571 | 0, null, | ||
| 1572 | ); | ||
| 1573 | |||
| 1574 | // Bind vertex buffers: binding 0 = quad, binding 1 = instances | ||
| 1575 | const buffers = [_]vk.Buffer{ self.quad_vertex_buffer, self.instance_buffer }; | ||
| 1576 | const offsets = [_]vk.DeviceSize{ 0, 0 }; | ||
| 1577 | self.vkd.cmdBindVertexBuffers(self.command_buffer, 0, 2, &buffers, &offsets); | ||
| 1578 | |||
| 1579 | self.vkd.cmdDraw(self.command_buffer, 6, instance_count, 0, 0); | ||
| 1580 | 1736 | ||
| 1581 | self.vkd.cmdEndRenderPass(self.command_buffer); | 1737 | self.vkd.cmdEndRenderPass(self.command_buffer); |
| 1582 | try self.vkd.endCommandBuffer(self.command_buffer); | 1738 | try self.vkd.endCommandBuffer(self.command_buffer); |
| @@ -1606,6 +1762,184 @@ pub const Context = struct { | |||
| 1606 | }; | 1762 | }; |
| 1607 | if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR; | 1763 | if (swapchainNeedsRebuild(present_result)) return error.OutOfDateKHR; |
| 1608 | } | 1764 | } |
| 1765 | |||
| 1766 | /// Render `instance_data` into the offscreen target and copy the rendered | ||
| 1767 | /// image into the target's host-visible readback buffer. | ||
| 1768 | /// | ||
| 1769 | /// After this call returns, `target.readback_buffer` contains the raw | ||
| 1770 | /// bytes in the target's native format (typically BGRA8). Use | ||
| 1771 | /// `readbackOffscreen` to pull them out as RGBA8. | ||
| 1772 | pub fn renderToOffscreen( | ||
| 1773 | self: *Context, | ||
| 1774 | target: *const OffscreenTarget, | ||
| 1775 | instance_data: []const Instance, | ||
| 1776 | push: PushConstants, | ||
| 1777 | ) !void { | ||
| 1778 | // Wait for any in-flight swapchain frame to complete before touching the | ||
| 1779 | // shared instance buffer. (drawCells and renderToOffscreen share | ||
| 1780 | // self.instance_memory; without this wait the host would overwrite bytes | ||
| 1781 | // the GPU is still reading.) | ||
| 1782 | _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64)); | ||
| 1783 | |||
| 1784 | // 1. Upload instances (same path drawCells uses) | ||
| 1785 | try self.uploadInstances(instance_data); | ||
| 1786 | |||
| 1787 | // 2. Reset + begin capture command buffer | ||
| 1788 | _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64)); | ||
| 1789 | try self.vkd.resetFences(self.device, 1, @ptrCast(&self.capture_fence)); | ||
| 1790 | |||
| 1791 | try self.vkd.resetCommandBuffer(self.capture_cmd, .{}); | ||
| 1792 | try self.vkd.beginCommandBuffer(self.capture_cmd, &vk.CommandBufferBeginInfo{ | ||
| 1793 | .flags = .{ .one_time_submit_bit = true }, | ||
| 1794 | }); | ||
| 1795 | |||
| 1796 | // 3. Transition target.image UNDEFINED -> COLOR_ATTACHMENT_OPTIMAL. | ||
| 1797 | // The render pass's `initial_layout = .undefined` technically | ||
| 1798 | // accepts the image in any layout, but an explicit barrier makes | ||
| 1799 | // the access masks/stage masks unambiguous. | ||
| 1800 | const to_color = vk.ImageMemoryBarrier{ | ||
| 1801 | .src_access_mask = .{}, | ||
| 1802 | .dst_access_mask = .{ .color_attachment_write_bit = true }, | ||
| 1803 | .old_layout = .undefined, | ||
| 1804 | .new_layout = .color_attachment_optimal, | ||
| 1805 | .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED, | ||
| 1806 | .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED, | ||
| 1807 | .image = target.image, | ||
| 1808 | .subresource_range = .{ | ||
| 1809 | .aspect_mask = .{ .color_bit = true }, | ||
| 1810 | .base_mip_level = 0, | ||
| 1811 | .level_count = 1, | ||
| 1812 | .base_array_layer = 0, | ||
| 1813 | .layer_count = 1, | ||
| 1814 | }, | ||
| 1815 | }; | ||
| 1816 | self.vkd.cmdPipelineBarrier( | ||
| 1817 | self.capture_cmd, | ||
| 1818 | .{ .top_of_pipe_bit = true }, | ||
| 1819 | .{ .color_attachment_output_bit = true }, | ||
| 1820 | .{}, | ||
| 1821 | 0, null, | ||
| 1822 | 0, null, | ||
| 1823 | 1, @ptrCast(&to_color), | ||
| 1824 | ); | ||
| 1825 | |||
| 1826 | // 4. Begin the render pass on target.framebuffer / extent | ||
| 1827 | const clear_value = vk.ClearValue{ .color = .{ .float_32 = .{ 0.0, 0.0, 0.0, 1.0 } } }; | ||
| 1828 | const extent = vk.Extent2D{ .width = target.width, .height = target.height }; | ||
| 1829 | self.vkd.cmdBeginRenderPass(self.capture_cmd, &vk.RenderPassBeginInfo{ | ||
| 1830 | .render_pass = self.render_pass, | ||
| 1831 | .framebuffer = target.framebuffer, | ||
| 1832 | .render_area = .{ | ||
| 1833 | .offset = .{ .x = 0, .y = 0 }, | ||
| 1834 | .extent = extent, | ||
| 1835 | }, | ||
| 1836 | .clear_value_count = 1, | ||
| 1837 | .p_clear_values = @ptrCast(&clear_value), | ||
| 1838 | }, .@"inline"); | ||
| 1839 | |||
| 1840 | // 5. Shared draw commands (same as drawCells) | ||
| 1841 | self.recordDrawCommands(self.capture_cmd, extent, @intCast(instance_data.len), push); | ||
| 1842 | |||
| 1843 | // 6. End render pass (the pass's final_layout is .present_src_khr) | ||
| 1844 | self.vkd.cmdEndRenderPass(self.capture_cmd); | ||
| 1845 | |||
| 1846 | // 7. Transition target.image PRESENT_SRC_KHR -> TRANSFER_SRC_OPTIMAL | ||
| 1847 | // The render pass forced the final layout to .present_src_khr | ||
| 1848 | // (it's the same render pass the swapchain uses). Barrier from | ||
| 1849 | // there to TRANSFER_SRC before cmdCopyImageToBuffer. | ||
| 1850 | const to_transfer = vk.ImageMemoryBarrier{ | ||
| 1851 | .src_access_mask = .{ .color_attachment_write_bit = true }, | ||
| 1852 | .dst_access_mask = .{ .transfer_read_bit = true }, | ||
| 1853 | .old_layout = .present_src_khr, | ||
| 1854 | .new_layout = .transfer_src_optimal, | ||
| 1855 | .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED, | ||
| 1856 | .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED, | ||
| 1857 | .image = target.image, | ||
| 1858 | .subresource_range = .{ | ||
| 1859 | .aspect_mask = .{ .color_bit = true }, | ||
| 1860 | .base_mip_level = 0, | ||
| 1861 | .level_count = 1, | ||
| 1862 | .base_array_layer = 0, | ||
| 1863 | .layer_count = 1, | ||
| 1864 | }, | ||
| 1865 | }; | ||
| 1866 | self.vkd.cmdPipelineBarrier( | ||
| 1867 | self.capture_cmd, | ||
| 1868 | .{ .color_attachment_output_bit = true }, | ||
| 1869 | .{ .transfer_bit = true }, | ||
| 1870 | .{}, | ||
| 1871 | 0, null, | ||
| 1872 | 0, null, | ||
| 1873 | 1, @ptrCast(&to_transfer), | ||
| 1874 | ); | ||
| 1875 | |||
| 1876 | // 8. Copy image -> readback buffer | ||
| 1877 | const region = vk.BufferImageCopy{ | ||
| 1878 | .buffer_offset = 0, | ||
| 1879 | .buffer_row_length = 0, | ||
| 1880 | .buffer_image_height = 0, | ||
| 1881 | .image_subresource = .{ | ||
| 1882 | .aspect_mask = .{ .color_bit = true }, | ||
| 1883 | .mip_level = 0, | ||
| 1884 | .base_array_layer = 0, | ||
| 1885 | .layer_count = 1, | ||
| 1886 | }, | ||
| 1887 | .image_offset = .{ .x = 0, .y = 0, .z = 0 }, | ||
| 1888 | .image_extent = .{ .width = target.width, .height = target.height, .depth = 1 }, | ||
| 1889 | }; | ||
| 1890 | self.vkd.cmdCopyImageToBuffer( | ||
| 1891 | self.capture_cmd, | ||
| 1892 | target.image, | ||
| 1893 | .transfer_src_optimal, | ||
| 1894 | target.readback_buffer, | ||
| 1895 | 1, | ||
| 1896 | @ptrCast(®ion), | ||
| 1897 | ); | ||
| 1898 | |||
| 1899 | // 9. End + submit + wait | ||
| 1900 | try self.vkd.endCommandBuffer(self.capture_cmd); | ||
| 1901 | try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{ | ||
| 1902 | .command_buffer_count = 1, | ||
| 1903 | .p_command_buffers = @ptrCast(&self.capture_cmd), | ||
| 1904 | }), self.capture_fence); | ||
| 1905 | _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64)); | ||
| 1906 | } | ||
| 1907 | |||
| 1908 | /// Read the offscreen target's readback buffer into `out_rgba`. | ||
| 1909 | /// Converts BGRA (the swapchain/native format) to RGBA and forces | ||
| 1910 | /// alpha to 0xFF. Caller must ensure a prior `renderToOffscreen` has | ||
| 1911 | /// finished (it waits on the capture fence, so simple back-to-back | ||
| 1912 | /// call is safe). | ||
| 1913 | pub fn readbackOffscreen( | ||
| 1914 | self: *Context, | ||
| 1915 | target: *const OffscreenTarget, | ||
| 1916 | out_rgba: []u8, | ||
| 1917 | ) !void { | ||
| 1918 | std.debug.assert(target.format == .b8g8r8a8_unorm); // swizzle below assumes BGRA8 | ||
| 1919 | std.debug.assert(out_rgba.len == @as(usize, target.width) * @as(usize, target.height) * 4); | ||
| 1920 | if (out_rgba.len < target.readback_size) return error.BufferTooSmall; | ||
| 1921 | |||
| 1922 | const mapped = try self.vkd.mapMemory( | ||
| 1923 | self.device, | ||
| 1924 | target.readback_memory, | ||
| 1925 | 0, | ||
| 1926 | @intCast(target.readback_size), | ||
| 1927 | .{}, | ||
| 1928 | ); | ||
| 1929 | defer self.vkd.unmapMemory(self.device, target.readback_memory); | ||
| 1930 | |||
| 1931 | const src = @as([*]const u8, @ptrCast(mapped))[0..target.readback_size]; | ||
| 1932 | const pixel_count: usize = @intCast(@as(u64, target.width) * @as(u64, target.height)); | ||
| 1933 | var i: usize = 0; | ||
| 1934 | while (i < pixel_count) : (i += 1) { | ||
| 1935 | const o = i * 4; | ||
| 1936 | // Source is BGRA8 (swapchain-native); produce RGBA8, force alpha=0xFF. | ||
| 1937 | out_rgba[o + 0] = src[o + 2]; // R <- B | ||
| 1938 | out_rgba[o + 1] = src[o + 1]; // G <- G | ||
| 1939 | out_rgba[o + 2] = src[o + 0]; // B <- R | ||
| 1940 | out_rgba[o + 3] = 0xFF; | ||
| 1941 | } | ||
| 1942 | } | ||
| 1609 | }; | 1943 | }; |
| 1610 | 1944 | ||
| 1611 | test "vulkan module imports" { | 1945 | test "vulkan module imports" { |
src/tools/bench_baseline.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,121 @@ | |||
| 1 | const std = @import("std"); | ||
| 2 | const bench_stats = @import("bench_stats"); | ||
| 3 | |||
| 4 | pub fn main() !void { | ||
| 5 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 6 | defer _ = gpa.deinit(); | ||
| 7 | const alloc = gpa.allocator(); | ||
| 8 | |||
| 9 | const args = try std.process.argsAlloc(alloc); | ||
| 10 | defer std.process.argsFree(alloc, args); | ||
| 11 | |||
| 12 | const Mode = enum { save, check }; | ||
| 13 | const mode: Mode = if (args.len >= 2 and std.mem.eql(u8, args[1], "save")) | ||
| 14 | .save | ||
| 15 | else | ||
| 16 | .check; | ||
| 17 | |||
| 18 | const baseline_path = "tests/bench/baseline.json"; | ||
| 19 | const tmp_json = "/tmp/waystty-bench-current.json"; | ||
| 20 | |||
| 21 | try std.fs.cwd().makePath("tests/bench"); | ||
| 22 | |||
| 23 | // Run waystty with WAYSTTY_BENCH=1 WAYSTTY_BENCH_JSON=<tmp> | ||
| 24 | var env = try std.process.getEnvMap(alloc); | ||
| 25 | defer env.deinit(); | ||
| 26 | try env.put("WAYSTTY_BENCH", "1"); | ||
| 27 | try env.put("WAYSTTY_BENCH_JSON", tmp_json); | ||
| 28 | |||
| 29 | const child = try std.process.Child.run(.{ | ||
| 30 | .allocator = alloc, | ||
| 31 | .argv = &.{"zig-out/bin/waystty"}, | ||
| 32 | .env_map = &env, | ||
| 33 | }); | ||
| 34 | defer alloc.free(child.stdout); | ||
| 35 | defer alloc.free(child.stderr); | ||
| 36 | |||
| 37 | if (child.term != .Exited or child.term.Exited != 0) { | ||
| 38 | std.debug.print("bench: waystty exited abnormally: {any}\n stderr: {s}\n", .{ child.term, child.stderr }); | ||
| 39 | std.process.exit(2); | ||
| 40 | } | ||
| 41 | |||
| 42 | const current_bytes = std.fs.cwd().readFileAlloc(alloc, tmp_json, 16 * 1024) catch |err| { | ||
| 43 | std.debug.print("bench: no JSON output at {s}: {s}\n", .{ tmp_json, @errorName(err) }); | ||
| 44 | std.process.exit(2); | ||
| 45 | }; | ||
| 46 | defer alloc.free(current_bytes); | ||
| 47 | |||
| 48 | const current = try bench_stats.readBaselineJson(alloc, current_bytes); | ||
| 49 | defer { | ||
| 50 | alloc.free(current.workload_sha); | ||
| 51 | alloc.free(current.zig_version); | ||
| 52 | alloc.free(current.waystty_sha); | ||
| 53 | } | ||
| 54 | |||
| 55 | if (mode == .save) { | ||
| 56 | const json_out = try bench_stats.writeBaselineJson(alloc, current); | ||
| 57 | defer alloc.free(json_out); | ||
| 58 | const out = try std.fs.cwd().createFile(baseline_path, .{}); | ||
| 59 | defer out.close(); | ||
| 60 | try out.writeAll(json_out); | ||
| 61 | std.debug.print("bench: wrote {s} (frame_count={d})\n", .{ baseline_path, current.frame_count }); | ||
| 62 | return; | ||
| 63 | } | ||
| 64 | |||
| 65 | // check mode — compare current to baseline. | ||
| 66 | const baseline_bytes = std.fs.cwd().readFileAlloc(alloc, baseline_path, 16 * 1024) catch |err| { | ||
| 67 | std.debug.print("bench: no baseline at {s}: {s}\n run: zig build bench-baseline\n", .{ baseline_path, @errorName(err) }); | ||
| 68 | std.process.exit(2); | ||
| 69 | }; | ||
| 70 | defer alloc.free(baseline_bytes); | ||
| 71 | |||
| 72 | const baseline = try bench_stats.readBaselineJson(alloc, baseline_bytes); | ||
| 73 | defer { | ||
| 74 | alloc.free(baseline.workload_sha); | ||
| 75 | alloc.free(baseline.zig_version); | ||
| 76 | alloc.free(baseline.waystty_sha); | ||
| 77 | } | ||
| 78 | |||
| 79 | if (!std.mem.eql(u8, baseline.workload_sha, current.workload_sha)) { | ||
| 80 | std.debug.print("WARN: bench script changed since baseline; consider regenerating via `zig build bench-baseline`\n", .{}); | ||
| 81 | } | ||
| 82 | |||
| 83 | const pct_threshold: f64 = blk: { | ||
| 84 | const v = std.posix.getenv("WAYSTTY_BENCH_REGRESSION_PCT") orelse break :blk 20.0; | ||
| 85 | break :blk std.fmt.parseFloat(f64, v) catch 20.0; | ||
| 86 | }; | ||
| 87 | |||
| 88 | var regressed = false; | ||
| 89 | |||
| 90 | const SectionName = struct { | ||
| 91 | name: []const u8, | ||
| 92 | base_p99: u32, | ||
| 93 | cur_p99: u32, | ||
| 94 | }; | ||
| 95 | |||
| 96 | const sections = [_]SectionName{ | ||
| 97 | .{ .name = "snapshot", .base_p99 = baseline.sections.snapshot.p99, .cur_p99 = current.sections.snapshot.p99 }, | ||
| 98 | .{ .name = "row_rebuild", .base_p99 = baseline.sections.row_rebuild.p99, .cur_p99 = current.sections.row_rebuild.p99 }, | ||
| 99 | .{ .name = "atlas_upload", .base_p99 = baseline.sections.atlas_upload.p99, .cur_p99 = current.sections.atlas_upload.p99 }, | ||
| 100 | .{ .name = "instance_upload", .base_p99 = baseline.sections.instance_upload.p99, .cur_p99 = current.sections.instance_upload.p99 }, | ||
| 101 | .{ .name = "gpu_submit", .base_p99 = baseline.sections.gpu_submit.p99, .cur_p99 = current.sections.gpu_submit.p99 }, | ||
| 102 | }; | ||
| 103 | |||
| 104 | std.debug.print("bench: threshold {d:.1}% p99 growth\n", .{pct_threshold}); | ||
| 105 | for (sections) |s| { | ||
| 106 | const delta_pct: f64 = if (s.base_p99 == 0) | ||
| 107 | 0.0 | ||
| 108 | else | ||
| 109 | ((@as(f64, @floatFromInt(s.cur_p99)) - @as(f64, @floatFromInt(s.base_p99))) / @as(f64, @floatFromInt(s.base_p99))) * 100.0; | ||
| 110 | const status = if (delta_pct > pct_threshold) "REGRESSION" else "OK"; | ||
| 111 | if (delta_pct > pct_threshold) regressed = true; | ||
| 112 | const sign: []const u8 = if (delta_pct >= 0) "+" else "-"; | ||
| 113 | const abs_delta: f64 = if (delta_pct >= 0) delta_pct else -delta_pct; | ||
| 114 | std.debug.print( | ||
| 115 | "bench: {s:<16} p99 {d:>5}us (baseline {d:>5}us) {s}{d:>5.1}% {s}\n", | ||
| 116 | .{ s.name, s.cur_p99, s.base_p99, sign, abs_delta, status }, | ||
| 117 | ); | ||
| 118 | } | ||
| 119 | |||
| 120 | if (regressed) std.process.exit(1); | ||
| 121 | } | ||
src/tools/imgdiff.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,151 @@ | |||
| 1 | const std = @import("std"); | ||
| 2 | const png = @import("png"); | ||
| 3 | |||
| 4 | pub const DiffResult = struct { | ||
| 5 | rmse: f64, // [0, 1] | ||
| 6 | max_pixel: f64, // [0, 1] | ||
| 7 | pixel_count: usize, | ||
| 8 | }; | ||
| 9 | |||
| 10 | pub fn compare(a: png.Image, b: png.Image) !DiffResult { | ||
| 11 | if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer; | ||
| 12 | std.debug.assert(a.pixels.len == b.pixels.len); | ||
| 13 | |||
| 14 | const px_count = @as(usize, a.width) * a.height; | ||
| 15 | var sum_sq: f64 = 0; | ||
| 16 | var max_d: f64 = 0; | ||
| 17 | |||
| 18 | var i: usize = 0; | ||
| 19 | while (i < px_count) : (i += 1) { | ||
| 20 | const off = i * 4; | ||
| 21 | const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0; | ||
| 22 | const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0; | ||
| 23 | const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0; | ||
| 24 | const d_sq = (dr * dr + dg * dg + db * db) / 3.0; | ||
| 25 | sum_sq += d_sq; | ||
| 26 | const d = @sqrt(d_sq); | ||
| 27 | if (d > max_d) max_d = d; | ||
| 28 | } | ||
| 29 | |||
| 30 | return .{ | ||
| 31 | .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))), | ||
| 32 | .max_pixel = max_d, | ||
| 33 | .pixel_count = px_count, | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | test "identical images produce zero RMSE" { | ||
| 38 | var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 }; | ||
| 39 | var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 }; | ||
| 40 | const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a }; | ||
| 41 | const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b }; | ||
| 42 | const r = try compare(a, b); | ||
| 43 | try std.testing.expectEqual(@as(f64, 0.0), r.rmse); | ||
| 44 | try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel); | ||
| 45 | } | ||
| 46 | |||
| 47 | test "fully saturated difference produces rmse=1.0 and max=1.0" { | ||
| 48 | var pixels_a = [_]u8{ 0, 0, 0, 255 }; | ||
| 49 | var pixels_b = [_]u8{ 255, 255, 255, 255 }; | ||
| 50 | const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a }; | ||
| 51 | const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b }; | ||
| 52 | const r = try compare(a, b); | ||
| 53 | try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9); | ||
| 54 | try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9); | ||
| 55 | } | ||
| 56 | |||
| 57 | pub fn main() !void { | ||
| 58 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 59 | defer _ = gpa.deinit(); | ||
| 60 | const alloc = gpa.allocator(); | ||
| 61 | |||
| 62 | const args = try std.process.argsAlloc(alloc); | ||
| 63 | defer std.process.argsFree(alloc, args); | ||
| 64 | |||
| 65 | if (args.len < 3) { | ||
| 66 | std.debug.print("usage: imgdiff <actual.png> <reference.png> [diff.png]\n", .{}); | ||
| 67 | std.process.exit(2); | ||
| 68 | } | ||
| 69 | const actual_path = args[1]; | ||
| 70 | const reference_path = args[2]; | ||
| 71 | const diff_path: ?[]const u8 = if (args.len >= 4) args[3] else null; | ||
| 72 | |||
| 73 | const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", 0.005); | ||
| 74 | const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", 0.125); | ||
| 75 | |||
| 76 | const actual_bytes = try std.fs.cwd().readFileAlloc(alloc, actual_path, 64 * 1024 * 1024); | ||
| 77 | defer alloc.free(actual_bytes); | ||
| 78 | const reference_bytes = try std.fs.cwd().readFileAlloc(alloc, reference_path, 64 * 1024 * 1024); | ||
| 79 | defer alloc.free(reference_bytes); | ||
| 80 | |||
| 81 | var actual = try png.decode(alloc, actual_bytes); | ||
| 82 | defer actual.deinit(alloc); | ||
| 83 | var reference = try png.decode(alloc, reference_bytes); | ||
| 84 | defer reference.deinit(alloc); | ||
| 85 | |||
| 86 | if (actual.width != reference.width or actual.height != reference.height) { | ||
| 87 | std.debug.print("FAIL: dimensions differ ({}x{} vs {}x{})\n", .{ actual.width, actual.height, reference.width, reference.height }); | ||
| 88 | std.process.exit(3); | ||
| 89 | } | ||
| 90 | |||
| 91 | const r = try compare(actual, reference); | ||
| 92 | const pass = r.rmse <= rmse_max and r.max_pixel <= pixel_max; | ||
| 93 | |||
| 94 | if (pass) { | ||
| 95 | std.debug.print("OK: {s} RMSE={d:.4}% worst={d:.4}%\n", .{ reference_path, r.rmse * 100.0, r.max_pixel * 100.0 }); | ||
| 96 | std.process.exit(0); | ||
| 97 | } | ||
| 98 | |||
| 99 | std.debug.print("FAIL: {s}\n RMSE: {d:.4}% (max {d:.4}%)\n worst pixel: {d:.4}% (max {d:.4}%)\n", .{ reference_path, r.rmse * 100.0, rmse_max * 100.0, r.max_pixel * 100.0, pixel_max * 100.0 }); | ||
| 100 | |||
| 101 | if (diff_path) |p| { | ||
| 102 | const diff_img = try makeDiffImage(alloc, actual, reference); | ||
| 103 | defer alloc.free(diff_img.pixels); | ||
| 104 | |||
| 105 | var buf: std.ArrayList(u8) = .empty; | ||
| 106 | defer buf.deinit(alloc); | ||
| 107 | try png.encode(alloc, diff_img, buf.writer(alloc)); | ||
| 108 | |||
| 109 | const out = try std.fs.cwd().createFile(p, .{ .truncate = true }); | ||
| 110 | defer out.close(); | ||
| 111 | try out.writeAll(buf.items); | ||
| 112 | |||
| 113 | std.debug.print(" diff: {s}\n", .{p}); | ||
| 114 | } | ||
| 115 | std.debug.print(" actual: {s}\n", .{actual_path}); | ||
| 116 | std.process.exit(1); | ||
| 117 | } | ||
| 118 | |||
| 119 | fn readFloatEnv(name: []const u8, default: f64) f64 { | ||
| 120 | const val = std.posix.getenv(name) orelse return default; | ||
| 121 | return std.fmt.parseFloat(f64, val) catch default; | ||
| 122 | } | ||
| 123 | |||
| 124 | fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image { | ||
| 125 | // Side-by-side: [actual | reference | delta-heatmap] | ||
| 126 | const w = a.width * 3; | ||
| 127 | const h = a.height; | ||
| 128 | const pixels = try alloc.alloc(u8, w * h * 4); | ||
| 129 | var y: u32 = 0; | ||
| 130 | while (y < h) : (y += 1) { | ||
| 131 | const row_off = @as(usize, y) * w * 4; | ||
| 132 | const a_off = @as(usize, y) * a.width * 4; | ||
| 133 | @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]); | ||
| 134 | @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]); | ||
| 135 | var x: u32 = 0; | ||
| 136 | while (x < a.width) : (x += 1) { | ||
| 137 | const off = a_off + x * 4; | ||
| 138 | const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0; | ||
| 139 | const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0; | ||
| 140 | const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0; | ||
| 141 | const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0); | ||
| 142 | const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0)); | ||
| 143 | const dst = row_off + 2 * a.width * 4 + x * 4; | ||
| 144 | pixels[dst + 0] = brightness; | ||
| 145 | pixels[dst + 1] = brightness; | ||
| 146 | pixels[dst + 2] = brightness; | ||
| 147 | pixels[dst + 3] = 255; | ||
| 148 | } | ||
| 149 | } | ||
| 150 | return .{ .width = w, .height = h, .pixels = pixels }; | ||
| 151 | } | ||
src/tools/test_render.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,75 @@ | |||
| 1 | const std = @import("std"); | ||
| 2 | |||
| 3 | pub fn main() !void { | ||
| 4 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 5 | defer _ = gpa.deinit(); | ||
| 6 | const alloc = gpa.allocator(); | ||
| 7 | |||
| 8 | const mode_update = blk: { | ||
| 9 | const m = std.posix.getenv("WAYSTTY_GOLDEN_UPDATE") orelse break :blk false; | ||
| 10 | break :blk std.mem.eql(u8, m, "1"); | ||
| 11 | }; | ||
| 12 | |||
| 13 | try std.fs.cwd().makePath("tests/golden/output"); | ||
| 14 | |||
| 15 | var scripts_dir = try std.fs.cwd().openDir("tests/golden/scripts", .{ .iterate = true }); | ||
| 16 | defer scripts_dir.close(); | ||
| 17 | var it = scripts_dir.iterate(); | ||
| 18 | |||
| 19 | var passed: usize = 0; | ||
| 20 | var failed: usize = 0; | ||
| 21 | |||
| 22 | while (try it.next()) |entry| { | ||
| 23 | if (entry.kind != .file) continue; | ||
| 24 | if (!std.mem.endsWith(u8, entry.name, ".vt")) continue; | ||
| 25 | |||
| 26 | const base = entry.name[0 .. entry.name.len - 3]; | ||
| 27 | const script_path = try std.fmt.allocPrint(alloc, "tests/golden/scripts/{s}.vt", .{base}); | ||
| 28 | defer alloc.free(script_path); | ||
| 29 | const output_path = try std.fmt.allocPrint(alloc, "tests/golden/output/{s}.png", .{base}); | ||
| 30 | defer alloc.free(output_path); | ||
| 31 | const reference_path = try std.fmt.allocPrint(alloc, "tests/golden/reference/{s}.png", .{base}); | ||
| 32 | defer alloc.free(reference_path); | ||
| 33 | const diff_path = try std.fmt.allocPrint(alloc, "tests/golden/output/{s}.diff.png", .{base}); | ||
| 34 | defer alloc.free(diff_path); | ||
| 35 | |||
| 36 | // Run waystty --capture | ||
| 37 | const cap = try std.process.Child.run(.{ | ||
| 38 | .allocator = alloc, | ||
| 39 | .argv = &.{ "zig-out/bin/waystty", "--capture", script_path, output_path }, | ||
| 40 | }); | ||
| 41 | defer alloc.free(cap.stdout); | ||
| 42 | defer alloc.free(cap.stderr); | ||
| 43 | if (cap.term != .Exited or cap.term.Exited != 0) { | ||
| 44 | std.debug.print("FAIL: {s}: capture exited with {}\n stderr: {s}\n", | ||
| 45 | .{ base, cap.term, cap.stderr }); | ||
| 46 | failed += 1; | ||
| 47 | continue; | ||
| 48 | } | ||
| 49 | |||
| 50 | if (mode_update) { | ||
| 51 | try std.fs.cwd().makePath("tests/golden/reference"); | ||
| 52 | try std.fs.cwd().copyFile(output_path, std.fs.cwd(), reference_path, .{}); | ||
| 53 | std.debug.print("UPDATED: {s}\n", .{base}); | ||
| 54 | passed += 1; | ||
| 55 | continue; | ||
| 56 | } | ||
| 57 | |||
| 58 | // Run imgdiff | ||
| 59 | const dif = try std.process.Child.run(.{ | ||
| 60 | .allocator = alloc, | ||
| 61 | .argv = &.{ "zig-out/bin/imgdiff", output_path, reference_path, diff_path }, | ||
| 62 | }); | ||
| 63 | defer alloc.free(dif.stdout); | ||
| 64 | defer alloc.free(dif.stderr); | ||
| 65 | std.debug.print("{s}", .{dif.stdout}); | ||
| 66 | if (dif.term == .Exited and dif.term.Exited == 0) { | ||
| 67 | passed += 1; | ||
| 68 | } else { | ||
| 69 | failed += 1; | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | std.debug.print("\n=== test-render: {} passed, {} failed ===\n", .{ passed, failed }); | ||
| 74 | if (failed > 0) std.process.exit(1); | ||
| 75 | } | ||
tests/bench/baseline.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,38 @@ | |||
| 1 | { | ||
| 2 | "workload_sha": "066a95eee2d2f6195c0eb997e7e18a63f75b48010b538dd287056f948cc65005", | ||
| 3 | "zig_version": "0.15.2", | ||
| 4 | "waystty_sha": "828c61f589b2bac3785e37531a094d6abb1f40ad", | ||
| 5 | "frame_count": 5, | ||
| 6 | "sections": { | ||
| 7 | "snapshot": { | ||
| 8 | "min": 10, | ||
| 9 | "avg": 201, | ||
| 10 | "p99": 214, | ||
| 11 | "max": 725 | ||
| 12 | }, | ||
| 13 | "row_rebuild": { | ||
| 14 | "min": 115, | ||
| 15 | "avg": 2075, | ||
| 16 | "p99": 2166, | ||
| 17 | "max": 5447 | ||
| 18 | }, | ||
| 19 | "atlas_upload": { | ||
| 20 | "min": 0, | ||
| 21 | "avg": 10, | ||
| 22 | "p99": 0, | ||
| 23 | "max": 50 | ||
| 24 | }, | ||
| 25 | "instance_upload": { | ||
| 26 | "min": 4, | ||
| 27 | "avg": 37, | ||
| 28 | "p99": 21, | ||
| 29 | "max": 144 | ||
| 30 | }, | ||
| 31 | "gpu_submit": { | ||
| 32 | "min": 37, | ||
| 33 | "avg": 58, | ||
| 34 | "p99": 66, | ||
| 35 | "max": 79 | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } | ||
| \ No newline at end of file | 38 | \ No newline at end of file | |
tests/golden/reference/basic_ascii.png
| Old | New | ||
|---|---|---|---|
| Binary file | |||
| Binary files differ | |||
tests/golden/reference/bold_colors.png
| Old | New | ||
|---|---|---|---|
| Binary file | |||
| Binary files differ | |||
tests/golden/reference/box_drawing.png
| Old | New | ||
|---|---|---|---|
| Binary file | |||
| Binary files differ | |||
tests/golden/scripts/basic_ascii.vt
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,2 @@ | |||
| 1 | [2J[H !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ | ||
| 2 | [H | ||
| \ No newline at end of file | 2 | \ No newline at end of file | |
tests/golden/scripts/bold_colors.vt
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,9 @@ | |||
| 1 | [2J[H[0mnormal[0m | ||
| 2 | [1mbold[0m | ||
| 3 | [2mdim[0m | ||
| 4 | [3mitalic[0m | ||
| 5 | [4munderline[0m | ||
| 6 | [7mreverse[0m | ||
| 7 | [30mfg30 [31mfg31 [32mfg32 [33mfg33 [34mfg34 [35mfg35 [36mfg36 [37mfg37 [0m | ||
| 8 | [40mbg40 [41mbg41 [42mbg42 [43mbg43 [44mbg44 [45mbg45 [46mbg46 [47mbg47 [0m | ||
| 9 | [H | ||
| \ No newline at end of file | 9 | \ No newline at end of file | |
tests/golden/scripts/box_drawing.vt
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,7 @@ | |||
| 1 | [2J[H┌──────────┐ | ||
| 2 | │ │ | ||
| 3 | │ │ | ||
| 4 | │ │ | ||
| 5 | └──────────┘ | ||
| 6 | ░▒▓█ block chars | ||
| 7 | [H | ||
| \ No newline at end of file | 7 | \ No newline at end of file | |