Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6a20983
RagnarokOnline: layer toggles, fog/light/sky overrides, music picker
malvarezcastillo May 25, 2026
e58042d
RagnarokOnline: add Show Warp Portals toggle
malvarezcastillo May 25, 2026
09f770a
RagnarokOnline: restore Music panel + smart BGM labels
malvarezcastillo May 25, 2026
3b90574
RagnarokOnline: drop color/BGM pickers, collapse panels
malvarezcastillo May 25, 2026
33cf698
RagnarokOnline: simplify mapnametable decode to CP949
malvarezcastillo May 25, 2026
704489d
RagnarokOnline: replace em-dashes in labels and comments
malvarezcastillo May 25, 2026
c2a91b1
RagnarokOnline: lowercase staged texture and model paths for case-sen…
malvarezcastillo May 25, 2026
8563e8a
RagnarokOnline: collapse @classic scene entries into a live era toggle
malvarezcastillo May 25, 2026
8594788
RagnarokOnline: cache decoded textures, model meshes, and water frame…
malvarezcastillo May 25, 2026
a2696e4
RagnarokOnline: pack lightmap tiles into a 2D atlas
malvarezcastillo May 25, 2026
f2b1c12
RagnarokOnline: add 22 pre-renewal scene entries for geometry-rebuilt…
malvarezcastillo May 25, 2026
0ceef88
RagnarokOnline: rename regen-maps to gen-maps
malvarezcastillo May 25, 2026
42eac18
RagnarokOnline: drop dead map-manifest code from extract
malvarezcastillo May 25, 2026
91cea12
RagnarokOnline: add Event Horizon GRF reader + asset top-up tool
malvarezcastillo May 25, 2026
44477ff
RagnarokOnline: post-review cleanup
malvarezcastillo May 25, 2026
c77e119
Merge remote-tracking branch 'upstream/main' into ragnarok-follow-up
malvarezcastillo May 25, 2026
2907364
RagnarokOnline: blend GND corner colors across tile boundaries
malvarezcastillo May 25, 2026
6d5ff47
RagnarokOnline: unify offline extraction pipeline into a single script
malvarezcastillo May 25, 2026
58f2815
RagnarokOnline: roll Python iro-grf into extract.ts --bulk-extract
malvarezcastillo May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/RagnarokOnline/coord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { GndMap } from "./gnd.js";
import { GatMap } from "./gat.js";

// RO's world is left-handed D3D9 (Y-down heights); noclip is right-handed
// Y-up. We negate Y and mirror X about the map centre — picking the centre
// Y-up. We negate Y and mirror X about the map centre. Picking the centre
// over a plain `-x` keeps the [0,0] corner at world origin instead of at
// -worldWidth. The terrain mesh (render.ts) and model placement matrix
// (model.ts) apply the same flip.
Expand Down
30 changes: 18 additions & 12 deletions src/RagnarokOnline/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Entity layer: places NPCs and monsters from the per-map manifest as animated,
// grounded billboards. NPCs are static; monsters wander via the ported FindPath
// on the GAT walkability grid (target selection and idle cadence are our own
// synthesis the real client only displayed server-streamed positions).
// synthesis; the real client only displayed server-streamed positions).

import { vec3 } from "gl-matrix";
import { DataFetcher } from "../DataFetcher.js";
Expand All @@ -11,9 +11,10 @@ import { GatMap, isWalkable } from "./gat.js";
import { findPath, PathStep } from "./pathfinder.js";
import { parseSPR, SprModel } from "./spr.js";
import { parseACT, ActModel } from "./act.js";
import { SpriteActor, computeActorFootPxY } from "./sprite.js";
import { SpriteActor, computeActorFootPxY, SpriteKind } from "./sprite.js";
import { gatCellToWorld, gatCellGroundHeight, gatCellSurfaceHeight } from "./coord.js";
import { RswEffectSource } from "./rsw.js";
import { Era } from "./era.js";

