From c3a7776f30d9cb20855576b745ffdf2c486f59c5 Mon Sep 17 00:00:00 2001 From: Luke Benstead Date: Mon, 22 Jun 2026 09:05:16 +0100 Subject: [PATCH 1/2] Add more extensive test suite --- GL/private.h | 10 + tests/CMakeLists.txt | 35 ++ tests/README.md | 127 +++++++ tests/goldens/bgra_vertex_colors_alpha.ppm | 35 ++ tests/goldens/colored_quad_strip.ppm | Bin 0 -> 27661 bytes tests/goldens/gouraud_triangle.ppm | Bin 0 -> 27661 bytes tests/goldens/overlapping_opaque.ppm | Bin 0 -> 27661 bytes tests/goldens/solid_white_triangle.ppm | Bin 0 -> 27661 bytes tests/goldens/textured_quad.ppm | Bin 0 -> 27661 bytes tests/goldens/transparent_over_opaque.ppm | Bin 0 -> 27661 bytes tests/goldens/triangle_on_background.ppm | Bin 0 -> 27661 bytes tests/test_glcolor.h | 61 +--- tests/test_glteximage2d.h | 14 +- tests/test_golden_rendering.h | 243 ++++++++++++++ tests/test_pvr_vertex_submission.h | 19 +- tests/test_texcoord_formats.h | 247 ++++++++++++++ tests/test_texture_formats.h | 316 +++++++++++++++++ tests/test_vertex_colors.h | 191 +++++++++++ tests/test_vertex_formats.h | 313 +++++++++++++++++ tools/gl_test.h | 106 ++++++ tools/golden.h | 373 +++++++++++++++++++++ tools/test.h | 5 +- tools/test_generator.py | 2 +- 23 files changed, 2019 insertions(+), 78 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/goldens/bgra_vertex_colors_alpha.ppm create mode 100644 tests/goldens/colored_quad_strip.ppm create mode 100644 tests/goldens/gouraud_triangle.ppm create mode 100644 tests/goldens/overlapping_opaque.ppm create mode 100644 tests/goldens/solid_white_triangle.ppm create mode 100644 tests/goldens/textured_quad.ppm create mode 100644 tests/goldens/transparent_over_opaque.ppm create mode 100644 tests/goldens/triangle_on_background.ppm create mode 100644 tests/test_golden_rendering.h create mode 100644 tests/test_texcoord_formats.h create mode 100644 tests/test_texture_formats.h create mode 100644 tests/test_vertex_colors.h create mode 100644 tests/test_vertex_formats.h create mode 100644 tools/gl_test.h create mode 100644 tools/golden.h diff --git a/GL/private.h b/GL/private.h index 8d24f82..19289e8 100644 --- a/GL/private.h +++ b/GL/private.h @@ -16,6 +16,12 @@ #include "../containers/aligned_vector.h" #include "../containers/named_array.h" +#ifdef __cplusplus +/* Ensure the helper functions declared below keep C linkage when this header + * is pulled into the C++ test harness, so they link against the C library. */ +extern "C" { +#endif + #define MAX_GLDC_4BPP_PALETTE_SLOTS 16 #define MAX_GLDC_PALETTE_SLOTS 4 #define MAX_GLDC_SHARED_PALETTES (MAX_GLDC_PALETTE_SLOTS*MAX_GLDC_4BPP_PALETTE_SLOTS) @@ -476,4 +482,8 @@ float _glUnpackHalfFloat(half_float_t h); #define MAX(a,b) (((a)>(b))?(a):(b)) #define CLAMP( X, _MIN, _MAX ) ( (X)<(_MIN) ? (_MIN) : ((X)>(_MAX) ? (_MAX) : (X)) ) +#ifdef __cplusplus +} +#endif + #endif // PRIVATE_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 584e953..7ccf608 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,6 +2,15 @@ FILE(GLOB GL_TESTS ${CMAKE_CURRENT_SOURCE_DIR}/test_*.h) +# The allocator tests are host-side unit tests for the standalone block +# allocator: they assert exact heap addresses and allocate multi-megabyte +# scratch pools, neither of which is meaningful (or safe) under KOS. Exclude +# them from the Dreamcast build; the allocator is still exercised on hardware +# indirectly through the texture tests. +if(PLATFORM_DREAMCAST) + list(REMOVE_ITEM GL_TESTS ${CMAKE_CURRENT_SOURCE_DIR}/test_allocator.h) +endif() + INCLUDE_DIRECTORIES(${CMAKE_SOURCE_DIR}) SET(TEST_GENERATOR_BIN ${CMAKE_SOURCE_DIR}/tools/test_generator.py) @@ -16,6 +25,32 @@ ADD_CUSTOM_COMMAND( add_executable(gldc_tests ${TEST_FILES} ${TEST_SOURCES} ${TEST_MAIN_FILENAME}) target_link_libraries(gldc_tests GL) +# Directory holding the committed golden reference images. +# +# On the desktop the tests read/write them straight from the source tree. On +# the Dreamcast the goldens are read through the dcload host-filesystem mount +# (KOS mounts it at /pc), so we point at /pc/goldens and copy the references +# next to the .elf so that running the emulator with `-C .` from the build +# directory exposes them as /pc/goldens. +if(PLATFORM_DREAMCAST) + set(GLDC_GOLDEN_DIR "/pc/goldens") + + add_custom_command( + TARGET gldc_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/goldens + ${CMAKE_CURRENT_BINARY_DIR}/goldens + COMMENT "Copying golden reference images next to gldc_tests.elf" + ) +else() + set(GLDC_GOLDEN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/goldens") +endif() + +target_compile_definitions( + gldc_tests PRIVATE + GLDC_GOLDEN_DIR="${GLDC_GOLDEN_DIR}" +) + if(NOT PLATFORM_DREAMCAST) set_target_properties( gldc_tests diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..1b6bf88 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,127 @@ +# GLdc test suite + +The tests are plain C++ headers under `tests/`. Every class deriving from +`test::TestCase` (or `GLTestCase`) is auto-discovered (see +`tools/test_generator.py`) and each `void test_*()` method becomes a test case. +They build into the `gldc_tests` binary. + +The same tests run on two backends: + + * **desktop / software** backend — fast iteration in CI; + * **Dreamcast / kospvr** backend — the real target, run under an emulator. + +Suites that drive the GL API derive from **`GLTestCase`** (`tools/gl_test.h`), +which initialises the GPU exactly once and resets GL state before each test +(see "Running once on the Dreamcast" below). Pure non-GL suites (e.g. the +standalone allocator tests) derive from `test::TestCase` directly. + +## Desktop + +```sh +# configure + build (32-bit desktop build) +mkdir build && cd build +cmake -DCMAKE_BUILD_TYPE=Debug .. +make gldc_tests + +# run everything (headless) +SDL_VIDEODRIVER=dummy ./tests/gldc_tests + +# run a single suite (prefix match on "Suite::test") +SDL_VIDEODRIVER=dummy ./tests/gldc_tests TextureFormatTests +``` + +## Dreamcast + +Build with the KallistiOS toolchain inside the `kazade/dreamcast-sdk` +container, then run the resulting `.elf` under the +[nitrocast](https://gitlab.com/simulant/nitrocast) emulator: + +```sh +# build (from the repo root; KOS_BASE is set inside the container) +podman run --rm -v "$PWD":"$PWD":Z -w "$PWD" localhost/kazade/dreamcast-sdk \ + /bin/sh -c "source /etc/bash.bashrc; \ + mkdir -p dcbuild && cd dcbuild && \ + cmake -DCMAKE_TOOLCHAIN_FILE=../toolchains/Dreamcast.cmake \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SAMPLES=OFF .. && \ + make gldc_tests" + +# run on the emulator. -u enables dcload host-file syscalls and -C mounts the +# given directory as the dcload filesystem (KOS sees it as /pc), which is where +# the golden references are read from. The build copies goldens/ next to the +# .elf, so run from there: +cd dcbuild/tests +nitrocast -b -e gldc_tests.elf -u -C . -r 32 +``` + +The allocator unit tests are desktop-only (they assert exact host heap +addresses); they are automatically excluded from the Dreamcast build. + +### Running once on the Dreamcast + +`InitGPU()`/`ShutdownGPU()` map to `pvr_init()`/`pvr_shutdown()`, which must not +be torn down and brought back up between tests. `GLTestCase` therefore opens the +PVR (and the display) lazily on the first test, registers an `atexit()` handler +to shut it down once at program exit, and only *resets* GL state between tests. +New GL-driving suites should derive from `GLTestCase`; if they need extra setup +they should override `set_up()` and call `GLTestCase::set_up()` first. + +## What's covered + +| File | Focus | +|------|-------| +| `test_glcolor.h` | `glColor*` / `glColorPointer` state | +| `test_glteximage2d.h` | basic internal-format selection | +| `test_pvr_vertex_submission.h`| TA poly-list structure & headers | +| `test_vertex_formats.h` | `glVertexPointer` types/sizes/strides, immediate mode, `glDrawElements` | +| `test_texcoord_formats.h` | `glTexCoordPointer` type scaling, immediate `glTexCoord` | +| `test_texture_formats.h` | byte-exact texture conversion (RGB565 / ARGB4444 / ARGB1555 / RGBA8 / RED / ALPHA / paletted), `glTexSubImage2D`, errors | +| `test_golden_rendering.h` | end-to-end rendered-output comparison | + +The format/submission tests work by inspecting the internal state the driver +produces — the converted texture bytes (`TextureObject::data`) and the submitted +vertices in `OP_LIST` / `PT_LIST` / `TR_LIST` — so they are exact and +deterministic, and they pin down the per-type readers and pixel conversions +without needing a framebuffer. + +## Golden-image tests + +`tools/golden.h` contains a small, self-contained, deterministic CPU rasteriser. +It consumes the **same** TA poly-lists the real backend submits and reproduces +the backend's triangle-strip walk and perspective divide, then fills triangles +with Gouraud vertex colour (optionally modulated by a decoded texture). Output is +compared against committed PPM references in `tests/goldens/`. + +Because the rasteriser lives in the test harness (not the library) it doesn't +move when the library changes, so a diff in the rendered output reliably points +at a regression in GLdc's transform / colour / clipping / submission pipeline. +The poly-lists are produced by the real (SH4-compiled) library, so the same +references are validated against actual Dreamcast output too — on the Dreamcast +the textured cases even sample the decoded texture straight out of PVR VRAM. +Comparison is tolerant (per-channel + mismatch-fraction thresholds) so sub-LSB +rounding never causes flakiness. + +The references live in `tests/goldens/`. On the desktop they are read straight +from the source tree; on the Dreamcast they are read through the dcload mount at +`/pc/goldens` (the build copies them next to the `.elf`, so `-C .` from the +build directory exposes them). + +### Adding or updating a golden + +1. Write a test that draws something and calls + `golden::rasterize_all_lists(img)` then + `assert_true(golden::check(img, "my_scene"))`. +2. Generate (or refresh) the reference image (desktop): + + ```sh + GLDC_UPDATE_GOLDENS=1 SDL_VIDEODRIVER=dummy ./tests/gldc_tests GoldenRenderingTests + ``` + +3. **Eyeball the new `tests/goldens/my_scene.ppm`** before committing it + (any image viewer / `convert ... png` works) and commit it alongside the test. + +On a mismatch the harness writes `my_scene.actual.ppm` and `my_scene.diff.ppm` +(differing pixels highlighted red) next to the golden for inspection. + +> Note: the video mode is 640×480 and the viewport y-flip is relative to that +> height, so golden scenes place the viewport at `(0, 480 - H)` to capture a +> W×H image at the top-left of the framebuffer. diff --git a/tests/goldens/bgra_vertex_colors_alpha.ppm b/tests/goldens/bgra_vertex_colors_alpha.ppm new file mode 100644 index 0000000..59a2205 --- /dev/null +++ b/tests/goldens/bgra_vertex_colors_alpha.ppm @@ -0,0 +1,35 @@ +P6 +96 96 +255 +(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( !$(+-0469<?AD G I L N +P +S +U +W Z| \y ^u ar co dk gi if jc l` n] pZ qWtTuRvOxLzJ{G}E~B@=;86420.,*(&$"!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( ! $ ( + - 0 +4 +6 +9 +< > A D F I L N P S U W| Zy \u ^raobldigfhcj`l]n[oWqUsRuOvMxJyG{E|C~@=;97420.,*(&$"!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( +        " $ ( + - 0 469<>ADFILNPSU|WyZv\s^p`lbidffdh`j]k[nWoUqRsOtMvJxGyEzC|@}>~;97520.,*(&$"!((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((  "$(+-0369<>ADFHLNPS}UzWvZs\p]l`ibgddf`h^i[kXmUoSpOrMtJuGwEyCz@|>}<~97520.,*(&$"!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( "$(+-0368<>ADFHKNP}SzUvWsYp[m]j`gbdcaf^h[iXkUmSoPpMrKtHuEwCx@y>{<|9}7520.,*(&$"!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( "$(+-0368<>ADFHKM}PzRwTtVqYm[j]g`daac^f[gXiVjSmPnMpKrHsEuCw@x>y<{9|7}520.,*(&$"!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( "$(+-0368<>@DFHK~M{OwRtTqVmYj]e_aa^c\eXgViSjPlMnKoHqFsCt@v>w<y9z7|5}2~0.,*(&$"!   (((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( "$(+-0368<>@CFH~K{MwOtRqTmVkYh[e]a__a\cYeVgShPjNlKnHoFqCr@t>v<w9x7z5{2|0~.,*( & $ "       !!!!!!!! !(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( "$(+-0368 < > @ C F H{ Kx Mu Or Rn Tk Vh Ye Zb \_ _\!aY!bV!eT!fP!hN!jK!lH!mF!oC!q@!r>!s"@"C"F|"Hx"Ku"Mr"On"Rk#Th#Ve#Xb#Z_#\\#_Y#`V#bT#dP#fN#hK#iH#kF#mD#nA#p>#r<#s9#u7#v5#w2#y0$z.${,$}*$~($&$$$"$ $$$$$$$$$$$$$$% %"%#%(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((($$$ $$$$$$$"$$$($+%-%0%3%6%8%;%>%@%C|%Ex%Hu%Kr%Mn%Ok%Ri%Tf%Ub%X_%Z]%\Y%^W%`T%bQ%dN%fL%gH%iF%kD%mA%n>%p<%q9%s7%u5%v2%w0%y.%z,%{*&}(&~&&~$&"& &&&&&&&&&&&&&&& &"&#&~%&}((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( ((((((("($(((+(-(0(3(6(8(;(>(@|(Cy(Eu(Gr(Jo(Ml(Oi(Qf(Sb(U`(X](ZY(\W(^T(`Q(bN(dL(fI(gF(iD(kA(l?(n<(p9(q7(r5(t2(u0(w.(x,(y*({((|&(}$(~"( ((((((((((((((( ("(}#(|%({'(z((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((+++ +++++++"+$+(+++-+0+3+6+8+;*>}*@y*Cv*Es*Go*Jl*Li*Nf*Qc*S`*U]*XZ*ZW*[T*^Q*`N*aL*dI*eF*gD*hA*k?*l<*m:*o7*q5*r2*t0*u.*v,*x**y(*z&*|$*}"*~ *************** )~")}#){%)z')y()x((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((... ...----"-$-(-+---0-3-6-8-;}-=y-@v-Cs-Eo-Gl-Ji-Lf-Nc-Q`-S]-UZ-XW,YU,[Q,^O,_L,aI,cG,eD,gA,h?,j=,l:,m7,o5,p3,r0,t.,u,,v*,x(,y&,z$,{",| ,},,++++++++++++~+}"+|#+{%+y'+x(+w)+v((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((100 0000000"0$0(0+0-00030508}/;y/=v/@s/Cp/Em/Gj/Jg/Lc/N`/Q^/SZ/UW/WU/YQ/[O/]L/_I.aG.cD.eA.f?.h=.j:.k7.m5.o3.p1.q..s,.t*.u(.w&.x$.y"-{ -|-}-~-----------~-}-|",{#,z%,y',w(,v),u+,t((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((444 4444444"3$3(3+3-303335~38z3;w3=t3@p2Cm2Ej2Gg2Jc2La2N^2QZ2SX2UU2WR2YO2[L1]I1_G1aD1cA1d?1f=1g:1j81k51l31n11p/0q,0s*0t(0u&0w$0x"0y 0z0{0|0~0~////////~/|/{/z!/y#.x%.w'.v(.u).t+.s-.r((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((777 7776666"6%6(6+6-6053~55z58w5;t5=p5?m5Cj5Eg5Gd5Ja4L^4NZ4QX4RU4TR4WO4YM4[I4]G4_E3`A3c?3d=3f:3g83i53k33l13n/2o,2q*2r(2t&2u$2v"2w 2x2z2{1|1}1~111111}0|0{0z0y!0x#0w%0v'0u(0t)0s+/r-/q./p((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((::: 9999999"9%8(8+8-80~83z85w88t8;p7=m7?j7Bh7Ed7Ga7J^7L[7NX6PU6RR6TO6WM6YJ6ZG6]E6^B5`?5b=5d:5e85g65i35j15l/4n,4o*4p(4r&4s$4t"4v 4w4x3z3z3{3}3}3~33~2}2|2{2z2y2x!2w#2v%1u'1t(1s)1r+1q-1p.1o/1n((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((=== ====<<<"<%<(<+<-;0{;3x;5u;8q;;n;=k:?h:Bd:Da:G^:I[:KX:MU9PR9RP9TM9WJ9XG9ZE9]B8^?8`=8b:8d88e68g38i17j/7k,7m*7o(7p&7r$6s"6t 6v6w6x6y6z5{5|5}5~5}5|5{4z4y4x4w!4v#4u%3t'3s(3r)3q+3p-3o.3n/2m12l((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((@@ @ @@?????">%>(>+>-{>0x>3u=5q=8n=;k==h=?d=BaIV>KR>MP>PM>RJ=TG=VE=XB=Z@=\=<^:<_8d/>f,>h*>i(=j&=l$=n"=o =qp>q>r>t=u=v=w=x=wv>u>t>s=r=r=q!=p#GU;GW8FY6F[3F]1F^/E`,Eb*Ec(De&Dg#Dh"Di CkCmCnBoBqBrBsAtAuAtAs@r@r@q?p?o!?n#?m$>l&>l(>k)=j+=i-=h.=g0IS;IU8IW6HY3H[1H\/G^,G`*Gb(Gc&Fe#Fg"Fh EiEkElDmDoDpDqCsCtCsBrBqBpAoAoAn!Am#@l$@k&@j(?j*?i+?h->g.>f0>e1>d3=d4=c5=b7LQ;KR8KU6KW3JX1J[/J\,I^*I`(Ia&Hc#Hd"Hf GhGiGkFlFmFoFpEqErErDqDpDoCnCmCm!Bl#Bk$Bj&Ai(Ah*Ah+@g-@f.@e0?d1?d3?c4>b6>a7>`8>_9=_:=^;=]((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((XX W WWV~VzVwUsU"pT%mT(jT*fS-cS/`S2]R5ZR7WR:TQNQAKPCHPEFPHCOJ@OL>OO;NP8NR6NU3MV1MX/MZ,L\*L^(L`&Ka#Kc!Kd JfJgIhIjIkHmHnHoGpGpGoFnFmFlElEk!Ej#Di$Dh&Dh(Cg*Cf+Ce-Bd.Bd0Bc2Ab3Aa4A`6@`7@_8?^9?]:?\;>\=>[((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((ZZ +Z YY~Y{XxXtX pW"mW%jV(gV*dV-aU/]U2ZU5WT7TT:QTKSAIRCFRECRH@QJ>QL;QN9PP6PR3PU1OV/OX,NZ*N\(N]&M_#Ma!Mb LdLfLgKhKjJkJlJnIoInImHmHlHkGjGj!Fi#Fh$Fg&Ef(Ef*Ee+Dd-Dc.Dc0Cb2Ca3B`4B_6B_7A^8A]:A\;@\<@[=@Z>?Y((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((]] +\ \~[{[x[tZqZ nZ"kY%gY(dX*aX-]X/ZW2XW5TV7QV:OVIUAFUCCTE@TH>SJ;SL9SN6RP3RR1RT/QV,QX*PZ(P[&P]#O_!Oa NbNcNeMgMhMjLkLlKnKmKlJkJkIjIiIh!Hg#Hg$Hf&Ge(Gd*Fd+Fc-Fb.Ea0Ea2D`3D_4D^6C^7C]8C\:B[;B[AX?@X((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((`` +`_{_x^t^q^n] k]"g\%d\(a[*][-[[/XZ2TZ5RY7OY:KYFXACWCAWE>WH;VJ9VL6UN3UP1UR/TT,TV*SW(SZ&R[#R]!R_Q`QbPcPePfOgOiNjNlNkMjMiLiLhLgKf!Kf#Je$Jd&Ic(Ic*Ib+Ha-Ha.G`0G_2G^3F^4F]6E\7E[8E[:DZ;DYBW?BV@BV((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((cb +b|byataq`n`k_ g_"d_%a^(^^*[]-X]/T\2R\5O\7L[:I[CZAAYC>YE;YH9XJ6XK3WN1WP/VR,VT*VV(UW&UY#T[!T]S_S`SaRcReQfQgPiPjPiOiOhNgNgMfMe!Md#Ld$Lc&Kb(Kb*Ja+J`-J_/I_0I^2H]3H]4G\6G[7GZ8FZ:FY;EXDV?DUACUACT((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((ee +|dydudrcockbhb ea"ba%^`([`*X_-U_/R_2O^5L^7I]:G]A\A>[C;[E9[H7ZI4ZK1YN/YP,XQ*XT(WU&WW#WY!V[V\U^U`TaTbSdSfSgRiRhQgQgPfPeOdOd!Nc"Nb$Nb&Ma(M`*L`+L_-K^/K^0J]2J\3I[4I[6IZ7HY9HY:GX;GWFV?EU@EUAETBDSBDR((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((i|h +yhugrgoflfheee bd"^d%[c(Xc*Ub-Rb/Ob2La5Ia7G`:C`>_A;^C9^E7]G4]I1\K/\N,[O*[Q(ZT&ZU#YW!YYY[X\X^W_WaVbVdUeUgTfTeSeSdRcRcQb!Qa"Pa$P`&O_(O_*O^+N]-N]/M\0M[2LZ3LZ5KY6KY7JX9JW:IV;IVHU?GT@GSAFSBFRCEQDEQ((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((}kyk +ujrjoilihhehbg ^g"[f%Yf(Ue*Re-Pd/Ld2Ic4Gc7Db:Ab;aA9`C7`E4_G1_I/^K,^M*]O(]Q&\S#\U![W[YZZZ\Y^Y_XaXbWdWeVdVdUcUbTbTaS`!S`"R_$R_&Q^(Q]*P]+P\-O[/O[0NZ2NY3MY5MX6LW8LW9KV:KU;JU=JT>IT?IS@HRAHRBGQCGPDFPEFO((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((zmvm +rlollkhkejbj_i \i"Yh%Uh(Rg*Pg-Lf/Jf2Ge4De7Ad:?d<;c>9cA7bB4bD1aG/aI,`K*`M(_O&_Q#^S!^U]V]Y\Z[[[^Z_Z`YbYcXcXbWbWaV`V`U_!U^"T^$T]&S](S\*R[+R[-QZ/QY0PY2PX3OX5OW6NV8NV9MU:MTKS?JR@JQAIQBIPCHODHOEGNFGM((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((vqsp +pploiofncm_m\l Yl"Uk%Sk(Pj*Lj-Ji/Gi2Dh4Ah7?g97e@4eB1dD/dG,cI*cK(bM&bO#aQ!aS`U_V_X^Z^[]]]_\`\a[a[`Z_Z_Y^X^X]!W\"W\$V[&V[(UZ*UY+TY-TX/SX0RW2RV3QV5QU7PU8PT9OS;OSMQ?LP@LPBKOBKOCJNDJMEIMFILGHL((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((sspr mriqfqcp_p\oYo Vn"Sm%Pm(Ml*Jl-Gk/Dk2Aj4?j64g@1gB/fD,fG*eI(eJ&dM#cO!cPbSbTaVaX`Z_[_]^^^`]_]_\^\][]Z\Z\!Y["YZ$XZ&XY(WY*WX+VX-UW/UV0TV2TU4SU5ST7RS8QS9QR;PROP?NO@NOBMNCLNDLMEKMEKLFJKGJKHIJ((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((pumu itftcs_r\rYqVq Sp"Pp%Mo(Jn*Gn-Dm/Am2?l4VL?VK@UKATJCTJCSIDSIFRHFQHGQGHPGIOFJOFJNEKMELMD((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((d` ]~Z}V}T|Q{M{Kz Hy"Dy%Bx(?w*XJ?XJ@WJAVICVIDUHETHFTGGSGGRFIRFIQEJPEJPDKODLNCLNC((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((`] ZWTQM~K~H} E|"B|%?{(ZI?ZHAYHBXGCXGDWGEVFFVFGUEHTEISDISDJRCKQCLQBLPBMOBNOA((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((^ [ WTQNKHE B~"@~%<}(:|*7{-4{/2z2/y4,y6*x9(w;&v=#v@!uAtCtFsHrIrLqMpOoQoSnRmRmRlQkQjPjP iO"hO$hN&gN(fN*eM+eM.dL/cL1cK3bK4aK5`J7`J9_I:^I<^H=]H>\H@[GA[GBZFCYFDYEEXEFWEGVDHVDIUCJTCJTBKSBLRBMQAMQANP@NO@(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((([ W TQNKHEB @"<%:(7~*4}-2}//|2,{4*z6(z9&y;#x=!x?wAvCuFuGtIsLrMrOqQpQpPoPnOmOmOlN kN"jM$jM&iM(hL*hL+gK.fK/eJ1eJ3dJ4cI6bI7bH9aH:`H<_G=_G>^F@]FA]FB\EC[EDZDEZDGYCGXCHWCIWBJVBKUAKTALTAMS@MR@NR?OQ?OP?((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((W T RNKHEB@ <#:%7(4*2-0/,2*~4(}6&|9#|;!{=z?yAyCxFwGvIvKuMtOsNrNrNqMpMoLoL nL"mK$lK&lJ(kJ*jJ+iI.iI/hH1gH3fH4fG6eG8dG9cF:cF`E@`DA_DB^CD]CE]CE\BG[BHZBHYAJYAJX@KW@LV@MV?MU?NT>OS>OS>OR=PQ=((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((U R NKIEB@< :#8%4(2*0-,/*2(4&6#~9!};}=|?{AzCyEyGxIwKvMvMuLtLsKrKrKqJ pJ"oJ$oI&nI(mH*lH+kH.kG/jG1iG3hF4hF6gF8fE9eE:dDbC@aCAaCB`BD_BE^BF]AG]AH\@I[@JZ@KZ?KY?LX?MW>MV>NV>OU=OT=PSLZ>LZ>MY=NX=NW=OVgA?f@@f@Be@Cd?Dc?Eb?Fa>Ga>H`>I_=J^=K]=L]i??h?Ag?Bg>Cf>De>Ed=Fc=Hb=Ib=Ia=l>>k>?j>Ai=Bh=Ch=Eg/v>1u>3t>5s=6r=8q=:p=;o<=n<>n$}>&|>({>*z=,z=.y=/x=1w<3v<5u<6t<8s;:r;;q;=p;>p;?o:An:Bm:Cl:Ek9Fj9Gi9Hh9Ig8Jf8Kf8Le8Md7Nc7Ob7Oa7P`7Q_6Q^6R]6S\6S\5S[5TZ5TY5TX4UW4UV4((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((A += ;8520-* (#&%#(!*,/1358:< >>>>>=== ="=$<&<(~<*}<,|;.{;/z;1y;3x;5w:6v:8u::t:;s:=r9>r9@q9Ap9Bo8Dn8Em8Fl8Gk8Ij7Ji7Jh7Lg7Lf7Me6Ne6Od6Pc6Pb5Qa5R`5R_5S^5S]4T\4T[4UZ4UY3UX3VX3VW3((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((= +; 8520-*( &##%!(*,/1358: <<;;;;;; :":$:&:(:*9,9.~90}91|93{85z86y88x8:w8;v7=u7>t7@s7Ar7Cq7Dp6Eo6Fn6Gm6Il6Jl5Kk5Lj5Mi5Nh5Ng4Of4Pe4Qd4Rc4Rb4Sa3S`3T_3T^3U]3U\2U[2VZ2VY2VX2VW1((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((; +8 520-*(& ##!%(*,.1358 :::99999 9"8$8&8(8*8,8.7071~73}75|76{69z6:y6;x6=w6?v6@u5Bt5Cs5Dr5Fq5Gp5Ho4In4Jm4Kl4Lk4Mj4Ni3Oi3Ph3Pg3Qf3Re3Rd2Sc2Tb2Ta2U`2U_2V^1V]1V\1V[1WZ1WY1WX0((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((8 +5 20-*(&# !#%(*,.135 88877777 7"7$6&6(6*6,6.60515355~57}59|5:{5;z4=y4?x4@w4Bv4Cu4Dt4Fs3Gr3Hq3Ip3Jo3Kn3Lm2Ml2Nk2Oj2Pi2Qh2Qg2Rf1Se1Sd1Tc1Ub1Ua1V`1V_0V^0W]0W\0W[0WZ0WY/WX/((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((5 +2 0-*(&# #%(*,.13 55555544 4"4$4&4(4*4,3.3031343537393:~2<}2>|2?{2@z2By2Cx2Dw2Fv1Gu1Ht1Js1Jr1Kq1Mp1No1Nn0Om0Pl0Qk0Rj0Sh0Sg0Tf0Te/Ud/Uc/Vb/Va/W`/W_/W^/W].X\.X[.XZ.XY.((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((2 +0 -*(&# !#%(*,.1 33333332 2"2$2&2(2*2,2.2011141517191:1<1>~1?}1@|0B{0Cz0Dy0Fx0Gw0Hv0Ju0Kt0Ls0Mr/Np/Oo/On/Qm/Ql/Rk/Sj/Si/Th.Ug.Uf.Ve.Vd.Wc.Wb.Xa.X`.X_.X^-X]-X\-Y[-YZ-((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((0 +- *(&# !#%(*,. 11111111 0"0$0&0(0*0,0.00010405/7/9/://?/@~/B}/C|/E{/Fz/Gy.Hx.Jw.Kv.Lu.Ms.Nr.Oq.Pp.Qo.Rn.Rm.Sl-Tk-Tj-Ui-Vh-Vg-Wf-We-Wd-Xc-Xa-X`-Y_,Y^,Y],Y\,Y[,YZ,((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((- * (&# !#%(*, ........ .".$.&.(.*.,..-0-1-4-5-7-9-:-<->-?-A-B-D-E}-F|-H{-Iz,Jy,Kx,Lw,Nv,Nu,Ot,Ps,Qr,Rp,So,Tn,Tm,Ul,Vk,Vj,Vi,Wh+Xg+Xf+Xd+Yc+Yb+Ya+Y`+Y_+Z^+Z]+Z\+Z[+((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((* ( &# !#%(* ,,,,,,,,,",$,&,(,*,,,.,0,2,4,5,7+9+;+<+>+?+A+C+D+E+G~+H}+I|+J{+Kz+Ly+Nx+Ow+Ov+Pt+Qs+Rr+Sq+Tp+Uo+Un*Vm*Vl*Wk*Xi*Xh*Xg*Yf*Ye*Yd*Zc*Zb*Za*Z`*Z^*Z]*Z\*Z[*((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( & # !#% ( *********!*#*&*(***,*.*0*2*4*5*7*9*;*<*>*@*A*C*D*E*G*H*I~*K})L|)M{)Nz)Oy)Px)Qv)Ru)St)Ss)Tr)Uq)Up)Vo)Wm)Wl)Xk)Xj)Yi)Yh)Zg)Zf)Zd)Zc)Zb)[a)[`)[_)[^)[])[[)((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((& # !# % (((((((((!(#(&(((*(,(.(0(2(4(6(7(9(;(<(>(@(A(C(D(E(G(H(I(K(L~(M}(N|(O{(Py(Qx(Rw(Sv(Tu(Ut(Us(Vq(Wp(Wo(Xn(Xm(Yl(Yk(Zi(Zh(Zg([f([e([d([c([a([`([_([^([]([\(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((# ! # %%%%%%%%%!%#%&%(%*%,%.&0&2&4&6&7&9&;&<&>&@&A&C&D&F&G&H&J&K&L&M&O~&O}&P|&Q{&Rz&Sx&Tw&Uv&Vu&Vt&Ws&Xq&Xp&Yo&Yn&Zm&Zl&Zj'[i'[h'[g'[f'\e'\c'\b'\a'\`'\_'[^'[\'((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((  ! #########!###&#($*$,$.$0$2$4$6$7$:$;$=$?$@$A$C$D$F$G$I$J$K$L%M%O%P%Q~%Q}%S|%Sz%Ty%Ux%Vw%Vv%Wt%Xs%Xr%Yq%Zp%Zn%[m%[l%[k%\j%\i&\g&\f&\e&\d&\c&\a&\`&\_&\^&\]&((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((   ! !!!!!!!!!!#!&!("*","."0"2"4"6"7":";"="?"@"A"C#E#F#H#I#J#L#M#N#O#P#Q#R#S~#T|#T{$Vz$Vy$Wx$Xv$Xu$Yt$Zs$Zq$Zp$[o$[n$\m$\k$\j$\i$]h%]g%]e%]d%]c%]b%]a%]_%\^%\]%((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((    !#&(*,.0246 8 : ; = ? @ B D E F H!I!J!L!M!N!O!P!Q!R!S!T"U~"V|"W{"Wz"Xy"Yw"Yv"Zu"Zt"[r"\q#\p#\o#]m#]l#]k#]j#]i#]g#^f#]e#]d$]b$]a$]`$]_$]]$((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((    !#&(*,.02468:;=?@BDEFHIJLMNP Q Q R T T U V~ W} X|!Y{!Yy!Zx!Zw![u![t!\s!\r!\p"]o"]n"]m"^k"^j"^i"^h"^f"^e#^d#^c#^a#]`#]_#]^#((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((    !#&(*,.02468:;=?ABDEFHIKLMNPQRSTUUWWX~Y|Y{Zz [y [w \v \u ]t ]r ^q!^p!^n!^m!^l!^k!_i!_h!^g"^e"^d"^c"^b"^`"]_"]^"((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((    !#&(*,.02468:<=?ABDEGHJKLNOPQRSTUVWXXYZ~Z|[{\z\x]w]v]t^s^r^q_o_n _m _k _j _i _g _f!_e!^d!^b!^a!^`!]^!((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((   + !#&(*,.02468:<=?ABDFGIJKMNOPQRSUUVWXYZZ[~\}\|\z]y^x^v^u_t_r_q_p_n`m`l`j_i`h_f _e _d _b ^a ^`!^^!((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((  + !#&(*,.02568:<=?ABDFGIJKMNOQRSSUVVXXYZ[[\]}]|^{^y^x_w_u_t`s`q`p`o`m`l`k`i`h`g`e_d_c_a ^` ^_ (((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( + !#&(*,.02568:<=@ACEFGIJKMNOQRSTUVWXYYZ[\\]]~^}^|_z_y`x`v`u`s`raqaoan`mak`j`i`g`f`d_c_b^`^_(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( +     !#&(*,/02568:<>@ACEFGIJLMOPQRSTUVWXYZ[[\]]^__~_|`{`y`xawauataraqapanamalajaiag`f`e`c_b_`^_(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( \ No newline at end of file diff --git a/tests/goldens/colored_quad_strip.ppm b/tests/goldens/colored_quad_strip.ppm new file mode 100644 index 0000000000000000000000000000000000000000..a2721f351943c604e9977f843794b3024b363d56 GIT binary patch literal 27661 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 O2>c=Nhrs_S0{5)2Xv28jfNL~=nA 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 V0-q50guo{RJ|XZ4f&Y)de*gs$KUM$$ literal 0 HcmV?d00001 diff --git a/tests/goldens/overlapping_opaque.ppm b/tests/goldens/overlapping_opaque.ppm new file mode 100644 index 0000000000000000000000000000000000000000..a3325da15dc83a7cad712b096e1acee48e094282 GIT binary patch literal 27661 zcmeI1OK!v<5Ja<{Q{)VhW~AIA_rFYn#K`eDP!u1x8&W6>n=YW=8@$TbJBlKdpK z#L0u4@T##l@YjzN$Q|W0!8y0>j@EjHw(leD0WVzP@(qUO4Jx%$5g0DERsHY=Kyr+nc!0Aj1gQDas(yF_I62Z!ILXbx)MRzA95;SQ z{9;UgfTHno;$aRxF+k0iR`tUh0LioD#3L`gLV%htt?Gw2fRpF?31?q4Ff~~{UCu>* zNc>_O>cmp8mOHOQb&=msId}(1nV>I(BEt6x|x_7Zet~#rm zY|I8#JzIvy;Pvfd$=h&NAKZY|4Lw^f89>;vg(Ye8Ssi@6OPhPPT-XcQvECB1=d2FI f_N6@y8?SAL)JhqU0U3}18IS=PkO3KxffoZmj%8Pp literal 0 HcmV?d00001 diff --git a/tests/goldens/solid_white_triangle.ppm b/tests/goldens/solid_white_triangle.ppm new file mode 100644 index 0000000000000000000000000000000000000000..786605bd7f9b2725f7e2843d0cc4222053069de4 GIT binary patch literal 27661 zcmeIwL2AQ55Jb_eb&8xJV1aMp`(GwDF_@x+j2+L6x_iD>)w9#%U)JTkexKLn=X6@W z_z*w<0R#|0009ILKmdV?0>6J>#XoEFPxw4>yyHN$M}*Iw%}p;iI+K{sd=+gz&Lo^!yyBc0%}QEw)?XR80sU3r5ejIQ0_3M{BX&1E*3#_*gJ{ z_QR=>5I$Oq?cO-q6T-)W(Q^z=-h}YcT5ONR$(Rs67L1}Fv=-YLaYiPDj|HP=UYs!r;iI+K&W_VNA$%+t zJ!|0fO9&sW#dal}9tq)N!RT2Jr)@&`Xf3v@;x>f)H literal 0 HcmV?d00001 diff --git a/tests/goldens/textured_quad.ppm b/tests/goldens/textured_quad.ppm new file mode 100644 index 0000000000000000000000000000000000000000..177fb528ca1672636fbc38ef36d578d603e01aa8 GIT binary patch literal 27661 zcmeI&I|{-;07cQ%S%qs547dyTzZpN3mA6QA2H^~L7fEt_T6x>n>vp-ln77l!}= z0t5&UAV7csfiDT%mu2v4^!1*Pci;@hdEM-UOYR4naI+IG$tP#IpJ&F{?WaFXK73N3 z2QuR;z5fTOpXC>oPpk6b(*j-XhffQ1wI4n$(A9qUv_Mz;;nM0wh+7F)==xRTFTA-`_@M(dr_QR(Iy4nw)7U*g}d|IHZ V{j`rtfB*pk1PBlyK!Cto-~kl1x>f)H literal 0 HcmV?d00001 diff --git a/tests/goldens/transparent_over_opaque.ppm b/tests/goldens/transparent_over_opaque.ppm new file mode 100644 index 0000000000000000000000000000000000000000..2671eff011909a463379c4994406b8169f47dc79 GIT binary patch literal 27661 zcmeI&Tdw0E3jB-1%%BU$9(V)@kayKk1Qh(md`4JxTL<;Sl^{`&Xx zp_J==*C`v7f~%(%1`rNKz6Hvk%6N)33(sB+V;l~v5&d^QAr29Jj1Rx=i=Pn=?^Yx- z(?1tw=*Pag|ECB&g%qds$R zI7^C1d;fI%$UDcUFz4edZ(VfYaEcR=>i%hE;rMPGdfbrDd>qbrB+}bI9X?k&$EP#l zL;n)=g%uXlaHs|!IiFIE!{g(622 z6;hkHHPpw1K()wmMAg(L0tWe*IJ>aO5k$h&Cc&gqBJU+?N^K(eMjsRB?I`jt yqK?!i9=XNG#7Vo0yiU}e+QfqweN3G3Vv!e#@Hhk@009U<00Izz00bc51^xot;%X@X literal 0 HcmV?d00001 diff --git a/tests/goldens/triangle_on_background.ppm b/tests/goldens/triangle_on_background.ppm new file mode 100644 index 0000000000000000000000000000000000000000..9b82390fbd925f77e8d51ccea410901b26f68917 GIT binary patch literal 27661 zcmeIwL2ANa6ot`_vx*KoE-1JQm($(bfFPF_BDR{}{KG*j%4&R5v z@OA$25FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV8qKz~%BWKJCBT%NP0F`BPUM zL~}%Z=48hAi6fyY6rZWd?adTNLN}ao@JWrb=Wrx+h2m3!$@4yrg!p7FWj}(m=Y;r_ zVDfw%XP*i2$y&<(E6&~$;!}dj^M5$|Nr+F@QuZ@Adq{{+2`10yakiZhpRA?qSK(|m zAwDITJYS2my@dE=EoCo(v!#Uilwk5)4rdz)@yS}sUK*$Og!q(T@@#|CZ$f;sma<#o z^q3Hz5=@@$ar#P#Pu5cQyKs6*h))S7&-dc2KOsI@OWA9{S#v^sN-%k@2WQO*rI<~C h009C72oNAZfB*pk1PBlyK!5-N0t5&UAV8qKz%Oi~gLwb| literal 0 HcmV?d00001 diff --git a/tests/test_glcolor.h b/tests/test_glcolor.h index fc92515..1394e77 100644 --- a/tests/test_glcolor.h +++ b/tests/test_glcolor.h @@ -1,6 +1,7 @@ #pragma once #include "tools/test.h" +#include "tools/gl_test.h" #include #include @@ -21,21 +22,11 @@ * index 2 = green (G8IDX) * index 3 = blue (B8IDX) * =========================================================================*/ -class GlColorTests : public test::TestCase { +class GlColorTests : public GLTestCase { public: - void set_up() { - GLdcConfig config; - glKosInitConfig(&config); - config.texture_twiddle = false; - glKosInitEx(&config); - /* _glInitContext does not reset current_color, so do it explicitly - * here so every test starts from a known white default. */ - glColor4f(1.0f, 1.0f, 1.0f, 1.0f); - } - - void tear_down() { - glKosShutdown(); - } + /* GLTestCase::set_up() initialises the GPU once and resets the current + * colour to white, which is the known-good starting state these tests + * expect. */ /* After set_up the current colour must be white (1,1,1,1). */ void test_default_color_is_white() { @@ -211,20 +202,9 @@ public: * pointer, that stride=0 triggers the automatic stride calculation, and * that invalid size arguments raise GL_INVALID_VALUE. * =========================================================================*/ -class GlColorPointerTests : public test::TestCase { +class GlColorPointerTests : public GLTestCase { public: - void set_up() { - GLdcConfig config; - glKosInitConfig(&config); - config.texture_twiddle = false; - glKosInitEx(&config); - /* Reset enabled flags – _glInitAttributePointers does not do this. */ - ATTRIB_LIST.enabled = 0; - } - - void tear_down() { - glKosShutdown(); - } + /* GLTestCase::set_up() already clears ATTRIB_LIST.enabled. */ /* size=4, GL_FLOAT: stride auto-calculated to 4*4=16. */ void test_colorpointer_size4_float_stores_metadata() { @@ -352,19 +332,8 @@ public: * Mirrors GlColorPointerTests for the secondary (offset) colour pointer * stored in ATTRIB_LIST.s_color. * =========================================================================*/ -class GlSecondaryColorPointerTests : public test::TestCase { +class GlSecondaryColorPointerTests : public GLTestCase { public: - void set_up() { - GLdcConfig config; - glKosInitConfig(&config); - config.texture_twiddle = false; - glKosInitEx(&config); - ATTRIB_LIST.enabled = 0; - } - - void tear_down() { - glKosShutdown(); - } /* size=4, GL_FLOAT: basic happy path. */ void test_secondary_colorpointer_size4_float_stores_metadata() { @@ -460,20 +429,8 @@ public: * appropriate flag bits in ATTRIB_LIST.enabled are set or cleared, that the * dirty flag is updated, and that invalid enumerants raise GL_INVALID_ENUM. * =========================================================================*/ -class GlClientStateTests : public test::TestCase { +class GlClientStateTests : public GLTestCase { public: - void set_up() { - GLdcConfig config; - glKosInitConfig(&config); - config.texture_twiddle = false; - glKosInitEx(&config); - /* Clear all enabled flags for a clean baseline. */ - ATTRIB_LIST.enabled = 0; - } - - void tear_down() { - glKosShutdown(); - } /* Enabling GL_COLOR_ARRAY must set COLOR_ENABLED_FLAG. */ void test_enable_color_array_sets_flag() { diff --git a/tests/test_glteximage2d.h b/tests/test_glteximage2d.h index 3349c42..bdae539 100644 --- a/tests/test_glteximage2d.h +++ b/tests/test_glteximage2d.h @@ -1,19 +1,19 @@ +#pragma once + #include "tools/test.h" +#include "tools/gl_test.h" #include #include #include -class TexImage2DTests : public test::TestCase { +class TexImage2DTests : public GLTestCase { public: uint8_t image_data[8 * 8 * 4] = {0}; void set_up() { - GLdcConfig config; - glKosInitConfig(&config); - config.texture_twiddle = false; - glKosInitEx(&config); + GLTestCase::set_up(); /* Init image data so each texel RGBA value matches the * position in the array */ @@ -25,10 +25,6 @@ public: } } - void tear_down() { - glKosShutdown(); - } - void test_rgb_to_rgb565() { glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 8, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, image_data); assert_equal(glGetError(), GL_NO_ERROR); diff --git a/tests/test_golden_rendering.h b/tests/test_golden_rendering.h new file mode 100644 index 0000000..82f513f --- /dev/null +++ b/tests/test_golden_rendering.h @@ -0,0 +1,243 @@ +#pragma once + +#include "tools/test.h" +#include "tools/gl_test.h" +#include "tools/golden.h" + +#include +#include +#include + +#include "GL/private.h" + +/* ========================================================================= + * GoldenRenderingTests + * + * End-to-end "golden image" tests. Each test issues real GL draw calls, then + * rasterises the resulting TA poly-lists with the deterministic CPU rasteriser + * in tools/golden.h and compares the output to a committed PPM reference under + * tests/goldens/. + * + * These guard the whole submission pipeline — vertex transform, viewport + * mapping, colour/UV handling, primitive assembly, list routing (opaque vs + * transparent) and texture sampling — against regressions. + * + * To (re)generate the references run the test binary with GLDC_UPDATE_GOLDENS=1. + * =========================================================================*/ +class GoldenRenderingTests : public GLTestCase { +public: + /* The video mode is 640x480 and the viewport y-flip is relative to that + * height, so to capture a WxH image at the top-left of the framebuffer we + * place the viewport at (0, 480 - H). */ + static const int VIDEO_H = 480; + static const int W = 96; + static const int H = 96; + + void set_up() { + GLTestCase::set_up(); + glViewport(0, VIDEO_H - H, W, H); + } + + /* A single white triangle on a black background. */ + void test_solid_white_triangle() { + golden::Image img(W, H); + img.clear(0, 0, 0); + + glColor4f(1.0f, 1.0f, 1.0f, 1.0f); + glBegin(GL_TRIANGLES); + glVertex3f(-0.8f, -0.8f, 0.0f); + glVertex3f( 0.8f, -0.8f, 0.0f); + glVertex3f( 0.0f, 0.8f, 0.0f); + glEnd(); + + golden::rasterize_all_lists(img); + assert_true(golden::check(img, "solid_white_triangle")); + } + + /* Gouraud-shaded triangle: red/green/blue corners interpolated. */ + void test_gouraud_triangle() { + golden::Image img(W, H); + img.clear(0, 0, 0); + + glBegin(GL_TRIANGLES); + glColor4f(1.0f, 0.0f, 0.0f, 1.0f); glVertex3f(-0.8f, -0.8f, 0.0f); + glColor4f(0.0f, 1.0f, 0.0f, 1.0f); glVertex3f( 0.8f, -0.8f, 0.0f); + glColor4f(0.0f, 0.0f, 1.0f, 1.0f); glVertex3f( 0.0f, 0.8f, 0.0f); + glEnd(); + + golden::rasterize_all_lists(img); + assert_true(golden::check(img, "gouraud_triangle")); + } + + /* A full-screen-ish coloured quad built from a triangle strip. */ + void test_colored_quad_strip() { + golden::Image img(W, H); + img.clear(16, 16, 16); + + glBegin(GL_TRIANGLE_STRIP); + glColor4f(1.0f, 0.0f, 0.0f, 1.0f); glVertex3f(-0.7f, 0.7f, 0.0f); + glColor4f(1.0f, 1.0f, 0.0f, 1.0f); glVertex3f(-0.7f, -0.7f, 0.0f); + glColor4f(0.0f, 1.0f, 0.0f, 1.0f); glVertex3f( 0.7f, 0.7f, 0.0f); + glColor4f(0.0f, 0.0f, 1.0f, 1.0f); glVertex3f( 0.7f, -0.7f, 0.0f); + glEnd(); + + golden::rasterize_all_lists(img); + assert_true(golden::check(img, "colored_quad_strip")); + } + + /* Two overlapping opaque triangles: painter's order must put the second + * (green) on top of the first (red). */ + void test_overlapping_opaque_painter_order() { + golden::Image img(W, H); + img.clear(0, 0, 0); + + glColor4f(1.0f, 0.0f, 0.0f, 1.0f); + glBegin(GL_TRIANGLES); + glVertex3f(-0.8f, -0.6f, 0.0f); + glVertex3f( 0.4f, -0.6f, 0.0f); + glVertex3f(-0.2f, 0.8f, 0.0f); + glEnd(); + + glColor4f(0.0f, 1.0f, 0.0f, 1.0f); + glBegin(GL_TRIANGLES); + glVertex3f(-0.4f, -0.8f, 0.0f); + glVertex3f( 0.8f, -0.8f, 0.0f); + glVertex3f( 0.2f, 0.6f, 0.0f); + glEnd(); + + golden::rasterize_all_lists(img); + assert_true(golden::check(img, "overlapping_opaque")); + } + + /* A half-transparent quad over an opaque red triangle: routed to the + * transparent list and alpha-blended by the rasteriser. */ + void test_transparent_over_opaque() { + golden::Image img(W, H); + img.clear(0, 0, 0); + + /* Opaque red triangle (OP_LIST). */ + glDisable(GL_BLEND); + glColor4f(1.0f, 0.0f, 0.0f, 1.0f); + glBegin(GL_TRIANGLES); + glVertex3f(-0.8f, -0.8f, 0.0f); + glVertex3f( 0.8f, -0.8f, 0.0f); + glVertex3f( 0.0f, 0.8f, 0.0f); + glEnd(); + + /* 50% blue quad on top (TR_LIST). */ + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glColor4f(0.0f, 0.0f, 1.0f, 0.5f); + glBegin(GL_TRIANGLE_STRIP); + glVertex3f(-0.5f, 0.5f, 0.0f); + glVertex3f(-0.5f, -0.5f, 0.0f); + glVertex3f( 0.5f, 0.5f, 0.0f); + glVertex3f( 0.5f, -0.5f, 0.0f); + glEnd(); + + golden::rasterize_all_lists(img); + assert_true(golden::check(img, "transparent_over_opaque")); + } + + /* A Gouraud quad coloured via a GL_BGRA GL_UNSIGNED_BYTE array (OpenGL 1.4), + * blended over a coloured background. This exercises the whole BGRA path end + * to end: the red/blue channel swap must land the right colour at each + * corner, and the per-vertex alpha must drive the blend against the + * background. If BGRA decoded as RGBA, or alpha were ignored, the rendered + * output would differ from the golden. */ + void test_bgra_vertex_colors_with_alpha() { + golden::Image img(W, H); + img.clear(40, 40, 40); /* dark grey so blending is visible */ + + GLfloat verts[] = { + -0.8f, 0.8f, 0.0f, + -0.8f, -0.8f, 0.0f, + 0.8f, 0.8f, 0.0f, + 0.8f, -0.8f, 0.0f, + }; + /* GL_BGRA memory order is B, G, R, A -> colour R, G, B, A. + * Distinct corners with decreasing alpha down/right. */ + GLubyte colors[] = { + /* B G R A -> colour */ + 0, 0, 255, 255, /* opaque red */ + 0, 255, 0, 192, /* green, ~75% alpha */ + 255, 0, 0, 128, /* blue, 50% alpha */ + 0, 255, 255, 64, /* yellow, 25% alpha */ + }; + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + glEnableClientState(GL_VERTEX_ARRAY); + glEnableClientState(GL_COLOR_ARRAY); + glVertexPointer(3, GL_FLOAT, 0, verts); + glColorPointer(GL_BGRA, GL_UNSIGNED_BYTE, 0, colors); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glDisableClientState(GL_COLOR_ARRAY); + glDisableClientState(GL_VERTEX_ARRAY); + + golden::rasterize_all_lists(img); + assert_true(golden::check(img, "bgra_vertex_colors_alpha")); + } + + /* A textured quad. A 2x2 RGB texture (red/green/blue/white) is mapped over + * a quad; nearest filtering gives four solid colour cells. */ + void test_textured_quad() { + golden::Image img(W, H); + img.clear(0, 0, 0); + + /* 8x8 (the PVR minimum) split into four 4x4 colour quadrants: + * red / green / blue / white. */ + GLubyte texels[8 * 8 * 3]; + for(int y = 0; y < 8; ++y) { + for(int x = 0; x < 8; ++x) { + GLubyte r = 0, g = 0, b = 0; + if(y < 4 && x < 4) { r = 255; } /* top-left red */ + else if(y < 4) { g = 255; } /* top-right green */ + else if(x < 4) { b = 255; } /* bottom-left blue */ + else { r = g = b = 255; } /* bottom-right white */ + int i = (y * 8 + x) * 3; + texels[i + 0] = r; texels[i + 1] = g; texels[i + 2] = b; + } + } + + GLuint tex = 0; + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 8, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, texels); + assert_equal(glGetError(), GL_NO_ERROR); + + glEnable(GL_TEXTURE_2D); + glColor4f(1.0f, 1.0f, 1.0f, 1.0f); + glBegin(GL_TRIANGLE_STRIP); + glTexCoord2f(0.0f, 0.0f); glVertex3f(-0.8f, 0.8f, 0.0f); + glTexCoord2f(0.0f, 1.0f); glVertex3f(-0.8f, -0.8f, 0.0f); + glTexCoord2f(1.0f, 0.0f); glVertex3f( 0.8f, 0.8f, 0.0f); + glTexCoord2f(1.0f, 1.0f); glVertex3f( 0.8f, -0.8f, 0.0f); + glEnd(); + + golden::rasterize_all_lists(img, _glGetBoundTexture()); + assert_true(golden::check(img, "textured_quad")); + + glDeleteTextures(1, &tex); + } + + /* The same triangle drawn with a coloured background must leave the + * background untouched outside the triangle (clear-colour preservation). */ + void test_triangle_preserves_background() { + golden::Image img(W, H); + img.clear(40, 80, 120); + + glColor4f(1.0f, 1.0f, 0.0f, 1.0f); + glBegin(GL_TRIANGLES); + glVertex3f(-0.5f, -0.5f, 0.0f); + glVertex3f( 0.5f, -0.5f, 0.0f); + glVertex3f( 0.0f, 0.5f, 0.0f); + glEnd(); + + golden::rasterize_all_lists(img); + assert_true(golden::check(img, "triangle_on_background")); + } +}; diff --git a/tests/test_pvr_vertex_submission.h b/tests/test_pvr_vertex_submission.h index 4fcd030..bb28900 100644 --- a/tests/test_pvr_vertex_submission.h +++ b/tests/test_pvr_vertex_submission.h @@ -1,6 +1,7 @@ #pragma once #include "tools/test.h" +#include "tools/gl_test.h" #include #include @@ -32,22 +33,10 @@ /* ========================================================================= * PVRVertexSubmissionTests * =========================================================================*/ -class PVRVertexSubmissionTests : public test::TestCase { +class PVRVertexSubmissionTests : public GLTestCase { public: - void set_up() { - GLdcConfig config; - glKosInitConfig(&config); - config.texture_twiddle = false; - glKosInitEx(&config); - - /* Explicit clean slate for colour and client-state. */ - glColor4f(1.0f, 1.0f, 1.0f, 1.0f); - ATTRIB_LIST.enabled = 0; - } - - void tear_down() { - glKosShutdown(); - } + /* GLTestCase::set_up() gives a clean slate: GPU initialised once, colour + * reset to white and all client-state arrays disabled. */ /* Convenience: cast element i of a list to Vertex*. */ static Vertex* vertex_at(PolyList* list, uint32_t i) { diff --git a/tests/test_texcoord_formats.h b/tests/test_texcoord_formats.h new file mode 100644 index 0000000..7ffb813 --- /dev/null +++ b/tests/test_texcoord_formats.h @@ -0,0 +1,247 @@ +#pragma once + +#include "tools/test.h" +#include "tools/gl_test.h" + +#include +#include +#include +#include +#include + +#include "GL/private.h" +#include "GL/state.h" +#include "containers/aligned_vector.h" + +/* ========================================================================= + * TexCoordFormatTests + * + * Coverage for the texture-coordinate attribute readers (GL/attributes.c). + * The supported glTexCoordPointer types each normalise differently: + * GL_FLOAT / GL_DOUBLE - passed through + * GL_UNSIGNED_BYTE - divided by 255 + * GL_SHORT - divided by SHRT_MAX (32767) + * GL_INT - passed through (raw) + * Each is compared against an equivalent GL_FLOAT draw so the per-type scaling + * is pinned down. Immediate-mode glTexCoord2f and the disabled-array (zeroed) + * case are covered too. + * =========================================================================*/ +class TexCoordFormatTests : public GLTestCase { +public: + void set_up() { + GLTestCase::set_up(); + glEnableClientState(GL_VERTEX_ARRAY); + glEnableClientState(GL_TEXTURE_COORD_ARRAY); + } + + static void reset_list() { + aligned_vector_clear(&OP_LIST.vector); + _glGPUStateMarkDirty(); + } + + static std::vector captured() { + std::vector out; + uint32_t n = aligned_vector_size(&OP_LIST.vector); + for(uint32_t i = 0; i < n; ++i) { + Vertex* v = (Vertex*) aligned_vector_at(&OP_LIST.vector, i); + if(v->flags == GPU_CMD_VERTEX || v->flags == GPU_CMD_VERTEX_EOL) { + out.push_back(*v); + } + } + return out; + } + + void assert_uvs_match(const std::vector& a, const std::vector& b) { + assert_equal(a.size(), b.size()); + for(size_t i = 0; i < a.size(); ++i) { + assert_close(a[i].uv[0], b[i].uv[0], 0.0005f); + assert_close(a[i].uv[1], b[i].uv[1], 0.0005f); + } + } + + /* A fixed triangle for the position data. */ + static const GLfloat* positions() { + static const GLfloat verts[] = { + -0.5f, -0.5f, 0.0f, + 0.5f, -0.5f, 0.0f, + 0.0f, 0.5f, 0.0f, + }; + return verts; + } + + std::vector draw_with_float_uv(const GLfloat* uv) { + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, positions()); + glTexCoordPointer(2, GL_FLOAT, 0, uv); + glDrawArrays(GL_TRIANGLES, 0, 3); + return captured(); + } + + /* --------------------------------------------------------- float basic */ + + void test_float_uv_passthrough() { + GLfloat uv[] = { 0.0f, 0.0f, 1.0f, 0.0f, 0.5f, 1.0f }; + std::vector v = draw_with_float_uv(uv); + assert_equal(v.size(), (size_t) 3); + assert_close(v[0].uv[0], 0.0f, 0.0005f); + assert_close(v[1].uv[0], 1.0f, 0.0005f); + assert_close(v[2].uv[1], 1.0f, 0.0005f); + } + + /* --------------------------------------------------------- short / 32767 */ + + void test_short_uv_is_scaled_by_shrt_max() { + GLshort suv[] = { + 0, 0, + SHRT_MAX, 0, + SHRT_MAX / 2, SHRT_MAX, + }; + GLfloat fuv[] = { + 0.0f, 0.0f, + (float) SHRT_MAX / SHRT_MAX, 0.0f, + (float)(SHRT_MAX / 2) / SHRT_MAX, (float) SHRT_MAX / SHRT_MAX, + }; + + std::vector fref = draw_with_float_uv(fuv); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, positions()); + glTexCoordPointer(2, GL_SHORT, 0, suv); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector sres = captured(); + + assert_uvs_match(fref, sres); + } + + /* --------------------------------------------------------- ubyte / 255 */ + + void test_ubyte_uv_is_scaled_by_255() { + GLubyte buv[] = { 0, 0, 255, 0, 128, 255 }; + GLfloat fuv[] = { + 0.0f, 0.0f, + 255.0f/255.0f, 0.0f, + 128.0f/255.0f, 255.0f/255.0f, + }; + + std::vector fref = draw_with_float_uv(fuv); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, positions()); + glTexCoordPointer(2, GL_UNSIGNED_BYTE, 0, buv); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector bres = captured(); + + assert_uvs_match(fref, bres); + } + + /* --------------------------------------------------------- int raw */ + + void test_int_uv_is_raw() { + GLint iuv[] = { 0, 0, 1, 0, 0, 1 }; + GLfloat fuv[] = { 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f }; + + std::vector fref = draw_with_float_uv(fuv); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, positions()); + glTexCoordPointer(2, GL_INT, 0, iuv); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector ires = captured(); + + assert_uvs_match(fref, ires); + } + + /* --------------------------------------------------------- double */ + + void test_double_uv_passthrough() { + GLdouble duv[] = { 0.0, 0.0, 1.0, 0.0, 0.5, 1.0 }; + GLfloat fuv[] = { 0.0f, 0.0f, 1.0f, 0.0f, 0.5f, 1.0f }; + + std::vector fref = draw_with_float_uv(fuv); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, positions()); + glTexCoordPointer(2, GL_DOUBLE, 0, duv); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector dres = captured(); + + assert_uvs_match(fref, dres); + } + + /* --------------------------------------------------------- stride */ + + void test_uv_stride_is_respected() { + GLfloat tight[] = { 0.0f, 0.0f, 1.0f, 0.0f, 0.5f, 1.0f }; + GLfloat padded[] = { + 0.0f, 0.0f, 77.0f, + 1.0f, 0.0f, 77.0f, + 0.5f, 1.0f, 77.0f, + }; + + std::vector rt = draw_with_float_uv(tight); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, positions()); + glTexCoordPointer(2, GL_FLOAT, 3 * sizeof(GLfloat), padded); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector rp = captured(); + + assert_uvs_match(rt, rp); + } + + /* --------------------------------------------------------- disabled */ + + /* With the texture-coordinate array disabled, every vertex takes the + * current texture coordinate (set via glTexCoord), matching fixed-function + * GL semantics. */ + void test_disabled_coord_array_uses_current_texcoord() { + glDisableClientState(GL_TEXTURE_COORD_ARRAY); + glTexCoord2f(0.25f, 0.75f); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, positions()); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector v = captured(); + + assert_equal(v.size(), (size_t) 3); + for(size_t i = 0; i < v.size(); ++i) { + assert_close(v[i].uv[0], 0.25f, 0.0005f); + assert_close(v[i].uv[1], 0.75f, 0.0005f); + } + } + + /* --------------------------------------------------------- immediate */ + + void test_immediate_texcoord2f_reaches_vertex() { + reset_list(); + glBegin(GL_TRIANGLES); + glTexCoord2f(0.0f, 0.0f); glVertex3f(-0.5f, -0.5f, 0.0f); + glTexCoord2f(1.0f, 0.0f); glVertex3f( 0.5f, -0.5f, 0.0f); + glTexCoord2f(0.5f, 1.0f); glVertex3f( 0.0f, 0.5f, 0.0f); + glEnd(); + std::vector v = captured(); + + assert_equal(v.size(), (size_t) 3); + assert_close(v[0].uv[0], 0.0f, 0.0005f); + assert_close(v[0].uv[1], 0.0f, 0.0005f); + assert_close(v[1].uv[0], 1.0f, 0.0005f); + assert_close(v[2].uv[0], 0.5f, 0.0005f); + assert_close(v[2].uv[1], 1.0f, 0.0005f); + } + + /* Immediate texcoords must match the equivalent vertex-array draw. */ + void test_immediate_texcoord_matches_array() { + GLfloat uv[] = { 0.0f, 0.0f, 1.0f, 0.0f, 0.5f, 1.0f }; + std::vector arr = draw_with_float_uv(uv); + + reset_list(); + glBegin(GL_TRIANGLES); + glTexCoord2f(0.0f, 0.0f); glVertex3f(-0.5f, -0.5f, 0.0f); + glTexCoord2f(1.0f, 0.0f); glVertex3f( 0.5f, -0.5f, 0.0f); + glTexCoord2f(0.5f, 1.0f); glVertex3f( 0.0f, 0.5f, 0.0f); + glEnd(); + std::vector imm = captured(); + + assert_uvs_match(arr, imm); + } +}; diff --git a/tests/test_texture_formats.h b/tests/test_texture_formats.h new file mode 100644 index 0000000..172e0c9 --- /dev/null +++ b/tests/test_texture_formats.h @@ -0,0 +1,316 @@ +#pragma once + +#include "tools/test.h" +#include "tools/gl_test.h" + +#include +#include +#include +#include +#include + +#include "GL/private.h" + +/* ========================================================================= + * TextureFormatTests + * + * Regression coverage for texture *loading*: that glTexImage2D / glTexSubImage2D + * pick the right internal format and, crucially, that the host->PVR pixel + * conversion produces the exact expected bytes. These checks are byte-exact and + * fully deterministic, so they pin down the conversion routines (RGB565, + * ARGB4444, ARGB1555, RGBA8, RED/ALPHA/LUMINANCE sources, paletted) against + * accidental changes. + * + * Twiddling is left OFF for the byte-exact tests so the stored data is a plain + * row-major array of 16/32-bit texels we can index directly. Twiddled layouts + * are validated at the internal-format level only. + * =========================================================================*/ +class TextureFormatTests : public GLTestCase { +public: + GLuint tex = 0; + + void set_up() { + GLTestCase::set_up(); + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + } + + void tear_down() { + glDeleteTextures(1, &tex); + GLTestCase::tear_down(); + } + + /* The converted 16bpp texel array for the currently bound texture. */ + static const uint16_t* data16() { + return (const uint16_t*) _glGetBoundTexture()->data; + } + static const uint8_t* data8() { + return (const uint8_t*) _glGetBoundTexture()->data; + } + static GLint internal_format() { + GLint f; + glGetIntegerv(GL_TEXTURE_INTERNAL_FORMAT_KOS, &f); + return f; + } + + /* ------------------------------------------------------ Internal format */ + + void test_rgb_ubyte_selects_rgb565() { + std::vector img(8 * 8 * 3, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 8, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, img.data()); + assert_equal(glGetError(), GL_NO_ERROR); + assert_equal(internal_format(), GL_RGB565_KOS); + } + + void test_rgba_ubyte_selects_argb4444() { + std::vector img(8 * 8 * 4, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, img.data()); + assert_equal(glGetError(), GL_NO_ERROR); + assert_equal(internal_format(), GL_ARGB4444_KOS); + } + + void test_twiddle_enabled_selects_twiddled_format() { + std::vector img(8 * 8 * 3, 0); + glEnable(GL_TEXTURE_TWIDDLE_KOS); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 8, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, img.data()); + glDisable(GL_TEXTURE_TWIDDLE_KOS); + assert_equal(glGetError(), GL_NO_ERROR); + assert_equal(internal_format(), GL_RGB565_TWID_KOS); + } + + /* ----------------------------------------------------- Dimensions/state */ + + void test_dimensions_are_stored() { + std::vector img(16 * 32 * 3, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 16, 32, 0, GL_RGB, GL_UNSIGNED_BYTE, img.data()); + assert_equal(glGetError(), GL_NO_ERROR); + TextureObject* t = _glGetBoundTexture(); + assert_equal((int) t->width, 16); + assert_equal((int) t->height, 32); + } + + /* ------------------------------------------------- Byte-exact: RGB565 */ + + void test_rgb888_to_rgb565_packing() { + /* Four distinct texels with known RGB values. */ + uint8_t img[8 * 8 * 3] = {0}; + uint8_t colors[4][3] = { + {255, 0, 0}, /* red */ + { 0, 255, 0}, /* green */ + { 0, 0, 255}, /* blue */ + {130, 70, 30}, /* arbitrary */ + }; + for(int i = 0; i < 4; ++i) { + img[i * 3 + 0] = colors[i][0]; + img[i * 3 + 1] = colors[i][1]; + img[i * 3 + 2] = colors[i][2]; + } + + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 8, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, img); + assert_equal(glGetError(), GL_NO_ERROR); + + const uint16_t* d = data16(); + for(int i = 0; i < 4; ++i) { + uint16_t r = (colors[i][0] >> 3) & 0x1f; + uint16_t g = (colors[i][1] >> 2) & 0x3f; + uint16_t b = (colors[i][2] >> 3) & 0x1f; + uint16_t expected = (r << 11) | (g << 5) | b; + assert_equal(d[i], expected); + } + } + + void test_red_ubyte_to_rgb565_only_sets_red() { + uint8_t img[8 * 8] = {0}; + img[0] = 0xFF; /* full red */ + img[1] = 0x80; + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB565_KOS, 8, 8, 0, GL_RED, GL_UNSIGNED_BYTE, img); + assert_equal(glGetError(), GL_NO_ERROR); + + const uint16_t* d = data16(); + /* _r8_to_rgb565: (r & 0xF8) << 8 -> only top 5 red bits, green/blue zero */ + assert_equal(d[0], (uint16_t)((0xFF & 0xF8) << 8)); + assert_equal(d[1], (uint16_t)((0x80 & 0xF8) << 8)); + /* green/blue bits must be clear */ + assert_equal((int)(d[0] & 0x07FF), 0); + } + + /* ------------------------------------------------- Byte-exact: ARGB4444 */ + + void test_rgba8888_to_argb4444_packing() { + uint8_t img[8 * 8 * 4] = {0}; + uint8_t colors[4][4] = { + {0xFF, 0x00, 0x00, 0xFF}, /* opaque red */ + {0x00, 0xFF, 0x00, 0x80}, /* half green */ + {0x00, 0x00, 0xFF, 0x00}, /* transp blue */ + {0x12, 0x34, 0x56, 0x78}, /* arbitrary */ + }; + for(int i = 0; i < 4; ++i) + for(int c = 0; c < 4; ++c) + img[i * 4 + c] = colors[i][c]; + + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, img); + assert_equal(glGetError(), GL_NO_ERROR); + + const uint16_t* d = data16(); + for(int i = 0; i < 4; ++i) { + uint8_t r = colors[i][0], g = colors[i][1], b = colors[i][2], a = colors[i][3]; + uint16_t expected = ((a & 0xF0) << 8) | ((r & 0xF0) << 4) | (g & 0xF0) | ((b & 0xF0) >> 4); + assert_equal(d[i], expected); + } + } + + void test_alpha_ubyte_to_argb4444_replicates_alpha() { + uint8_t img[8 * 8] = {0}; + img[0] = 0xFF; + img[1] = 0x00; + img[2] = 0x5A; + glTexImage2D(GL_TEXTURE_2D, 0, GL_ARGB4444_KOS, 8, 8, 0, GL_ALPHA, GL_UNSIGNED_BYTE, img); + assert_equal(glGetError(), GL_NO_ERROR); + + const uint16_t* d = data16(); + /* _a8_to_argb4444 replicates the top nibble across all 4 channels. */ + for(int i = 0; i < 3; ++i) { + uint8_t a4 = (img[i] >> 4) & 0xF; + uint16_t expected = (a4 << 12) | (a4 << 8) | (a4 << 4) | a4; + assert_equal(d[i], expected); + } + } + + /* GL_RGBA8 is not a native PVR texture format in GLdc; it is downgraded to + * ARGB4444. Pin that behaviour so a change is noticed. */ + void test_rgba8_is_downgraded_to_argb4444() { + std::vector img(8 * 8 * 4, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, img.data()); + assert_equal(glGetError(), GL_NO_ERROR); + assert_equal(internal_format(), GL_ARGB4444_KOS); + } + + /* ------------------------------------------------- glTexSubImage2D */ + + void test_subimage_updates_only_target_region() { + /* Start with an all-black 16x16 RGB565 texture. */ + std::vector img(16 * 16 * 3, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 16, 16, 0, GL_RGB, GL_UNSIGNED_BYTE, img.data()); + assert_equal(glGetError(), GL_NO_ERROR); + + /* Replace a 2x2 block at (4,4) with red. */ + uint8_t sub[2 * 2 * 3]; + for(int i = 0; i < 4; ++i) { sub[i*3+0] = 255; sub[i*3+1] = 0; sub[i*3+2] = 0; } + glTexSubImage2D(GL_TEXTURE_2D, 0, 4, 4, 2, 2, GL_RGB, GL_UNSIGNED_BYTE, sub); + assert_equal(glGetError(), GL_NO_ERROR); + + const uint16_t* d = data16(); + uint16_t red565 = (uint16_t)((0xFF >> 3) << 11); + + /* Updated texels are red, a neighbour outside the region is still black. */ + assert_equal(d[4 * 16 + 4], red565); + assert_equal(d[4 * 16 + 5], red565); + assert_equal(d[5 * 16 + 4], red565); + assert_equal(d[5 * 16 + 5], red565); + assert_equal(d[4 * 16 + 6], (uint16_t) 0); /* just right of region */ + assert_equal(d[0], (uint16_t) 0); /* corner untouched */ + } + + /* ------------------------------------------------- Paletted textures */ + + void test_color_index8_is_paletted() { + std::vector img(8 * 8, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_COLOR_INDEX8_EXT, 8, 8, 0, + GL_COLOR_INDEX, GL_UNSIGNED_BYTE, img.data()); + assert_equal(glGetError(), GL_NO_ERROR); + + TextureObject* t = _glGetBoundTexture(); + assert_true(t->isPaletted); + /* Paletted formats are always twiddled internally. */ + assert_equal(t->internalFormat, GL_COLOR_INDEX8_TWID_KOS); + } + + void test_color_table_then_indexed_texture_no_error() { + /* A 4 entry RGBA palette. */ + uint8_t palette[4 * 4] = { + 255, 0, 0, 255, + 0, 255, 0, 255, + 0, 0, 255, 255, + 255, 255, 255, 255, + }; + glColorTableEXT(GL_TEXTURE_2D, GL_RGBA8, 4, GL_RGBA, GL_UNSIGNED_BYTE, palette); + assert_equal(glGetError(), GL_NO_ERROR); + + uint8_t indices[8 * 8]; + for(int i = 0; i < 8 * 8; ++i) indices[i] = i & 3; + glTexImage2D(GL_TEXTURE_2D, 0, GL_COLOR_INDEX8_EXT, 8, 8, 0, + GL_COLOR_INDEX, GL_UNSIGNED_BYTE, indices); + assert_equal(glGetError(), GL_NO_ERROR); + + TextureObject* t = _glGetBoundTexture(); + assert_is_not_null(t->palette); + } + + /* ------------------------------------------------- Error handling */ + + void test_non_power_of_two_width_raises_invalid_value() { + std::vector img(24 * 8 * 3, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 24, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, img.data()); + assert_equal(glGetError(), GL_INVALID_VALUE); + } + + void test_oversized_texture_raises_invalid_value() { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 2048, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL); + assert_equal(glGetError(), GL_INVALID_VALUE); + } + + void test_invalid_target_raises_invalid_enum() { + std::vector img(8 * 8 * 3, 0); + /* Any target other than GL_TEXTURE_2D is unsupported. */ + glTexImage2D((GLenum) 0x0DE0 /* GL_TEXTURE_1D */, 0, GL_RGB, 8, 8, 0, + GL_RGB, GL_UNSIGNED_BYTE, img.data()); + assert_equal(glGetError(), GL_INVALID_ENUM); + } + + /* ------------------------------------------------- Multiple textures */ + + void test_two_textures_keep_independent_data() { + GLuint a = 0, b = 0; + glGenTextures(1, &a); + glGenTextures(1, &b); + + uint8_t reds[8 * 8 * 3]; + uint8_t blues[8 * 8 * 3]; + for(int i = 0; i < 8 * 8; ++i) { + reds[i*3+0] = 255; reds[i*3+1] = 0; reds[i*3+2] = 0; + blues[i*3+0] = 0; blues[i*3+1] = 0; blues[i*3+2] = 255; + } + + glBindTexture(GL_TEXTURE_2D, a); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 8, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, reds); + glBindTexture(GL_TEXTURE_2D, b); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 8, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, blues); + assert_equal(glGetError(), GL_NO_ERROR); + + uint16_t red565 = (uint16_t)((0xFF >> 3) << 11); + uint16_t blue565 = (uint16_t)((0xFF >> 3)); + + glBindTexture(GL_TEXTURE_2D, a); + assert_equal(data16()[0], red565); + glBindTexture(GL_TEXTURE_2D, b); + assert_equal(data16()[0], blue565); + + glDeleteTextures(1, &a); + glDeleteTextures(1, &b); + } + + /* Re-uploading a different size to the same texture must reallocate and + * store the new dimensions. */ + void test_reupload_different_size_updates_dimensions() { + std::vector img(8 * 8 * 3, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 8, 8, 0, GL_RGB, GL_UNSIGNED_BYTE, img.data()); + + std::vector img2(32 * 32 * 3, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 32, 32, 0, GL_RGB, GL_UNSIGNED_BYTE, img2.data()); + assert_equal(glGetError(), GL_NO_ERROR); + + TextureObject* t = _glGetBoundTexture(); + assert_equal((int) t->width, 32); + assert_equal((int) t->height, 32); + } +}; diff --git a/tests/test_vertex_colors.h b/tests/test_vertex_colors.h new file mode 100644 index 0000000..4dd1b82 --- /dev/null +++ b/tests/test_vertex_colors.h @@ -0,0 +1,191 @@ +#pragma once + +#include "tools/test.h" +#include "tools/gl_test.h" + +#include +#include +#include +#include + +#include "GL/private.h" +#include "GL/state.h" +#include "containers/aligned_vector.h" + +/* ========================================================================= + * VertexColorTests + * + * Byte vertex-colour coverage for glColorPointer, with particular attention to + * GL_BGRA as specified by OpenGL 1.4. + * + * Per the spec, a colour array may use size = GL_BGRA (only valid with + * GL_UNSIGNED_BYTE). The four bytes are then stored in memory in B, G, R, A + * order but are interpreted as the R, G, B, A colour components — i.e. the + * red and blue channels are swapped relative to a normal GL_RGBA array. Alpha + * stays in the last byte. + * + * GLdc stores the resulting colour per vertex as normalised floats in ARGB + * slot order (A8IDX / R8IDX / G8IDX / B8IDX). These tests draw a triangle and + * read the submitted vertices back out of OP_LIST to verify the byte->float + * conversion and the BGRA channel ordering. + * =========================================================================*/ +class VertexColorTests : public GLTestCase { +public: + static const GLfloat* positions() { + static const GLfloat verts[] = { + -1.0f, -1.0f, 0.5f, + 1.0f, -1.0f, 0.5f, + 0.0f, 1.0f, 0.5f, + }; + return verts; + } + + /* Submitted (non-header) vertices currently in OP_LIST. */ + static std::vector captured() { + std::vector out; + uint32_t n = aligned_vector_size(&OP_LIST.vector); + for(uint32_t i = 0; i < n; ++i) { + Vertex* v = (Vertex*) aligned_vector_at(&OP_LIST.vector, i); + if(v->flags == GPU_CMD_VERTEX || v->flags == GPU_CMD_VERTEX_EOL) { + out.push_back(*v); + } + } + return out; + } + + /* Draw a single triangle whose vertices use the supplied colour array. */ + std::vector draw_with_colors(GLint size, GLenum type, const void* colors) { + glEnableClientState(GL_VERTEX_ARRAY); + glEnableClientState(GL_COLOR_ARRAY); + glVertexPointer(3, GL_FLOAT, 0, positions()); + glColorPointer(size, type, 0, colors); + glDrawArrays(GL_TRIANGLES, 0, 3); + return captured(); + } + + void assert_argb_close(const Vertex& v, float r, float g, float b, float a) { + assert_close(v.argb[R8IDX], r, 0.005f); + assert_close(v.argb[G8IDX], g, 0.005f); + assert_close(v.argb[B8IDX], b, 0.005f); + assert_close(v.argb[A8IDX], a, 0.005f); + } + + /* --------------------------------------------------- byte RGBA values */ + + /* size=4 GL_UNSIGNED_BYTE: each byte is normalised by 1/255 and kept in + * RGBA order. Use non-trivial values so a wrong scale or order shows up. */ + void test_byte_rgba4_normalizes_and_keeps_order() { + GLubyte colors[] = { + 10, 20, 30, 40, + 200, 150, 100, 50, + 255, 128, 1, 254, + }; + std::vector v = draw_with_colors(4, GL_UNSIGNED_BYTE, colors); + assert_equal(v.size(), (size_t) 3); + assert_argb_close(v[0], 10/255.0f, 20/255.0f, 30/255.0f, 40/255.0f); + assert_argb_close(v[1], 200/255.0f, 150/255.0f, 100/255.0f, 50/255.0f); + assert_argb_close(v[2], 255/255.0f, 128/255.0f, 1/255.0f, 254/255.0f); + } + + /* size=3 GL_UNSIGNED_BYTE: RGB normalised, alpha forced to 1.0. */ + void test_byte_rgb3_forces_alpha_one() { + GLubyte colors[] = { + 10, 20, 30, + 200, 150, 100, + 0, 0, 0, + }; + std::vector v = draw_with_colors(3, GL_UNSIGNED_BYTE, colors); + assert_equal(v.size(), (size_t) 3); + assert_argb_close(v[0], 10/255.0f, 20/255.0f, 30/255.0f, 1.0f); + assert_argb_close(v[1], 200/255.0f, 150/255.0f, 100/255.0f, 1.0f); + assert_argb_close(v[2], 0.0f, 0.0f, 0.0f, 1.0f); + } + + /* --------------------------------------------------- GL_BGRA (GL 1.4) */ + + /* GL_BGRA: memory order is B, G, R, A but maps to R, G, B, A. With distinct + * channel values this proves the red/blue swap (and that alpha is the last + * byte). */ + void test_bgra_byte_channel_order() { + /* memory: B=10, G=20, R=30, A=200 -> colour R=30, G=20, B=10, A=200 */ + GLubyte colors[] = { + 10, 20, 30, 200, + 10, 20, 30, 200, + 10, 20, 30, 200, + }; + std::vector v = draw_with_colors(GL_BGRA, GL_UNSIGNED_BYTE, colors); + assert_equal(v.size(), (size_t) 3); + for(size_t i = 0; i < v.size(); ++i) { + assert_argb_close(v[i], 30/255.0f, 20/255.0f, 10/255.0f, 200/255.0f); + } + } + + /* A GL_BGRA array laid out as {B,G,R,A} must produce exactly the same + * submitted colour as the equivalent GL_RGBA array {R,G,B,A}. */ + void test_bgra_matches_equivalent_rgba() { + /* Logical colour: R=30, G=20, B=10, A=200 */ + GLubyte rgba[] = { + 30, 20, 10, 200, + 30, 20, 10, 200, + 30, 20, 10, 200, + }; + GLubyte bgra[] = { + 10, 20, 30, 200, + 10, 20, 30, 200, + 10, 20, 30, 200, + }; + + std::vector rgba_v = draw_with_colors(4, GL_UNSIGNED_BYTE, rgba); + + /* Fresh list for the BGRA draw. */ + aligned_vector_clear(&OP_LIST.vector); + _glGPUStateMarkDirty(); + std::vector bgra_v = draw_with_colors(GL_BGRA, GL_UNSIGNED_BYTE, bgra); + + assert_equal(rgba_v.size(), bgra_v.size()); + for(size_t i = 0; i < rgba_v.size(); ++i) { + assert_close(rgba_v[i].argb[R8IDX], bgra_v[i].argb[R8IDX], 0.005f); + assert_close(rgba_v[i].argb[G8IDX], bgra_v[i].argb[G8IDX], 0.005f); + assert_close(rgba_v[i].argb[B8IDX], bgra_v[i].argb[B8IDX], 0.005f); + assert_close(rgba_v[i].argb[A8IDX], bgra_v[i].argb[A8IDX], 0.005f); + } + } + + /* Distinct per-vertex GL_BGRA colours must each map correctly. */ + void test_bgra_per_vertex_distinct() { + GLubyte colors[] = { + /* B G R A -> R / G / B / A */ + 0, 0, 255, 255, /* red, opaque */ + 0, 255, 0, 128, /* green, half a */ + 255, 0, 0, 64, /* blue, quarter */ + }; + std::vector v = draw_with_colors(GL_BGRA, GL_UNSIGNED_BYTE, colors); + assert_equal(v.size(), (size_t) 3); + assert_argb_close(v[0], 1.0f, 0.0f, 0.0f, 255/255.0f); /* red */ + assert_argb_close(v[1], 0.0f, 1.0f, 0.0f, 128/255.0f); /* green */ + assert_argb_close(v[2], 0.0f, 0.0f, 1.0f, 64/255.0f); /* blue */ + } + + /* Alpha for GL_BGRA comes from the last byte, independent of the RGB + * channels. */ + void test_bgra_alpha_is_independent() { + GLubyte colors[] = { + 255, 255, 255, 0, /* white, fully transparent */ + 255, 255, 255, 0, + 255, 255, 255, 0, + }; + std::vector v = draw_with_colors(GL_BGRA, GL_UNSIGNED_BYTE, colors); + assert_equal(v.size(), (size_t) 3); + for(size_t i = 0; i < v.size(); ++i) { + assert_argb_close(v[i], 1.0f, 1.0f, 1.0f, 0.0f); + } + } + + /* GL_BGRA is accepted by glColorPointer without raising an error. */ + void test_bgra_size_is_accepted() { + GLubyte colors[] = { 1, 2, 3, 4 }; + glColorPointer(GL_BGRA, GL_UNSIGNED_BYTE, 0, colors); + assert_equal(glGetError(), GL_NO_ERROR); + assert_equal(ATTRIB_LIST.colour.size, (GLint) GL_BGRA); + } +}; diff --git a/tests/test_vertex_formats.h b/tests/test_vertex_formats.h new file mode 100644 index 0000000..a9d6a18 --- /dev/null +++ b/tests/test_vertex_formats.h @@ -0,0 +1,313 @@ +#pragma once + +#include "tools/test.h" +#include "tools/gl_test.h" + +#include +#include +#include +#include + +#include "GL/private.h" +#include "GL/state.h" +#include "containers/aligned_vector.h" + +/* ========================================================================= + * VertexFormatTests + * + * Coverage for the vertex-position attribute readers used by glDrawArrays / + * glDrawElements (GL/attributes.c). Each supported pointer type (float, short, + * int, byte, double) and size (2 / 3) is exercised, and the *submitted* vertex + * positions are compared against an equivalent GL_FLOAT draw. + * + * This pins down the per-type interpretation, e.g. that GL_SHORT/GL_INT are + * read as raw values while GL_UNSIGNED_BYTE is normalised by 1/255, so a + * regression in any reader is caught without depending on the exact viewport + * matrix maths. + * =========================================================================*/ +class VertexFormatTests : public GLTestCase { +public: + void set_up() { + GLTestCase::set_up(); + glEnableClientState(GL_VERTEX_ARRAY); + } + + /* Empty OP_LIST and force a fresh poly header on the next draw. */ + static void reset_list() { + aligned_vector_clear(&OP_LIST.vector); + _glGPUStateMarkDirty(); + } + + /* All vertex-flagged entries currently in OP_LIST (headers skipped). */ + static std::vector captured() { + std::vector out; + uint32_t n = aligned_vector_size(&OP_LIST.vector); + for(uint32_t i = 0; i < n; ++i) { + Vertex* v = (Vertex*) aligned_vector_at(&OP_LIST.vector, i); + if(v->flags == GPU_CMD_VERTEX || v->flags == GPU_CMD_VERTEX_EOL) { + out.push_back(*v); + } + } + return out; + } + + void assert_positions_match(const std::vector& a, const std::vector& b) { + assert_equal(a.size(), b.size()); + for(size_t i = 0; i < a.size(); ++i) { + assert_close(a[i].xyz[0], b[i].xyz[0], 0.01f); + assert_close(a[i].xyz[1], b[i].xyz[1], 0.01f); + assert_close(a[i].xyz[2], b[i].xyz[2], 0.01f); + assert_close(a[i].w, b[i].w, 0.01f); + } + } + + /* --------------------------------------------------------- float (ref) */ + + void test_float_size3_basic_submission() { + GLfloat verts[] = { + -0.5f, -0.5f, 0.0f, + 0.5f, -0.5f, 0.0f, + 0.0f, 0.5f, 0.0f, + }; + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, verts); + glDrawArrays(GL_TRIANGLES, 0, 3); + + std::vector v = captured(); + assert_equal(v.size(), (size_t) 3); + /* w must be 1 for an identity transform of a z=0 triangle. */ + assert_close(v[0].w, 1.0f, 0.001f); + } + + /* --------------------------------------------------------- short == float */ + + void test_short_matches_equivalent_float() { + GLshort sverts[] = { 2, 3, 5, 7, 11, 1 }; /* size 2 */ + GLfloat fverts[] = { 2, 3, 5, 7, 11, 1 }; + + reset_list(); + glVertexPointer(2, GL_FLOAT, 0, fverts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector fref = captured(); + + reset_list(); + glVertexPointer(2, GL_SHORT, 0, sverts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector sres = captured(); + + assert_positions_match(fref, sres); + } + + /* --------------------------------------------------------- int == float */ + + void test_int_matches_equivalent_float() { + GLint iverts[] = { 1, 4, 6, 2, 3, 9 }; + GLfloat fverts[] = { 1, 4, 6, 2, 3, 9 }; + + reset_list(); + glVertexPointer(2, GL_FLOAT, 0, fverts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector fref = captured(); + + reset_list(); + glVertexPointer(2, GL_INT, 0, iverts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector ires = captured(); + + assert_positions_match(fref, ires); + } + + /* --------------------------------------------------------- double == float */ + + void test_double_matches_equivalent_float() { + GLdouble dverts[] = { -0.5, -0.5, 0.5, -0.5, 0.0, 0.5 }; + GLfloat fverts[] = { -0.5f, -0.5f, 0.5f, -0.5f, 0.0f, 0.5f }; + + reset_list(); + glVertexPointer(2, GL_FLOAT, 0, fverts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector fref = captured(); + + reset_list(); + glVertexPointer(2, GL_DOUBLE, 0, dverts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector dres = captured(); + + assert_positions_match(fref, dres); + } + + /* GL_UNSIGNED_BYTE positions are normalised by 1/255. */ + void test_ubyte_matches_normalized_float() { + GLubyte bverts[] = { 10, 20, 30, 40, 50, 60 }; + GLfloat fverts[] = { + 10.0f/255.0f, 20.0f/255.0f, + 30.0f/255.0f, 40.0f/255.0f, + 50.0f/255.0f, 60.0f/255.0f, + }; + + reset_list(); + glVertexPointer(2, GL_FLOAT, 0, fverts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector fref = captured(); + + reset_list(); + glVertexPointer(2, GL_UNSIGNED_BYTE, 0, bverts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector bres = captured(); + + assert_positions_match(fref, bres); + } + + /* --------------------------------------------------------- size 2 vs 3 */ + + /* A size-2 pointer must produce z == 0 (and therefore match a size-3 draw + * whose z components are all zero). */ + void test_size2_implies_zero_z() { + GLfloat v2[] = { -0.5f, -0.5f, 0.5f, -0.5f, 0.0f, 0.5f }; + GLfloat v3[] = { + -0.5f, -0.5f, 0.0f, + 0.5f, -0.5f, 0.0f, + 0.0f, 0.5f, 0.0f, + }; + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, v3); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector r3 = captured(); + + reset_list(); + glVertexPointer(2, GL_FLOAT, 0, v2); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector r2 = captured(); + + assert_positions_match(r3, r2); + } + + /* --------------------------------------------------------- stride */ + + /* An interleaved array with padding between vertices must read the same + * positions as a tightly-packed one. */ + void test_custom_stride_is_respected() { + GLfloat tight[] = { + -0.5f, -0.5f, 0.0f, + 0.5f, -0.5f, 0.0f, + 0.0f, 0.5f, 0.0f, + }; + /* Same positions, but each followed by 2 padding floats (stride 20). */ + GLfloat padded[] = { + -0.5f, -0.5f, 0.0f, 99.0f, 99.0f, + 0.5f, -0.5f, 0.0f, 99.0f, 99.0f, + 0.0f, 0.5f, 0.0f, 99.0f, 99.0f, + }; + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, tight); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector rt = captured(); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 5 * sizeof(GLfloat), padded); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector rp = captured(); + + assert_positions_match(rt, rp); + } + + /* --------------------------------------------------------- glDrawElements */ + + void test_draw_elements_matches_draw_arrays() { + GLfloat verts[] = { + -0.5f, -0.5f, 0.0f, + 0.5f, -0.5f, 0.0f, + 0.0f, 0.5f, 0.0f, + }; + GLubyte indices[] = { 0, 1, 2 }; + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, verts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector da = captured(); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, verts); + glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_BYTE, indices); + std::vector de = captured(); + + assert_positions_match(da, de); + } + + /* glDrawElements honours the index order. */ + void test_draw_elements_reorders_vertices() { + GLfloat verts[] = { + -0.5f, -0.5f, 0.0f, + 0.5f, -0.5f, 0.0f, + 0.0f, 0.5f, 0.0f, + }; + GLubyte forward[] = { 0, 1, 2 }; + GLubyte reversed[] = { 2, 1, 0 }; + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, verts); + glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_BYTE, forward); + std::vector f = captured(); + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, verts); + glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_BYTE, reversed); + std::vector r = captured(); + + assert_equal(f.size(), (size_t) 3); + /* First of forward == last of reversed. */ + assert_close(f[0].xyz[0], r[2].xyz[0], 0.01f); + assert_close(f[0].xyz[1], r[2].xyz[1], 0.01f); + assert_close(f[2].xyz[0], r[0].xyz[0], 0.01f); + } + + /* --------------------------------------------------------- immediate mode */ + + /* glVertex2f(x,y) must equal glVertex3f(x,y,0). */ + void test_immediate_vertex2f_equals_vertex3f_zero_z() { + reset_list(); + glBegin(GL_TRIANGLES); + glVertex3f(-0.5f, -0.5f, 0.0f); + glVertex3f( 0.5f, -0.5f, 0.0f); + glVertex3f( 0.0f, 0.5f, 0.0f); + glEnd(); + std::vector v3 = captured(); + + reset_list(); + glBegin(GL_TRIANGLES); + glVertex2f(-0.5f, -0.5f); + glVertex2f( 0.5f, -0.5f); + glVertex2f( 0.0f, 0.5f); + glEnd(); + std::vector v2 = captured(); + + assert_positions_match(v3, v2); + } + + /* Immediate mode must produce the same submission as an equivalent + * vertex-array draw. */ + void test_immediate_matches_vertex_array() { + GLfloat verts[] = { + -0.5f, -0.5f, 0.0f, + 0.5f, -0.5f, 0.0f, + 0.0f, 0.5f, 0.0f, + }; + + reset_list(); + glVertexPointer(3, GL_FLOAT, 0, verts); + glDrawArrays(GL_TRIANGLES, 0, 3); + std::vector arr = captured(); + + reset_list(); + glBegin(GL_TRIANGLES); + glVertex3f(-0.5f, -0.5f, 0.0f); + glVertex3f( 0.5f, -0.5f, 0.0f); + glVertex3f( 0.0f, 0.5f, 0.0f); + glEnd(); + std::vector imm = captured(); + + assert_positions_match(arr, imm); + } +}; diff --git a/tools/gl_test.h b/tools/gl_test.h new file mode 100644 index 0000000..152fb4b --- /dev/null +++ b/tools/gl_test.h @@ -0,0 +1,106 @@ +/* + * Shared test fixture for GLdc tests that drive the GL API. + * + * On the Dreamcast the GPU/PVR (and the display) must be initialised exactly + * once: InitGPU() calls pvr_init() and ShutdownGPU() calls pvr_shutdown(), and + * tearing those down and back up between every test is both slow and fragile on + * real hardware / emulators. So this fixture: + * + * - initialises the GPU lazily on the first test and registers an atexit() + * handler to shut it down once, at program exit (window opens at start, + * closes at the end); + * - resets the mutable GL state to a known baseline before each test, without + * re-initialising the GPU. + * + * GL-driving test suites should derive from GLTestCase (instead of + * test::TestCase). If a suite needs extra per-test setup it should override + * set_up()/tear_down() and call GLTestCase::set_up()/tear_down() first. + */ +#pragma once + +#include + +#include "tools/test.h" + +#include +#include + +#include "GL/private.h" +#include "GL/state.h" +#include "containers/aligned_vector.h" + +class GLTestCase : public test::TestCase { +public: + /* One-shot init flag (function-local static to keep this header-only). */ + static bool& gpu_initialized() { + static bool v = false; + return v; + } + + static void shutdown_gpu_at_exit() { + glKosShutdown(); + } + + static void ensure_gpu_initialized() { + if(gpu_initialized()) { + return; + } + + GLdcConfig config; + glKosInitConfig(&config); + config.texture_twiddle = GL_FALSE; + glKosInitEx(&config); + + gpu_initialized() = true; + + /* Close the window / shut the PVR down once, when the process exits. */ + atexit(GLTestCase::shutdown_gpu_at_exit); + } + + /* Return all GL state to a predictable baseline. This deliberately avoids + * the allocating _glInit* helpers (immediate-mode buffer, texture/named + * arrays, framebuffers) so it is safe to call repeatedly. */ + void reset_gl_state() { + aligned_vector_clear(&OP_LIST.vector); + aligned_vector_clear(&PT_LIST.vector); + aligned_vector_clear(&TR_LIST.vector); + + glDisable(GL_TEXTURE_2D); + glDisable(GL_BLEND); + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + glDisable(GL_LIGHTING); + glDisable(GL_FOG); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_TEXTURE_TWIDDLE_KOS); + + glBlendFunc(GL_ONE, GL_ZERO); + glDepthFunc(GL_LESS); + glShadeModel(GL_SMOOTH); + + glBindTexture(GL_TEXTURE_2D, 0); + + glMatrixMode(GL_PROJECTION); glLoadIdentity(); + glMatrixMode(GL_MODELVIEW); glLoadIdentity(); + glViewport(0, 0, 640, 480); + + glColor4f(1.0f, 1.0f, 1.0f, 1.0f); + glTexCoord2f(0.0f, 0.0f); + glNormal3f(0.0f, 0.0f, 1.0f); + + ATTRIB_LIST.enabled = 0; + ATTRIB_LIST.dirty = ~0u; + + _glGPUStateMarkDirty(); + + /* Drain any error raised while resetting. */ + while(glGetError() != GL_NO_ERROR) {} + } + + void set_up() { + ensure_gpu_initialized(); + reset_gl_state(); + } + + void tear_down() {} +}; diff --git a/tools/golden.h b/tools/golden.h new file mode 100644 index 0000000..bb13ce2 --- /dev/null +++ b/tools/golden.h @@ -0,0 +1,373 @@ +/* + * Golden-image test harness for GLdc. + * + * GLdc has a "software" backend that, on the desktop, normally renders the + * submitted Tile-Accelerator (TA) poly-lists through SDL's GPU renderer. That + * path needs a display and is not deterministic across machines/SDL versions, + * so it is unsuitable for committed "golden" reference images. + * + * Instead this harness contains a tiny, fully self-contained, deterministic CPU + * rasteriser that consumes exactly the same poly-lists the real backend does + * (OP_LIST / PT_LIST / TR_LIST, filled by glEnd / glDrawArrays / ...). It + * reproduces the backend's triangle-strip walking (see SceneListFinish in + * GL/platforms/software.c) and the perspective divide it performs, then fills + * triangles with Gouraud-interpolated vertex colour, optionally modulated by a + * decoded texture. The result is compared against a committed PPM reference. + * + * Because the rasteriser lives here (not in the library) it never changes when + * the library changes, so a diff in the rendered output reliably indicates a + * regression in GLdc's transform / colour / clipping / submission pipeline. + * + * Determinism notes: + * - Pure float maths, pixel-centre sampling, top-left-ish fill rule. + * - Painter's-order compositing (matching the software backend, which does + * no depth buffering): later triangles overwrite earlier ones for opaque + * lists; the transparent list alpha-blends. + * - Comparison is tolerant (per-channel + mismatch-fraction thresholds) so + * sub-LSB rounding differences between compilers do not cause flakiness, + * while real geometry/colour regressions are still caught. + * + * Usage from a test: + * golden::Image img(64, 64); + * img.clear(0, 0, 0); + * ... GL draw calls ... + * golden::rasterize_all_lists(img); // or rasterize_list(...) + * assert_true(golden::check(img, "name")); // compares tests/goldens/name.ppm + * + * Set the environment variable GLDC_UPDATE_GOLDENS=1 to (re)generate references. + * On a mismatch the actual and diff images are written next to the golden with + * .actual.ppm / .diff.ppm suffixes for inspection. + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "GL/private.h" +#include "GL/platform.h" +#include "containers/aligned_vector.h" + +#ifndef GLDC_GOLDEN_DIR +/* Fallback; the real value is injected by CMake as a compile definition. */ +#define GLDC_GOLDEN_DIR "goldens" +#endif + +namespace golden { + +/* ------------------------------------------------------------------ Image */ + +struct Image { + int w; + int h; + std::vector rgb; /* w*h*3, row-major, top row first */ + + Image(int width, int height) : w(width), h(height), rgb(size_t(width) * height * 3, 0) {} + + void clear(uint8_t r, uint8_t g, uint8_t b) { + for(size_t i = 0; i < rgb.size(); i += 3) { + rgb[i + 0] = r; + rgb[i + 1] = g; + rgb[i + 2] = b; + } + } + + inline void put(int x, int y, uint8_t r, uint8_t g, uint8_t b) { + if(x < 0 || y < 0 || x >= w || y >= h) return; + size_t i = (size_t(y) * w + x) * 3; + rgb[i + 0] = r; + rgb[i + 1] = g; + rgb[i + 2] = b; + } + + inline void get(int x, int y, uint8_t& r, uint8_t& g, uint8_t& b) const { + size_t i = (size_t(y) * w + x) * 3; + r = rgb[i + 0]; + g = rgb[i + 1]; + b = rgb[i + 2]; + } +}; + +/* -------------------------------------------------------------------- PPM */ + +inline bool write_ppm(const std::string& path, const Image& img) { + FILE* f = fopen(path.c_str(), "wb"); + if(!f) return false; + fprintf(f, "P6\n%d %d\n255\n", img.w, img.h); + fwrite(img.rgb.data(), 1, img.rgb.size(), f); + fclose(f); + return true; +} + +inline bool read_ppm(const std::string& path, Image& out) { + FILE* f = fopen(path.c_str(), "rb"); + if(!f) return false; + char magic[3] = {0}; + int w = 0, h = 0, maxv = 0; + if(fscanf(f, "%2s", magic) != 1 || strcmp(magic, "P6") != 0) { fclose(f); return false; } + if(fscanf(f, "%d %d %d", &w, &h, &maxv) != 3) { fclose(f); return false; } + fgetc(f); /* single whitespace after the header */ + out = Image(w, h); + size_t got = fread(out.rgb.data(), 1, out.rgb.size(), f); + fclose(f); + return got == out.rgb.size(); +} + +/* ------------------------------------------------------- Texture decoding */ + +/* Decodes one texel of a non-twiddled 16bpp DC texture to RGBA 0-255. + * Only the formats useful for golden tests are supported; everything else + * falls back to opaque white so the modulate path still produces vertex + * colour. Twiddled/compressed/paletted formats should be exercised by the + * byte-exact texture-loading unit tests instead. */ +struct Texel { uint8_t r, g, b, a; }; + +inline Texel decode_texel(const TextureObject* tex, int x, int y) { + Texel out = {255, 255, 255, 255}; + if(!tex || !tex->data) return out; + + const int w = tex->width; + const uint16_t* px = (const uint16_t*) tex->data; + uint16_t v = px[size_t(y) * w + x]; + + switch(tex->internalFormat) { + case GL_RGB565_KOS: { + uint8_t r5 = (v >> 11) & 0x1f, g6 = (v >> 5) & 0x3f, b5 = v & 0x1f; + out.r = (r5 << 3) | (r5 >> 2); + out.g = (g6 << 2) | (g6 >> 4); + out.b = (b5 << 3) | (b5 >> 2); + out.a = 255; + } break; + case GL_ARGB4444_KOS: { + uint8_t a4 = (v >> 12) & 0xf, r4 = (v >> 8) & 0xf, g4 = (v >> 4) & 0xf, b4 = v & 0xf; + out.a = (a4 << 4) | a4; + out.r = (r4 << 4) | r4; + out.g = (g4 << 4) | g4; + out.b = (b4 << 4) | b4; + } break; + case GL_ARGB1555_KOS: { + uint8_t a1 = (v >> 15) & 0x1, r5 = (v >> 10) & 0x1f, g5 = (v >> 5) & 0x1f, b5 = v & 0x1f; + out.a = a1 ? 255 : 0; + out.r = (r5 << 3) | (r5 >> 2); + out.g = (g5 << 3) | (g5 >> 2); + out.b = (b5 << 3) | (b5 >> 2); + } break; + default: + break; /* unsupported -> white */ + } + return out; +} + +/* ------------------------------------------------------------- Rasteriser */ + +struct RVertex { + float x, y; /* screen space (post perspective divide) */ + float a, r, g, b; /* 0-1 */ + float u, v; /* texture coords */ +}; + +inline float edge(const RVertex& a, const RVertex& b, float px, float py) { + return (px - a.x) * (b.y - a.y) - (py - a.y) * (b.x - a.x); +} + +inline int wrap_coord(int c, int n) { + c %= n; + if(c < 0) c += n; + return c; +} + +inline void fill_triangle(Image& img, const RVertex& v0, const RVertex& v1, + const RVertex& v2, const TextureObject* tex, bool blend) { + float area = edge(v0, v1, v2.x, v2.y); + if(fabsf(area) < 1e-6f) return; /* degenerate */ + float inv_area = 1.0f / area; + + int minx = (int) floorf(fminf(v0.x, fminf(v1.x, v2.x))); + int maxx = (int) ceilf (fmaxf(v0.x, fmaxf(v1.x, v2.x))); + int miny = (int) floorf(fminf(v0.y, fminf(v1.y, v2.y))); + int maxy = (int) ceilf (fmaxf(v0.y, fmaxf(v1.y, v2.y))); + + if(minx < 0) minx = 0; + if(miny < 0) miny = 0; + if(maxx > img.w) maxx = img.w; + if(maxy > img.h) maxy = img.h; + + for(int y = miny; y < maxy; ++y) { + float py = y + 0.5f; + for(int x = minx; x < maxx; ++x) { + float px = x + 0.5f; + + float w0 = edge(v1, v2, px, py) * inv_area; + float w1 = edge(v2, v0, px, py) * inv_area; + float w2 = edge(v0, v1, px, py) * inv_area; + + /* Inside test that accepts either winding (no culling, like the + * software backend). */ + bool inside = (w0 >= 0 && w1 >= 0 && w2 >= 0) || + (w0 <= 0 && w1 <= 0 && w2 <= 0); + if(!inside) continue; + + float a = w0 * v0.a + w1 * v1.a + w2 * v2.a; + float r = w0 * v0.r + w1 * v1.r + w2 * v2.r; + float g = w0 * v0.g + w1 * v1.g + w2 * v2.g; + float b = w0 * v0.b + w1 * v1.b + w2 * v2.b; + + if(tex) { + float u = w0 * v0.u + w1 * v1.u + w2 * v2.u; + float v = w0 * v0.v + w1 * v1.v + w2 * v2.v; + int tx = wrap_coord((int) floorf(u * tex->width), tex->width); + int ty = wrap_coord((int) floorf(v * tex->height), tex->height); + Texel t = decode_texel(tex, tx, ty); + r *= t.r / 255.0f; + g *= t.g / 255.0f; + b *= t.b / 255.0f; + a *= t.a / 255.0f; + } + + a = a < 0 ? 0 : (a > 1 ? 1 : a); + r = r < 0 ? 0 : (r > 1 ? 1 : r); + g = g < 0 ? 0 : (g > 1 ? 1 : g); + b = b < 0 ? 0 : (b > 1 ? 1 : b); + + uint8_t sr = (uint8_t) lrintf(r * 255.0f); + uint8_t sg = (uint8_t) lrintf(g * 255.0f); + uint8_t sb = (uint8_t) lrintf(b * 255.0f); + + if(blend) { + uint8_t dr, dg, db; + img.get(x, y, dr, dg, db); + sr = (uint8_t) lrintf(sr * a + dr * (1.0f - a)); + sg = (uint8_t) lrintf(sg * a + dg * (1.0f - a)); + sb = (uint8_t) lrintf(sb * a + db * (1.0f - a)); + } + img.put(x, y, sr, sg, sb); + } + } +} + +inline RVertex to_screen(const Vertex* v) { + /* Reproduce the software backend perspective divide (see + * _glPerspectiveDivideVertex). Vertices in the lists are already viewport + * transformed, so dividing x/y by w gives window coordinates. */ + float inv_w = (v->w != 0.0f) ? 1.0f / v->w : 1.0f; + RVertex out; + out.x = v->xyz[0] * inv_w; + out.y = v->xyz[1] * inv_w; + out.a = v->argb[0]; + out.r = v->argb[1]; + out.g = v->argb[2]; + out.b = v->argb[3]; + out.u = v->uv[0]; + out.v = v->uv[1]; + return out; +} + +/* Rasterise a single poly list. Triangle-strip extraction mirrors + * SceneListFinish() exactly so the same triangles are produced. */ +inline void rasterize_list(Image& img, PolyList* list, const TextureObject* tex, bool blend) { + uint32_t n = aligned_vector_size(&list->vector); + if(n < 4) return; + + uint32_t vidx = 0; + for(uint32_t i = 0; i < n; ++i) { + Vertex* v = (Vertex*) aligned_vector_at(&list->vector, i); + + if((v->flags & GPU_CMD_POLYHDR) == GPU_CMD_POLYHDR) { + vidx = 0; + continue; + } + + if(v->flags == GPU_CMD_VERTEX || v->flags == GPU_CMD_VERTEX_EOL) { + ++vidx; + } + + if(vidx > 2) { + Vertex* a = (Vertex*) aligned_vector_at(&list->vector, i - 2); + Vertex* b = (Vertex*) aligned_vector_at(&list->vector, i - 1); + RVertex r0 = to_screen(a); + RVertex r1 = to_screen(b); + RVertex r2 = to_screen(v); + fill_triangle(img, r0, r1, r2, tex, blend); + } + + if(v->flags == GPU_CMD_VERTEX_EOL) { + vidx = 0; + } + } +} + +/* Convenience: rasterise the opaque, punch-through and transparent lists in + * the order the backend submits them. */ +inline void rasterize_all_lists(Image& img, const TextureObject* tex = NULL) { + rasterize_list(img, &OP_LIST, tex, false); + rasterize_list(img, &PT_LIST, tex, false); + rasterize_list(img, &TR_LIST, tex, true); +} + +/* ------------------------------------------------------------- Comparison */ + +inline std::string golden_path(const std::string& name, const char* suffix = "") { + return std::string(GLDC_GOLDEN_DIR) + "/" + name + suffix + ".ppm"; +} + +/* Compare img against the committed golden. Returns true on match (or when a + * golden was just (re)generated). max_channel_diff is the largest per-channel + * absolute difference tolerated per pixel; max_bad_fraction is the fraction of + * pixels allowed to exceed that. */ +inline bool check(const Image& img, const std::string& name, + int max_channel_diff = 2, double max_bad_fraction = 0.005) { + std::string path = golden_path(name); + + const char* update = getenv("GLDC_UPDATE_GOLDENS"); + Image golden(0, 0); + bool have_golden = read_ppm(path, golden); + + if((update && update[0] == '1') || !have_golden) { + if(!write_ppm(path, img)) { + fprintf(stderr, "golden: failed to write %s\n", path.c_str()); + return false; + } + fprintf(stderr, "golden: generated reference %s\n", path.c_str()); + return true; + } + + if(golden.w != img.w || golden.h != img.h) { + fprintf(stderr, "golden: size mismatch for %s (%dx%d vs %dx%d)\n", + name.c_str(), golden.w, golden.h, img.w, img.h); + write_ppm(golden_path(name, ".actual"), img); + return false; + } + + size_t bad = 0; + int worst = 0; + Image diff(img.w, img.h); + for(size_t i = 0; i < img.rgb.size(); i += 3) { + int dr = abs(int(img.rgb[i + 0]) - int(golden.rgb[i + 0])); + int dg = abs(int(img.rgb[i + 1]) - int(golden.rgb[i + 1])); + int db = abs(int(img.rgb[i + 2]) - int(golden.rgb[i + 2])); + int d = dr > dg ? (dr > db ? dr : db) : (dg > db ? dg : db); + if(d > worst) worst = d; + if(d > max_channel_diff) { + ++bad; + diff.rgb[i + 0] = 255; /* highlight differing pixels in red */ + } + } + + size_t total = img.rgb.size() / 3; + double frac = double(bad) / double(total); + if(frac > max_bad_fraction) { + fprintf(stderr, + "golden: MISMATCH %s — %zu/%zu px differ (%.3f%%), worst channel diff %d\n", + name.c_str(), bad, total, frac * 100.0, worst); + write_ppm(golden_path(name, ".actual"), img); + write_ppm(golden_path(name, ".diff"), diff); + return false; + } + return true; +} + +} // namespace golden diff --git a/tools/test.h b/tools/test.h index 7f1ad89..45ba7f6 100644 --- a/tools/test.h +++ b/tools/test.h @@ -351,7 +351,10 @@ public: output += " "; } - std::cout << output; + /* Flush the name before running so that, if a test hard-crashes + * (e.g. on real Dreamcast hardware), the last line printed + * identifies the offending test. */ + std::cout << output << std::flush; test(); std::cout << "\033[32m" << " OK " << "\033[0m" << std::endl; junit_lines.push_back(" \n"); diff --git a/tools/test_generator.py b/tools/test_generator.py index a3bb43c..5cfb303 100755 --- a/tools/test_generator.py +++ b/tools/test_generator.py @@ -165,7 +165,7 @@ def find_tests(files): # If this subclasses TestCase, or it subclasses any of the already found testcase subclasses # then add it to the list - if "TestCase" in subclass_names or "SimulantTestCase" in subclass_names or any(x[1] in subclasses[i][2] for x in test_case_subclasses): + if "TestCase" in subclass_names or "SimulantTestCase" in subclass_names or "GLTestCase" in subclass_names or any(x[1] in subclasses[i][2] for x in test_case_subclasses): if subclasses[i] not in test_case_subclasses: test_case_subclasses.append(subclasses[i]) From 9f83169b7ab95199681febb704ecba777dcaffa0 Mon Sep 17 00:00:00 2001 From: Luke Benstead Date: Tue, 23 Jun 2026 06:23:52 +0100 Subject: [PATCH 2/2] Fix failing test --- tests/test_texture_formats.h | 5 +++++ tools/gl_test.h | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/tests/test_texture_formats.h b/tests/test_texture_formats.h index 172e0c9..0e9c0e0 100644 --- a/tests/test_texture_formats.h +++ b/tests/test_texture_formats.h @@ -188,6 +188,11 @@ public: /* ------------------------------------------------- glTexSubImage2D */ void test_subimage_updates_only_target_region() { + /* The 2-wide RGB sub-image below is tightly packed (6 bytes/row), so it + * does not satisfy the default GL_UNPACK_ALIGNMENT of 4 (which would pad + * each row to 8 bytes). Tell GL the rows are tightly packed. */ + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + /* Start with an all-black 16x16 RGB565 texture. */ std::vector img(16 * 16 * 3, 0); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 16, 16, 0, GL_RGB, GL_UNSIGNED_BYTE, img.data()); diff --git a/tools/gl_test.h b/tools/gl_test.h index 152fb4b..1ad3a6a 100644 --- a/tools/gl_test.h +++ b/tools/gl_test.h @@ -78,6 +78,10 @@ public: glDepthFunc(GL_LESS); glShadeModel(GL_SMOOTH); + /* Restore the spec default so a test that changes pixel-store state + * doesn't leak into the next (we no longer re-init per test). */ + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glBindTexture(GL_TEXTURE_2D, 0); glMatrixMode(GL_PROJECTION); glLoadIdentity();