Website: Achievements

The game stores each character's achievement progress as one binary row. This guide shows how to read it and show it on the website: a character's achievement page (cards, tiers, progress, points) and a server-wide points ladder.

For what achievements are and how to configure them, see Achievements.

What you can show#

PageShows
Character profileEvery achievement as a card: name, text, icon, tier, progress bar, claimed / ready / in progress. Points total in the header
LadderCharacters sorted by achievement points
"Recently completed"Not possible: completion times are not stored anywhere

Where the data lives#

DataWhereWhat you get
ProgressSQL table AchievementProgress, column BlobOne counter and one "claimed" byte per achievement
Names, texts, tiers, pointsData/Plugins/Achievements/*.xmlEverything you print
IconsClient files Data/Interface/UI/atlas/achievements.png + achievements.xml48 × 48 icons
CREATE TABLE AchievementProgress (
    Name varchar(10)    PRIMARY KEY,   -- character name
    Blob varbinary(1254) NOT NULL
)

No row = the character has no progress yet.

Step 1 - Read the blob#

All numbers are little-endian (least significant byte first).

OffsetSizeField
01Version. Must be 1. 0 = empty row, no progress
11Reserved
22Count = number of entries (250)
45 × CountOne entry per achievement Id, in Id order (entry 0 = Id 0, entry 1 = Id 1, ...)

Each entry (5 bytes), at offset 4 + Id × 5:

OffsetSizeField
04Counter, signed 32-bit. Below 0 = treat as 0
41Claimed, bit mask. Bit 0 = tier 1 claimed, bit 1 = tier 2, ... bit 7 = tier 8

Example: Id 14 is at offset 4 + 14 × 5 = 74. Bytes 58 02 00 00 01 → Counter 600, tier 1 claimed.

Step 2 - Load the achievement files#

Load the folder the same way the GameServer does:

  1. Every *.xml in Data/Plugins/Achievements/, in alphabetical order.
  2. Skip Settings.xml and every file whose name starts with _.
  3. Each file has one <Category Id Name Order Icon> root.
  4. Each <Achievement Id Name Icon Text Enabled> inside it. Skip Enabled="0".
  5. Its <Tier Target Points> children, in file order. A tier whose Target is not bigger than the one before is skipped (the server skips it too). Max 8 tiers.

Keep the first achievement with a given Id; the server skips duplicates.

Parse once and cache. Reload when a file changes.

Step 3 - Work out each card#

For one achievement with tiers T[0..n-1], the character's Counter and Claimed:

ValueRule
Tier i reachedCounter >= T[i].Target
Tier i claimedClaimed & (1 << i) is not 0
Next targetTarget of the first tier not reached. None = all reached
Progress barCounter / next target (full when all reached)
Points earnedSum of Points of the claimed tiers

Card state:

StateWhenSuggested look
CompleteEvery tier claimedGold / done badge
Ready to claimA tier is reached but not claimedHighlight. Players claim in game (Window Menu U → Achievements)
In progressCounter > 0, nothing to claimNormal
Not startedCounter = 0Dimmed

The character's achievement points = sum of "points earned" over every achievement. That is the number the game shows in the window header. Max points = sum of every tier's Points.

Counters that keep the highest value: ReachLevel, Reset and ItemUpgrade achievements store the best value seen (level 350, 12 resets, +13), not a count. Show them as 350 / 400, the same as the others. PlayTime counts minutes.

Step 4 - Icons#

The game icons are in one image (achievements.png) with a map of where each icon is (achievements.xml):

<Atlas image="atlas/achievements">
    <Sprite name="ach/balgass" x="0" y="0" w="48" h="48"/>
    <Sprite name="ach/bloodcastle" x="50" y="0" w="48" h="48"/>

Copy achievements.png to the website and use CSS sprites. An achievement's Icon="ach/bloodcastle" → background-position: -50px 0. Missing icon → use ach/default, then an empty box (the game does the same).

.ach-icon { width: 48px; height: 48px; background: url(/img/achievements.png) no-repeat; }
<div class="ach-icon" style="background-position:-50px 0"></div>

Full code#

Uses mssql and fast-xml-parser (npm i fast-xml-parser).

// achievements.ts
import fs from "fs";
import path from "path";
import sql from "mssql";
import { XMLParser } from "fast-xml-parser";

const ACH_DIR = "C:/MuServer/Data/Plugins/Achievements";   // your server's path

// ---------- Step 1: blob ----------
export interface AchProgress { counter: number; claimed: number }

export function readProgress(blob: Buffer | null | undefined): Map<number, AchProgress> {
  const out = new Map<number, AchProgress>();
  if (!blob || blob.length < 4 || blob[0] !== 1) return out;   // empty row
  const count = Math.min(blob.readUInt16LE(2), 250);
  for (let id = 0; id < count; id++) {
    const p = 4 + id * 5;
    if (p + 5 > blob.length) break;
    const counter = Math.max(0, blob.readInt32LE(p));
    const claimed = blob[p + 4];
    if (counter !== 0 || claimed !== 0) out.set(id, { counter, claimed });
  }
  return out;
}

// ---------- Step 2: config ----------
export interface AchTier { target: number; points: number }
export interface AchDef { id: number; category: number; name: string; icon: string; text: string; tiers: AchTier[] }
export interface AchCategory { id: number; name: string; order: number; icon: string }

let cache: { stamp: string; categories: AchCategory[]; defs: AchDef[] } | null = null;
const asArray = <T>(v: T | T[] | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);

export function loadAchievements() {
  const files = fs.readdirSync(ACH_DIR)
    .filter((f) => f.toLowerCase().endsWith(".xml") && !f.startsWith("_") && f.toLowerCase() !== "settings.xml")
    .sort();
  const stamp = files.map((f) => `${f}:${fs.statSync(path.join(ACH_DIR, f)).mtimeMs}`).join("|");
  if (cache && cache.stamp === stamp) return cache;

  const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", parseAttributeValue: false });
  const categories: AchCategory[] = [];
  const defs: AchDef[] = [];
  const seen = new Set<number>();

  for (const f of files) {
    const cat = parser.parse(fs.readFileSync(path.join(ACH_DIR, f), "utf8")).Category;
    const cid = parseInt(cat?.Id, 10);
    if (!(cid >= 1 && cid <= 16) || categories.some((c) => c.id === cid)) continue;
    categories.push({ id: cid, name: String(cat.Name ?? "Category").slice(0, 31), order: parseInt(cat.Order ?? cid, 10), icon: String(cat.Icon ?? "") });

    for (const a of asArray<any>(cat.Achievement)) {
      const id = parseInt(a.Id, 10);
      if (!(id >= 0 && id <= 249) || String(a.Enabled ?? "1") === "0" || seen.has(id)) continue;
      const tiers: AchTier[] = [];
      for (const t of asArray<any>(a.Tier)) {
        if (tiers.length >= 8) break;
        const target = Math.max(1, parseInt(t.Target ?? "1", 10) || 1);
        if (tiers.length > 0 && target <= tiers[tiers.length - 1].target) continue;   // must ascend
        tiers.push({ target, points: Math.min(65535, Math.max(0, parseInt(t.Points ?? "0", 10) || 0)) });
      }
      if (tiers.length === 0) continue;
      seen.add(id);
      defs.push({ id, category: cid, name: String(a.Name ?? "").slice(0, 31), icon: String(a.Icon ?? ""), text: String(a.Text ?? "").slice(0, 127), tiers });
    }
  }
  categories.sort((x, y) => x.order - y.order);
  cache = { stamp, categories, defs };
  return cache;
}

// ---------- Step 3: cards ----------
export type AchState = "complete" | "claimable" | "progress" | "new";
export interface AchCard {
  def: AchDef; counter: number; reached: number; claimedTiers: boolean[];
  nextTarget: number | null; percent: number; points: number; state: AchState;
}

export function buildCards(progress: Map<number, AchProgress>) {
  const { categories, defs } = loadAchievements();
  let points = 0, maxPoints = 0;

  const cards: AchCard[] = defs.map((def) => {
    const p = progress.get(def.id) ?? { counter: 0, claimed: 0 };
    const claimedTiers = def.tiers.map((_, i) => (p.claimed & (1 << i)) !== 0);
    const reached = def.tiers.filter((t) => p.counter >= t.target).length;
    const next = def.tiers.find((t) => p.counter < t.target) ?? null;
    const earned = def.tiers.reduce((s, t, i) => s + (claimedTiers[i] ? t.points : 0), 0);
    points += earned;
    maxPoints += def.tiers.reduce((s, t) => s + t.points, 0);

    let state: AchState = "new";
    if (claimedTiers.every(Boolean)) state = "complete";
    else if (def.tiers.some((t, i) => p.counter >= t.target && !claimedTiers[i])) state = "claimable";
    else if (p.counter > 0) state = "progress";

    return {
      def, counter: p.counter, reached, claimedTiers,
      nextTarget: next ? next.target : null,
      percent: next ? Math.min(100, Math.floor((p.counter * 100) / next.target)) : 100,
      points: earned, state,
    };
  });

  return { categories, cards, points, maxPoints };
}

// ---------- Character page ----------
export async function characterAchievements(pool: sql.ConnectionPool, name: string) {
  const r = await pool.request()
    .input("name", sql.VarChar(10), name)
    .query("SELECT Blob FROM AchievementProgress WHERE Name = @name");
  return buildCards(readProgress(r.recordset[0]?.Blob));
}

// ---------- Ladder (cache the result for a few minutes) ----------
export async function achievementLadder(pool: sql.ConnectionPool, top = 100) {
  const r = await pool.request().query(
    `SELECT a.Name, a.Blob, c.Class, c.cLevel
       FROM AchievementProgress a
       JOIN Character c ON c.Name = a.Name`);   // drops rows of deleted characters
  return r.recordset
    .map((row) => ({ name: row.Name, cls: row.Class, level: row.cLevel, points: buildCards(readProgress(row.Blob)).points }))
    .filter((x) => x.points > 0)
    .sort((a, b) => b.points - a.points || a.name.localeCompare(b.name))
    .slice(0, top);
}

Rendering a card (React example):

<div className={`ach-card ach-${card.state}`}>
  <div className="ach-icon" style={iconStyle(card.def.icon)} />
  <b>{card.def.name}</b>
  <span>Tier {card.reached} / {card.def.tiers.length}</span>
  <p>{card.def.text}</p>
  <progress value={card.percent} max={100} />
  <small>{card.nextTarget === null ? "Done" : `${card.counter} / ${card.nextTarget}`}</small>
</div>

Group the cards by card.def.category and show the categories as tabs, in order, like the game.

Worked example#

02 - Progression.xml has (rewards left out):

<Achievement Id="14" Name="Devoted" Icon="ach/playtime" Text="Spend time in the world (minutes online).">
  <Trigger Type="PlayTime"/>
  <Tier Target="60"   Points="5"/>
  <Tier Target="600"  Points="10"/>
  <Tier Target="6000" Points="25"/>
</Achievement>

Blob bytes 74-78 (Id 14): 58 02 00 00 01.

Counter0x00000258 = 600
Claimed0x01 = tier 1 claimed
Reachedtiers 1 and 2 (600 ≥ 60, 600 ≥ 600)
Next target6000 → bar at 10%
Points earned5 (only tier 1 claimed)
StateReady to claim (tier 2 reached, not claimed)

Important Behavior#

  • Data delay. Counters are saved every AutosaveSec seconds (default 180) and on logout. Tier completions and claims save at once. So an online player's progress bars can be up to 3 minutes behind; claimed tiers and points are always current.
  • Rows by character name. A character created after the plugin was enabled has no row until it first logs in.
  • Deleted characters keep their row. Join with Character (as the ladder code does) so they do not show up.
  • Plugin off or unlicensed. The rows stay, the game shows nothing. The website cannot see the license; if the paid module is not licensed, do not show the pages.
  • Removed achievement. Its progress stays in the blob but there is no definition, so it is not shown. Never reuse an Id for a different achievement.
  • Rewards are not needed on the website. Players claim in game; the site only shows status.

Common Issues#

Last updated October 1, 2026