@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.
- package/LICENSE +202 -0
- package/README.md +261 -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-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__/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 +610 -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-transcoder.unit.test.ts +151 -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__/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 +315 -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,241 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import {
|
|
4
|
+
inspectBasisSource,
|
|
5
|
+
ktx2ColorSpace,
|
|
6
|
+
parseKtx2,
|
|
7
|
+
selectTranscodeTarget,
|
|
8
|
+
type TranscodeCaps,
|
|
9
|
+
} from '@forgeax/engine-codec';
|
|
10
|
+
import { basisEncode } from '@forgeax/engine-codec/encode';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import type { Ktx2Parsed } from '../ktx2.js';
|
|
13
|
+
import type { BasisEncoderModule, BasisFile, BasisModuleFactory } from '../wasm/basis-types.js';
|
|
14
|
+
|
|
15
|
+
const ENCODER_GLUE = new URL('../../pkg/encode/basis_encoder.mjs', import.meta.url);
|
|
16
|
+
const TRANSCODER_GLUE = new URL('../../pkg/basis_transcoder.mjs', import.meta.url);
|
|
17
|
+
const pkgBuilt =
|
|
18
|
+
existsSync(fileURLToPath(ENCODER_GLUE)) && existsSync(fileURLToPath(TRANSCODER_GLUE));
|
|
19
|
+
|
|
20
|
+
function parsedWithTransfer(transferFunction: number): Ktx2Parsed {
|
|
21
|
+
return {
|
|
22
|
+
header: {
|
|
23
|
+
vkFormat: 0,
|
|
24
|
+
typeSize: 1,
|
|
25
|
+
pixelWidth: 4,
|
|
26
|
+
pixelHeight: 4,
|
|
27
|
+
pixelDepth: 0,
|
|
28
|
+
layerCount: 0,
|
|
29
|
+
faceCount: 1,
|
|
30
|
+
levelCount: 1,
|
|
31
|
+
supercompressionScheme: 1,
|
|
32
|
+
},
|
|
33
|
+
index: {
|
|
34
|
+
dfdByteOffset: 0,
|
|
35
|
+
dfdByteLength: 0,
|
|
36
|
+
kvdByteOffset: 0,
|
|
37
|
+
kvdByteLength: 0,
|
|
38
|
+
sgdByteOffset: 0,
|
|
39
|
+
sgdByteLength: 0,
|
|
40
|
+
},
|
|
41
|
+
levelIndex: [{ byteOffset: 0, byteLength: 0, uncompressedByteLength: 0 }],
|
|
42
|
+
dfd: {
|
|
43
|
+
totalSize: 44,
|
|
44
|
+
vendorId: 0,
|
|
45
|
+
descriptorType: 0,
|
|
46
|
+
versionNumber: 2,
|
|
47
|
+
descriptorBlockSize: 40,
|
|
48
|
+
colorModel: 166,
|
|
49
|
+
colorPrimaries: 1,
|
|
50
|
+
transferFunction,
|
|
51
|
+
flags: 0,
|
|
52
|
+
texelBlockDimension: [3, 3, 0, 0],
|
|
53
|
+
bytesPlane: [16, 0, 0, 0, 0, 0, 0, 0],
|
|
54
|
+
samples: [],
|
|
55
|
+
},
|
|
56
|
+
kvEntries: [],
|
|
57
|
+
sgd: null,
|
|
58
|
+
rawBytes: new Uint8Array(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fakeBasisFile(): BasisFile {
|
|
63
|
+
return {
|
|
64
|
+
close: () => undefined,
|
|
65
|
+
getNumImages: () => 1,
|
|
66
|
+
getNumLevels: () => 2,
|
|
67
|
+
getImageWidth: () => 8,
|
|
68
|
+
getImageHeight: () => 4,
|
|
69
|
+
getBasisTexFormat: () => 0,
|
|
70
|
+
getHasAlpha: () => 1,
|
|
71
|
+
startTranscoding: () => 1,
|
|
72
|
+
getImageTranscodedSizeInBytes: () => 16,
|
|
73
|
+
transcodeImage: () => 1,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function loadEncoder(): Promise<BasisEncoderModule> {
|
|
78
|
+
const factory = (
|
|
79
|
+
(await import(/* @vite-ignore */ ENCODER_GLUE.href)) as {
|
|
80
|
+
default: BasisModuleFactory<BasisEncoderModule>;
|
|
81
|
+
}
|
|
82
|
+
).default;
|
|
83
|
+
const mod = await factory({
|
|
84
|
+
locateFile: () => new URL('../../pkg/encode/basis_encoder.wasm', import.meta.url).href,
|
|
85
|
+
});
|
|
86
|
+
mod.initializeBasis();
|
|
87
|
+
return mod;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function realKtx2(mode: 'etc1s' | 'uastc-ldr' | 'uastc-hdr'): Promise<Uint8Array> {
|
|
91
|
+
const pixels =
|
|
92
|
+
mode === 'uastc-hdr'
|
|
93
|
+
? new Uint8Array(4 * 4 * 8)
|
|
94
|
+
: new Uint8Array(4 * 4 * 4).map((_, index) => (index * 17) & 0xff);
|
|
95
|
+
const result = await basisEncode(pixels, {
|
|
96
|
+
mode,
|
|
97
|
+
width: 4,
|
|
98
|
+
height: 4,
|
|
99
|
+
srgb: mode !== 'uastc-hdr',
|
|
100
|
+
perceptual: mode !== 'uastc-hdr',
|
|
101
|
+
uastcSupercompression: mode === 'uastc-ldr',
|
|
102
|
+
mipGen: false,
|
|
103
|
+
});
|
|
104
|
+
if (!result.ok) throw new Error(result.error.code);
|
|
105
|
+
return result.value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function realBasis(): Promise<Uint8Array> {
|
|
109
|
+
const mod = await loadEncoder();
|
|
110
|
+
const encoder = new mod.BasisEncoder();
|
|
111
|
+
try {
|
|
112
|
+
encoder.setSliceSourceImage(0, new Uint8Array(4 * 4 * 4).fill(127), 4, 4, 0);
|
|
113
|
+
encoder.setCreateKTX2File(false);
|
|
114
|
+
encoder.setFormatMode(mod.basis_tex_format.cUASTC_LDR_4x4.value);
|
|
115
|
+
encoder.setPerceptual(false);
|
|
116
|
+
encoder.setMipGen(false);
|
|
117
|
+
const bytes = new Uint8Array(1 << 20);
|
|
118
|
+
const length = encoder.encode(bytes);
|
|
119
|
+
if (length <= 0) throw new Error('raw Basis encode failed');
|
|
120
|
+
return bytes.slice(0, length);
|
|
121
|
+
} finally {
|
|
122
|
+
encoder.delete();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const capabilityCells = [
|
|
127
|
+
[{ bc: false, etc2: false, astc: false }, 'rgba'],
|
|
128
|
+
[{ bc: false, etc2: false, astc: true }, 'astc'],
|
|
129
|
+
[{ bc: true, etc2: false, astc: false }, 'bc'],
|
|
130
|
+
[{ bc: false, etc2: true, astc: false }, 'etc'],
|
|
131
|
+
[{ bc: true, etc2: true, astc: true }, 'all'],
|
|
132
|
+
] as const;
|
|
133
|
+
|
|
134
|
+
function expectedLdrTarget(srgb: boolean, arm: string): string {
|
|
135
|
+
const suffix = srgb ? '-srgb' : '';
|
|
136
|
+
switch (arm) {
|
|
137
|
+
case 'bc':
|
|
138
|
+
case 'all':
|
|
139
|
+
return `bc7-rgba-unorm${suffix}`;
|
|
140
|
+
case 'astc':
|
|
141
|
+
return `astc-4x4-unorm${suffix}`;
|
|
142
|
+
case 'etc':
|
|
143
|
+
return `etc2-rgba8unorm${suffix}`;
|
|
144
|
+
default:
|
|
145
|
+
return `rgba8unorm${suffix}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function expectedHdrTarget(arm: string): string {
|
|
150
|
+
return arm === 'bc' || arm === 'all' ? 'bc6h-rgb-ufloat' : 'rgba16float';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('KTX2/Basis capability matrix', () => {
|
|
154
|
+
it('projects DFD transfer functions without a duplicate source color fact', async () => {
|
|
155
|
+
expect(ktx2ColorSpace(parsedWithTransfer(1))).toBe('linear');
|
|
156
|
+
expect(ktx2ColorSpace(parsedWithTransfer(2))).toBe('srgb');
|
|
157
|
+
expect(ktx2ColorSpace(parsedWithTransfer(0))).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('inspects raw Basis dimensions and keeps Meta color-space provenance', () => {
|
|
161
|
+
const result = inspectBasisSource(fakeBasisFile(), { colorSpace: 'srgb' }, 'etc1s');
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
ok: true,
|
|
164
|
+
value: {
|
|
165
|
+
colorSpace: 'srgb',
|
|
166
|
+
profile: 'etc1s',
|
|
167
|
+
width: 8,
|
|
168
|
+
height: 4,
|
|
169
|
+
levelCount: 2,
|
|
170
|
+
imageCount: 1,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('rejects raw Basis inspection without an authored color-space fact', () => {
|
|
176
|
+
const result = inspectBasisSource(fakeBasisFile(), {}, 'uastc-ldr');
|
|
177
|
+
expect(result.ok).toBe(false);
|
|
178
|
+
if (result.ok) return;
|
|
179
|
+
expect(result.error.code).toBe('ktx2-parse-failed');
|
|
180
|
+
expect('reason' in result.error.detail).toBe(true);
|
|
181
|
+
if ('reason' in result.error.detail)
|
|
182
|
+
expect(result.error.detail.reason).toContain('Meta.colorSpace');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.each([
|
|
186
|
+
[{ bc: true, etc2: true, astc: true }, 'bc7-rgba-unorm-srgb'],
|
|
187
|
+
[{ bc: false, etc2: false, astc: true }, 'astc-4x4-unorm-srgb'],
|
|
188
|
+
[{ bc: false, etc2: true, astc: false }, 'etc2-rgba8unorm-srgb'],
|
|
189
|
+
[{ bc: false, etc2: false, astc: false }, 'rgba8unorm-srgb'],
|
|
190
|
+
] as const)('selects a deterministic LDR target or explicit RGBA fallback', (caps, target) => {
|
|
191
|
+
expect(
|
|
192
|
+
selectTranscodeTarget(
|
|
193
|
+
{ model: 'etc1s', srgb: true, channels: 'rgba' },
|
|
194
|
+
caps as TranscodeCaps,
|
|
195
|
+
),
|
|
196
|
+
).toBe(target);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it.skipIf(!pkgBuilt)('covers every real input profile across every capability cell', async () => {
|
|
200
|
+
const inputs = [
|
|
201
|
+
{ id: 'ktx2-etc1s', bytes: await realKtx2('etc1s'), model: 'etc1s', srgb: true },
|
|
202
|
+
{ id: 'ktx2-uastc-ldr', bytes: await realKtx2('uastc-ldr'), model: 'uastc-ldr', srgb: true },
|
|
203
|
+
{ id: 'ktx2-uastc-hdr', bytes: await realKtx2('uastc-hdr'), model: 'uastc-hdr', srgb: false },
|
|
204
|
+
] as const;
|
|
205
|
+
|
|
206
|
+
for (const input of inputs) {
|
|
207
|
+
const parsed = await parseKtx2(input.bytes);
|
|
208
|
+
expect(parsed.ok, input.id).toBe(true);
|
|
209
|
+
if (!parsed.ok) continue;
|
|
210
|
+
expect(ktx2ColorSpace(parsed.value), input.id).toBe(input.srgb ? 'srgb' : 'linear');
|
|
211
|
+
for (const [caps, arm] of capabilityCells) {
|
|
212
|
+
const target = selectTranscodeTarget(
|
|
213
|
+
{ model: input.model, srgb: input.srgb, channels: 'rgba' },
|
|
214
|
+
caps,
|
|
215
|
+
);
|
|
216
|
+
expect(target, `${input.id}:${arm}`).toBe(
|
|
217
|
+
input.model === 'uastc-hdr' ? expectedHdrTarget(arm) : expectedLdrTarget(input.srgb, arm),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const raw = await realBasis();
|
|
223
|
+
const transcoder = await (await import(/* @vite-ignore */ TRANSCODER_GLUE.href)).default({
|
|
224
|
+
locateFile: () => new URL('../../pkg/basis_transcoder.wasm', import.meta.url).href,
|
|
225
|
+
});
|
|
226
|
+
transcoder.initializeBasis();
|
|
227
|
+
const file = new transcoder.BasisFile(raw);
|
|
228
|
+
try {
|
|
229
|
+
const inspected = inspectBasisSource(file, { colorSpace: 'srgb' }, 'uastc-ldr');
|
|
230
|
+
expect(inspected.ok, 'raw-basis').toBe(true);
|
|
231
|
+
for (const [caps, arm] of capabilityCells) {
|
|
232
|
+
expect(
|
|
233
|
+
selectTranscodeTarget({ model: 'uastc-ldr', srgb: true, channels: 'rgba' }, caps),
|
|
234
|
+
`raw-basis:${arm}`,
|
|
235
|
+
).toBe(expectedLdrTarget(true, arm));
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
file.close();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -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
|
+
});
|