Ragnarok Online · Browser Tool Suite · Client-Side Only

RO DEV SUITE

A browser-based workbench that browses a private RO server's compiled visual effects and sprites, and authors skills, items, and NPC scripts against its real data files — with nothing ever uploaded.

7tools
2,373effect IDs decoded
530+automated tests
0assets uploaded

About the Project

What Is RO Dev Suite?

Ragnarok Online private servers distribute two large, loosely-documented asset trees: a client (graphics, sprites, Lua “database” files) and a server (YAML configuration and NPC scripts). Editing either by hand means digging through binary archives and undocumented formats with a text editor and a lot of tribal knowledge. This suite turns that into a real application — one tool per job, sharing a registry, a shell, and a local file-access layer.

Next.js 16 · App RouterReact 19 · TypeScriptFile System Access APIWebGL2 (custom engine)IndexedDB + OPFSHand-rolled binary & Lua parsersVitest + Playwright

Architecture

Everything Runs In The Tab That's Open

The foundational decision behind the whole suite: no server-side asset storage, ever. A Ragnarok Online client is several gigabytes of copyrighted game data; a private server's script tree is a developer's own unpublished work. Neither belongs on someone else's infrastructure just to run a browsing tool. The app grants itself access to a user's local folders through the browser's showDirectoryPicker() API, stores only the directory handle, and every downstream read — GRF archive, loose folder, or server checkout — goes through one SourceReader interface so parsers never know where the bytes actually came from.

Developer's machineLocal RO client (.grf archives, loose folders) + private server checkout
FS Access API
This app, in-tabParsers · WebGL renderer · IndexedDB cache · OPFS backups
static deploy
VercelCode and UI only — no upload endpoint exists for game data

A consequence worth stating plainly: the app is Chromium-only. The File System Access API isn't implemented in Firefox or Safari — a known trade-off, not an oversight.

From the suite-wide progress log

“Stale source names are pruned on load. A remembered source filter can name a GRF the currently granted client doesn't have; left alone it would empty the grid with no checkbox on screen to explain it.” — the standing rule behind every persisted-state feature in the suite: a saved setting must degrade to a safe default field-by-field, never take down the whole page.

The Suite

One Registry, Seven Tools

Every tool is a single entry in a shared tools registry — id, route, description, status — which drives the sidebar, the dashboard tiles, and the “coming soon” placeholders. Four tools are feature-complete and in active use; one is functionally done but held back pending a live in-game write-and-reload check; two are scoped but not yet built.

Effects Viewer

Active

Browses and renders every .str visual effect the client can produce, decoded straight from the game's own compiled executable.

  • Reverse-engineered effect ID → filename table, decoded from disassembled switch-dispatch code
  • Custom shared-context WebGL2 renderer with D3DBLEND-accurate compositing
  • Every ID (0–2372) typed and explained, including the free slots

Sprite Viewer

Active

Browses NPC, monster, and homunculus sprites with full .spr/.act animation playback.

  • Binary sprite/action parsers built from scratch, corrected against real files
  • Pre-rendered “filmstrip” thumbnails so 40k+ animated cards stay cheap
  • Pose-chunked detail view with a compass-style facing picker

Skill Builder

Active

Authors skill tooltips and mechanics against the client's own skillinfoz Lua tables.

  • Hand-written Lua tokenizer/parser reading the live client folder
  • One line-builder is the single source for both the preview and the codegen — they cannot drift
  • Homunculus tooltip layout derived from the client's real hex color data

Item Builder

Active

Authors item display data and the full rAthena item_db.yml schema, writing directly into real files.

  • Byte-exact writes preserve legacy Latin-1/EUC-KR bytes a clipboard paste would silently corrupt
  • Every write is backed up to disk (OPFS) first, five deep, restorable
  • pre-re / re / import server layers kept deliberately separate, never merged

NPC Builder

In progress

Authors NPC scripts — placement, sprite, shops, warps, dialogue — with a live in-game preview.

  • Hybrid editor: structured header form, raw script body with lint and snippets
  • Own .gat/.gnd map parsers, click-to-place on the client's real minimap
  • Reads/writes signboardlist.lub — the client's undocumented name-plate mechanic

Set Builder

Planned

Item set combo bonuses. Registry placeholder only — scoped, not started.

  • Will sit alongside the Item Builder's layered item_combos.yml data, already surfaced read-only there

Quest Builder

Planned

Quest chains spanning multiple NPCs and files. Shared quest module already built and mounted inside the NPC Builder.

  • Deliberately not merged into the NPC Builder nor fully separated — see Engineering Practices, below

Deep Dive

Decoding the Client's Compiled Binary

The hardest and highest-payoff problem in the suite. A .str effect file has no header field saying which skill or hat effect plays it — that mapping is compiled directly into the client's executable as a numeric ID and a jump table, with no shipped documentation. The project treated the compiled client itself as the source of truth and reverse-engineered it in three stages, each one a hypothesis checked against real bytes before being trusted.

01 — Recover the string table

