AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
라그나로크 온라인 스프라이트·아이템 아이콘·카드 아트·월드맵·BGM·클라이언트 데이터 테이블을 PNG·APNG·GIF로 렌더링·제공합니다
Renders and serves Ragnarok Online sprites as PNG/APNG/GIF, plus item icons, card art, world maps, BGM and client data tables. Native Go port of zhad3/zrenderer, rendering in-process from a local asse
개발 도구JavaScript · GoGitHub @adsonpleal온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Renders and serves Ragnarok Online sprites as PNG/APNG/GIF, plus item icons, card art, world maps, BGM and client data tables. Native Go port of zhad3/zrenderer, rendering in-process from a local asset tree — a single self-hosted binary. Live demo: https://assets.latam-tools.com.br
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
ragassets — a fast caching image/animation provider for Ragnarok Online sprites
ragassets is a thin, fast HTTP layer that renders and serves Ragnarok Online
sprites as images and animations. Renders are computed per request and cached by
the client — every response is immutable and carries an ETag, so browsers and
CDNs keep them forever. It also serves the client's item, collection,
skill, class (job) and status-effect (buff/debuff) icons, and the full-size
card artwork, as static PNGs — those are plain files extracted straight from the
GRF, no rendering involved.README 전문 보기 ↗ragassets is a thin, fast HTTP layer that renders and serves Ragnarok Online
sprites as images and animations. Renders are computed per request and cached by
the client — every response is immutable and carries an ETag, so browsers and
CDNs keep them forever. It also serves the client's item, collection,
skill, class (job) and status-effect (buff/debuff) icons, and the full-size
card artwork, as static PNGs — those are plain files extracted straight from the
GRF, no rendering involved.
Rendering is done in-process by a native Go reimplementation of zrenderer's algorithm (under
gateway/internal/render): the SPR/ACT/PAL/IMF parsers, sprite compositing, z-ordering, palette application and APNG output. There is no separate renderer service — the gateway reads the extracted GRF assets and renders directly. The rendering logic is a faithful port of zhad3's zrenderer (output is pixel-identical for player/monster sprites); huge thanks to zhad3 — please star and support the original project. See its API docs.
A free, best-effort public instance runs at https://assets.latam-tools.com.br.
You can use it right away — no API key, no sign-up — by pointing an <img> (or
anything) at it:
<img src="https://assets.latam-tools.com.br/image?job=1002&action=0" alt="Poring">
It's a small hobby server with no SLA — it may be slow, rate-limited, or go
away at any time, so please don't build anything critical on it. For real or
heavy use, self-host (one go build and a directory of assets — see
Running it).
The images below are served live by that instance (animations are APNG and play in your browser):
| Poring · idle | Poring · attack | Dragon Knight · idle |
|---|---|---|
| Dragon Knight · attack | Arch Mage ♀ | Custom Swordman ♀ |
The last three are 4th-class and customized player sprites; the first row are monsters. Every one is just a URL — see the API below.
One Go binary renders in-process and serves every route from a local tree. The public instance is that same binary, reached directly — there is no CDN in front of it:
client ──GET /image?job=1002&...──▶ Caddy (TLS, gzip on /raw)
│ Cloudflare is DNS only for this
│ host — the record is grey-clouded
▼
gateway (Go)
│ render in-process, stream bytes
│ (internal/render: parse SPR/ACT/PAL/IMF,
│ composite layers, z-order, APNG encode)
▼
mirror/ + resources/ on local disk
The two halves of that tree are not the same thing, and the distinction matters
everywhere below. mirror/ is the merged client — the whole GRF plus every
patch since — and it is both what the renderer reads and what the extractor runs
against. resources/ holds only what the extractor produces from it: icons,
card art, effects, maps, BGM, sounds and the data tables.
Cache-Control: public, max-age=31536000, immutable and an
ETag derived (order-independently) from the query string, so browsers and
CDNs cache them forever and a revalidating client gets a 304 (answered
without re-rendering).Content-Type: image/png (modern browsers animate APNG natively).
action (and no frame) → you get the animation (APNG).frame → you get a single still image.0)./gif rather than
/image (see GET /gif). The APNG→GIF conversion is the only
image processing on top of rendering.GET /icons/* and GET /illust/* are plain static file serving — the
icons and the card artwork are extracted once from the client GRF by
extract-grf.mjs --icons / --illust (see
GRF extraction); no rendering involved.GET /imageRenders a sprite. Every meaningful render parameter is available as a query parameter:
| Query param | Type | Notes |
|---|---|---|
job | comma-separated IDs | Required. e.g. job=1002 or job=1002,1003 |
action | integer | Animation/action index. Its presence (without frame) yields an animation. |
frame | integer | A specific frame → a still image. -1 = all frames (animation). |
gender | male/female or 1/0 | Default male. |
head | integer | Player head id. |
outfit | integer | Alternate outfit (0 = default). |
headgear | comma-separated ints | Up to 3 view ids. Client item metadata determines their equipment slots; for unknown or ambiguous views, supply upper, middle, lower order with 0 for an empty slot, e.g. headgear=4,125. A hat-effect costume (1500 Fúria dos Shuras, the only one the client ships today) has a blank accessory sprite and its visual in a separate looping sprite the client plays at the character's head; the renderer composites that automatically, and its longer timeline sets the animation's length. |
headgearBehind | comma-separated ints | Usually unnecessary — whether an effect headgear (aura/halo/the Sun God's Ornament 2669) draws behind the character is decided automatically per direction from the client's layer-priority table. This param is a manual override that forces the listed ids behind in every direction (for accessories the client table doesn't cover). |
garment | integer | |
weapon | integer | |
shield | integer | |
bodyPalette | integer | -1 = standard. |
headPalette | integer | -1 = standard. |
Deployment-level settings (resource path, port) are configured via environment variables, not query parameters.
A missing or malformed job/parameter returns 400. A render failure (e.g. a
job whose sprite isn't in the extracted assets) returns 500.
Ragnarok Online has no skin-tone option. There is no such setting in the
client, no server ever sends one, and no official sprite ships in more than one
skin tone. skinTone and skinColor are a capability of this gateway: the
ramps are generated here, from the sprites' own palettes. Nothing about them is
official, and a character rendered with one will not match that character in
game. It exists because the LATAM community has asked for skin tones for years,
and a fan site can at least show what they would look like.
How it works, briefly. Human skin in a sprite is an 8-entry ramp of palette
indices — highlight through deep shadow. Recolouring the ramp recolours skin in
every frame, direction and animation at once, and leaves clothing, hair and eyes
untouched. New ramps are generated in Oklab (darkening skin in HSL turns it grey
and purple), preserving the original ramp's relative lightness spacing so the
sprite keeps its own shading. skinColor anchors your colour to the ramp's
dominant midtone, so what you pass is what most of the skin becomes.
Two behaviours worth knowing:
skinTone/skinColor the requested tone wins.Where the skin ramp sits in a palette is not fixed, so it is not guessed at
render time. It is baked per sprite by cmd/gen-skin-table — see
Regenerating the skin table.
/image?job=1002 # still Poring
/image?job=1002&action=0 # animated Poring (APNG)
/image?job=1002&action=0&frame=2 # a single frame of that action
/image?job=1&gender=female&headgear=4,125&garment=1&weapon=2&head=4&action=32
/image?job=0&canvas=200x200+75+175&action=93
/image?job=4069&gender=female&skinTone=3 # fan-made skin tone (not in the game)
/image?job=4069&gender=female&skinColor=8a5a3b
action (animations & directions)zrenderer has no flat list of named actions: the action number is an index
into the sprite's .act file, and it encodes two things at once:
action = (animation type × 8) + direction
Every animation is stored as 8 directional variants (one per 45°), so actions come in blocks of 8.
Direction (the +0…7 part) — 0 faces south/front, then rotates 45° each step:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| S | SW | W | NW | N | NE | E | SE |
(Conventional order — easiest to confirm by rendering frame 0–7 of a block.)
Animation type (the × 8 part) depends on the sprite kind.
Players / jobs (rich set):
| Type | action (south-facing) | Meaning |
|---|---|---|
| 0 | 0 | Idle / stand |
| 1 | 8 | Walk |
| 2 | 16 | Sit |
| 3 | 24 | Pick up |
| 4 | 32 | Standby (ready to fight) |
| 5 | 40 | Attack |
| 6 | 48 | Hurt (took damage) |
| 7 | 56 | Frozen / stun |
| 8 | 64 | Dead |
| 9 | 72 | Frozen 2 |
| 10–12 | 80 / 88 / 96 | Attack variants 1–3 (weapon-dependent) |
Monsters (only ~5 blocks):
| Type | action | Meaning |
|---|---|---|
| 0 | 0 | Idle |
| 1 | 8 | Walk |
| 2 | 16 | Attack |
| 3 | 24 | Hurt |
| 4 | 32 | Dead |
So action=18 is "walk, facing west" for a monster (8 + 2), and the zrenderer
examples line up: --action=16 is a monster attack, --action=32 a player
standby pose, --action=93 a player attack variant (88 + 5) facing direction 5.
NPCs, homunculi, mercenaries, pets and mounts each have their own (usually smaller) tables. The real source of truth is always the individual sprite's
.actfile — zrenderer renders whatever index exists in it, so the valid range varies per sprite.
A few companion job ids don't render the sprite
jobname.lubnames for them: the Windhawk (Ranger 4th-job) falconjob=20830and wargjob=20833are drawn by the client from class-specific sprites in the이팩트("effect") folder (windhawk_hawk/windhawk_wolf), not the generic monster falcon/warg. The resolver hardcodes these two remaps; every other companion (e.g.job=20831,job=20832) already resolves correctly.
Note: body direction is part of action; the separate headdir parameter only
rotates the head.
GET /gifExactly like GET /image — every query parameter above works the
same way, including the still-vs-animation rule — except the rendered PNG/APNG
is converted to a GIF before it's served (Content-Type: image/gif):
action (and no frame) → an animated, infinitely-looping GIF.frame (or neither) → a single-frame GIF (a still image)./gif?job=1002&action=0 # animated Poring, as a GIF
/gif?job=1002 # still Poring, as a GIF
/gif?job=4252&head=1&action=40 # Dragon Knight attack, as a GIF
/gif?job=1&gender=female&headgear=4,125&garment=1&weapon=2&head=4&action=32
Use this for clients that can't display APNG (some chat embeds, link-preview crawlers, older image tooling). Two caveats are inherent to the GIF format:
/image (APNG) when you can keep crisp edges.The same immutable cache headers and ETag/304 support as /image apply. The
only parameter that behaves differently is outputFormat: zip is rejected
(400), since the response is always a single GIF image.
GET /icons/{type}/{name}.pngServes a static image extracted from the client GRF (see
GRF extraction — this endpoint returns
404 until you run the --icons extraction step):
type | What you get | name |
|---|---|---|
item | Inventory icon (~24×24) | item id |
collection | Larger item description image (~75×100) | item id |
skill | Skill icon (~24×24) | skill id |
job | Class/job icon | job id |
status | Status-effect (buff/debuff) icon (32×32) | EFST status id |
ui | Character-creation UI element | client filename (see below) |
/icons/item/501.png # Red Potion inventory icon
/icons/collection/501.png # Red Potion description image
/icons/skill/28.png # Heal
/icons/job/4252.png # Dragon Knight
/icons/status/883.png # Poison status icon
/icons/status/876.png # Freezing status icon
/icons/ui/bt_female_on.png # gender toggle, female, selected
The source images carry transparency either via a magenta (#FF00FF) colorkey
(item/collection/skill/job/ui BMPs) or a real alpha channel (status-effect TGAs);
the extractor normalizes both to a PNG alpha channel. Responses carry the same
immutable cache headers and ETag/304 support as /image. Unknown names
(or types) return 404.
The status type is keyed by the client's EFST status id (the numeric ids in
luafiles514/.../stateicon/efstids.lub). Not every EFST has an icon — those the
client maps to an image in stateiconimginfo.lub are served, plus a supplemental
hardcoded STATUS_ICON_OVERRIDES table (in extract-grf.mjs) for EFSTs the client
shows an icon for via a convention baked into its exe rather than the lua data —
e.g. the stat food buffs (241–246 EFST_FOOD_* and 271–276 *_CASH) →
data/texture/effect/*_gogi.tga. stateiconimginfo.lub wins whenever it has its
own entry for an id. Most status icons are 32×32, but the source TGA's own header
is honoured (the *_gogi icons vary), so the served PNG matches the client asset.
The ui type exposes the character-creation screen's elements under their
original client filenames:
| Element | Names |
|---|---|
| Gender toggle | bt_male_<state>, bt_female_<state> with states off (idle), on (selected), over (hover), press |
| Rotation arrows | bt_leftturn_<state>, bt_rightturn_<state> with states normal, over, press |
| Hair styles — human | img_hairstyle01…img_hairstyle23 (male), img_hairstyle_girl01…girl23 (female), img_hairstyle_none |
| Hair styles — doram | img_hairstyle_doramboy01…06, img_hairstyle_doramgirl01…06 |
| Hair colors | color01…color09 with states off, on, over, press (e.g. color03_on) |
| Misc | bt_make_*, bt_close_*, bt_doublecheck_*, bt_hairstyle_*, img_human_on/off, img_doram_*, bg_makebg |
Cards are the one item type whose picture is not here: every card shares one
generic inventory icon and one generic description image (their iteminfo resource
name is literally 이름없는카드, "nameless card"), so /icons/collection/4001.png
and /icons/collection/4302.png are the same bytes. The per-card artwork is a
separate, much larger asset — see /illust.
GET /illust/card/{id}.png — card artworkServes a card's full-size illustration (300×400) — the picture the client
shows for a card, keyed by item id (see
GRF extraction — this endpoint returns
404 until you run the --illust extraction step):
/illust/card/4001.png # Poring Card
/illust/card/4302.png # Tao Gunka Card
/illust/card/27083.png # a newer-block card
It is not an /icons type on purpose: this is an illustration rather than an
icon, and the client links it through its own data/num2cardillustnametable.txt
(<item id>#<bmp basename>#) instead of the iteminfo resource name every /icons
image is keyed by. Responses carry the same immutable cache headers, ETag/304
and wildcard CORS as /icons; unknown ids return 404.
1,095 of the client's 1,291 card ids have art. The rest resolve only to the
client's own sorry placeholder bitmap (192 ids) or name a file that was never
shipped (4 ids: 4486, 4488, 27221, 27225). Those 404 rather than serving
~190 copies of the same apology image — render your own fallback.
GET /effects/... — effect-only costumesSome costumes have no character sprite to draw — auras, falling petals,
spotlights, ghosts, weather. Either the client ships no accessory sprite for them
at all, or it ships one that is deliberately blank (every .act layer tinted
alpha 0, flagged as spriteBlank on
/raw/items.json).
Either way it draws them with its .str world-effect system, so the sprite
renderer above can't produce them. (One blank accessory is not in this set: a
hat effect puts its visual in a separate looping sprite instead of a .str,
and /image composites that itself — see
spriteBlank below.) extract-grf.mjs --effects pulls each one's
.str out of the GRF as a small JSON + PNG bundle (see
GRF extraction); the gateway serves those
bundles for the latamvisuais map
simulator to render client-side. These endpoints return 404 until you run the
--effects step.
A costume is linked to its .str by resource name — efst_<res> names the
folder — but Gravity romanizes some folders (흩날리는낙엽 lives in
efst_maple_falls/) and some folders hold more than one .str. Both cases go in
the STR_OVERRIDE table in extract-grf.mjs, and the picks there come from the
client's own hat-effect table, HatEffectInfo/HatEffectInfo.lub, which maps each
HAT_EF_* id to the exact file it plays. Read it with the HAT_EF_* constants
from HatEffectInfo/HatEffectIds.lub loaded into the same Lua globals, or the
table's keys come back unresolved. It has no item ids — the item → hat-effect
link is server-side — so the costume still has to be matched by hand, but it
settles which .str an ambiguous folder plays.
One costume reaches this set from the garment side rather than the accessory
side. A garment's .act files are never blank, so "the visual is an effect" shows
up differently there: the whole 로브/<garment>/ folder is the copied
모험가배낭 template, every .spr in it is a leftover backpack (see
the robe prune) and there is no
folder-root .spr to fall back to, so the garment has no artwork anywhere in the
client. c_snow_powder (view 100, [Visual] Aura Nevada) is the only one today
and is listed in GARMENT_TEMPLATE_ONLY; its .str
(efst_snow_powder/ssnnnn2.str) then resolves by the ordinary name rule.
--prune-robes reports any folder it empties, which is how the next one surfaces.
EffectHatItemTable.lub looks like the missing link and is not: its keys are a
contiguous 1..109, so it is a flat list of the items that have a hat effect,
not a HAT_EF id → item map. Joining the two by number produces plausible
nonsense — HAT_EF_Blossom_Fluttering lands on 20522 Miaura while the item
that really is 흩날리는벚꽃 sits one row later. Match by resource name instead.
The Pedras Gráficas (Encantamento de Visual, the Malangdo Loja Fashion)
are not costumes: a stone is applied into a costume already equipped in a
position, and what the player sees is a hat effect — the same .str system, so
the same bundles. The client models each as two items, the tradeable stone
(whose name is the only place the target position is written) and the enchant
it becomes once applied. --effects writes stones.json alongside index.json,
keyed by the stone's id because that is the item a shop lists.
The chain is stone → enchant → HAT_EF_* → .str, and the client owns only its
second half: HatEffectInfo.lub maps every HAT_EF_* to the file it plays, read
live so a client update repaths them. Nothing in the client links an item id to a
HAT_EF_* — the server does, by running the enchant's hateffect script — so
that half is the hand-written STONE_HAT_EFFECT table, transcribed from
rAthena's item_db and cross-checked against each enchant's own resourceName
wherever that name is specific rather than the generic 블루크리스탈조각. One row
overrides rAthena on that evidence: Raios Vermelhos is HAT_EF_BAKURETSU_HADOU
and not HAT_EF_DOUBLEGUMGANG, because its enchant's resourceName is exactly
the sprite the former plays and the latter's effect id isn't in the effect table
at all. Two more come from the enchant's aegisName, which rAthena has no script
for (Golden_Aura_TW, Magic_D_Space_BTM).
Only 12 of the 29 stones produce a stones.json bundle, and the ways of missing
are worth telling apart:
hatEffectTable row carries a
hatEffectID instead of a resourceFileName, so there is no .str to bundle.
Eight of those ids have no asset at all — the client draws them procedurally or
just tints the body (Miniatura, Palidez, Sombra, Aura Verde, …) — and are
permanently unrenderable. The other two, Raios Vermelhos (effect 1130) and
Espaço Digital (effect 1240), name a played sprite that the same
--effects run already bundles at /effects/sprites/eff_<id>/; the report
points at it. That is a different bundle shape from a .str, so it can't be a
stones.json key as the file stands — showing those two would need the
catalogue (and the consumer) to carry a sprite reference as well.The last one out is Ventania (HAT_EF_Golden_Aura_TW): this client declares the
constant in HatEffectIDs.lub and then gives it no row in either table and ships
no matching .str — nothing to draw, and nothing to draw it from.
A stone has no resource name to key on, so its bundle key is derived from the
.str folder alone (efst_ljosalfar/ljosalfar.str → ljosalfar), the same
fallback the Korean-named costumes take. A stone that plays a .str a costume
already produced reuses that bundle rather than writing a second copy.
| Path | What you get |
|---|---|
GET /effects/index.json | Catalogue: {"items":[{"id","name","slots","effect"}]} — one entry per effect-only costume (effect is the bundle key; there is no character view). |
GET /effects/index_en.json | The same catalogue with each costume's English name (index_es.json for Spanish) — same rows, same effect keys. See Languages. |
GET /effects/stones.json | Graphic-stone catalogue: {"items":[{"id","effect"}]} — id is the stone's item id, effect its bundle key. Only the stones that play a .str appear; a missing stone means "no preview", not an error (see Graphic stones). |
GET /effects/footprints.json | Footprint catalogue: one row per footprint stone, four bundle keys and the placement numbers (see Footprints). Being listed here is what marks a stone a footprint. |
GET /effects/{key}/effect.json | The parsed .str animation: {"key","fps","maxKey","layers":[{"textures":[…],"anims":[…]}]}. |
GET /effects/{key}/tex_N.png | That effect's layer textures (TGA alpha kept; BMP magenta-keyed → alpha). |
GET /effects/sprites/{key}/sprite.json | A sprite-based effect's play list: {"frames":[{"img":"0.png","delay":96,"offset":[x,y]},…]} — frames in play order, per-frame delay in ms, and offset (RO px, +x right / +y down) the composited image centre relative to the effect's placement origin. key is a map effect's slug (torch_01) or eff_<id> for the skill/hat effects the replay viewer plays by effect id. |
GET /effects/sprites/{key}/N.png | That sprite effect's composited frames (each frame's layers baked into one image). |
/effects/index.json
/effects/stones.json
/effects/footprints.json
/effects/c_spot_light/effect.json
/effects/c_spot_light/tex_0.png
/effects/sprites/torch_01/sprite.json
/effects/sprites/torch_01/0.png
key is a costume resource-name slug ([a-z0-9_]); the few Korean-named effects
get an ASCII key from their .str folder instead (e.g. angel_fluttering).
Responses carry the same immutable cache headers, ETag/304 and wildcard CORS as
/icons. Not every effect-only costume resolves to a .str automatically — the
extraction step prints a resolved/unresolved/excluded report and a manual override
table covers the rest (see below).
The same --effects step also builds bundles for the in-world map effects — the
.str effects a map's .rsw places (underwater bubbles, etc.; see
/maps manifest.effects). These share the /effects/{key}/
format above, keyed by the .str basename (e.g. bubble1). The id→.str mapping is
the STR-type subset of roBrowser's EffectTable.js, ported into extract-grf.mjs; a
bundle is built for every servable STR effect in that table, so any map's effect
references resolve.
Six of the garment stones are not a looping effect at all. They stamp a decal
once per footstep while the character walks, and the client reads them from a
second table, FootPrintEffectTable, keyed by the same HatEFID ids. A row is
four .str — a mark left on the ground and a puff above it, each with a left and
a right variant — plus the numbers that space them along the walk line. There is
no single effect key to put in stones.json, so --effects writes
footprints.json instead:
{"items":[{"id":1002239,
"bottomLeft":"footprint_dragon_face_2d_bottom",
"bottomRight":"footprint_dragon_face_2d_bottom",
"topLeft":"footprint_dragon_face_2d",
"topRight":"footprint_dragon_face_2d",
"scaleBottom":0.06,"scaleTop":0.15,
"heightTop":0,"stride":50,"gap":2,"adjustAngle":true}]}
The four keys are ordinary /effects/{key}/ bundles built by the same pass, and
deduped against every other key it produces — the two panda footprints share one
puff, so footprint_panda is extracted once. topLeft/topRight are absent when
a row genuinely has no top half; a row with no bottomLeft is likewise a real
row, not a broken one. Being listed here at all is what marks a stone as a
footprint, which is a different thing for a consumer to say than stones.json's
silence: "it draws while you walk, the artwork just isn't extracted" rather than
"nothing can ever draw this".
Every number is emitted on every row, because the client's defaults for the ones
a row leaves out are not the neutral values a reader would guess. They live in
HatEffect_F.lub, at the end of each GetFootprintStr* accessor
(luac5.1 -l prints the LOADK), and FOOTPRINT_DEFAULTS copies them:
| field | client default | what it is |
|---|---|---|
scaleBottom, scaleTop | 0.05 | multiplier on the .str's own geometry — not 1 |
heightTop | 0 | how far the top layer floats above the mark |
stride | 50 | spacing along the walk line — not 0, which would stamp the whole walk on one spot |
gap | 2 | lateral offset, left foot against right |
adjustAngle | false | whether the decal turns to face the walk direction |
stride, gap and heightTop are in the .str's own pixel unit — the same one
the quad coordinates inside effect.json use — and are not multiplied by
scale*: Scale_Bottom normalizes art authored at different sizes onto a common
drawn size, and the spacing is measured in that drawn space. The client's own
numbers bear this out. Only two rows in the whole 26-row table set Stride at
all — FOOTPRINT_EF_DogFoot at 35 and FOOTPRINT_EF_flower_garden at 60 — so the
observed range is 35–60 around a default of 50, while the same rows carry the
table's largest Scale values (0.1 and 0.2); read as pre-scale pixels their
strides would be 3.5 and 12, an order of magnitude under the drawn size of the
art they space. All six of ours take the default 50, against ground marks that
measure 15–27 drawn px and puffs that measure 41–58 — a mark about a third of the
spacing, which is what a footstep trail looks like.
The absolute px-per-cell conversion is a renderer's choice and nothing in these
assets fixes it, but the client's own art gives a cross-check: the two .str
whose ground area is unambiguous, sanctuary (5×5 cells) and magnus (7×7),
measure 657 and 896 px across, i.e. ≈128–131 px per GAT cell. At that rate a
default stride of 50 is ~0.4 of a cell — a dense decorative trail rather than
anatomical footprints, which matches what these are (a stream of dumplings,
pandas and dragon faces). For all six the left and right files are identical and
gap is the default 2 px, so there is no visible left/right asymmetry; the only
row in the client that authors distinct left and right art is flower_garden.
A handful of map effects are played sprites (.spr/.act) rather than .str —
EF_TORCH, EF_SMOKE and EF_BANJJAKII. The --effects step renders each into a
/effects/sprites/{key}/ bundle: one composited N.png per frame of the effect's
first .act action — every layer's scale, rotation, mirror and colour baked in, so
the served image already looks like the in-game frame — plus a sprite.json
{frames:[{img, delay, offset}]} play list. delay is the action's real frame
interval from the .act (default 100ms); offset is the composited image's centre
relative to the effect's placement origin (RO px, +x right / +y down — the client
negates y for its Y-up world), so frames whose size shifts across the animation
still sit on the effect's origin. A map's manifest.effects references these by key
in a sprite field (see /maps).
GET /effect/... — skill & world effects (data + textures)The .rrf replay viewer renders
skill effects (Fire Bolt flames, Heal sparkles, Storm Gust, auras…) itself in
WebGL — a port of roBrowser's StrEffect renderer, which needs the correct
per-layer additive blending. So ragassets is a data + texture server here, not
a renderer: it parses the .str binaries and converts the layer textures, but
never bakes an effect to a flat image (that would destroy the blend modes). This
is a separate namespace from /effects/...
above (pre-built costume/map bundles); /effect/... (singular) parses any
effect on demand from the extracted GRF tree.
str/texture read from data/texture/effect/ in RESOURCE_DIR, so they need
that subtree extracted (see GRF extraction)
and 404 otherwise. skill-map/table are embedded in the binary and always
serve.
| Path | What you get |
|---|---|
GET /effect/str?file=<name> | The parsed .str as JSON: {"fps","maxKey","layers":[{"textures":["<name>",…],"animations":[{"frame","type","pos":[x,y],"uv":[8],"xy":[8],"aniframe","anitype","delay","angle","color":[r,g,b,a],"srcalpha","destalpha","mtpreset"}]}]}. srcalpha/destalpha are the raw D3DBLEND ints (the client maps them to gl.blendFunc — never collapsed); color stays in the file's 0–255 range. <name> is relative to data/texture/effect/ and may omit the .str suffix. |
GET /effect/texture?file=<name> | One .str layer texture as an RGBA PNG: magenta (#FF00FF) colorkey → alpha, transparent RGB bled outward to kill bilinear fringes; 32-bit TGA keeps its real alpha. .bmp/.tga source resolves either way when the extension is omitted. |
GET /effect/sound?file=<name> | One sound effect as browser-playable WAV (audio/wav) — the audio behind an effect table row's wav field. <name> is relative to data/wav/ without the .wav extension, exactly as wav carries it (effect/ef_portal, the bare _heal_effect). Reads the static tree extract-grf.mjs --sounds writes (SOUNDS_DIR); a name the client GRF never shipped 404s (the viewer treats that as "no sound" and skips it). |
GET /effect/sound/index.json | {"count", "names":[…]} — the sound names present in the extracted tree, for coverage preflighting. |
GET /effect/skill-map | skillId → {effectId?, hitEffectId?, groundEffectId?, wav?} — from roBrowserLegacy's SkillEffect (~683 skills, all job eras). skillId is the AEGIS/packet id the client sends (matches rAthena). wav is a de-duplicated array of sound names already verified against the extracted tree (see below) — fetch /effect/sound?file=<name> directly, no fallback needed. It fills in the many 3rd/4th-class skills whose effect has no wav in the table below, by also trying the skill's own SKID constant name. |
/effect/str?file=stormgust # Storm Gust (skill 89 → effectId 89)
/effect/str?file=firewall1 # Fire Wall (skill 18 → groundEffectId 25)
/effect/texture?file=snow_a # a Storm Gust layer texture (.bmp)
/effect/texture?file=stormcannon/sto_shine_00
/effect/skill-map
/effect/table
/effect/sound?file=effect/ef_portal # the portal-open whoosh (effect 6)
/effect/sound?file=effect/ef_frostdiver2 # Frost Diver (skill 105 → effect 28)
/effect/sound?file=_heal_effect # a bare-name sound (no effect/ prefix)
Resolve a skill to its assets client-side: skill-map[skillId] → the effectIds →
table[effectId] → each part's file (expand a %d over rand:[a,b]) → fetch
/effect/str?file=<file>, then /effect/texture?file=<texname> for every name the
STR lists. For audio, prefer skill-map[skillId].wav directly (already
verified, needs no fallback); it's only absent when nothing resolved. Otherwise
read the effect rows' own wav field and fetch /effect/sound?file=<wav> (%d
variants like effect/ef_firearrow%d expand the same way). A row with no type
is a sound-only part — just a wav, no visual. <name> lookups are case-insensitive (GRF paths carry inconsistent
casing / EUC-KR); responses carry the same immutable cache headers, ETag/304
and wildcard CORS as /icons. The two tables are ported from roBrowserLegacy
(SkillConst/SkillEffect/EffectTable) by tools/gen-effect-tables.mjs and
embedded, so they need no extraction; re-run that script (optionally --src <dir>)
if the upstream tables change. A handful of upstream rows name an asset that
cannot exist — a \xHH escape with a wrong byte, a sprite no client ships — and
EFFECT_PART_FIXUPS in that script corrects them per part; each key has to keep
matching, so an upstream fix fails the build instead of passing unnoticed.
type:"STR" parts render from /effect/str, and type:"SPR" parts have a
pre-composited bundle at
/effects/sprites/eff_<id>/ — every one of
them, so a skill that maps to a SPR effect has something to play. A skill that
maps only to 2D/3D/CYLINDER/FUNC effects still shows nothing: those the
client generates procedurally.
/effect/sound reads a separate static tree (extract-grf.mjs --sounds, see
GRF extraction) and 404s until it's built.
RO's data/wav/ is almost all standard PCM (browser-playable, copied verbatim);
the few ADPCM sources are transcoded to PCM WAV at extraction, so every sound plays
in Chrome/Firefox/Safari and the whole tree is one Content-Type. Names resolve
case-insensitively, and a name the table stores as raw EUC-KR bytes (the client
requests Korean-named sounds verbatim) is retried under its decoded Hangul path — so
those resolve too. The endpoint never strips or adds the effect/ prefix: a wav
that points at a file this client's GRF didn't ship stays a 404, exactly as it
would in the real client. Responses carry the same immutable cache / ETag / CORS
headers as /bgm.
GET /maps/... — world mapsThe full 3D world maps (ground mesh, models, textures, animated water) for the
latamvisuais map simulator to render
client-side. extract-grf.mjs --maps pulls every map's .gat/.gnd/.rsw
geometry plus the .rsm models and BMP/TGA textures they reference (see
GRF extraction). The geometry binaries are
served raw (parsed in the browser); models, textures, water frames and the shared
cursor/grid UI are de-duplicated across all maps (922 in the current client)
into content-addressed stores, so each blob is stored and served exactly once —
keeping the whole set to ~5.8 GB instead of the ~10–15 GB a per-map copy would
cost. These endpoints return 404 until you run the --maps step.
| Path | What you get |
|---|---|
GET /maps/index.json | Catalogue: {"maps":[…]} — every extracted map name. |
GET /maps/{map}/manifest.json | The map's asset manifest: files (geometry), models, textures, water, ui — resource names mapped to shared blob paths (../_t/<hash>.png, …) — plus fog ({near,far,color:[r,g,b],factor}, present only for maps listed in data/fogparametertable.txt) and effects (the .rsw in-world effects — .str bundles, played sprites, procedural FUNC effects and parametric emitters; present only for maps that place any). |
GET /maps/{map}/{map}.gat|gnd|rsw | Raw geometry binaries (altitude, ground mesh, world objects). |
GET /maps/_t/{hash}.png | A shared texture (TGA alpha kept; BMP magenta-keyed → alpha, fringe-bled). |
GET /maps/_m/{hash}.rsm | A shared model (raw .rsm). |
GET /maps/_w/{hash}.jpg | A shared animated-water frame. |
GET /maps/_u/{hash}.png | A shared UI image (hover-cell grid selector / cursor frame). |
/maps/index.json
/maps/prontera/manifest.json
/maps/prontera/prontera.gnd
/maps/_t/a6abef1ba59fbf23.png
The manifest references blobs with a leading ../ so the browser fetches them as
baseUrl + path and the URL parser folds the .. to resolve against the shared
store. Map names are lowercase slugs ([a-z0-9_@-]); blob hashes are 16 hex chars
— the strict per-segment patterns make path traversal structurally impossible.
Responses carry the same immutable cache headers, ETag/304 and wildcard CORS as
/icons and /effects.
When a map's .rsw places in-world effects, the manifest carries an effects
array — one entry per placed instance (positions are not deduplicated; the client
proximity-culls). There are four renderable kinds:
"effects": [
{ "id": 109, "pos": [x, y, z], "str": ["bubble1","bubble2","bubble3","bubble4"], "delay": 0, "param": [0,0,0,0] },
{ "id": 47, "pos": [x, y, z], "sprite": "torch_01", "delay": 125, "param": [1,0,0,0] },
{ "id": 45, "pos": [x, y, z], "delay": 500, "param": [0.1,0.1,0,0] },
{ "id": 974, "pos": [x, y, z], "delay": 1, "param": [0,0,0,0],
"emitter": { "dir1": [-3,-5,-3], "dir2": [5,0,5], "gravity": [0.7,-2,0.7], "color": [255,255,255,255],
"rate": [1,3], "size": [6,8], "life": [3,4], "texture": "../_t/<hash>.png",
"speed": [0], "srcmode": [5], "destmode": [2], "maxcount": [20], "zenable": [1] } }
]
id is the .rsw effect id and pos is the ÷5 world position.
str — the id's deduped set of
/effects/{key}/ bundle keys (resolved via the
ported EffectTable.js STR subset) the client picks from at random per spawn.
iz_dun03, for instance, places 312 of id 109 (EF_BUBBLE → bubble1…bubble4).sprite — the key of a
/effects/sprites/{key}/ bundle (a played
.spr/.act). EF_TORCH (47 → torch_01), EF_SMOKE (44 → smoke) and
EF_BANJJAKII (165 → banjjakii). iz_dun00, for instance, places 53 of id 47.FUNC effects carry no asset field — just id/pos/delay/param;
the client generates them itself. 45 EF_FIREFLY is the one baked (iz_dun00 places
369 of them).EF_EMITTER (974), EF_ANIMATED_EMITTER (1073) and
EF_MAGIC_FLOOR (1025) are not .str files; their particle spec lives per-map in
the client's effecttool/<map>.lub (a parsed Lua emitter table). Each placement is
matched to its lub entry by horizontal (X/Z) position and the spec is baked inline as
emitter (a texture field is rewritten into the shared _t store; magic-floor
entries carry Speed/Size/Angle/RiseAngle/Alpha/Height0…20 instead).Any other id — the classic hardcoded ambient effects (forest lights, light pillars, …) the client draws procedurally with no shippable data — is skipped.
GET /bgm/... — per-map background musicEach world map's background-music track, for a client to play alongside the map
simulator. extract-grf.mjs --bgm reads the client's data/mp3nametable.txt
(which maps <map>.rsw → a track in the bgm\ folder) and copies the referenced
.mp3 files out of the client's loose BGM/ folder (they live next to the GRF,
not inside it — see GRF extraction). Many
maps share one track, so tracks are de-duplicated by their (numeric) filename
and each is stored and served once (~325 MB / ~183 tracks for the current client,
covering ~1080 maps). These endpoints return 404 until you run the --bgm step.
| Path | What you get |
|---|---|
GET /bgm/index.json | Catalogue: {"maps":{"<map>":"<track>.mp3",…}} — every mapped map name → its track filename. |
GET /bgm/{track}.mp3 | One background-music track (audio/mpeg). |
/bgm/index.json
/bgm/210.mp3
Track names are numeric slugs ([0-9a-z_-].mp3) — the strict filename pattern
makes path traversal structurally impossible. Responses carry the same immutable
cache headers, ETag/304 and wildcard CORS as /maps.
GET /raw/... — client data tables (items, jobs, skills, monsters…)The client's reference data as plain JSON, so a project can consume it without owning a GRF reader, a Lua VM or a Ragnarok install. This is the endpoint that makes ragassets the single source of truth for game-file extraction: the LATAM calculator, the costume simulator, the replay viewer and the market catalogue all build their own data files from these instead of each extracting the client themselves.
| Path | What you get |
|---|---|
GET /raw/items.json | Every item: id, name, slots, aegisName, resourceName, description, view, spriteView, spriteBlank, viewKind, equipSlots, costume — plus contains on the 1,498 that are boxes. |
GET /raw/jobs.json | Every class: id, jt, name, hasIcon. |
GET /raw/skills.json | Every skill: id, name, maxLevel, description, delay — plus parent on follow-up hits. Includes the ids the client leaves unnamed that a name can be built for; see Unnamed skills. |
GET /raw/status.json | Status-effect (EFST) id → name. |
GET /raw/randomopt.json | Random-option id → display template ("ATQM +%d"). |
GET /raw/classes.json | Classes with palettes, swatches and alternative outfits. |
GET /raw/hair.json | Hair styles and colour swatches per race/gender. |
GET /raw/mobs.json | Monster stats — see Monster stats. mobs_en.json / mobs_es.json carry the same rows with the monster's name in that locale. |
Every table that carries text has an English and a Spanish copy beside it —
/raw/items_en.json, /raw/skills_es.json and so on for items, jobs,
skills, status, randomopt and classes. See
Languages.
Every table is a flat JSON array sorted by id, written compact. They are a
deliberately faithful projection of the client, not a curated one: naming
overrides, slot-suffix formatting and per-project reshaping stay in each
consumer's own sync step, so this stays one unopinionated upstream. The single
exception is skill names for ids the client leaves unnamed, below — and even
there a name the client ships always wins.
name is the bare identified name — the client appends the [3] slot suffix
at display time, so slots is a separate number and a consumer that wants
"Espada [3]" re-joins them. Items with no display name are kept with
name: null, because ~640 of them still carry a renderable view.
view is the literal client ClassNum; spriteView is the one to render
with. Newer costumes ship ClassNum: 0 and keep their real view only in the
client's accessory/robe name tables, so spriteView falls back to that lookup
(232 items in the current client) — a costume catalogue built on view alone
silently loses them. viewKind ("headgear"/"garment"/null) says which of
the two sprite tables the view lives in, which usually follows the equip slot but
not always; rendering those few from the slot-implied table draws the wrong item.
spriteBlank marks the handful of views (10 items in the current client) that
render as nothing. Their sprite is there but every layer of the .act is
tinted alpha 0, which is how the client says "this costume's visual is an effect,
not a sprite": the falling-petal and aura costumes all work this way. Rendering
them yields an empty image, so a costume catalogue has to skip them and take the
visual from /effects/index.json instead —
where the effect is a .str we can serve.
A blank .act is not on its own enough to set the flag. 31089 Fúria dos
Shuras (view 1500) is a hat effect: the client plays its visual from a
second sprite shipped beside the item sprites under the costume's own resource
name plus _이펙트 (아이템/c홍염의폭렬파동_이펙트), attached to the character's
head. /image composites that sprite, so the view
does draw and spriteBlank is false — those items are ordinary renderable
costumes, listed only in items.json and not in /effects/index.json. It is the
only file in the whole GRF carrying that suffix, and the only costume of its kind
today. The same sprite is served as a bundle at /effects/sprites/eff_1130/ —
that is the numbered effect the client reaches it by
(HAT_EF_BAKURETSU_HADOU → hatEffectTable[47].hatEffectID), for consumers that
play effects by id rather than render a character.
contains on items.json is what a box gives you, on the row of the box
itself — the client ships that table, so a catalogue can show a box's loot
without a server-side item_db to join against. The key is present only on a
box — the 15,446 rows that open into nothing leave it out entirely rather than
carrying an empty array, which would add 216 KB of noise to a file consumers
pull over the wire, so read it as item.contains ?? []. On the 1,498 that are
boxes it lists one entry per drop:
"contains": [
{ "id": 1000274, "prob": 10, "group": 0 },
{ "id": 23047, "prob": 1400, "group": 6 }
]
prob is the client's raw weight, unnormalized, because the file has no one
denominator to normalize against: per-group sums land on 10000 and 20000
(basis points) for the gacha-style boxes but on 1, 2, 3, 10… for the
fixed-contents ones, and 552 groups sum to 0. To show a percentage, sum the
weights of one group and divide by that. group is the sub-pool — a box rolls
each of its groups independently, so the example above hands out one drop from
group 0 and one from group 6.
Each drop is id-only: the client's table stores a display name inline too and
the projection drops it, because it is a second, worse copy of a name
items.json already carries — 4,776 of the 13,111 drop rows disagree with it,
nearly all of them because the package table bakes in the [2] slot suffix that
slots keeps separate, and all 37 drops that no longer have an item row are
literally named "Unknown Item". Join on id like everywhere else. The table is
ProbabilityInfo/PackageItem.lub, read at its full data/luafiles514/…
path for the same reason the skill tooltips are: data/spanish/ ships the
largest copy. Because the inline names are dropped, the one pt-BR copy serves
every locale's items table. 106 of its 1,604 boxes are keyed by an id
iteminfo_new.lub has no row for and are dropped — --raw prints the count.
description — on both items.json and skills.json — is the client's own
tooltip in that table's locale (Languages), raw: the ^RRGGBB colour codes and the line breaks are kept
and nothing is reflowed, because consumers treat that text as the source of truth
for what an item or skill actually does and each formats it its own way. Skills
whose tooltip the client ships empty (or doesn't ship at all) keep
description: null rather than dropping out — 283 of the 1,558 in the current
client. The tooltips come from SkillInfoz/SkillDescript.lub at its full
data/luafiles514/… path: the GRF also carries a data/spanish/ copy of that
same file, and it is the largest of the three, so a suffix-only lookup quietly
publishes Spanish into the pt-BR table. (skills_es.json reads that same
data/spanish/ copy — on purpose, and by its full path.)
delay on skills.json is the client's Conjuração e Espera window — the four
columns it prints, in milliseconds, one entry per skill level:
"delay": {
"castFixed": [1500, 1500, 1500, …], // Fixa — cast time nothing reduces
"castVariable": [4500, 4700, 4900, …], // Variável — the DEX/INT-reducible part
"afterCast": [1000, 1000, 1000, …], // Pós — blocks every skill afterwards
"cooldown": [6000, 6000, 6000, …] // Recarga — blocks only this skill
}
The arrays are verbatim: index N-1 is level N, trailing zeros are kept
and nothing is padded out. Their length is usually maxLevel (2,733 of the
3,044 columns) but not reliably — 258 are padded past it and 53 stop short, 52 of
those holding the single value that plainly means "same at every level" — so
clamp on maxLevel and repeat the last entry rather than trusting the length. A
skill the client gives no timings — every passive, and 619 of the 1,558 in total
— has delay: null, and a single column the client omits is null rather than
[0], so "says nothing" never reads as "says zero". 939 skills carry timings in
the current client.
maxLevel is the client's MaxLv, and it comes from SkillInfoList_data.lub
— not from the SkillInfoList.lub sitting next to it, which is a stale copy
of the same table, one skill short (5383 Invocação do Abismo). Six skills the
client never released carry MaxLv: 0; that zero is the client's own, while a
skill with no row at all would be null. The tooltips corroborate the numbers:
of the 1,179 that spell out a Nível máximo, 1,178 match, the one exception
(2535 Loja de Compras) being wording drift between the two client tables.
skillid.lub defines ~240 skill ids that SkillInfoList gives no name, and
the server uses some of them — a replay carries Crimson Arrow's explosion and a
monster's NPC_* skills under ids like these, which used to show up as
skill#686 in every consumer. skills.json fills in the ones a name can be
justified for, only while the client leaves them unnamed: the first patch
that names one replaces ours, and --raw logs which table entries that has made
redundant.
"Flecha Escarlate (explosão)" / "Crimson Arrow (explosion)",
and carry parent, the parent's id, so a consumer can count one use rather
than one per packet. The pairs are read off the SKID constants
(<PARENT>_ATK, _FIRE/_WATER/_WIND/_GROUND/_POISON, _MELEE,
_MAGIC, …), with FOLLOW_UP_OVERRIDES for the ones that don't line up —
the WL_SUMMON_ATK_* spheres, say. Because the relation comes from the
constant, parent is also on the follow-ups the client does name (Magni
Lumen → Gemini Lumen, the elemental spirits' _ATK hits), and stays when a
patch names one of ours. Rows that aren't follow-ups have no parent key.UNNAMED_SKILL_NAMES in
extract-grf.mjs: a pt-BR reading of what the constant says
(NPC_KILLING_AURA → "Aura Assassina"), with the English and Spanish
wording in UNNAMED_SKILL_NAMES_I18N beside it; or the client's own name for
the skill it copies (NPC_CHEAL → AB_CHEAL's "Sopro Divino", the way the
client already names NPC_DRAGONBREATH), which needs no translation because
the copied name is already in the right language. Those carry no parent:
each is a cast of its own.Two narrower cases. SKID_ADDITIONS covers ids the server sends that LATAM's
skillid.lub doesn't define at all (5306, kRO's AG_DESTRUCTIVE_HURRICANE_BUFF),
and steps aside once the client defines the id. SAME_NAME_SUFFIXES suffixes
the client's own name for a skill's modes when it is word for word the base
skill's — Vigília Noturna's gun modes become "Vigília Noturna (metralhadora)"
and "(espingarda)", "The Vigilante at Night (gatling gun)" / "(shotgun)" in
English — without parent, since the server sends the use under those ids. Client names are also trimmed; four ship with a trailing space.
Ids whose constant says nothing a name could be built from — NPC_KEEPING,
event and GM skills, unreleased 4th-class skills — stay out of the table, and
--raw prints them. Range markers (WL_STARTMARK, NPC_LAST) are never cast
and are not listed. The filled rows usually have description: null and often
no maxLevel or delay, because the client has no rows for them there either.
/icons/skill/{id}.png follows the same rule for follow-ups: one the client
ships no icon for is served its parent's. The client ships no icon for any
NPC_* skill, so every monster skill — all 200 in the current client, named or
not — is served one generic icon instead: the one divine-pride uses for them,
which is the client's own item/hfli_sbr44.bmp (S.B.R.44, 8012). A monster
copy of a player skill gets that too, never the player skill's icon.
/raw/{name}_en.json / /raw/{name}_es.json)The server runs in three locales and the client ships a whole second and third
copy of every string table, so every /raw table that carries text is published
three times:
/raw/items.json | pt-BR — the base. Unsuffixed, so nothing that already fetches it changes. |
/raw/items_en.json | English, from data/english/ and System/english/iteminfo_new.lub. |
/raw/items_es.json | Spanish, from data/spanish/ and System/spanish/iteminfo_new.lub. |
items, jobs, skills, status, randomopt and classes all have the three
copies. hair.json carries no text at all — styles, colour counts and dye
swatches — so it is published once for every locale to share.
/effects/index_en.json and /effects/index_es.json are the costume
catalogue's two translations: the same rows and the same effect keys, each
costume's name as that locale's item table spells it.
mobs.json has the three copies as well, but it is the one table whose locales
do not come from the client, because no locale of the client has a monster
name to give — see Monster names in the other two
locales.
Every locale's table is indexable by the same ids as the pt-BR one, except
items, where the client itself disagrees: the English and Spanish tables each
carry ~427 rows pt-BR has no entry for, and lack 3–4 that it has. Where a locale
ships a row but leaves the text empty, the pt-BR wording is kept rather than the
row dropping out — 4 skill names (318, 3043, 5383, 8028, which the localized
trees' name table is short of) and 31 English / 43 Spanish status titles. --raw
prints each count.
Everything structural is re-derived from each locale's own table rather than
copied across, because each is its own client fact: they disagree on ClassNum
for a handful of ids, and the slot line is worded differently in each (Equipa em: Topo / Equip on: Upper / Equipar en: Superior), so equipSlots is read
with a union of all three locales' labels and slot words. That union is also why
items.json itself gained three slot readings it was missing: a few pt-BR rows
are only half-translated and spell their slot in English or Spanish.
The two sources that need naming, because they are not simply the pt-BR path with a prefix:
SkillInfoz/skillinfolist_ptbr.lub under
SkillInfoList_string. A localized tree ships no _<lang> copy at all, only
the un-suffixed SkillInfoList.lub — the stale merge that maxLevel above
warns against — under SKILL_INFO_LIST. There it is the only copy there is,
and it is the four ids short named above.msgstringtable_ml.csv is multi-language in one
file: column 2 is English, 7 pt-BR, 9 Spanish. Only the column changes, and an
untranslated cell falls back to English.The curated half of skills.json — the ids the client leaves
unnamed — is written in each locale too, or an English consumer
would get Portuguese names for every monster skill. UNNAMED_SKILL_NAMES_I18N in
extract-grf.mjs holds the English and Spanish wording of each plain name, and
the follow-up and same-name suffixes carry all three ("Crimson Arrow (explosion)", "Flecha Carmesí (explosión)"). A { sameAs } entry needs no
translation — it copies a client name that is already in the right language. A
test asserts the overlays cover exactly the pt-BR table's own entries, so a name
added without a translation fails the suite instead of shipping.
Unlike every other endpoint here these files are mutable at a stable URL —
they change whenever the client does — so they are served with a short
Cache-Control: public, max-age=300, must-revalidate instead of the immutable
policy the content-addressed assets use. ETag/304 and wildcard CORS work the
same, so a sync that finds nothing new costs one 304. Everything under /raw
returns 404 until you run extract-grf.mjs --raw (and, for mobs.json,
tools/scrape-mobs.mjs).
These are also the only text this host serves, and the reverse proxy compresses
them (encode zstd gzip on /raw/* in caddy/ragassets.caddy) — items.json
is 9.3 MB raw and ~1.2 MB over the wire (1.16 MB via zstd). Everything else ragassets serves is already
compressed bytes, so the directive is deliberately scoped to /raw. The three
locales make resources/raw/ ~32 MB on disk rather than ~11 MB, but a consumer
still fetches only the tables and the locale it wants.
GET /healthzLiveness check — returns 200 ok.
One binary. It renders in-process and reads assets straight off disk, so there is nothing to orchestrate and no container to build.
# 1. Provide game assets (see "Resources" below) into ./resources
# 2. Build and run, pointing it at that tree
cd gateway && go build -o ragassets-gateway .
RESOURCE_DIR=../resources ICONS_DIR=../resources/icons ILLUST_DIR=../resources/illust EFFECTS_DIR=../resources/effects MAPS_DIR=../resources/maps BGM_DIR=../resources/bgm SOUNDS_DIR=../resources/sounds RAW_DIR=../resources/raw GATEWAY_PORT=8080 ./ragassets-gateway
http://localhost:8080 (override with
GATEWAY_PORT)./resources, /icons, …), which is a leftover from when this
ran in a container and is almost certainly not where your tree is. A missing
directory is logged at startup and its routes 404, except RESOURCE_DIR, whose
absence is fatal — the renderer has nothing to read.deploy/ragassets-gateway.service. See
Running it in production.The public instance is one Oracle Cloud ARM box — VM.Standard.A1.Flex,
2 OCPU, 12 GB, Ubuntu 24.04 aarch64, 100 GB boot at VPU 10 — running the gateway
and Caddy as native systemd services. Cloudflare provides DNS for this host and
nothing else — the assets record is grey-clouded, so browsers reach this box
directly. Those numbers sit inside the Always Free allowance (4 OCPU / 24 GB
Ampere, 200 GB block storage), so the box is free whatever the account's billing
status. tools/provision-oracle.sh sets it up and is safe to re-run;
deploy/ holds the units it installs.
tools/provision-oracle.sh # idempotent; prints what it cannot do
Memory is a ceiling, not a target. GOMEMLIMIT=500MiB with
MemoryMax=600M, ~224 MB RSS observed, and the parse-cache budgets in
internal/render/resource/manager.go (96 MiB spr + 48 MiB act) are sized to sit
inside it. Raising one without the other either wastes the headroom or makes the
caches thrash. The patch cycle gets its own, larger cgroup ceiling so a runaway
extraction is killed instead of the gateway.
Disk: ~35 GB steady (toolchains ~10, the client ~4.7, mirror/ ~13–15,
resources/ ~6.7), peaking near 45 GB when --maps rebuilds its tree alongside
the old one. Oracle's minimum boot volume is 50 GB, which does not fit that peak.
Production moved to this box on 2026-09-08. The Cloudflare Worker, its R2 bucket
and its KV namespace have been deleted, so this origin is the only thing serving
assets.latam-tools.com.br and there is no rollback target — recovery is a
rebuild, which is why tools/provision-oracle.sh and the extraction steps below
are kept honest.
Ampere capacity is the scarce thing, not quota. Creating this instance took
91 consecutive Out of capacity for shape VM.Standard.A1.Flex failures in
sa-saopaulo-1 over about an hour, at both 2 OCPU/12 GB and 1 OCPU/6 GB — the
smaller ask fails identically, so shrinking the shape is not a workaround. The
service limit was never the constraint (standard-a1-core-count reads Dynamic
with usage 0); free-tier tenancies are simply last in line for Ampere hosts, and
it launched first try once a payment method was attached. If this box is ever
lost and has to be rebuilt, expect that queue again and budget for it.
Always Free carries no SLA, and a free-tier tenancy additionally has idle
instances reclaimed when CPU, network and memory all sit under 20% for seven
days — a heuristic that does not apply to a Pay As You Go account, even one
running purely inside the free allowances. Either way the mitigation is the same
and worth keeping true: everything on this box comes from a CDN or this repo, so
a rebuild is an afternoon rather than an excavation. Keep
provision-oracle.sh honest and that stays so.
One provisioning trap worth stating on its own: Oracle's Ubuntu images ship iptables rules that block inbound independently of the VCN security list. Both have to be opened, and forgetting the local half is the classic "the port is open but nothing connects" afternoon, because the console shows you the rule you did add. The script handles it; a hand-built box will not.
assets → the box's IP, DNS only (grey cloud) since 2026-09-09,
no AAAA record. The Cloudflare zone is managed by hand in the dashboard; its
zone-wide settings also govern the sibling projects on latam-tools.com.br,
which are still proxied.internal/api);
Caddy sets none, and with no edge in the path the browser is the only cache.Invalidation has one real gap, and it is the browser's. Adding a sprite
creates new ids and therefore new URLs, so nothing stale exists. Redrawing an
existing sprite does not: the URL and its query-derived ETag are unchanged, so a
browser holding a year-long immutable entry keeps the old pixels until it
expires. Nothing can be purged on its behalf, and one sprite maps to unboundedly
many /image query permutations, so no finite invalidation list exists even in
principle. Accept it, or change the URL.
The game patches two or three times a day. ragassets-patch.timer runs
tools/patch-cycle.mjs every ten minutes, which conditionally GETs the LATAM
patch index and, on a new sequence:
tools/apply-patches.mjs);mirror/ — patches carry whole files, so
copy-with-overwrite is the merge;#novidades
(tools/post-novidades.mjs).Almost every run stops at the conditional GET: one process start, one request, a 304, no body, no disk write. That is what makes ten minutes affordable.
Step 4 is the point of the whole arrangement. Because the mirror is on local disk, a patch that adds an item now updates its icon and its data tables, not just its sprite — which a stateless runner could never do. Step 5's restart is mandatory rather than hygiene: the parse caches are keyed by name and the effect store's directory index is built once per folder, so a live process would serve a patched sprite from a stale parse and 404 a new effect file forever.
Two things it deliberately does not do. It never rebuilds the baked resolver
tables, because that recompiles the renderer; a patch carrying
data/luafiles514/ logs loudly and leaves it to a human. And it never advances
its recorded sequence except on full success — a failed cycle must leave the work
pending, or the patch is skipped forever.
Seed the state once before enabling the timer, or the first run asks for the entire archive history and is refused:
node tools/patch-cycle.mjs --seed # record the current head
node tools/patch-cycle.mjs --dry-run # what a cycle would do
.github/workflows/ci.yml runs the extractor tests, go vet, go test and a
gofmt check on every push to main. It deploys nothing — the box builds its
own binary.
Never add a pull_request trigger, and never give that workflow credentials.
This repository is public: a pull_request trigger runs a fork's code, so any
secret the job can reach is a secret a stranger can exfiltrate. The rule predates
the self-hosted origin, and matters more now, because the obvious next credential
to add is an SSH key into the box that serves production.
gateway/ # the Go gateway + in-process renderer (this project)
gateway/internal/render/ # the native zrenderer reimplementation (parsers, raster, engine)
gateway/internal/api/ # the HTTP contract: query→request, ETags, cache headers
gateway/cmd/gen-resolver/ # offline tool: bakes id→sprite-name tables from the client .lub
gateway/cmd/gen-skin-table/ # offline tool: bakes per-sprite skin-ramp palette indices
gateway/cmd/gen-tables/ # offline tool: turns those JSON tables into Go source (no JSON at startup)
caddy/ragassets.caddy # the live site block: Let's Encrypt via ACME, gzip on /raw, proxy to :8080
deploy/ # the systemd units, the timer, and the one-line sudoers rule
deploy/deploy-from-ci.sh # what the CI deploy key is allowed to run, and the only thing
tools/provision-oracle.sh # idempotent setup for the Oracle box — the rebuild plan, in code
tools/patch-cycle.mjs # ONE client-update cycle: poll, apply, rebuild, restart, announce
tools/apply-patches.mjs # download and unpack client patches (.gpf and .rgz)
tools/patchlist.mjs # the patch index parser, shared by the poller and the applier
tools/post-novidades.mjs # announce an asset update in #novidades
.github/workflows/ci.yml # tests only — no credentials, never on pull_request
.github/workflows/deploy.yml # deploys to the box after a green CI run; holds the SSH key
mirror/ # YOUR merged client: the whole GRF plus every patch (git-ignored)
resources/ # what the extractor derives from it (git-ignored, not distributed)
resources/icons/ # static icons (extract-grf.mjs --icons), served at /icons/*
resources/illust/ # card artwork (extract-grf.mjs --illust), served at /illust/*
resources/effects/ # effect-only costume + graphic-stone bundles (extract-grf.mjs --effects), served at /effects/*
resources/maps/ # world-map bundles (extract-grf.mjs --maps), served at /maps/*
resources/bgm/ # per-map background music (extract-grf.mjs --bgm), served at /bgm/*
resources/sounds/ # skill/effect/monster sound effects (extract-grf.mjs --sounds), served at /effect/sound
resources/raw/ # client data tables (extract-grf.mjs --raw), served at /raw/*
extract-grf.mjs # helper to extract a GRF into resources/
tools/scrape-mobs.mjs # rebuilds resources/raw/mobs.json from the RagnaPlace Public API
tools/crawl-divine-pride.mjs # second source for that file: the res/mres RagnaPlace omits
This project distributes no Ragnarok Online game assets. To render anything,
the gateway needs the sprite/palette data from a Ragnarok Online client's GRF
archive, extracted into ./resources. You must extract your own GRF from a
client you are entitled to use.
A standalone extractor, extract-grf.mjs, is included. It needs only Node 18+
(no dependencies) and reads Gravity's GRF/GPF formats — including the custom
0x300 "Event Horizon" fork used by recent official clients, with the per-entry
DES decryption that the standard tools can't handle.
Extract exactly the directories the gateway needs into ./resources:
node extract-grf.mjs --extract resources --grf path/to/data.grf \
--match "data\\(sprite|palette|imf|luafiles514|texture\\effect)\\"
Pass
--iteminfoexplicitly in anything automated. With--grf <directory>the extractor resolves iteminfo asdirname(<directory>)/System, so a mirror at~/ragassets/mirrorlooks in~/ragassets/System— which is exactly where the.rgzhalf of a patch lands. Convenient, and far too subtle to rely on in an unattended job: move the mirror one level and the icons silently rebuild against the wrong table.
On the production box this goes to mirror/ instead, and holds the whole GRF
rather than the subset below — the derived modes need a merged view of the entire
client, so the box extracts everything once and patches it in place. resources/
there means only the derived stores. Locally the two can share one tree; the
split exists because --grf <dir> walks every file it is pointed at, and aiming
it at a tree containing the 21k-file maps/ output it is about to rewrite is
both slow and confusing.
This populates resources/data/sprite, resources/data/palette,
resources/data/imf, resources/data/luafiles514, resources/data/texture/effect,
etc., which the gateway reads via RESOURCE_DIR (default /resources in the
container). texture/effect holds the .str skill/world effects and their layer
textures that /effect/str and
/effect/texture parse on demand; drop texture\\effect from the match if you
don't need those endpoints. The headgear/garment
ID→sprite-name tables are baked from the client luafiles514/.lub into the binary
by gateway/cmd/gen-resolver — re-run it when you update the client. Equipment
ordering uses the full numeric TB_Layer_Priority values: direction override,
item default, then equipment-slot default (middle 100, upper 200, lower 300).
Garments use the client's default robe priority 400, behind the body for directions
0, 1 and 7 and over the back for 2 through 6. The client's isIgnoreRiding flags
exempt listed headgears from mount occlusion; an explicit headgearBehind still
takes precedence.
node tools/gen-layer-slots.mjs regenerates slot defaults from the client-derived
resources/raw/items.json. Run go generate ./internal/render/resolve from
gateway/ afterwards. Eight views have conflicting item slots; they use the
request's upper/middle/lower positions when no numeric override applies.
tools/rebake-resolver.sh does the whole cycle on the origin box (dump, generate,
bake, test, rebuild, restart) and exits early if nothing changed.
That script needs a 32-bit Lua, which is the non-obvious part. .lub files are
precompiled Lua 5.1 bytecode and the header pins the word size:
1b 4c 75 61 51 00 01 04 04 04 08 00
^^ sizeof(size_t) = 4
lua_undump refuses a chunk whose sizes do not match the host, so a 64-bit
interpreter cannot read these files at all. What that requires is a 32-bit
interpreter, not a 32-bit machine: lua5.1:armhf run through
qemu-user-static's binfmt handler, since Ampere Altra is AArch64-only and cannot
execute AArch32 natively. It produces a tables.json byte-identical to a 32-bit
x86 Lua on Windows. tools/provision-oracle.sh installs it.
Skipping the rebake is not cosmetic: a job id missing from jobName is an
unresolvable request, so /image returns 500 rather than degrading.
Anywhere a mode takes --grf <file.grf> it also takes:
| argument | what it is |
|---|---|
<file.gpf> | a patch archive (GRF v0x102), carrying data\** |
<file.rgz> | the loose half of a patch: System\** (including iteminfo_new.lub), RagHash.dat, Ragexe.exe |
<directory> | a mirrored client laid out by path (<dir>/data/...) |
The container is detected from its contents, not its extension. A patch release ships both kinds, and extracting them into the same directory reproduces the client's own layout — which is precisely what makes that directory usable as the tree:
node extract-grf.mjs --extract client-mirror --grf 2026-09-01_live_client_2021_2021.gpf
node extract-grf.mjs --extract client-mirror --grf 2026-09-01_live_client_2023_2025.rgz
node extract-grf.mjs --icons resources/icons --grf client-mirror
Every mode behaves identically against a tree or an archive; --effects was run
both ways over the real client and the two outputs are byte-identical across
3,100 files.
node extract-grf.mjs --icons resources/icons --grf path/to/client-mirror
This exists for incremental updates. A patch archive (.gpf) carries whole
copies of the files it changes rather than binary deltas, so applying one is just
a copy — but only --extract is correct when run against a single patch.
The derived modes need to see the whole client: --icons resolves item ids
through iteminfo_new.lub and then looks for each icon BMP, --maps needs a
map's .gat/.gnd/.rsw together with every texture and model they reference,
and --illust picks the first card name that resolves to real art. Point those
at one patch and they do not merely come up short — --effects rewrites its
index.json from whatever it happened to resolve, replacing the full catalogue
with a handful of entries, and --illust silently picks a different name than
the merged client would. Keeping a mirror and pointing the modes at it avoids
the whole class of problem.
Run this after every --extract:
node extract-grf.mjs --prune-robes resources
Gravity builds each data/sprite/로브/<garment>/ folder by copying the
모험가배낭 ("Adventurer's Backpack") folder. It replaces every per-job .act
with the new garment's geometry, and the folder-root .spr with its artwork —
but it leaves the per-job .spr files behind as the backpack's. The client never
reads them (it pairs a per-job .act with the folder-root .spr), so the bug is
invisible in-game. Our renderer does read them: loadGarment takes the first
candidate pair where both files exist and GarmentCandidates offers
{per-job act, per-job spr} first, so every job that inherited a leftover renders
a backpack instead of the garment. Only the 4th classes escaped, because the
folders are act-only for them and they fell through to the root .spr.
This step deletes the leftovers, after which the {per-job act, root spr} pair
already in the candidate list takes over on its own — the resolver needs no
change. It is idempotent, and it runs on the extracted tree rather than inside
--extract so that --extract stays a plain byte copier and an already extracted
(or deployed) resources/ can be repaired in place. In this client it removes
2090 sprites from 21 folders; six garments were almost entirely destroyed
(c_scepter, c_evil_scythe, c_sakura_wing, c_snow_powder,
c_giantcatbag_jp_bl, c_ice_wing — views 97/79/83/100/80/71) and the rest lost
13–18 job slots each, mostly the mounted variants.
The decision only ever compares hashes, never bytes, so it can be made from a recorded index instead of the tree:
node extract-grf.mjs --robe-index robe-index.json --grf resources # once, ~31s
node extract-grf.mjs --prune-robes resources --index robe-index.json --dry-run
That matters because the robe tree is 134,707 sprites — roughly 4.5 GB, three
quarters of the whole sprite library — and a CI runner with 14 GB of disk should
not have to hydrate it to find out what to delete. An upload step already reads
every file it ships and can record path → md5 as it goes; from then on pruning
costs ~0.2s and no sprite bytes at all. Measured on the real tree: 218 folders,
56,437 sprites, 31.4s to index, 0.21s to plan from the index, with both paths
producing identical results (verified on a deliberately un-pruned tree, and with
the sprite files deleted afterwards to confirm the index path never touches them).
The index also yields the delete list directly, which is what lets the update pipeline prune object storage where the files are not local at all.
An index describes the tree as it was when built, so it goes stale the moment
sprites change: tools/patch-cycle.mjs rebuilds it whenever a patch ships
anything under data/sprite/, and --prune-robes --index rebuilds one of an
old version (or one it cannot parse) rather than planning from it.
A leftover is identified by content, never by position: 201 of the 218 robe
folders are healthy, and plenty of them (c_giant_white_rabbit, c_niflheim_key,
c_samba_carnival) ship genuine per-job image banks that differ from their root
.spr and must keep winning — so "the root .spr always wins" would be wrong.
The rule is that a content shared by ten or more distinct robe folders cannot be
any one garment's artwork. The measurement is unambiguous: eight contents appear
in 18–20 folders each, the next-largest sharing group is 4, and all eight decode
to the backpack (seven of them are the same bag drawn for a different body — the
per-job variants 모험가배낭's own folder no longer ships, which is why matching
only 모험가배낭/모험가배낭.spr byte-for-byte would miss ~260 of the bad slots).
Gravity sometimes copies another garment's folder instead of the backpack's —
c_pitaya_r_bag is c_pitaya_g_bag with a red root .spr, so it rendered
green. Two folders sharing a content is below the threshold, so a second rule
covers it: when a folder has its own root .spr and one of its per-job slots is
byte-identical to a different garment's root .spr, the folder was copied from
that garment, and every per-job sprite whose content that garment also carries is
removed. Folders sharing one root .spr are never donors to each other, and a
folder with no root .spr is never touched. The prune log names the donor
(copy of c_pitaya_g_bag).
The step also reports any folder left with no sprite at all. That garment has no
artwork anywhere in the client, which means its visual is a .str effect —
see effect-only costumes.
The fan-made skin tone feature needs to know
where each sprite keeps its skin ramp. That is baked into the binary as
gateway/internal/render/skin/data/skin_table.json; the committed JSON is the
source of truth, so this only needs re-running when the client's sprites change:
cd gateway && go run ./cmd/gen-skin-table -resources ../resources -sheets /tmp/skin-review
Nothing about the ramp's position is assumed, and it is not assumed there is only one. For each of ~540 body and 84 head sprites the tool maps every palette index carrying a canonical skin colour to the ramp step it represents. Blocks of three or more consecutive steps are taken directly — eight specific colours in order is not something a garment stumbles into — and an isolated skin-coloured index is taken only if its pixels are actually drawn touching confirmed skin.
All three parts are load-bearing:
costume_1 body keeps it at 43–50.타조원더러_여 has a block at 48–55 and another at 240–247, and
costume_1/레인져늑대_여_1 has 32–39 plus a 43-pixel block at 240–247.
Recolouring only one leaves the rest of the skin in its original colour.It also finds the pixels that cannot be handled by a palette swap. Most head
sprites use a single palette index for two different things — a hair highlight
and the face's specular highlight — so those pixels are located here (about 1,800
across the whole head library) and repainted individually at render time. Two
independent rules find them, and -sheets writes contact sheets of every pixel
either rule touched. Look at them before committing a new table: every bug
found in this feature so far was caught by eye and missed by the pixel counts.
To serve the static icons (/icons/*), run the icon extraction step too:
node extract-grf.mjs --icons resources/icons --grf path/to/data.grf
This decodes the item/collection/skill/job icon BMPs (keyed by numeric id), the
status-effect icon TGAs (keyed by EFST id) and the character-creation UI elements
(keyed by their client basename) into transparent PNGs under
resources/icons/{item,collection,skill,job,status,ui}/, which the gateway serves
directly. Item ids are resolved via System/iteminfo_new.lub (found automatically
next to the GRF; override with --iteminfo <path>), skill ids via skillid.lub,
and status icons via the stateicon/efstids.lub + stateicon/stateiconimginfo.lub
tables — all inside the GRF. Rerunning overwrites in place.
To serve the card artwork (/illust/card/*), run the illustration step:
node extract-grf.mjs --illust resources/illust --grf path/to/data.grf
This decodes the 300×400 card BMPs from data/texture/유저인터페이스/cardbmp/
into resources/illust/card/<id>.png (~1,095 files, ~110 MB), keyed by item id
through the client's data/num2cardillustnametable.txt. Unlike the icon BMPs
these are full-bleed artwork with no colorkey, so they are written fully opaque —
a magenta pixel in a card illustration is part of the picture.
The table repeats some ids: a late block re-points ~190 of them at the client's
sorry placeholder, in a few cases over a name whose real art is still shipped
(the 마신의정수 cards), and one id (4557) names art that was never shipped before
naming art that was. So the extractor takes the first name that resolves to a
real file, never the placeholder, and skips ids that have nothing else — those
404, which is the honest answer for "this card has no art".
To serve the effect-only costumes (/effects/*), run the effect extraction step:
node extract-grf.mjs --effects resources/effects --grf path/to/data.grf
This enumerates the costumes that have no character sprite (drawn by the
client's .str world-effect system) from System/iteminfo_new.lub, maps each to
its .str in the GRF, and writes a per-effect bundle (effect.json describing the
keyframe animation + the tex_N.png textures it references) under
resources/effects/<key>/, plus the catalogue resources/effects/index.json. It
prints a resolved / unresolved / excluded report: the "invisible" gear-hiding
costumes are excluded (no visual), and a handful of Korean-named or EXE/shared-bound
effects (the level auras, magic circles, …) whose .str path isn't derivable from
the resource name stay unresolved — those are filled in by hand via the
STR_OVERRIDE table near the top of the effects section in extract-grf.mjs.
The same run then builds the in-world map effects: for every servable STR entry
in the ported EffectTable.js table (EFFECT_STR_TABLE), it resolves the .str in
the GRF and writes a resources/effects/<basename>/ bundle (same format), so the
.rsw effects a map places — see /maps manifest.effects,
e.g. iz_dun03's bubble1…bubble4 — resolve. %d/rand names expand to one
bundle each; Korean-named (unservable) STR effects are skipped. The run also renders
the sprite-based map effects (SPRITE_EFFECT_TABLE: EF_TORCH/EF_SMOKE/
EF_BANJJAKII) into resources/effects/sprites/<key>/ — one composited N.png per
.act frame plus a sprite.json {frames:[{img, delay, offset}]} play list — so a
map's sprite effect references resolve.
Last, it writes resources/effects/stones.json: the graphic stones (see
Graphic stones) that play a .str hat effect, each mapped to
its bundle key — reusing a costume's bundle where the two play the same file — and
resources/effects/footprints.json for the six that stamp a decal per footstep
instead (see Footprints), each with its four bundle keys and the
client's placement numbers. The report accounts for all 29: bundled, built into
the client (no asset exists), footprints, unresolved. It prints every footprint's
scale, height, stride and gap, marking in parentheses the ones that come from the
client's default rather than the row itself.
To serve the world maps (/maps/*), run the map extraction step:
node extract-grf.mjs --maps resources/maps --grf path/to/data.grf
# or just one map:
node extract-grf.mjs --maps resources/maps --grf path/to/data.grf --map prontera
This enumerates every data/<name>.rsw in the GRF and, for each map, writes the
raw .gat/.gnd/.rsw geometry and a manifest.json under
resources/maps/<name>/, while the .rsm models, BMP/TGA textures (converted to
transparent PNG), animated-water JPGs and the shared cursor/grid UI are
de-duplicated by content hash into the shared stores resources/maps/{_m,_t,_w,_u}/
— so identical assets shared between maps are written once. A catalogue
resources/maps/index.json lists every map. Maps missing a required geometry file
are skipped (reported at the end — in the current client 17 of 939 .rsw entries
are ground-mesh-less server/template maps, leaving 922 extracted). A full run with no --map rebuilds the whole
tree from scratch; --map <name> refreshes just that map and merges it into the
existing index.json.
To serve the per-map background music (/bgm/*), run the BGM extraction step:
node extract-grf.mjs --bgm resources/bgm --grf path/to/data.grf
# the .mp3 tracks live in the client's BGM/ folder next to the GRF; override with:
node extract-grf.mjs --bgm resources/bgm --grf path/to/data.grf --bgmsrc path/to/BGM
This reads data/mp3nametable.txt from the GRF (the client's <map>.rsw → bgm\<file>.mp3
table) and copies each referenced track out of the client's loose BGM/ folder —
the .mp3 files are not inside the GRF — into resources/bgm/, de-duplicated by
filename (many maps share one track). It writes resources/bgm/index.json mapping
each map name to its track. A full run rebuilds the directory from scratch.
To serve the effect sound effects (/effect/sound), run the sound extraction
step:
node extract-grf.mjs --sounds resources/sounds --grf path/to/data.grf
This extracts the whole data/wav/ tree — every name an effect table row's wav
field can reference — into resources/sounds/, mirroring the GRF paths
(effect/ef_portal.wav, _heal_effect.wav, …). Standard PCM wavs are copied
verbatim; the handful stored as MS/IMA ADPCM (which some browsers can't decode) are
transcoded to 16-bit PCM so the whole tree is uniformly browser-playable. It writes
resources/sounds/index.json listing the names present. (In the current client:
2678 sounds, 6 transcoded from ADPCM, 1 corrupt GRF entry skipped.) A full run
rebuilds the directory from scratch.
/raw)node extract-grf.mjs --raw resources/raw --grf path/to/data.grf
Writes items.json, jobs.json, skills.json, randomopt.json, status.json,
classes.json and hair.json into resources/raw/, which the gateway serves at
/raw/.... This is
the step that lets every other project stop extracting the client for itself, so
re-run it after a client update — on the box the patch cycle does, whenever a
patch ships data/luafiles514/. It reads System/iteminfo_new.lub from next to
the GRF (override with --iteminfo) plus the job, skill, status, random-option
and box-contents tables from inside it, and refuses to write an empty table — or
a skills.json where fewer than half the skills kept their description (or fewer
than a quarter their cast/delay times), or an items.json where fewer than half
the boxes matched an item, which is what reading the tooltips, the timings or the
contents from the wrong chunk looks like.
Each of those checks runs per locale: one run writes all three copies of
every table that carries text (items_en.json, items_es.json, …) from the
client's data/english and data/spanish trees and its System/english and
System/spanish item tables, logging a [ptbr]/[en]/[es] block each. See
Languages. The whole set is built in
memory and written only once every table has succeeded, locales included — a
half-regenerated resources/raw is worse than a stale one, since the deploy
skill tars the directory onto a live /raw with no PR in between. The shared
inputs (aegis names, box contents, the view resolver, the cast/delay numbers, the
palette scan) are read once and handed to all three, so three locales cost one
pass over the GRF's 278k-entry file table, not three.
mobs.json is the one file in resources/raw/ this doesn't produce — it isn't
in the client at all, see Monster stats.
Other modes:
# List every entry in a GRF (filename, size, flags):
node extract-grf.mjs --list path/to/data.grf
# Dump a single file to stdout (use forward slashes in the path):
node extract-grf.mjs --dump path/to/data.grf::data/sprite/some_file.spr > some_file.spr
The --match value is a JavaScript regex tested case-insensitively against each
stored filename. Stored names use backslash separators, so escape them
(data\\sprite\\).
/raw/mobs.json)resources/raw/mobs.json is the only table served here that isn't extracted from
the GRF — the client carries no monster HP or EXP anywhere. One record per monster
(2724 of them), with the id, names, level, HP, EXP, DEF/MDEF/ATK, the six base
stats, race/size/element, the boss/MVP flags and the 4th-job resistances:
{ "id": 1039, "aegisId": "BAPHOMET", "name": "Bafomé", "boss": true, "mvp": true,
"level": 81, "baseExp": 218089, "jobExp": 167053, "mvpExp": 109044,
"hp": 668000, "def": 379, "mdef": 45, "attack": 2520,
"str": 120, "agi": 125, "vit": 30, "int": 85, "dex": 186, "luk": 85,
"race": "Demon", "size": "Large", "property": "Dark", "propertyLevel": 3,
"res": 0, "mres": 0 }
res / mres are nullable, and null is not 0. 0 means the monster
genuinely has no resistance — true of 2,570 of the 2,724 records, Baphomet above
included. null (18 records) means unknown: divine-pride has no usable data for
that monster. Consumers must keep the two apart. Treating an unknown resistance as
0 puts a level 224 MVP's simulated damage at roughly 3.3× what the server
actually deals. All 136 non-zero resistances belong to level 200+ monsters.
It is rebuilt from two sources, split by which one is authoritative for what:
| authority for | |
|---|---|
| RagnaPlace Public API | identity (id, aegisId) and the pt-BR name, plus the whole stat block |
| divine-pride.net | res / mres, which exist in no other source |
RagnaPlace needs a RAGNAPLACE_API_KEY in .env (request one at
https://ragnaplace.com/api); divine-pride is crawled anonymously.
mobs_en.json and mobs_es.json are published beside it, with the same rows
and the same numbers — see Monster names in the other two
locales.
node extract-grf.mjs --mobids _scratch/mobids.json --grf path/to/data.grf
node tools/scrape-mobs.mjs --ids _scratch/mobids.json --no-dp # first run only
node tools/crawl-divine-pride.mjs # ~1 h, resumable
node tools/scrape-mobs.mjs --merge-only # no API quota
node tools/scrape-mobs.mjs --langs-only # mobs_en/mobs_es
The first two steps are split because neither source is sufficient alone. The API
has no bulk mob endpoint — its search caps at 20 pages × 20 rows — so every monster
needs its own GET /v1/<gateway>/mob/<id>, and the id list to walk has to come from
the client's datainfo/npcidentity.lub (--mobids, ~4585 candidates; the ~1900
that aren't monsters simply 404). --gateway selects the server (default
laro-pt; /v1/gateways lists all 36). The run throttles itself off the API's
X-RateLimit-* headers and resumes from mobs.json.partial.jsonl if interrupted.
Once mobs.json exists, the crawler walks it rather than the client's id list,
sparing divine-pride ~1900 requests for ids that aren't monsters. It is serialised
with a delay, caches every parsed record in _scratch/dp-cache.jsonl and reuses
anything younger than 30 days, so re-runs are nearly free and an interrupted crawl
resumes. --merge-only then folds the result into the existing mobs.json without
touching the RagnaPlace API at all, so refreshing the resistances doesn't cost the
key's whole quota.
/raw/mobs_en.json and /raw/mobs_es.json hold the same monsters as
mobs.json, in the same order, with the same numbers — only name differs.
Each is literally the pt-BR records with that one field replaced, so a consumer
can index any locale by the pt-BR table's ids and no locale can drift from the
others on a stat.
The client is not the source, in any locale. It ships no monster-name table:
the only monster file under data/english/ and data/spanish/ is
adventureguide/guide_monster.lub, and all three copies are byte-identical apart
from the compile-time path in the bytecode header — ids, no strings.
npcidentity.lub is id → JT_ constant, jobname.lub is sprite folders,
navi_mob_br.lub has no locale sibling at all, and i18n/mobchat and i18n/sc
are dialogue keyed by opaque hashes. In RO the server sends monster names, which
is the same reason the stats are scraped rather than extracted.
So the locales come from where the pt-BR name does. RagnaPlace serves the LATAM
server under one gateway per locale — laro-pt, laro-es, laro-en — and the
same id on another gateway is the same monster with its name in that
language. That is an association by key, and the only one available:
Matching the pt-BR text against the client's
msgstringtable_ml.csvwas measured and does not work. 20 of 2,732 names match, and all 20 are false positives — interface labels colliding with a monster name, so "Mercenário" resolves to Assassin / Asesino and "Bruxa" to Wizard / Hechicero. That table is the UI message table (MSI_*keys), not a monster table. Matching by text would cover nothing and corrupt what it did cover.
--langs-only (re)builds just the two locale files from the mobs.json already
on disk; a normal --ids run writes all three, and --no-langs writes pt-BR
alone. --merge-only rewrites the locale files too, from the names already in
them, so a resistance refresh can't leave the locales a patch behind. The locale
pass walks only the ids mobs.json already holds, never the ~4,600 candidates,
so it costs one request per monster per locale and no 404s.
The locale's slug is derived from that naming convention — laro-pt → laro-es
— which makes it a proposal, not a fact, so it is confirmed against
/v1/gateways before a single id is fetched. One request settles it, rather
than a wrong slug surfacing 2,732 requests and seven minutes later.
Two thresholds then check the file itself, because both remaining failures produce one that looks perfectly valid:
| refuses when | |
|---|---|
| mostly fallback | over 5% of monsters have no name in that locale, so the file would be largely Portuguese under an _es name |
| not actually translated | under 25% of names differ from pt-BR, which is what a gateway answering with the pt-BR wording looks like |
A monster the locale's gateway doesn't carry keeps the pt-BR wording rather than
dropping out, and so does one whose name comes back blank. Each run prints the
counts. A row whose sprite differs between the locale gateway and pt-BR stops
the run without writing: the files are keyed by the pt-BR ids, so that would file
one monster's name under another's.
Measured over the full catalogue on 2026-10-27:
| translated | same word as pt-BR | fell back | |
|---|---|---|---|
mobs_en.json | 2,159 | 571 | 2 |
mobs_es.json | 2,051 | 677 | 4 |
Names that are simply the same in two languages are normal and not a fault — Poring, Argiope, Marse, Metaling — which is why the floor sits at 25% and not higher.
Everything both sources publish — level, HP, DEF/MDEF, the six base stats,
race, size, element and element level — is compared, and any disagreement
fails the run without writing mobs.json, reporting both values and a link
to the monster's page. Neither source is silently preferred: a divergence means
either the crawler broke or the two databases genuinely differ, and only a human
can tell those apart. Acknowledge one by adding it to
tools/dp-divergences.json; the entry matches only
while both recorded values still hold, so a re-review is forced the moment either
source moves. attack is deliberately not compared — RagnaPlace returns the
database's raw attack and divine-pride renders the computed renewal range
(Baphomet: 2520 vs 2,721 - 3,981), so they are different quantities.
Across the full catalogue this found 82 disagreements on 39 of 2,710 monsters,
all reviewed and recorded. The largest cluster is instructive: RagnaPlace reports
propertyLevel: 1 for all 20 E_*/EVENT_* event-clone MVPs, while divine-pride
reports a varied value that — on the 13 clones whose base monster can be
identified — is exactly the base's, on which the two sources already agree. A
uniform 1 across clones whose bases span levels 1–4 is a default, not data, so
those 20 now publish divine-pride's value. That is the only pre-existing field the
second source changed.
A ledger entry may also carry "resistances": "unknown", which makes that monster
publish res/mres as null rather than divine-pride's number. It exists for
the four records where divine-pride's page is a dummy (level 1, 10 HP) describing
something other than the monster RagnaPlace has real data for: its 0 there is
not a measurement, and publishing it would be the very mistake this source fixes.
A monster page renders one stat table per server/episode, each in a
<div class="alternatestats" id="alternatestats_<SOURCE>">. LATAM is
alternatestats_default, and it is not the first — on Poring the order is
iRO, kRO, twRO, vnRO, then default. The blocks disagree (iRO's Poring has 60 HP
and 13–16 attack; LATAM's has 55 and 7–8), so a positional selector yields another
server's monster, plausibly and silently. The crawler selects by id and raises a
hard error if the block is missing rather than falling back.
Three further states all mean unknown and must not be mistaken for a broken
page — or for zero. An unknown id answers HTTP 200 with a "Monster not found"
page, not a 404. A monster the site lists but has no numbers for renders every
cell as ? with the title "We don't have this yet" and ships no per-server blocks
at all (25239 C4_SASQUATCH). And ten monsters get a stat block that is simply
blank — level 0, 0 HP, every stat 0 — including six Byalan mobs with perfectly
good RagnaPlace records, so taking those zeros at face value would assert "no
resistance" on the strength of an empty page. Note that 0 HP alone is not the
test: 1210 and the twelve Agni/Varuna/Vayu/Chandra spirits carry 0 HP with a real
level, and RagnaPlace independently agrees.
No divine-pride HTML is checked in. Copies of their page source would put
their markup into this MIT-licensed repo, so the fixtures in
tools/crawl-divine-pride.test.mjs are
written: page() and statBlock() emit only the structure the parser keys on.
The stat numbers in them are game facts, which is a separate thing from
divine-pride's expression of those facts.
Written fixtures can only encode what we believe the markup is, so the same file carries four opt-in live checks:
DP_LIVE=1 node --test tools/crawl-divine-pride.test.mjs
They fetch four real pages and assert the written fixtures still describe
reality — including that alternatestats_default is still not first and still
disagrees with the first block, the assumption the whole parser rests on. Plain
node --test skips them and stays hermetic. Run the live pass whenever you touch
the parser, or before trusting a crawl after a long gap.
This file used to be committed at the repo root and consumers fetched it from
raw.githubusercontent.com. It now lives in the gitignored resources/raw/
alongside the extracted tables and is served at /raw/mobs.json, so there is one
delivery path for all of it. Regenerating it costs a full rate-limited walk of the
API, so keep the deployed copy — it is not recoverable from a checkout.
internal/render engine is ported
from. All the hard-won RO sprite knowledge (formats, layering, head direction)
is theirs; please star and support it.adsonpleal/ragreplaystats./raw/mobs.json (level, HP, EXP, DEF/MDEF/ATK, base
stats, race, size, element, boss/MVP flags) come from
RagnaPlace, via their
Public API — thanks to them for
compiling and publishing per-server RO database data, and for offering a proper
keyed API for it. That table is the only data here sourced from them;
everything else under /raw is extracted from the client GRF. If you find it
useful, visit and support the site.res / mres resistances in /raw/mobs.json come from
divine-pride.net, which is the only public
source that publishes them per server. The crawler fetches one page per monster,
serialised and cached, and takes nothing else from the site. Thanks to them for
maintaining it — please visit and support the site.This project is licensed under the MIT License — do whatever you want with it.
skinTone |
1–4 (or default) |
Fan-made — see below. 1 (the default) is the untouched original; 2–4 are progressively deeper tones. |
skinColor | hex RRGGBB | Fan-made — see below. A custom skin colour, e.g. skinColor=8a5a3b. A full 8-step ramp with the sprite's own shading is generated from it, not a flat fill. Mutually exclusive with skinTone. |
headdir | straight/left/right/all or 0/1/2/3 | Default all. For stand/sit, straight/left/right pin the head to that facing across the whole animation (the body still animates); all cycles the head through directions. |
madogearType | robot/suit or 0/2 |
enableShadow | boolean | true/false. |
canvas | string | WxH±X±Y, e.g. canvas=200x200+75+175. |
outputFormat | png/zip or 0/1 | Default png. zip returns a ZIP of PNGs. |
.actGET /effect/table |
effectId → [{type, file, min?, wav?, attachedEntity?, rand?, …}] — roBrowserLegacy's EffectTable. type is STR for the served (.str) effects; 2D/3D/SPR/CYLINDER/FUNC parts are procedural (client-only) and carry only metadata. The wav field is the sound name for /effect/sound above (both visual rows and sound-only rows carry it). |