@forgeax/engine-codec 0.1.2

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 (122) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -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-encode-mode-owner.test-d.d.ts +2 -0
  9. package/dist/__tests__/basis-encode-mode-owner.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/basis-transcoder.unit.test.d.ts +2 -0
  11. package/dist/__tests__/basis-transcoder.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/block-format.unit.test.d.ts +2 -0
  13. package/dist/__tests__/block-format.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/byte-identical-falsification.test.d.ts +2 -0
  15. package/dist/__tests__/byte-identical-falsification.test.d.ts.map +1 -0
  16. package/dist/__tests__/codec-errors.test-d.d.ts +2 -0
  17. package/dist/__tests__/codec-errors.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/codec-errors.unit.test.d.ts +2 -0
  19. package/dist/__tests__/codec-errors.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/compression-field.unit.test.d.ts +19 -0
  21. package/dist/__tests__/compression-field.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/encode-pixel-limit.unit.test.d.ts +2 -0
  23. package/dist/__tests__/encode-pixel-limit.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/encode-roundtrip.unit.test.d.ts +2 -0
  25. package/dist/__tests__/encode-roundtrip.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts +2 -0
  27. package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/ktx2-errors.test.d.ts +2 -0
  29. package/dist/__tests__/ktx2-errors.test.d.ts.map +1 -0
  30. package/dist/__tests__/ktx2-parse-scheme0.test.d.ts +2 -0
  31. package/dist/__tests__/ktx2-parse-scheme0.test.d.ts.map +1 -0
  32. package/dist/__tests__/ktx2-parse-scheme2.test.d.ts +2 -0
  33. package/dist/__tests__/ktx2-parse-scheme2.test.d.ts.map +1 -0
  34. package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts +2 -0
  35. package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/ktx2-to-texture.integration.test.d.ts +2 -0
  37. package/dist/__tests__/ktx2-to-texture.integration.test.d.ts.map +1 -0
  38. package/dist/__tests__/runtime-validation.unit.test.d.ts +2 -0
  39. package/dist/__tests__/runtime-validation.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/transcode-select-target.unit.test.d.ts +2 -0
  41. package/dist/__tests__/transcode-select-target.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/wasm-init-smoke.test.d.ts +2 -0
  43. package/dist/__tests__/wasm-init-smoke.test.d.ts.map +1 -0
  44. package/dist/__tests__/zstd-deterministic.unit.test.d.ts +2 -0
  45. package/dist/__tests__/zstd-deterministic.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts +2 -0
  47. package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/zstd-roundtrip.unit.test.d.ts +2 -0
  49. package/dist/__tests__/zstd-roundtrip.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/zstd-singleton.unit.test.d.ts +2 -0
  51. package/dist/__tests__/zstd-singleton.unit.test.d.ts.map +1 -0
  52. package/dist/basis-transcoder.d.ts +86 -0
  53. package/dist/basis-transcoder.d.ts.map +1 -0
  54. package/dist/block-format.d.ts +41 -0
  55. package/dist/block-format.d.ts.map +1 -0
  56. package/dist/encode/basis-encode.d.ts +68 -0
  57. package/dist/encode/basis-encode.d.ts.map +1 -0
  58. package/dist/encode/index.d.ts +10 -0
  59. package/dist/encode/index.d.ts.map +1 -0
  60. package/dist/encode/index.mjs +207 -0
  61. package/dist/encode/index.mjs.map +1 -0
  62. package/dist/encode-impl.d.ts +30 -0
  63. package/dist/encode-impl.d.ts.map +1 -0
  64. package/dist/errors.d.ts +61 -0
  65. package/dist/errors.d.ts.map +1 -0
  66. package/dist/index.d.ts +17 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.mjs +672 -0
  69. package/dist/index.mjs.map +1 -0
  70. package/dist/ktx2.d.ts +111 -0
  71. package/dist/ktx2.d.ts.map +1 -0
  72. package/dist/transcode.d.ts +55 -0
  73. package/dist/transcode.d.ts.map +1 -0
  74. package/dist/wasm/basis-types.d.ts +179 -0
  75. package/dist/wasm/basis-types.d.ts.map +1 -0
  76. package/dist/zstd.d.ts +28 -0
  77. package/dist/zstd.d.ts.map +1 -0
  78. package/package.json +71 -0
  79. package/pkg/basis_transcoder.mjs +2 -0
  80. package/pkg/basis_transcoder.wasm +0 -0
  81. package/pkg/encode/basis_encoder.mjs +2 -0
  82. package/pkg/encode/basis_encoder.wasm +0 -0
  83. package/scripts/build-wasm.mjs +370 -0
  84. package/scripts/content-key.mjs +85 -0
  85. package/scripts/ensure-wasm.mjs +38 -0
  86. package/scripts/fetch-basis.mjs +150 -0
  87. package/scripts/fetch-wasm.mjs +97 -0
  88. package/src/__tests__/basis-encode-fast-preset.unit.test.ts +204 -0
  89. package/src/__tests__/basis-encode-lifecycle.unit.test.ts +249 -0
  90. package/src/__tests__/basis-encode-mode-owner.test-d.ts +39 -0
  91. package/src/__tests__/basis-transcoder.unit.test.ts +502 -0
  92. package/src/__tests__/block-format.unit.test.ts +215 -0
  93. package/src/__tests__/byte-identical-falsification.test.ts +164 -0
  94. package/src/__tests__/codec-errors.test-d.ts +67 -0
  95. package/src/__tests__/codec-errors.unit.test.ts +128 -0
  96. package/src/__tests__/compression-field.unit.test.ts +245 -0
  97. package/src/__tests__/encode-pixel-limit.unit.test.ts +117 -0
  98. package/src/__tests__/encode-roundtrip.unit.test.ts +239 -0
  99. package/src/__tests__/ktx2-basis-capability-matrix.unit.test.ts +241 -0
  100. package/src/__tests__/ktx2-errors.test.ts +239 -0
  101. package/src/__tests__/ktx2-parse-scheme0.test.ts +263 -0
  102. package/src/__tests__/ktx2-parse-scheme2.test.ts +220 -0
  103. package/src/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.ts +178 -0
  104. package/src/__tests__/ktx2-to-texture.integration.test.ts +384 -0
  105. package/src/__tests__/runtime-validation.unit.test.ts +19 -0
  106. package/src/__tests__/transcode-select-target.unit.test.ts +167 -0
  107. package/src/__tests__/wasm-init-smoke.test.ts +102 -0
  108. package/src/__tests__/zstd-deterministic.unit.test.ts +49 -0
  109. package/src/__tests__/zstd-encode-singleton.unit.test.ts +63 -0
  110. package/src/__tests__/zstd-roundtrip.unit.test.ts +87 -0
  111. package/src/__tests__/zstd-singleton.unit.test.ts +105 -0
  112. package/src/basis-transcoder.ts +380 -0
  113. package/src/block-format.ts +128 -0
  114. package/src/encode/basis-encode.ts +264 -0
  115. package/src/encode/index.ts +19 -0
  116. package/src/encode-impl.ts +103 -0
  117. package/src/errors.ts +79 -0
  118. package/src/index.ts +38 -0
  119. package/src/ktx2.ts +469 -0
  120. package/src/transcode.ts +99 -0
  121. package/src/wasm/basis-types.ts +220 -0
  122. package/src/zstd.ts +85 -0
@@ -0,0 +1,239 @@
1
+ import type { CodecErrorCode } from '@forgeax/engine-codec';
2
+ import { parseKtx2 } from '@forgeax/engine-codec';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ /**
6
+ * KTX2 2.0 identifier magic.
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 minimal valid KTX2 header (no pixel data).
25
+ * Used as base for constructing error fixtures.
26
+ */
27
+ function buildMinimalHeader(supercompressionScheme: number, levelPayloadSize?: number): Uint8Array {
28
+ const dfdBlockSize = 40;
29
+ const dfdTotalSize = 4 + dfdBlockSize;
30
+ const headerSize = 12 + 9 * 4 + 4 * 4 + 2 * 8 + 1 * 24; // 104
31
+
32
+ const levelPayloadOffset = headerSize + dfdTotalSize + 0; // after DFD + empty KV
33
+ const lpSize = levelPayloadSize ?? 16384;
34
+ const totalSize = levelPayloadOffset + lpSize;
35
+
36
+ const buf = new Uint8Array(totalSize);
37
+ buf.set(KTX2_IDENTIFIER, 0);
38
+
39
+ writeU32(buf, 12, 37); // vkFormat
40
+ writeU32(buf, 16, 4);
41
+ writeU32(buf, 20, 64);
42
+ writeU32(buf, 24, 64);
43
+ writeU32(buf, 28, 0);
44
+ writeU32(buf, 32, 0);
45
+ writeU32(buf, 36, 1);
46
+ writeU32(buf, 40, 1);
47
+ writeU32(buf, 44, supercompressionScheme);
48
+
49
+ writeU32(buf, 48, headerSize); // dfdByteOffset
50
+ writeU32(buf, 52, dfdTotalSize);
51
+ writeU32(buf, 56, headerSize + dfdTotalSize); // kvdByteOffset
52
+ writeU32(buf, 60, 0); // kvdByteLength = 0
53
+ writeU64(buf, 64, 0); // sgdByteOffset
54
+ writeU64(buf, 72, 0); // sgdByteLength
55
+
56
+ writeU64(buf, 80, levelPayloadOffset);
57
+ writeU64(buf, 88, lpSize);
58
+ writeU64(buf, 96, lpSize); // uncompressedByteLength
59
+
60
+ // DFD
61
+ writeU32(buf, headerSize, dfdTotalSize);
62
+ writeU32(buf, headerSize + 4, 0);
63
+ writeU32(buf, headerSize + 8, (40 << 16) | 2);
64
+ writeU32(buf, headerSize + 12, (1 << 16) | (1 << 8) | 1);
65
+ writeU32(buf, headerSize + 16, 0);
66
+ writeU32(buf, headerSize + 20, 4);
67
+ writeU32(buf, headerSize + 24, 0);
68
+ writeU32(buf, headerSize + 28, 7 << 12);
69
+ writeU32(buf, headerSize + 32, 0);
70
+ writeU32(buf, headerSize + 36, 0);
71
+ writeU32(buf, headerSize + 40, 0xffffffff);
72
+
73
+ return buf;
74
+ }
75
+
76
+ describe('KTX2 error paths (w23)', () => {
77
+ describe('E5 — truncated header (falsification)', () => {
78
+ it('11-byte truncated identifier returns ktx2-parse-failed', async () => {
79
+ const truncated = new Uint8Array(11); // not even full 12B magic
80
+ // Fill with garbage
81
+ for (let i = 0; i < 11; i++) truncated[i] = 0xab;
82
+
83
+ const result = await parseKtx2(truncated);
84
+ expect(result.ok).toBe(false);
85
+ if (result.ok) throw new Error('expected failure');
86
+
87
+ expect(result.error.code).toBe('ktx2-parse-failed');
88
+ expect((result.error.detail as { reason: string }).reason).toContain('truncated');
89
+ expect(result.error.hint.length).toBeGreaterThan(0);
90
+ });
91
+
92
+ it('header cut short at 20 bytes (before supercompressionScheme) returns ktx2-parse-failed', async () => {
93
+ // Copy first 20 bytes of a valid file (identifier + 2 u32s)
94
+ const valid = buildMinimalHeader(0);
95
+ const truncated = valid.slice(0, 20);
96
+
97
+ const result = await parseKtx2(truncated);
98
+ expect(result.ok).toBe(false);
99
+ if (result.ok) throw new Error('expected failure');
100
+ expect(result.error.code).toBe('ktx2-parse-failed');
101
+ });
102
+ });
103
+
104
+ describe('E5 — level index OOB', () => {
105
+ it('level index byteOffset pointing beyond file end returns ktx2-parse-failed', async () => {
106
+ // Build a valid file, then corrupt level index byteOffset to be huge
107
+ const valid = buildMinimalHeader(0);
108
+ // levelIndex[0].byteOffset is at offset 80, write a value > file.length
109
+ writeU64(valid, 80, valid.length + 10000);
110
+
111
+ const result = await parseKtx2(valid);
112
+ expect(result.ok).toBe(false);
113
+ if (result.ok) throw new Error('expected failure');
114
+ expect(result.error.code).toBe('ktx2-parse-failed');
115
+ expect((result.error.detail as { reason: string }).reason).toContain('OOB');
116
+ });
117
+
118
+ it('level index byteLength exceeding file boundary returns ktx2-parse-failed', async () => {
119
+ const valid = buildMinimalHeader(0);
120
+ // level payload starts at offset 148 with 16384 bytes
121
+ // Set byteLength to exceed remaining file
122
+ writeU64(valid, 88, valid.length - 148 + 10000); // byteLength far beyond
123
+
124
+ const result = await parseKtx2(valid);
125
+ expect(result.ok).toBe(false);
126
+ if (result.ok) throw new Error('expected failure');
127
+ expect(result.error.code).toBe('ktx2-parse-failed');
128
+ });
129
+ });
130
+
131
+ describe('E5 — DFD truncated', () => {
132
+ it('dfdByteLength exceeding file boundary returns ktx2-parse-failed', async () => {
133
+ const valid = buildMinimalHeader(0);
134
+ // dfdByteLength is at offset 52, set to exceed file size
135
+ writeU32(valid, 52, valid.length + 1000);
136
+
137
+ const result = await parseKtx2(valid);
138
+ expect(result.ok).toBe(false);
139
+ if (result.ok) throw new Error('expected failure');
140
+ expect(result.error.code).toBe('ktx2-parse-failed');
141
+ });
142
+
143
+ it('dfdByteOffset beyond EOF returns ktx2-parse-failed', async () => {
144
+ const valid = buildMinimalHeader(0);
145
+ writeU32(valid, 48, valid.length + 1000); // dfdByteOffset way past end
146
+
147
+ const result = await parseKtx2(valid);
148
+ expect(result.ok).toBe(false);
149
+ if (result.ok) throw new Error('expected failure');
150
+ expect(result.error.code).toBe('ktx2-parse-failed');
151
+ });
152
+ });
153
+
154
+ describe('E6 — unsupported supercompression scheme', () => {
155
+ it('scheme=1 (BasisLZ) now parses (Loop 2 Basis transcode arm, F-1 gate opened)', async () => {
156
+ // Loop 2 (w13) opened the single-point scheme gate for BasisLZ so the
157
+ // Basis payload passes through to the transcode layer. scheme=1 is no
158
+ // longer rejected; parseKtx2 does not interpret the payload itself.
159
+ const valid = buildMinimalHeader(1);
160
+
161
+ const result = await parseKtx2(valid);
162
+ expect(result.ok).toBe(true);
163
+ if (!result.ok) throw new Error(`expected ok, got ${result.error.code}`);
164
+ expect(result.value.header.supercompressionScheme).toBe(1);
165
+ });
166
+
167
+ it('scheme=3 (ZLIB) returns ktx2-unsupported-scheme with scheme value', async () => {
168
+ const valid = buildMinimalHeader(3);
169
+
170
+ const result = await parseKtx2(valid);
171
+ expect(result.ok).toBe(false);
172
+ if (result.ok) throw new Error('expected failure');
173
+ expect(result.error.code).toBe('ktx2-unsupported-scheme');
174
+ expect((result.error.detail as { scheme: number }).scheme).toBe(3);
175
+ });
176
+
177
+ it('scheme=999 (unknown) returns ktx2-unsupported-scheme', async () => {
178
+ const valid = buildMinimalHeader(999);
179
+
180
+ const result = await parseKtx2(valid);
181
+ expect(result.ok).toBe(false);
182
+ if (result.ok) throw new Error('expected failure');
183
+ expect(result.error.code).toBe('ktx2-unsupported-scheme');
184
+ expect((result.error.detail as { scheme: number }).scheme).toBe(999);
185
+ });
186
+ });
187
+
188
+ describe('E5 — invalid magic identifier', () => {
189
+ it('wrong magic bytes returns ktx2-parse-failed', async () => {
190
+ const valid = buildMinimalHeader(0);
191
+ // Corrupt first byte of magic
192
+ valid[0] = 0x00;
193
+
194
+ const result = await parseKtx2(valid);
195
+ expect(result.ok).toBe(false);
196
+ if (result.ok) throw new Error('expected failure');
197
+ expect(result.error.code).toBe('ktx2-parse-failed');
198
+ });
199
+ });
200
+
201
+ describe('Error code closed-union exhaustiveness', () => {
202
+ it('all 6 CodecErrorCode members are reachable', () => {
203
+ // Type-level check: verify the CodecErrorCode union has exactly 6 members.
204
+ // This is validated by TS compilation — if a member is missing,
205
+ // the following code fails to typecheck.
206
+ const codes: CodecErrorCode[] = [
207
+ 'decompression-failed',
208
+ 'codec-init-failed',
209
+ 'ktx2-parse-failed',
210
+ 'ktx2-unsupported-scheme',
211
+ 'transcode-failed',
212
+ 'ktx2-encode-failed',
213
+ ];
214
+ expect(codes.length).toBe(6);
215
+
216
+ // Verify exhaustive switch compiles (no default branch)
217
+ for (const code of codes) {
218
+ // TS ensures all cases are covered
219
+ const _checked: string = (() => {
220
+ switch (code) {
221
+ case 'decompression-failed':
222
+ return 'decompression-failed';
223
+ case 'codec-init-failed':
224
+ return 'codec-init-failed';
225
+ case 'ktx2-parse-failed':
226
+ return 'ktx2-parse-failed';
227
+ case 'ktx2-unsupported-scheme':
228
+ return 'ktx2-unsupported-scheme';
229
+ case 'transcode-failed':
230
+ return 'transcode-failed';
231
+ case 'ktx2-encode-failed':
232
+ return 'ktx2-encode-failed';
233
+ }
234
+ })();
235
+ expect(typeof _checked).toBe('string');
236
+ }
237
+ });
238
+ });
239
+ });
@@ -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
+ });