@chicowall/grf-loader
Version:
A loader for GRF files (Ragnarok Online game file)
290 lines (283 loc) • 9.64 kB
TypeScript
import jDataview from 'jdataview';
interface TFileEntry {
type: number;
offset: number;
realSize: number;
compressedSize: number;
lengthAligned: number;
/** Raw filename bytes for re-decoding if needed */
rawNameBytes?: Uint8Array;
}
/** Supported filename encodings */
type FilenameEncoding = 'utf-8' | 'euc-kr' | 'cp949' | 'latin1' | 'auto';
/** GRF loader options */
interface GrfOptions {
/** Encoding for filenames (default: 'auto') */
filenameEncoding?: FilenameEncoding;
/** Threshold for auto-detection: if % of U+FFFD exceeds this, try Korean encodings (default: 0.01 = 1%) */
autoDetectThreshold?: number;
/** Maximum uncompressed size per file in bytes (default: 256MB) */
maxFileUncompressedBytes?: number;
/** Maximum total entries allowed (default: 500000) */
maxEntries?: number;
}
/** Search/find options */
interface FindOptions {
/** Filter by file extension (without dot, e.g., 'spr', 'act') */
ext?: string;
/** Filter by substring in path */
contains?: string;
/** Filter by path ending */
endsWith?: string;
/** Filter by regex pattern */
regex?: RegExp;
/** Maximum results to return (default: unlimited) */
limit?: number;
}
/** Result of path resolution */
interface ResolveResult {
status: 'found' | 'not_found' | 'ambiguous';
/** The exact matched path (if found) */
matchedPath?: string;
/** All candidate paths (if ambiguous) */
candidates?: string[];
}
/** GRF statistics */
interface GrfStats {
/** Total file count */
fileCount: number;
/** Number of filenames with replacement character (U+FFFD) */
badNameCount: number;
/** Number of normalized key collisions */
collisionCount: number;
/** Extension statistics: ext -> count */
extensionStats: Map<string, number>;
/** Detected encoding used */
detectedEncoding: FilenameEncoding;
}
declare const GRF_ERROR_CODES: {
readonly INVALID_MAGIC: "GRF_INVALID_MAGIC";
readonly UNSUPPORTED_VERSION: "GRF_UNSUPPORTED_VERSION";
readonly NOT_LOADED: "GRF_NOT_LOADED";
readonly FILE_NOT_FOUND: "GRF_FILE_NOT_FOUND";
readonly AMBIGUOUS_PATH: "GRF_AMBIGUOUS_PATH";
readonly DECOMPRESS_FAIL: "GRF_DECOMPRESS_FAIL";
readonly CORRUPT_TABLE: "GRF_CORRUPT_TABLE";
readonly LIMIT_EXCEEDED: "GRF_LIMIT_EXCEEDED";
readonly INVALID_OFFSET: "GRF_INVALID_OFFSET";
readonly DECRYPT_REQUIRED: "GRF_DECRYPT_REQUIRED";
};
declare class GrfError extends Error {
code: keyof typeof GRF_ERROR_CODES;
context?: Record<string, unknown> | undefined;
constructor(code: keyof typeof GRF_ERROR_CODES, message: string, context?: Record<string, unknown> | undefined);
}
declare abstract class GrfBase<T> {
private fd;
version: number;
fileCount: number;
loaded: boolean;
/** Map of exact filename -> entry */
files: Map<string, TFileEntry>;
/** Map of normalized path -> array of exact filenames (supports collisions) */
private normalizedIndex;
/** Map of extension -> array of exact filenames (for fast extension lookup) */
private extensionIndex;
private fileTableOffset;
private cache;
private cacheMaxSize;
private cacheOrder;
protected options: Required<GrfOptions>;
private _stats;
constructor(fd: T, options?: GrfOptions);
abstract getStreamBuffer(fd: T, offset: number, length: number): Promise<Uint8Array>;
getStreamReader(offset: number, length: number): Promise<jDataview>;
load(): Promise<void>;
private parseHeader;
private parseFileList;
private decodeEntry;
private addToCache;
private getFromCache;
clearCache(): void;
getFile(filename: string): Promise<{
data: null | Uint8Array;
error: null | string;
}>;
/**
* Resolve a path to its exact filename in the GRF.
* Tries exact match first, then normalized (case-insensitive, slash-agnostic).
*/
resolvePath(query: string): ResolveResult;
/**
* Check if a file exists in the GRF.
*/
hasFile(filename: string): boolean;
/**
* Get file entry metadata without extracting the file.
*/
getEntry(filename: string): TFileEntry | null;
/**
* Find files matching the given criteria.
*/
find(options?: FindOptions): string[];
/**
* Get all files with a specific extension.
*/
getFilesByExtension(ext: string): string[];
/**
* List all unique extensions in the GRF.
*/
listExtensions(): string[];
/**
* List all files in the GRF.
*/
listFiles(): string[];
/**
* Get GRF statistics.
*/
getStats(): GrfStats;
/**
* Get the detected/configured encoding used for filenames.
*/
getDetectedEncoding(): FilenameEncoding;
/**
* Re-decode all filenames with a different encoding.
* Useful if auto-detection chose wrong or you want to try a specific encoding.
*/
reloadWithEncoding(encoding: FilenameEncoding): Promise<void>;
}
/**
* Using this Browser, we work from a File or Blob object.
* We are use the FileReader API to read only some part of the file to avoid
* loading 2 gigas into memory
*/
declare class GrfBrowser extends GrfBase<File | Blob> {
constructor(file: File | Blob, options?: GrfOptions);
getStreamBuffer(buffer: File | Blob, offset: number, length: number): Promise<Uint8Array>;
}
/** Options for GrfNode */
interface GrfNodeOptions extends GrfOptions {
/** Use buffer pool for better performance (default: true) */
useBufferPool?: boolean;
}
declare class GrfNode extends GrfBase<number> {
private useBufferPool;
constructor(fd: number, options?: GrfNodeOptions);
getStreamBuffer(fd: number, offset: number, length: number): Promise<Uint8Array>;
}
/**
* Simple buffer pool for reducing GC pressure
* Pools buffers of common sizes for reuse
*/
declare class BufferPool {
private pools;
private maxPoolSize;
private readonly poolSizes;
constructor();
/**
* Get appropriate pool size for requested length
*/
private getPoolSize;
/**
* Acquire a buffer from the pool or create new one
*/
acquire(length: number): Buffer;
/**
* Release a buffer back to the pool
*/
release(buffer: Buffer): void;
/**
* Clear all pools
*/
clear(): void;
/**
* Get pool statistics
*/
stats(): {
size: number;
total: number;
inUse: number;
}[];
}
declare const bufferPool: BufferPool;
/**
* Korean encoding decoder module
*
* Uses iconv-lite in Node.js for proper CP949 support.
* Falls back to TextDecoder in browser (with limitations for CP949 extended chars).
*/
/**
* Check if we're in a Node.js environment with iconv-lite available
*/
declare function hasIconvLite(): boolean;
/**
* Count C1 control characters (U+0080-U+009F) in a string.
* These usually indicate incorrectly decoded Korean bytes.
* When EUC-KR decoder encounters CP949-extended bytes (0x80-0x9F range),
* they get decoded as C1 control characters instead of Korean characters.
*/
declare function countC1ControlChars(str: string): number;
/**
* Count replacement characters (U+FFFD) in a string
*/
declare function countReplacementChars(str: string): number;
/**
* Count total "bad" characters (replacement + C1 control)
*/
declare function countBadChars(str: string): number;
/**
* Check if a string looks like mojibake (CP949 bytes misread as Windows-1252).
*
* Mojibake occurs when:
* 1. Korean text is encoded as CP949 bytes
* 2. Those bytes are incorrectly decoded as Windows-1252/Latin-1
*
* Example: "유저인터페이스" → "À¯ÀúÀÎÅÍÆäÀ̽º"
*
* @param str - The string to check
* @returns true if the string appears to be mojibake
*/
declare function isMojibake(str: string): boolean;
/**
* Fix mojibake by re-encoding as Windows-1252 and decoding as CP949.
*
* This reverses the common encoding error where CP949 bytes were
* incorrectly interpreted as Windows-1252.
*
* Example: "À¯ÀúÀÎÅÍÆäÀ̽º" → "유저인터페이스"
*
* @param garbled - The mojibake string to fix
* @returns The corrected Korean string, or the original if unfixable
*/
declare function fixMojibake(garbled: string): string;
/**
* Convert Korean text to mojibake (for testing purposes).
*
* This simulates the encoding error where Korean text is encoded as CP949
* but decoded as Windows-1252.
*
* Example: "유저인터페이스" → "À¯ÀúÀÎÅÍÆäÀ̽º"
*
* @param korean - The Korean string to garble
* @returns The mojibake string
*/
declare function toMojibake(korean: string): string;
/**
* Normalize a filename by detecting and fixing encoding issues.
*
* This function:
* 1. Checks if the filename is mojibake and fixes it
* 2. Returns the normalized filename
*
* @param filename - The filename to normalize
* @returns The normalized filename
*/
declare function normalizeFilename(filename: string): string;
/**
* Normalize a path by fixing mojibake in each segment.
*
* @param filepath - The full path to normalize
* @returns The normalized path
*/
declare function normalizePath(filepath: string): string;
export { type FilenameEncoding, type FindOptions, GRF_ERROR_CODES, GrfBrowser, GrfError, GrfNode, type GrfNodeOptions, type GrfOptions, type GrfStats, type ResolveResult, type TFileEntry, bufferPool, countBadChars, countC1ControlChars, countReplacementChars, fixMojibake, hasIconvLite, isMojibake, normalizePath as normalizeEncodingPath, normalizeFilename, toMojibake };