From 3f468dfeee31d6f102cc7b1a91a327c47da4996b Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Tue, 3 Feb 2026 21:00:58 -0600 Subject: [PATCH] Improve failure modes, fix DXT1 lookup in transparency mode --- .gitignore | 2 + src/map-parser.ts | 276 +++++++++++++++++++++++++++++++++++++--------- src/parse-dxt.ts | 133 +++++++++++----------- 3 files changed, 292 insertions(+), 119 deletions(-) diff --git a/.gitignore b/.gitignore index 5ede78e..b8b5f1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea/ dist node_modules /working-files @@ -7,3 +8,4 @@ node_modules /texture.png /test.jpg /test.png +/test/output/ diff --git a/src/map-parser.ts b/src/map-parser.ts index d1bac7b..407af60 100644 --- a/src/map-parser.ts +++ b/src/map-parser.ts @@ -73,7 +73,9 @@ export class MapParser { const fileExt = filePath.ext; const tempArchiveDir = path.join(os.tmpdir(), fileName); - const sigintBinding = process.on("SIGINT", async () => this.sigint(tempArchiveDir)); + // register a named handler so we can remove only our listener later + const sigintHandler = async () => this.sigint(tempArchiveDir); + process.on("SIGINT", sigintHandler); try { if (fileExt !== ".sd7" && fileExt !== ".sdz") { @@ -119,7 +121,8 @@ export class MapParser { scriptName = archive.smfName; } - sigintBinding.removeAllListeners(); + // remove only our SIGINT listener + process.removeListener("SIGINT", sigintHandler); let resources: Record | undefined; if (this.config.parseResources) { @@ -146,14 +149,14 @@ export class MapParser { }; } catch (err: any) { await this.cleanup(tempArchiveDir); - sigintBinding.removeAllListeners(); + process.removeListener("SIGINT", sigintHandler); console.error(err); throw err; } } protected async extractSd7(sd7Path: string, outPath: string): Promise<{ smf: Buffer, smt: Buffer, smd?: Buffer, smfName?: string, mapInfo?: Buffer, specular?: Jimp }> { - return new Promise(async resolve => { + return new Promise(async (resolve, reject) => { if (this.config.verbose) { console.log(`Extracting .sd7 to ${outPath}`); } @@ -170,8 +173,16 @@ export class MapParser { }); extractStream.on("end", async () => { - const archiveFiles = await this.extractArchiveFiles(outPath); - resolve(archiveFiles); + try { + const archiveFiles = await this.extractArchiveFiles(outPath); + resolve(archiveFiles); + } catch (err) { + reject(err); + } + }); + + extractStream.on("error", (err: any) => { + reject(err); }); }); } @@ -262,36 +273,60 @@ export class MapParser { const level = percent * 255; return [level, level, level, 255]; }); - const heightMap = new Jimp({ - data: Buffer.from(heightMapColors.flat()), - width: mapWidth + 1, - height: mapHeight + 1 - }); + const hmBuf = Buffer.from(heightMapColors.flat()); + + let heightMap: Jimp; + try { + heightMap = new Jimp({ data: hmBuf, width: mapWidth + 1, height: mapHeight + 1 }); + } catch (err) { + const empty = Buffer.alloc((mapWidth + 1) * (mapHeight + 1) * 4, 0); + heightMap = new Jimp({ data: empty, width: mapWidth + 1, height: mapHeight + 1 }); + } const typeMapSize = (mapWidth/2) * (mapHeight/2); const typeMapBuffer = smfBuffer.slice(typeMapIndex, typeMapIndex + typeMapSize); - const typeMap = new Jimp({ - data: singleChannelToQuadChannel(typeMapBuffer), - width: mapWidth / 2, - height: mapHeight / 2 - }); + const tmBuf = singleChannelToQuadChannel(typeMapBuffer); + + let typeMap: Jimp; + try { + typeMap = new Jimp({ data: tmBuf, width: mapWidth / 2, height: mapHeight / 2 }); + } catch (err) { + const empty = Buffer.alloc((mapWidth / 2) * (mapHeight / 2) * 4, 0); + typeMap = new Jimp({ data: empty, width: mapWidth / 2, height: mapHeight / 2 }); + } + + // Calculate miniMap size from surrounding indices instead of hardcoding + let miniMapSize = 0; + if (metalMapIndex && metalMapIndex > miniMapIndex) { + miniMapSize = metalMapIndex - miniMapIndex; + } else if (featureMapIndex && featureMapIndex > miniMapIndex) { + miniMapSize = featureMapIndex - miniMapIndex; + } else { + miniMapSize = smfBuffer.length - miniMapIndex; + } - const miniMapSize = 699048; const miniMapBuffer = smfBuffer.slice(miniMapIndex, miniMapIndex + miniMapSize); const miniMapRgbaBuffer = parseDxt(miniMapBuffer, 1024, 1024); - const miniMap = new Jimp({ - data: miniMapRgbaBuffer, - width: 1024, - height: 1024 - }); + + let miniMap: Jimp; + try { + miniMap = new Jimp({ data: miniMapRgbaBuffer, width: 1024, height: 1024 }); + } catch (err) { + const empty = Buffer.alloc(1024 * 1024 * 4, 0); + miniMap = new Jimp({ data: empty, width: 1024, height: 1024 }); + } const metalMapSize = (mapWidth/2) * (mapHeight/2); const metalMapBuffer = smfBuffer.slice(metalMapIndex, metalMapIndex + metalMapSize); - const metalMap = new Jimp({ - data: singleChannelToQuadChannel(metalMapBuffer), - width: mapWidth / 2, - height: mapHeight / 2 - }); + const mmBuf = singleChannelToQuadChannel(metalMapBuffer); + + let metalMap: Jimp; + try { + metalMap = new Jimp({ data: mmBuf, width: mapWidth / 2, height: mapHeight / 2 }); + } catch (err) { + const empty = Buffer.alloc((mapWidth / 2) * (mapHeight / 2) * 4, 0); + metalMap = new Jimp({ data: empty, width: mapWidth / 2, height: mapHeight / 2 }); + } const tileIndexMapBufferStream = new BufferStream(smfBuffer.slice(tileIndexMapIndex)); const numOfTileFiles = tileIndexMapBufferStream.readInt(); @@ -328,27 +363,139 @@ export class MapParser { const tileSize = bufferStream.readInt(); const compressionType = bufferStream.readInt(); - const startIndex = mipmapSize === 32 ? 0 : mipmapSize === 16 ? 512 : mipmapSize === 8 ? 640 : 672; - const dxt1Size = Math.pow(mipmapSize, 2) / 2; - const rowLength = mipmapSize * 4; - - const refTiles: Buffer[][] = []; - for (let i=0; i 0 ? Math.floor(dataSize / numOfTiles) : 680; + + let TILE_STRIDE: number; + let bytesToRead: number; + let real_w = 4, real_h = 4; + + if (calcStride >= 512) { + TILE_STRIDE = 680; real_w = 32; real_h = 32; bytesToRead = 512; + } else { + TILE_STRIDE = calcStride; bytesToRead = calcStride; + if (calcStride >= 128) { real_w = 16; real_h = 16; } + else if (calcStride >= 32) { real_w = 8; real_h = 8; } + else { real_w = 4; real_h = 4; } + } + + const rowLength = real_w * 4; + + // We'll assemble tiles at the requested mipmapSize. When tiles are stored at larger sizes + // (e.g. 32x32) we'll decode at real_w/real_h then resize down to mipmapSize. If stored at + // smaller sizes we'll decode and scale up. + const assembledRowLength = mipmapSize * 4; + + // Prepare default empty tile to fill missing indices (mipmapSize rows) + const defaultRow = Buffer.alloc(assembledRowLength, 0); + const defaultTile: Buffer[] = []; + for (let r = 0; r < mipmapSize; r++) defaultTile.push(Buffer.from(defaultRow)); + + // pre-allocate refTiles with placeholders sized to mipmapSize + const refTiles: Buffer[][] = new Array(numOfTiles).fill(null).map(() => defaultTile.map(row => Buffer.from(row))); + + const uniqueIndices = Array.from(new Set(tileIndexes)); + const smtDataStart = headerSize; + let successCount = 0; + + for (const tileId of uniqueIndices) { + if (typeof tileId !== 'number') continue; + if (tileId < 0 || tileId >= numOfTiles) continue; + + const offset = smtDataStart + (tileId * TILE_STRIDE); + if (offset + bytesToRead > smtBuffer.length) continue; + + // Determine expected DXT length for this tile's native resolution + const dxtLen = (real_w * real_h) / 2; // bytes for DXT1 + + // If tiles are stored in 680-byte blocks with multiple mipmaps embedded, pick the correct offset + let dxtSlice: Buffer | null = null; + if (TILE_STRIDE === 680) { + const tileBlock = smtBuffer.slice(offset, Math.min(offset + TILE_STRIDE, smtBuffer.length)); + const startIndex = real_w === 32 ? 0 : real_w === 16 ? 512 : real_w === 8 ? 640 : 672; + dxtSlice = tileBlock.slice(startIndex, startIndex + dxtLen); + } else { + // Tiles are tightly packed per-mip; read only the expected DXT length + if (offset + dxtLen > smtBuffer.length) continue; + dxtSlice = smtBuffer.slice(offset, offset + dxtLen); + } + + if (!dxtSlice || dxtSlice.length < dxtLen) continue; + + try { + // Decode the tile at its native resolution using the exact DXT bytes + const refTileRGBABuffer = parseDxt(dxtSlice, real_w, real_h); + + // Create a temporary Jimp image to perform a nearest-neighbour resize to the requested mipmapSize + let tileImage = new Jimp({ data: Buffer.from(refTileRGBABuffer), width: real_w, height: real_h }); + if (real_w !== mipmapSize || real_h !== mipmapSize) { + tileImage = tileImage.resize(mipmapSize, mipmapSize, Jimp.RESIZE_NEAREST_NEIGHBOR); + } + + // Extract per-row buffers at the assembled mip size + const assembledRows: Buffer[] = []; + const tileData = tileImage.bitmap.data; + for (let k = 0; k < mipmapSize; k++) { + const pixelIndex = k * mipmapSize * 4; + const rowBuf = Buffer.from(tileData.slice(pixelIndex, pixelIndex + mipmapSize * 4)); + assembledRows.push(rowBuf); + } + refTiles[tileId] = assembledRows; + successCount++; + } catch (err) { + // ignore single tile failures + } + } + + if (successCount === 0) { + // fallback: try to decode sequentially like older implementation + bufferStream.destroy(); + for (let i=0; i smtBuffer.length) break; + try { + const dxtLen = (real_w * real_h) / 2; + let dxtSlice: Buffer | null = null; + if (TILE_STRIDE === 680) { + const tileBlock = smtBuffer.slice(offset, Math.min(offset + TILE_STRIDE, smtBuffer.length)); + const startIndex = real_w === 32 ? 0 : real_w === 16 ? 512 : real_w === 8 ? 640 : 672; + dxtSlice = tileBlock.slice(startIndex, startIndex + dxtLen); + } else { + if (offset + dxtLen > smtBuffer.length) continue; + dxtSlice = smtBuffer.slice(offset, offset + dxtLen); + } + + if (!dxtSlice || dxtSlice.length < dxtLen) continue; + + const refTileRGBABuffer = parseDxt(dxtSlice, real_w, real_h); + + let tileImage = new Jimp({ data: Buffer.from(refTileRGBABuffer), width: real_w, height: real_h }); + if (real_w !== mipmapSize || real_h !== mipmapSize) { + tileImage = tileImage.resize(mipmapSize, mipmapSize, Jimp.RESIZE_NEAREST_NEIGHBOR); + } + + const assembledRows: Buffer[] = []; + const tileData = tileImage.bitmap.data; + for (let k = 0; k < mipmapSize; k++) { + const pixelIndex = k * mipmapSize * 4; + const rowBuf = Buffer.from(tileData.slice(pixelIndex, pixelIndex + mipmapSize * 4)); + assembledRows.push(rowBuf); + } + refTiles[i] = assembledRows; + } catch (err) { + // ignore + } } - refTiles.push(refTile); + } else { + bufferStream.destroy(); } const tiles: Buffer[][] = []; for (let i=0; i { @@ -394,7 +556,7 @@ export class MapParser { if (field.value.type === "StringLiteral" || field.value.type === "NumericLiteral" || field.value.type === "BooleanLiteral") { obj[field.key.name] = field.value.value; } else if (field.value.type === "UnaryExpression" && field.value.argument.type === "NumericLiteral") { - obj[field.key.name] = -field.value.argument.value; + obj[field.key.name] = -((field.value.argument as any).value); } else if (field.value.type === "TableConstructorExpression") { obj[field.key.name] = this.parseMapInfoFields(field.value.fields); } @@ -406,10 +568,14 @@ export class MapParser { } else if (field.type === "TableKey") { if (field.value.type === "StringLiteral" || field.value.type === "NumericLiteral" || field.value.type === "BooleanLiteral") { if (field.key.type === "NumericLiteral") { - arr[field.key.type] = field.value.value; + // use the numeric literal value as the array index (was using .type previously which is incorrect) + arr[(field.key as any).value] = field.value.value; } } else if (field.value.type === "UnaryExpression" && field.value.argument.type === "NumericLiteral") { - arr[field.key.type] = -field.value.argument.value; + // Ensure the key is a numeric literal before using .value, and assert types for the unary argument + if (field.key.type === "NumericLiteral") { + arr[(field.key as any).value] = -((field.value.argument as any).value); + } } else if (field.value.type === "TableConstructorExpression") { arr.push(this.parseMapInfoFields(field.value.fields)); } @@ -472,9 +638,9 @@ export class MapParser { return clone; } - protected joinTilesHorizontally(tiles: Buffer[][], mipmapSize: 4 | 8 | 16 | 32) : Buffer { + protected joinTilesHorizontally(tiles: Buffer[][], rows: number) : Buffer { const tileRows: Buffer[] = []; - for (let y=0; y> bitOffset % 8) & 3; + const bitOffset = i * 2; + const byte = 4 + Math.floor(bitOffset / 8); + const bits = (data[byte] >> (bitOffset % 8)) & 3; out[i * 4 + 0] = lookup[bits * 4 + 0]; out[i * 4 + 1] = lookup[bits * 4 + 1]; @@ -49,30 +49,31 @@ function decompress(width: number, height: number, data: Uint8Array) { throw new Error("Size of the texture is to small"); } - let w = width / BlockWidth; - let h = height / BlockHeight; - let blockNumber = w * h; + const w = width / BlockWidth; + const h = height / BlockHeight; + const blockNumber = w * h; //if (blockNumber * DXT1BlockSize != data.length) throw new Error("Data does not match dimensions"); - let out = new Uint8Array(width * height * 4); - let blockBuffer = new Uint8Array(RGBABlockSize); + const out = new Uint8Array(width * height * 4); + const blockBuffer = new Uint8Array(RGBABlockSize); for (let i = 0; i < blockNumber; i++) { - let decompressed = decompressBlockDXT1(data.slice(i * DXT1BlockSize, (i + 1) * DXT1BlockSize), blockBuffer); + const decompressed = decompressBlockDXT1(data.slice(i * DXT1BlockSize, (i + 1) * DXT1BlockSize), blockBuffer); - let pixelX = (i % w) * 4; - let pixelY = Math.floor(i / w) * 4; + const pixelX = (i % w) * 4; + const pixelY = Math.floor(i / w) * 4; let j = 0; for (let y = 0; y < 4; y++) { for (let x = 0; x < 4; x++) { - let px = x + pixelX; - let py = y + pixelY; - out[px * 4 + py * 4 * width] = decompressed[j]; - out[px * 4 + py * 4 * width + 1] = decompressed[j + 1]; - out[px * 4 + py * 4 * width + 2] = decompressed[j + 2]; - out[px * 4 + py * 4 * width + 3] = decompressed[j + 3]; + const px = x + pixelX; + const py = y + pixelY; + const outIndex = (py * width + px) * 4; + out[outIndex] = decompressed[j]; + out[outIndex + 1] = decompressed[j + 1]; + out[outIndex + 2] = decompressed[j + 2]; + out[outIndex + 3] = decompressed[j + 3]; j += 4; } } @@ -82,68 +83,70 @@ function decompress(width: number, height: number, data: Uint8Array) { } function generateDXT1Lookup(colorValue0: number, colorValue1: number, out = null) { - let color0 = getComponentsFromRGB565(colorValue0); - let color1 = getComponentsFromRGB565(colorValue1); + const color0 = getComponentsFromRGB565(colorValue0); + const color1 = getComponentsFromRGB565(colorValue1); - let lookup = out || new Uint8Array(16); + const lookup = out || new Uint8Array(16); if (colorValue0 > colorValue1) { // Non transparent mode - lookup[0] = Math.floor((color0.R) * 255); - lookup[1] = Math.floor((color0.G) * 255); - lookup[2] = Math.floor((color0.B) * 255); - lookup[3] = Math.floor(255); - - lookup[4] = Math.floor((color1.R) * 255); - lookup[5] = Math.floor((color1.G) * 255); - lookup[6] = Math.floor((color1.B) * 255); - lookup[7] = Math.floor(255); - - lookup[8] = Math.floor((color0.R * 2 / 3 + color1.R * 1 / 3) * 255); - lookup[9] = Math.floor((color0.G * 2 / 3 + color1.G * 1 / 3) * 255); - lookup[10] = Math.floor((color0.B * 2 / 3 + color1.B * 1 / 3) * 255); - lookup[11] = Math.floor(255); - - lookup[12] = Math.floor((color0.R * 1 / 3 + color1.R * 2 / 3) * 255); - lookup[13] = Math.floor((color0.G * 1 / 3 + color1.G * 2 / 3) * 255); - lookup[14] = Math.floor((color0.B * 1 / 3 + color1.B * 2 / 3) * 255); - lookup[15] = Math.floor(255); + lookup[0] = color0.R; + lookup[1] = color0.G; + lookup[2] = color0.B; + lookup[3] = 255; + + lookup[4] = color1.R; + lookup[5] = color1.G; + lookup[6] = color1.B; + lookup[7] = 255; + + lookup[8] = Math.floor((color0.R * 2 + color1.R * 1) / 3); + lookup[9] = Math.floor((color0.G * 2 + color1.G * 1) / 3); + lookup[10] = Math.floor((color0.B * 2 + color1.B * 1) / 3); + lookup[11] = 255; + + lookup[12] = Math.floor((color0.R * 1 + color1.R * 2) / 3); + lookup[13] = Math.floor((color0.G * 1 + color1.G * 2) / 3); + lookup[14] = Math.floor((color0.B * 1 + color1.B * 2) / 3); + lookup[15] = 255; } else { // transparent mode - lookup[0] = Math.floor((color0.R) * 255); - lookup[1] = Math.floor((color0.G) * 255); - lookup[2] = Math.floor((color0.B) * 255); - lookup[3] = Math.floor(255); - - lookup[4] = Math.floor((color0.R * 1 / 2 + color1.R * 1 / 2) * 255); - lookup[5] = Math.floor((color0.G * 1 / 2 + color1.G * 1 / 2) * 255); - lookup[6] = Math.floor((color0.B * 1 / 2 + color1.B * 1 / 2) * 255); - lookup[7] = Math.floor(255); - - lookup[8] = Math.floor((color1.R) * 255); - lookup[9] = Math.floor((color1.G) * 255); - lookup[10] = Math.floor((color1.B) * 255); - lookup[11] = Math.floor(255); - - lookup[12] = Math.floor(0); - lookup[13] = Math.floor(0); - lookup[14] = Math.floor(0); - lookup[15] = Math.floor(0); + lookup[0] = color0.R; + lookup[1] = color0.G; + lookup[2] = color0.B; + lookup[3] = 255; + + lookup[4] = color1.R; + lookup[5] = color1.G; + lookup[6] = color1.B; + lookup[7] = 255; + + lookup[8] = Math.floor((color0.R + color1.R) / 2); + lookup[9] = Math.floor((color0.G + color1.G) / 2); + lookup[10] = Math.floor((color0.B + color1.B) / 2); + lookup[11] = 255; + + lookup[12] = 0; + lookup[13] = 0; + lookup[14] = 0; + lookup[15] = 0; } return lookup; } -function getComponentsFromRGB565(color: any) { - return { - R: ((color & 0b11111000_00000000) >> 8) / 0xff, - G: ((color & 0b00000111_11100000) >> 3) / 0xff, - B: ((color & 0b00000000_00011111) << 3) / 0xff - }; +function getComponentsFromRGB565(color: number) { + // Simple bit shift approach matching Python implementation + // This produces smoother gradients with fewer artifacts than bit replication + const r = (color & 0xF800) >> 8; // 5 bits shifted to positions 3-7 + const g = (color & 0x07E0) >> 3; // 6 bits shifted to positions 2-7 + const b = (color & 0x001F) << 3; // 5 bits shifted to positions 3-7 + + return { R: r, G: g, B: b }; } function makeRGB565(r: any, g: any, b: any) { return ((r & 0b11111000) << 8) | ((g & 0b11111100) << 3) | ((b & 0b11111000) >> 3); -} \ No newline at end of file +}