Confirmed the hardcoded .str resource names are literal strings inside the executable: 100% of a 235-name anchor set was located in the binary, clustered in a single contiguous 1,091-member run inside the .rdata section — the hit rate that made the next two stages worth attempting.

235/235anchor names found in the compiled binary
1,091strings in one contiguous .rdata run
02 — Locate the dispatch code

A pointer-table scan located the code region that consumes those strings. An early automated check reported this as a failure — later shown to be a metric mismatch in the validator itself, not a real negative, and corrected in the log rather than quietly dropped. The region was right; the next stage is what proved it.

passesGate: false // first report // ↳ metric mismatch in the validator, // not a real negative — corrected, // not silently dropped
03 — Decode the actual switch tables

A disassembly-level decoder walked the real switch dispatch blocks — every case in every dispatch, including ones that push a texture, a sprite, or hand-written code with no string at all, and the ones that fall through to the unused-ID default handler. That default handler isn't assumed — its address is recovered from the bounds-check branch's own displacement bytes and confirmed against the real instruction stream.

Effects tooling log, 2026-07-24

“Scope 3's headline 90.9% was a pooled average that hid a 0/22-anchor failure in its single largest dispatch (83% of all decoded IDs)... Gate now passes: every validated dispatch is at 100% anchor agreement, worst-case included.” The 10 remaining disagreements were checked in-game with a GM command and turned out to be stale community reference data, not decode errors.

The payoff: every one of the 2,373 possible effect IDs is now typed — renders live, is a real effect this viewer can't draw, is a genuinely confirmed-empty slot, or is one of the 10 IDs that are simply undecodable with certainty. That matters in practice: a real, working effect was initially misclassified as an empty slot by an earlier, cruder heuristic — exactly the situation where a developer grabs a “free” ID and silently clobbers a live effect.

Deep Dive

A Shared WebGL Engine, Built Around a Browser Limit

Rendering a grid of hundreds of simultaneously-animating particle effects ran into a real platform ceiling: Chromium caps a page at roughly 16 live WebGL contexts. One canvas per card silently lost contexts during a fast virtualized scroll, rendering as a blank white box with no error. The fix was one shared GL context for the entire visible grid, with each card claiming a viewport/scissor rectangle computed analytically from the virtualizer's own layout math.

1

A transparent canvas that never clears

The shared canvas overlays the whole scrollable grid, including every card's own text — so it has to stay fully transparent everywhere it isn't actively drawing an effect.

2

A per-slot opaque backdrop, alpha-locked

Ragnarok Online effects are authored for an opaque dark scene. Each slot draws a checker backdrop into its own scissor rect, then locks that rectangle's alpha channel so the effect layers on top can't drag it back toward transparent.

3

D3DBLEND fidelity via a constant-alpha trick

The original client's DirectX blend modes read a back-buffer alpha channel it never actually had. Mapping those to WebGL's constant-alpha blend factors with a fixed blend color reproduces that behavior deliberately, not by coincidence.

The same engine handles magenta/black colorkey transparency, per-effect multi-layer compositing, an adjustable rest between animation loops, and a continuous-vs-finite classification computed by inspecting whether any layer is still visible at its final authored keyframe — a file-content fact, deliberately named to avoid claiming knowledge of how the live client actually schedules playback.

Deep Dive

Binary Formats, Decoded From Nothing

None of the suite's binary parsers came from a library — Ragnarok Online's asset formats predate any maintained JS ecosystem for them. Every one was written from scratch against format documentation, a reference client's open-source loader code, and — decisively, whenever documentation and reality disagreed — real files read byte-for-byte until the parser matched them.

FormatWhat it holdsWhere the truth came from
.grfThe client's packed archive format — a file table plus zlib-compressed entries, optionally DES-encryptedPorted decrypt logic from roBrowserLegacy; decompression via pako, since Node's zlib doesn't run client-side
.strLayered, keyframed particle-effect animations — textures, blend modes, transforms per layer per frameBinary layout confirmed against thousands of real files pulled through the archive reader
.spr / .actSprite frames (indexed + RLE, or RGBA) paired with per-action, per-direction animation dataFirst pass built from recalled documentation — wrong in several places. Rebuilt against roBrowser's actual loader source, verified frame-by-frame
.gat / .gndA map's walkability/altitude grid and its ground meshPorted from two references that disagreed with each other and with the public docs — resolved against a real map's actual byte length
.lub / .luaThe client's own “database” files — skill tooltips, item display data, NPC identity tablesA hand-written tokenizer and recursive-descent parser, since no package parses this project's exact real-file quirks
Map-parsing log, 2026-07-31

“A GND surface's texture id is read signed. roBrowserLegacy reads it as an unsigned short, which turns the -1 ‘no texture’ sentinel into 65535... The GND surface record is 40 bytes. The research lab's GND page lists it as 56... parsing a real map at 40 bytes lands exactly on EOF; at 56 it overruns by 283,056.” Two respected references, both wrong in the same file format, caught only by checking the arithmetic against a real file's actual length.

Deep Dive

Authoring Pipeline: Text That Becomes Real Bytes

Skill, Item, and NPC authoring share a harder problem than parsing: every generated block eventually has to become correct bytes, in a real file, on a real server checkout — and every one of those files is decades of hand-maintained legacy-encoding text, not clean UTF-8.

