@flighthq/texture-formats 0.1.0
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/dist/byteReader.d.ts +14 -0
- package/dist/byteReader.d.ts.map +1 -0
- package/dist/byteReader.js +51 -0
- package/dist/byteReader.js.map +1 -0
- package/dist/detectTextureContainer.d.ts +2 -0
- package/dist/detectTextureContainer.d.ts.map +1 -0
- package/dist/detectTextureContainer.js +31 -0
- package/dist/detectTextureContainer.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/parseAtf.d.ts +3 -0
- package/dist/parseAtf.d.ts.map +1 -0
- package/dist/parseAtf.js +139 -0
- package/dist/parseAtf.js.map +1 -0
- package/dist/parseBasis.d.ts +3 -0
- package/dist/parseBasis.d.ts.map +1 -0
- package/dist/parseBasis.js +82 -0
- package/dist/parseBasis.js.map +1 -0
- package/dist/parseDds.d.ts +3 -0
- package/dist/parseDds.d.ts.map +1 -0
- package/dist/parseDds.js +151 -0
- package/dist/parseDds.js.map +1 -0
- package/dist/parseKtx2.d.ts +3 -0
- package/dist/parseKtx2.d.ts.map +1 -0
- package/dist/parseKtx2.js +173 -0
- package/dist/parseKtx2.js.map +1 -0
- package/dist/selectTextureContainer.d.ts +4 -0
- package/dist/selectTextureContainer.d.ts.map +1 -0
- package/dist/selectTextureContainer.js +15 -0
- package/dist/selectTextureContainer.js.map +1 -0
- package/dist/textureLevelLayout.d.ts +8 -0
- package/dist/textureLevelLayout.d.ts.map +1 -0
- package/dist/textureLevelLayout.js +109 -0
- package/dist/textureLevelLayout.js.map +1 -0
- package/package.json +37 -0
- package/src/byteReader.test.ts +105 -0
- package/src/detectTextureContainer.test.ts +33 -0
- package/src/parseAtf.test.ts +288 -0
- package/src/parseBasis.test.ts +124 -0
- package/src/parseDds.test.ts +146 -0
- package/src/parseKtx2.test.ts +135 -0
- package/src/selectTextureContainer.test.ts +38 -0
- package/src/textureLevelLayout.test.ts +45 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { TextureContainer } from '@flighthq/types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { parseAtf } from './parseAtf';
|
|
5
|
+
import { selectTextureContainer } from './selectTextureContainer';
|
|
6
|
+
|
|
7
|
+
interface AtfOptions {
|
|
8
|
+
version: number;
|
|
9
|
+
formatCode: number;
|
|
10
|
+
cube?: boolean;
|
|
11
|
+
log2Width: number;
|
|
12
|
+
log2Height: number;
|
|
13
|
+
mipCount: number;
|
|
14
|
+
// Block byte-lengths in the file's stored order: side-major, then mip level, then GPU-format slot
|
|
15
|
+
// (0 = DXT, 1 = ETC1, 2 = PVRTC, 3 = ETC2). A zero length marks an absent encoding — the length prefix
|
|
16
|
+
// is still written. The caller supplies exactly `sides * mipCount * slotCount` entries.
|
|
17
|
+
blockLengths: readonly number[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Builds a minimal ATF byte buffer to the OpenFL `ATFReader` framing and reports the absolute data
|
|
21
|
+
// byteOffset of each block, so a test asserts the parser reproduces the builder's own bookkeeping rather
|
|
22
|
+
// than a hand-computed number. A versioned file (version != 0) uses the 12-byte header + 32-bit block
|
|
23
|
+
// lengths; version 0 uses the 6-byte header + 24-bit block lengths.
|
|
24
|
+
function buildAtf(opts: AtfOptions): { bytes: Uint8Array; blockOffsets: number[] } {
|
|
25
|
+
const { version, formatCode, cube = false, log2Width, log2Height, mipCount, blockLengths } = opts;
|
|
26
|
+
const versioned = version !== 0;
|
|
27
|
+
const headerOffset = versioned ? 12 : 6;
|
|
28
|
+
const lengthWidth = versioned ? 4 : 3;
|
|
29
|
+
const bodySize = blockLengths.reduce((sum, length) => sum + lengthWidth + length, 0);
|
|
30
|
+
const bytes = new Uint8Array(headerOffset + 4 + bodySize);
|
|
31
|
+
|
|
32
|
+
bytes[0] = 0x41; // 'A'
|
|
33
|
+
bytes[1] = 0x54; // 'T'
|
|
34
|
+
bytes[2] = 0x46; // 'F'
|
|
35
|
+
|
|
36
|
+
// Declared payload = everything from the format header (tdata) to the end of the buffer.
|
|
37
|
+
const payloadLength = bytes.byteLength - headerOffset;
|
|
38
|
+
if (versioned) {
|
|
39
|
+
bytes[6] = 0xff; // version marker
|
|
40
|
+
bytes[7] = version;
|
|
41
|
+
writeUintBigEndian(bytes, 8, payloadLength, 4);
|
|
42
|
+
} else {
|
|
43
|
+
writeUintBigEndian(bytes, 3, payloadLength, 3);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
bytes[headerOffset] = (cube ? 0x80 : 0) | formatCode;
|
|
47
|
+
bytes[headerOffset + 1] = log2Width;
|
|
48
|
+
bytes[headerOffset + 2] = log2Height;
|
|
49
|
+
bytes[headerOffset + 3] = mipCount;
|
|
50
|
+
|
|
51
|
+
const blockOffsets: number[] = [];
|
|
52
|
+
let offset = headerOffset + 4;
|
|
53
|
+
for (const length of blockLengths) {
|
|
54
|
+
writeUintBigEndian(bytes, offset, length, lengthWidth);
|
|
55
|
+
offset += lengthWidth;
|
|
56
|
+
blockOffsets.push(offset);
|
|
57
|
+
for (let i = 0; i < length; i += 1) bytes[offset + i] = (offset + i) & 0xff;
|
|
58
|
+
offset += length;
|
|
59
|
+
}
|
|
60
|
+
return { blockOffsets, bytes };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeUintBigEndian(bytes: Uint8Array, offset: number, value: number, width: number): void {
|
|
64
|
+
for (let i = 0; i < width; i += 1) bytes[offset + i] = (value >>> ((width - 1 - i) * 8)) & 0xff;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Asserts every located level range lies inside the buffer and no two ranges overlap (gaps for the
|
|
68
|
+
// length prefixes between data blocks are expected).
|
|
69
|
+
function expectLevelRangesInBounds(containers: readonly TextureContainer[], byteLength: number): void {
|
|
70
|
+
const ranges: [number, number][] = [];
|
|
71
|
+
for (const container of containers) {
|
|
72
|
+
for (const level of container.levels) ranges.push([level.byteOffset, level.byteOffset + level.byteLength]);
|
|
73
|
+
}
|
|
74
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
75
|
+
let previousEnd = 0;
|
|
76
|
+
for (const [start, end] of ranges) {
|
|
77
|
+
expect(start).toBeGreaterThanOrEqual(previousEnd); // non-overlapping
|
|
78
|
+
expect(end).toBeLessThanOrEqual(byteLength); // in-bounds
|
|
79
|
+
previousEnd = end;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('parseAtf', () => {
|
|
84
|
+
it('parses a cross-platform ATF as one peer container per populated GPU-format slot', () => {
|
|
85
|
+
// Version 3, format 5 (RAW_COMPRESSED_ALPHA), 8x8, 3 mips, all four slots populated.
|
|
86
|
+
// Slot sizes per level, in side -> level -> slot order:
|
|
87
|
+
// level0: bc3=64 etc1=32 pvrtc=24 etc2=64
|
|
88
|
+
// level1: bc3=16 etc1=8 pvrtc=6 etc2=16
|
|
89
|
+
// level2: bc3=16 etc1=8 pvrtc=6 etc2=16
|
|
90
|
+
const { blockOffsets, bytes } = buildAtf({
|
|
91
|
+
blockLengths: [64, 32, 24, 64, 16, 8, 6, 16, 16, 8, 6, 16],
|
|
92
|
+
formatCode: 5,
|
|
93
|
+
log2Height: 3,
|
|
94
|
+
log2Width: 3,
|
|
95
|
+
mipCount: 3,
|
|
96
|
+
version: 3,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const containers = parseAtf(bytes);
|
|
100
|
+
expect(containers).not.toBeNull();
|
|
101
|
+
expect(containers).toHaveLength(4);
|
|
102
|
+
expect(containers!.map((c) => c.format)).toEqual(['bc3', 'etc1', 'pvrtc4bppRgba', 'etc2Rgba']);
|
|
103
|
+
for (const container of containers!) {
|
|
104
|
+
expect(container.width).toBe(8);
|
|
105
|
+
expect(container.height).toBe(8);
|
|
106
|
+
expect(container.mipLevels).toBe(3);
|
|
107
|
+
expect(container.faces).toBe(1);
|
|
108
|
+
expect(container.levels).toHaveLength(3);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The DXT5 (bc3) slot owns blocks 0, 4, 8; its level0 byteLength is the DXT5 size emitted (64).
|
|
112
|
+
expect(containers![0].levels).toEqual([
|
|
113
|
+
{ byteLength: 64, byteOffset: blockOffsets[0], height: 8, width: 8 },
|
|
114
|
+
{ byteLength: 16, byteOffset: blockOffsets[4], height: 4, width: 4 },
|
|
115
|
+
{ byteLength: 16, byteOffset: blockOffsets[8], height: 2, width: 2 },
|
|
116
|
+
]);
|
|
117
|
+
expect(containers![0].levels[0].byteLength).toBe(64);
|
|
118
|
+
// ETC2 slot owns blocks 3, 7, 11.
|
|
119
|
+
expect(containers![3].levels).toEqual([
|
|
120
|
+
{ byteLength: 64, byteOffset: blockOffsets[3], height: 8, width: 8 },
|
|
121
|
+
{ byteLength: 16, byteOffset: blockOffsets[7], height: 4, width: 4 },
|
|
122
|
+
{ byteLength: 16, byteOffset: blockOffsets[11], height: 2, width: 2 },
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
expectLevelRangesInBounds(containers!, bytes.byteLength);
|
|
126
|
+
expect(selectTextureContainer(containers!, ['bc3'])).toBe(containers![0]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('drops all-zero slots, yielding a single-format array', () => {
|
|
130
|
+
// Version 3, format 5, 8x8, 3 mips, but only the DXT5 slot carries data (slots 1-3 length-0).
|
|
131
|
+
const { blockOffsets, bytes } = buildAtf({
|
|
132
|
+
blockLengths: [64, 0, 0, 0, 16, 0, 0, 0, 16, 0, 0, 0],
|
|
133
|
+
formatCode: 5,
|
|
134
|
+
log2Height: 3,
|
|
135
|
+
log2Width: 3,
|
|
136
|
+
mipCount: 3,
|
|
137
|
+
version: 3,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const containers = parseAtf(bytes);
|
|
141
|
+
expect(containers).not.toBeNull();
|
|
142
|
+
expect(containers).toHaveLength(1);
|
|
143
|
+
expect(containers![0].format).toBe('bc3');
|
|
144
|
+
expect(containers![0].levels).toEqual([
|
|
145
|
+
{ byteLength: 64, byteOffset: blockOffsets[0], height: 8, width: 8 },
|
|
146
|
+
{ byteLength: 16, byteOffset: blockOffsets[4], height: 4, width: 4 },
|
|
147
|
+
{ byteLength: 16, byteOffset: blockOffsets[8], height: 2, width: 2 },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports mipLevels as the POPULATED count, not the declared mipCount (empty-mipmaps files)', () => {
|
|
152
|
+
// png2atf "empty mipmaps": the header declares the full chain (3) but only the base level is
|
|
153
|
+
// stored; mips 1-2 are length-0 across every slot. mipLevels must be 1, matching levels.length —
|
|
154
|
+
// this is the case the real Starling compressed_texture.atf exercises and synthetic slot-drop
|
|
155
|
+
// tests missed.
|
|
156
|
+
const { blockOffsets, bytes } = buildAtf({
|
|
157
|
+
blockLengths: [64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
158
|
+
formatCode: 5,
|
|
159
|
+
log2Height: 3,
|
|
160
|
+
log2Width: 3,
|
|
161
|
+
mipCount: 3,
|
|
162
|
+
version: 3,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const containers = parseAtf(bytes);
|
|
166
|
+
expect(containers).toHaveLength(1);
|
|
167
|
+
expect(containers![0].format).toBe('bc3');
|
|
168
|
+
expect(containers![0].mipLevels).toBe(1);
|
|
169
|
+
expect(containers![0].levels).toEqual([{ byteLength: 64, byteOffset: blockOffsets[0], height: 8, width: 8 }]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('exposes only three GPU-format slots before version 3 (no ETC2)', () => {
|
|
173
|
+
// Version 2, format 3 (RAW_COMPRESSED, opaque), 4x4, 1 mip, three slots populated. A four-slot walk
|
|
174
|
+
// would read a phantom fourth length prefix and overrun; three containers proves the 3-slot stride.
|
|
175
|
+
const { bytes } = buildAtf({
|
|
176
|
+
blockLengths: [8, 8, 8],
|
|
177
|
+
formatCode: 3,
|
|
178
|
+
log2Height: 2,
|
|
179
|
+
log2Width: 2,
|
|
180
|
+
mipCount: 1,
|
|
181
|
+
version: 2,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const containers = parseAtf(bytes);
|
|
185
|
+
expect(containers).not.toBeNull();
|
|
186
|
+
expect(containers).toHaveLength(3);
|
|
187
|
+
expect(containers!.map((c) => c.format)).toEqual(['bc1', 'etc1', 'pvrtc4bppRgb']);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('reports six faces for a cube ATF, sides as the container levels', () => {
|
|
191
|
+
// Cube flag set, version 3, format 3, 2x2, 1 mip, four slots. 6 sides * 1 level * 4 slots = 24 blocks.
|
|
192
|
+
// Each slot's container carries the six same-slot sides as its levels (side-major for the single mip).
|
|
193
|
+
const { bytes } = buildAtf({
|
|
194
|
+
blockLengths: new Array(24).fill(8),
|
|
195
|
+
cube: true,
|
|
196
|
+
formatCode: 3,
|
|
197
|
+
log2Height: 1,
|
|
198
|
+
log2Width: 1,
|
|
199
|
+
mipCount: 1,
|
|
200
|
+
version: 3,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const containers = parseAtf(bytes);
|
|
204
|
+
expect(containers).not.toBeNull();
|
|
205
|
+
expect(containers).toHaveLength(4);
|
|
206
|
+
for (const container of containers!) {
|
|
207
|
+
expect(container.faces).toBe(6);
|
|
208
|
+
expect(container.levels).toHaveLength(6); // 6 sides * 1 mip
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('reads the legacy version-0 header and 24-bit block lengths', () => {
|
|
213
|
+
// Version 0: 6-byte header at offset 6, 24-bit big-endian block-length prefixes, three slots.
|
|
214
|
+
const { blockOffsets, bytes } = buildAtf({
|
|
215
|
+
blockLengths: [16, 0, 0, 16, 0, 0],
|
|
216
|
+
formatCode: 5,
|
|
217
|
+
log2Height: 2,
|
|
218
|
+
log2Width: 2,
|
|
219
|
+
mipCount: 2,
|
|
220
|
+
version: 0,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const containers = parseAtf(bytes);
|
|
224
|
+
expect(containers).not.toBeNull();
|
|
225
|
+
expect(containers).toHaveLength(1);
|
|
226
|
+
expect(containers![0].format).toBe('bc3');
|
|
227
|
+
expect(containers![0].width).toBe(4);
|
|
228
|
+
expect(containers![0].height).toBe(4);
|
|
229
|
+
expect(containers![0].levels).toEqual([
|
|
230
|
+
{ byteLength: 16, byteOffset: blockOffsets[0], height: 4, width: 4 },
|
|
231
|
+
{ byteLength: 16, byteOffset: blockOffsets[3], height: 2, width: 2 },
|
|
232
|
+
]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('returns null for non-ATF or too-short bytes', () => {
|
|
236
|
+
expect(parseAtf(new Uint8Array([0x44, 0x44, 0x53, 0x20, 0, 0, 0]))).toBeNull(); // 'DDS '
|
|
237
|
+
expect(parseAtf(new Uint8Array([0x41, 0x54]))).toBeNull(); // too short for the signature
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('returns null for unsupported format codes', () => {
|
|
241
|
+
// Code 0 (raw BGRA) and code 1 (raw RGBA) are unsupported; only 2/3/12 and 4/5/13 are.
|
|
242
|
+
const zero = buildAtf({
|
|
243
|
+
blockLengths: [0, 0, 0, 0],
|
|
244
|
+
formatCode: 0,
|
|
245
|
+
log2Height: 2,
|
|
246
|
+
log2Width: 2,
|
|
247
|
+
mipCount: 1,
|
|
248
|
+
version: 3,
|
|
249
|
+
});
|
|
250
|
+
const one = buildAtf({
|
|
251
|
+
blockLengths: [0, 0, 0, 0],
|
|
252
|
+
formatCode: 1,
|
|
253
|
+
log2Height: 2,
|
|
254
|
+
log2Width: 2,
|
|
255
|
+
mipCount: 1,
|
|
256
|
+
version: 3,
|
|
257
|
+
});
|
|
258
|
+
expect(parseAtf(zero.bytes)).toBeNull();
|
|
259
|
+
expect(parseAtf(one.bytes)).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('returns null when a block length overruns the buffer', () => {
|
|
263
|
+
// Truncating the tail drops declared payload → null.
|
|
264
|
+
const valid = buildAtf({
|
|
265
|
+
blockLengths: [64, 32, 24, 64, 16, 8, 6, 16, 16, 8, 6, 16],
|
|
266
|
+
formatCode: 5,
|
|
267
|
+
log2Height: 3,
|
|
268
|
+
log2Width: 3,
|
|
269
|
+
mipCount: 3,
|
|
270
|
+
version: 3,
|
|
271
|
+
});
|
|
272
|
+
expect(parseAtf(valid.bytes.subarray(0, valid.bytes.byteLength - 8))).toBeNull();
|
|
273
|
+
|
|
274
|
+
// A block-length prefix that claims more than the buffer holds, with the header length left intact,
|
|
275
|
+
// is caught by the per-block bounds check during the walk.
|
|
276
|
+
const patched = buildAtf({
|
|
277
|
+
blockLengths: [64, 0, 0, 0, 16, 0, 0, 0, 16, 0, 0, 0],
|
|
278
|
+
formatCode: 5,
|
|
279
|
+
log2Height: 3,
|
|
280
|
+
log2Width: 3,
|
|
281
|
+
mipCount: 3,
|
|
282
|
+
version: 3,
|
|
283
|
+
});
|
|
284
|
+
// First block's 4-byte prefix sits at offset 16 (12-byte header + 4-byte format block).
|
|
285
|
+
writeUintBigEndian(patched.bytes, 16, 0xffffff, 4);
|
|
286
|
+
expect(parseAtf(patched.bytes)).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { parseBasis } from './parseBasis';
|
|
4
|
+
|
|
5
|
+
interface BasisSlice {
|
|
6
|
+
imageIndex: number;
|
|
7
|
+
levelIndex: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
fileOfs: number;
|
|
11
|
+
fileSize: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface BasisOptions {
|
|
15
|
+
totalImages?: number;
|
|
16
|
+
texFormat?: number;
|
|
17
|
+
slices: readonly BasisSlice[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const basisHeaderSize = 77;
|
|
21
|
+
const basisSliceDescSize = 22;
|
|
22
|
+
|
|
23
|
+
function buildBasis(opts: BasisOptions): Uint8Array {
|
|
24
|
+
const { totalImages = 1, texFormat = 0, slices } = opts;
|
|
25
|
+
const sliceTableOffset = basisHeaderSize;
|
|
26
|
+
const dataStart = sliceTableOffset + slices.length * basisSliceDescSize;
|
|
27
|
+
const total = slices.reduce((max, s) => Math.max(max, s.fileOfs + s.fileSize), dataStart);
|
|
28
|
+
const bytes = new Uint8Array(total);
|
|
29
|
+
const dv = new DataView(bytes.buffer);
|
|
30
|
+
bytes[0] = 0x73; // 's'
|
|
31
|
+
bytes[1] = 0x42; // 'B'
|
|
32
|
+
dv.setUint16(12, slices.length, true); // m_total_slices
|
|
33
|
+
dv.setUint16(14, totalImages, true); // m_total_images
|
|
34
|
+
bytes[16] = texFormat; // m_tex_format
|
|
35
|
+
dv.setUint32(61, sliceTableOffset, true); // m_slice_desc_file_ofs
|
|
36
|
+
let off = sliceTableOffset;
|
|
37
|
+
for (const slice of slices) {
|
|
38
|
+
dv.setUint16(off + 0, slice.imageIndex, true);
|
|
39
|
+
bytes[off + 2] = slice.levelIndex;
|
|
40
|
+
dv.setUint16(off + 4, slice.width, true);
|
|
41
|
+
dv.setUint16(off + 6, slice.height, true);
|
|
42
|
+
dv.setUint32(off + 12, slice.fileOfs, true);
|
|
43
|
+
dv.setUint32(off + 16, slice.fileSize, true);
|
|
44
|
+
off += basisSliceDescSize;
|
|
45
|
+
}
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('parseBasis', () => {
|
|
50
|
+
it('parses a single-slice ETC1S image', () => {
|
|
51
|
+
const container = parseBasis(
|
|
52
|
+
buildBasis({ slices: [{ fileOfs: 99, fileSize: 8, height: 4, imageIndex: 0, levelIndex: 0, width: 4 }] }),
|
|
53
|
+
);
|
|
54
|
+
expect(container).not.toBeNull();
|
|
55
|
+
expect(container!.format).toBe('etc1s');
|
|
56
|
+
expect(container!.width).toBe(4);
|
|
57
|
+
expect(container!.height).toBe(4);
|
|
58
|
+
expect(container!.mipLevels).toBe(1);
|
|
59
|
+
expect(container!.layers).toBe(1);
|
|
60
|
+
expect(container!.faces).toBe(1);
|
|
61
|
+
expect(container!.supercompression).toBe('None');
|
|
62
|
+
expect(container!.levels).toEqual([{ byteLength: 8, byteOffset: 99, height: 4, width: 4 }]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('reports a mip chain from the slice level indices', () => {
|
|
66
|
+
const container = parseBasis(
|
|
67
|
+
buildBasis({
|
|
68
|
+
slices: [
|
|
69
|
+
{ fileOfs: 121, fileSize: 8, height: 4, imageIndex: 0, levelIndex: 0, width: 4 },
|
|
70
|
+
{ fileOfs: 129, fileSize: 4, height: 2, imageIndex: 0, levelIndex: 1, width: 2 },
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
expect(container).not.toBeNull();
|
|
75
|
+
expect(container!.mipLevels).toBe(2);
|
|
76
|
+
expect(container!.levels.map((l) => l.width)).toEqual([4, 2]);
|
|
77
|
+
expect(container!.levels.map((l) => l.byteOffset)).toEqual([121, 129]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('maps the UASTC texture format', () => {
|
|
81
|
+
const container = parseBasis(
|
|
82
|
+
buildBasis({
|
|
83
|
+
slices: [{ fileOfs: 99, fileSize: 16, height: 4, imageIndex: 0, levelIndex: 0, width: 4 }],
|
|
84
|
+
texFormat: 1,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
expect(container!.format).toBe('uastc');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('reports each image as an array layer', () => {
|
|
91
|
+
const container = parseBasis(
|
|
92
|
+
buildBasis({
|
|
93
|
+
slices: [
|
|
94
|
+
{ fileOfs: 121, fileSize: 8, height: 4, imageIndex: 0, levelIndex: 0, width: 4 },
|
|
95
|
+
{ fileOfs: 129, fileSize: 8, height: 4, imageIndex: 1, levelIndex: 0, width: 4 },
|
|
96
|
+
],
|
|
97
|
+
totalImages: 2,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
expect(container!.layers).toBe(2);
|
|
101
|
+
expect(container!.levels).toHaveLength(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns null for a non-Basis, truncated, or unknown-format buffer', () => {
|
|
105
|
+
expect(parseBasis(new Uint8Array([0x44, 0x44, 0x53, 0x20]))).toBeNull();
|
|
106
|
+
expect(parseBasis(new Uint8Array([0x73, 0x42, 0, 0]))).toBeNull();
|
|
107
|
+
expect(
|
|
108
|
+
parseBasis(
|
|
109
|
+
buildBasis({
|
|
110
|
+
slices: [{ fileOfs: 99, fileSize: 8, height: 4, imageIndex: 0, levelIndex: 0, width: 4 }],
|
|
111
|
+
texFormat: 5,
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns null when the slice table runs past the buffer', () => {
|
|
118
|
+
const bytes = buildBasis({
|
|
119
|
+
slices: [{ fileOfs: 99, fileSize: 8, height: 4, imageIndex: 0, levelIndex: 0, width: 4 }],
|
|
120
|
+
});
|
|
121
|
+
new DataView(bytes.buffer).setUint32(61, 1_000_000, true); // corrupt m_slice_desc_file_ofs
|
|
122
|
+
expect(parseBasis(bytes)).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { parseDds } from './parseDds';
|
|
4
|
+
|
|
5
|
+
function fourCC(text: string): number {
|
|
6
|
+
return (
|
|
7
|
+
(text.charCodeAt(0) | (text.charCodeAt(1) << 8) | (text.charCodeAt(2) << 16) | (text.charCodeAt(3) << 24)) >>> 0
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Dx10Options {
|
|
12
|
+
dxgiFormat: number;
|
|
13
|
+
miscFlag?: number;
|
|
14
|
+
arraySize?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DdsOptions {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
mipMapCount?: number;
|
|
21
|
+
pfFlags: number;
|
|
22
|
+
fourCC?: number;
|
|
23
|
+
rgbBitCount?: number;
|
|
24
|
+
masks?: readonly [number, number, number, number];
|
|
25
|
+
caps2?: number;
|
|
26
|
+
dx10?: Dx10Options;
|
|
27
|
+
dataBytes?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildDds(opts: DdsOptions): Uint8Array {
|
|
31
|
+
const {
|
|
32
|
+
width,
|
|
33
|
+
height,
|
|
34
|
+
mipMapCount = 1,
|
|
35
|
+
pfFlags,
|
|
36
|
+
fourCC: fourCCValue = 0,
|
|
37
|
+
rgbBitCount = 0,
|
|
38
|
+
masks = [0, 0, 0, 0],
|
|
39
|
+
caps2 = 0,
|
|
40
|
+
dx10,
|
|
41
|
+
dataBytes = 256,
|
|
42
|
+
} = opts;
|
|
43
|
+
const headerEnd = 128 + (dx10 ? 20 : 0);
|
|
44
|
+
const bytes = new Uint8Array(headerEnd + dataBytes);
|
|
45
|
+
const dv = new DataView(bytes.buffer);
|
|
46
|
+
bytes.set([0x44, 0x44, 0x53, 0x20], 0);
|
|
47
|
+
dv.setUint32(4, 124, true);
|
|
48
|
+
dv.setUint32(12, height, true);
|
|
49
|
+
dv.setUint32(16, width, true);
|
|
50
|
+
dv.setUint32(28, mipMapCount, true);
|
|
51
|
+
dv.setUint32(76, 32, true);
|
|
52
|
+
dv.setUint32(80, pfFlags, true);
|
|
53
|
+
dv.setUint32(84, fourCCValue, true);
|
|
54
|
+
dv.setUint32(88, rgbBitCount, true);
|
|
55
|
+
dv.setUint32(92, masks[0], true);
|
|
56
|
+
dv.setUint32(96, masks[1], true);
|
|
57
|
+
dv.setUint32(100, masks[2], true);
|
|
58
|
+
dv.setUint32(104, masks[3], true);
|
|
59
|
+
dv.setUint32(112, caps2, true);
|
|
60
|
+
if (dx10) {
|
|
61
|
+
dv.setUint32(128, dx10.dxgiFormat, true);
|
|
62
|
+
dv.setUint32(132, 3, true); // resourceDimension = TEXTURE2D
|
|
63
|
+
dv.setUint32(136, dx10.miscFlag ?? 0, true);
|
|
64
|
+
dv.setUint32(140, dx10.arraySize ?? 1, true);
|
|
65
|
+
}
|
|
66
|
+
return bytes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('parseDds', () => {
|
|
70
|
+
it('parses a single-mip BC1 (DXT1) texture', () => {
|
|
71
|
+
const container = parseDds(buildDds({ fourCC: fourCC('DXT1'), height: 4, pfFlags: 0x4, width: 4 }));
|
|
72
|
+
expect(container).not.toBeNull();
|
|
73
|
+
expect(container!.format).toBe('bc1');
|
|
74
|
+
expect(container!.width).toBe(4);
|
|
75
|
+
expect(container!.height).toBe(4);
|
|
76
|
+
expect(container!.mipLevels).toBe(1);
|
|
77
|
+
expect(container!.faces).toBe(1);
|
|
78
|
+
expect(container!.layers).toBe(1);
|
|
79
|
+
expect(container!.supercompression).toBe('None');
|
|
80
|
+
expect(container!.levels).toEqual([{ byteLength: 8, byteOffset: 128, height: 4, width: 4 }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('maps uncompressed RGBA channel masks to bgra8unorm and rgba8unorm', () => {
|
|
84
|
+
const bgra = parseDds(
|
|
85
|
+
buildDds({
|
|
86
|
+
height: 2,
|
|
87
|
+
masks: [0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000],
|
|
88
|
+
pfFlags: 0x40,
|
|
89
|
+
rgbBitCount: 32,
|
|
90
|
+
width: 2,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
expect(bgra!.format).toBe('bgra8unorm');
|
|
94
|
+
expect(bgra!.levels).toEqual([{ byteLength: 16, byteOffset: 128, height: 2, width: 2 }]);
|
|
95
|
+
const rgba = parseDds(
|
|
96
|
+
buildDds({
|
|
97
|
+
height: 2,
|
|
98
|
+
masks: [0x000000ff, 0x0000ff00, 0x00ff0000, 0xff000000],
|
|
99
|
+
pfFlags: 0x40,
|
|
100
|
+
rgbBitCount: 32,
|
|
101
|
+
width: 2,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
expect(rgba!.format).toBe('rgba8unorm');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('reports six faces for a cubemap and a full mip chain per face', () => {
|
|
108
|
+
const container = parseDds(buildDdsCube());
|
|
109
|
+
expect(container).not.toBeNull();
|
|
110
|
+
expect(container!.faces).toBe(6);
|
|
111
|
+
expect(container!.levels).toHaveLength(6);
|
|
112
|
+
expect(container!.levels.map((l) => l.byteLength)).toEqual([16, 16, 16, 16, 16, 16]);
|
|
113
|
+
expect(container!.levels.map((l) => l.byteOffset)).toEqual([128, 144, 160, 176, 192, 208]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('computes descending byte ranges for a multi-mip BC1 texture', () => {
|
|
117
|
+
const container = parseDds(buildDds({ fourCC: fourCC('DXT1'), height: 8, mipMapCount: 4, pfFlags: 0x4, width: 8 }));
|
|
118
|
+
expect(container).not.toBeNull();
|
|
119
|
+
expect(container!.mipLevels).toBe(4);
|
|
120
|
+
expect(container!.levels.map((l) => l.width)).toEqual([8, 4, 2, 1]);
|
|
121
|
+
expect(container!.levels.map((l) => l.byteLength)).toEqual([32, 8, 8, 8]);
|
|
122
|
+
expect(container!.levels.map((l) => l.byteOffset)).toEqual([128, 160, 168, 176]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('reads a DX10 extension header and its DXGI format', () => {
|
|
126
|
+
const container = parseDds(
|
|
127
|
+
buildDds({ dx10: { dxgiFormat: 98 }, fourCC: fourCC('DX10'), height: 4, pfFlags: 0x4, width: 4 }),
|
|
128
|
+
);
|
|
129
|
+
expect(container).not.toBeNull();
|
|
130
|
+
expect(container!.format).toBe('bc7');
|
|
131
|
+
expect(container!.levels[0].byteOffset).toBe(148); // data follows the 20-byte DX10 header
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns null for a non-DDS, truncated, unknown-format, or volume texture', () => {
|
|
135
|
+
expect(parseDds(new Uint8Array([0xab, 0x4b, 0x54, 0x58]))).toBeNull();
|
|
136
|
+
expect(parseDds(new Uint8Array([0x44, 0x44, 0x53, 0x20, 0, 0, 0, 0]))).toBeNull();
|
|
137
|
+
expect(parseDds(buildDds({ fourCC: fourCC('ZZZZ'), height: 4, pfFlags: 0x4, width: 4 }))).toBeNull();
|
|
138
|
+
expect(
|
|
139
|
+
parseDds(buildDds({ caps2: 0x200000, fourCC: fourCC('DXT1'), height: 4, pfFlags: 0x4, width: 4 })),
|
|
140
|
+
).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
function buildDdsCube(): Uint8Array {
|
|
145
|
+
return buildDds({ caps2: 0x200, fourCC: fourCC('DXT5'), dataBytes: 96, height: 4, pfFlags: 0x4, width: 4 });
|
|
146
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { parseKtx2 } from './parseKtx2';
|
|
4
|
+
|
|
5
|
+
const ktx2Magic = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
6
|
+
|
|
7
|
+
interface Ktx2Level {
|
|
8
|
+
byteOffset: number;
|
|
9
|
+
byteLength: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Ktx2Options {
|
|
13
|
+
vkFormat: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
depth?: number;
|
|
17
|
+
layerCount?: number;
|
|
18
|
+
faceCount?: number;
|
|
19
|
+
scheme?: number;
|
|
20
|
+
levels: readonly Ktx2Level[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildKtx2(opts: Ktx2Options): Uint8Array {
|
|
24
|
+
const { vkFormat, width, height, depth = 0, layerCount = 0, faceCount = 1, scheme = 0, levels } = opts;
|
|
25
|
+
const dataStart = 80 + levels.length * 24;
|
|
26
|
+
const total = levels.reduce((max, l) => Math.max(max, l.byteOffset + l.byteLength), dataStart);
|
|
27
|
+
const bytes = new Uint8Array(total);
|
|
28
|
+
const dv = new DataView(bytes.buffer);
|
|
29
|
+
bytes.set(ktx2Magic, 0);
|
|
30
|
+
dv.setUint32(12, vkFormat, true);
|
|
31
|
+
dv.setUint32(16, 4, true); // typeSize
|
|
32
|
+
dv.setUint32(20, width, true);
|
|
33
|
+
dv.setUint32(24, height, true);
|
|
34
|
+
dv.setUint32(28, depth, true);
|
|
35
|
+
dv.setUint32(32, layerCount, true);
|
|
36
|
+
dv.setUint32(36, faceCount, true);
|
|
37
|
+
dv.setUint32(40, levels.length, true);
|
|
38
|
+
dv.setUint32(44, scheme, true);
|
|
39
|
+
let off = 80;
|
|
40
|
+
for (const level of levels) {
|
|
41
|
+
dv.setBigUint64(off, BigInt(level.byteOffset), true);
|
|
42
|
+
dv.setBigUint64(off + 8, BigInt(level.byteLength), true);
|
|
43
|
+
dv.setBigUint64(off + 16, BigInt(level.byteLength), true);
|
|
44
|
+
off += 24;
|
|
45
|
+
}
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('parseKtx2', () => {
|
|
50
|
+
it('parses a single-mip uncompressed 2D texture', () => {
|
|
51
|
+
const container = parseKtx2(
|
|
52
|
+
buildKtx2({ vkFormat: 37, width: 4, height: 4, levels: [{ byteLength: 64, byteOffset: 104 }] }),
|
|
53
|
+
);
|
|
54
|
+
expect(container).not.toBeNull();
|
|
55
|
+
expect(container!.format).toBe('rgba8unorm');
|
|
56
|
+
expect(container!.width).toBe(4);
|
|
57
|
+
expect(container!.height).toBe(4);
|
|
58
|
+
expect(container!.depth).toBe(1);
|
|
59
|
+
expect(container!.mipLevels).toBe(1);
|
|
60
|
+
expect(container!.layers).toBe(1);
|
|
61
|
+
expect(container!.faces).toBe(1);
|
|
62
|
+
expect(container!.supercompression).toBe('None');
|
|
63
|
+
expect(container!.levels).toEqual([{ byteLength: 64, byteOffset: 104, height: 4, width: 4 }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('splits an uncompressed cubemap level into six per-face sub-images', () => {
|
|
67
|
+
const container = parseKtx2(
|
|
68
|
+
buildKtx2({ vkFormat: 37, width: 4, height: 4, faceCount: 6, levels: [{ byteLength: 384, byteOffset: 104 }] }),
|
|
69
|
+
);
|
|
70
|
+
expect(container).not.toBeNull();
|
|
71
|
+
expect(container!.faces).toBe(6);
|
|
72
|
+
expect(container!.levels).toHaveLength(6);
|
|
73
|
+
expect(container!.levels.map((l) => l.byteLength)).toEqual([64, 64, 64, 64, 64, 64]);
|
|
74
|
+
expect(container!.levels.map((l) => l.byteOffset)).toEqual([104, 168, 232, 296, 360, 424]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reports each mip of a mip chain from the level index', () => {
|
|
78
|
+
const container = parseKtx2(
|
|
79
|
+
buildKtx2({
|
|
80
|
+
vkFormat: 37,
|
|
81
|
+
width: 8,
|
|
82
|
+
height: 8,
|
|
83
|
+
levels: [
|
|
84
|
+
{ byteLength: 256, byteOffset: 176 },
|
|
85
|
+
{ byteLength: 64, byteOffset: 432 },
|
|
86
|
+
{ byteLength: 16, byteOffset: 496 },
|
|
87
|
+
{ byteLength: 4, byteOffset: 512 },
|
|
88
|
+
],
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
expect(container).not.toBeNull();
|
|
92
|
+
expect(container!.mipLevels).toBe(4);
|
|
93
|
+
expect(container!.levels.map((l) => l.width)).toEqual([8, 4, 2, 1]);
|
|
94
|
+
expect(container!.levels.map((l) => l.byteOffset)).toEqual([176, 432, 496, 512]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('reports a BasisLZ-supercompressed level as one compressed blob (etc1s)', () => {
|
|
98
|
+
const container = parseKtx2(
|
|
99
|
+
buildKtx2({ vkFormat: 0, width: 4, height: 4, scheme: 1, levels: [{ byteLength: 48, byteOffset: 104 }] }),
|
|
100
|
+
);
|
|
101
|
+
expect(container).not.toBeNull();
|
|
102
|
+
expect(container!.format).toBe('etc1s');
|
|
103
|
+
expect(container!.supercompression).toBe('BasisLZ');
|
|
104
|
+
expect(container!.levels).toHaveLength(1);
|
|
105
|
+
expect(container!.levels[0].byteLength).toBe(48);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('maps an undefined vkFormat with no supercompression to uastc', () => {
|
|
109
|
+
const container = parseKtx2(
|
|
110
|
+
buildKtx2({ vkFormat: 0, width: 4, height: 4, scheme: 0, levels: [{ byteLength: 64, byteOffset: 104 }] }),
|
|
111
|
+
);
|
|
112
|
+
expect(container!.format).toBe('uastc');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns null for a non-KTX2 buffer', () => {
|
|
116
|
+
expect(parseKtx2(new Uint8Array([0x44, 0x44, 0x53, 0x20]))).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns null for an unmapped vkFormat', () => {
|
|
120
|
+
expect(
|
|
121
|
+
parseKtx2(buildKtx2({ vkFormat: 9999, width: 4, height: 4, levels: [{ byteLength: 64, byteOffset: 104 }] })),
|
|
122
|
+
).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns null when a level range runs past the buffer', () => {
|
|
126
|
+
const bytes = buildKtx2({ vkFormat: 37, width: 4, height: 4, levels: [{ byteLength: 64, byteOffset: 104 }] });
|
|
127
|
+
const dv = new DataView(bytes.buffer);
|
|
128
|
+
dv.setBigUint64(80 + 8, BigInt(1_000_000), true); // corrupt the level byteLength
|
|
129
|
+
expect(parseKtx2(bytes)).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns null for a truncated header', () => {
|
|
133
|
+
expect(parseKtx2(new Uint8Array([...ktx2Magic, 0, 0, 0, 0]))).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|