@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.
- package/LICENSE +202 -0
- package/README.md +268 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/basis-encode-fast-preset.unit.test.d.ts +2 -0
- package/dist/__tests__/basis-encode-fast-preset.unit.test.d.ts.map +1 -0
- package/dist/__tests__/basis-encode-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/basis-encode-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/basis-encode-mode-owner.test-d.d.ts +2 -0
- package/dist/__tests__/basis-encode-mode-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/basis-transcoder.unit.test.d.ts +2 -0
- package/dist/__tests__/basis-transcoder.unit.test.d.ts.map +1 -0
- package/dist/__tests__/block-format.unit.test.d.ts +2 -0
- package/dist/__tests__/block-format.unit.test.d.ts.map +1 -0
- package/dist/__tests__/byte-identical-falsification.test.d.ts +2 -0
- package/dist/__tests__/byte-identical-falsification.test.d.ts.map +1 -0
- package/dist/__tests__/codec-errors.test-d.d.ts +2 -0
- package/dist/__tests__/codec-errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/codec-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/codec-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/compression-field.unit.test.d.ts +19 -0
- package/dist/__tests__/compression-field.unit.test.d.ts.map +1 -0
- package/dist/__tests__/encode-pixel-limit.unit.test.d.ts +2 -0
- package/dist/__tests__/encode-pixel-limit.unit.test.d.ts.map +1 -0
- package/dist/__tests__/encode-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/encode-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts +2 -0
- package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-errors.test.d.ts +2 -0
- package/dist/__tests__/ktx2-errors.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-parse-scheme0.test.d.ts +2 -0
- package/dist/__tests__/ktx2-parse-scheme0.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-parse-scheme2.test.d.ts +2 -0
- package/dist/__tests__/ktx2-parse-scheme2.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-to-texture.integration.test.d.ts +2 -0
- package/dist/__tests__/ktx2-to-texture.integration.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-validation.unit.test.d.ts +2 -0
- package/dist/__tests__/runtime-validation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/transcode-select-target.unit.test.d.ts +2 -0
- package/dist/__tests__/transcode-select-target.unit.test.d.ts.map +1 -0
- package/dist/__tests__/wasm-init-smoke.test.d.ts +2 -0
- package/dist/__tests__/wasm-init-smoke.test.d.ts.map +1 -0
- package/dist/__tests__/zstd-deterministic.unit.test.d.ts +2 -0
- package/dist/__tests__/zstd-deterministic.unit.test.d.ts.map +1 -0
- package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts +2 -0
- package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts.map +1 -0
- package/dist/__tests__/zstd-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/zstd-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/zstd-singleton.unit.test.d.ts +2 -0
- package/dist/__tests__/zstd-singleton.unit.test.d.ts.map +1 -0
- package/dist/basis-transcoder.d.ts +86 -0
- package/dist/basis-transcoder.d.ts.map +1 -0
- package/dist/block-format.d.ts +41 -0
- package/dist/block-format.d.ts.map +1 -0
- package/dist/encode/basis-encode.d.ts +68 -0
- package/dist/encode/basis-encode.d.ts.map +1 -0
- package/dist/encode/index.d.ts +10 -0
- package/dist/encode/index.d.ts.map +1 -0
- package/dist/encode/index.mjs +207 -0
- package/dist/encode/index.mjs.map +1 -0
- package/dist/encode-impl.d.ts +30 -0
- package/dist/encode-impl.d.ts.map +1 -0
- package/dist/errors.d.ts +61 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +672 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ktx2.d.ts +111 -0
- package/dist/ktx2.d.ts.map +1 -0
- package/dist/transcode.d.ts +55 -0
- package/dist/transcode.d.ts.map +1 -0
- package/dist/wasm/basis-types.d.ts +179 -0
- package/dist/wasm/basis-types.d.ts.map +1 -0
- package/dist/zstd.d.ts +28 -0
- package/dist/zstd.d.ts.map +1 -0
- package/package.json +71 -0
- package/pkg/basis_transcoder.mjs +2 -0
- package/pkg/basis_transcoder.wasm +0 -0
- package/pkg/encode/basis_encoder.mjs +2 -0
- package/pkg/encode/basis_encoder.wasm +0 -0
- package/scripts/build-wasm.mjs +370 -0
- package/scripts/content-key.mjs +85 -0
- package/scripts/ensure-wasm.mjs +38 -0
- package/scripts/fetch-basis.mjs +150 -0
- package/scripts/fetch-wasm.mjs +97 -0
- package/src/__tests__/basis-encode-fast-preset.unit.test.ts +204 -0
- package/src/__tests__/basis-encode-lifecycle.unit.test.ts +249 -0
- package/src/__tests__/basis-encode-mode-owner.test-d.ts +39 -0
- package/src/__tests__/basis-transcoder.unit.test.ts +502 -0
- package/src/__tests__/block-format.unit.test.ts +215 -0
- package/src/__tests__/byte-identical-falsification.test.ts +164 -0
- package/src/__tests__/codec-errors.test-d.ts +67 -0
- package/src/__tests__/codec-errors.unit.test.ts +128 -0
- package/src/__tests__/compression-field.unit.test.ts +245 -0
- package/src/__tests__/encode-pixel-limit.unit.test.ts +117 -0
- package/src/__tests__/encode-roundtrip.unit.test.ts +239 -0
- package/src/__tests__/ktx2-basis-capability-matrix.unit.test.ts +241 -0
- package/src/__tests__/ktx2-errors.test.ts +239 -0
- package/src/__tests__/ktx2-parse-scheme0.test.ts +263 -0
- package/src/__tests__/ktx2-parse-scheme2.test.ts +220 -0
- package/src/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.ts +178 -0
- package/src/__tests__/ktx2-to-texture.integration.test.ts +384 -0
- package/src/__tests__/runtime-validation.unit.test.ts +19 -0
- package/src/__tests__/transcode-select-target.unit.test.ts +167 -0
- package/src/__tests__/wasm-init-smoke.test.ts +102 -0
- package/src/__tests__/zstd-deterministic.unit.test.ts +49 -0
- package/src/__tests__/zstd-encode-singleton.unit.test.ts +63 -0
- package/src/__tests__/zstd-roundtrip.unit.test.ts +87 -0
- package/src/__tests__/zstd-singleton.unit.test.ts +105 -0
- package/src/basis-transcoder.ts +380 -0
- package/src/block-format.ts +128 -0
- package/src/encode/basis-encode.ts +264 -0
- package/src/encode/index.ts +19 -0
- package/src/encode-impl.ts +103 -0
- package/src/errors.ts +79 -0
- package/src/index.ts +38 -0
- package/src/ktx2.ts +469 -0
- package/src/transcode.ts +99 -0
- package/src/wasm/basis-types.ts +220 -0
- 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
|
+
});
|