One line-builder, two consumers, zero drift

Skill and item tooltips are colored, line-wrapped text with a strict in-game character width. A single typed model feeds one line-builder function that is the only place that knows field order, color palette, and formatting rules. Every generated line carries a field id tracing it back to the exact form field that produced it, which is also how the editor surfaces “this line will wrap in-game” warnings next to the field that caused them.

The in-game color-code convention this pipeline generates verbatim:

Charge
------------------------------
Ether Infusion · Required Intimacy: Loyal
A focused dash that closes distance and
knocks the target back on impact.
ACD: 1s  FCT: 0.5s  Cooldown: 8s
The byte-safety incident

The most consequential bug of the whole project. Item resource names can contain legacy CP949/EUC-KR bytes; the tool correctly decoded them for display — but the only export path was a clipboard copy. The user pasted a generated block into their real client file and saved it, and it crashed the game and broke every sprite: the receiving editor silently re-saved the pasted text as UTF-8, a different byte sequence than the legacy encoding the client actually reads.

“Clipboard export can't be made byte-safe for non-ASCII resource names; there's no way to control what encoding the paste target saves with.”

The fix, found by reading a working reference implementation rather than guessing: keep the raw legacy bytes as the only stored representation, decode to Unicode only transiently for display, and write directly back into the real file — never through a clipboard — using the File System Access API's createWritable(). Every direct write is preceded by a disk-backed backup in the Origin Private File System — five generations deep, independently restorable — because a direct write to a developer's real, unversioned server checkout needs its own undo path.

Every direct write — an item's entry, an NPC's script block, a signboard row — is a targeted line/brace-depth splice against the real file, never a parsed-and-redumped rewrite. Real config files carry hand-written license headers and section comments that a full parse-and-redump would silently discard.

Engineering Culture

How the Suite Stays Coherent Across Seven Tools

With four active builders and a growing shared library underneath them, a small set of practices — applied consistently, not a framework — is what keeps the codebase from fragmenting into slightly-different copies of the same idea.

Extract on the second consumer, not the first

Shared modules — the Lua parser, the icon-thumbnail cache, the drag-reorder hook, the binary cursor reader, the map renderer — are never built shared speculatively. Each started inside one tool and was pulled into a shared module the moment a second tool needed the same behavior, after a real bug had to be fixed twice in two near-identical copies.

A blank state must say why it's blank

An effect tile that doesn't render, a filter that returns nothing, a directory grant with no DATA.ini — none of these fail silently. Each carries a status explaining what's actually true, so a developer never mistakes an unknown state for permission to overwrite something live.

Never invent a schema field

The item_db.yml model is documented as best-effort. Every parsed entry carries an unknownFields bag so a real field the model doesn't know about survives a clone/edit round-trip instead of being silently dropped.

Layers stay layers

rAthena's item_db.yml is split across pre-re/re/import with real override semantics. The tool displays all three separately and surfaces every conflict rather than pre-resolving “the one that wins.”

A hybrid editor beats a leaky abstraction

The NPC Builder is a structured header form plus a raw, linted script-body editor — not a visual flow-graph. The server's real scripts use idioms no node-based tool could express; automate what's genuinely structured, assist the rest.

Verification means real files, not just green checks

Every change runs tsc --noEmit, a full Vitest pass, and an ESLint diff compared by exact file and rule against a clean checkout — comparing raw error counts once hid a real regression a second, unrelated fix happened to cancel out.

Under the Hood

Technology & Scope

Application shell
  • Next.js 16 — App Router
  • React 19 / TypeScript
  • Tailwind CSS v4
Local data access
  • File System Access API
  • IndexedDB — versioned index caches
  • Origin Private File System — write backups
Rendering
  • Custom shared-context WebGL2 engine
  • Canvas 2D compositor (sprites)
  • @tanstack/react-virtual for large grids
Parsers from scratch
  • GRF archive reader + DES decrypt
  • .str / .spr / .act / .gat / .gnd decoders
  • Lua tokenizer + recursive-descent parser
Server-side authoring
  • js-yaml for rAthena item_db.yml/quest_db.yml
  • Line/indent-aware splice writers
  • Layered pre-re/re/import conflict surfacing
Quality
  • Vitest — 530+ tests
  • Playwright — headless smoke checks
  • Strict TypeScript, zero-tolerance ESLint diffing
ToolStatusWhat it does
Effects ViewerActiveBrowse & render .str visual effects, ID-space fully decoded
Sprite ViewerActiveBrowse & animate NPC / monster / homunculus sprites
Skill BuilderActiveAuthor skill tooltips & mechanics against live client Lua data
Item BuilderActiveAuthor item display data + rAthena item_db.yml
NPC BuilderIn progressAuthor NPC scripts with live in-game preview
Set BuilderPlannedItem set combo bonuses
Quest BuilderPlannedMulti-NPC quest chains, on the shared quest module

A Personal Engineering Project

Built solo — architecture, binary reverse engineering, custom rendering, and the tools it took to write it all safely back into a real server.