vtf-js
Version:
A javascript IO library for the Valve Texture Format.
272 lines (271 loc) • 11.8 kB
JavaScript
import { VEncodedImageData, getCodec } from './image.js';
import { DataBuffer } from './buffer.js';
import { VFormats, NO_DATA } from './enums.js';
import { VDataCollection } from './providers.js';
import { getFaceCount, getMipSize, compress, decompress } from './utils.js';
/** A map of header tags and their corresponding decoders. Using {@link registerResourceType} to register new tags is recommended! */
export const VResourceTypes = {};
/** Registers a resource to be used when the specified tag is encountered. */
export function registerResourceType(resource, tag) {
VResourceTypes[tag] = resource;
}
/** A collection of common resource header tags as BE 24-bit integers. */
export var VHeaderTags;
(function (VHeaderTags) {
VHeaderTags[VHeaderTags["TAG_LEGACY_BODY"] = 3145728] = "TAG_LEGACY_BODY";
VHeaderTags[VHeaderTags["TAG_LEGACY_THUMB"] = 65536] = "TAG_LEGACY_THUMB";
VHeaderTags[VHeaderTags["TAG_SHEET"] = 1048576] = "TAG_SHEET";
VHeaderTags[VHeaderTags["TAG_AXC"] = 4282435] = "TAG_AXC";
VHeaderTags[VHeaderTags["TAG_HOTSPOT"] = 2818048] = "TAG_HOTSPOT";
})(VHeaderTags || (VHeaderTags = {}));
/** Implements a resource header. This serves as a container to provide to {@link VResourceStatic} when decoding. */
export class VHeader {
tag;
flags;
start;
length;
constructor(tag, flags, start, length) {
this.tag = tag;
this.flags = flags;
this.start = start;
this.length = length;
}
/** Returns true if the `0x2` flag is unset. */
hasData() {
return !(this.flags & NO_DATA);
}
}
/** Implements a generic resource entry. This can be subclassed to quickly implement {@link VResource}. */
export class VBaseResource {
tag;
flags;
raw;
constructor(tag, flags, raw) {
this.tag = tag;
this.flags = flags;
this.raw = raw;
}
isLegacy() {
return this.tag === VHeaderTags.TAG_LEGACY_BODY || this.tag === VHeaderTags.TAG_LEGACY_THUMB;
}
static decode(header, view, info) {
return new VBaseResource(header.tag, header.flags, view);
}
encode(info) {
return this.raw?.buffer;
}
}
/** @internal The hi-res image data resource. This is managed internally! */
export class VBodyResource extends VBaseResource {
images;
constructor(flags, images) {
super(VHeaderTags.TAG_LEGACY_BODY, flags);
this.images = images;
}
static async decode(header, view, info, lazy = false) {
const face_count = getFaceCount(info);
const codec = getCodec(info.format);
const mips = new Array(info.mipmaps);
for (let x = info.mipmaps - 1; x >= 0; x--) { // Vtfs store mipmaps smallest-to-largest
const frames = mips[x] = new Array(info.frames);
for (let y = 0; y < info.frames; y++) {
const faces = frames[y] = new Array(face_count);
for (let z = 0; z < face_count; z++) {
const [width, height] = getMipSize(x, info.width, info.height);
const uncompressed_length = codec.length(width, height);
// AXC compression works on every mip/frame/face, but joins all slices together
let subview;
if (info.compression_level !== 0) {
const compressed_length = info.compressed_lengths[x][y][z];
const slice_data = view.read_u8(compressed_length);
subview = new DataBuffer(await decompress(slice_data, info.compression_method, info.compression_level));
}
else {
subview = view.ref(view.pointer, uncompressed_length * info.slices);
view.pointer += subview.length;
}
const slices = faces[z] = new Array(info.slices);
for (let w = 0; w < info.slices; w++) {
const data = subview.read_u8(uncompressed_length);
const encoded = new VEncodedImageData(data, width, height, info.format);
if (lazy)
slices[w] = encoded;
else
slices[w] = encoded.decode();
}
}
}
}
const images = new VDataCollection(mips);
return new VBodyResource(header.flags, images);
}
async encode(info) {
const face_count = getFaceCount(info);
const codec = getCodec(info.format);
const packed_slices = [];
let packed_length = 0;
const cl_mipmaps = info.compressed_lengths = new Array(info.mipmaps);
for (let x = info.mipmaps - 1; x >= 0; x--) { // mipmaps
const cl_frames = cl_mipmaps[x] = new Array(info.frames);
for (let y = 0; y < info.frames; y++) { // frames
const cl_faces = cl_frames[y] = new Array(face_count);
for (let z = 0; z < face_count; z++) { // faces
const [width, height] = getMipSize(x, info.width, info.height);
const uncompressed_length = codec.length(width, height);
const subview = new DataBuffer(uncompressed_length * info.slices);
for (let w = 0; w < info.slices; w++) { // slices
const slice = this.images.getImage(x, y, z, w, true);
// If slice is encoded, .encode() will no-op if the format matches. Otherwise, it is re-encoded.
// If slice isn't encoded, it will be encoded into the desired format.
const sliceData = slice.encode(info.format).data;
subview.write_u8(sliceData);
}
// Compress
let data = subview;
if (info.compression_level !== 0) {
data = await compress(data, info.compression_method, info.compression_level);
}
cl_faces[z] = data.length;
packed_slices.push(data);
packed_length += data.length;
}
}
}
const view = new DataBuffer(packed_length);
for (let i = 0; i < packed_slices.length; i++) {
view.write_u8(packed_slices[i]);
}
return view.buffer;
}
}
/** @internal The low-res image data resource. This is managed internally! */
export class VThumbResource extends VBaseResource {
image;
constructor(flags, image) {
super(VHeaderTags.TAG_LEGACY_THUMB, flags);
this.image = image;
}
static decode(header, view, info) {
const codec = getCodec(info.thumb_format);
const data = view.read_u8(codec.length(info.thumb_width, info.thumb_height));
const image = new VEncodedImageData(data, info.thumb_width, info.thumb_height, info.thumb_format);
return new VThumbResource(header.flags, image);
}
encode(info) {
if (this.image.width === 0 || this.image.height === 0)
return new ArrayBuffer(0);
return this.image.encode(VFormats.DXT1).data.buffer;
}
}
export class VSheetResource extends VBaseResource {
sequences;
static {
registerResourceType(VSheetResource, VHeaderTags.TAG_SHEET);
}
constructor(flags, sequences) {
super(VHeaderTags.TAG_SHEET, flags);
this.sequences = sequences;
}
static decode(header, view, info) {
const coord_count = info.version === 0 ? 1 : 4;
const sequence_count = view.read_u32();
const sequences = new Array(sequence_count);
for (let i = 0; i < sequence_count; i++) {
view.pad(4); // const id = view.read_u32();
const clamp = !!view.read_u32();
const frame_count = view.read_u32();
const duration = view.read_f32();
const frames = new Array(frame_count);
for (let j = 0; j < frame_count; j++) {
const duration = view.read_f32();
const coords = new Array(coord_count);
for (let k = 0; k < coord_count; k++) {
coords[k] = view.read_f32(4);
}
frames[j] = { duration, coords };
}
sequences[i] = { clamp, duration, frames };
}
return new VSheetResource(header.flags, sequences);
}
encode(info) {
const coord_count = info.version === 0 ? 1 : 4;
let buffer_length = 4;
for (let i = 0; i < this.sequences.length; i++) {
buffer_length += 16 + this.sequences[i].frames.length * (4 + 4 * 4 * coord_count);
}
const view = new DataBuffer(buffer_length);
for (let i = 0; i < this.sequences.length; i++) {
const sequence = this.sequences[i];
view.write_u32(i);
view.write_u32(sequence.clamp ? 0xff : 0x00);
view.write_u32(sequence.frames.length);
view.write_f32(sequence.duration);
for (let j = 0; j < sequence.frames.length; j++) {
const frame = sequence.frames[j];
view.write_f32(frame.duration);
if (coord_count !== frame.coords.length)
throw Error(`Expected ${coord_count} coordinate sets, but got ${frame.coords.length}!`);
for (let k = 0; k < coord_count; k++) {
if (frame.coords[k].length !== 4)
throw Error('SheetFrame coords must be of length 4!');
view.write_f32(frame.coords[k]);
}
}
}
return view.buffer;
}
}
/** The Hotspot data resource. See {@link https://wiki.stratasource.org/modding/overview/vtf-hotspot-resource this page} for more information. */
export class VHotspotResource extends VBaseResource {
version;
editorFlags;
rects;
static {
registerResourceType(VHotspotResource, VHeaderTags.TAG_HOTSPOT);
}
constructor(flags, version, editorFlags, rects) {
super(VHeaderTags.TAG_HOTSPOT, flags);
this.version = version;
this.editorFlags = editorFlags;
this.rects = rects;
}
static decode(header, view, info) {
if (!header.hasData())
return new VHotspotResource(header.flags, 0, 0, []);
const version = view.read_u8();
const flags = view.read_u8();
const rectCount = view.read_u16();
if (version !== 0x1)
throw Error(`Failed to parse VHotspotResource: Invalid version! (Expected 1, got ${version})`);
const rects = Array(rectCount);
for (let i = 0; i < rectCount; i++) {
rects[i] = {
flags: view.read_u8(),
min_x: view.read_u16(),
min_y: view.read_u16(),
max_x: view.read_u16(),
max_y: view.read_u16(),
};
}
return new VHotspotResource(header.flags, version, flags, rects);
}
encode(info) {
const length = 4 + this.rects.length * 9;
const view = new DataBuffer(length);
if (this.version !== 0x1)
throw Error(`Failed to write VHotspotResource: Invalid version! (Expected 1, got ${this.version})`);
view.write_u8(this.version);
view.write_u8(this.editorFlags);
view.write_u16(this.rects.length);
for (let i = 0; i < this.rects.length; i++) {
const rect = this.rects[i];
view.write_u8(rect.flags);
view.write_u16(rect.min_x);
view.write_u16(rect.min_y);
view.write_u16(rect.max_x);
view.write_u16(rect.max_y);
}
return view.buffer;
}
}