| … | 394 unchanged lines hidden | ||
| 395 | 395 | usage: Image_Usage_Flags, | |
| 396 | 396 | } | |
| 397 | 397 | ||
| 398 | + | Readback_Texture_Desc :: struct { | |
| 399 | + | texture: Texture_Handle, | |
| 400 | + | width, height: u32, | |
| 401 | + | current_layout: Image_Layout, | |
| 402 | + | } | |
| 403 | + | ||
| 398 | 404 | Sampler_Desc :: struct { | |
| 399 | 405 | mag_filter: Filter, | |
| 400 | 406 | min_filter: Filter, | |
| … | 140 unchanged lines hidden | ||
| 541 | 547 | bool, | |
| 542 | 548 | ), | |
| 543 | 549 | destroy_texture: proc(handle: Texture_Handle), | |
| 550 | + | read_texture_rgba8: proc(desc: Readback_Texture_Desc, out: []u8) -> bool, | |
| 544 | 551 | ||
| 545 | 552 | // --- Samplers --- | |
| 546 | 553 | create_sampler: proc(desc: Sampler_Desc) -> (Sampler_Handle, bool), | |
| … | 179 unchanged lines hidden | ||
| … | 333 unchanged lines hidden | ||
| 334 | 334 | // Textures | |
| 335 | 335 | create_texture = create_texture_vk, | |
| 336 | 336 | destroy_texture = destroy_texture_vk, | |
| 337 | + | read_texture_rgba8 = read_texture_rgba8_vk, | |
| 337 | 338 | ||
| 338 | 339 | // Samplers | |
| 339 | 340 | create_sampler = create_sampler_vk, | |
| … | 206 unchanged lines hidden | ||
Diff hidden because this file has more than 800 lines.
| … | 229 unchanged lines hidden | ||
| 230 | 230 | ||
| 231 | 231 | - `max_color_targets` reports the implemented color attachment count. Vulkan, D3D11, D3D12, and OpenGL currently report `8` because planned IR lowering, render pass/framebuffer arrays, pipeline color-attachment metadata, and backend RTV/FBO/render-pass binding support are implemented across all targets. | |
| 232 | 232 | - `max_push_constant_size` reports the implemented push-constant byte limit for the selected backend. Pipeline and compute-shader creation must reject sizes above this limit. | |
| 233 | - | - `Multiple_Render_Targets` and `Indirect_Draws` are implemented for the planned backend path. `Stencil_Clips` remains false until mask-source IR, stencil attachment/state plumbing, backend lowering, and parity proof exist across all backends. | |
| 233 | + | - `Multiple_Render_Targets` and `Indirect_Draws` are implemented for the planned backend path. Mask clip IR exists as a semantic model for geometry coverage rendered into stencil, but `Stencil_Clips` remains false until stencil attachment/state plumbing, backend lowering, and parity proof exist across all backends. Texture-alpha/discard masking does not count as stencil clipping. | |
| 234 | 234 | ||
| 235 | 235 | Public API must not expose Vulkan/D3D-specific terms except through backend-only layers. | |
| 236 | 236 | ||
| … | 19 unchanged lines hidden | ||
| 256 | 256 | - invalid clips/targets do not silently cull | |
| 257 | 257 | - particle-specific semantic draw kinds are absent from final core IR | |
| 258 | 258 | - Vulkan/D3D11/D3D12/OpenGL checks cover backend-neutral lowering | |
| 259 | + | ||
| 260 | + | Visual/runtime proof: | |
| 261 | + | ||
| 262 | + | - Final proof artifacts must come from deterministic readback images, not window screenshots. | |
| 263 | + | - The proof image contract is RGBA8, fixed dimensions, top-left assertion coordinates, straight alpha, and exact byte colors unless a test explicitly declares tolerance. | |
| 264 | + | - Window screenshots are smoke artifacts only and must not close backend-parity ledger rows. | |
Diff hidden because this file has more than 800 lines.
| … | 29 unchanged lines hidden | ||
| 30 | 30 | Invalid_Descriptor, | |
| 31 | 31 | Resource_Feedback, | |
| 32 | 32 | Access_Metadata_Missing, | |
| 33 | + | Invalid_Mask, | |
| 34 | + | Stencil_Unsupported, | |
| 33 | 35 | Internal_Error, | |
| 34 | 36 | } | |
| 35 | 37 | ||
| … | 68 unchanged lines hidden | ||
| 104 | 106 | case .Invalid_Descriptor: return "RIR0008" | |
| 105 | 107 | case .Resource_Feedback: return "RIR0009" | |
| 106 | 108 | case .Access_Metadata_Missing: return "RIR0010" | |
| 109 | + | case .Invalid_Mask: return "RIR0011" | |
| 110 | + | case .Stencil_Unsupported: return "RIR0012" | |
| 107 | 111 | case .Internal_Error: return "RIR9999" | |
| 108 | 112 | } | |
| 109 | 113 | return "RIR9999" | |
| … | 60 unchanged lines hidden | ||
| 170 | 174 | line_num += 1 | |
| 171 | 175 | } | |
| 172 | 176 | } | |
| 177 | + | ||
| 173 | 178 | if line_num != d.span.line_start { | |
| 174 | 179 | return format_diagnostic(d, allocator) | |
| 175 | 180 | } | |
| … | 123 unchanged lines hidden | ||
| 299 | 304 | add_error(diagnostics, .Invalid_Handle, "clip uses invalid target handle") | |
| 300 | 305 | } | |
| 301 | 306 | } | |
| 307 | + | if clip.kind == .Mask { | |
| 308 | + | mask, mask_ok := get_mask(ir, clip.mask) | |
| 309 | + | if !mask_ok { | |
| 310 | + | add_error(diagnostics, .Invalid_Mask, "mask clip uses invalid mask handle") | |
| 311 | + | continue | |
| 312 | + | } | |
| 313 | + | if mask.target != clip.target { | |
| 314 | + | add_error(diagnostics, .Invalid_Mask, "mask clip target does not match mask target") | |
| 315 | + | } | |
| 316 | + | } | |
| 302 | 317 | } | |
| 303 | 318 | ||
| 319 | + | for mask in ir.masks { | |
| 320 | + | if _, ok := get_target(ir, mask.target); !ok { | |
| 321 | + | add_error(diagnostics, .Invalid_Mask, "mask uses invalid target handle") | |
| 322 | + | } | |
| 323 | + | switch mask.source_kind { | |
| 324 | + | case .None: | |
| 325 | + | add_error(diagnostics, .Invalid_Mask, "mask requires a source") | |
| 326 | + | case .Geometry_Coverage: | |
| 327 | + | resource, resource_ok := get_resource(ir, mask.coverage_resource) | |
| 328 | + | if !resource_ok || resource.kind != .Buffer { | |
| 329 | + | add_error(diagnostics, .Invalid_Mask, "mask geometry coverage requires a buffer resource") | |
| 330 | + | } else if .Vertex not_in resource.buffer.usage { | |
| 331 | + | add_error(diagnostics, .Invalid_Mask, "mask geometry coverage buffer lacks Vertex usage") | |
| 332 | + | } | |
| 333 | + | if mask.vertex_count == 0 && mask.index_count == 0 { | |
| 334 | + | add_error(diagnostics, .Invalid_Mask, "mask geometry coverage has no vertices or indices") | |
| 335 | + | } | |
| 336 | + | if mask.index_resource != INVALID_RESOURCE { | |
| 337 | + | index_resource, index_ok := get_resource(ir, mask.index_resource) | |
| 338 | + | if !index_ok || index_resource.kind != .Buffer { | |
| 339 | + | add_error(diagnostics, .Invalid_Mask, "mask geometry index source requires a buffer resource") | |
| 340 | + | } else if .Index not_in index_resource.buffer.usage { | |
| 341 | + | add_error(diagnostics, .Invalid_Mask, "mask geometry index buffer lacks Index usage") | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 304 | 347 | for draw in ir.draw_commands { | |
| 305 | 348 | if !draw_kind_is_packet(draw.kind) { | |
| 306 | 349 | continue | |
| … | 11 unchanged lines hidden | ||
| 318 | 361 | add_error(diagnostics, .Invalid_Handle, "draw packet uses invalid clip handle") | |
| 319 | 362 | } | |
| 320 | 363 | } | |
| 364 | + | ||
| 321 | 365 | if packet.material != INVALID_MATERIAL { | |
| 322 | 366 | if _, ok := get_material(ir, packet.material); !ok { | |
| 323 | 367 | add_error(diagnostics, .Invalid_Handle, "draw packet uses invalid material handle") | |
| 324 | 368 | } | |
| 325 | 369 | } | |
| 370 | + | ||
| 326 | 371 | if packet.geometry.resource != INVALID_RESOURCE { | |
| 327 | 372 | if _, ok := get_resource(ir, packet.geometry.resource); !ok { | |
| 328 | 373 | add_error(diagnostics, .Invalid_Resource, "draw packet geometry uses invalid resource handle") | |
| … | 45 unchanged lines hidden | ||
| 374 | 419 | } | |
| 375 | 420 | } | |
| 376 | 421 | } | |
| 422 | + | ||
| 423 | + | validate_mask_clips_supported :: proc(ir: ^Frame_IR, diagnostics: ^Diagnostic_List, stencil_clips_supported: bool) { | |
| 424 | + | if stencil_clips_supported { | |
| 425 | + | return | |
| 426 | + | } | |
| 427 | + | for clip in ir.clips { | |
| 428 | + | if clip.kind == .Mask { | |
| 429 | + | add_error(diagnostics, .Stencil_Unsupported, "mask clips require backend Stencil_Clips support") | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | } | |
Diff hidden because this file has more than 800 lines.
Diff hidden because this file has more than 800 lines.
| … | 15 unchanged lines hidden | ||
| 16 | 16 | testing.expect_value(t, ir.format_code(.Invalid_Descriptor), "RIR0008") | |
| 17 | 17 | testing.expect_value(t, ir.format_code(.Resource_Feedback), "RIR0009") | |
| 18 | 18 | testing.expect_value(t, ir.format_code(.Access_Metadata_Missing), "RIR0010") | |
| 19 | + | testing.expect_value(t, ir.format_code(.Invalid_Mask), "RIR0011") | |
| 20 | + | testing.expect_value(t, ir.format_code(.Stencil_Unsupported), "RIR0012") | |
| 19 | 21 | testing.expect_value(t, ir.format_code(.Internal_Error), "RIR9999") | |
| 20 | 22 | } | |
| 21 | 23 | ||
| … | 94 unchanged lines hidden | ||
| 1 | + | package render_ir_tests | |
| 2 | + | ||
| 3 | + | import ir "../../render_ir" | |
| 4 | + | import "core:testing" | |
| 5 | + | ||
| 6 | + | make_mask_frame :: proc() -> ( | |
| 7 | + | frame: ir.Frame_IR, | |
| 8 | + | target: ir.Target_Handle, | |
| 9 | + | coverage: ir.Resource_Handle, | |
| 10 | + | ) { | |
| 11 | + | frame = ir.init_frame_ir() | |
| 12 | + | target = ir.add_target( | |
| 13 | + | &frame, | |
| 14 | + | { | |
| 15 | + | kind = .Present, | |
| 16 | + | name = "present", | |
| 17 | + | width = 256, | |
| 18 | + | height = 256, | |
| 19 | + | color_format = .B8G8R8A8_SRGB, | |
| 20 | + | clear_color = {0, 0, 0, 1}, | |
| 21 | + | clear_depth = 1, | |
| 22 | + | }, | |
| 23 | + | ) | |
| 24 | + | coverage = ir.add_buffer(&frame, "mask-coverage", 256, {.Vertex}, {.Host_Visible}, .Transient) | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | @(test) | |
| 29 | + | test_mask_clip_model_validates_semantic_handles :: proc(t: ^testing.T) { | |
| 30 | + | frame, target, coverage := make_mask_frame() | |
| 31 | + | defer ir.destroy_frame_ir(&frame) | |
| 32 | + | ||
| 33 | + | mask := ir.add_mask( | |
| 34 | + | &frame, | |
| 35 | + | { | |
| 36 | + | target = target, | |
| 37 | + | name = "round-coverage", | |
| 38 | + | source_kind = .Geometry_Coverage, | |
| 39 | + | coverage_resource = coverage, | |
| 40 | + | vertex_count = 6, | |
| 41 | + | }, | |
| 42 | + | ) | |
| 43 | + | clip := ir.add_clip(&frame, {target = target, kind = .Mask, mask = mask}) | |
| 44 | + | ||
| 45 | + | testing.expect(t, mask != ir.INVALID_MASK) | |
| 46 | + | testing.expect(t, clip != ir.INVALID_CLIP) | |
| 47 | + | testing.expect_value(t, ir.mask_count(&frame), 1) | |
| 48 | + | testing.expect_value(t, ir.clip_count(&frame), 1) | |
| 49 | + | ||
| 50 | + | diagnostics := ir.init_diagnostics() | |
| 51 | + | defer ir.destroy_diagnostics(&diagnostics) | |
| 52 | + | ir.validate_intent_handles(&frame, &diagnostics) | |
| 53 | + | testing.expect_value(t, len(diagnostics.items), 0) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | @(test) | |
| 57 | + | test_mask_clip_rejects_invalid_mask_handle :: proc(t: ^testing.T) { | |
| 58 | + | frame, target, _ := make_mask_frame() | |
| 59 | + | defer ir.destroy_frame_ir(&frame) | |
| 60 | + | ||
| 61 | + | _ = ir.add_clip(&frame, {target = target, kind = .Mask, mask = ir.Mask_Handle(99)}) | |
| 62 | + | ||
| 63 | + | diagnostics := ir.init_diagnostics() | |
| 64 | + | defer ir.destroy_diagnostics(&diagnostics) | |
| 65 | + | ir.validate_intent_handles(&frame, &diagnostics) | |
| 66 | + | testing.expect(t, ir.has_errors(&diagnostics)) | |
| 67 | + | testing.expect_value(t, diagnostics.items[0].code, ir.Diagnostic_Code.Invalid_Mask) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | @(test) | |
| 71 | + | test_mask_clip_rejects_target_mismatch :: proc(t: ^testing.T) { | |
| 72 | + | frame, target_a, coverage := make_mask_frame() | |
| 73 | + | defer ir.destroy_frame_ir(&frame) | |
| 74 | + | target_b := ir.add_target( | |
| 75 | + | &frame, | |
| 76 | + | { | |
| 77 | + | kind = .Present, | |
| 78 | + | name = "other", | |
| 79 | + | width = 256, | |
| 80 | + | height = 256, | |
| 81 | + | color_format = .B8G8R8A8_SRGB, | |
| 82 | + | clear_color = {0, 0, 0, 1}, | |
| 83 | + | clear_depth = 1, | |
| 84 | + | }, | |
| 85 | + | ) | |
| 86 | + | mask := ir.add_mask( | |
| 87 | + | &frame, | |
| 88 | + | {target = target_a, name = "mask", source_kind = .Geometry_Coverage, coverage_resource = coverage, vertex_count = 3}, | |
| 89 | + | ) | |
| 90 | + | _ = ir.add_clip(&frame, {target = target_b, kind = .Mask, mask = mask}) | |
| 91 | + | ||
| 92 | + | diagnostics := ir.init_diagnostics() | |
| 93 | + | defer ir.destroy_diagnostics(&diagnostics) | |
| 94 | + | ir.validate_intent_handles(&frame, &diagnostics) | |
| 95 | + | testing.expect(t, ir.has_errors(&diagnostics)) | |
| 96 | + | testing.expect_value(t, diagnostics.items[0].code, ir.Diagnostic_Code.Invalid_Mask) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | @(test) | |
| 100 | + | test_mask_clip_rejects_missing_source :: proc(t: ^testing.T) { | |
| 101 | + | frame, target, _ := make_mask_frame() | |
| 102 | + | defer ir.destroy_frame_ir(&frame) | |
| 103 | + | ||
| 104 | + | _ = ir.add_mask(&frame, {target = target, name = "empty"}) | |
| 105 | + | ||
| 106 | + | diagnostics := ir.init_diagnostics() | |
| 107 | + | defer ir.destroy_diagnostics(&diagnostics) | |
| 108 | + | ir.validate_intent_handles(&frame, &diagnostics) | |
| 109 | + | testing.expect(t, ir.has_errors(&diagnostics)) | |
| 110 | + | testing.expect_value(t, diagnostics.items[0].code, ir.Diagnostic_Code.Invalid_Mask) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | @(test) | |
| 114 | + | test_mask_clip_lowering_refuses_until_stencil_capability_exists :: proc(t: ^testing.T) { | |
| 115 | + | frame, target, coverage := make_mask_frame() | |
| 116 | + | defer ir.destroy_frame_ir(&frame) | |
| 117 | + | ||
| 118 | + | mask := ir.add_mask( | |
| 119 | + | &frame, | |
| 120 | + | {target = target, name = "mask", source_kind = .Geometry_Coverage, coverage_resource = coverage, vertex_count = 3}, | |
| 121 | + | ) | |
| 122 | + | _ = ir.add_clip(&frame, {target = target, kind = .Mask, mask = mask}) | |
| 123 | + | ||
| 124 | + | diagnostics := ir.init_diagnostics() | |
| 125 | + | defer ir.destroy_diagnostics(&diagnostics) | |
| 126 | + | ir.validate_mask_clips_supported(&frame, &diagnostics, false) | |
| 127 | + | testing.expect(t, ir.has_errors(&diagnostics)) | |
| 128 | + | testing.expect_value(t, diagnostics.items[0].code, ir.Diagnostic_Code.Stencil_Unsupported) | |
| 129 | + | ||
| 130 | + | ir.reset_diagnostics(&diagnostics) | |
| 131 | + | ir.validate_mask_clips_supported(&frame, &diagnostics, true) | |
| 132 | + | testing.expect(t, !ir.has_errors(&diagnostics)) | |
| 133 | + | } |
| … | 329 unchanged lines hidden | ||
| 330 | 330 | assert(process.parse_elapsed_seconds("0:00.15") == 0.15) | |
| 331 | 331 | assert(process.parse_elapsed_seconds("1:02:03") == 3723) | |
| 332 | 332 | assert(process.command_exists("python3")) | |
| 333 | + | assert(process.run("python3 " .. path.shell_quote(path.join(root, "tools", "visual_assert.py")) .. " --selftest") == 0) | |
| 333 | 334 | visual_matrix.selftest(root) | |
| 334 | 335 | print("lua selftest ok") | |
| 335 | 336 | end | |
| … | 76 unchanged lines hidden | ||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | import argparse | |
| 3 | + | import json | |
| 4 | + | import os | |
| 5 | + | import struct | |
| 6 | + | import subprocess | |
| 7 | + | import sys | |
| 8 | + | import tempfile | |
| 9 | + | ||
| 10 | + | ||
| 11 | + | PALETTE = { | |
| 12 | + | "background": [16, 16, 24, 255], | |
| 13 | + | "foreground": [240, 48, 96, 255], | |
| 14 | + | "compute_to_draw": [48, 224, 128, 255], | |
| 15 | + | "offscreen_sampled": [64, 128, 240, 255], | |
| 16 | + | "mrt": [240, 64, 64, 255], | |
| 17 | + | "indirect_instance": [64, 240, 64, 255], | |
| 18 | + | "offscreen": [64, 64, 240, 255], | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | ||
| 22 | + | def _read_token(f): | |
| 23 | + | token = bytearray() | |
| 24 | + | while True: | |
| 25 | + | c = f.read(1) | |
| 26 | + | if not c: | |
| 27 | + | return None | |
| 28 | + | if c == b"#": | |
| 29 | + | f.readline() | |
| 30 | + | continue | |
| 31 | + | if not c.isspace(): | |
| 32 | + | token.extend(c) | |
| 33 | + | break | |
| 34 | + | while True: | |
| 35 | + | c = f.read(1) | |
| 36 | + | if not c or c.isspace(): | |
| 37 | + | break | |
| 38 | + | token.extend(c) | |
| 39 | + | return token.decode("ascii") | |
| 40 | + | ||
| 41 | + | ||
| 42 | + | def load_ppm(path): | |
| 43 | + | with open(path, "rb") as f: | |
| 44 | + | magic = _read_token(f) | |
| 45 | + | if magic != "P6": | |
| 46 | + | raise ValueError("unsupported PPM magic") | |
| 47 | + | width = int(_read_token(f)) | |
| 48 | + | height = int(_read_token(f)) | |
| 49 | + | max_value = int(_read_token(f)) | |
| 50 | + | if max_value != 255: | |
| 51 | + | raise ValueError("unsupported PPM max value") | |
| 52 | + | data = f.read(width * height * 3) | |
| 53 | + | if len(data) != width * height * 3: | |
| 54 | + | raise ValueError("truncated PPM image") | |
| 55 | + | rgba = bytearray(width * height * 4) | |
| 56 | + | for i in range(width * height): | |
| 57 | + | rgba[i * 4:i * 4 + 3] = data[i * 3:i * 3 + 3] | |
| 58 | + | rgba[i * 4 + 3] = 255 | |
| 59 | + | return width, height, bytes(rgba) | |
| 60 | + | ||
| 61 | + | ||
| 62 | + | def load_with_pillow(path): | |
| 63 | + | from PIL import Image | |
| 64 | + | img = Image.open(path).convert("RGBA") | |
| 65 | + | return img.width, img.height, img.tobytes() | |
| 66 | + | ||
| 67 | + | ||
| 68 | + | def load_with_magick(path): | |
| 69 | + | result = subprocess.run( | |
| 70 | + | ["magick", path, "rgba:-"], | |
| 71 | + | stdout=subprocess.PIPE, | |
| 72 | + | stderr=subprocess.PIPE, | |
| 73 | + | ) | |
| 74 | + | if result.returncode != 0: | |
| 75 | + | raise ValueError(result.stderr.decode("utf-8", "replace").strip() or "magick failed") | |
| 76 | + | size = subprocess.run( | |
| 77 | + | ["magick", "identify", "-format", "%w %h", path], | |
| 78 | + | stdout=subprocess.PIPE, | |
| 79 | + | stderr=subprocess.PIPE, | |
| 80 | + | text=True, | |
| 81 | + | ) | |
| 82 | + | if size.returncode != 0: | |
| 83 | + | raise ValueError(size.stderr.strip() or "magick identify failed") | |
| 84 | + | width, height = [int(v) for v in size.stdout.split()] | |
| 85 | + | return width, height, result.stdout | |
| 86 | + | ||
| 87 | + | ||
| 88 | + | def load_image(path): | |
| 89 | + | lower = path.lower() | |
| 90 | + | if lower.endswith(".ppm"): | |
| 91 | + | return load_ppm(path) | |
| 92 | + | try: | |
| 93 | + | return load_with_pillow(path) | |
| 94 | + | except Exception: | |
| 95 | + | return load_with_magick(path) | |
| 96 | + | ||
| 97 | + | ||
| 98 | + | def color_for(value): | |
| 99 | + | if isinstance(value, str): | |
| 100 | + | if value not in PALETTE: | |
| 101 | + | raise ValueError("unknown palette color: " + value) | |
| 102 | + | return PALETTE[value] | |
| 103 | + | if isinstance(value, list) and len(value) in (3, 4): | |
| 104 | + | out = [int(v) for v in value] | |
| 105 | + | if len(out) == 3: | |
| 106 | + | out.append(255) | |
| 107 | + | return out | |
| 108 | + | raise ValueError("invalid expected color") | |
| 109 | + | ||
| 110 | + | ||
| 111 | + | def pixel_at(width, data, x, y): | |
| 112 | + | idx = (y * width + x) * 4 | |
| 113 | + | return list(data[idx:idx + 4]) | |
| 114 | + | ||
| 115 | + | ||
| 116 | + | def within(actual, expected, tolerance): | |
| 117 | + | return all(abs(int(a) - int(e)) <= tolerance for a, e in zip(actual, expected)) | |
| 118 | + | ||
| 119 | + | ||
| 120 | + | def evaluate_assertion(width, height, data, assertion): | |
| 121 | + | kind = assertion.get("kind") | |
| 122 | + | expected = color_for(assertion.get("expected")) | |
| 123 | + | tolerance = int(assertion.get("tolerance", 0)) | |
| 124 | + | result = { | |
| 125 | + | "kind": kind, | |
| 126 | + | "expected": expected, | |
| 127 | + | "tolerance": tolerance, | |
| 128 | + | "pass": False, | |
| 129 | + | } | |
| 130 | + | if kind == "point": | |
| 131 | + | x = int(assertion["x"]) | |
| 132 | + | y = int(assertion["y"]) | |
| 133 | + | result["x"] = x | |
| 134 | + | result["y"] = y | |
| 135 | + | if x < 0 or y < 0 or x >= width or y >= height: | |
| 136 | + | result["reason"] = "point outside image" | |
| 137 | + | return result | |
| 138 | + | actual = pixel_at(width, data, x, y) | |
| 139 | + | result["actual"] = actual | |
| 140 | + | result["pass"] = within(actual, expected, tolerance) | |
| 141 | + | if not result["pass"]: | |
| 142 | + | result["reason"] = "point color mismatch" | |
| 143 | + | return result | |
| 144 | + | if kind == "rect": | |
| 145 | + | x = int(assertion["x"]) | |
| 146 | + | y = int(assertion["y"]) | |
| 147 | + | w = int(assertion["w"]) | |
| 148 | + | h = int(assertion["h"]) | |
| 149 | + | result.update({"x": x, "y": y, "w": w, "h": h}) | |
| 150 | + | if x < 0 or y < 0 or w <= 0 or h <= 0 or x + w > width or y + h > height: | |
| 151 | + | result["reason"] = "rect outside image" | |
| 152 | + | return result | |
| 153 | + | mismatches = [] | |
| 154 | + | for yy in range(y, y + h): | |
| 155 | + | for xx in range(x, x + w): | |
| 156 | + | actual = pixel_at(width, data, xx, yy) | |
| 157 | + | if not within(actual, expected, tolerance): | |
| 158 | + | mismatches.append({"x": xx, "y": yy, "actual": actual}) | |
| 159 | + | if len(mismatches) >= 8: | |
| 160 | + | break | |
| 161 | + | if len(mismatches) >= 8: | |
| 162 | + | break | |
| 163 | + | result["mismatches"] = mismatches | |
| 164 | + | result["pass"] = len(mismatches) == 0 | |
| 165 | + | if not result["pass"]: | |
| 166 | + | result["reason"] = "rect color mismatch" | |
| 167 | + | return result | |
| 168 | + | result["reason"] = "unknown assertion kind" | |
| 169 | + | return result | |
| 170 | + | ||
| 171 | + | ||
| 172 | + | def evaluate(path, spec): | |
| 173 | + | if spec.get("source_kind") == "screenshot": | |
| 174 | + | return { | |
| 175 | + | "ok": False, | |
| 176 | + | "path": path, | |
| 177 | + | "source_kind": "screenshot", | |
| 178 | + | "error": "screenshots are smoke artifacts, not proof artifacts", | |
| 179 | + | } | |
| 180 | + | width, height, data = load_image(path) | |
| 181 | + | expected_width = spec.get("width") | |
| 182 | + | expected_height = spec.get("height") | |
| 183 | + | results = [] | |
| 184 | + | ok = True | |
| 185 | + | if expected_width is not None and int(expected_width) != width: | |
| 186 | + | ok = False | |
| 187 | + | results.append({"kind": "dimensions", "pass": False, "actual": [width, height], "expected": [expected_width, expected_height]}) | |
| 188 | + | if expected_height is not None and int(expected_height) != height: | |
| 189 | + | ok = False | |
| 190 | + | if not results or results[-1].get("kind") != "dimensions": | |
| 191 | + | results.append({"kind": "dimensions", "pass": False, "actual": [width, height], "expected": [expected_width, expected_height]}) | |
| 192 | + | for assertion in spec.get("assertions", []): | |
| 193 | + | item = evaluate_assertion(width, height, data, assertion) | |
| 194 | + | results.append(item) | |
| 195 | + | ok = ok and item["pass"] | |
| 196 | + | return { | |
| 197 | + | "ok": ok, | |
| 198 | + | "path": path, | |
| 199 | + | "source_kind": spec.get("source_kind", "readback"), | |
| 200 | + | "origin": spec.get("origin", "top-left"), | |
| 201 | + | "format": "RGBA8", | |
| 202 | + | "width": width, | |
| 203 | + | "height": height, | |
| 204 | + | "assertions": results, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | ||
| 208 | + | def write_ppm(path, width, height, fill): | |
| 209 | + | rgb = bytes(fill[:3]) | |
| 210 | + | with open(path, "wb") as f: | |
| 211 | + | f.write(("P6\n%d %d\n255\n" % (width, height)).encode("ascii")) | |
| 212 | + | f.write(rgb * width * height) | |
| 213 | + | ||
| 214 | + | ||
| 215 | + | def patch_rect_ppm(path, width, x, y, w, h, color): | |
| 216 | + | with open(path, "r+b") as f: | |
| 217 | + | data = f.read() | |
| 218 | + | header_end = data.find(b"\n255\n") + 5 | |
| 219 | + | pixels = bytearray(data[header_end:]) | |
| 220 | + | for yy in range(y, y + h): | |
| 221 | + | for xx in range(x, x + w): | |
| 222 | + | idx = (yy * width + xx) * 3 | |
| 223 | + | pixels[idx:idx + 3] = bytes(color[:3]) | |
| 224 | + | f.seek(header_end) | |
| 225 | + | f.write(pixels) | |
| 226 | + | ||
| 227 | + | ||
| 228 | + | def selftest(): | |
| 229 | + | with tempfile.TemporaryDirectory() as tmp: | |
| 230 | + | on = os.path.join(tmp, "feature_on.ppm") | |
| 231 | + | off = os.path.join(tmp, "feature_off.ppm") | |
| 232 | + | write_ppm(on, 4, 4, PALETTE["background"]) | |
| 233 | + | write_ppm(off, 4, 4, PALETTE["background"]) | |
| 234 | + | patch_rect_ppm(on, 4, 1, 1, 2, 2, PALETTE["foreground"]) | |
| 235 | + | spec = { | |
| 236 | + | "source_kind": "fixture", | |
| 237 | + | "width": 4, | |
| 238 | + | "height": 4, | |
| 239 | + | "origin": "top-left", | |
| 240 | + | "assertions": [ | |
| 241 | + | {"kind": "point", "x": 1, "y": 1, "expected": "foreground"}, | |
| 242 | + | {"kind": "rect", "x": 0, "y": 0, "w": 1, "h": 4, "expected": "background"}, | |
| 243 | + | ], | |
| 244 | + | } | |
| 245 | + | passed = evaluate(on, spec) | |
| 246 | + | failed = evaluate(off, spec) | |
| 247 | + | screenshot = evaluate(on, {"source_kind": "screenshot", "assertions": []}) | |
| 248 | + | assert passed["ok"], passed | |
| 249 | + | assert not failed["ok"], failed | |
| 250 | + | assert not screenshot["ok"], screenshot | |
| 251 | + | print("visual_assert selftest ok") | |
| 252 | + | return 0 | |
| 253 | + | ||
| 254 | + | ||
| 255 | + | def main(): | |
| 256 | + | parser = argparse.ArgumentParser() | |
| 257 | + | parser.add_argument("image", nargs="?") | |
| 258 | + | parser.add_argument("--spec", help="JSON assertion spec path") | |
| 259 | + | parser.add_argument("--json", dest="json_path") | |
| 260 | + | parser.add_argument("--selftest", action="store_true") | |
| 261 | + | args = parser.parse_args() | |
| 262 | + | if args.selftest: | |
| 263 | + | return selftest() | |
| 264 | + | if not args.image or not args.spec: | |
| 265 | + | parser.error("image and --spec are required unless --selftest is used") | |
| 266 | + | with open(args.spec, "r", encoding="utf-8") as f: | |
| 267 | + | spec = json.load(f) | |
| 268 | + | result = evaluate(args.image, spec) | |
| 269 | + | encoded = json.dumps(result, sort_keys=True) | |
| 270 | + | if args.json_path: | |
| 271 | + | with open(args.json_path, "w", encoding="utf-8") as f: | |
| 272 | + | f.write(encoded + "\n") | |
| 273 | + | else: | |
| 274 | + | print(encoded) | |
| 275 | + | return 0 if result.get("ok") else 1 | |
| 276 | + | ||
| 277 | + | ||
| 278 | + | if __name__ == "__main__": | |
| 279 | + | sys.exit(main()) |
| … | 21 unchanged lines hidden | ||
| 22 | 22 | example = "parity_stencil_clip", | |
| 23 | 23 | ledger = "bp-stencil", | |
| 24 | 24 | negative_control = true, | |
| 25 | + | required_backends = { "vulkan", "opengl", "d3d11", "d3d12" }, | |
| 25 | 26 | assertions = { | |
| 26 | 27 | { kind = "point", x = 48, y = 48, expected = "background" }, | |
| 27 | 28 | { kind = "point", x = 128, y = 128, expected = "foreground" }, | |
| … | 5 unchanged lines hidden | ||
| 33 | 34 | example = "parity_resource_sync", | |
| 34 | 35 | ledger = "bp-resource-sync", | |
| 35 | 36 | negative_control = false, | |
| 37 | + | required_backends = { "vulkan", "opengl", "d3d11", "d3d12" }, | |
| 36 | 38 | assertions = { | |
| 37 | 39 | { kind = "point", x = 96, y = 96, expected = "compute_to_draw" }, | |
| 38 | 40 | { kind = "point", x = 160, y = 96, expected = "offscreen_sampled" }, | |
| … | 4 unchanged lines hidden | ||
| 43 | 45 | example = "parity_backend_features", | |
| 44 | 46 | ledger = "bp-validation", | |
| 45 | 47 | negative_control = false, | |
| 48 | + | required_backends = { "vulkan", "opengl", "d3d11", "d3d12" }, | |
| 46 | 49 | assertions = { | |
| 47 | 50 | { kind = "point", x = 64, y = 64, expected = "mrt" }, | |
| 48 | 51 | { kind = "point", x = 128, y = 64, expected = "indirect_instance" }, | |
| … | 90 unchanged lines hidden | ||
| 139 | 142 | raw_result = path.join(dir, "run.json"), | |
| 140 | 143 | assertions = scene.assertions, | |
| 141 | 144 | negative_control_required = scene.negative_control, | |
| 145 | + | required_backends = scene.required_backends, | |
| 142 | 146 | dry_run = opts.dry_run and true or false, | |
| 143 | 147 | } | |
| 144 | 148 | end | |
| … | 35 unchanged lines hidden | ||
| 180 | 184 | end | |
| 181 | 185 | ||
| 182 | 186 | row.status = "fail" | |
| 183 | - | row.error = "exact assertion evaluator/readback proof not implemented yet" | |
| 187 | + | row.error = "trusted GPU readback proof artifact missing" | |
| 184 | 188 | return row | |
| 185 | 189 | end | |
| 186 | 190 | ||
| … | 11 unchanged lines hidden | ||
| 198 | 202 | return summary | |
| 199 | 203 | end | |
| 200 | 204 | ||
| 205 | + | local function backend_required(scene, backend) | |
| 206 | + | for _, required in ipairs(scene.required_backends or {}) do | |
| 207 | + | if required == backend then return true end | |
| 208 | + | end | |
| 209 | + | return false | |
| 210 | + | end | |
| 211 | + | ||
| 212 | + | local function ledger_gate(scene, rows) | |
| 213 | + | local gate = { | |
| 214 | + | ledger = scene.ledger, | |
| 215 | + | scene = scene.id, | |
| 216 | + | required_backends = scene.required_backends or {}, | |
| 217 | + | status = "incomplete", | |
| 218 | + | pass = {}, | |
| 219 | + | fail = {}, | |
| 220 | + | skip = {}, | |
| 221 | + | missing = {}, | |
| 222 | + | } | |
| 223 | + | for _, backend in ipairs(gate.required_backends) do | |
| 224 | + | local found = false | |
| 225 | + | for _, row in ipairs(rows) do | |
| 226 | + | if row.scene == scene.id and row.backend == backend then | |
| 227 | + | found = true | |
| 228 | + | if row.status == "pass" then | |
| 229 | + | gate.pass[#gate.pass + 1] = backend | |
| 230 | + | elseif row.status == "skip" then | |
| 231 | + | gate.skip[#gate.skip + 1] = backend | |
| 232 | + | else | |
| 233 | + | gate.fail[#gate.fail + 1] = backend | |
| 234 | + | end | |
| 235 | + | break | |
| 236 | + | end | |
| 237 | + | end | |
| 238 | + | if not found then | |
| 239 | + | gate.missing[#gate.missing + 1] = backend | |
| 240 | + | end | |
| 241 | + | end | |
| 242 | + | if #gate.pass == #gate.required_backends and #gate.fail == 0 and #gate.skip == 0 and #gate.missing == 0 then | |
| 243 | + | gate.status = "pass" | |
| 244 | + | end | |
| 245 | + | return gate | |
| 246 | + | end | |
| 247 | + | ||
| 248 | + | local function ledger_gates(rows) | |
| 249 | + | local gates = {} | |
| 250 | + | for _, scene in ipairs(SCENES) do | |
| 251 | + | gates[#gates + 1] = ledger_gate(scene, rows) | |
| 252 | + | end | |
| 253 | + | return gates | |
| 254 | + | end | |
| 255 | + | ||
| 201 | 256 | function M.run(opts, config) | |
| 202 | 257 | config = config or {} | |
| 203 | 258 | local root = config.root or path.repo_root("tools/test.lua") | |
| … | 8 unchanged lines hidden | ||
| 212 | 267 | version = 1, | |
| 213 | 268 | rows = rows, | |
| 214 | 269 | summary = summarize(rows), | |
| 270 | + | ledger_gates = ledger_gates(rows), | |
| 215 | 271 | } | |
| 216 | 272 | if opts.json then | |
| 217 | 273 | path.mkdir_p(path.dirname(opts.json)) | |
| … | 14 unchanged lines hidden | ||
| 232 | 288 | assert(result.summary.skip == 2) | |
| 233 | 289 | assert(result.summary.pass == 0) | |
| 234 | 290 | assert(result.summary.fail == 0) | |
| 291 | + | assert(result.ledger_gates[1].status == "incomplete") | |
| 292 | + | assert(#result.ledger_gates[1].skip == 2) | |
| 293 | + | assert(#result.ledger_gates[1].missing == 2) | |
| 235 | 294 | assert(result.rows[1].status == "skip") | |
| 236 | 295 | assert(result.rows[1].skip_reason == "dry-run") | |
| 237 | 296 | assert(result.rows[1].negative_control_required == true) | |
| … | 14 unchanged lines hidden | ||