Skip to content

Commit 43e2d34

Browse files
thesprockeeheisthecat31claude
committed
feat(texconv): add texture format decoders, sRGB interpolation, and NRGBA output
Add decoders for R8_UNORM (grayscale), R8G8B8A8, B8G8R8A8 (sRGB and Typeless), and R11G11B10_FLOAT packed format with IEEE partial-precision float conversion. Fix RGB565 bit expansion to use proper bit-replication instead of the lossy multiply-divide method. Add sRGB linear-space interpolation for BC1/BC3 decompression (correct per DDS spec). Change output image type from image.RGBA (premultiplied) to image.NRGBA (non-premultiplied), which is correct for texture data with alpha channels. Based on work by heisthecat31 in PR #5, cleaned up and tested. Co-Authored-By: he_is_the_cat <heisthecat31@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent eb6824f commit 43e2d34

2 files changed

Lines changed: 540 additions & 68 deletions

File tree

cmd/texconv/decode_test.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package main
2+
3+
import (
4+
"math"
5+
"testing"
6+
)
7+
8+
func TestDecompressR8(t *testing.T) {
9+
data := []byte{0, 128, 255, 64}
10+
img, err := decompressR8(data, 2, 2)
11+
if err != nil {
12+
t.Fatal(err)
13+
}
14+
// Each grayscale value maps to R=G=B=val, A=255
15+
tests := []struct {
16+
x, y int
17+
r, g, b uint8
18+
}{
19+
{0, 0, 0, 0, 0},
20+
{1, 0, 128, 128, 128},
21+
{0, 1, 255, 255, 255},
22+
{1, 1, 64, 64, 64},
23+
}
24+
for _, tt := range tests {
25+
off := img.PixOffset(tt.x, tt.y)
26+
if img.Pix[off] != tt.r || img.Pix[off+1] != tt.g || img.Pix[off+2] != tt.b || img.Pix[off+3] != 255 {
27+
t.Errorf("pixel(%d,%d) = (%d,%d,%d,%d), want (%d,%d,%d,255)",
28+
tt.x, tt.y, img.Pix[off], img.Pix[off+1], img.Pix[off+2], img.Pix[off+3],
29+
tt.r, tt.g, tt.b)
30+
}
31+
}
32+
}
33+
34+
func TestDecompressR8_Truncated(t *testing.T) {
35+
_, err := decompressR8([]byte{0, 1}, 2, 2) // need 4 bytes, only 2
36+
if err == nil {
37+
t.Fatal("expected error for truncated data")
38+
}
39+
}
40+
41+
func TestDecompressRGBA(t *testing.T) {
42+
// 1x1 pixel: R=10, G=20, B=30, A=40
43+
data := []byte{10, 20, 30, 40}
44+
img, err := decompressRGBA(data, 1, 1)
45+
if err != nil {
46+
t.Fatal(err)
47+
}
48+
off := img.PixOffset(0, 0)
49+
if img.Pix[off] != 10 || img.Pix[off+1] != 20 || img.Pix[off+2] != 30 || img.Pix[off+3] != 40 {
50+
t.Errorf("pixel = (%d,%d,%d,%d), want (10,20,30,40)",
51+
img.Pix[off], img.Pix[off+1], img.Pix[off+2], img.Pix[off+3])
52+
}
53+
}
54+
55+
func TestDecompressRGBA_Truncated(t *testing.T) {
56+
_, err := decompressRGBA([]byte{1, 2, 3}, 1, 1)
57+
if err == nil {
58+
t.Fatal("expected error for truncated data")
59+
}
60+
}
61+
62+
func TestDecompressBGRA(t *testing.T) {
63+
// 1x1 BGRA pixel: B=10, G=20, R=30, A=40 -> should output R=30, G=20, B=10, A=40
64+
data := []byte{10, 20, 30, 40}
65+
img, err := decompressBGRA(data, 1, 1)
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
off := img.PixOffset(0, 0)
70+
if img.Pix[off] != 30 || img.Pix[off+1] != 20 || img.Pix[off+2] != 10 || img.Pix[off+3] != 40 {
71+
t.Errorf("pixel = (%d,%d,%d,%d), want (30,20,10,40)",
72+
img.Pix[off], img.Pix[off+1], img.Pix[off+2], img.Pix[off+3])
73+
}
74+
}
75+
76+
func TestDecompressBGRA_Truncated(t *testing.T) {
77+
_, err := decompressBGRA([]byte{1, 2, 3}, 1, 1)
78+
if err == nil {
79+
t.Fatal("expected error for truncated data")
80+
}
81+
}
82+
83+
func TestF11ToF32(t *testing.T) {
84+
tests := []struct {
85+
name string
86+
in uint32
87+
want float32
88+
}{
89+
{"zero", 0, 0.0},
90+
{"one", 0x3C0, 1.0}, // exponent=15, mantissa=0 -> 2^0 * 1.0 = 1.0
91+
{"max_exponent", 0x7C0, float32(math.Inf(1))}, // exponent=31, mantissa=0 -> +Inf
92+
}
93+
for _, tt := range tests {
94+
t.Run(tt.name, func(t *testing.T) {
95+
got := f11ToF32(tt.in)
96+
if math.IsInf(float64(tt.want), 1) {
97+
if !math.IsInf(float64(got), 1) {
98+
t.Errorf("f11ToF32(0x%x) = %f, want +Inf", tt.in, got)
99+
}
100+
} else if math.Abs(float64(got-tt.want)) > 0.001 {
101+
t.Errorf("f11ToF32(0x%x) = %f, want %f", tt.in, got, tt.want)
102+
}
103+
})
104+
}
105+
}
106+
107+
func TestF10ToF32(t *testing.T) {
108+
tests := []struct {
109+
name string
110+
in uint32
111+
want float32
112+
}{
113+
{"zero", 0, 0.0},
114+
{"one", 0x1E0, 1.0}, // exponent=15, mantissa=0 -> 2^0 * 1.0 = 1.0
115+
{"max_exponent", 0x3E0, float32(math.Inf(1))}, // exponent=31, mantissa=0 -> +Inf
116+
}
117+
for _, tt := range tests {
118+
t.Run(tt.name, func(t *testing.T) {
119+
got := f10ToF32(tt.in)
120+
if math.Abs(float64(got-tt.want)) > 0.001 {
121+
t.Errorf("f10ToF32(0x%x) = %f, want %f", tt.in, got, tt.want)
122+
}
123+
})
124+
}
125+
}
126+
127+
func TestSrgbRoundTrip(t *testing.T) {
128+
// sRGB -> linear -> sRGB should be identity (within rounding)
129+
for v := 0; v <= 255; v++ {
130+
linear := srgbToLinear(uint8(v))
131+
back := linearToSrgb(linear)
132+
diff := int(back) - v
133+
if diff < -1 || diff > 1 {
134+
t.Errorf("sRGB round-trip failed for %d: got %d (diff %d)", v, back, diff)
135+
}
136+
}
137+
}
138+
139+
func TestSrgbToLinear_Boundaries(t *testing.T) {
140+
// 0 -> 0.0
141+
if got := srgbToLinear(0); got != 0.0 {
142+
t.Errorf("srgbToLinear(0) = %f, want 0.0", got)
143+
}
144+
// 255 -> ~1.0
145+
if got := srgbToLinear(255); math.Abs(float64(got)-1.0) > 0.001 {
146+
t.Errorf("srgbToLinear(255) = %f, want ~1.0", got)
147+
}
148+
}
149+
150+
func TestLinearToSrgb_Boundaries(t *testing.T) {
151+
if got := linearToSrgb(0.0); got != 0 {
152+
t.Errorf("linearToSrgb(0.0) = %d, want 0", got)
153+
}
154+
if got := linearToSrgb(1.0); got < 254 {
155+
t.Errorf("linearToSrgb(1.0) = %d, want 254 or 255", got)
156+
}
157+
}
158+
159+
func TestDecompressR11G11B10Float(t *testing.T) {
160+
// Pack 1.0, 1.0, 1.0 into R11G11B10 format
161+
// R=1.0 as f11: exponent=15(0xF), mantissa=0 -> 0x3C0
162+
// G=1.0 as f11: same -> 0x3C0 << 11
163+
// B=1.0 as f10: exponent=15(0xF), mantissa=0 -> 0x1E0 << 22
164+
r11 := uint32(0x3C0)
165+
g11 := uint32(0x3C0) << 11
166+
b10 := uint32(0x1E0) << 22
167+
packed := r11 | g11 | b10
168+
169+
data := []byte{
170+
byte(packed), byte(packed >> 8), byte(packed >> 16), byte(packed >> 24),
171+
}
172+
173+
img, err := decompressR11G11B10Float(data, 1, 1)
174+
if err != nil {
175+
t.Fatal(err)
176+
}
177+
178+
off := img.PixOffset(0, 0)
179+
// 1.0 * 255 = 255
180+
if img.Pix[off] != 255 || img.Pix[off+1] != 255 || img.Pix[off+2] != 255 {
181+
t.Errorf("pixel = (%d,%d,%d), want (255,255,255)",
182+
img.Pix[off], img.Pix[off+1], img.Pix[off+2])
183+
}
184+
if img.Pix[off+3] != 255 {
185+
t.Errorf("alpha = %d, want 255", img.Pix[off+3])
186+
}
187+
}
188+
189+
func TestDecompressR11G11B10Float_Truncated(t *testing.T) {
190+
_, err := decompressR11G11B10Float([]byte{1, 2, 3}, 1, 1)
191+
if err == nil {
192+
t.Fatal("expected error for truncated data")
193+
}
194+
}
195+
196+
func TestDecompressBC1_4x4Block(t *testing.T) {
197+
// Minimal valid BC1 block: 8 bytes
198+
// c0=0xFFFF (white), c1=0x0000 (black), all indices=0 (use c0)
199+
block := []byte{
200+
0xFF, 0xFF, // c0 = white (RGB565)
201+
0x00, 0x00, // c1 = black
202+
0x00, 0x00, 0x00, 0x00, // all indices = 0 (use c0)
203+
}
204+
img, err := decompressBC1(block, 4, 4, false)
205+
if err != nil {
206+
t.Fatal(err)
207+
}
208+
// All pixels should be white (255,255,255,255)
209+
off := img.PixOffset(0, 0)
210+
if img.Pix[off] != 255 || img.Pix[off+1] != 255 || img.Pix[off+2] != 255 {
211+
t.Errorf("pixel(0,0) = (%d,%d,%d), want (255,255,255)",
212+
img.Pix[off], img.Pix[off+1], img.Pix[off+2])
213+
}
214+
}
215+
216+
func TestDecompressBC1_NRGBA(t *testing.T) {
217+
// Verify return type is NRGBA, not RGBA
218+
block := make([]byte, 8)
219+
img, err := decompressBC1(block, 4, 4, false)
220+
if err != nil {
221+
t.Fatal(err)
222+
}
223+
// Type assertion — img should be *image.NRGBA
224+
_ = img.Stride // only NRGBA has Stride; compilation proves the type
225+
}

0 commit comments

Comments
 (0)