// A zero area (cellX=cellY=spanX=spanY=0) is a whole-map random spawn.
interface MobSpawn {
Expand Down Expand Up @@ -48,9 +49,6 @@ export interface WarpEntry {
// Arrival cell on the destination map. Older manifests omit these.
destX?: number;
destY?: number;
// Hint for resolving the destination's era when it has both classic and
// renewal variants. See era.ts:resolveWarpDest.
destEra?: "classic" | "renewal";
}

interface EntityManifest {
Expand All @@ -74,6 +72,7 @@ export interface EntityPlacement {
// "feet" (default, NPCs stand on the ground) or "center" (effect sprites
// are authored around their emit point).
anchor?: "feet" | "center";
kind?: SpriteKind;
}

export interface EntitySceneData {
Expand Down Expand Up @@ -503,16 +502,22 @@ function randomWalkableInRect(gat: GatMap, x0: number, y0: number, x1: number, y
return null;
}

export async function loadEntities(dataFetcher: DataFetcher, pathBase: string, mapId: string, gnd: GndMap, gat: GatMap | null): Promise<EntitySceneData> {
export async function loadEntities(dataFetcher: DataFetcher, pathBase: string, mapId: string, era: Era, gnd: GndMap, gat: GatMap | null): Promise<EntitySceneData> {
const empty: EntitySceneData = { sprites: [], placements: [], mobs: [], warps: [] };

let manifest: EntityManifest;
try {
const raw = await dataFetcher.fetchData(`${pathBase}/entities/${mapId}.json`, { allow404: true });
manifest = JSON.parse(new TextDecoder().decode(raw.createTypedArray(Uint8Array))) as EntityManifest;
} catch {
const tryFetch = async (name: string): Promise<EntityManifest | null> => {
const raw = await dataFetcher.fetchData(`${pathBase}/entities/${name}.json`, { allow404: true });
if (raw.byteLength === 0) return null;
try {
return JSON.parse(new TextDecoder().decode(raw.createTypedArray(Uint8Array))) as EntityManifest;
} catch {
return null;
}
};

const manifest = (await tryFetch(`${mapId}@${era}`)) ?? (await tryFetch(mapId));
if (manifest === null)
return empty;
}

const npcs = manifest.npcs ?? [];
const mobSpawns = manifest.mobs ?? [];
Expand Down Expand Up @@ -665,6 +670,7 @@ export async function loadEffectSources(
worldPos: [mapOffX - e.pos.x, -e.pos.y, e.pos.z + mapOffZ],
name: "",
anchor: "center",
kind: "effect",
});
}

Expand Down
68 changes: 18 additions & 50 deletions src/RagnarokOnline/era.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,28 @@

// Era-aware destination resolution for warps. Maps can exist as `<base>@<era>`
// variants (e.g. `geffen@classic`, `geffen@renewal`); the bare id is the
// primary-era alias.
// Runtime era selector. Scene ids are bare; classic and renewal differ only in
// the entity manifest fetched at load time.

import { DataFetcher, NamedArrayBufferSlice } from "../DataFetcher.js";
import { maps } from "./maps.js";
import { Era, parseEra, PRIMARY_ERA } from "./mapcategory.js";
export type Era = "classic" | "renewal";

const eraAwareBases: Set<string> = new Set();
const eraQualifiedIds: Set<string> = new Set();
for (const m of maps) {
if (m.era !== undefined) {
eraAwareBases.add(parseEra(m.id).base);
eraQualifiedIds.add(m.id);
}
}

export function hasEraVariants(baseId: string): boolean {
return eraAwareBases.has(baseId);
}
const URL_KEY = "era";

export function resolveWarpDest(rawDest: string, destEra: Era | undefined, sourceMapEra: Era): string {
const parsed = parseEra(rawDest);
if (parsed.era !== null)
return rawDest;
if (!eraAwareBases.has(parsed.base))
return rawDest;
// Fall through to the bare id when the requested era variant isn't registered —
// the bare id IS that era's alias, so rewriting would 404.
const qualified = `${parsed.base}@${destEra ?? sourceMapEra}`;
return eraQualifiedIds.has(qualified) ? qualified : rawDest;
function readFromUrl(): Era {
if (typeof window === "undefined") return "renewal";
return new URLSearchParams(window.location.search).get(URL_KEY) === "classic" ? "classic" : "renewal";
}

export function eraSharedKey(id: string): string {
return parseEra(id).base;
}

export function eraSuffix(id: string): string {
const e = parseEra(id).era;
return e === null ? "" : `@${e}`;
}
let era: Era = readFromUrl();

export function eraOf(id: string): Era {
return parseEra(id).era ?? PRIMARY_ERA;
export function currentEra(): Era {
return era;
}

// Tries the era-qualified asset first, falls back to the bare path on 404.
// Era variants that don't ship distinct geometry share the bare files.
export async function fetchEraOrBare(dataFetcher: DataFetcher, baseUrl: string, id: string, ext: string): Promise<NamedArrayBufferSlice> {
const base = parseEra(id).base;
const era = eraSuffix(id);
if (era !== "") {
const eraTry = await dataFetcher.fetchData(`${baseUrl}/${base}${era}${ext}`, { allow404: true });
// allow404 resolves as a zero-byte slice rather than null.
if (eraTry.byteLength > 0)
return eraTry;
}
return dataFetcher.fetchData(`${baseUrl}/${base}${ext}`);
export function setEra(next: Era): void {
if (next === era) return;
era = next;
if (typeof window === "undefined") return;
const u = new URL(window.location.href);
if (next === "renewal") u.searchParams.delete(URL_KEY);
else u.searchParams.set(URL_KEY, next);
window.history.replaceState(null, "", u.toString());
}
5 changes: 3 additions & 2 deletions src/RagnarokOnline/gnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import ArrayBufferSlice from "../ArrayBufferSlice.js";
import { readString } from "../util.js";

// Normalises a GND texture path (backslash-separated CP949) to a URL relative
// to the textures root. Mirrors what the extractor writes.
// to the textures root. Lowercased to match the case-sensitive CDN; the
// extractor lowercases destination paths in the same way.
export function textureNameToUrl(name: string): string {
return name.split("\\").map(encodeURIComponent).join("/");
return name.toLowerCase().split("\\").map(encodeURIComponent).join("/");
}

export interface GndSurface {
Expand Down
2 changes: 1 addition & 1 deletion src/RagnarokOnline/granny-anim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { GrannyAnimation, GrannyBone, GrannyCurve, GrannySkeleton, GrannyTransfo
// WoE models top out at 43 bones; 64 leaves headroom.
export const GRANNY_MAX_BONES = 64;

// Hitch cap — a long pause shouldn't fast-forward the clip.
// Hitch cap. A long pause shouldn't fast-forward the clip.
const MAX_DT = 0.25;

// RO clips are dense enough that piecewise-linear is visually indistinguishable
Expand Down
2 changes: 1 addition & 1 deletion src/RagnarokOnline/granny-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ void main() {

// Granny actors use a fixed bright directional light (not the RSW ambient/
// diffuse), so a glowing prop like the gold Emperium stays vivid at night.
// RO's per-vertex shade is mix(0.5, 1.0, clamp(dot(N, sunDir), 0, 1)) a
// RO's per-vertex shade is mix(0.5, 1.0, clamp(dot(N, sunDir), 0, 1)): a
// wrapped half-Lambert with the floor as shadow brightness.
public override frag = `
const float GRANNY_LIGHT_FLOOR = 0.5;
Expand Down
6 changes: 3 additions & 3 deletions src/RagnarokOnline/granny.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
// fixup map rewrites stored pointers. A self-describing type tree is walked in
// lockstep with root data to pull out typed members.
//
// 32-bit LE only RO ships no 64-bit or BE variants.
// 32-bit LE only. RO ships no 64-bit or BE variants.
//
// Compression: this parser only handles NoCompression sections. RO .gr2 ship
// with Granny compression type 1 (Oodle0), which has no open decoder, so the
// offline tool `tools/gr2_decompress.c` (wine + granny2.dll) expands every
// section to NoCompression at extraction time. Both Oodle0 and Oodle1 sections
// throw at runtime they shouldn't appear in baked files.
// throw at runtime; they shouldn't appear in baked files.

import ArrayBufferSlice from "../ArrayBufferSlice.js";
import { assertExists, readString } from "../util.js";
Expand Down Expand Up @@ -762,7 +762,7 @@ function extractSkeleton(gr: GrannyFile, skTypeAbs: number, skDataAbs: number):
return { name, bones };
}

// Only handles inline Knots/Controls the form every RO baked .gr2 uses.
// Only handles inline Knots/Controls, the form every RO baked .gr2 uses.
// SDK files have a CurveData VariantReference indirection we don't need.
function extractCurve(gr: GrannyFile, curveTypeAbs: number, curveDataAbs: number, dimension: number): GrannyCurve | null {
const { view } = gr;
Expand Down
2 changes: 1 addition & 1 deletion src/RagnarokOnline/lights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface PointLight {

// Render-frame conversion: RSW X is centered in [-mapOffX, +mapOffX], so the
// corner-origin shift + mirror collapse to `mapOffX - pos.x`. (Using
// `worldWidth - pos.x` parks every light past the right edge — invisible.)
// `worldWidth - pos.x` would park every light past the right edge.)
export function loadPointLights(rsw: RswWorld, gnd: GndMap): PointLight[] {
const mapOffX = gnd.width * GND_CELL_SIZE * 0.5;
const mapOffZ = gnd.height * GND_CELL_SIZE * 0.5;
Expand Down
27 changes: 2 additions & 25 deletions src/RagnarokOnline/mapcategory.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,13 @@

// Classifies a map by its id. Consumed by the scene list (grouping), the sky
// renderer (dome enable + default sky tint), and the fog path (dungeons only).
// The category for each known map is baked into the manifest by regen-maps.ts;
// The category for each known map is baked into the manifest by extract.ts (Stage 5);
// the runtime is just a lookup. Ids outside the manifest fall back to "other".

import { maps } from "./maps.js";

export type MapCategory = "city" | "field" | "dungeon" | "indoor" | "castle" | "instance" | "other";

// Maps with both vintages (Geffen, Payon, Izlude) ship as `<base>@<era>` scenes;
// the bare id resolves to PRIMARY_ERA so existing URLs keep working.
export type Era = "classic" | "renewal";

export const PRIMARY_ERA: Era = "renewal";

// `geffen@classic` -> {base:"geffen", era:"classic"}; `geffen` -> {base, era:null}.
// Instance maps `1@gef`/`2@nyd` have `@` early; only trailing `@<era>` matches.
export function parseEra(id: string): { base: string, era: Era | null } {
const at = id.lastIndexOf("@");
if (at <= 0)
return { base: id, era: null };
const tail = id.substring(at + 1);
if (tail === "classic" || tail === "renewal")
return { base: id.substring(0, at), era: tail as Era };
return { base: id, era: null };
}

export function eraOfScene(id: string): Era {
return parseEra(id).era ?? PRIMARY_ERA;
}

const CATEGORY_BY_ID: Map<string, MapCategory> = new Map(maps.map((m) => [m.id, m.category]));

export function mapCategory(id: string): MapCategory {
Expand All @@ -50,7 +28,6 @@ const WEATHER_MAPS: Record<string, WeatherKind> = {
"xmas_fild01": "snow",
};

// Era-shared, so strip the era suffix before lookup.
export function mapWeather(id: string): WeatherKind | null {
return WEATHER_MAPS[parseEra(id).base] ?? null;
return WEATHER_MAPS[id] ?? null;
}
Loading