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.
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.
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.
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.
“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
ActiveBrowses 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
ActiveBrowses 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
ActiveAuthors 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
ActiveAuthors 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 progressAuthors 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
PlannedItem 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
PlannedQuest 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.
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.
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.
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.
“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.
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.
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.
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.
| Format | What it holds | Where the truth came from |
|---|---|---|
| .grf | The client's packed archive format — a file table plus zlib-compressed entries, optionally DES-encrypted | Ported decrypt logic from roBrowserLegacy; decompression via pako, since Node's zlib doesn't run client-side |
| .str | Layered, keyframed particle-effect animations — textures, blend modes, transforms per layer per frame | Binary layout confirmed against thousands of real files pulled through the archive reader |
| .spr / .act | Sprite frames (indexed + RLE, or RGBA) paired with per-action, per-direction animation data | First pass built from recalled documentation — wrong in several places. Rebuilt against roBrowser's actual loader source, verified frame-by-frame |
| .gat / .gnd | A map's walkability/altitude grid and its ground mesh | Ported from two references that disagreed with each other and with the public docs — resolved against a real map's actual byte length |
| .lub / .lua | The client's own “database” files — skill tooltips, item display data, NPC identity tables | A hand-written tokenizer and recursive-descent parser, since no package parses this project's exact real-file quirks |
“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.
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:
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
- Next.js 16 — App Router
- React 19 / TypeScript
- Tailwind CSS v4
- File System Access API
- IndexedDB — versioned index caches
- Origin Private File System — write backups
- Custom shared-context WebGL2 engine
- Canvas 2D compositor (sprites)
- @tanstack/react-virtual for large grids
- GRF archive reader + DES decrypt
- .str / .spr / .act / .gat / .gnd decoders
- Lua tokenizer + recursive-descent parser
- js-yaml for rAthena item_db.yml/quest_db.yml
- Line/indent-aware splice writers
- Layered pre-re/re/import conflict surfacing
- Vitest — 530+ tests
- Playwright — headless smoke checks
- Strict TypeScript, zero-tolerance ESLint diffing
| Tool | Status | What it does |
|---|---|---|
| Effects Viewer | Active | Browse & render .str visual effects, ID-space fully decoded |
| Sprite Viewer | Active | Browse & animate NPC / monster / homunculus sprites |
| Skill Builder | Active | Author skill tooltips & mechanics against live client Lua data |
| Item Builder | Active | Author item display data + rAthena item_db.yml |
| NPC Builder | In progress | Author NPC scripts with live in-game preview |
| Set Builder | Planned | Item set combo bonuses |
| Quest Builder | Planned | Multi-NPC quest chains, on the shared quest module |