const fs = require('fs').promises; const path = require("path"); class InputStream { constructor(arrayBuffer) { this.dataView = new DataView(arrayBuffer); this.offset = 0; } checkBounds(bytesToRead) { if (this.offset + bytesToRead > this.dataView.byteLength) { throw new EOFError(); } } readInt32() { this.checkBounds(4); const value = this.dataView.getInt32(this.offset, true); this.offset += 4; return value; } readInt16() { this.checkBounds(2); const value = this.dataView.getInt16(this.offset, true); this.offset += 2; return value; } readUint32() { this.checkBounds(4); const value = this.dataView.getUint32(this.offset, true); this.offset += 4; return value; } readUint16() { this.checkBounds(2); const value = this.dataView.getUint16(this.offset, true); this.offset += 2; return value; } readUint8() { this.checkBounds(1); const value = this.dataView.getUint8(this.offset); this.offset += 1; return value; } readString() { const length = this.readUint8(); this.checkBounds(length); const bytes = new Uint8Array(this.dataView.buffer, this.dataView.byteOffset + this.offset, length); this.offset += length; return new TextDecoder('ascii').decode(bytes); } skip(bytes) { this.checkBounds(bytes); this.offset += bytes; } setPos(pos) { this.offset = pos; } getPos() { return this.offset; } eof() { return this.offset >= this.dataView.byteLength; } } class EOFError extends Error { constructor(message = "End of file reached") { super(message); this.name = "EOFError"; } } class BufferUtils { /** * Creates a new ArrayBuffer and Stream from a slice of an existing buffer * @param {ArrayBuffer} sourceBuffer - The source ArrayBuffer to slice from * @param {number} offset - The starting offset in the source buffer * @param {number} length - The length of data to copy * @returns {{buffer: ArrayBuffer, stream: InputStream}} Object containing the new buffer and stream */ static createBufferSlice(sourceBuffer, offset, length) { // Create a new buffer of the specified length const newBuffer = new ArrayBuffer(length); const newArray = new Uint8Array(newBuffer); // Copy the data from the source buffer const sourceArray = new Uint8Array(sourceBuffer, offset, length); newArray.set(sourceArray); // Create and return a new stream for the buffer const stream = new InputStream(newBuffer); return { buffer: newBuffer, stream: stream }; } } class ImageryDatParser { static QUICKLOAD_FILE_ID = ('H'.charCodeAt(0)) | ('D'.charCodeAt(0) << 8) | ('R'.charCodeAt(0) << 16) | ('S'.charCodeAt(0) << 24); static QUICKLOAD_FILE_VERSION = 1; static MAX_IMAGERY_FILENAME_LENGTH = 80; // MAXIMFNAMELEN static MAXANIMNAME = 32; static gameDir = ""; static async loadFile(filePath, gameDir) { try { this.gameDir = gameDir; const buffer = await fs.readFile(filePath); const arrayBuffer = buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ); return await ImageryDatParser.parse(arrayBuffer); } catch (error) { console.error('Error loading IMAGERY.DAT file:', error); debugger; return null; } } static async parse(arrayBuffer) { const stream = new InputStream(arrayBuffer); // Read QuickLoad Header const header = { id: stream.readUint32(), version: stream.readUint32(), numHeaders: stream.readUint32() }; // Validate header if (header.id !== this.QUICKLOAD_FILE_ID) { throw new Error('Invalid IMAGERY.DAT file ID'); } if (header.version !== this.QUICKLOAD_FILE_VERSION) { throw new Error('Unsupported IMAGERY.DAT version'); } // Read imagery entries const entries = []; for (let i = 0; i < header.numHeaders; i++) { // Read filename (fixed-length buffer) const filenameBytes = new Uint8Array(arrayBuffer, stream.getPos(), this.MAX_IMAGERY_FILENAME_LENGTH); stream.skip(this.MAX_IMAGERY_FILENAME_LENGTH); // Convert to string until first null terminator let filename = ''; for (let j = 0; j < filenameBytes.length; j++) { if (filenameBytes[j] === 0) break; filename += String.fromCharCode(filenameBytes[j]); } // Read header size const headerSize = stream.readUint32(); // Read imagery header const imageryHeader = this.parseImageryHeader(stream, headerSize, arrayBuffer); // Read imagery body const imageryBody = await this.parseImageryBody(filename, imageryHeader); // Add entry to the list entries.push({ filename, headerSize, header: imageryHeader, body: imageryBody }); } return { header, entries }; } static parseImageryHeader(stream, headerSize, arrayBuffer) { const startPos = stream.getPos(); const header = { imageryId: ImageryType.getName(stream.readUint32()), // Id number for imagery handler (index to builder array) numStates: stream.readInt32(), // Number of states states: [] }; // Read state headers for (let i = 0; i < header.numStates; i++) { // Read animation name first const animNameBytes = new Uint8Array(arrayBuffer, stream.getPos(), this.MAXANIMNAME); stream.skip(this.MAXANIMNAME); let animName = ''; for (let j = 0; j < animNameBytes.length; j++) { if (animNameBytes[j] === 0) break; animName += String.fromCharCode(animNameBytes[j]); } const state = { animName, // Array of Ascii Names walkMap: stream.readUint32(), // Walkmap (OFFSET type) flags: new ObjectFlags(stream.readUint32()), // Imagery state flags (DWORD) aniFlags: new AnimationFlags(stream.readInt16()), // Animation state flags (short) frames: stream.readInt16(), // Number of frames (short) width: stream.readInt16(), // Graphics maximum width (short) height: stream.readInt16(), // Graphics maximum height (short) regX: stream.readInt16(), // Registration point x for graphics (short) regY: stream.readInt16(), // Registration point y for graphics (short) regZ: stream.readInt16(), // Registration point z for graphics (short) animRegX: stream.readInt16(), // Registration point of animation x (short) animRegY: stream.readInt16(), // Registration point of animation y (short) animRegZ: stream.readInt16(), // Registration point of animation z (short) wRegX: stream.readInt16(), // World registration x of walk and bounding box (short) wRegY: stream.readInt16(), // World registration y of walk and bounding box (short) wRegZ: stream.readInt16(), // World registration z of walk and bounding box (short) wWidth: stream.readInt16(), // Object's world width for walk map and bound box (short) wLength: stream.readInt16(), // Object's world length for walk map and bound box (short) wHeight: stream.readInt16(), // Object's world height for walk map and bound box (short) invAniFlags: new AnimationFlags(stream.readInt16()), // Animation flags for inventory animation (short) invFrames: stream.readInt16() // Number of frames of inventory animation (short) }; header.states.push(state); } // Ensure we've read exactly headerSize bytes const bytesRead = stream.getPos() - startPos; if (bytesRead < headerSize) { stream.skip(headerSize - bytesRead); } return header; } static async parseImageryBody(filename, imageryHeader) { // The imagery body is actually stored in separate .I2D or .I3D files // We need to load these files using the CGSResourceParser // First, determine if it's a 2D or 3D imagery based on the filename extension const extension = path.extname(filename).toLowerCase(); const is3D = extension === '.i3d'; // Create a result object to store all imagery data const result = { filename, header: imageryHeader, is3D, states: [] }; // For each state in the imagery header, load its corresponding resource file for (let i = 0; i < imageryHeader.numStates; i++) { const state = imageryHeader.states[i]; // Construct the resource filename // The resource files are typically stored in the Imagery directory const resourcePath = filename; try { // Load the resource file const resource = await DatParser.loadResourceFile(this.gameDir, resourcePath); if (resource) { // Add the loaded resource data to our state result.states.push({ ...state, resource: resource, bitmaps: resource.bitmaps.map(bitmap => ({ ...bitmap, })) }); } else { console.warn(`Failed to load resource for state ${i} of ${filename}`); result.states.push({ ...state, resource: null, bitmaps: [] }); } } catch (error) { console.error(`Error loading resource for state ${i} of ${filename}:`, error); result.states.push({ ...state, resource: null, bitmaps: [] }); } } return result; } } class ImageryType { static ANIMATION = 0; static MESH3D = 1; static MESH3DHELPER = 2; static MULTI = 3; static MULTIANIMATION = 4; static getName(id) { switch (id) { case this.ANIMATION: return 'ANIMATION'; case this.MESH3D: return 'MESH3D'; case this.MESH3DHELPER: return 'MESH3DHELPER'; case this.MULTI: return 'MULTI'; case this.MULTIANIMATION: return 'MULTIANIMATION'; default: return 'UNKNOWN'; } } static isValid(id) { return id >= this.ANIMATION && id <= this.MULTIANIMATION; } } class AnimationFlags { constructor(value) { // Convert number to 16-bit binary string (animation flags are 16-bit) const bits = (value >>> 0).toString(2).padStart(16, '0'); // Parse all animation flags this.looping = !!parseInt(bits[15 - 0]); // Circles back to original position this.faceMotion = !!parseInt(bits[15 - 1]); // This animation has facing motion data this.interFrame = !!parseInt(bits[15 - 2]); // Uses interframe compression this.noReg = !!parseInt(bits[15 - 3]); // Use 0,0 of FLC as registration pt of animation this.synchronize = !!parseInt(bits[15 - 4]); // Causes animation frame to match 'targets' animation frame this.move = !!parseInt(bits[15 - 5]); // Use the deltas in the ani to move the x,y position this.noInterpolation = !!parseInt(bits[15 - 6]); // Prevents system from interpolating between animations this.pingPong = !!parseInt(bits[15 - 7]); // Pingpong the animation this.reverse = !!parseInt(bits[15 - 8]); // Play the animation backwards this.noRestore = !!parseInt(bits[15 - 9]); // Draw to screen but don't bother to restore this.root = !!parseInt(bits[15 - 10]); // This animation is a root state this.fly = !!parseInt(bits[15 - 11]); // This animation is a flying animation (jump, etc.) this.sync = !!parseInt(bits[15 - 12]); // Synchronize all animations on screen this.noMotion = !!parseInt(bits[15 - 13]); // Ignore motion deltas this.accurateKeys = !!parseInt(bits[15 - 14]); // Has only high accuracy 'code' style 3D ani keys this.root2Root = !!parseInt(bits[15 - 15]); // This animation starts at root and returns to root } // Helper method to check if animation is a root animation isRootAnimation() { return this.root || this.root2Root; } // Helper method to check if animation involves motion hasMotion() { return this.move && !this.noMotion; } // Helper method to check if animation needs synchronization needsSync() { return this.synchronize || this.sync; } // Helper method to check if animation loops in some way isLooping() { return this.looping || this.pingPong; } // Helper method to get playback direction getPlaybackDirection() { if (this.pingPong) return 'pingpong'; if (this.reverse) return 'reverse'; return 'forward'; } } class BitmapFlags { constructor(value) { // Convert number to 32-bit binary string const bits = (value >>> 0).toString(2).padStart(32, '0'); // Bit depth flags this.bm_8bit = !!parseInt(bits[31 - 0]); // Bitmap data is 8 bit this.bm_15bit = !!parseInt(bits[31 - 1]); // Bitmap data is 15 bit this.bm_16bit = !!parseInt(bits[31 - 2]); // Bitmap data is 16 bit this.bm_24bit = !!parseInt(bits[31 - 3]); // Bitmap data is 24 bit this.bm_32bit = !!parseInt(bits[31 - 4]); // Bitmap data is 32 bit // Buffer flags this.bm_zbuffer = !!parseInt(bits[31 - 5]); // Bitmap has ZBuffer this.bm_normals = !!parseInt(bits[31 - 6]); // Bitmap has Normal Buffer this.bm_alias = !!parseInt(bits[31 - 7]); // Bitmap has Alias Buffer this.bm_alpha = !!parseInt(bits[31 - 8]); // Bitmap has Alpha Buffer this.bm_palette = !!parseInt(bits[31 - 9]); // Bitmap has 256 Color SPalette Structure // Special flags this.bm_regpoint = !!parseInt(bits[31 - 10]); // Bitmap has registration point this.bm_nobitmap = !!parseInt(bits[31 - 11]); // Bitmap has no pixel data this.bm_5bitpal = !!parseInt(bits[31 - 12]); // Bitmap palette is 5 bit for r,g,b instead of 8 bit this.bm_compressed = !!parseInt(bits[31 - 14]); // Bitmap is compressed this.bm_chunked = !!parseInt(bits[31 - 15]); // Bitmap is chunked out } // Helper methods to check bit depth getBitDepth() { if (this.bm_8bit) return 8; if (this.bm_15bit) return 15; if (this.bm_16bit) return 16; if (this.bm_24bit) return 24; if (this.bm_32bit) return 32; return 0; } // Helper method to get bytes per pixel getBytesPerPixel() { if (this.bm_8bit) return 1; if (this.bm_15bit || this.bm_16bit) return 2; if (this.bm_24bit) return 3; if (this.bm_32bit) return 4; return 0; } // Helper method to check if bitmap needs palette needsPalette() { return this.bm_8bit && this.bm_palette; } // Helper method to check if bitmap is valid isValid() { // Check that only one bit depth flag is set const bitDepthFlags = [ this.bm_8bit, this.bm_15bit, this.bm_16bit, this.bm_24bit, this.bm_32bit ].filter(flag => flag).length; return bitDepthFlags === 1 || this.bm_nobitmap; } } class DrawModeFlags { constructor(value) { // Convert number to 32-bit binary string const bits = (value >>> 0).toString(2).padStart(32, '0'); // Clipping flags this.dm_noclip = !!parseInt(bits[31 - 0]); // Disables clipping when drawing this.dm_wrapclip = !!parseInt(bits[31 - 1]); // Enables wrap clipping this.dm_wrapclipsrc = !!parseInt(bits[31 - 2]); // Enables wrap clipping of source buffer this.dm_nowrapclip = !!parseInt(bits[31 - 26]); // Overrides surface clipping mode to not wrap clip // Drawing mode flags this.dm_stretch = !!parseInt(bits[31 - 3]); // Enables Stretching when drawing this.dm_background = !!parseInt(bits[31 - 4]); // Draws bitmap to background this.dm_norestore = !!parseInt(bits[31 - 5]); // Disables automatic background restoring this.dm_fill = !!parseInt(bits[31 - 29]); // Fills the destination with the current color // Orientation flags this.dm_reversevert = !!parseInt(bits[31 - 6]); // Reverses vertical orientation this.dm_reversehorz = !!parseInt(bits[31 - 7]); // Reverses horizontal orientation // Transparency and effects flags this.dm_transparent = !!parseInt(bits[31 - 8]); // Enables transparent drawing this.dm_shutter = !!parseInt(bits[31 - 14]); // Enable Shutter transparent drawing this.dm_translucent = !!parseInt(bits[31 - 15]); // Enables Translucent drawing this.dm_fade = !!parseInt(bits[31 - 16]); // Fade image to key color // Buffer flags this.dm_zmask = !!parseInt(bits[31 - 9]); // Enables ZBuffer Masking this.dm_zbuffer = !!parseInt(bits[31 - 10]); // Draws bitmap ZBuffer to destination ZBuffer this.dm_normals = !!parseInt(bits[31 - 11]); // Draws bitmap Normals to dest. Normal buffer this.dm_zstatic = !!parseInt(bits[31 - 23]); // Draws bitmap at a static z value this.dm_nocheckz = !!parseInt(bits[31 - 28]); // Causes ZBuffer Draws to use transparency only // Enhancement flags this.dm_alias = !!parseInt(bits[31 - 12]); // Antiailiases edges using bitmap alias data this.dm_alpha = !!parseInt(bits[31 - 13]); // Enables Alpha drawing this.dm_alphalighten = !!parseInt(bits[31 - 24]);// Enables Alpha drawing lighten only // Color modification flags this.dm_changecolor = !!parseInt(bits[31 - 19]); // Draw in a different color this.dm_changehue = !!parseInt(bits[31 - 20]); // Use color to modify hue of image this.dm_changesv = !!parseInt(bits[31 - 21]); // Use color to modify saturation and brightness // Special flags this.dm_usereg = !!parseInt(bits[31 - 17]); // Draws bitmap based on registration point this.dm_selected = !!parseInt(bits[31 - 18]); // Draw selection highlight around bitmap this.dm_nodraw = !!parseInt(bits[31 - 22]); // Prevents bitmap graphics buffer from drawing this.dm_doescallback = !!parseInt(bits[31 - 25]);// Flag set by low level draw func this.dm_nohardware = !!parseInt(bits[31 - 27]); // Force no hardware use this.dm_usedefault = !!parseInt(bits[31 - 31]); // Causes draw routines to supply default value } // Helper methods isDefault() { return this.value === 0; } hasTransparencyEffect() { return this.dm_transparent || this.dm_translucent || this.dm_shutter || this.dm_alpha || this.dm_alphalighten; } hasColorModification() { return this.dm_changecolor || this.dm_changehue || this.dm_changesv; } isFlipped() { return this.dm_reversevert || this.dm_reversehorz; } usesZBuffer() { return this.dm_zmask || this.dm_zbuffer || this.dm_zstatic || !this.dm_nocheckz; } toString() { const flags = []; for (const [key, value] of Object.entries(this)) { if (value === true) { flags.push(key); } } return flags.join(' | ') || 'DM_DEFAULT'; } } class ChunkHeader { constructor(stream) { // Read the fixed part of the header this.type = stream.readUint32(); // Compressed flag this.width = stream.readInt32(); // Width in blocks this.height = stream.readInt32(); // Height in blocks // Validate dimensions if (this.width <= 0 || this.height <= 0 || this.width > 128 || this.height > 128) { throw new Error(`Invalid chunk header dimensions: ${this.width}x${this.height}`); } // Read the flexible array of block offsets const numBlocks = this.width * this.height; this.blocks = new Array(numBlocks); for (let i = 0; i < numBlocks; i++) { let currentOffset = stream.getPos(); let relativeOffset = stream.readUint32() // If the offset is 0, it means the block is empty if (relativeOffset === 0) { this.blocks[i] = 0; } else { // Otherwise, it's a relative offset from the start of the bitmap data this.blocks[i] = relativeOffset + currentOffset; } } } // Helper method to check if a block is blank isBlockBlank(x, y) { const blockIndex = this.getBlockIndex(x, y); return this.blocks[blockIndex] === 0; } // Helper method to get block index from x,y coordinates getBlockIndex(x, y) { if (x < 0 || x >= this.width || y < 0 || y >= this.height) { return -1; } return y * this.width + x; } getBlockSize(x, y) { const currentIndex = this.getBlockIndex(x, y); if (currentIndex === -1 || this.blocks[currentIndex] === 0) { return 0; } const currentOffset = this.blocks[currentIndex]; // Look for the next non-zero block offset for (let i = currentIndex + 1; i < this.blocks.length; i++) { if (this.blocks[i] !== 0) { return this.blocks[i] - currentOffset; } } // If we didn't find any non-zero blocks after this one, // or if this is the last block, return 0 return 0; } // Helper method to get block offset from x,y coordinates getBlockOffset(x, y) { const index = this.getBlockIndex(x, y); return index >= 0 ? this.blocks[index] : 0; } } class ChunkDecompressor { static decompressChunk(stream, blockWidth, blockHeight, clear = 1) { // Get chunk number from stream const number = stream.readUint8(); const bite1 = stream.readUint8(); const bite2 = stream.readUint8(); const bite3 = stream.readUint8(); console.log(`Decompressing chunk ${number} with flags ${bite1} ${bite2} ${bite3}`); // Get compression markers const rleMarker = stream.readUint8(); const lzMarker = stream.readUint8(); // Create destination buffer (start with expected size, but might grow) // Create destination buffer with the correct size let dest = new Uint8Array(blockWidth * blockHeight); const clearValue = clear === 1 ? 0x00 : 0xFF; dest.fill(clearValue); let dstPos = 0; try { while (!stream.eof()) { const byte = stream.readUint8(); if (byte === rleMarker) { // RLE compression let count = stream.readUint8(); if (count & 0x80) { // Skip RLE count &= 0x7F; dstPos += count; } else { // Normal RLE const value = stream.readUint8(); // Ensure dest array is large enough if (dstPos + count > dest.length) { const newDest = new Uint8Array(dest.length * 2); newDest.set(dest); dest = newDest; } for (let i = 0; i < count; i++) { dest[dstPos++] = value; } } } else if (byte === lzMarker) { // LZ compression const count = stream.readUint8(); const offset = stream.readUint16(); // Ensure dest array is large enough if (dstPos + count > dest.length) { const newDest = new Uint8Array(dest.length * 2); newDest.set(dest); dest = newDest; } // Copy from earlier in the output for (let i = 0; i < count; i++) { dest[dstPos] = dest[dstPos - offset]; dstPos++; } } else { // Raw byte // Ensure dest array is large enough if (dstPos >= dest.length) { const newDest = new Uint8Array(dest.length * 2); newDest.set(dest); dest = newDest; } dest[dstPos++] = byte; } } } catch (e) { if (!(e instanceof EOFError)) { console.error("Error decompressing chunk:", e); debugger; } } // Trim the array to actual size const finalDest = new Uint8Array(dstPos); finalDest.set(dest.subarray(0, dstPos)); return { number, data: finalDest }; } } class BitmapData { static readBitmap(stream, arrayBuffer) { // Read the fixed-size header structure const bitmap = { width: stream.readInt32(), height: stream.readInt32(), regx: stream.readInt32(), regy: stream.readInt32(), flags: new BitmapFlags(stream.readUint32()), drawmode: new DrawModeFlags(stream.readUint32()), keycolor: stream.readUint32(), }; // Read sizes and offsets, adjusting the offsets based on their position bitmap.aliassize = stream.readUint32(); const aliasOffsetPos = stream.getPos(); bitmap.alias = stream.readUint32(); if (bitmap.alias !== 0) { bitmap.alias += aliasOffsetPos; } bitmap.alphasize = stream.readUint32(); const alphaOffsetPos = stream.getPos(); bitmap.alpha = stream.readUint32(); if (bitmap.alpha !== 0) { bitmap.alpha += alphaOffsetPos; } bitmap.zbuffersize = stream.readUint32(); const zbufferOffsetPos = stream.getPos(); bitmap.zbuffer = stream.readUint32(); if (bitmap.zbuffer !== 0) { bitmap.zbuffer += zbufferOffsetPos; } bitmap.normalsize = stream.readUint32(); const normalOffsetPos = stream.getPos(); bitmap.normal = stream.readUint32(); if (bitmap.normal !== 0) { bitmap.normal += normalOffsetPos; } bitmap.palettesize = stream.readUint32(); const paletteOffsetPos = stream.getPos(); bitmap.palette = stream.readUint32(); if (bitmap.palette !== 0) { bitmap.palette += paletteOffsetPos; } bitmap.datasize = stream.readUint32(); // Sanity checks if (bitmap.width > 8192 || bitmap.height > 8192) { throw new Error('Corrupted bitmap dimensions'); } if (!bitmap.flags.isValid()) { throw new Error('Invalid bitmap flags configuration'); } // Handle pixel data if (bitmap.flags.bm_nobitmap) { // No pixel data bitmap.data = null; console.log('Bitmap has no pixel data'); return bitmap; } const baseOffset = stream.getPos(); const { buffer: bitmapBuffer, stream: bitmapStream } = BufferUtils.createBufferSlice( arrayBuffer, baseOffset, bitmap.datasize ); if (bitmap.flags.bm_compressed) { if (bitmap.flags.bm_chunked) { // The bitmap data directly points to a ChunkHeader const mainHeader = new ChunkHeader(bitmapStream); // Allocate the final bitmap data if (bitmap.flags.bm_8bit) { bitmap.data = new Uint8Array(bitmap.width * bitmap.height * 1); } else if (bitmap.flags.bm_15bit || bitmap.flags.bm_16bit) { bitmap.data = new Uint16Array(bitmap.width * bitmap.height * 1); } else if (bitmap.flags.bm_24bit) { // For 24-bit, we need to handle RGBTRIPLE structure bitmap.data = new Uint8Array(bitmap.width * bitmap.height * 3); } else if (bitmap.flags.bm_32bit) { bitmap.data = new Uint32Array(bitmap.width * bitmap.height * 1); } // Process each block for (let y = 0; y < mainHeader.height; y++) { for (let x = 0; x < mainHeader.width; x++) { if (mainHeader.isBlockBlank(x, y)) { // This is a blank block, skip it continue; } // Calculate the block width and height for each block const blockWidth = BitmapData.calculateBlockWidth(x, bitmap, mainHeader); const blockHeight = BitmapData.calculateBlockHeight(y, bitmap, mainHeader); const destX = x * BitmapData.calculateBlockWidth(0, bitmap, mainHeader); const destY = y * BitmapData.calculateBlockHeight(0, bitmap, mainHeader); // Process non-blank block const blockOffset = mainHeader.getBlockOffset(x, y); let blockSize = mainHeader.getBlockSize(x, y); if (blockSize === 0) { // This is a special case where the block size is 0, // which means it's the last block in the file blockSize = bitmapBuffer.byteLength - blockOffset; } const { buffer: blockBuffer, stream: blockStream } = BufferUtils.createBufferSlice( bitmapBuffer, blockOffset, blockSize ); const { number, data: decompressed } = ChunkDecompressor.decompressChunk(blockStream, blockWidth, blockHeight); // Copy the decompressed chunk to the right position if (bitmap.flags.bm_8bit) { // Copy each row of the decompressed data to the correct position for (let row = 0; row < blockHeight; row++) { // Skip if we're past the bitmap height if (destY + row >= bitmap.height) break; // Calculate source and destination positions for this row const srcOffset = row * blockWidth; const dstOffset = (destY + row) * bitmap.width + destX; // Calculate how many pixels to copy (handle edge cases) const pixelsToCopy = Math.min( blockWidth, // Pixels in this row in the block bitmap.width - destX, // Available width in destination decompressed.length - srcOffset // Available data in source ); // Copy the row bitmap.data.set( decompressed.subarray(srcOffset, srcOffset + pixelsToCopy), dstOffset ); } } else { console.warn('Unsupported bitmap format, will be implmented later' + bitmap.flags); debugger; } } } } else { // The bitmap data is compressed, but not chunked console.warn('Compressed, but not chunked bitmap data is not supported yet'); debugger; } } else { // Create a view into the pixel data based on the bit depth // This more closely matches the union structure in the C++ code if (bitmap.flags.bm_8bit) { bitmap.data = new Uint8Array(arrayBuffer, baseOffset, bitmap.datasize); } else if (bitmap.flags.bm_15bit || bitmap.flags.bm_16bit) { bitmap.data = new Uint16Array(arrayBuffer, baseOffset, bitmap.datasize / 2); } else if (bitmap.flags.bm_24bit) { // For 24-bit, we need to handle RGBTRIPLE structure bitmap.data = new Uint8Array(arrayBuffer, baseOffset, bitmap.datasize); } else if (bitmap.flags.bm_32bit) { bitmap.data = new Uint32Array(arrayBuffer, baseOffset, bitmap.datasize / 4); } } // Handle palette data if present if (bitmap.palettesize > 0 && bitmap.palette > 0) { const paletteOffset = bitmap.palette; const expectedSize = (256 * 2) + (256 * 4); // 256 * (2 bytes for colors + 4 bytes for rgbcolors) // Validate palette size if (bitmap.palettesize !== expectedSize) { console.warn(`Unexpected palette size: ${bitmap.palettesize} bytes (expected ${expectedSize} bytes)`); debugger; } // Validate that the palette data fits within the buffer if (paletteOffset + expectedSize <= arrayBuffer.byteLength) { const tempBuffer = new ArrayBuffer(expectedSize); const tempColors = new Uint16Array(tempBuffer, 0, 256); const tempRGBColors = new Uint32Array(tempBuffer, 512, 256); // Copy data byte by byte const view = new DataView(arrayBuffer); for (let i = 0; i < 256; i++) { tempColors[i] = view.getUint16(paletteOffset + i * 2, true); tempRGBColors[i] = view.getUint32(paletteOffset + 512 + i * 4, true); } bitmap.palette = { colors: tempColors, rgbcolors: tempRGBColors }; } else { console.warn('Palette data extends beyond buffer bounds'); } } // Handle additional buffers if present and not compressed if (!bitmap.flags.bm_compressed) { if (bitmap.flags.bm_zbuffer && bitmap.zbuffersize > 0) { // bitmap.zbuffer = new Uint16Array( // arrayBuffer, // baseOffset + bitmap.zbuffer, // bitmap.zbuffersize / 2 // ); } if (bitmap.flags.bm_normals && bitmap.normalsize > 0) { // bitmap.normal = new Uint16Array( // arrayBuffer, // baseOffset + bitmap.normal, // bitmap.normalsize / 2 // ); } if (bitmap.flags.bm_alpha && bitmap.alphasize > 0) { // bitmap.alpha = new Uint8Array( // arrayBuffer, // baseOffset + bitmap.alpha, // bitmap.alphasize // ); } if (bitmap.flags.bm_alias && bitmap.aliassize > 0) { // bitmap.alias = new Uint8Array( // arrayBuffer, // baseOffset + bitmap.alias, // bitmap.aliassize // ); } } return bitmap; } // Helper functions to calculate block dimensions static calculateBlockWidth(x, bitmap, mainHeader) { const baseBlockWidth = Math.floor(bitmap.width / mainHeader.width); if (x < mainHeader.width - 1) { return baseBlockWidth; } else { // Last block may be wider if bitmap.width is not perfectly divisible return bitmap.width - baseBlockWidth * (mainHeader.width - 1); } } static calculateBlockHeight(y, bitmap, mainHeader) { const baseBlockHeight = Math.floor(bitmap.height / mainHeader.height); if (y < mainHeader.height - 1) { return baseBlockHeight; } else { // Last block may be taller if bitmap.height is not perfectly divisible return bitmap.height - baseBlockHeight * (mainHeader.height - 1); } } } class BitmapRender { static async saveToBMP(bitmap, outputPath) { // First, let's validate the input if (!bitmap || !bitmap.width || !bitmap.height || !bitmap.data) { console.error('Invalid bitmap data'); debugger; return; } const headerSize = 14; const infoSize = 40; const bitsPerPixel = 24; const bytesPerPixel = bitsPerPixel / 8; const rowSize = Math.floor((bitsPerPixel * bitmap.width + 31) / 32) * 4; const paddingSize = rowSize - (bitmap.width * bytesPerPixel); const imageSize = rowSize * bitmap.height; const fileSize = headerSize + infoSize + imageSize; const buffer = Buffer.alloc(fileSize); // Write headers... buffer.write('BM', 0); buffer.writeUInt32LE(fileSize, 2); buffer.writeUInt32LE(0, 6); buffer.writeUInt32LE(headerSize + infoSize, 10); buffer.writeUInt32LE(infoSize, 14); buffer.writeInt32LE(bitmap.width, 18); buffer.writeInt32LE(bitmap.height, 22); buffer.writeUInt16LE(1, 26); buffer.writeUInt16LE(bitsPerPixel, 28); buffer.writeUInt32LE(0, 30); buffer.writeUInt32LE(imageSize, 34); buffer.writeInt32LE(0, 38); buffer.writeInt32LE(0, 42); buffer.writeUInt32LE(0, 46); buffer.writeUInt32LE(0, 50); let offset = headerSize + infoSize; // Pixel data writing with more defensive checks for (let y = bitmap.height - 1; y >= 0; y--) { for (let x = 0; x < bitmap.width; x++) { let r = 0, g = 0, b = 0; try { if (bitmap.flags.bm_8bit && bitmap.palette) { const index = y * bitmap.width + x; if (index < bitmap.data.length) { const paletteIndex = bitmap.data[index]; if (paletteIndex < 256) { if (bitmap.flags.bm_5bitpal) { // Use the 16-bit color value from colors array for 5-bit palette const colorData = bitmap.palette.colors[paletteIndex]; // Convert 16-bit color to RGB components const red = (colorData & 0xF800) >> 11; // Extract top 5 bits const green = (colorData & 0x07E0) >> 5; // Extract middle 6 bits const blue = (colorData & 0x001F); // Extract bottom 5 bits // Convert to 8-bit color values r = (red * 255) / 31; // Scale 5-bit to 8-bit g = (green * 255) / 63; // Scale 6-bit to 8-bit b = (blue * 255) / 31; // Scale 5-bit to 8-bit } else { // Use the 32-bit rgbcolors array for regular palette const rgbColor = bitmap.palette.rgbcolors[paletteIndex]; r = (rgbColor >> 16) & 0xFF; g = (rgbColor >> 8) & 0xFF; b = rgbColor & 0xFF; } } } } else if (bitmap.flags.bm_15bit) { const index = y * bitmap.width + x; if (index < bitmap.data.length) { const pixel = bitmap.data[index]; r = ((pixel & 0x7C00) >> 10) << 3; g = ((pixel & 0x03E0) >> 5) << 3; b = (pixel & 0x001F) << 3; } } else if (bitmap.flags.bm_16bit) { const index = y * bitmap.width + x; if (index < bitmap.data.length) { const pixel = bitmap.data[index]; r = ((pixel & 0xF800) >> 11) << 3; g = ((pixel & 0x07E0) >> 5) << 2; b = (pixel & 0x001F) << 3; } } else if (bitmap.flags.bm_24bit) { const pixelOffset = (y * bitmap.width + x) * 3; if (pixelOffset + 2 < bitmap.data.length) { b = bitmap.data[pixelOffset]; g = bitmap.data[pixelOffset + 1]; r = bitmap.data[pixelOffset + 2]; } } else if (bitmap.flags.bm_32bit) { const index = y * bitmap.width + x; if (index < bitmap.data.length) { const pixel = bitmap.data[index]; r = (pixel >> 16) & 0xFF; g = (pixel >> 8) & 0xFF; b = pixel & 0xFF; } } } catch (error) { console.warn(`Error processing pixel at ${x},${y}:`, error); // Continue with default black pixel } // Write the pixel buffer[offset++] = b; buffer[offset++] = g; buffer[offset++] = r; } offset += paddingSize; } await fs.writeFile(outputPath, buffer); } } // CRC32 calculation (needed for PNG format) function calculateCRC32(data) { let crc = -1; const crcTable = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { c = ((c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)); } crcTable[n] = c; } for (let i = 0; i < data.length; i++) { crc = crcTable[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8); } return crc ^ -1; } class CGSResourceParser { static RESMAGIC = 0x52534743; // 'CGSR' in little-endian static RESVERSION = 1; static async loadFile(filePath) { try { const buffer = await fs.readFile(filePath); const arrayBuffer = buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ); console.info(`Reading file: ${filePath}`); return await CGSResourceParser.parse(arrayBuffer, filePath); } catch (error) { console.error('Error loading CGS resource file:', error); debugger; return null; } } static async parse(arrayBuffer, filePath) { const stream = new InputStream(arrayBuffer); // Read FileResHdr according to the C++ structure const header = { resmagic: stream.readUint32(), // DWORD resmagic topbm: stream.readUint16(), // WORD topbm comptype: stream.readUint8(), // BYTE comptype version: stream.readUint8(), // BYTE version datasize: stream.readUint32(), // DWORD datasize objsize: stream.readUint32(), // DWORD objsize hdrsize: stream.readUint32(), // DWORD hdrsize imageryId: ImageryType.getName(stream.readUint32()), numStates: stream.readUint32(), }; // Validate magic number and version if (header.resmagic !== this.RESMAGIC) { throw new Error('Not a valid CGS resource file'); } if (header.version < this.RESVERSION) { throw new Error('Resource file version too old'); } if (header.version > this.RESVERSION) { throw new Error('Resource file version too new'); } if (header.objsize !== header.datasize) { console.warn('objsize does not match datasize'); debugger; } // Read imagery_header_meta for each state const imageryMetaData = []; for (let i = 0; i < header.numStates; i++) { const metaData = { ascii: new Uint8Array(32) // Initialize ASCII array }; // Read ASCII characters first for (let j = 0; j < 32; j++) { metaData.ascii[j] = stream.readUint8(); } // Convert ASCII array to string metaData.ascii = String.fromCharCode(...metaData.ascii) .replace(/\0/g, ''); // Remove null characters // Then read the rest of the fields metaData.walkmap = stream.readUint32(); // Walkmap metaData.imageryflags = new ObjectFlags(stream.readUint32()); // Imagery state flags metaData.aniflags = new AnimationFlags(stream.readUint16()); // Animation state flags metaData.frames = stream.readUint16(); // Number of frames metaData.widthmax = stream.readInt16(); // Graphics maximum width/height (for IsOnScreen and refresh rects) metaData.heightmax = stream.readUint16(); metaData.regx = stream.readInt16(); // Registration point x,y,z for graphics metaData.regy = stream.readUint16(); metaData.regz = stream.readUint16(); metaData.animregx = stream.readUint16(); // Registration point of animation metaData.animregy = stream.readUint16(); metaData.animregz = stream.readUint16(); metaData.wregx = stream.readUint16(); // World registration x and y of walk and bounding box info metaData.wregy = stream.readUint16(); metaData.wregz = stream.readUint16(); metaData.wwidth = stream.readUint16(); // Object's world width, length, and height for walk map and bound box metaData.wlength = stream.readUint16(); metaData.wheight = stream.readUint16(); metaData.invaniflags = new AnimationFlags(stream.readUint16()); // Animation flags for inventory animation metaData.invframes = stream.readUint16(); // Number of frames of inventory animation imageryMetaData.push(metaData); } // After all states are read, process walkmap data for states that have it for (const metaData of imageryMetaData) { if (metaData.walkmap !== 0 && metaData.wwidth > 0 && metaData.wlength > 0) { const walkmapSize = metaData.wwidth * metaData.wlength; metaData.walkmapData = new Uint8Array(walkmapSize); for (let j = 0; j < walkmapSize; j++) { metaData.walkmapData[j] = stream.readUint8(); } } } // Add padding to align to 4 bytes const totalWalkmapSize = imageryMetaData.reduce((sum, metaData) => sum + metaData.wwidth * metaData.wlength, 0); const padding = (4 - (totalWalkmapSize % 4)) % 4; if (padding > 0) { stream.skip(padding); } // Skip unknown data const unknownDataSize = header.hdrsize - 12 - (72 * header.numStates); if (unknownDataSize > 0) { // stream.skip(unknownDataSize); console.warn(`Attempt to skip ${unknownDataSize} bytes of unknown data prevented`); if (unknownDataSize > 4) { // debugger; } } // Read bitmap offsets const bitmapOffsets = []; for (let i = 0; i < header.topbm; i++) { bitmapOffsets.push(stream.readUint32()); } // Process bitmaps if present let bitmaps = []; if (bitmapOffsets && bitmapOffsets.length) { for (let i = 0; i < header.topbm; i++) { const currentOffset = bitmapOffsets[i]; // Calculate size: either difference to next offset, or remaining data const nextOffset = (i < header.topbm - 1) ? bitmapOffsets[i + 1] : header.datasize; const bitmapSize = nextOffset - currentOffset; // Create a new ArrayBuffer specifically for this bitmap const bitmapBuffer = new ArrayBuffer(bitmapSize); const bitmapData = new Uint8Array(bitmapBuffer); // Copy the bitmap data from the original buffer const sourceData = new Uint8Array( arrayBuffer, stream.getPos() + currentOffset, bitmapSize ); bitmapData.set(sourceData); // Create a stream with the isolated bitmap data const bitmapStream = new InputStream(bitmapBuffer); // Read bitmap (now starting from position 0 since we have isolated data) const bitmap = BitmapData.readBitmap(bitmapStream, bitmapBuffer); // Get the relative path and save bitmap const relativePath = filePath .split('Resources/')[1] .replace('.i2d', ''); const outputPath = path.join( '_OUTPUT', relativePath, `bitmap_${i}.bmp` ); await fs.mkdir(path.dirname(outputPath), { recursive: true }); await BitmapRender.saveToBMP(bitmap, outputPath); // Perform sanity checks and conversions if (bitmap.width > 8192 || bitmap.height > 8192) { throw new Error('Corrupted bitmap list in resource'); } if (bitmap.flags.bm_15bit) { this.convert15to16(bitmap); } if (bitmap.flags.bm_8bit) { this.convertPal15to16(bitmap); } bitmaps.push(bitmap); } } return { header, imageryMetaData, size: header.objsize, bitmaps }; } static convert15to16(bitmap) { if (!bitmap || !bitmap.data) { console.warn('No bitmap data, nothing to convert.') return; } // Convert 15-bit color to 16-bit color const data = new Uint16Array(bitmap.data.buffer); for (let i = 0; i < data.length; i++) { const color15 = data[i]; const r = (color15 & 0x7C00) >> 10; const g = (color15 & 0x03E0) >> 5; const b = color15 & 0x001F; data[i] = (r << 11) | (g << 6) | b; } bitmap.flags &= ~0x0002; // Clear BM_15BIT flag } static convertPal15to16(bitmap) { // Convert palette entries from 15-bit to 16-bit color for (let i = 0; i < bitmap.palette.length; i++) { const color15 = bitmap.palette[i]; const r = (color15.r & 0x7C00) >> 10; const g = (color15.g & 0x03E0) >> 5; const b = color15.b & 0x001F; bitmap.palette[i] = { r: (r << 11), g: (g << 6), b: b, a: color15.a }; } } } class ClassDefParser { static async loadFile(filePath) { try { const content = await fs.readFile(filePath, 'utf8'); return ClassDefParser.parse(content); } catch (error) { console.error('Error loading class definition file:', error); debugger; return null; } } static parse(content) { const result = { uniqueTypeId: null, classes: new Map() }; let currentClass = null; let currentSection = null; let inStats = false; let inObjStats = false; const lines = content.split('\n'); for (let line of lines) { line = line.trim(); if (line === '' || line.startsWith('//')) continue; if (line.startsWith('Unique Type ID')) { result.uniqueTypeId = parseInt(line.split('=')[1].trim(), 16); continue; } if (line.startsWith('CLASS')) { currentClass = { className: line.split('"')[1], stats: [], // Changed to array to maintain order objStats: [], // Changed to array to maintain order types: [] }; result.classes.set(currentClass.className, currentClass); currentSection = null; inStats = false; inObjStats = false; continue; } if (!currentClass) continue; if (line === 'STATS') { currentSection = 'stats'; inStats = false; continue; } else if (line === 'OBJSTATS') { currentSection = 'objStats'; inObjStats = false; continue; } else if (line === 'TYPES') { currentSection = 'types'; continue; } if (line === 'BEGIN') { if (currentSection === 'stats') inStats = true; if (currentSection === 'objStats') inObjStats = true; continue; } if (line === 'END') { inStats = false; inObjStats = false; continue; } if (inStats && currentSection === 'stats') { const parts = line.split(' ').filter(part => part !== ''); if (parts.length >= 5) { currentClass.stats.push({ name: parts[0], id: parts[1], default: parseInt(parts[2]), min: parseInt(parts[3]), max: parseInt(parts[4]) }); } } else if (inObjStats && currentSection === 'objStats') { const parts = line.split(' ').filter(part => part !== ''); if (parts.length >= 5) { currentClass.objStats.push({ name: parts[0], id: parts[1], default: parseInt(parts[2]), min: parseInt(parts[3]), max: parseInt(parts[4]) }); } } else if (currentSection === 'types') { // Modified regex to make the stats values optional const match = line.match(/"([^"]+)"\s+"([^"]+)"\s+(0x[0-9a-fA-F]+)(?:\s+{([^}]*)})?(?:\s+{([^}]*)})?/); if (match) { const values = match[4] ? match[4].split(',').map(v => parseInt(v.trim())) : []; const extra = match[5] ? match[5].split(',').map(v => v.trim()) : []; // Create mapped stats object only if stats exist const mappedStats = {}; if (currentClass.stats.length > 0) { currentClass.stats.forEach((stat, index) => { if (index < values.length) { mappedStats[stat.name] = { value: values[index], ...stat }; } }); } // Create mapped objStats object only if objStats exist const mappedObjStats = {}; if (currentClass.objStats.length > 0 && extra.length > 0) { currentClass.objStats.forEach((stat, index) => { if (index < extra.length) { mappedObjStats[stat.name] = { value: parseInt(extra[index]) || extra[index], ...stat }; } }); } currentClass.types.push({ name: match[1], model: match[2], id: parseInt(match[3], 16), ...(Object.keys(mappedStats).length > 0 && { stats: mappedStats }), ...(Object.keys(mappedObjStats).length > 0 && { objStats: mappedObjStats }) }); } } } return result; } } class DatParser { static SectorMapFCC = ('M'.charCodeAt(0) << 0) | ('A'.charCodeAt(0) << 8) | ('P'.charCodeAt(0) << 16) | (' '.charCodeAt(0) << 24); static MAXOBJECTCLASSES = 64; static OBJ_CLASSES = { 0: 'item', 1: 'weapon', 2: 'armor', 3: 'talisman', 4: 'food', 5: 'container', 6: 'lightsource', 7: 'tool', 8: 'money', 9: 'tile', 10: 'exit', 11: 'player', 12: 'character', 13: 'trap', 14: 'shadow', 15: 'helper', 16: 'key', 17: 'invcontainer', 18: 'poison', 19: 'unused1', 20: 'unused2', 21: 'ammo', 22: 'scroll', 23: 'rangedweapon', 24: 'unused3', 25: 'effect', 26: 'mapscroll' }; static OBJCLASS_TILE = 9; // Add static property for game directory static gameDir = ''; // Modify the main loading function to accept gameDir static async loadFile(filePath, gameDir) { this.gameDir = gameDir; // Store gameDir for resource loading try { const buffer = await fs.readFile(filePath); const arrayBuffer = buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ); return DatParser.parse(arrayBuffer); } catch (error) { console.error('Error loading file:', error); debugger; return null; } } static parse(buffer) { const stream = new InputStream(buffer); let version = 0; // Read number of objects let numObjects = stream.readInt32(); // Check if this is a sector map with header information if (numObjects === this.SectorMapFCC) { // Get sector map version version = stream.readInt32(); numObjects = stream.readInt32(); } // Array to store all loaded objects const objects = []; // Load each object for (let i = 0; i < numObjects; i++) { console.log(`Loading object ${i + 1} of ${numObjects}`); const obj = this.loadObject(stream, version, true); if (obj) { objects.push(obj); } } return { version, numObjects, objects }; } static classDefs = new Map(); static async loadClassDefinitions(gameDir) { const classDefPath = path.join(gameDir, 'Resources', 'class.def'); try { const classDefs = await ClassDefParser.loadFile(classDefPath); if (classDefs) { this.classDefs = classDefs; } } catch (error) { console.error('Error loading class definitions:', error); debugger; } } static loadObject(stream, version, isMap = false) { let uniqueId; let objVersion = 0; let objClass; let objType; let blockSize; let def = {}; let forcesimple = false; let corrupted = false; // ****** Load object block header ****** // Get object version if (version >= 8) { objVersion = stream.readInt16(); } if (objVersion < 0) { // Objversion is the placeholder in map version 8 or above return null; } objClass = stream.readInt16(); if (objClass < 0) { // Placeholder for empty object slot return null; } // Check the sector map version before we read the type info if (version < 1) { // Version 0 - No Unique ID's, so just read the objtype directly objType = stream.readInt16(); uniqueId = 0; blockSize = -1; } else if (version < 4) { // Version 1 and above - Unique ID's used instead of objtype objType = -1; uniqueId = stream.readUint32(); blockSize = -1; } else { // Version 4 has block size objType = -1; uniqueId = stream.readUint32(); blockSize = stream.readInt16(); } // ****** Is this object any good? ****** const cl = this.getObjectClass(objClass); if (!cl) { if (this.Debug) { throw new Error("Object in map file has invalid class - possible file corruption"); } else if (blockSize >= 0) { stream.skip(blockSize); // Just quietly skip this object return null; } else { // Try to fix it by assuming its a tile objClass = this.OBJCLASS_TILE; corrupted = true; } } if (objType < 0) { objType = this.findObjectType(uniqueId, objClass); if (objType < 0) { // not found in this class, so check all of them for (let newObjClass = 0; newObjClass < this.MAXOBJECTCLASSES; newObjClass++) { const newType = this.findObjectType(uniqueId, newObjClass); if (newType >= 0) { objClass = newObjClass; objType = newType; forcesimple = true; break; } } } if (objType < 0) { // Still can't find type if (this.Debug) { throw new Error(`Object unique id 0x${uniqueId.toString(16)} not found in class.def`); } else if (blockSize >= 0) { // Just skip over this object stream.skip(blockSize); return null; } else { // If attempting to fix, assume type is type 0 objType = 0; corrupted = true; } } } // ****** Create the object ****** def.objClass = objClass; def.objType = objType; // Get start of object const startPos = stream.getPos(); const typeInfo = this.getTypeInfo(uniqueId, objClass) // Load object data const objectData = forcesimple ? this.loadBaseObjectData(stream, version, objVersion) : // Used if object changed class this.loadObjectData(stream, version, objVersion, typeInfo, this.OBJ_CLASSES[objClass]); // This should normally be used const inventory = this.loadInventory(stream, version); // Reset position to start of next object if (blockSize >= 0) { stream.setPos(startPos + blockSize); } // If this object is corrupted in some way, return null if (corrupted || (isMap && this.hasNonMapFlag(objectData))) { return null; } return { version: objVersion, class: { id: objClass, name: this.OBJ_CLASSES[objClass] || 'unknown' }, type: objType, typeInfo, uniqueId, blockSize, data: objectData, inventory }; } static loadBaseObjectData(stream, version, objVersion) { // Read name (length-prefixed string) const name = stream.readString(); // Note: we might need to adjust the flags handling since we're using ObjectFlags class // For now, let's store both raw value and parsed flags const flagsRaw = stream.readUint32(); const flags = new ObjectFlags(flagsRaw); const position = { x: stream.readInt32(), y: stream.readInt32(), z: stream.readInt32() }; // Read velocity if mobile and version < 6 let velocity = { x: 0, y: 0, z: 0 }; if (version < 6 || !flags.of_immobile) { velocity = { x: stream.readInt32(), y: stream.readInt32(), z: stream.readInt32() }; } // Read state let state; if (version < 9) { state = stream.readUint8(); } else { state = stream.readUint16(); } // Handle level for non-map objects let level = 0; if (version >= 6 && flags.of_nonmap) { if (version < 9) { level = stream.readUint8(); } else { level = stream.readUint16(); } } // Handle health for old versions let health; if (version < 5) { health = stream.readUint8(); } // Read inventory and rotation data let inventNum, invIndex, shadow, rotateX, rotateY, rotateZ, mapIndex; if (version < 3) { const facing = stream.readUint8(); const dummy16 = stream.readInt16(); inventNum = stream.readInt16(); const dummy16_2 = stream.readInt16(); shadow = stream.readInt32(); const dummy8 = stream.readUint8(); // ignore inventories in old version inventNum = -1; mapIndex = -1; } else { inventNum = stream.readInt16(); invIndex = stream.readInt16(); shadow = stream.readInt32(); rotateX = stream.readUint8(); rotateY = stream.readUint8(); rotateZ = stream.readUint8(); mapIndex = stream.readInt32(); } // Handle animation and stats let frame = 0; let frameRate = 1; let group = 0; let stats = []; if (version < 5) { // Set up empty stat array and stick health in it if (this.getNumObjStats() > 0) { stats = new Array(this.getNumObjStats()).fill(0); this.setHealth(health, stats); } } else { if (version >= 6) { if (flags.of_animate) { frame = stream.readInt16(); frameRate = stream.readInt16(); } } else { frame = stream.readInt16(); frameRate = stream.readInt16(); } group = stream.readUint8(); // Read stats const numStats = stream.readUint8(); if (numStats > 0) { stats = []; for (let st = 0; st < numStats; st++) { const stat = stream.readInt32(); const uniqueId = stream.readUint32(); stats.push({ stat, uniqueId }); } } } // Read light data if present let lightDef = null; if (flags.of_light) { lightDef = new SLightDef(); // Read flags const lightFlags = stream.readUint8(); lightDef.flags = new LightFlags(lightFlags); // Read position lightDef.pos = new S3DPoint( stream.readInt32(), // x stream.readInt32(), // y stream.readInt32() // z ); // Read color lightDef.color = new SColor( stream.readUint8(), // red stream.readUint8(), // green stream.readUint8() // blue ); // Read intensity and multiplier lightDef.intensity = stream.readUint8(); lightDef.multiplier = stream.readInt16(); // Set the light and animate flags using our ObjectFlags properties flags.of_light = true; flags.of_animate = true; } return { name, flags, // This will be the ObjectFlags instance flagsRaw, // This is the raw uint32 value position, velocity, state, level, inventNum, invIndex, shadow, rotation: { x: rotateX, y: rotateY, z: rotateZ }, mapIndex, frame, frameRate, group, stats, lightDef }; } static getNumObjStats() { // Implement this method to return the number of object stats return 0; } static setHealth(health, stats) { // Implement this method to set health in stats array if (stats.length > 0) { stats[0] = health; } } static Debug = false; // Add this class property static readBaseObjectData(stream) { return { name: stream.readString(), flags: new ObjectFlags(stream.readUint32()), position: { x: stream.readInt32(), y: stream.readInt32(), z: stream.readInt32() } }; } static readBaseObjectDataAfterPos(stream) { return { state: stream.readUint16(), inventNum: stream.readInt16(), inventIndex: stream.readInt16(), shadowMapId: stream.readInt32(), rotation: { x: stream.readUint8(), y: stream.readUint8(), z: stream.readUint8() }, mapIndex: stream.readInt32() }; } static readVelocityData(stream) { return { velocity: { x: stream.readInt32(), y: stream.readInt32(), z: stream.readInt32() } }; } static readObjectStats(stream) { const numStats = stream.readUint8(); const stats = []; for (let i = 0; i < numStats; i++) { stats.push({ value: stream.readInt32(), encryptedId: stream.readUint32() }); } return stats; } static readCharacterData(stream) { const complexObjVer = stream.readUint8(); const charObjVer = stream.readUint8(); const baseData = this.readBaseObjectData(stream); const velocityData = this.readVelocityData(stream); const baseDataAfterPos = this.readBaseObjectDataAfterPos(stream); return { complexObjVer, charObjVer, ...baseData, ...velocityData, ...baseDataAfterPos, frame: stream.readInt16(), frameRate: stream.readInt16(), group: stream.readUint8(), stats: this.readObjectStats(stream), actionCode: stream.readUint8(), actionName: stream.readString(), timestamps: { lastHealth: stream.readUint32(), lastFatigue: stream.readUint32(), lastMana: stream.readUint32(), lastPoison: stream.readUint32() }, teleport: { x: stream.readInt32(), y: stream.readInt32(), z: stream.readInt32(), level: stream.readInt32() } }; } static readObjectData(stream, objClass, dataSize) { const startPos = stream.getPos(); let data; switch (objClass) { case 12: // character data = this.readCharacterData(stream); break; case 5: // container data = { ...this.readBaseObjectData(stream), ...this.readVelocityData(stream), ...this.readBaseObjectDataAfterPos(stream), numItems: stream.readUint32() }; break; default: data = { ...this.readBaseObjectData(stream), ...this.readBaseObjectDataAfterPos(stream) }; } // Ensure we've read exactly dataSize bytes const bytesRead = stream.getPos() - startPos; if (bytesRead < dataSize) { stream.skip(dataSize - bytesRead); } return data; } static getObjectClass(classId) { // For now, just check if it's a valid class ID return this.OBJ_CLASSES.hasOwnProperty(classId); } static findObjectType(uniqueId, classId) { // Get class name from classId const className = this.OBJ_CLASSES[classId]; if (!className) return -1; // Get class definition from our loaded class definitions const classDefs = this.classDefs.classes; const classDef = classDefs.get(className.toUpperCase()); if (!classDef) return -1; // Find the type with matching uniqueId const typeIndex = classDef.types.findIndex(t => t.id === uniqueId); if (typeIndex !== -1) { return typeIndex; } // If not found in the expected class, optionally search all classes for (const [otherClassName, otherClassDef] of classDefs) { if (otherClassName !== className.toUpperCase()) { const index = otherClassDef.types.findIndex(t => t.id === uniqueId); if (index !== -1) { console.warn(`Found object type ${uniqueId.toString(16)} in class ${otherClassName} instead of ${className}`); return index; } } } // If still not found, return -1 console.warn(`Could not find object type ${uniqueId.toString(16)} in class ${className}`); return -1; } // Add a helper method to get type information static getTypeInfo(uniqueId, classId) { const className = this.OBJ_CLASSES[classId]; if (!className) return null; const classDefs = this.classDefs.classes; const classDef = classDefs.get(className.toUpperCase()); if (!classDef) return null; return classDef.types.find(t => t.id === uniqueId) || null; } static fileCache = new Map(); // Cache for file paths static async buildFileCache(baseDir) { const cache = new Map(); async function scanDirectory(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.relative(baseDir, fullPath).toLowerCase(); if (entry.isDirectory()) { await scanDirectory(fullPath); } else { cache.set(relativePath, fullPath); } } } await scanDirectory(baseDir); return cache; } static async findRealPath(baseDir, searchPath) { // Normalize the search path const normalizedSearch = searchPath.toLowerCase().replace(/\\/g, path.sep); // Initialize cache if needed if (this.fileCache.size === 0) { this.fileCache = await this.buildFileCache(baseDir); } // Look up the real path in the cache const realPath = this.fileCache.get(normalizedSearch); if (realPath) { return realPath; } return null; } static async loadResourceFile(gameDir, resourcePath) { try { const resourcesDir = path.join(gameDir, 'Resources'); // Prepend 'Imagery' to the resource path const imageryPath = path.join('Imagery', resourcePath); const realPath = await this.findRealPath(resourcesDir, imageryPath); if (!realPath) { console.warn(`Resource file not found: ${resourcePath}`); return null; } const resource = await CGSResourceParser.loadFile(realPath); return resource; } catch (error) { console.error(`Error loading resource file ${resourcePath}:`, error); debugger; return null; } } static loadObjectData(stream, version, objVersion, typeInfo, objClassName) { switch (objClassName.toLowerCase()) { case 'tile': case 'effect': case 'helper': case 'shadow': case 'trap': case 'food': case 'item': return this.loadBaseObjectData(stream, version, objVersion); case 'exit': return this.loadExitData(stream, version, objVersion); case 'container': return this.loadContainerData(stream, version, objVersion); case 'complexobject': return this.loadComplexObjectData(stream, version, objVersion); case 'character': return this.loadCharacterData(stream, version, objVersion); case 'scroll': return this.loadScrollData(stream, version, objVersion); case 'weapon': return this.loadWeaponData(stream, version, objVersion); default: console.warn(`Unknown object class: ${objClassName}`); debugger; return {}; } } static loadWeaponData(stream, version, objVersion) { // Load base object data first const baseData = this.loadBaseObjectData(stream, version, objVersion); // Read poison value const poison = stream.readInt32(); return { ...baseData, className: 'weapon', poison, // Add helper methods and getters for stats getPoison: () => poison, getType: () => baseData.stats?.find(s => s.name === "Type")?.value ?? 0, getDamage: () => baseData.stats?.find(s => s.name === "Damage")?.value ?? 0, getEqSlot: () => baseData.stats?.find(s => s.name === "EqSlot")?.value ?? 0, getCombining: () => baseData.stats?.find(s => s.name === "Combining")?.value ?? 0, getValue: () => baseData.stats?.find(s => s.name === "Value")?.value ?? 0, // Helper method to clear weapon (matches C++ ClearWeapon()) clearWeapon: function () { this.poison = 0; } }; } static loadScrollData(stream, version, objVersion) { // Load base object data first const baseData = this.loadBaseObjectData(stream, version, objVersion); // Read text length const textLength = stream.readInt16(); let text = null; if (textLength > 0) { // Read text characters const textBytes = new Uint8Array(textLength); for (let i = 0; i < textLength; i++) { textBytes[i] = stream.readUint8(); } // Convert to string text = new TextDecoder('ascii').decode(textBytes); } return { ...baseData, className: 'scroll', text, // Add helper methods getText: () => text, cursorType: (inst) => inst ? CURSOR_NONE : CURSOR_EYE }; } static loadCharacterData(stream, version, objVersion) { let baseData; // Load base complex object data based on version if (objVersion >= 3) { // Read complex object version byte first const complexObjVersion = stream.readUint8(); baseData = this.loadComplexObjectData(stream, version, complexObjVersion); } else { baseData = this.loadComplexObjectData(stream, version, 0); } // Early return for old versions if (objVersion < 1) { return { ...baseData, className: 'character' }; } // Load recovery timestamps const lasthealthrecov = stream.readInt32(); const lastfatiguerecov = stream.readInt32(); let lastmanarecov = -1; try { lastmanarecov = stream.readInt32(); } catch (error) { debugger } // Load poison damage (version 4+) let lastpoisondamage = -1; if (objVersion >= 4) { lastpoisondamage = stream.readInt32(); } // Load teleport data (version 2+) let teleportPosition = new S3DPoint(-1, -1, -1); let teleportLevel = -1; if (objVersion >= 2) { teleportPosition = new S3DPoint( stream.readInt32(), // x stream.readInt32(), // y stream.readInt32() // z ); teleportLevel = stream.readInt32(); } return { ...baseData, className: 'character', lasthealthrecov, lastfatiguerecov, lastmanarecov, lastpoisondamage, teleportPosition, teleportLevel }; } static loadComplexObjectData(stream, version, objVersion) { let baseData; // Load base object data based on version if (objVersion >= 1) { // Read base class version byte first const baseObjVersion = stream.readUint8(); baseData = this.loadBaseObjectData(stream, version, baseObjVersion); } else { baseData = this.loadBaseObjectData(stream, version, 0); } // Load root state let actionBlock; if (version < 7) { actionBlock = new ActionBlock("still"); // DefaultRootState } else { const action = stream.readUint8(); const name = stream.readString(); actionBlock = new ActionBlock(name, action); } return { ...baseData, className: 'complexobject', root: actionBlock, doing: actionBlock, desired: actionBlock, state: -1 }; } static loadContainerData(stream, version, objVersion) { // Load base object data first const baseData = this.loadBaseObjectData(stream, version, objVersion); // Handle container-specific data for versions 2-4 if (version >= 2 && version < 5) { const contflags = stream.readInt32(); const pickdifficulty = stream.readInt32(); baseData.stats = baseData.stats || []; baseData.stats.push( { name: "Locked", value: contflags !== 0 }, { name: "PickDifficulty", value: pickdifficulty } ); } return baseData; } static loadExitData(stream, version, objVersion) { // Load container data first (which includes base object data) const containerData = this.loadContainerData(stream, version, objVersion); // Load TExit specific data const exitflags = stream.readUint32(); return { ...containerData, exitflags, className: 'exit', isOn: () => !!(exitflags & ExitFlags.EX_ON), isActivated: () => !!(exitflags & ExitFlags.EX_ACTIVATED), isFromExit: () => !!(exitflags & ExitFlags.EX_FROMEXIT) }; } static loadInventory(stream, version) { // Early return for versions < 3 if (version < 3) { return []; } // Read number of inventory items const num = stream.readInt32(); // Sanity check for inventory size if (num > 2048) { console.warn("Invalid inventory size:", num); return []; } // Array to store inventory items const inventory = []; // Load each inventory object for (let i = 0; i < num; i++) { try { // the code below is commented out because it's not working properly // it supposed to load objects into inventory recursevely, but it's not working // when we attempt to read inventory from the dat map file for an object like a // chatacter, it starts to read garbage. I coulnd't figure out why or find where // the problem is. // Anyways, for the purpose of building a map inventory is not needed anyways. // const inst = this.loadObject(stream, version); console.log("Skipping loading inventory object " + i); continue; if (inst) { // In the C++ version, inst->SetOwner(this) is called // We might need to implement something similar depending on our needs inst.owner = this; // or however we handle ownership inventory.push(inst); } else { console.warn("Invalid inventory object loaded"); } } catch (error) { console.warn("Error loading inventory object:", error); // Continue loading other items even if one fails } } return inventory; } static hasNonMapFlag(objectData) { return objectData.flags.of_nonmap; } } // Exit states const ExitStates = { EXIT_CLOSED: 0, EXIT_OPEN: 1, EXIT_CLOSING: 2, EXIT_OPENING: 3 }; // Exit flags const ExitFlags = { EX_ON: 1 << 0, // player is on exit strip EX_ACTIVATED: 1 << 1, // exit has been activated EX_FROMEXIT: 1 << 2 // player just came from another exit.. don't do anything }; class ExitRef { constructor() { this.name = ''; // name of exit this.target = null; // position on level (S3DPoint) this.level = 0; // level to change to this.mapindex = 0; // object character is transfered to (usually another exit) this.ambient = 0; // level of ambient light this.ambcolor = null; // color of ambient light (SColor) this.next = null; // next in list } } // Weapon type constants const WeaponType = { WT_HAND: 0, // Hand, claw, tail, etc. WT_KNIFE: 1, // Daggers, knives WT_SWORD: 2, // Swords WT_BLUDGEON: 3, // Clubs, maces, hammers WT_AXE: 4, // Axes WT_STAFF: 5, // Staffs, polearms, spears, etc. WT_BOW: 6, // Bow WT_CROSSBOW: 7, // Crossbow WT_LAST: 7 // Last weapon type }; // Weapon mask constants const WeaponMask = { WM_HAND: 0x0001, WM_KNIFE: 0x0002, WM_SWORD: 0x0004, WM_BLUDGEON: 0x0008, WM_AXE: 0x0010, WM_STAFF: 0x0020, WM_BOW: 0x0040, WM_CROSSBOW: 0x0080 }; const ActionTypes = { ACTION_NONE: 0, ACTION_ANIMATE: 1, ACTION_MOVE: 2, ACTION_COMBAT: 3, ACTION_COMBATMOVE: 4, ACTION_COMBATLEAP: 5, ACTION_COLLAPSE: 6, ACTION_ATTACK: 7, ACTION_BLOCK: 8, ACTION_DODGE: 9, ACTION_MISS: 10, ACTION_INVOKE: 11, ACTION_IMPACT: 12, ACTION_STUN: 13, ACTION_KNOCKDOWN: 14, ACTION_FLYBACK: 15, ACTION_SAY: 16, ACTION_PIVOT: 17, ACTION_PULL: 18, ACTION_DEAD: 19, ACTION_PULP: 20, ACTION_BURN: 21, ACTION_FLAIL: 22, ACTION_SLEEP: 23, ACTION_LEAP: 24, ACTION_BOW: 25, ACTION_BOWMOVE: 26, ACTION_BOWAIM: 27, ACTION_BOWSHOOT: 28 }; class ActionBlock { constructor(name, action = ActionTypes.ACTION_ANIMATE) { this.action = action; this.name = name; this.frame = 0; this.wait = 0; this.angle = 0; this.moveangle = 0; this.turnrate = 0; this.target = null; // S3DPoint this.obj = null; // ObjectInstance reference this.attack = null; this.impact = null; this.damage = 0; this.data = null; this.flags = 0; } } // Light flags class LightFlags { static LIGHT_DIR = 1 << 0; // Directional light static LIGHT_SUN = 1 << 1; // Sunlight static LIGHT_MOON = 1 << 2; // Moonlight constructor(value) { this.isDirectional = !!(value & LightFlags.LIGHT_DIR); this.isSunlight = !!(value & LightFlags.LIGHT_SUN); this.isMoonlight = !!(value & LightFlags.LIGHT_MOON); } } class S3DPoint { constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; } add(other) { return new S3DPoint( this.x + other.x, this.y + other.y, this.z + other.z ); } subtract(other) { return new S3DPoint( this.x - other.x, this.y - other.y, this.z - other.z ); } multiply(scalar) { return new S3DPoint( this.x * scalar, this.y * scalar, this.z * scalar ); } inRange(pos, dist) { const absX = Math.abs(this.x - pos.x); const absY = Math.abs(this.y - pos.y); return absY <= dist && absX <= dist && (Math.pow(absY, 2) + Math.pow(absX, 2) <= Math.pow(dist, 2) * 2); } inRange3D(pos, dist) { const absX = Math.abs(this.x - pos.x); const absY = Math.abs(this.y - pos.y); const absZ = Math.abs(this.z - pos.z); return absY <= dist && absX <= dist && absZ <= dist && (Math.pow(absY, 2) + Math.pow(absX, 2) + Math.pow(absZ, 2) <= Math.pow(dist, 2) * 3); } } class SColor { constructor(red = 0, green = 0, blue = 0) { this.red = red; this.green = green; this.blue = blue; } } class SLightDef { constructor() { this.flags = new LightFlags(0); // LIGHT_x this.multiplier = 0; // Multiplier this.pos = new S3DPoint(); // Position of light this.color = new SColor(); // RGB Color of light this.intensity = 0; // Intensity of light this.lightindex = 0; // Light index for 3d system this.lightid = 0; // Light id for dls system } } class ObjectFlags { constructor(value) { // Convert number to 32-bit binary string const bits = (value >>> 0).toString(2).padStart(32, '0'); this.of_immobile = !!parseInt(bits[31 - 0]); // Not affected by gravity etc this.of_editorlock = !!parseInt(bits[31 - 1]); // Object is locked down (can't move in editor) this.of_light = !!parseInt(bits[31 - 2]); // Object generates light (a light is on for object) this.of_moving = !!parseInt(bits[31 - 3]); // Object is a moving object (characters, exits, players, missiles, etc.) this.of_animating = !!parseInt(bits[31 - 4]); // Has animating imagery (animator pointer is set) this.of_ai = !!parseInt(bits[31 - 5]); // Object has A.I. this.of_disabled = !!parseInt(bits[31 - 6]); // Object A.I. is disabled this.of_invisible = !!parseInt(bits[31 - 7]); // Not visible in map pane during normal play this.of_editor = !!parseInt(bits[31 - 8]); // Is editor only object this.of_drawflip = !!parseInt(bits[31 - 9]); // Reverse on the horizontal this.of_seldraw = !!parseInt(bits[31 - 10]); // Editor is manipulating object this.of_reveal = !!parseInt(bits[31 - 11]); // Player needs to see behind object (shutter draw) this.of_kill = !!parseInt(bits[31 - 12]); // Suicidal (tells system to kill object next frame) this.of_generated = !!parseInt(bits[31 - 13]); // Created by map generator this.of_animate = !!parseInt(bits[31 - 14]); // Call the objects Animate() func AND create object animators this.of_pulse = !!parseInt(bits[31 - 15]); // Call the object Pulse() function this.of_weightless = !!parseInt(bits[31 - 16]); // Object can move, but is not affected by gravity this.of_complex = !!parseInt(bits[31 - 17]); // Object is a complex object this.of_notify = !!parseInt(bits[31 - 18]); // Notify object of a system change (see notify codes below) this.of_nonmap = !!parseInt(bits[31 - 19]); // Not created, deleted, saved, or loaded by map (see below) this.of_onexit = !!parseInt(bits[31 - 20]); // Object is currently on an exit (used to prevent exit loops) this.of_pause = !!parseInt(bits[31 - 21]); // Script is paused this.of_nowalk = !!parseInt(bits[31 - 22]); // Don't use walk map for this tile this.of_paralize = !!parseInt(bits[31 - 23]); // Freeze the object in mid-animation this.of_nocollision = !!parseInt(bits[31 - 24]); // Let the object go through boundries this.of_iced = !!parseInt(bits[31 - 25]); // Used to know when to end the iced effect } // NOTE: OF_NONMAP // ---------------- // // OF_NONMAP tells the map system that this object is managed outside of the regular map // system. This object will not be LOADED, SAVED, CREATED, or DELETED by the map or // sector system. Any object with this flag can be inserted into the map and assume that // it won't be deleted by the map system. This flag is intended for players, but can be used for // other objects. } async function main() { const gameDir = path.join('_INSTALLED_GAME', 'Revenant'); const mapDir = path.join(gameDir, 'Modules', 'Ahkuilon', 'Map'); const resourcesDir = path.join(gameDir, 'Resources'); try { // Build the file cache console.log('Building file cache...'); await DatParser.buildFileCache(resourcesDir); // Load IMAGERY.DAT first console.log('Loading IMAGERY.DAT...'); const imageryDatPath = path.join(resourcesDir, 'imagery.dat'); const imageryData = await ImageryDatParser.loadFile(imageryDatPath, gameDir); if (imageryData) { console.log(`Loaded ${imageryData.entries.length} imagery entries`); debugger; } // Then proceed with the rest of the processing await DatParser.loadClassDefinitions(gameDir); const files = await fs.readdir(mapDir); const datFiles = files.filter(file => file.toLowerCase().endsWith('.dat')); for (const datFile of datFiles) { const filePath = path.join(mapDir, datFile); console.log(`Processing ${datFile}...`); const result = await DatParser.loadFile(filePath, gameDir); if (result && result.numObjects) { console.log(`File: ${datFile}`); console.log('Version:', result.version); console.log('Number of objects:', result.numObjects); // Process each object's resource for (const obj of result.objects) { // todo } console.log('-------------------'); } } } catch (error) { debugger; console.error('Error reading directory:', error); } } main().catch(console.error);