@chicowall/grf-loader
v1.2.0
Published
A loader for GRF files (Ragnarok Online game file)
Maintainers
Readme
GRF Loader
GRF is an archive file format that supports lossless data compression used on Ragnarok Online to store game assets. A GRF file may contain one or more files or directories that may have been compressed (deflate) and encrypted (variant of DES).
Features
- ✅ GRF version 0x200 and 0x300 support
- ✅ Works in both Node.js and browser environments
- ✅ DES decryption support
- ✅ Korean filename encoding (CP949/EUC-KR) with auto-detection
- ✅ Mojibake detection and fixing
- ✅ Case-insensitive path resolution
- ✅ Collision-safe indexing (no lost files)
- ✅ Memory efficient (streams data without loading entire file)
- ❌ Custom encryption not supported
Installation
npm install @chicowall/grf-loaderQuick Start
Node.js
import { GrfNode } from '@chicowall/grf-loader';
import { openSync } from 'fs';
const fd = openSync('path/to/data.grf', 'r');
const grf = new GrfNode(fd);
await grf.load();
// Get file
const { data, error } = await grf.getFile('data\\sprite\\monster.spr');Browser
import { GrfBrowser } from '@chicowall/grf-loader';
const file = document.querySelector('input[type="file"]').files[0];
const grf = new GrfBrowser(file);
await grf.load();Configuration Options
const grf = new GrfNode(fd, {
// Filename encoding: 'auto' | 'cp949' | 'euc-kr' | 'utf-8' | 'latin1'
filenameEncoding: 'auto',
// Auto-detection threshold for bad characters (default: 1%)
autoDetectThreshold: 0.01,
// Maximum uncompressed file size (default: 256MB)
maxFileUncompressedBytes: 256 * 1024 * 1024,
// Maximum entries allowed (default: 500,000)
maxEntries: 500000,
// Decoded files kept in memory by getFile (default: 50; 0 turns the cache off)
cacheMaxFiles: 50,
// Bytes of decoded files kept in memory (default: 64 MiB; a larger file is never cached)
cacheMaxBytes: 64 * 1024 * 1024
});A server with a cache of its own should pass cacheMaxFiles: 0 and keep the memory in one place.
Builds
| How you load it | File | What it has |
|---|---|---|
| import / require in Node | dist/index.js, dist/index.cjs | GrfNode and GrfBrowser; Korean names through iconv-lite |
| A bundler for the browser (the browser export condition) | dist/browser.js | GrfBrowser and the types; no Node modules |
| A <script> tag | dist/index.global.js (window.GrfLoader) | the same as above |
In the browser, names are decoded with TextDecoder('euc-kr'), which the Encoding Standard defines as
windows-949 -- CP949 extension syllables included. The mojibake helpers need iconv-lite and return their
input unchanged there; hasIconvLite() says which of the two you are in.
API Reference
File Operations
// Get file data
const { data, error } = await grf.getFile('data\\clientinfo.xml');
// Check if file exists (case-insensitive)
grf.hasFile('DATA\\CLIENTINFO.XML'); // true
// Get file entry metadata
const entry = grf.getEntry('data\\clientinfo.xml');
// { type, offset, realSize, compressedSize, lengthAligned, rawNameBytes }
// Resolve path (handles case-insensitivity and collisions)
const result = grf.resolvePath('DATA\\Sprite\\Test.spr');
// { status: 'found' | 'not_found' | 'ambiguous', matchedPath?, candidates? }Search API
// Find files with multiple filters
const files = grf.find({
ext: 'spr', // Filter by extension
contains: 'monster', // Filter by substring (case-insensitive)
endsWith: 'poring.spr', // Filter by path ending
regex: /^data\\sprite/, // Filter by regex
limit: 100 // Max results
});
// Get all files by extension (fast, uses index)
const sprites = grf.getFilesByExtension('spr');
const textures = grf.getFilesByExtension('bmp');
// List all unique extensions
const extensions = grf.listExtensions();
// ['spr', 'act', 'bmp', 'wav', ...]
// List all files
const allFiles = grf.listFiles();Statistics
const stats = grf.getStats();
// {
// fileCount: 203092,
// badNameCount: 4, // Files with encoding issues
// collisionCount: 0, // Normalized path collisions
// extensionStats: Map, // Extension -> count
// detectedEncoding: 'cp949'
// }
// Get detected encoding
const encoding = grf.getDetectedEncoding(); // 'cp949' | 'utf-8' | ...Encoding Utilities
import {
isMojibake,
fixMojibake,
normalizeFilename,
normalizeEncodingPath,
countBadChars,
hasIconvLite
} from '@chicowall/grf-loader';
// Detect mojibake (CP949 misread as Windows-1252)
isMojibake('À¯ÀúÀÎÅÍÆäÀ̽º'); // true
isMojibake('유저인터페이스'); // false
// Fix mojibake
fixMojibake('À¯ÀúÀÎÅÍÆäÀ̽º'); // '유저인터페이스'
// Normalize entire path
normalizeEncodingPath('data\\texture\\À¯ÀúÀÎÅÍÆäÀ̽º\\test.bmp');
// 'data\\texture\\유저인터페이스\\test.bmp'
// Count problematic characters
countBadChars('test�file.txt'); // 1 (U+FFFD replacement char)
// Check if iconv-lite is available (Node.js only)
hasIconvLite(); // true in Node.js, false in browserKorean Encoding Support
GRF files from Korean Ragnarok Online clients use CP949 encoding for filenames. This library automatically detects and handles Korean encoding:
// Auto-detection (default)
const grf = new GrfNode(fd, { filenameEncoding: 'auto' });
// Force CP949
const grf = new GrfNode(fd, { filenameEncoding: 'cp949' });
// Reload with different encoding
await grf.reloadWithEncoding('euc-kr');Encoding Detection Results
| Scenario | Detection | Result |
|----------|-----------|--------|
| Korean GRF | cp949 | ✅ Proper Korean display |
| English GRF | utf-8 | ✅ ASCII preserved |
| Mixed content | cp949 | ✅ Both work |
Error Handling
import { GrfError, GRF_ERROR_CODES } from '@chicowall/grf-loader';
try {
await grf.load();
} catch (e) {
if (e instanceof GrfError) {
switch (e.code) {
case 'INVALID_MAGIC':
console.log('Not a GRF file');
break;
case 'UNSUPPORTED_VERSION':
console.log('Only versions 0x200 and 0x300 are supported');
break;
case 'CORRUPT_TABLE':
console.log('File table is corrupted');
break;
case 'LIMIT_EXCEEDED':
console.log('File exceeds size limit');
break;
}
}
}Error Codes
| Code | Description |
|------|-------------|
| INVALID_MAGIC | File is not a GRF (invalid signature) |
| UNSUPPORTED_VERSION | GRF version is neither 0x200 nor 0x300 |
| NOT_LOADED | GRF not loaded yet |
| FILE_NOT_FOUND | Requested file not in archive |
| AMBIGUOUS_PATH | Multiple files match (collision) |
| DECOMPRESS_FAIL | Decompression failed |
| CORRUPT_TABLE | File table is corrupted |
| LIMIT_EXCEEDED | Size/count limit exceeded |
Validation Tools
Validate a Single GRF
npm run validate:grf -- path/to/data.grf auto 100Validate All GRFs in a Folder
npm run validate:all -- path/to/grf/folder autoOutput example:
================================================================================
SUMMARY
================================================================================
GRFs loaded: 3/3
Total files: 655,144
Bad U+FFFD: 12
Bad C1 Control: 40
Read tests passed: 300
Read tests failed: 0
Encoding Health: 99.99% (655,092/655,144 clean)Examples
Extract All Files
npx ts-node examples/extract-all.ts path/to/data.grf output-directoryList All Files by Extension
const grf = new GrfNode(fd);
await grf.load();
// Get all sprite files
const sprites = grf.getFilesByExtension('spr');
console.log(`Found ${sprites.length} sprite files`);
// Get extension statistics
const stats = grf.getStats();
for (const [ext, count] of stats.extensionStats) {
console.log(`${ext}: ${count} files`);
}Handle Case-Insensitive Lookups
// All of these resolve to the same file:
await grf.getFile('data\\sprite\\monster.spr');
await grf.getFile('DATA\\SPRITE\\MONSTER.SPR');
await grf.getFile('data/sprite/monster.spr');Browser Limitations
- iconv-lite is not available in browsers, so
fixMojibake,toMojibakeandnormalizeFilenamereturn what they are given;hasIconvLite()returns false - Filenames are decoded with
TextDecoder, whose EUC-KR is windows-949 in browsers but plain EUC-KR in Node: reading an archive withGrfBrowserunder Node turns CP949 extension syllables into C1 control characters
Performance
yarn build
yarn bench path/to/data.grf # this build
yarn bench path/to/data.grf other/dist/index.cjs # and another one, side by sideSee PERFORMANCE.md for what the numbers look like on a 3.27 GiB archive.
Deprecated
Removed in the next major version:
| What | Why |
|---|---|
| bufferPool, GrfNodeOptions.useBufferPool | reads allocate their own buffer; the option is ignored |
| getStreamReader() | the loader reads with DataView; jdataview is only still there for this method |
License
MIT
