Skip to content

Repository files navigation

roBrowser Legacy Remote Client (Node.js)

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.


Table of Contents


Features

  • 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 loose data/ 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)

Architecture

Unified Mode (Default)

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

Separate Mode (Legacy)

Set ENABLE_WSPROXY=false and ENABLE_STATIC_SERVE=false in .env to run in legacy mode with separate processes.


Installation and Setup

1. Install Dependencies

Requires Node.js 22.12 or newer (.nvmrc pins 24). The server is written as ES modules on Express 5.

npm install

2. Add Ragnarok Client Files

Place 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, .gpf patches 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:

  1. Open your .grf file in GRF Builder
  2. Go to: File → Options → Repack type → Decrypt
  3. Click: Tools → Repack
  4. Wait for completion and replace the original file

3. Configure Environment

Copy the example file and adjust as needed:

cp .env.example .env

See Environment Variables for all options.

4. Prepare for Optimal Startup (Recommended)

# Full preparation (validates config, generates path mapping, builds index)
npm run setup

# Quick preparation (skips deep encoding validation)
npm run setup:quick

5. Run the Server

# Development mode (verbose logging, debug middleware, validation report)
npm start

# Production mode (minimal logging, no debug middleware, quiet startup)
npm run start:prod

Development 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.

Development vs Production

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=production in .env

Unified Server Mode

Embedded WebSocket Proxy

When ENABLE_WSPROXY=true, the server embeds a WebSocket-to-TCP proxy that replaces the standalone wsproxy package.

How it works:

  1. Browser connects via WebSocket to ws://localhost:3338/ws/127.0.0.1:6900
  2. Server extracts the target (127.0.0.1:6900) from the URL path
  3. Server opens a TCP connection to rAthena
  4. 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_URL and the local dev ports, or CORS_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 1008 rather 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:5121

When 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').

Embedded Static File Server

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).

Switching to Separate Mode

To revert to the legacy 3-process architecture, update your .env:

ENABLE_WSPROXY=false
ENABLE_STATIC_SERVE=false

And revert Config.local.js:

socketProxy: 'ws://127.0.0.1:5999/'

Then start wsproxy and live-server separately as before.


Plugins

ESRGAN Upscaling Plugin

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

Quick Setup

# 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 start

How It Works

Browser 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)

Environment Variables

Variable Default Description
ESRGAN_ENABLED false Enable the ESRGAN upscaling plugin
ESRGAN_CACHE_DIR ./upscaled_cache Path to the upscaled asset cache

Disabling / Uninstalling

To disable without removing:

ESRGAN_ENABLED=false

To fully remove:

npm uninstall @chicowall/robrowser-esrgan
# Remove ESRGAN_ENABLED and ESRGAN_CACHE_DIR from .env

The server works normally without the plugin installed.

External Data Directory

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/AI

Lookup 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.

Configuration

# Points to the RO client's data/ folder
DATA_OVERRIDE_PATH=../cliente_exe/data

Paths are absolute or relative to the project root. When one is not set, the server skips that step.

Typical files served via DATA_OVERRIDE_PATH

  • msgstringtable.txt — in-game UI strings
  • skillnametable.txt / skilldesctable.txt — skill names and descriptions
  • idnum2itemdesctable.txt / num2itemresnametable.txt — item descriptions
  • lua files/datainfo/*.lua — job names, accessory IDs, NPC identities
  • questid2display.txt — quest display names
  • mapnametable.txt — map display names

Environment Variables

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/

Performance Features

LRU File Cache

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

Cache Warm-Up

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-load

Pre-loaded asset categories (in priority order):

  1. UI/interface textures
  2. Loading screens and card images
  3. Default spawn map data (prontera)
  4. Common map formats (.gat, .rsw)
  5. Player sprites (all classes)
  6. Palette files (.pal)
  7. Lua/Lub config files

The warm-up runs after the server is ready and does not block incoming requests.

GRF File Index

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

One Load per Archive

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 as data\??.txt and 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.

HTTP Cache Headers

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(() => {}, () => {})))).

Response Compression

  • Gzip/Deflate compression for text-based responses (JSON, XML, HTML, JS)
  • Only compresses responses larger than 1KB
  • Automatic content-type detection and encoding negotiation

Auto-Extract to Disk

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.


Environment Variables

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)

API Endpoints

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)

Usage Examples

# 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
  }
}

File Search

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 always gi (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 matches data\\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. The X-Search-Error header says what happened.
  • No cap on results. A search for every name in a full data.grf returns 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.


Testing

npm test

The 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.

Validating a Change End to End

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:

  1. npm ci && npm test.
  2. Start the server with your client's DATA.INI. The boot log must show Client initialized before Server ready.
  3. Log in through roBrowser, pick a character and enter a map. The browser console should show no errors from this server, and /api/missing-files nothing that was not missing before.
  4. If the change touches search: open the GRF Viewer with remoteClient pointing at this server, open data, then texture, then the Korean interface folder, and preview a file.

NPM Scripts

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

Korean Filename Encoding Support

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:

  1. Mojibake indexing — builds GRF index with both Korean Unicode and mojibake variants
  2. Runtime decoding — decodes mojibake paths back to Korean Unicode on request, in either spelling
  3. Path mapping — optional path-mapping.json for explicit Korean → GRF path mappings
# Deep encoding validation
npm run doctor:deep

# Generate path-mapping.json
npm run convert:encoding

Directory Structure

roBrowserLegacy-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

Troubleshooting

Encoding Issues

If files are not found due to encoding issues:

  1. Run deep validation: npm run doctor:deep
  2. Generate path mapping: npm run convert:encoding
  3. Restart the server

Missing Files

The server logs missing files to logs/missing-files.log. Check:

  • /api/missing-files endpoint for recent missing files
  • Console output for missing file alerts (triggers after 10+ missing files)

Performance Issues

  1. Check cache hit rate: curl http://localhost:3338/api/cache-stats
  2. Increase cache size via .env (see Environment Variables)
  3. Enable cache warm-up: CACHE_WARM_UP=true
  4. Run npm run setup to pre-build indexes

WebSocket Proxy Not Working

  1. Verify ENABLE_WSPROXY=true in .env
  2. Check Config.local.js has socketProxy: 'ws://127.0.0.1:3338/ws/'
  3. Assets loading from grf.robrowser.com instead of this server: remoteClient is missing from Config.local.js
  4. Ensure rAthena is running (login:6900, char:6121, map:5121)
  5. Check server logs for WS proxy blocked connection messages
  6. For Docker/remote rAthena: set WS_ALLOWED_TARGETS in .env (see Environment Variables)
  7. WS proxy refused origin ... in the log: the page that runs roBrowser is served from an origin the server does not allow. Set CLIENT_PUBLIC_URL to it, or list it in CORS_ORIGINS

Common Issues

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

License

GNU GPL V3

Authors

  • Vincent Thibault
  • Francisco Wallison

About

Remote client that lets users play Ragnarok Online by downloading resources from an external server, without needing the FullClient installed locally.

Resources

Stars

23 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages