@forgeax/engine-codec 0.0.0-dev.8d955ade1c79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +261 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/basis-encode-fast-preset.unit.test.d.ts +2 -0
  5. package/dist/__tests__/basis-encode-fast-preset.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/basis-encode-lifecycle.unit.test.d.ts +2 -0
  7. package/dist/__tests__/basis-encode-lifecycle.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/basis-transcoder.unit.test.d.ts +2 -0
  9. package/dist/__tests__/basis-transcoder.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/block-format.unit.test.d.ts +2 -0
  11. package/dist/__tests__/block-format.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/byte-identical-falsification.test.d.ts +2 -0
  13. package/dist/__tests__/byte-identical-falsification.test.d.ts.map +1 -0
  14. package/dist/__tests__/codec-errors.test-d.d.ts +2 -0
  15. package/dist/__tests__/codec-errors.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/codec-errors.unit.test.d.ts +2 -0
  17. package/dist/__tests__/codec-errors.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/compression-field.unit.test.d.ts +19 -0
  19. package/dist/__tests__/compression-field.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/encode-pixel-limit.unit.test.d.ts +2 -0
  21. package/dist/__tests__/encode-pixel-limit.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/encode-roundtrip.unit.test.d.ts +2 -0
  23. package/dist/__tests__/encode-roundtrip.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts +2 -0
  25. package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/ktx2-errors.test.d.ts +2 -0
  27. package/dist/__tests__/ktx2-errors.test.d.ts.map +1 -0
  28. package/dist/__tests__/ktx2-parse-scheme0.test.d.ts +2 -0
  29. package/dist/__tests__/ktx2-parse-scheme0.test.d.ts.map +1 -0
  30. package/dist/__tests__/ktx2-parse-scheme2.test.d.ts +2 -0
  31. package/dist/__tests__/ktx2-parse-scheme2.test.d.ts.map +1 -0
  32. package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts +2 -0
  33. package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/ktx2-to-texture.integration.test.d.ts +2 -0
  35. package/dist/__tests__/ktx2-to-texture.integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/transcode-select-target.unit.test.d.ts +2 -0
  37. package/dist/__tests__/transcode-select-target.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/wasm-init-smoke.test.d.ts +2 -0
  39. package/dist/__tests__/wasm-init-smoke.test.d.ts.map +1 -0
  40. package/dist/__tests__/zstd-deterministic.unit.test.d.ts +2 -0
  41. package/dist/__tests__/zstd-deterministic.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts +2 -0
  43. package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/zstd-roundtrip.unit.test.d.ts +2 -0
  45. package/dist/__tests__/zstd-roundtrip.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/zstd-singleton.unit.test.d.ts +2 -0
  47. package/dist/__tests__/zstd-singleton.unit.test.d.ts.map +1 -0
  48. package/dist/basis-transcoder.d.ts +86 -0
  49. package/dist/basis-transcoder.d.ts.map +1 -0
  50. package/dist/block-format.d.ts +41 -0
  51. package/dist/block-format.d.ts.map +1 -0
  52. package/dist/encode/basis-encode.d.ts +68 -0
  53. package/dist/encode/basis-encode.d.ts.map +1 -0
  54. package/dist/encode/index.d.ts +10 -0
  55. package/dist/encode/index.d.ts.map +1 -0
  56. package/dist/encode/index.mjs +207 -0
  57. package/dist/encode/index.mjs.map +1 -0
  58. package/dist/encode-impl.d.ts +30 -0
  59. package/dist/encode-impl.d.ts.map +1 -0
  60. package/dist/errors.d.ts +61 -0
  61. package/dist/errors.d.ts.map +1 -0
  62. package/dist/index.d.ts +17 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.mjs +610 -0
  65. package/dist/index.mjs.map +1 -0
  66. package/dist/ktx2.d.ts +111 -0
  67. package/dist/ktx2.d.ts.map +1 -0
  68. package/dist/transcode.d.ts +55 -0
  69. package/dist/transcode.d.ts.map +1 -0
  70. package/dist/wasm/basis-types.d.ts +179 -0
  71. package/dist/wasm/basis-types.d.ts.map +1 -0
  72. package/dist/zstd.d.ts +28 -0
  73. package/dist/zstd.d.ts.map +1 -0
  74. package/package.json +71 -0
  75. package/pkg/basis_transcoder.mjs +2 -0
  76. package/pkg/basis_transcoder.wasm +0 -0
  77. package/pkg/encode/basis_encoder.mjs +2 -0
  78. package/pkg/encode/basis_encoder.wasm +0 -0
  79. package/scripts/build-wasm.mjs +370 -0
  80. package/scripts/content-key.mjs +85 -0
  81. package/scripts/ensure-wasm.mjs +38 -0
  82. package/scripts/fetch-basis.mjs +150 -0
  83. package/scripts/fetch-wasm.mjs +97 -0
  84. package/src/__tests__/basis-encode-fast-preset.unit.test.ts +204 -0
  85. package/src/__tests__/basis-encode-lifecycle.unit.test.ts +249 -0
  86. package/src/__tests__/basis-transcoder.unit.test.ts +151 -0
  87. package/src/__tests__/block-format.unit.test.ts +215 -0
  88. package/src/__tests__/byte-identical-falsification.test.ts +164 -0
  89. package/src/__tests__/codec-errors.test-d.ts +67 -0
  90. package/src/__tests__/codec-errors.unit.test.ts +128 -0
  91. package/src/__tests__/compression-field.unit.test.ts +245 -0
  92. package/src/__tests__/encode-pixel-limit.unit.test.ts +117 -0
  93. package/src/__tests__/encode-roundtrip.unit.test.ts +239 -0
  94. package/src/__tests__/ktx2-basis-capability-matrix.unit.test.ts +241 -0
  95. package/src/__tests__/ktx2-errors.test.ts +239 -0
  96. package/src/__tests__/ktx2-parse-scheme0.test.ts +263 -0
  97. package/src/__tests__/ktx2-parse-scheme2.test.ts +220 -0
  98. package/src/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.ts +178 -0
  99. package/src/__tests__/ktx2-to-texture.integration.test.ts +384 -0
  100. package/src/__tests__/transcode-select-target.unit.test.ts +167 -0
  101. package/src/__tests__/wasm-init-smoke.test.ts +102 -0
  102. package/src/__tests__/zstd-deterministic.unit.test.ts +49 -0
  103. package/src/__tests__/zstd-encode-singleton.unit.test.ts +63 -0
  104. package/src/__tests__/zstd-roundtrip.unit.test.ts +87 -0
  105. package/src/__tests__/zstd-singleton.unit.test.ts +105 -0
  106. package/src/basis-transcoder.ts +315 -0
  107. package/src/block-format.ts +128 -0
  108. package/src/encode/basis-encode.ts +264 -0
  109. package/src/encode/index.ts +19 -0
  110. package/src/encode-impl.ts +103 -0
  111. package/src/errors.ts +79 -0
  112. package/src/index.ts +38 -0
  113. package/src/ktx2.ts +469 -0
  114. package/src/transcode.ts +99 -0
  115. package/src/wasm/basis-types.ts +220 -0
  116. package/src/zstd.ts +85 -0
