@chicowall/grf-loader
Version:
A loader for GRF files (Ragnarok Online game file)
1 lines • 72 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/grf-base.ts","../src/des.ts","../src/decoder.ts","../src/grf-browser.ts","../src/grf-node.ts","../src/buffer-pool.ts"],"sourcesContent":["export {GrfBrowser} from './grf-browser';\nexport {GrfNode, GrfNodeOptions} from './grf-node';\nexport type {\n TFileEntry,\n FilenameEncoding,\n GrfOptions,\n FindOptions,\n ResolveResult,\n GrfStats\n} from './grf-base';\nexport {GrfError, GRF_ERROR_CODES} from './grf-base';\nexport {bufferPool} from './buffer-pool';\n\n// Encoding utilities\nexport {\n isMojibake,\n fixMojibake,\n toMojibake,\n normalizeFilename,\n normalizePath as normalizeEncodingPath,\n countBadChars,\n countC1ControlChars,\n countReplacementChars,\n hasIconvLite\n} from './decoder';\n","import pako from 'pako';\nimport jDataview from 'jdataview';\nimport {decodeFull, decodeHeader} from './des';\nimport {\n decodeBytes,\n detectBestKoreanEncoding,\n countBadChars,\n countReplacementChars,\n countC1ControlChars,\n hasIconvLite\n} from './decoder';\n\n// ============================================================================\n// Types and Interfaces\n// ============================================================================\n\nexport interface TFileEntry {\n type: number;\n offset: number;\n realSize: number;\n compressedSize: number;\n lengthAligned: number;\n /** Raw filename bytes for re-decoding if needed */\n rawNameBytes?: Uint8Array;\n}\n\n/** Supported filename encodings */\nexport type FilenameEncoding = 'utf-8' | 'euc-kr' | 'cp949' | 'latin1' | 'auto';\n\n/** GRF loader options */\nexport interface GrfOptions {\n /** Encoding for filenames (default: 'auto') */\n filenameEncoding?: FilenameEncoding;\n /** Threshold for auto-detection: if % of U+FFFD exceeds this, try Korean encodings (default: 0.01 = 1%) */\n autoDetectThreshold?: number;\n /** Maximum uncompressed size per file in bytes (default: 256MB) */\n maxFileUncompressedBytes?: number;\n /** Maximum total entries allowed (default: 500000) */\n maxEntries?: number;\n}\n\n/** Search/find options */\nexport interface FindOptions {\n /** Filter by file extension (without dot, e.g., 'spr', 'act') */\n ext?: string;\n /** Filter by substring in path */\n contains?: string;\n /** Filter by path ending */\n endsWith?: string;\n /** Filter by regex pattern */\n regex?: RegExp;\n /** Maximum results to return (default: unlimited) */\n limit?: number;\n}\n\n/** Result of path resolution */\nexport interface ResolveResult {\n status: 'found' | 'not_found' | 'ambiguous';\n /** The exact matched path (if found) */\n matchedPath?: string;\n /** All candidate paths (if ambiguous) */\n candidates?: string[];\n}\n\n/** GRF statistics */\nexport interface GrfStats {\n /** Total file count */\n fileCount: number;\n /** Number of filenames with replacement character (U+FFFD) */\n badNameCount: number;\n /** Number of normalized key collisions */\n collisionCount: number;\n /** Extension statistics: ext -> count */\n extensionStats: Map<string, number>;\n /** Detected encoding used */\n detectedEncoding: FilenameEncoding;\n}\n\n// ============================================================================\n// Error Codes\n// ============================================================================\n\nexport const GRF_ERROR_CODES = {\n INVALID_MAGIC: 'GRF_INVALID_MAGIC',\n UNSUPPORTED_VERSION: 'GRF_UNSUPPORTED_VERSION',\n NOT_LOADED: 'GRF_NOT_LOADED',\n FILE_NOT_FOUND: 'GRF_FILE_NOT_FOUND',\n AMBIGUOUS_PATH: 'GRF_AMBIGUOUS_PATH',\n DECOMPRESS_FAIL: 'GRF_DECOMPRESS_FAIL',\n CORRUPT_TABLE: 'GRF_CORRUPT_TABLE',\n LIMIT_EXCEEDED: 'GRF_LIMIT_EXCEEDED',\n INVALID_OFFSET: 'GRF_INVALID_OFFSET',\n DECRYPT_REQUIRED: 'GRF_DECRYPT_REQUIRED',\n} as const;\n\nexport class GrfError extends Error {\n constructor(\n public code: keyof typeof GRF_ERROR_CODES,\n message: string,\n public context?: Record<string, unknown>\n ) {\n super(message);\n this.name = 'GrfError';\n }\n}\n\nconst FILELIST_TYPE_FILE = 0x01;\nconst FILELIST_TYPE_ENCRYPT_MIXED = 0x02; // encryption mode 0 (header DES + periodic DES/shuffle)\nconst FILELIST_TYPE_ENCRYPT_HEADER = 0x04; // encryption mode 1 (header DES only)\n\nconst HEADER_SIGNATURE = 'Master of Magic';\nconst HEADER_SIZE = 46;\nconst FILE_TABLE_SIZE = Uint32Array.BYTES_PER_ELEMENT * 2;\n\n// Default limits\nconst DEFAULT_MAX_FILE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024; // 256MB\nconst DEFAULT_MAX_ENTRIES = 500000;\nconst DEFAULT_AUTO_DETECT_THRESHOLD = 0.01; // 1%\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Normalize a path for case-insensitive, slash-agnostic lookup\n */\nfunction normalizePath(path: string): string {\n return path.toLowerCase().replace(/\\\\/g, '/');\n}\n\n/**\n * Get file extension from path (lowercase, without dot)\n */\nfunction getExtension(path: string): string {\n const lastDot = path.lastIndexOf('.');\n if (lastDot === -1 || lastDot === path.length - 1) return '';\n return path.substring(lastDot + 1).toLowerCase();\n}\n\n// Note: countReplacementChars, countBadChars, countC1ControlChars, decodeBytes,\n// and detectBestKoreanEncoding are imported from './decoder'\n\n/**\n * Decode filename bytes with specified encoding.\n * Uses iconv-lite for Korean encodings in Node.js for proper CP949 support.\n */\nfunction decodeFilenameBytes(bytes: Uint8Array, encoding: FilenameEncoding): string {\n // For Korean encodings, always use 'cp949' as it's a superset of euc-kr\n // This ensures proper handling of extended Korean characters\n const actualEncoding = (encoding === 'euc-kr' || encoding === 'cp949') ? 'cp949' : encoding;\n return decodeBytes(bytes, actualEncoding);\n}\n\n// ============================================================================\n// GrfBase Class\n// ============================================================================\n\nexport abstract class GrfBase<T> {\n public version = 0x200;\n public fileCount = 0;\n public loaded = false;\n\n /** Map of exact filename -> entry */\n public files = new Map<string, TFileEntry>();\n\n /** Map of normalized path -> array of exact filenames (supports collisions) */\n private normalizedIndex = new Map<string, string[]>();\n\n /** Map of extension -> array of exact filenames (for fast extension lookup) */\n private extensionIndex = new Map<string, string[]>();\n\n private fileTableOffset = 0;\n private cache = new Map<string, Uint8Array>();\n private cacheMaxSize = 50;\n private cacheOrder: string[] = [];\n\n // Options\n protected options: Required<GrfOptions>;\n\n // Statistics\n private _stats: GrfStats = {\n fileCount: 0,\n badNameCount: 0,\n collisionCount: 0,\n extensionStats: new Map(),\n detectedEncoding: 'utf-8'\n };\n\n constructor(private fd: T, options?: GrfOptions) {\n this.options = {\n filenameEncoding: options?.filenameEncoding ?? 'auto',\n autoDetectThreshold: options?.autoDetectThreshold ?? DEFAULT_AUTO_DETECT_THRESHOLD,\n maxFileUncompressedBytes: options?.maxFileUncompressedBytes ?? DEFAULT_MAX_FILE_UNCOMPRESSED_BYTES,\n maxEntries: options?.maxEntries ?? DEFAULT_MAX_ENTRIES\n };\n }\n\n abstract getStreamBuffer(\n fd: T,\n offset: number,\n length: number\n ): Promise<Uint8Array>;\n\n public async getStreamReader(\n offset: number,\n length: number\n ): Promise<jDataview> {\n const buffer = await this.getStreamBuffer(this.fd, offset, length);\n\n return new jDataview(buffer, void 0, void 0, true);\n }\n\n public async load(): Promise<void> {\n if (!this.loaded) {\n await this.parseHeader();\n await this.parseFileList();\n this.loaded = true;\n }\n }\n\n private async parseHeader(): Promise<void> {\n const reader = await this.getStreamReader(0, HEADER_SIZE);\n\n const signature = reader.getString(15);\n if (signature !== HEADER_SIGNATURE) {\n throw new GrfError('INVALID_MAGIC', 'Not a GRF file (invalid signature)', { signature });\n }\n\n reader.skip(15);\n // Now at offset 30. Version is always at offset 42 for both 0x200 and 0x300.\n // Read version first to determine how to parse the rest of the header.\n // Save position, peek version, then parse based on version.\n const afterKey = reader.tell();\n reader.seek(42);\n this.version = reader.getUint32();\n\n if (this.version !== 0x200 && this.version !== 0x300) {\n throw new GrfError('UNSUPPORTED_VERSION', `Unsupported version \"0x${this.version.toString(16)}\"`, { version: this.version });\n }\n\n reader.seek(afterKey);\n\n if (this.version === 0x200) {\n // 0x200: [table_offset:u32][seeds:u32][filecount:u32][version:u32]\n this.fileTableOffset = reader.getUint32() + HEADER_SIZE;\n const reservedFiles = reader.getUint32();\n this.fileCount = reader.getUint32() - reservedFiles - 7;\n } else {\n // 0x300: [table_offset:u64][filecount:u32][version:u32]\n const low = reader.getUint32();\n const high = reader.getUint32();\n\n // GRFEditor heuristic: bytes 35-37 (upper 3 bytes of high word) must be zero.\n // Protects against mis-tagged GRFs where version says 0x300 but layout is 0x200.\n if ((high >>> 8) !== 0) {\n // Fall back to 0x200 parsing\n this.version = 0x200;\n reader.seek(afterKey);\n this.fileTableOffset = reader.getUint32() + HEADER_SIZE;\n const reservedFiles = reader.getUint32();\n this.fileCount = reader.getUint32() - reservedFiles - 7;\n } else {\n this.fileTableOffset = high * 0x100000000 + low + HEADER_SIZE;\n this.fileCount = reader.getUint32();\n }\n }\n\n // Validate entry count against limit\n if (this.fileCount > this.options.maxEntries) {\n throw new GrfError('LIMIT_EXCEEDED', `File count ${this.fileCount} exceeds limit ${this.options.maxEntries}`, {\n fileCount: this.fileCount,\n maxEntries: this.options.maxEntries\n });\n }\n }\n\n private async parseFileList(): Promise<void> {\n // GRF 0x300 has an extra 4-byte field before the file table header\n const tableSkip = this.version === 0x300 ? 4 : 0;\n\n // Read table list, stored information\n const reader = await this.getStreamReader(\n this.fileTableOffset + tableSkip,\n FILE_TABLE_SIZE\n );\n const compressedSize = reader.getUint32();\n const realSize = reader.getUint32();\n\n // Load the chunk and uncompress it\n const compressed = await this.getStreamBuffer(\n this.fd,\n this.fileTableOffset + tableSkip + FILE_TABLE_SIZE,\n compressedSize\n );\n\n let data: Uint8Array;\n try {\n data = pako.inflate(compressed);\n } catch (error) {\n throw new GrfError('CORRUPT_TABLE', 'Failed to decompress file table', {\n compressedSize,\n realSize,\n error: error instanceof Error ? error.message : String(error)\n });\n }\n\n // Validate decompressed size\n if (data.length !== realSize) {\n throw new GrfError('CORRUPT_TABLE', `File table size mismatch: expected ${realSize}, got ${data.length}`, {\n expected: realSize,\n actual: data.length\n });\n }\n\n // Determine encoding to use\n let detectedEncoding: FilenameEncoding = this.options.filenameEncoding;\n\n // If auto-detect, sample filenames and use improved detection algorithm\n if (this.options.filenameEncoding === 'auto') {\n const sampleBytes: Uint8Array[] = [];\n let samplePos = 0;\n // Sample more files for better detection accuracy\n const sampleCount = Math.min(200, this.fileCount);\n // 0x200: 17-byte entries (4-byte offset), 0x300: 21-byte entries (8-byte offset)\n const entryDataSize = this.version === 0x300 ? 21 : 17;\n\n for (let i = 0; i < sampleCount && samplePos < data.length; i++) {\n let endPos = samplePos;\n while (data[endPos] !== 0 && endPos < data.length) endPos++;\n\n // Only include samples with non-ASCII bytes for detection\n const bytes = data.subarray(samplePos, endPos);\n sampleBytes.push(bytes);\n\n samplePos = endPos + 1 + entryDataSize;\n }\n\n // Use improved encoding detection algorithm from decoder module\n // This analyzes:\n // 1. Valid byte sequence patterns for UTF-8 vs EUC-KR/CP949\n // 2. Replacement character ratios after decoding (including C1 control chars)\n // 3. Uses iconv-lite in Node.js for proper CP949 support\n detectedEncoding = detectBestKoreanEncoding(sampleBytes, this.options.autoDetectThreshold);\n }\n\n this._stats.detectedEncoding = detectedEncoding;\n\n // Reset stats\n this._stats.badNameCount = 0;\n this._stats.collisionCount = 0;\n this._stats.extensionStats.clear();\n\n // 0x200: 17-byte entries (4-byte offset), 0x300: 21-byte entries (8-byte offset)\n const entryDataSize = this.version === 0x300 ? 21 : 17;\n\n for (let i = 0, p = 0; i < this.fileCount; ++i) {\n // Validate position\n if (p >= data.length) {\n throw new GrfError('CORRUPT_TABLE', `Unexpected end of file table at entry ${i}`, {\n position: p,\n dataLength: data.length,\n entryIndex: i\n });\n }\n\n // Find null terminator\n let endPos = p;\n while (data[endPos] !== 0 && endPos < data.length) {\n endPos++;\n }\n\n // Store raw bytes and decode filename using the detected encoding\n // Uses iconv-lite for Korean encodings in Node.js for proper CP949 support\n const rawBytes = data.slice(p, endPos); // Copy for storage\n const filename = decodeFilenameBytes(rawBytes, detectedEncoding);\n\n // Count bad names (including C1 control chars that indicate wrong decode)\n if (countBadChars(filename) > 0) {\n this._stats.badNameCount++;\n }\n\n p = endPos + 1;\n\n // Validate remaining bytes for entry\n if (p + entryDataSize > data.length) {\n throw new GrfError('CORRUPT_TABLE', `Incomplete entry data at entry ${i}`, {\n position: p,\n dataLength: data.length,\n entryIndex: i\n });\n }\n\n // prettier-ignore\n const compressedSize = data[p++] | (data[p++] << 8) | (data[p++] << 16) | (data[p++] << 24);\n // prettier-ignore\n const lengthAligned = data[p++] | (data[p++] << 8) | (data[p++] << 16) | (data[p++] << 24);\n // prettier-ignore\n const realSize = data[p++] | (data[p++] << 8) | (data[p++] << 16) | (data[p++] << 24);\n const type = data[p++];\n\n let offset: number;\n if (this.version === 0x300) {\n // 0x300: 8-byte (64-bit) offset\n // prettier-ignore\n const low = (data[p++] | (data[p++] << 8) | (data[p++] << 16) | (data[p++] << 24)) >>> 0;\n // prettier-ignore\n const high = (data[p++] | (data[p++] << 8) | (data[p++] << 16) | (data[p++] << 24)) >>> 0;\n offset = high * 0x100000000 + low;\n } else {\n // 0x200: 4-byte (32-bit) offset\n // prettier-ignore\n offset = (data[p++] | (data[p++] << 8) | (data[p++] << 16) | (data[p++] << 24)) >>> 0;\n }\n\n const entry: TFileEntry = {\n compressedSize,\n lengthAligned,\n realSize,\n type,\n offset,\n rawNameBytes: rawBytes\n };\n\n // Validate sizes against limits\n if (entry.realSize > this.options.maxFileUncompressedBytes) {\n // Skip this entry but don't fail - just warn\n continue;\n }\n\n // Only process files (not folders)\n if (entry.type & FILELIST_TYPE_FILE) {\n // Add to main files map\n this.files.set(filename, entry);\n\n // Add to normalized index (supports collisions)\n const normalizedKey = normalizePath(filename);\n const existingNorm = this.normalizedIndex.get(normalizedKey);\n if (existingNorm) {\n existingNorm.push(filename);\n this._stats.collisionCount++;\n } else {\n this.normalizedIndex.set(normalizedKey, [filename]);\n }\n\n // Add to extension index\n const ext = getExtension(filename);\n if (ext) {\n const existingExt = this.extensionIndex.get(ext);\n if (existingExt) {\n existingExt.push(filename);\n } else {\n this.extensionIndex.set(ext, [filename]);\n }\n\n // Update extension stats\n this._stats.extensionStats.set(ext, (this._stats.extensionStats.get(ext) || 0) + 1);\n }\n }\n }\n\n this._stats.fileCount = this.files.size;\n }\n\n private decodeEntry(data: Uint8Array, entry: TFileEntry): Uint8Array {\n // Decode the file\n if (entry.type & FILELIST_TYPE_ENCRYPT_MIXED) {\n decodeFull(data, entry.lengthAligned, entry.compressedSize);\n } else if (entry.type & FILELIST_TYPE_ENCRYPT_HEADER) {\n decodeHeader(data, entry.lengthAligned);\n }\n\n // No compression\n if (entry.realSize === entry.compressedSize) {\n return data;\n }\n\n // Uncompress\n return pako.inflate(data);\n }\n\n private addToCache(filename: string, data: Uint8Array): void {\n // Remove oldest if cache is full\n if (this.cacheOrder.length >= this.cacheMaxSize) {\n const oldest = this.cacheOrder.shift();\n if (oldest) {\n this.cache.delete(oldest);\n }\n }\n\n // Add to cache\n this.cache.set(filename, data);\n this.cacheOrder.push(filename);\n }\n\n private getFromCache(filename: string): Uint8Array | undefined {\n const cached = this.cache.get(filename);\n if (cached) {\n // Move to end (most recently used)\n const index = this.cacheOrder.indexOf(filename);\n if (index > -1) {\n this.cacheOrder.splice(index, 1);\n this.cacheOrder.push(filename);\n }\n }\n return cached;\n }\n\n public clearCache(): void {\n this.cache.clear();\n this.cacheOrder = [];\n }\n\n public async getFile(\n filename: string\n ): Promise<{data: null | Uint8Array; error: null | string}> {\n if (!this.loaded) {\n return Promise.resolve({data: null, error: 'GRF not loaded yet'});\n }\n\n // Try to resolve the path (exact match first, then normalized)\n const resolved = this.resolvePath(filename);\n\n if (resolved.status === 'not_found') {\n return Promise.resolve({data: null, error: `File \"${filename}\" not found`});\n }\n\n if (resolved.status === 'ambiguous') {\n return Promise.resolve({\n data: null,\n error: `Ambiguous path \"${filename}\": ${resolved.candidates?.length} matches found. Use exact path: ${resolved.candidates?.slice(0, 5).join(', ')}${(resolved.candidates?.length || 0) > 5 ? '...' : ''}`\n });\n }\n\n const path = resolved.matchedPath!;\n\n // Check cache first\n const cached = this.getFromCache(path);\n if (cached) {\n return Promise.resolve({data: cached, error: null});\n }\n\n const entry = this.files.get(path);\n\n if (!entry) {\n return { data: null, error: `File \"${path}\" not found` };\n }\n\n const data = await this.getStreamBuffer(\n this.fd,\n entry.offset + HEADER_SIZE,\n entry.lengthAligned\n );\n\n try {\n const result = this.decodeEntry(data, entry);\n\n // Add to cache\n this.addToCache(path, result);\n\n return Promise.resolve({data: result, error: null});\n } catch (error) {\n const message =\n error instanceof Error ? error.message : String(error);\n return { data: null, error: message };\n }\n }\n\n // ===========================================================================\n // Path Resolution\n // ===========================================================================\n\n /**\n * Resolve a path to its exact filename in the GRF.\n * Tries exact match first, then normalized (case-insensitive, slash-agnostic).\n */\n public resolvePath(query: string): ResolveResult {\n // Try exact match first\n if (this.files.has(query)) {\n return { status: 'found', matchedPath: query };\n }\n\n // Try normalized lookup\n const normalizedQuery = normalizePath(query);\n const candidates = this.normalizedIndex.get(normalizedQuery);\n\n if (!candidates || candidates.length === 0) {\n return { status: 'not_found' };\n }\n\n if (candidates.length === 1) {\n return { status: 'found', matchedPath: candidates[0] };\n }\n\n // Multiple candidates - ambiguous\n return { status: 'ambiguous', candidates };\n }\n\n /**\n * Check if a file exists in the GRF.\n */\n public hasFile(filename: string): boolean {\n const resolved = this.resolvePath(filename);\n return resolved.status === 'found';\n }\n\n /**\n * Get file entry metadata without extracting the file.\n */\n public getEntry(filename: string): TFileEntry | null {\n const resolved = this.resolvePath(filename);\n if (resolved.status !== 'found' || !resolved.matchedPath) {\n return null;\n }\n return this.files.get(resolved.matchedPath) || null;\n }\n\n // ===========================================================================\n // Search API\n // ===========================================================================\n\n /**\n * Find files matching the given criteria.\n */\n public find(options: FindOptions = {}): string[] {\n const { ext, contains, endsWith, regex, limit } = options;\n let results: string[] = [];\n\n // If searching by extension only, use the extension index (fast path)\n if (ext && !contains && !endsWith && !regex) {\n const extLower = ext.toLowerCase().replace(/^\\./, ''); // Remove leading dot if present\n results = this.extensionIndex.get(extLower) || [];\n } else {\n // Full search\n for (const filename of this.files.keys()) {\n // Extension filter\n if (ext) {\n const extLower = ext.toLowerCase().replace(/^\\./, '');\n if (getExtension(filename) !== extLower) continue;\n }\n\n // Contains filter (case-insensitive)\n if (contains) {\n const normalizedFilename = normalizePath(filename);\n const normalizedContains = normalizePath(contains);\n if (!normalizedFilename.includes(normalizedContains)) continue;\n }\n\n // EndsWith filter (case-insensitive)\n if (endsWith) {\n const normalizedFilename = normalizePath(filename);\n const normalizedEndsWith = normalizePath(endsWith);\n if (!normalizedFilename.endsWith(normalizedEndsWith)) continue;\n }\n\n // Regex filter\n if (regex && !regex.test(filename)) continue;\n\n results.push(filename);\n\n // Limit check\n if (limit && results.length >= limit) break;\n }\n }\n\n // Apply limit if not already applied\n if (limit && results.length > limit) {\n results = results.slice(0, limit);\n }\n\n return results;\n }\n\n /**\n * Get all files with a specific extension.\n */\n public getFilesByExtension(ext: string): string[] {\n const extLower = ext.toLowerCase().replace(/^\\./, '');\n return this.extensionIndex.get(extLower) || [];\n }\n\n /**\n * List all unique extensions in the GRF.\n */\n public listExtensions(): string[] {\n return Array.from(this.extensionIndex.keys()).sort();\n }\n\n /**\n * List all files in the GRF.\n */\n public listFiles(): string[] {\n return Array.from(this.files.keys());\n }\n\n // ===========================================================================\n // Statistics\n // ===========================================================================\n\n /**\n * Get GRF statistics.\n */\n public getStats(): GrfStats {\n return { ...this._stats, extensionStats: new Map(this._stats.extensionStats) };\n }\n\n /**\n * Get the detected/configured encoding used for filenames.\n */\n public getDetectedEncoding(): FilenameEncoding {\n return this._stats.detectedEncoding;\n }\n\n // ===========================================================================\n // Re-decoding Support\n // ===========================================================================\n\n /**\n * Re-decode all filenames with a different encoding.\n * Useful if auto-detection chose wrong or you want to try a specific encoding.\n */\n public async reloadWithEncoding(encoding: FilenameEncoding): Promise<void> {\n this.options.filenameEncoding = encoding;\n this.files.clear();\n this.normalizedIndex.clear();\n this.extensionIndex.clear();\n this.clearCache();\n this.loaded = false;\n await this.load();\n }\n}\n","/**\n * Ragnarok Online DES decoder implementation\n * It's a custom one with some alterations\n */\nexport {decodeFull, decodeHeader};\n\nconst mask = new Uint8Array([0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01]);\nconst tmp = new Uint8Array(8);\nconst tmp2 = new Uint8Array(8);\nconst clean = new Uint8Array(8);\n\n// prettier-ignore\nconst initialPermutationTable = new Uint8Array([\n 58, 50, 42, 34, 26, 18, 10, 2,\n 60, 52, 44, 36, 28, 20, 12, 4,\n 62, 54, 46, 38, 30, 22, 14, 6,\n 64, 56, 48, 40, 32, 24, 16, 8,\n 57, 49, 41, 33, 25, 17, 9, 1,\n 59, 51, 43, 35, 27, 19, 11, 3,\n 61, 53, 45, 37, 29, 21, 13, 5,\n 63, 55, 47, 39, 31, 23, 15, 7\n]);\n\n// prettier-ignore\nconst finalPermutationTable = new Uint8Array([\n 40, 8, 48, 16, 56, 24, 64, 32,\n 39, 7, 47, 15, 55, 23, 63, 31,\n 38, 6, 46, 14, 54, 22, 62, 30,\n 37, 5, 45, 13, 53, 21, 61, 29,\n 36, 4, 44, 12, 52, 20, 60, 28,\n 35, 3, 43, 11, 51, 19, 59, 27,\n 34, 2, 42, 10, 50, 18, 58, 26,\n 33, 1, 41, 9, 49, 17, 57, 25\n]);\n\n// prettier-ignore\nconst transpositionTable = new Uint8Array([\n 16, 7, 20, 21,\n 29, 12, 28, 17,\n 1, 15, 23, 26,\n 5, 18, 31, 10,\n 2, 8, 24, 14,\n 32, 27, 3, 9,\n 19, 13, 30, 6,\n 22, 11, 4, 25\n]);\n\n// prettier-ignore\nconst substitutionBoxTable = [\n new Uint8Array([\n 0xef, 0x03, 0x41, 0xfd, 0xd8, 0x74, 0x1e, 0x47, 0x26, 0xef, 0xfb, 0x22, 0xb3, 0xd8, 0x84, 0x1e,\n 0x39, 0xac, 0xa7, 0x60, 0x62, 0xc1, 0xcd, 0xba, 0x5c, 0x96, 0x90, 0x59, 0x05, 0x3b, 0x7a, 0x85,\n 0x40, 0xfd, 0x1e, 0xc8, 0xe7, 0x8a, 0x8b, 0x21, 0xda, 0x43, 0x64, 0x9f, 0x2d, 0x14, 0xb1, 0x72,\n 0xf5, 0x5b, 0xc8, 0xb6, 0x9c, 0x37, 0x76, 0xec, 0x39, 0xa0, 0xa3, 0x05, 0x52, 0x6e, 0x0f, 0xd9 \n ]),\n new Uint8Array([\n 0xa7, 0xdd, 0x0d, 0x78, 0x9e, 0x0b, 0xe3, 0x95, 0x60, 0x36, 0x36, 0x4f, 0xf9, 0x60, 0x5a, 0xa3,\n 0x11, 0x24, 0xd2, 0x87, 0xc8, 0x52, 0x75, 0xec, 0xbb, 0xc1, 0x4c, 0xba, 0x24, 0xfe, 0x8f, 0x19,\n 0xda, 0x13, 0x66, 0xaf, 0x49, 0xd0, 0x90, 0x06, 0x8c, 0x6a, 0xfb, 0x91, 0x37, 0x8d, 0x0d, 0x78,\n 0xbf, 0x49, 0x11, 0xf4, 0x23, 0xe5, 0xce, 0x3b, 0x55, 0xbc, 0xa2, 0x57, 0xe8, 0x22, 0x74, 0xce\n ]),\n new Uint8Array([\n 0x2c, 0xea, 0xc1, 0xbf, 0x4a, 0x24, 0x1f, 0xc2, 0x79, 0x47, 0xa2, 0x7c, 0xb6, 0xd9, 0x68, 0x15,\n 0x80, 0x56, 0x5d, 0x01, 0x33, 0xfd, 0xf4, 0xae, 0xde, 0x30, 0x07, 0x9b, 0xe5, 0x83, 0x9b, 0x68,\n 0x49, 0xb4, 0x2e, 0x83, 0x1f, 0xc2, 0xb5, 0x7c, 0xa2, 0x19, 0xd8, 0xe5, 0x7c, 0x2f, 0x83, 0xda,\n 0xf7, 0x6b, 0x90, 0xfe, 0xc4, 0x01, 0x5a, 0x97, 0x61, 0xa6, 0x3d, 0x40, 0x0b, 0x58, 0xe6, 0x3d\n ]),\n new Uint8Array([\n 0x4d, 0xd1, 0xb2, 0x0f, 0x28, 0xbd, 0xe4, 0x78, 0xf6, 0x4a, 0x0f, 0x93, 0x8b, 0x17, 0xd1, 0xa4,\n 0x3a, 0xec, 0xc9, 0x35, 0x93, 0x56, 0x7e, 0xcb, 0x55, 0x20, 0xa0, 0xfe, 0x6c, 0x89, 0x17, 0x62,\n 0x17, 0x62, 0x4b, 0xb1, 0xb4, 0xde, 0xd1, 0x87, 0xc9, 0x14, 0x3c, 0x4a, 0x7e, 0xa8, 0xe2, 0x7d,\n 0xa0, 0x9f, 0xf6, 0x5c, 0x6a, 0x09, 0x8d, 0xf0, 0x0f, 0xe3, 0x53, 0x25, 0x95, 0x36, 0x28, 0xcb\n ])\n];\n\n/**\n * Initial permutation (IP).\n */\nfunction initialPermutation(src: Uint8Array, index: number): void {\n for (let i = 0; i < 64; ++i) {\n const j = initialPermutationTable[i] - 1;\n if (src[index + ((j >> 3) & 7)] & mask[j & 7]) {\n tmp[(i >> 3) & 7] |= mask[i & 7];\n }\n }\n\n src.set(tmp, index);\n tmp.set(clean);\n}\n\n/**\n * Final permutation (IP^-1).\n */\nfunction finalPermutation(src: Uint8Array, index: number): void {\n for (let i = 0; i < 64; ++i) {\n const j = finalPermutationTable[i] - 1;\n if (src[index + ((j >> 3) & 7)] & mask[j & 7]) {\n tmp[(i >> 3) & 7] |= mask[i & 7];\n }\n }\n\n src.set(tmp, index);\n tmp.set(clean);\n}\n\n/**\n * Transposition (P-BOX).\n */\nfunction transposition(src: Uint8Array, index: number): void {\n for (let i = 0; i < 32; ++i) {\n const j = transpositionTable[i] - 1;\n if (src[index + (j >> 3)] & mask[j & 7]) {\n tmp[(i >> 3) + 4] |= mask[i & 7];\n }\n }\n\n src.set(tmp, index);\n tmp.set(clean);\n}\n\n/**\n * Expansion (E).\n * Expands upper four 8-bits (32b) into eight 6-bits (48b).\n */\nfunction expansion(src: Uint8Array, index: number): void {\n tmp[0] = ((src[index + 7] << 5) | (src[index + 4] >> 3)) & 0x3f; // ..0 vutsr\n tmp[1] = ((src[index + 4] << 1) | (src[index + 5] >> 7)) & 0x3f; // ..srqpo n\n tmp[2] = ((src[index + 4] << 5) | (src[index + 5] >> 3)) & 0x3f; // ..o nmlkj\n tmp[3] = ((src[index + 5] << 1) | (src[index + 6] >> 7)) & 0x3f; // ..kjihg f\n tmp[4] = ((src[index + 5] << 5) | (src[index + 6] >> 3)) & 0x3f; // ..g fedcb\n tmp[5] = ((src[index + 6] << 1) | (src[index + 7] >> 7)) & 0x3f; // ..cba98 7\n tmp[6] = ((src[index + 6] << 5) | (src[index + 7] >> 3)) & 0x3f; // ..8 76543\n tmp[7] = ((src[index + 7] << 1) | (src[index + 4] >> 7)) & 0x3f; // ..43210 v\n\n src.set(tmp, index);\n tmp.set(clean);\n}\n\n/**\n * Substitution boxes (S-boxes).\n * NOTE: This implementation was optimized to process two nibbles in one step (twice as fast).\n */\nfunction substitutionBox(src: Uint8Array, index: number): void {\n for (let i = 0; i < 4; ++i) {\n tmp[i] =\n (substitutionBoxTable[i][src[i * 2 + 0 + index]] & 0xf0) |\n (substitutionBoxTable[i][src[i * 2 + 1 + index]] & 0x0f);\n }\n\n src.set(tmp, index);\n tmp.set(clean);\n}\n\n/**\n * DES round function.\n * XORs src[0..3] with TP(SBOX(E(src[4..7]))).\n */\nfunction roundFunction(src: Uint8Array, index: number): void {\n for (let i = 0; i < 8; i++) {\n tmp2[i] = src[index + i];\n }\n\n expansion(tmp2, 0);\n substitutionBox(tmp2, 0);\n transposition(tmp2, 0);\n\n src[index + 0] ^= tmp2[4];\n src[index + 1] ^= tmp2[5];\n src[index + 2] ^= tmp2[6];\n src[index + 3] ^= tmp2[7];\n}\n\n/**\n * DEcrypt a block\n */\nfunction decryptBlock(src: Uint8Array, index: number): void {\n initialPermutation(src, index);\n roundFunction(src, index);\n finalPermutation(src, index);\n}\n\n/**\n * Decode the whole file\n */\nfunction decodeFull(\n src: Uint8Array,\n length: number,\n entryLength: number\n): void {\n // compute number of digits of the entry length\n const digits = entryLength.toString().length;\n\n // choose size of gap between two encrypted blocks\n // digits: 0 1 2 3 4 5 6 7 8 9 ...\n // cycle: 1 1 1 4 5 14 15 22 23 24 ...\n const cycle =\n digits < 3\n ? 1\n : digits < 5\n ? digits + 1\n : digits < 7\n ? digits + 9\n : digits + 15;\n\n const nblocks = length >> 3;\n\n // first 20 blocks are all des-encrypted\n for (let i = 0; i < 20 && i < nblocks; ++i) {\n decryptBlock(src, i * 8);\n }\n\n for (let i = 20, j = -1; i < nblocks; ++i) {\n // decrypt block\n if (i % cycle === 0) {\n decryptBlock(src, i * 8);\n continue;\n }\n\n // de-shuffle block\n if (++j && j % 7 === 0) {\n shuffleDec(src, i * 8);\n }\n }\n}\n\n/**\n * Decode only the header\n */\nfunction decodeHeader(src: Uint8Array, length: number): void {\n const count = length >> 3;\n\n // first 20 blocks are all des-encrypted\n for (let i = 0; i < 20 && i < count; ++i) {\n decryptBlock(src, i * 8);\n }\n\n // the rest is plaintext, done.\n}\n\n/**\n * Shuffle decode\n */\nfunction shuffleDec(src: Uint8Array, index: number) {\n tmp[0] = src[index + 3];\n tmp[1] = src[index + 4];\n tmp[2] = src[index + 6];\n tmp[3] = src[index + 0];\n tmp[4] = src[index + 1];\n tmp[5] = src[index + 2];\n tmp[6] = src[index + 5];\n tmp[7] = shuffleDecTable[src[index + 7]];\n\n src.set(tmp, index);\n tmp.set(clean);\n}\n\n/**\n * GRF substitution table\n */\nconst shuffleDecTable = (() => {\n // prettier-ignore\n const list = new Uint8Array([\n 0x00, 0x2b, 0x6c, 0x80, 0x01, 0x68, 0x48,\n 0x77, 0x60, 0xff, 0xb9, 0xc0, 0xfe, 0xeb\n ]);\n\n const out = new Uint8Array(Array.from({length: 256}, (_, k) => k));\n const count = list.length;\n\n for (let i = 0; i < count; i += 2) {\n out[list[i + 0]] = list[i + 1];\n out[list[i + 1]] = list[i + 0];\n }\n\n return out;\n})();\n","/**\n * Korean encoding decoder module\n *\n * Uses iconv-lite in Node.js for proper CP949 support.\n * Falls back to TextDecoder in browser (with limitations for CP949 extended chars).\n */\n\n// Try to import iconv-lite (available in Node.js)\nlet iconv: typeof import('iconv-lite') | null = null;\n\n// Dynamic import for Node.js environment\ntry {\n // Use require for synchronous loading in Node.js\n // This will fail in browser environments\n if (typeof process !== 'undefined' && process.versions?.node) {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n iconv = require('iconv-lite');\n }\n} catch {\n // iconv-lite not available (browser environment)\n iconv = null;\n}\n\n/**\n * Check if we're in a Node.js environment with iconv-lite available\n */\nexport function hasIconvLite(): boolean {\n return iconv !== null;\n}\n\n/**\n * Count C1 control characters (U+0080-U+009F) in a string.\n * These usually indicate incorrectly decoded Korean bytes.\n * When EUC-KR decoder encounters CP949-extended bytes (0x80-0x9F range),\n * they get decoded as C1 control characters instead of Korean characters.\n */\nexport function countC1ControlChars(str: string): number {\n let count = 0;\n for (const char of str) {\n const code = char.charCodeAt(0);\n if (code >= 0x80 && code <= 0x9F) {\n count++;\n }\n }\n return count;\n}\n\n/**\n * Count replacement characters (U+FFFD) in a string\n */\nexport function countReplacementChars(str: string): number {\n let count = 0;\n for (const char of str) {\n if (char === '\\uFFFD') count++;\n }\n return count;\n}\n\n/**\n * Count total \"bad\" characters (replacement + C1 control)\n */\nexport function countBadChars(str: string): number {\n return countReplacementChars(str) + countC1ControlChars(str);\n}\n\n/**\n * Decode bytes to string using the specified encoding.\n *\n * For Korean encodings (cp949, euc-kr), uses iconv-lite in Node.js\n * for proper CP949 extended character support.\n *\n * @param bytes - The bytes to decode\n * @param encoding - The encoding to use ('utf-8', 'euc-kr', 'cp949', 'latin1')\n * @returns The decoded string\n */\nexport function decodeBytes(bytes: Uint8Array, encoding: string): string {\n // Normalize encoding name\n const enc = encoding.toLowerCase();\n\n // For Korean encodings, prefer iconv-lite in Node.js\n // iconv-lite properly handles CP949 extended range (0x81-0xFE first byte)\n // which TextDecoder('euc-kr') doesn't fully support\n if ((enc === 'cp949' || enc === 'euc-kr') && iconv) {\n try {\n // Always use 'cp949' with iconv-lite as it's a superset of euc-kr\n // This properly handles the extended range that causes C1 control chars\n const buffer = Buffer.from(bytes);\n return iconv.decode(buffer, 'cp949');\n } catch {\n // Fall through to TextDecoder\n }\n }\n\n // Use TextDecoder for other encodings or as fallback\n try {\n // Map cp949 to euc-kr for TextDecoder (best effort, not perfect)\n const textDecoderEncoding = enc === 'cp949' ? 'euc-kr' : enc;\n const decoder = new TextDecoder(textDecoderEncoding, { fatal: false });\n return decoder.decode(bytes);\n } catch {\n // Ultimate fallback: decode as latin1 (preserves all byte values)\n return Array.from(bytes).map(b => String.fromCharCode(b)).join('');\n }\n}\n\n/**\n * Try to decode bytes and check quality of the result\n */\nexport function tryDecodeWithQuality(\n bytes: Uint8Array,\n encoding: string\n): { text: string; badChars: number; c1Chars: number; replacementChars: number } {\n const text = decodeBytes(bytes, encoding);\n const c1Chars = countC1ControlChars(text);\n const replacementChars = countReplacementChars(text);\n const badChars = c1Chars + replacementChars;\n\n return { text, badChars, c1Chars, replacementChars };\n}\n\n// ============================================================================\n// Mojibake Detection and Fixing\n// ============================================================================\n\n/**\n * Common mojibake patterns that indicate CP949 was misread as Windows-1252.\n * These are high-frequency Korean syllable byte sequences that produce\n * recognizable Latin character patterns when misinterpreted.\n */\nconst MOJIBAKE_PATTERNS = [\n /[ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß][¡-þ]/, // Common Korean lead bytes as Latin\n /À¯/, // 유 (very common)\n /Àú/, // 저\n /ÀÎ/, // 인\n /Ÿ/, // 터/타\n /Æä/, // 페\n /ÀÌ/, // 이\n /½º/, // 스\n /¾Æ/, // 아\n /¸ð/, // 모\n /¸®/, // 리\n /¿¡/, // 에\n /Áö/, // 지\n /µ¥/, // 데\n /ÅØ/, // 텍\n /½ºÆ®/, // 스트\n /¸ÁÅä/, // 망토\n];\n\n/**\n * Check if a string looks like mojibake (CP949 bytes misread as Windows-1252).\n *\n * Mojibake occurs when:\n * 1. Korean text is encoded as CP949 bytes\n * 2. Those bytes are incorrectly decoded as Windows-1252/Latin-1\n *\n * Example: \"유저인터페이스\" → \"À¯ÀúÀÎÅÍÆäÀ̽º\"\n *\n * @param str - The string to check\n * @returns true if the string appears to be mojibake\n */\nexport function isMojibake(str: string): boolean {\n // Quick checks\n if (!str || str.length === 0) return false;\n\n // If string contains Korean characters, it's not mojibake\n if (/[\\uAC00-\\uD7AF]/.test(str)) return false;\n\n // Check for common mojibake patterns\n for (const pattern of MOJIBAKE_PATTERNS) {\n if (pattern.test(str)) return true;\n }\n\n // Check for high concentration of Latin Extended characters (0x80-0xFF)\n // which are common in mojibake but rare in normal text\n let highLatinCount = 0;\n for (const char of str) {\n const code = char.charCodeAt(0);\n if (code >= 0x80 && code <= 0xFF) {\n highLatinCount++;\n }\n }\n\n // If more than 30% of characters are in the Latin Extended range,\n // it's likely mojibake\n const ratio = highLatinCount / str.length;\n return ratio > 0.3;\n}\n\n/**\n * Fix mojibake by re-encoding as Windows-1252 and decoding as CP949.\n *\n * This reverses the common encoding error where CP949 bytes were\n * incorrectly interpreted as Windows-1252.\n *\n * Example: \"À¯ÀúÀÎÅÍÆäÀ̽º\" → \"유저인터페이스\"\n *\n * @param garbled - The mojibake string to fix\n * @returns The corrected Korean string, or the original if unfixable\n */\nexport function fixMojibake(garbled: string): string {\n if (!iconv) {\n // Without iconv-lite, we can't fix mojibake\n return garbled;\n }\n\n try {\n // Encode the garbled string back to Windows-1252 bytes\n const bytes = iconv.encode(garbled, 'windows-1252');\n // Decode those bytes as CP949 to get the original Korean\n const fixed = iconv.decode(bytes, 'cp949');\n\n // Verify the fix worked by checking if:\n // 1. The result contains Korean characters (Hangul Syllables block)\n // 2. The result has fewer or equal bad chars\n const hasKorean = /[\\uAC00-\\uD7AF]/.test(fixed);\n const fixedBadChars = countBadChars(fixed);\n const garbledBadChars = countBadChars(garbled);\n\n if (hasKorean && fixedBadChars <= garbledBadChars) {\n return fixed;\n }\n\n return garbled;\n } catch {\n return garbled;\n }\n}\n\n/**\n * Convert Korean text to mojibake (for testing purposes).\n *\n * This simulates the encoding error where Korean text is encoded as CP949\n * but decoded as Windows-1252.\n *\n * Example: \"유저인터페이스\" → \"À¯ÀúÀÎÅÍÆäÀ̽º\"\n *\n * @param korean - The Korean string to garble\n * @returns The mojibake string\n */\nexport function toMojibake(korean: string): string {\n if (!iconv) {\n return korean;\n }\n\n try {\n const bytes = iconv.encode(korean, 'cp949');\n return iconv.decode(bytes, 'windows-1252');\n } catch {\n return korean;\n }\n}\n\n/**\n * Normalize a filename by detecting and fixing encoding issues.\n *\n * This function:\n * 1. Checks if the filename is mojibake and fixes it\n * 2. Returns the normalized filename\n *\n * @param filename - The filename to normalize\n * @returns The normalized filename\n */\nexport function normalizeFilename(filename: string): string {\n if (isMojibake(filename)) {\n return fixMojibake(filename);\n }\n return filename;\n}\n\n/**\n * Normalize a path by fixing mojibake in each segment.\n *\n * @param filepath - The full path to normalize\n * @returns The normalized path\n */\nexport function normalizePath(filepath: string): string {\n // Split by both forward and back slashes\n const segments = filepath.split(/[\\\\/]/);\n const normalizedSegments = segments.map(seg => normalizeFilename(seg));\n\n // Preserve original separator style\n const separator = filepath.includes('\\\\') ? '\\\\' : '/';\n return normalizedSegments.join(separator);\n}\n\n// ============================================================================\n// Encoding Detection\n// ============================================================================\n\n/**\n * Detect the best encoding for Korean GRF files by analyzing byte patterns\n * and comparing decoded results.\n *\n * This function:\n * 1. Checks if bytes contain non-ASCII characters\n * 2. Tries UTF-8 and CP949 decoding\n * 3. Compares quality (bad chars, C1 control chars)\n * 4. Returns the encoding with best quality\n */\nexport function detectBestKoreanEncoding(\n sampleBytes: Uint8Array[],\n threshold: number = 0.01\n): 'utf-8' | 'cp949' {\n if (sampleBytes.length === 0) return 'utf-8';\n\n let utf8BadTotal = 0;\n let cp949BadTotal = 0;\n let totalBytes = 0;\n let samplesWithHighBytes = 0;\n\n for (const bytes of sampleBytes) {\n // Check if this sample has non-ASCII bytes\n const hasHighBytes = bytes.some(b => b > 0x7F);\n if (!hasHighBytes) continue;\n\n samplesWithHighBytes++;\n totalBytes += bytes.length;\n\n const utf8Result = tryDecodeWithQuality(bytes, 'utf-8');\n const cp949Result = tryDecodeWithQuality(bytes, 'cp949');\n\n utf8BadTotal += utf8Result.badChars;\n cp949BadTotal += cp949Result.badChars;\n }\n\n // If no high bytes found, it's pure ASCII - use UTF-8\n if (samplesWithHighBytes === 0) {\n return 'utf-8';\n }\n\n const utf8BadRatio = totalBytes > 0 ? utf8BadTotal / totalBytes : 0;\n const cp949BadRatio = totalBytes > 0 ? cp949BadTotal / totalBytes : 0;\n\n // If UTF-8 looks perfect, use it\n if (utf8BadRatio < threshold) {\n return 'utf-8';\n }\n\n // If CP949 produces fewer bad chars, use it\n if (cp949BadRatio < utf8BadRatio) {\n return 'cp949';\n }\n\n // Default to UTF-8\n return 'utf-8';\n}\n","import jDataview from 'jdataview';\nimport {GrfBase, GrfOptions} from './grf-base';\n\n/**\n * Using this Browser, we work from a File or Blob object.\n * We are use the FileReader API to read only some part of the file to avoid\n * loading 2 gigas into memory\n */\nexport class GrfBrowser extends GrfBase<File | Blob> {\n constructor(file: File | Blob, options?: GrfOptions) {\n super(file, options);\n }\n public async getStreamBuffer(\n buffer: File | Blob,\n offset: number,\n length: number\n ): Promise<Uint8Array> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onerror = reject;\n reader.onload = () =>\n resolve(new Uint8Array(reader.result as ArrayBuffer));\n reader.readAsArrayBuffer(buffer.slice(offset, offset + length));\n });\n }\n}\n","// src/grf-node.ts\nimport { fstatSync, read as readCallback } from 'fs';\nimport { promisify } from 'util';\nimport { GrfBase, GrfOptions } from './grf-base';\nimport { bufferPool } from './buffer-pool';\n\nconst readAsync = promisify(readCallback);\n\n/** Options for GrfNode */\nexport interface GrfNodeOptions extends GrfOptions {\n /** Use buffer pool for better performance (default: true) */\n useBufferPool?: boolean;\n}\n\nexport class GrfNode extends GrfBase<number> {\n private useBufferPool: boolean;\n\n constructor(fd: number, options?: GrfNodeOptions) {\n super(fd, options);\n\n this.useBufferPool = options?.useBufferPool ?? true;\n\n // Na nossa API, apenas FDs para arquivos regulares são válidos.\n // fstatSync lança erro se o descritor não existir ou não for arquivo.\n try {\n const stat = fstatSync(fd);\n if (!stat.isFile()) {\n throw new Error('GRFNode: file descriptor must point to a regular file');\n }\n } catch {\n // Converte em mensagem clara para o usuário\n throw new Error('GRFNode: invalid file descriptor');\n }\n }\n\n public async getStreamBuffer(\n fd: number,\n offset: number,\n length: number\n ): Promise<Uint8Array> {\n // Use buffer pool for better performance\n const buffer = this.useBufferPool\n ? bufferPool.acquire(length)\n : Buffer.allocUnsafe(length);\n\n const { bytesRead } = await readAsync(fd, buffer, 0, length, offset);\n\n if (bytesRead !== length) {\n // Release buffer back to pool if read failed\n if (this.useBufferPool) {\n bufferPool.release(buffer);\n }\n // ERRO TYPE: GRFNode: unexpected EOF\n throw new Error('Not a GRF file (invalid signature)');\n }\n\n return buffer;\n }\n}\n","/**\n * Simple buffer pool for reducing GC pressure\n * Pools buffers of common sizes for reuse\n */\n\ninterface PoolEntry {\n buffer: Buffer;\n inUse: boolean;\n}\n\nclass BufferPool {\n private pools = new Map<number, PoolEntry[]>();\n private maxPoolSize = 10;\n\n // Common buffer sizes to pool (in bytes)\n private readonly poolSizes = [\n 1024, // 1KB\n 4096, // 4KB\n 8192, // 8KB\n 16384, // 16KB\n 32768, // 32KB\n 65536, // 64KB\n 131072, // 128KB\n 262144, // 256KB\n ];\n\n constructor() {\n // Initialize pools for common sizes\n for (const size of this.poolSizes) {\n this.pools.set(size, []);\n }\n }\n\n /**\n * Get appropriate pool size for requested length\n */\n private getPoolSize(length: number): number | null {\n for (const size of this.poolSizes) {\n if (length <= size) {\n return size;\n }\n }\n return null; // Too large, don't pool\n }\n\n /**\n * Acquire a buffer from the pool or create new one\n */\n acquire(length: number): Buffer {\n const poolSize = this.getPoolSize(length);\n\n // Don't pool large buffers\n if (poolSize === null) {\n return Buffer.allocUnsafe(length);\n }\n\n const pool = this.pools.get(poolSize);\n\n if (pool) {\n // Try to find available buffer\n const available = pool.find(entry => !entry.inUse);\n\n if (available) {\n available.inUse = true;\n return available.buffer.subarray(0, length);\n }\n\n // Pool is full or all in use, create new if pool not maxed\n if (pool.length < this.maxPoolSize) {\n const buffer = Buffer.allocUnsafe(poolSize);\n pool.push({ buffer, inUse: true });\n return buffer.subarray(0, length);\n }\n }\n\n // Fallback: create non-pooled buffer\n return Buffer.allocUnsafe(length);\n }\n\n /**\n * Release a buffer back to the pool\n */\n release(buffer: Buffer): void {\n const actualSize = buffer.buffer.byteLength;\n const pool = this.pools.get(actualSize);\n\n if (pool) {\n const entry = pool.find(e => e.buffer === buffer || e.buffer.buffer === buffer.buffer);\n if (entry) {\n entry.inUse = false;\n }\n }\n }\n\n /**\n * Clear all pools\n */\n clear(): void {\n for (const pool of this.pools.values()) {\n pool.length = 0;\n }\n }\n\n /**\n * Get pool statistics\n */\n stats(): { size: number; total: number; inUse: number }[] {\n const stats: { size: number; total: number; inUse: number }[] = [];\n\n for (const [size, pool] of this.pools.entries()) {\n stats.push({\n size,\n total: pool.length,\n inUse: pool.filter(e => e.inUse).length\n });\n }\n\n return stats;\n }\n}\n\n// Export singleton instance\nexport const bufferPool = new BufferPool();\n"],"mappings":"skBAAA,IAAAA,GAAA,GAAAC,GAAAD,GAAA,qBAAAE,GAAA,eAAAC,EAAA,aAAAC,EAAA,YAAAC,EAAA,eAAAC,EAAA,kBAAAC,EAAA,wBAAAC,EAAA,0BAAAC,EAAA,gBAAAC,EAAA,iBAAAC,EAAA,eAAAC,EAAA,0BAAAC,GAAA,sBAAAC,EAAA,eAAAC,IAAA,eAAAC,GAAAhB,ICAA,IAAAiB,EAAiB,qBACjBC,GAAsB,0BCKtB,IAAMC,EAAO,IAAI,WAAW,CAAC,IAAM,GAAM,GAAM,GAAM,EAAM,EAAM,EAAM,CAAI,CAAC,EACtEC,EAAM,IAAI,WAAW,CAAC,EACtBC,EAAO,IAAI,WAAW,CAAC,EACvBC,EAAQ,IAAI,WAAW,CAAC,EAGxBC,GAA0B,IAAI,WAAW,CAC7C,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAK,EAC7B,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAK,EAC7B,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAK,EAC7B,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAK,EAC7B,GAAI,GAAI,GAAI,GAAI,GAAI,GAAK,EAAI,EAC7B,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAK,EAC7B,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAK,EAC7B,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAK,CAC/B,CAAC,EAGKC,GAAwB,IAAI,WAAW,CAC3C,GAAK,EAAG,GAAI,GAAI,GAAI,GAAI,GAAI,GAC5B,GAAK,EAAG,GAAI,GAAI,GAAI,GAAI,GAAI,GAC5B,GAAK,EAAG,GAAI,GAAI,GAAI,GAAI,GAAI,GAC5B