Remote client server for roBrowserLegacy that serves Ragnarok Online game assets from GRF files over HTTP. Players can play directly in the browser without needing the full client installed locally.
With Unified Server Mode, this single Node.js process replaces three separate services — serving game assets, static files, and proxying WebSocket connections to rAthena — all on one port.
- Features
- Architecture
- Installation and Setup
- Unified Server Mode
- Plugins
- Performance Features
- Environment Variables
- API Endpoints
- Testing
- NPM Scripts
- Korean Filename Encoding Support
- Directory Structure
- Troubleshooting
- License
- Authors
- Unified Server Mode — single process replaces wsproxy + live-server + asset server
- Embedded WebSocket proxy — bridges browser WebSocket to rAthena TCP (replaces standalone wsproxy)
- Embedded static file server — serves roBrowserLegacy client files (replaces live-server)
- LRU file cache with configurable size (up to 5000 files / 1GB+)
- Cache warm-up — pre-loads frequently used assets on startup
- GRF file indexing — O(1) file lookups across all GRF archives
- HTTP cache headers (ETag, Cache-Control) for browser caching, and byte ranges for audio
- File search for the map, model and GRF viewers, answering exactly as a locally loaded archive would
- Gzip/Deflate compression for text-based responses
- Korean filename encoding support (CP949/EUC-KR) with mojibake detection/fixing
- Path mapping system for encoding conversion (Korean path → GRF path)
- Auto-extraction (opt-in) — saves GRF files to disk for faster subsequent access
- Client folders anywhere —
BGM/,System/,AI/and loosedata/files read from a client installed elsewhere, and GRFs by absolute path - Missing files logging with notifications
- REST API for health checks, cache stats, and file search
- Cross-Origin Resource Sharing (CORS)
One Node.js process on a single port handles everything:
Browser ──HTTP──→ Express (:3338)
├── /applications/pwa/* → roBrowserLegacy static files
├── /data/* → GRF asset serving
├── /api/* → Health, cache stats, search
└── /ws/* → WebSocket proxy → rAthena TCP
Docker (rAthena)
├── MariaDB :3306
├── Login :6900 ←──┐
├── Char :6121 ←──┤ TCP via /ws/ proxy
└── Map :5121 ←──┘
Before (3 Node.js processes):
| Process | Port | Purpose |
|---|---|---|
| wsproxy | 5999 | WebSocket → TCP bridge |
| RemoteClient-JS | 3338 | GRF asset server |
| live-server | 8000 | Static file server |
After (1 Node.js process):
| Process | Port | Purpose |
|---|---|---|
| RemoteClient-JS | 3338 | Everything |
Set ENABLE_WSPROXY=false and ENABLE_STATIC_SERVE=false in .env to run in legacy mode with separate processes.
Requires Node.js 22.12 or newer (.nvmrc pins 24). The server is written as ES modules on Express 5.
npm installPlace your GRF files in the resources/ directory:
resources/
├── DATA.INI # REQUIRED - lists GRF files to load
├── data.grf # Main GRF file
├── rdata.grf # Additional GRF file
└── *.grf # Other GRF files
DATA.INI lists the archives in its [Data] section, by number:
[Data]
0=custom.grf
1=data.grf
2=D:\RO\rdata.grf- The lowest number wins when two archives hold the same file, as in roBrowser itself.
- A path may be absolute, so a GRF can stay on another drive or in the client's own folder; a relative one is
read from
resources/. - Any extension is loaded,
.gpfpatches included. Lines starting with;or#are comments.
The server, the startup validation, npm run setup and the encoding tools all read the file the same way
(src/utils/dataIni.js).
GRF Compatibility: This project works with GRF versions 0x200 and 0x300 without DES encryption.
To ensure compatibility, repack your GRFs using GRF Builder/Editor:
- Open your
.grffile in GRF Builder - Go to: File → Options → Repack type → Decrypt
- Click: Tools → Repack
- Wait for completion and replace the original file
Copy the example file and adjust as needed:
cp .env.example .envSee Environment Variables for all options.
# Full preparation (validates config, generates path mapping, builds index)
npm run setup
# Quick preparation (skips deep encoding validation)
npm run setup:quick# Development mode (verbose logging, debug middleware, validation report)
npm start
# Production mode (minimal logging, no debug middleware, quiet startup)
npm run start:prodDevelopment output:
Starting roBrowser Remote Client... [development]
📋 VALIDATION REPORT
================================================================================
✓ INFO:
Node.js: v24.14.0
Valid GRF: data.grf (version 0x200, no DES)
================================================================================
Static serve enabled: D:\projeto\roBrowserLegacy
WebSocket proxy enabled on /ws/ (allowed: 127.0.0.1:6900, 127.0.0.1:6121, 127.0.0.1:5121)
Client initialized in 1250ms (450,000 files indexed)
File index built in 320ms
Added 12000 mojibake path mappings for roBrowser compatibility
Server ready on http://localhost:3338 | Game: http://localhost:3338/applications/pwa/index.html | WS Proxy: /ws/
Warming cache (up to 500 files)...
Cache warmed with 500 files in 3200ms
Production output:
Starting roBrowser Remote Client... [production]
Client initialized in 1250ms (450,000 files indexed)
Server ready on http://localhost:3338 | Game: http://localhost:3338/applications/pwa/index.html | WS Proxy: /ws/
Cache warmed with 500 files in 3200ms
Stopping: Ctrl+C (SIGINT) or SIGTERM from a container runtime or process manager stops the server cleanly: game
sessions get a close frame (1001), requests in flight finish, and the missing-files log is flushed. A connection
still open after 5 seconds is abandoned. On Windows only Ctrl+C in the terminal delivers the signal; ending the
process from Task Manager kills it outright.
| Feature | Development | Production |
|---|---|---|
| Request logging | Every request logged | Disabled |
| Validation report | Full report on startup | Only on errors |
| WS proxy connect/disconnect | Logged | Silent |
| File index details | Logged | Silent |
| Missing file per-file log | Logged to console | Only to file |
/list-files, /api/missing-files, /api/cache-stats |
Available | 404 |
| Startup info | Verbose | One-line summary |
| Errors and warnings | Always shown | Always shown |
Switch modes by:
npm start(development) /npm run start:prod(production)- Or change
NODE_ENV=productionin.env
When ENABLE_WSPROXY=true, the server embeds a WebSocket-to-TCP proxy that replaces the standalone wsproxy package.
How it works:
- Browser connects via WebSocket to
ws://localhost:3338/ws/127.0.0.1:6900 - Server extracts the target (
127.0.0.1:6900) from the URL path - Server opens a TCP connection to rAthena
- Packets are bridged bidirectionally:
WS ↔ TCP
Security: Only connections to whitelisted targets are allowed (default):
127.0.0.1:6900(Login server)127.0.0.1:6121(Char server)127.0.0.1:5121(Map server)
Limits — none of which roBrowser comes near:
- Origin. A browser page can only open the proxy if its origin is one the server allows for CORS
(
CLIENT_PUBLIC_URLand the local dev ports, orCORS_ORIGINS). Without the check, any web page a player visited could use the proxy from their browser. Clients that send no Origin (not a browser page) are let through. - Frame size: 64 KiB. The library's default was 100 MiB; a game packet is a few hundred bytes.
- Connect timeout: 10 s to reach the game server, then the browser gets close code
1011. - Before the game server answers: at most 256 KiB are buffered, then the connection is closed with
1008rather than dropping packets, which would desynchronise the game.
Custom targets (Docker/Kubernetes):
When rAthena runs on a different host (e.g., Docker containers), configure allowed targets via the WS_ALLOWED_TARGETS environment variable:
# Docker Desktop (macOS/Windows):
WS_ALLOWED_TARGETS=host.docker.internal:6900,host.docker.internal:6121,host.docker.internal:5121
# Remote rAthena server:
WS_ALLOWED_TARGETS=10.0.0.5:6900,10.0.0.5:6121,10.0.0.5:5121When not set, defaults to localhost targets (127.0.0.1:6900/6121/5121).
roBrowserLegacy configuration (Config.local.js):
remoteClient: 'http://127.0.0.1:3338/', // REQUIRED - see below
socketProxy: 'ws://127.0.0.1:3338/ws/' // unified mode
// socketProxy: 'ws://127.0.0.1:5999/' // separate mode (legacy)remoteClient is not optional. roBrowserLegacy ships with
remoteClient: 'https://grf.robrowser.com/' as its default
(applications/pwa/Config.js), and that single value decides where every asset
comes from. Leave it unset and the client quietly loads everything from the
public CDN — the game runs, nothing looks broken, and this server is never used.
The trailing slash is required: the client concatenates directly
(remoteClient + url, and remoteClient + 'batch').
When ENABLE_STATIC_SERVE=true, the server serves roBrowserLegacy client files via Express static middleware, replacing the need for live-server.
Access the game at: http://localhost:3338/applications/pwa/index.html
The ROBROWSER_PATH variable points to the roBrowserLegacy directory (default: ../roBrowserLegacy).
To revert to the legacy 3-process architecture, update your .env:
ENABLE_WSPROXY=false
ENABLE_STATIC_SERVE=falseAnd revert Config.local.js:
socketProxy: 'ws://127.0.0.1:5999/'Then start wsproxy and live-server separately as before.
The server supports an optional plugin for serving AI-upscaled textures and sprites via Real-ESRGAN. The plugin is an external package that can be installed independently.
Package: @chicowall/robrowser-esrgan
# 1. Install the plugin
npm install @chicowall/robrowser-esrgan
# 2. Enable in .env
ESRGAN_ENABLED=true
ESRGAN_CACHE_DIR=./upscaled_cache
# 3. Populate the cache (requires Real-ESRGAN API running)
python ../tools/esrgan_pipeline/orchestrate.py
# 4. Restart the server
npm startBrowser GET /data/texture/btn_ok.bmp
│
▼
┌─────────────────────────┐
│ ESRGAN Middleware │ ← Checks upscaled_cache/
│ (plugin) │ for pre-upscaled .png
└───────────┬─────────────┘
│ found? → serve .png (with X-ESRGAN-Upscaled: true)
│ not found? ↓
┌─────────────────────────┐
│ GRF Lookup (original) │ ← Serves original from GRF
└─────────────────────────┘
- Intercepts requests for
.bmp,.tga,.png,.jpg,.spr,.act - Serves pre-upscaled PNG from disk cache with HTTP 304 support
- Falls through to normal GRF serving if no upscaled version exists
- Zero overhead when
ESRGAN_ENABLED=false(plugin is not loaded)
| Variable | Default | Description |
|---|---|---|
ESRGAN_ENABLED |
false |
Enable the ESRGAN upscaling plugin |
ESRGAN_CACHE_DIR |
./upscaled_cache |
Path to the upscaled asset cache |
To disable without removing:
ESRGAN_ENABLED=falseTo fully remove:
npm uninstall @chicowall/robrowser-esrgan
# Remove ESRGAN_ENABLED and ESRGAN_CACHE_DIR from .envThe server works normally without the plugin installed.
Some Ragnarok Online data files (e.g., msgstringtable.txt, skillnametable.txt, Lua configs) exist as loose files in the client's data/ folder and are not packed inside the GRF. The DATA_OVERRIDE_PATH setting lets the server find and serve these files without copying them into the project.
Music, fonts and AI scripts are not in any GRF either. BGM_PATH, SYSTEM_PATH and AI_PATH point at the
client's BGM/, System/ and AI/ folders, so they are served without copying them in or linking them. They are
read-only, and confined by real path: a junction or symlink inside one cannot lead a request out of it.
DATA_OVERRIDE_PATH=../cliente_exe/data
BGM_PATH=../cliente_exe/BGM
SYSTEM_PATH=../cliente_exe/System
AI_PATH=../cliente_exe/AILookup order when a file is requested:
1. Memory cache (LRU)
2. The project's own data/, BGM/, System/ or AI/ folder
3. BGM_PATH / SYSTEM_PATH / AI_PATH, for requests under BGM/, System/ or AI/
4. DATA_OVERRIDE_PATH, for requests under data/
5. GRF archives, in DATA.INI order
6. 404 Not Found
A folder named in .env must exist: the startup validation reports it as an error otherwise.
# Points to the RO client's data/ folder
DATA_OVERRIDE_PATH=../cliente_exe/dataPaths are absolute or relative to the project root. When one is not set, the server skips that step.
msgstringtable.txt— in-game UI stringsskillnametable.txt/skilldesctable.txt— skill names and descriptionsidnum2itemdesctable.txt/num2itemresnametable.txt— item descriptionslua files/datainfo/*.lua— job names, accessory IDs, NPC identitiesquestid2display.txt— quest display namesmapnametable.txt— map display names
| Variable | Default | Description |
|---|---|---|
DATA_OVERRIDE_PATH |
(unset) | Path to external directory with loose data files not in GRF |
BGM_PATH |
(unset) | The client's BGM/ folder, served for requests under BGM/ |
SYSTEM_PATH |
(unset) | The client's System/ folder (fonts, Lua tables), served for requests under System/ |
AI_PATH |
(unset) | The client's AI/ folder, served for requests under AI/ |
In-memory LRU (Least Recently Used) cache for file content with O(1) get/set operations.
CACHE_MAX_FILES=5000 # Max cached files (default: 5000)
CACHE_MAX_MEMORY_MB=1024 # Max memory in MB (default: 1024)- Files larger than 10% of max memory are not cached
- Automatic eviction when limits are reached
- Cache stats available at
/api/cache-stats
Sizing guide:
| GRF Size | Recommended Files | Recommended Memory (MB) |
|---|---|---|
| < 500MB | 2000 | 512 |
| 500MB - 2GB | 5000 | 1024 |
| > 2GB | 10000 | 2048 |
Pre-loads frequently accessed assets into cache on startup, so the first player to connect gets fast load times.
CACHE_WARM_UP=true # Enable/disable warm-up
CACHE_WARM_UP_LIMIT=500 # Max files to pre-loadPre-loaded asset categories (in priority order):
- UI/interface textures
- Loading screens and card images
- Default spawn map data (prontera)
- Common map formats (
.gat,.rsw) - Player sprites (all classes)
- Palette files (
.pal) - Lua/Lub config files
The warm-up runs after the server is ready and does not block incoming requests.
At startup, the server builds a unified index from all GRF files for O(1) lookups:
- Normalized paths (case-insensitive, slash direction)
- Mojibake path variants for roBrowser compatibility
- Path mapping integration for Korean → GRF path resolution
- Index statistics available via
/api/cache-stats
The startup validator and the file index run at the same time and read the same archives. Each used to open and parse its own copy — the validator twice, counting its encoding check — so the file table of every GRF was parsed three times and three copies of it sat in memory.
src/utils/grfArchive.js keeps one loaded archive per file: the first caller starts the load, the others
await the same promise, and closeArchives() releases the descriptors at shutdown. A load that fails is
not cached, so the next caller may try again.
- The loader's own file cache is off (
cacheMaxFiles: 0): this server caches decoded files itself, with a byte budget and the ETags the responses need, and two caches would hold the same files twice. - The spellings the client asks for come from the bytes stored in the archive (
rawNameBytes), not from encoding the decoded name back to CP949 — a name whose bytes no encoder produces again used to be indexed asdata\??.txtand was unreachable. - Boot on a 3.27 GiB
data.grf(205,404 files, 771,268 indexed paths): 5.8 s → 1.7 s, of which the index is ~0.9 s.
Static game assets receive proper cache headers for browser-side caching:
| Header | Value | Purpose |
|---|---|---|
| ETag | MD5 hash | Content validation |
| Cache-Control | max-age=86400, immutable |
1-day cache for game assets |
| 304 Not Modified | — | Skip re-download if unchanged |
| Last-Modified | (not sent) | It used to be the time of the response, so every file claimed to change at every request; the ETag validates |
| Accept-Ranges / 206 | bytes |
.mp3, .wav and .ogg only: lets the <audio> element that plays BGM seek. If-Range is honoured |
Editing a file the server reads from disk (DATA_OVERRIDE_PATH, SYSTEM_PATH, the project's asset
trees) does not reach a client that already has it: the server's own cache does not check the file's
timestamp, the response is immutable for a day, and roBrowser saves everything it downloads in the
browser's FileSystem storage and reads it from there first. Restart the server and clear that storage —
in the browser console: webkitRequestFileSystem(0, 1e9, fs => fs.root.createReader().readEntries(es => es.forEach(e => e.removeRecursively(() => {}, () => {})))).
- Gzip/Deflate compression for text-based responses (JSON, XML, HTML, JS)
- Only compresses responses larger than 1KB
- Automatic content-type detection and encoding negotiation
Off by default. With CLIENT_AUTOEXTRACT=true, every file read from a GRF is also written into the project's
data/ folder, and later requests read it from disk.
It used to be always on. Two problems made it opt-in: the copies are read before DATA_OVERRIDE_PATH, so they
hide the translated files it provides (seen on a real setup: four translated tables replaced by the originals from
the GRF), and any anonymous request triggers a write to disk. The LRU cache already avoids re-extracting hot files.
When on, it only writes inside data/, BGM/, System/ or AI/, and on Windows never under a name the system
would reinterpret (nul.txt, com1.spr, x.txt:stream, a trailing dot or space). If you turn it off after
running with it, the copies already in data/ are still served: move them out.
| Variable | Default | Description |
|---|---|---|
PORT |
3338 |
Server port |
CLIENT_PUBLIC_URL |
http://localhost:8000 |
Allowed CORS origin, added to the local dev ports (8000, 8080, 3338 on localhost and 127.0.0.1) |
CORS_ORIGINS |
(unset) | Comma-separated list of allowed origins. When set it is the complete list — the defaults above are not added — and * allows any origin |
CLIENT_ENABLESEARCH |
true |
false turns file search off; it then answers with an empty list |
NODE_ENV |
development |
Node environment |
CACHE_MAX_FILES |
5000 |
Max files in LRU cache |
CACHE_MAX_MEMORY_MB |
1024 |
Max cache memory (MB) |
CACHE_WARM_UP |
true |
Enable cache warm-up on startup |
CACHE_WARM_UP_LIMIT |
500 |
Max files to pre-load on warm-up |
ENABLE_WSPROXY |
true |
Embed WebSocket proxy (replaces wsproxy) |
WS_ALLOWED_TARGETS |
127.0.0.1:6900,... |
Comma-separated host:port pairs for WS proxy allowlist |
ENABLE_STATIC_SERVE |
true |
Serve roBrowserLegacy static files (replaces live-server) |
ROBROWSER_PATH |
../roBrowserLegacy |
Path to roBrowserLegacy directory |
ESRGAN_ENABLED |
false |
Enable ESRGAN upscaling plugin (requires @chicowall/robrowser-esrgan) |
ESRGAN_CACHE_DIR |
./upscaled_cache |
Path to the pre-built upscaled asset cache |
DATA_OVERRIDE_PATH |
(unset) | External directory with loose data files (e.g., ../cliente_exe/data) |
BGM_PATH / SYSTEM_PATH / AI_PATH |
(unset) | The client's BGM/, System/, AI/ folders, read-only (details) |
CLIENT_AUTOEXTRACT |
false |
Write files read from a GRF into data/ (details) |
| Method | Route | Description |
|---|---|---|
| GET | / |
Returns index.html |
| GET | /api/health |
Liveness. Full detail (validation, cache, index, missing files) in development; status and counts only in production |
| GET | /api/cache-stats |
Cache and index statistics. Development only — 404 in production |
| GET | /api/missing-files |
List of files not found. Development only — 404 in production |
| GET | /* |
Serves any client file (from disk, cache, or GRF) |
| POST | / |
File search, where roBrowser sends it |
| POST | /search |
The same search, at the route this server used before |
| GET | /list-files |
List all available files. Development only — 404 in production |
| WS | /ws/{host}:{port} |
WebSocket proxy to TCP (when ENABLE_WSPROXY=true) |
# Check system health
curl http://localhost:3338/api/health
# Check cache performance
curl http://localhost:3338/api/cache-stats
# Check missing files
curl http://localhost:3338/api/missing-files
# Search the GRF names, the way the Map Viewer lists maps
curl -X POST http://localhost:3338/ --data-urlencode 'filter=data\\([^\0]+\.rsw)'Cache stats response example:
{
"cache": {
"size": 500,
"maxSize": 5000,
"memoryUsedMB": "384.50",
"maxMemoryMB": "1024",
"hits": 12500,
"misses": 500,
"hitRate": "96.15%"
},
"index": {
"totalFiles": 450000,
"grfCount": 3,
"indexBuilt": true
}
}Only the viewers search — the Map, Model, STR, Granny and GRF viewers; the game itself never does. The
client's FileManager.search posts filter=<RegExp.source> to the remote client's root URL and splits
the answer on newlines. With an archive loaded locally it runs the same regex over the archive's name
table instead, and the viewers cannot tell the two apart — so the server answers exactly as that local
search would:
- The regex runs over the name table, not over paths. Every name in a GRF, followed by a NUL, one
character per byte — the client's
table.data. Flags are alwaysgi(the client drops the ones it had), and the answer is each matched substring, once. That is what lets the GRF Viewer list a folder: it matchesdata\\texture\\([^(\0|\\)]+)and gets back each entry directly under it, not every path below it. - The body is the name bytes as stored in the GRF, labelled
text/plain; charset=ISO-8859-1. The client reads it that way and reuses each line as a path, so every name the search returns can be fetched. - Every failure is an empty
200: search disabled, a missing filter, a pattern that does not compile or is longer than 256 characters, a pattern that runs past the 2-second deadline. The client ignores the status and would read an error message as file names. TheX-Search-Errorheader says what happened. - No cap on results. A search for every name in a full
data.grfreturns all 205,404 in under a second.
A pattern is attacker-controlled, and one like ^(.+)+#$ backtracks exponentially. The search runs in a
worker thread that is terminated at the deadline, so the server stays responsive while it burns.
npm testThe suite runs on Node's built-in test runner — no test framework to install — and needs no Ragnarok
client. Tests build small GRF archives on the fly (tests/helpers/grfBuilder.js), including Korean
names stored as CP949 bytes; the header fixtures under tests/fixtures/ are synthetic and under 2 KB.
CI runs the same command on Node 22 and 24 for every pull request.
The HTTP tests start the real app (createApp()) on a random port and talk to it the way roBrowser does.
tests/helpers/roBrowser.js reproduces the client's own code — the URL FileManager.getHTTP builds, the
request FileManager.search sends, the pattern each viewer builds, the answer a locally loaded archive
gives — so a test fails when the server stops working for the real client, not only when it stops
matching its own idea of a request.
| File | What it covers |
|---|---|
http.test.js |
Serving assets: case-insensitive lookup, Korean names in every spelling the client sends, 404, ETag/304, CORS and CORS_ORIGINS, /batch, path containment on the wire, diagnostics gated in production, the static mount |
search.test.js |
The search contract: for every viewer's pattern, the answer is identical to a locally loaded archive's; every returned name can be fetched; Latin-1 bytes; an empty 200 on every failure |
search-scale.test.js |
No cap on the number of results |
search-redos.test.js |
A catastrophic pattern is cut off at the deadline without blocking the server |
range.test.js |
Byte ranges for audio: 206, 416, If-Range, never compressed |
mojibake.test.js |
The two spellings of a Korean name and the way back to Korean |
data-ini.test.js |
DATA.INI: section, comments, priority order, repeated numbers, absolute paths — and the server loading archives that way |
asset-dirs.test.js |
BGM_PATH, SYSTEM_PATH, AI_PATH: served, case-insensitive, ranges, no escape by .. or by a junction |
autoextract.test.js |
AutoExtract off by default; when on, writing only into the asset folders, never under a name Windows would reinterpret |
validator-paths.test.js |
The startup validation from any working directory, without npm on the PATH, with the server's DATA.INI reader |
wsproxy.test.js |
The WebSocket proxy against a fake rAthena: relay, pre-connect buffering, allowlist, a real Close frame, cleanup, and the limits: Origin, frame size, connect timeout, bytes before connect |
shutdown.test.js |
Stopping: game sessions closed with 1001, requests in flight finished, the log flushed; a stuck connection abandoned after the deadline |
grf-loader.test.js |
The GRF loader decodes Korean names with iconv-lite — it would not if the package were imported as an ES module (see src/utils/grfLoader.js) |
env.test.js |
.env is read from the project root whatever the current directory, is optional, and never overrides a variable already set |
node-version.test.js |
The startup check warns below the Node version in engines |
containment.test.js |
Path containment in Client.getFile(), the funnel for /batch and GET /* |
rawimport.test.js |
Containment in the raw-import middleware |
grf-header.test.js |
GRF header parsing for 0x200 and 0x300 |
Each test was checked by breaking the behaviour it guards and confirming that it fails.
Test file names are made up on purpose. The server reads the project's data/ folder before any
GRF, and AutoExtract fills that folder with every file a running game requests, so a test named like a
real asset can be answered from disk. The test helper refuses to start when that happens and names the
file.
The suite uses synthetic archives. Before opening a pull request that touches how files are found or served, also run the change against a real client:
npm ci && npm test.- Start the server with your client's
DATA.INI. The boot log must showClient initializedbeforeServer ready. - Log in through roBrowser, pick a character and enter a map. The browser console should show no errors
from this server, and
/api/missing-filesnothing that was not missing before. - If the change touches search: open the GRF Viewer with
remoteClientpointing at this server, opendata, thentexture, then the Korean interface folder, and preview a file.
| Script | Description |
|---|---|
npm start |
Start the server (development, verbose) |
npm run start:prod |
Start the server (production, minimal logging) |
npm test |
Run the regression suite (no Ragnarok client required) |
npm run setup |
Full pre-startup optimization |
npm run setup:quick |
Quick pre-startup (skip deep validation) |
npm run doctor |
Run diagnostic validation |
npm run doctor:deep |
Deep validation with encoding check |
npm run debug-grf |
Debug GRF file loading |
npm run convert:encoding |
Generate path-mapping.json |
npm run validate:grf |
Validate a single GRF file |
npm run validate:all |
Validate all GRFs in resources/ |
npm run validate:encoding |
Validate encoding with iconv-lite |
npm run test:mojibake |
Test mojibake detection |
Many Ragnarok GRF files contain Korean filenames encoded in CP949/EUC-KR. When read on non-Korean systems, they appear as mojibake (garbled characters).
The problem:
GRF stores: data\texture\유저인터페이스\t_배경3-3.tga as CP949 bytes
roBrowser requests: /data/texture/À¯ÀúÀÎÅÍÆäÀ̽º/t_¹è°æ3-3.tga one character per byte
The client never decodes these names as Korean: it holds each byte as one character and builds URLs from
that. Bytes 0x80–0x9F have two spellings — a C1 control in Latin-1, a printable character in
windows-1252 (Œ for 0x8C) — and the client uses both: file names read from maps come as Latin-1, names
from a search answer or a decoded table as windows-1252. In the bRO data.grf, 13 names contain such a
byte (똠양꿍.spr is Œc¾ç²á.spr).
The solution:
The server handles this automatically through:
- Mojibake indexing — builds GRF index with both Korean Unicode and mojibake variants
- Runtime decoding — decodes mojibake paths back to Korean Unicode on request, in either spelling
- Path mapping — optional
path-mapping.jsonfor explicit Korean → GRF path mappings
# Deep encoding validation
npm run doctor:deep
# Generate path-mapping.json
npm run convert:encodingroBrowserLegacy-RemoteClient-JS/
│
├── index.js # Entry point: validation, GRF index, HTTP server, WS proxy
├── start-prod.js # Production launcher (sets NODE_ENV=production, then imports index.js)
├── index.html # Home page served at the server root
├── doctor.js # Diagnostic tool for troubleshooting
├── prepare.js # Pre-startup optimization script
├── package.json # Project dependencies and scripts
├── .env # Environment configuration
├── .env.example # Environment template
├── path-mapping.json # Generated encoding conversion mappings
│
├── src/ # Application source code (ES modules)
│ ├── app.js # createApp(): the Express app, without starting anything
│ ├── env.js # Loads .env; the first import of every entry point
│ ├── shutdown.js # Clean stop on SIGINT/SIGTERM
│ ├── wsProxy.js # WebSocket -> TCP proxy for rAthena
│ ├── config/
│ │ └── configs.js # Client and server settings
│ ├── controllers/
│ │ ├── clientController.js # File operations, caching, indexing, warm-up
│ │ └── grfController.js # GRF extraction using @chicowall/grf-loader
│ ├── middlewares/
│ │ ├── debugMiddleware.js # Debug logging middleware (dev only)
│ │ └── rawImportMiddleware.js # ?raw imports for the static roBrowser mount
│ ├── routes/
│ │ └── index.js # Asset serving, search, /batch, /list-files
│ ├── utils/
│ │ ├── dataIni.js # The one DATA.INI parser
│ │ ├── grfLoader.js # @chicowall/grf-loader through its CommonJS build
│ │ ├── logger.js # Logger utility (respects NODE_ENV)
│ │ ├── LRUCache.js # LRU cache implementation
│ │ ├── mojibake.js # The two spellings of Korean names, and back to Korean
│ │ ├── safePath.js # File names Windows would reinterpret (AutoExtract)
│ │ ├── searchPool.js # Runs searches in a worker, with a deadline
│ │ └── searchWorker.js # The search worker
│ └── validators/
│ └── startupValidator.js # Startup and encoding validation
│
├── tests/ # npm test (node:test), no Ragnarok client needed
│
├── tools/ # CLI tools for validation and conversion
│ ├── validate-grf.mjs # Single GRF validation
│ ├── validate-all-grfs.mjs # Batch GRF validation
│ ├── validate-grf-iconv.mjs # Encoding validation with iconv-lite
│ ├── convert-encoding.mjs # Generate path-mapping.json
│ └── test-mojibake.mjs # Test mojibake detection
│
├── logs/ # Log files
│ └── missing-files.log # Missing files log
│
├── resources/ # RAGNAROK CLIENT FILES
│ ├── DATA.INI # Client configuration file (required)
│ └── *.grf # Client GRF files
│
├── BGM/ # Game background music
├── data/ # Loose client data files (and AutoExtract copies, when on)
├── System/ # Client system files
└── AI/ # AI scripts for homunculus/mercenaries
If files are not found due to encoding issues:
- Run deep validation:
npm run doctor:deep - Generate path mapping:
npm run convert:encoding - Restart the server
The server logs missing files to logs/missing-files.log. Check:
/api/missing-filesendpoint for recent missing files- Console output for missing file alerts (triggers after 10+ missing files)
- Check cache hit rate:
curl http://localhost:3338/api/cache-stats - Increase cache size via
.env(see Environment Variables) - Enable cache warm-up:
CACHE_WARM_UP=true - Run
npm run setupto pre-build indexes
- Verify
ENABLE_WSPROXY=truein.env - Check
Config.local.jshassocketProxy: 'ws://127.0.0.1:3338/ws/' - Assets loading from
grf.robrowser.cominstead of this server:remoteClientis missing fromConfig.local.js - Ensure rAthena is running (login:6900, char:6121, map:5121)
- Check server logs for
WS proxy blocked connectionmessages - For Docker/remote rAthena: set
WS_ALLOWED_TARGETSin.env(see Environment Variables) WS proxy refused origin ...in the log: the page that runs roBrowser is served from an origin the server does not allow. SetCLIENT_PUBLIC_URLto it, or list it inCORS_ORIGINS
| Problem | Solution |
|---|---|
| Dependencies not installed | Run npm install |
| Incompatible GRF | Repack with GRF Builder (version 0x200, no DES) |
| Missing DATA.INI | Create resources/DATA.INI |
| Encoding issues | Run npm run convert:encoding |
| Slow file access | Increase cache size, enable warm-up, run npm run setup |
| WS proxy connection refused | Check rAthena is running, verify target ports |
| Static files not served | Check ROBROWSER_PATH points to roBrowserLegacy directory |
GNU GPL V3
- Vincent Thibault
- Francisco Wallison