@@ -0,0 +1,263 @@
1
+ import { ktx2LevelsToRGBA, parseKtx2 } from '@forgeax/engine-codec';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ /**
5
+ * KTX2 2.0 identifier magic (12 bytes).
6
+ * spec section 1: «KTX 20»\r\n\x1A\n
7
+ */
8
+ const KTX2_IDENTIFIER = new Uint8Array([
9
+ 0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
10
+ ]);
11
+
12
+ /**
13
+ * Write a u32 little-endian into `out` at `offset`.
14
+ */
15
+ function writeU32(out: Uint8Array, offset: number, value: number): void {
16
+ new DataView(out.buffer, out.byteOffset + offset, 4).setUint32(0, value, true);
17
+ }
18
+
19
+ /**
20
+ * Write a u64 little-endian into `out` at `offset`.
21
+ * Uses two u32 writes because bitwise ops truncate to 32 bits.
22
+ */
23
+ function writeU64(out: Uint8Array, offset: number, value: number): void {
24
+ const lo = value >>> 0;
25
+ const hi = (value - lo) / 0x100000000;
26
+ new DataView(out.buffer, out.byteOffset + offset, 8).setUint32(0, lo, true);
27
+ new DataView(out.buffer, out.byteOffset + offset + 4, 4).setUint32(0, hi, true);
28
+ }
29
+
30
+ /**
31
+ * Synthesize a minimal KTX2 file with scheme=0 (uncompressed RGBA8).
32
+ *
33
+ * Layout (per spec sections 1-7):
34
+ * [12] identifier
35
+ * [ 4] vkFormat = VK_FORMAT_R8G8B8A8_UNORM (37)
36
+ * [ 4] typeSize = 4 (4 bytes per pixel)
37
+ * [ 4] pixelWidth = 64
38
+ * [ 4] pixelHeight = 64
39
+ * [ 4] pixelDepth = 0
40
+ * [ 4] layerCount = 0
41
+ * [ 4] faceCount = 1
42
+ * [ 4] levelCount = 1
43
+ * [ 4] supercompressionScheme = 0
44
+ * [ 4] dfdByteOffset
45
+ * [ 4] dfdByteLength
46
+ * [ 4] kvdByteOffset
47
+ * [ 4] kvdByteLength
48
+ * [ 8] sgdByteOffset = 0
49
+ * [ 8] sgdByteLength = 0
50
+ * [24] levelIndex[0] { byteOffset, byteLength, uncompressedByteLength }
51
+ * ... DFD block
52
+ * ... KV data (empty)
53
+ * ... level 0 RGBA pixels
54
+ *
55
+ * Returns the raw bytes of the KTX2 file.
56
+ */
57
+ function buildScheme0Fixture(): Uint8Array {
58
+ const W = 64;
59
+ const H = 64;
60
+ const pixelBytes = W * H * 4; // RGBA8 = 16384 bytes per level
61
+
62
+ // DFD descriptor: minimal RGBA8 descriptor
63
+ // descriptorBlockSize = 24 (header) + 16*1 (sample) = 40 bytes
64
+ // total dfd = 4 (dfdTotalSize) + 40 (descriptor block) = 44
65
+ const dfdBlockSize = 40; // 24 header + 16 sample
66
+ const dfdTotalSize = 4 + dfdBlockSize; // 44
67
+
68
+ // Offsets
69
+ const headerEnd = 12 + 9 * 4 + 4 * 4 + 2 * 8; // 12 + 36 + 16 + 16 = 80
70
+ const levelIndexEnd = headerEnd + 1 * 24; // 80 + 24 = 104
71
+ const dfdByteOffset = levelIndexEnd; // 104
72
+ const dfdByteLength = dfdTotalSize; // 44
73
+ const kvdByteOffset = dfdByteOffset + dfdByteLength; // 148
74
+ const kvdByteLength = 0; // no KV data
75
+ const levelPayloadOffset = kvdByteOffset + kvdByteLength; // 148
76
+ const levelPayloadSize = pixelBytes; // 16384
77
+ const totalSize = levelPayloadOffset + levelPayloadSize; // 16532
78
+
79
+ const buf = new Uint8Array(totalSize);
80
+
81
+ // --- identifier (offset 0, 12 bytes) ---
82
+ buf.set(KTX2_IDENTIFIER, 0);
83
+
84
+ // --- header (offset 12) ---
85
+ // vkFormat = VK_FORMAT_R8G8B8A8_UNORM = 37
86
+ writeU32(buf, 12, 37);
87
+ // typeSize = 4
88
+ writeU32(buf, 16, 4);
89
+ // pixelWidth = 64
90
+ writeU32(buf, 20, W);
91
+ // pixelHeight = 64
92
+ writeU32(buf, 24, H);
93
+ // pixelDepth = 0
94
+ writeU32(buf, 28, 0);
95
+ // layerCount = 0
96
+ writeU32(buf, 32, 0);
97
+ // faceCount = 1
98
+ writeU32(buf, 36, 1);
99
+ // levelCount = 1
100
+ writeU32(buf, 40, 1);
101
+ // supercompressionScheme = 0
102
+ writeU32(buf, 44, 0);
103
+
104
+ // --- index (offset 48) ---
105
+ writeU32(buf, 48, dfdByteOffset);
106
+ writeU32(buf, 52, dfdByteLength);
107
+ writeU32(buf, 56, kvdByteOffset);
108
+ writeU32(buf, 60, kvdByteLength);
109
+ writeU64(buf, 64, 0); // sgdByteOffset = 0
110
+ writeU64(buf, 72, 0); // sgdByteLength = 0
111
+
112
+ // --- level index (offset 80) ---
113
+ writeU64(buf, 80, levelPayloadOffset);
114
+ writeU64(buf, 88, levelPayloadSize);
115
+ writeU64(buf, 96, levelPayloadSize); // uncompressedByteLength == byteLength for scheme=0
116
+
117
+ // --- DFD (offset 104) ---
118
+ // dfdTotalSize (u32)
119
+ writeU32(buf, 104, dfdTotalSize);
120
+ // descriptor block word 0: vendorId(17)=0, descriptorType(15)=0
121
+ writeU32(buf, 108, 0);
122
+ // descriptor block word 1: versionNumber(16)=2, descriptorBlockSize(16)=40
123
+ writeU32(buf, 112, (40 << 16) | 2);
124
+ // descriptor block word 2: colorModel(8)=1 (RGBSDA), colorPrimaries(8)=1 (BT709/SRGB), transferFunction(8)=1 (LINEAR), flags(8)=0
125
+ writeU32(buf, 116, (1 << 16) | (1 << 8) | 1);
126
+ // descriptor block word 3: texelBlockDimension[0..3]=0 (uncompressed, block=1x1x1)
127
+ writeU32(buf, 120, 0);
128
+ // descriptor block words 4-5: bytesPlane[0..7] = 4 for plane 0 (RGBA8 = 4 bytes), rest 0
129
+ writeU32(buf, 124, 4); // bytesPlane0=4
130
+ writeU32(buf, 128, 0); // bytesPlane1..7 = 0
131
+ // sample 0 (16 bytes): R8G8B8A8
132
+ // word 0: bitOffset(8)=0, bitLength(12)=7 (actual 8 - 1), channelType(8)=0 (RED/RGB), qualifiers(4)=0
133
+ writeU32(buf, 132, 7 << 12); // bitLength=7, channelType=0 (RED — means RGBA combined sample)
134
+ // word 1: samplePosition[0..3] = 0
135
+ writeU32(buf, 136, 0);
136
+ // word 2: sampleLower = 0
137
+ writeU32(buf, 140, 0);
138
+ // word 3: sampleUpper = UINT32_MAX for UNORM
139
+ writeU32(buf, 144, 0xffffffff);
140
+
141
+ // --- KV data: empty (kvdByteLength=0) ---
142
+
143
+ // --- level 0 pixel data (offset 148) ---
144
+ // Fill with known pattern: pixel(x,y) = (r,g,b,a) based on position
145
+ for (let y = 0; y < H; y++) {
146
+ for (let x = 0; x < W; x++) {
147
+ const px = levelPayloadOffset + (y * W + x) * 4;
148
+ buf[px] = (x * 3 + y * 7) & 0xff; // R
149
+ buf[px + 1] = (x * 5 + y * 11) & 0xff; // G
150
+ buf[px + 2] = (x * 13 + y * 17) & 0xff; // B
151
+ buf[px + 3] = 255; // A
152
+ }
153
+ }
154
+
155
+ return buf;
156
+ }
157
+
158
+ const scheme0Fixture = buildScheme0Fixture();
159
+
160
+ describe('KTX2 parse — scheme=0 uncompressed (w21)', () => {
161
+ it('parses header fields from scheme=0 fixture', async () => {
162
+ const result = await parseKtx2(scheme0Fixture);
163
+ expect(result.ok).toBe(true);
164
+ if (!result.ok) throw new Error('parse failed');
165
+
166
+ const h = result.value.header;
167
+ expect(h.vkFormat).toBe(37); // VK_FORMAT_R8G8B8A8_UNORM
168
+ expect(h.typeSize).toBe(4);
169
+ expect(h.pixelWidth).toBe(64);
170
+ expect(h.pixelHeight).toBe(64);
171
+ expect(h.pixelDepth).toBe(0);
172
+ expect(h.layerCount).toBe(0);
173
+ expect(h.faceCount).toBe(1);
174
+ expect(h.levelCount).toBe(1);
175
+ expect(h.supercompressionScheme).toBe(0);
176
+ });
177
+
178
+ it('parses index fields (DFD/KVD/SGD offsets)', async () => {
179
+ const result = await parseKtx2(scheme0Fixture);
180
+ expect(result.ok).toBe(true);
181
+ if (!result.ok) throw new Error('parse failed');
182
+
183
+ const idx = result.value.index;
184
+ // dfdByteOffset should be after header + level index = 104
185
+ expect(idx.dfdByteOffset).toBe(104);
186
+ expect(idx.dfdByteLength).toBe(44);
187
+ expect(idx.kvdByteOffset).toBe(148);
188
+ expect(idx.kvdByteLength).toBe(0);
189
+ expect(idx.sgdByteOffset).toBe(0);
190
+ expect(idx.sgdByteLength).toBe(0);
191
+ });
192
+
193
+ it('parses level index entries', async () => {
194
+ const result = await parseKtx2(scheme0Fixture);
195
+ expect(result.ok).toBe(true);
196
+ if (!result.ok) throw new Error('parse failed');
197
+
198
+ expect(result.value.levelIndex.length).toBe(1);
199
+ // biome-ignore lint/style/noNonNullAssertion: previous assertion ensures levelIndex[0] exists
200
+ const l0 = result.value.levelIndex[0]!;
201
+ expect(l0.byteOffset).toBe(148);
202
+ expect(l0.byteLength).toBe(16384); // 64*64*4
203
+ expect(l0.uncompressedByteLength).toBe(16384); // scheme=0: same as byteLength
204
+ });
205
+
206
+ it('parses DFD descriptor block', async () => {
207
+ const result = await parseKtx2(scheme0Fixture);
208
+ expect(result.ok).toBe(true);
209
+ if (!result.ok) throw new Error('parse failed');
210
+
211
+ const dfd = result.value.dfd;
212
+ expect(dfd).toBeDefined();
213
+ expect(dfd?.totalSize).toBe(44);
214
+ expect(dfd?.colorModel).toBe(1); // RGBSDA
215
+ expect(dfd?.colorPrimaries).toBe(1); // BT709/SRGB
216
+ expect(dfd?.transferFunction).toBe(1); // LINEAR
217
+ expect(dfd?.samples.length).toBe(1);
218
+ });
219
+
220
+ it('ktx2LevelsToRGBA returns correct pixel bytes for scheme=0', async () => {
221
+ const parsed = await parseKtx2(scheme0Fixture);
222
+ expect(parsed.ok).toBe(true);
223
+ if (!parsed.ok) throw new Error('parse failed');
224
+
225
+ const rgba = await ktx2LevelsToRGBA(parsed.value, 0);
226
+ expect(rgba.ok).toBe(true);
227
+ if (!rgba.ok) throw new Error('levelsToRGBA failed');
228
+
229
+ const pixels = rgba.value;
230
+ expect(pixels.length).toBe(16384);
231
+
232
+ // Verify pixel at (0,0): R = (0*3 + 0*7) & 0xff = 0, G = 0, B = 0, A = 255
233
+ expect(pixels[0]).toBe(0);
234
+ expect(pixels[1]).toBe(0);
235
+ expect(pixels[2]).toBe(0);
236
+ expect(pixels[3]).toBe(255);
237
+
238
+ // Verify pixel at (63,63): R = (63*3 + 63*7) & 0xff = (189+441) & 0xff = 630 & 0xff = 118
239
+ const lastPx = 16384 - 4;
240
+ expect(pixels[lastPx]).toBe((63 * 3 + 63 * 7) & 0xff); // 118
241
+ expect(pixels[lastPx + 1]).toBe((63 * 5 + 63 * 11) & 0xff); // 252
242
+ expect(pixels[lastPx + 2]).toBe((63 * 13 + 63 * 17) & 0xff); // 118
243
+ expect(pixels[lastPx + 3]).toBe(255);
244
+ });
245
+
246
+ it('parses empty KV metadata', async () => {
247
+ const result = await parseKtx2(scheme0Fixture);
248
+ expect(result.ok).toBe(true);
249
+ if (!result.ok) throw new Error('parse failed');
250
+
251
+ expect(result.value.kvEntries).toEqual([]);
252
+ });
253
+
254
+ it('SG descriptor is empty for scheme=0', async () => {
255
+ const result = await parseKtx2(scheme0Fixture);
256
+ expect(result.ok).toBe(true);
257
+ if (!result.ok) throw new Error('parse failed');
258
+
259
+ expect(result.value.sgd).toBeNull();
260
+ // sgdByteLength is 0 for scheme=0
261
+ expect(result.value.index.sgdByteLength).toBe(0);
262
+ });
263
+ });
@@ -0,0 +1,220 @@
1
+ import { ktx2LevelsToRGBA, parseKtx2 } from '@forgeax/engine-codec';
2
+ import { compressZstd } from '@forgeax/engine-codec/encode';
3
+ import { beforeAll, describe, expect, it } from 'vitest';
4
+
5
+ /**
6
+ * KTX2 2.0 identifier magic (12 bytes).
7
+ */
8
+ const KTX2_IDENTIFIER = new Uint8Array([
9
+ 0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
10
+ ]);
11
+
12
+ function writeU32(out: Uint8Array, offset: number, value: number): void {
13
+ new DataView(out.buffer, out.byteOffset + offset, 4).setUint32(0, value, true);
14
+ }
15
+
16
+ function writeU64(out: Uint8Array, offset: number, value: number): void {
17
+ const lo = value >>> 0;
18
+ const hi = (value - lo) / 0x100000000;
19
+ new DataView(out.buffer, out.byteOffset + offset, 8).setUint32(0, lo, true);
20
+ new DataView(out.buffer, out.byteOffset + offset + 4, 4).setUint32(0, hi, true);
21
+ }
22
+
23
+ /**
24
+ * Build a scheme=2 KTX2 fixture: same RGBA content as scheme=0,
25
+ * but level payload is zstd-compressed.
26
+ */
27
+ async function buildScheme2Fixture(): Promise<Uint8Array> {
28
+ const W = 64;
29
+ const H = 64;
30
+ const pixelBytes = W * H * 4; // 16384
31
+
32
+ // Generate raw RGBA pixels (same pattern as scheme=0)
33
+ const rawPixels = new Uint8Array(pixelBytes);
34
+ for (let y = 0; y < H; y++) {
35
+ for (let x = 0; x < W; x++) {
36
+ const px = (y * W + x) * 4;
37
+ rawPixels[px] = (x * 3 + y * 7) & 0xff;
38
+ rawPixels[px + 1] = (x * 5 + y * 11) & 0xff;
39
+ rawPixels[px + 2] = (x * 13 + y * 17) & 0xff;
40
+ rawPixels[px + 3] = 255;
41
+ }
42
+ }
43
+
44
+ // Compress with zstd
45
+ const compResult = await compressZstd(rawPixels);
46
+ if (!compResult.ok) throw new Error(`compressZstd failed: ${compResult.error.code}`);
47
+ const compressedPayload = compResult.value;
48
+
49
+ // DFD descriptor: minimal RGBA8
50
+ const dfdBlockSize = 40;
51
+ const dfdTotalSize = 4 + dfdBlockSize; // 44
52
+
53
+ // Level payload offset is right after index + level index + DFD + empty KV + empty SGD
54
+ // Header: 80 bytes, levelIndex (1 entry): 24 bytes, total: 104
55
+ // DFD at 104, length 44, ends at 148
56
+ // KV: empty, so kvdByteOffset=148, kvdByteLength=0
57
+ // SGD: empty (scheme=2), so sgdByteOffset=0, sgdByteLength=0 (per spec §7: Zstandard has no global data)
58
+ // Level payload at 148 (mipPadding not needed for scheme!=0 per spec §8.1)
59
+ const levelPayloadOffset = 148;
60
+ const totalSize = levelPayloadOffset + compressedPayload.length;
61
+
62
+ const buf = new Uint8Array(totalSize);
63
+
64
+ // identifier
65
+ buf.set(KTX2_IDENTIFIER, 0);
66
+
67
+ // header
68
+ writeU32(buf, 12, 37); // vkFormat = VK_FORMAT_R8G8B8A8_UNORM
69
+ writeU32(buf, 16, 4); // typeSize
70
+ writeU32(buf, 20, W);
71
+ writeU32(buf, 24, H);
72
+ writeU32(buf, 28, 0);
73
+ writeU32(buf, 32, 0);
74
+ writeU32(buf, 36, 1);
75
+ writeU32(buf, 40, 1);
76
+ writeU32(buf, 44, 2); // supercompressionScheme = 2 (Zstandard)
77
+
78
+ // index
79
+ writeU32(buf, 48, 104); // dfdByteOffset
80
+ writeU32(buf, 52, dfdTotalSize);
81
+ writeU32(buf, 56, 148); // kvdByteOffset (after DFD)
82
+ writeU32(buf, 60, 0); // kvdByteLength = 0
83
+ writeU64(buf, 64, 0); // sgdByteOffset = 0
84
+ writeU64(buf, 72, 0); // sgdByteLength = 0 (spec §7: Zstandard has no global data)
85
+
86
+ // level index
87
+ writeU64(buf, 80, levelPayloadOffset);
88
+ writeU64(buf, 88, compressedPayload.length);
89
+ writeU64(buf, 96, pixelBytes); // uncompressedByteLength
90
+
91
+ // DFD
92
+ writeU32(buf, 104, dfdTotalSize);
93
+ writeU32(buf, 108, 0);
94
+ writeU32(buf, 112, (40 << 16) | 2);
95
+ writeU32(buf, 116, (1 << 16) | (1 << 8) | 1);
96
+ writeU32(buf, 120, 0);
97
+ writeU32(buf, 124, 4);
98
+ writeU32(buf, 128, 0);
99
+ writeU32(buf, 132, 7 << 12);
100
+ writeU32(buf, 136, 0);
101
+ writeU32(buf, 140, 0);
102
+ writeU32(buf, 144, 0xffffffff);
103
+
104
+ // level payload (zstd-compressed)
105
+ buf.set(compressedPayload, levelPayloadOffset);
106
+
107
+ return buf;
108
+ }
109
+
110
+ let scheme2Fixture: Uint8Array;
111
+ let expectedRawPixels: Uint8Array;
112
+
113
+ describe('KTX2 parse — scheme=2 zstd supercompression (w22)', () => {
114
+ beforeAll(async () => {
115
+ scheme2Fixture = await buildScheme2Fixture();
116
+
117
+ // Build expected raw pixels for comparison
118
+ const W = 64;
119
+ const H = 64;
120
+ expectedRawPixels = new Uint8Array(W * H * 4);
121
+ for (let y = 0; y < H; y++) {
122
+ for (let x = 0; x < W; x++) {
123
+ const px = (y * W + x) * 4;
124
+ expectedRawPixels[px] = (x * 3 + y * 7) & 0xff;
125
+ expectedRawPixels[px + 1] = (x * 5 + y * 11) & 0xff;
126
+ expectedRawPixels[px + 2] = (x * 13 + y * 17) & 0xff;
127
+ expectedRawPixels[px + 3] = 255;
128
+ }
129
+ }
130
+ });
131
+
132
+ it('parses header with supercompressionScheme=2', async () => {
133
+ const result = await parseKtx2(scheme2Fixture);
134
+ expect(result.ok).toBe(true);
135
+ if (!result.ok) throw new Error('parse failed');
136
+
137
+ expect(result.value.header.supercompressionScheme).toBe(2);
138
+ expect(result.value.header.pixelWidth).toBe(64);
139
+ expect(result.value.header.pixelHeight).toBe(64);
140
+ expect(result.value.header.levelCount).toBe(1);
141
+ });
142
+
143
+ it('sgdByteLength is 0 (Zstandard has no global data per spec §7)', async () => {
144
+ const result = await parseKtx2(scheme2Fixture);
145
+ expect(result.ok).toBe(true);
146
+ if (!result.ok) throw new Error('parse failed');
147
+
148
+ expect(result.value.index.sgdByteLength).toBe(0);
149
+ expect(result.value.sgd).toBeNull();
150
+ });
151
+
152
+ it('level index has compressed byteLength < uncompressedByteLength', async () => {
153
+ const result = await parseKtx2(scheme2Fixture);
154
+ expect(result.ok).toBe(true);
155
+ if (!result.ok) throw new Error('parse failed');
156
+
157
+ // biome-ignore lint/style/noNonNullAssertion: previous assertion ensures levelIndex[0] exists
158
+ const l0 = result.value.levelIndex[0]!;
159
+ expect(l0.byteLength).toBeGreaterThan(0);
160
+ expect(l0.byteLength).toBeLessThan(l0.uncompressedByteLength);
161
+ expect(l0.uncompressedByteLength).toBe(16384);
162
+ });
163
+
164
+ it('ktx2LevelsToRGBA decompresses scheme=2 and returns correct pixels', async () => {
165
+ const parsed = await parseKtx2(scheme2Fixture);
166
+ expect(parsed.ok).toBe(true);
167
+ if (!parsed.ok) throw new Error('parse failed');
168
+
169
+ const rgba = await ktx2LevelsToRGBA(parsed.value, 0);
170
+ expect(rgba.ok).toBe(true);
171
+ if (!rgba.ok) throw new Error('levelsToRGBA failed');
172
+
173
+ const pixels = rgba.value;
174
+ expect(pixels.length).toBe(16384);
175
+
176
+ // Compare all pixels byte-by-byte against expected
177
+ for (let i = 0; i < pixels.length; i++) {
178
+ // biome-ignore lint/style/noNonNullAssertion: index within bounds of expectedRawPixels array verified above
179
+ expect(pixels[i]).toBe(expectedRawPixels[i]!);
180
+ }
181
+ });
182
+
183
+ it('scheme=2 decompressed pixels match scheme=0 pixel pattern byte-identical', async () => {
184
+ // This asserts AC-04: same zstd decoder gives same result
185
+ // Compare just key pixels (first, middle, last) to keep test fast
186
+ const parsed = await parseKtx2(scheme2Fixture);
187
+ expect(parsed.ok).toBe(true);
188
+ if (!parsed.ok) throw new Error('parse failed');
189
+
190
+ const rgba = await ktx2LevelsToRGBA(parsed.value, 0);
191
+ expect(rgba.ok).toBe(true);
192
+ if (!rgba.ok) throw new Error('levelsToRGBA failed');
193
+ const pixels = rgba.value;
194
+
195
+ // First pixel (0,0)
196
+ expect(pixels[0]).toBe(0);
197
+ expect(pixels[1]).toBe(0);
198
+ expect(pixels[2]).toBe(0);
199
+ expect(pixels[3]).toBe(255);
200
+
201
+ // Last pixel (63,63)
202
+ const lastPx = 16384 - 4;
203
+ expect(pixels[lastPx]).toBe((63 * 3 + 63 * 7) & 0xff);
204
+ expect(pixels[lastPx + 1]).toBe((63 * 5 + 63 * 11) & 0xff);
205
+ expect(pixels[lastPx + 2]).toBe((63 * 13 + 63 * 17) & 0xff);
206
+ expect(pixels[lastPx + 3]).toBe(255);
207
+ });
208
+
209
+ it('structural check: levels-to-RGBA decompresses fewer bytes than raw size', async () => {
210
+ // Verify that compression actually happened (zstd produced smaller data)
211
+ const parsed = await parseKtx2(scheme2Fixture);
212
+ expect(parsed.ok).toBe(true);
213
+ if (!parsed.ok) throw new Error('parse failed');
214
+
215
+ // biome-ignore lint/style/noNonNullAssertion: previous assertion ensures levelIndex[0] exists
216
+ const l0 = parsed.value.levelIndex[0]!;
217
+ // zstd should compress RGBA8 patterned data well below 16384 bytes
218
+ expect(l0.byteLength).toBeLessThan(16384);
219
+ });
220
+ });
@@ -0,0 +1,178 @@
1
+ import type { CodecErrorCode } from '@forgeax/engine-codec';
2
+ import { codecError, parseKtx2 } from '@forgeax/engine-codec';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ /**
6
+ * KTX2 scheme=1 pass-through + transcode-failed error unit tests (w10).
7
+ *
8
+ * (a) A byte-level scheme=1 (BasisLZ) KTX2 header parses successfully -- parseKtx2
9
+ * no longer rejects scheme=1 with ktx2-unsupported-scheme (F-1 single-point
10
+ * gate at ktx2.ts:308 opened for the Basis arm).
11
+ * (b) A scheme=3 (ZLIB) header still rejects with ktx2-unsupported-scheme (the
12
+ * old rejection is preserved -- contract seam AC-11(c): extend, do not
13
+ * rewrite the container parser).
14
+ * (c) The transcode-failed CodecError object carries .code / .detail (with the
15
+ * sourceFormat/targetFormat discriminant) / a non-empty actionable .hint
16
+ * (AC-10 add-only-minor union member).
17
+ *
18
+ * Does NOT execute real transcode (M5 integration test's job); this only builds
19
+ * byte-level headers and inspects error object shape.
20
+ */
21
+
22
+ const KTX2_IDENTIFIER = new Uint8Array([
23
+ 0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
24
+ ]);
25
+
26
+ function writeU32(out: Uint8Array, offset: number, value: number): void {
27
+ new DataView(out.buffer, out.byteOffset + offset, 4).setUint32(0, value, true);
28
+ }
29
+
30
+ function writeU64(out: Uint8Array, offset: number, value: number): void {
31
+ const lo = value >>> 0;
32
+ const hi = (value - lo) / 0x100000000;
33
+ new DataView(out.buffer, out.byteOffset + offset, 8).setUint32(0, lo, true);
34
+ new DataView(out.buffer, out.byteOffset + offset + 4, 4).setUint32(0, hi, true);
35
+ }
36
+
37
+ /**
38
+ * Build a minimal KTX2 header with the given supercompression scheme. The level
39
+ * payload is a small opaque blob (not real Basis data -- parseKtx2 does not
40
+ * interpret it). One level, no DFD / KV / SGD, so the parser exercises only the
41
+ * header + level index + scheme gate.
42
+ */
43
+ function buildHeaderFixture(scheme: number): Uint8Array {
44
+ const W = 8;
45
+ const H = 8;
46
+ const payload = new Uint8Array(64);
47
+ for (let i = 0; i < payload.length; i++) payload[i] = (i * 17 + 3) & 0xff;
48
+
49
+ // header(80) + level index (1 entry = 24) = 104; payload at 104
50
+ const levelPayloadOffset = 104;
51
+ const totalSize = levelPayloadOffset + payload.length;
52
+ const buf = new Uint8Array(totalSize);
53
+
54
+ buf.set(KTX2_IDENTIFIER, 0);
55
+ writeU32(buf, 12, 0); // vkFormat = VK_FORMAT_UNDEFINED (Basis payload)
56
+ writeU32(buf, 16, 1); // typeSize
57
+ writeU32(buf, 20, W);
58
+ writeU32(buf, 24, H);
59
+ writeU32(buf, 28, 0);
60
+ writeU32(buf, 32, 0);
61
+ writeU32(buf, 36, 1);
62
+ writeU32(buf, 40, 1); // levelCount
63
+ writeU32(buf, 44, scheme); // supercompressionScheme
64
+
65
+ // index: no DFD / KV / SGD
66
+ writeU32(buf, 48, 0); // dfdByteOffset
67
+ writeU32(buf, 52, 0); // dfdByteLength
68
+ writeU32(buf, 56, 0); // kvdByteOffset
69
+ writeU32(buf, 60, 0); // kvdByteLength
70
+ writeU64(buf, 64, 0); // sgdByteOffset
71
+ writeU64(buf, 72, 0); // sgdByteLength
72
+
73
+ // level index (smallest-first; only 1 level)
74
+ writeU64(buf, 80, levelPayloadOffset);
75
+ writeU64(buf, 88, payload.length);
76
+ writeU64(buf, 96, payload.length); // uncompressedByteLength
77
+
78
+ buf.set(payload, levelPayloadOffset);
79
+ return buf;
80
+ }
81
+
82
+ describe('parseKtx2 — scheme=1 (BasisLZ) pass-through (w10)', () => {
83
+ it('scheme=1 parses successfully (no ktx2-unsupported-scheme)', async () => {
84
+ const result = await parseKtx2(buildHeaderFixture(1));
85
+ expect(result.ok).toBe(true);
86
+ if (!result.ok) throw new Error(`expected ok, got ${result.error.code}`);
87
+ expect(result.value.header.supercompressionScheme).toBe(1);
88
+ expect(result.value.header.pixelWidth).toBe(8);
89
+ expect(result.value.levelIndex.length).toBe(1);
90
+ });
91
+
92
+ it('scheme=0 still parses (regression guard)', async () => {
93
+ const result = await parseKtx2(buildHeaderFixture(0));
94
+ expect(result.ok).toBe(true);
95
+ });
96
+
97
+ it('scheme=2 still parses (regression guard)', async () => {
98
+ const result = await parseKtx2(buildHeaderFixture(2));
99
+ expect(result.ok).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe('parseKtx2 — scheme=3 (ZLIB) still rejected (w10)', () => {
104
+ it('scheme=3 returns ktx2-unsupported-scheme echoing the scheme value', async () => {
105
+ const result = await parseKtx2(buildHeaderFixture(3));
106
+ expect(result.ok).toBe(false);
107
+ if (result.ok) throw new Error('expected rejection');
108
+ expect(result.error.code).toBe('ktx2-unsupported-scheme');
109
+ expect(result.error.detail).toEqual({ scheme: 3 });
110
+ });
111
+
112
+ it('scheme=4 (arbitrary future scheme) also rejected', async () => {
113
+ const result = await parseKtx2(buildHeaderFixture(4));
114
+ expect(result.ok).toBe(false);
115
+ if (result.ok) throw new Error('expected rejection');
116
+ expect(result.error.code).toBe('ktx2-unsupported-scheme');
117
+ });
118
+ });
119
+
120
+ describe('transcode-failed error object shape (AC-10, w10)', () => {
121
+ it('code / detail (sourceFormat + targetFormat) / hint all present', () => {
122
+ const err = codecError('transcode-failed', {
123
+ sourceFormat: 'uastc-ldr',
124
+ targetFormat: 'bc7-rgba-unorm',
125
+ });
126
+ expect(err.ok).toBe(false);
127
+ expect(err.error.code).toBe('transcode-failed');
128
+ expect(err.error.detail).toEqual({
129
+ sourceFormat: 'uastc-ldr',
130
+ targetFormat: 'bc7-rgba-unorm',
131
+ });
132
+ expect(typeof err.error.hint).toBe('string');
133
+ expect(err.error.hint.length).toBeGreaterThan(0);
134
+ expect(typeof err.error.expected).toBe('string');
135
+ expect(err.error.expected.length).toBeGreaterThan(0);
136
+ });
137
+
138
+ it('transcode-failed is a member of the CodecErrorCode union', () => {
139
+ const code: CodecErrorCode = 'transcode-failed';
140
+ expect(code).toBe('transcode-failed');
141
+ });
142
+
143
+ it('pre-existing codes remain intact (add-only-minor, not a rewrite)', () => {
144
+ const stillThere: CodecErrorCode[] = [
145
+ 'decompression-failed',
146
+ 'codec-init-failed',
147
+ 'ktx2-parse-failed',
148
+ 'ktx2-unsupported-scheme',
149
+ ];
150
+ for (const c of stillThere) {
151
+ const e = codecError(c, dummyDetailFor(c));
152
+ expect(e.error.code).toBe(c);
153
+ expect(e.error.hint.length).toBeGreaterThan(0);
154
+ }
155
+ });
156
+ });
157
+
158
+ /** Build a minimal valid detail payload for each pre-existing code. */
159
+ function dummyDetailFor(code: CodecErrorCode): never {
160
+ switch (code) {
161
+ case 'decompression-failed':
162
+ return { reason: 'test' } as never;
163
+ case 'codec-init-failed':
164
+ return { stage: 'test' } as never;
165
+ case 'ktx2-parse-failed':
166
+ return { reason: 'test' } as never;
167
+ case 'ktx2-unsupported-scheme':
168
+ return { scheme: 3 } as never;
169
+ case 'transcode-failed':
170
+ return { sourceFormat: 'uastc-ldr', targetFormat: 'bc7-rgba-unorm' } as never;
171
+ case 'ktx2-encode-failed':
172
+ return { mode: 'uastc-ldr', reason: 'test' } as never;
173
+ default: {
174
+ const _exhaustive: never = code;
175
+ return _exhaustive;
176
+ }
177
+ }
178
+ }