diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 40d6bc2..126f7b9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -53,5 +53,5 @@ jobs: node - << END const m = require('spring-map-parser'); const p = new m.MapParser({ verbose: true, mipmapSize: 4, skipSmt: false }); - p.parseMap("source/test/test_maps/barren_2.sd7").then(() => console.log("ok")); + p.parseMap("source/test/test_maps/hooked_1.1.1.sd7").then(() => console.log("ok")); END diff --git a/README.md b/README.md index 41ddc3c..fe35758 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ import { MapParser } from "spring-map-parser"; await map.typeMap!.writeAsync("working-files/type.png"); await map.miniMap!.writeAsync("working-files/mini.png"); await map.textureMap!.scaleToFit(765, 300).quality(80).writeAsync("working-files/test.jpg"); + + // If the map has a skybox defined in mapinfo.lua (atmosphere.skyBox) + // it will be automatically converted from a DDS cubemap to an equirectangular (2:1) image + if (map.skybox) { + await map.skybox.writeAsync("working-files/skybox.png"); + } })(); ``` diff --git a/src/cubemap-to-equirectangular.ts b/src/cubemap-to-equirectangular.ts new file mode 100644 index 0000000..522da29 --- /dev/null +++ b/src/cubemap-to-equirectangular.ts @@ -0,0 +1,121 @@ +import Jimp from "jimp"; + +/** + * Represents the six faces of a cubemap in the order: + * [+X (right), -X (left), +Y (top), -Y (bottom), +Z (front), -Z (back)] + */ +export type CubemapFaces = [Jimp, Jimp, Jimp, Jimp, Jimp, Jimp]; + +/** + * Converts a cubemap (6 square faces) to an equirectangular (2:1) projection. + * + * @param faces - Array of 6 Jimp images representing cubemap faces in order: +X, -X, +Y, -Y, +Z, -Z + * @param outputWidth - Width of output equirectangular image (height will be outputWidth/2) + * @returns Jimp image in equirectangular projection + */ +export function cubemapToEquirectangular(faces: CubemapFaces, outputWidth: number): Jimp { + const outputHeight = Math.floor(outputWidth / 2); + + // Correct face orientations - flip faces 0, 1, 4, 5 vertically + const correctedFaces: Jimp[] = faces.map((face, i) => { + if (i === 0 || i === 1 || i === 4 || i === 5) { + return face.clone().flip(false, true); + } + return face; + }); + + const faceSize = correctedFaces[0].getWidth(); + const output = new Jimp(outputWidth, outputHeight); + + // For each pixel in the output equirectangular image + for (let y = 0; y < outputHeight; y++) { + for (let x = 0; x < outputWidth; x++) { + // Convert pixel coordinates to normalized coordinates [0, 1] + const u = x / outputWidth; + const v = y / outputHeight; + + // Convert to spherical coordinates + const theta = u * 2 * Math.PI; // longitude + const phi = v * Math.PI; // latitude + + // Convert spherical to cartesian coordinates + const cartX = -Math.sin(phi) * Math.sin(theta); + const cartY = Math.cos(phi); + const cartZ = -Math.sin(phi) * Math.cos(theta); + + // Determine which face to sample from and get UV coordinates on that face + const { faceIndex, faceU, faceV } = cartesianToCubemapUV(cartX, cartY, cartZ); + + // Convert face UV [0, 1] to pixel coordinates + const pixelU = Math.min(Math.floor(faceU * faceSize), faceSize - 1); + const pixelV = Math.min(Math.floor(faceV * faceSize), faceSize - 1); + + // Sample the color from the appropriate face + const color = correctedFaces[faceIndex].getPixelColor(pixelU, pixelV); + output.setPixelColor(color, x, y); + } + } + + return output; +} + +/** + * Converts cartesian coordinates to cubemap face index and UV coordinates. + * + * @param x - X coordinate in cartesian space + * @param y - Y coordinate in cartesian space + * @param z - Z coordinate in cartesian space + * @returns Object containing face index (0-5) and UV coordinates on that face + */ +function cartesianToCubemapUV(x: number, y: number, z: number): { faceIndex: number; faceU: number; faceV: number } { + const absX = Math.abs(x); + const absY = Math.abs(y); + const absZ = Math.abs(z); + + const isXPositive = x > 0; + const isYPositive = y > 0; + const isZPositive = z > 0; + + let faceIndex: number; + let uc: number; + let vc: number; + + // Determine which face we're sampling from + if (isXPositive && absX >= absY && absX >= absZ) { + // +X face (right) + faceIndex = 0; + uc = -z / absX; + vc = y / absX; + } else if (!isXPositive && absX >= absY && absX >= absZ) { + // -X face (left) + faceIndex = 1; + uc = z / absX; + vc = y / absX; + } else if (isYPositive && absY >= absX && absY >= absZ) { + // +Y face (top) + faceIndex = 2; + uc = x / absY; + vc = z / absY; + } else if (!isYPositive && absY >= absX && absY >= absZ) { + // -Y face (bottom) + faceIndex = 3; + uc = x / absY; + vc = -z / absY; + } else if (isZPositive && absZ >= absX && absZ >= absY) { + // +Z face (front) + faceIndex = 4; + uc = x / absZ; + vc = y / absZ; + } else { + // -Z face (back) + faceIndex = 5; + uc = -x / absZ; + vc = y / absZ; + } + + // Convert from [-1, 1] to [0, 1] + const faceU = 0.5 * (uc + 1.0); + const faceV = 0.5 * (vc + 1.0); + + return { faceIndex, faceU, faceV }; +} diff --git a/src/map-model.ts b/src/map-model.ts index 598f840..940b732 100644 --- a/src/map-model.ts +++ b/src/map-model.ts @@ -15,7 +15,8 @@ export type SpringMap = { miniMap: Jimp; metalMap: Jimp; typeMap: Jimp; - resources?: Record + skybox?: Jimp; + resources?: Record; } export type SMD = { @@ -268,4 +269,4 @@ export type Custom = { export const defaultWaterOptions: Optionals = { rgbColor: { r: 33, g: 35, b: 77 }, rgbModifier: { r: 1, g: 1.2, b: 1 } -}; \ No newline at end of file +}; diff --git a/src/map-parser.ts b/src/map-parser.ts index 2028aad..410f4c6 100644 --- a/src/map-parser.ts +++ b/src/map-parser.ts @@ -13,7 +13,9 @@ import * as os from "os"; import * as path from "path"; import { BufferStream } from "./buffer-stream"; +import { cubemapToEquirectangular } from "./cubemap-to-equirectangular"; import { defaultWaterOptions, MapInfo, SMD, SMF, SpringMap, WaterOptions } from "./map-model"; +import { parseDDSCubemap } from "./parse-dds-cubemap"; import { parseDxt } from "./parse-dxt"; /* eslint-disable @typescript-eslint/no-require-imports */ @@ -48,6 +50,11 @@ export interface MapParserConfig { * @default true */ water: boolean; + /** + * Parse skybox image file from mapinfo->atmosphere->skybox + * @default false + */ + parseSkybox: boolean; /** * Parse resource image files from mapinfo->resources * @default false @@ -61,6 +68,7 @@ const mapParserDefaultConfig: Partial = { skipSmt: false, path7za: sevenBin.path7za, water: true, + parseSkybox: false, parseResources: false }; @@ -130,6 +138,11 @@ export class MapParser { resources = await this.parseResources(tempArchiveDir, mapInfo?.resources); } + let skybox: Jimp | undefined; + if (this.config.parseSkybox && mapInfo?.atmosphere?.skyBox) { + skybox = await this.parseSkybox(tempArchiveDir, mapInfo.atmosphere.skyBox); + } + await this.cleanup(tempArchiveDir); return { @@ -146,7 +159,8 @@ export class MapParser { miniMap: smf.miniMap, typeMap: smf.typeMap, textureMap: smt, - resources + resources, + skybox }; } catch (err) { await this.cleanup(tempArchiveDir); @@ -533,7 +547,7 @@ export class MapParser { for (const key in resourceFiles) { const value = resourceFiles[key]; - if (typeof value !== "string") { + if (["splatDetailTex", "detailTex"].includes(key) || typeof value !== "string") { continue; } @@ -569,6 +583,43 @@ export class MapParser { return resources; } + protected async parseSkybox(mapArchiveDir: string, skyboxPath: string, targetWidth: number = 4096): Promise { + if (this.config.verbose) { + console.log(`Parsing skybox: ${skyboxPath}`); + } + + const filename = path.join(mapArchiveDir, "maps", skyboxPath); + + try { + if (!existsSync(filename)) { + console.warn(`Skybox file not found: ${filename}`); + return undefined; + } + + if (path.extname(filename) !== ".dds") { + console.warn(`Skybox must be a .dds file, got: ${filename}`); + return undefined; + } + + const skyboxBuffer = await fs.readFile(filename); + + // Parse the DDS cubemap into 6 faces + const faces = await parseDDSCubemap(skyboxBuffer); + + // Convert cubemap to equirectangular projection (2:1 aspect ratio) + const equirectangular = cubemapToEquirectangular(faces, targetWidth); + + if (this.config.verbose) { + console.log(`Converted skybox to equirectangular ${equirectangular.getWidth()}x${equirectangular.getHeight()}`); + } + + return equirectangular; + } catch (err) { + console.error(`Error parsing skybox: ${filename}`, err); + return undefined; + } + } + protected async cleanup(tmpDir: string) { if (this.config.verbose) { console.log(`Cleaning up temp dir: ${tmpDir}`); diff --git a/src/parse-dds-cubemap.ts b/src/parse-dds-cubemap.ts new file mode 100644 index 0000000..53eb6aa --- /dev/null +++ b/src/parse-dds-cubemap.ts @@ -0,0 +1,205 @@ +import Jimp from "jimp"; + +import { CubemapFaces } from "./cubemap-to-equirectangular"; +import parseDDS from "./utex.dds"; + +interface DDSHeader { + flags: number; + height: number; + width: number; + pitch: number; + depth: number; + mmcount: number; // mipmap count + pixFormat: { + flags: number; + fourCC: string; + bitCount: number; + }; + caps: number; + caps2: number; +} + +/** + * Parses a DDS cubemap file and returns the 6 faces as Jimp images. + * @param buffer - Buffer containing the DDS cubemap file + * @returns Array of 6 Jimp images representing the cubemap faces in order: +X, -X, +Y, -Y, +Z, -Z + */ +export async function parseDDSCubemap(buffer: Buffer): Promise { + // Read DDS header + const data = new Uint8Array(buffer); + let offset = 0; + + // Check magic number + const magic = String.fromCharCode(data[0], data[1], data[2], data[3]); + if (magic !== "DDS ") { + throw new Error("Invalid DDS file: missing DDS magic number"); + } + offset += 4; + + // Read header (124 bytes) + const header = readDDSHeader(data, offset); + offset += 124; + + // Check if DX10 header exists + const DDPF_FOURCC = 0x4; + if ((header.pixFormat.flags & DDPF_FOURCC) && header.pixFormat.fourCC === "DX10") { + offset += 20; // Skip DX10 header + } + + const faceWidth = header.width; + const faceHeight = header.height; + const mipCount = Math.max(1, header.mmcount); + + // Calculate size of one mipmap chain for one face + const mainFaceBytes = calculateMipChainSize(faceWidth, faceHeight, 1, header.pixFormat.fourCC, header.pixFormat.bitCount); + const fullMipChainBytes = calculateMipChainSize(faceWidth, faceHeight, mipCount, header.pixFormat.fourCC, header.pixFormat.bitCount); + const skipBytes = fullMipChainBytes - mainFaceBytes; + + // Create a modified header with no mipmaps for parsing individual faces + const modifiedHeader = Buffer.from(buffer.slice(0, 128)); + // Set mipmap count to 0 at offset 28 (after 4 byte magic) + modifiedHeader.writeUInt32LE(0, 28); + // Clear cubemap flags from caps (offset 108 after magic) + // DDSCAPS_COMPLEX = 0x8, DDSCAPS_MIPMAP = 0x400000, DDSCAPS_TEXTURE = 0x1000 + const simpleCaps = 0x1000; // Just DDSCAPS_TEXTURE + modifiedHeader.writeUInt32LE(simpleCaps, 108); + // Clear caps2 (offset 112 after magic) - remove all cubemap flags + modifiedHeader.writeUInt32LE(0, 112); + + const faces: Jimp[] = []; + + // Read 6 faces + for (let i = 0; i < 6; i++) { + // Read the main mipmap level for this face + const faceData = buffer.slice(offset, offset + mainFaceBytes); + offset += mainFaceBytes; + + // Skip smaller mipmap levels if they exist + if (skipBytes > 0) { + offset += skipBytes; + } + + // Create a complete DDS file for this face (header + data) + const faceDDS = Buffer.concat([modifiedHeader, faceData]); + + try { + // Parse this face using the existing DDS parser + const decoded = parseDDS(faceDDS); + const face = new Jimp({ + data: Buffer.from(decoded.image), + width: decoded.width, + height: decoded.height + }); + faces.push(face); + } catch (err) { + console.error(err); + + throw new Error(`Failed to parse cubemap face ${i}: ${err}`); + } + } + + if (faces.length !== 6) { + throw new Error(`Expected 6 cubemap faces, got ${faces.length}`); + } + + return faces as CubemapFaces; +} + +/** + * Reads DDS header from buffer + */ +function readDDSHeader(data: Uint8Array, offset: number): DDSHeader { + const readUint32LE = (data: Uint8Array, offset: number): number => { + return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24); + }; + + offset += 4; // Skip size field (124) + const flags = readUint32LE(data, offset); offset += 4; + const height = readUint32LE(data, offset); offset += 4; + const width = readUint32LE(data, offset); offset += 4; + const pitch = readUint32LE(data, offset); offset += 4; + const depth = readUint32LE(data, offset); offset += 4; + const mmcount = readUint32LE(data, offset); offset += 4; + + offset += 11 * 4; // Skip reserved fields + + // Read pixel format (32 bytes) + offset += 4; // Skip pixel format size + const pfFlags = readUint32LE(data, offset); offset += 4; + const fourCC = String.fromCharCode(data[offset], data[offset + 1], data[offset + 2], data[offset + 3]); + offset += 4; + const bitCount = readUint32LE(data, offset); offset += 4; + offset += 16; // Skip RGBA masks + + const caps = readUint32LE(data, offset); offset += 4; + const caps2 = readUint32LE(data, offset); + + return { + flags, + height, + width, + pitch, + depth, + mmcount, + pixFormat: { + flags: pfFlags, + fourCC, + bitCount + }, + caps, + caps2 + }; +} + +/** + * Calculates the total size of a mipmap chain for a texture + */ +function calculateMipChainSize( + width: number, + height: number, + mipCount: number, + formatCode: string, + bitCount: number +): number { + let totalBytes = 0; + let w = width; + let h = height; + + // Determine block size based on format + let blockSize = 0; + if (formatCode === "DXT1") { + blockSize = 8; + } else if (formatCode === "DXT3" || formatCode === "DXT5") { + blockSize = 16; + } + + // Determine bytes per pixel for uncompressed formats + let bpp = 4; + if (blockSize === 0) { + if (bitCount === 24) { + bpp = 3; + } else if (bitCount === 8) { + bpp = 1; + } else { + bpp = 4; + } + } + + for (let i = 0; i < Math.max(1, mipCount); i++) { + if (blockSize > 0) { + // Block-compressed format + const blocksWide = Math.max(1, Math.floor((w + 3) / 4)); + const blocksHigh = Math.max(1, Math.floor((h + 3) / 4)); + totalBytes += blocksWide * blocksHigh * blockSize; + } else { + // Uncompressed format + totalBytes += w * h * bpp; + } + + w = Math.max(1, Math.floor(w / 2)); + h = Math.max(1, Math.floor(h / 2)); + } + + return totalBytes; +} + diff --git a/src/utex.dds.js b/src/utex.dds.js index c2b3e35..f132779 100644 --- a/src/utex.dds.js +++ b/src/utex.dds.js @@ -609,6 +609,29 @@ UTEX.DDS = { } else { throw ("unknown bit count "+bc); } + } else if (pf.flags&C.DDPF_RGB) { + // RGB without alpha channel (24-bit) + if (bc===24) { + for (var i=0; i>2)*3; + img[i+0] = data[idx+2]; // R + img[i+1] = data[idx+1]; // G + img[i+2] = data[idx+0]; // B + img[i+3] = 255; // A (fully opaque) + } + offset += (w * h * 3); + } else if (bc===32) { + // 32-bit RGB (with unused alpha or padding) + for (var i=0; i { - await fs.promises.mkdir("test/output"); + await fs.promises.mkdir("test/output", { recursive: true }); }); test("everything", async () => { - const mapPath = path.join(testMapsDir, "barren_2.sd7"); + const mapPath = path.join(testMapsDir, "hooked_1.1.1.sd7"); const parser = new MapParser({ verbose: false, @@ -39,15 +39,9 @@ test("everything", async () => { await map.miniMap.quality(50).writeAsync("test/output/mini.jpg"); expect(fs.existsSync("test/output/mini.jpg")).toBe(true); - await map.resources!.detailTex?.writeAsync("test/output/detailTex.png"); - expect(fs.existsSync("test/output/detailTex.png")).toBe(true); - await map.resources!.specularTex?.writeAsync("test/output/specularTex.png"); expect(fs.existsSync("test/output/specularTex.png")).toBe(true); - await map.resources!.splatDetailTex?.writeAsync("test/output/splatDetailTex.png"); - expect(fs.existsSync("test/output/splatDetailTex.png")).toBe(true); - await map.resources!.splatDetailNormalTex1?.writeAsync("test/output/splatDetailNormalTex1.png"); expect(fs.existsSync("test/output/splatDetailNormalTex1.png")).toBe(true); }, 60000); @@ -64,4 +58,4 @@ test("sdz", async () => { afterAll(async () => { await fs.promises.rmdir("test/output", { recursive: true }); -}); \ No newline at end of file +}); diff --git a/test/skybox.test.ts b/test/skybox.test.ts new file mode 100644 index 0000000..63b8979 --- /dev/null +++ b/test/skybox.test.ts @@ -0,0 +1,59 @@ +import fs from "fs"; +import * as path from "path"; + +import { MapParser } from "../dist/map-parser"; + +const testDir = "test"; +const testMapsDir = path.join(testDir, "test_maps"); + +beforeAll(async () => { + await fs.promises.mkdir("test/output", { recursive: true }); +}); + +test("skybox parsing - hooked", async () => { + const mapPath = path.join(testMapsDir, "hooked_1.1.1.sd7"); + + const parser = new MapParser({ + verbose: false, + mipmapSize: 4, + skipSmt: true, + parseSkybox: true, + }); + + const map = await parser.parseMap(mapPath); + + // Check map parsed correctly + expect(map).toBeDefined(); + expect(map.mapInfo).toBeDefined(); + expect(map.skybox).toBeDefined(); + + // Check that it's a 2:1 aspect ratio (equirectangular) + const width = map.skybox!.getWidth(); + const height = map.skybox!.getHeight(); + expect(width / height).toBe(2); + + // Save the skybox for visual inspection + await map.skybox!.writeAsync("test/output/skybox.png"); + expect(fs.existsSync("test/output/skybox.png")).toBe(true); +}, 240000); + +test("skybox disabled - hooked", async () => { + const mapPath = path.join(testMapsDir, "hooked_1.1.1.sd7"); + + const parser = new MapParser({ + verbose: false, + mipmapSize: 4, + skipSmt: true, + }); + + const map = await parser.parseMap(mapPath); + + // Check map parsed correctly + expect(map).toBeDefined(); + expect(map.mapInfo).toBeDefined(); + expect(map.skybox).not.toBeDefined(); +}); + +afterAll(async () => { + await fs.promises.rmdir("test/output", { recursive: true }); +}); diff --git a/test/test_maps/barren_2.sd7 b/test/test_maps/hooked_1.1.1.sd7 similarity index 57% rename from test/test_maps/barren_2.sd7 rename to test/test_maps/hooked_1.1.1.sd7 index 884f3da..8d1aa48 100644 Binary files a/test/test_maps/barren_2.sd7 and b/test/test_maps/hooked_1.1.1.sd7 differ