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#
| Page | Shows |
|---|---|
| Character profile | Every achievement as a card: name, text, icon, tier, progress bar, claimed / ready / in progress. Points total in the header |
| Ladder | Characters sorted by achievement points |
| "Recently completed" | Not possible: completion times are not stored anywhere |
Where the data lives#
| Data | Where | What you get |
|---|---|---|
| Progress | SQL table AchievementProgress, column Blob | One counter and one "claimed" byte per achievement |
| Names, texts, tiers, points | Data/Plugins/Achievements/*.xml | Everything you print |
| Icons | Client files Data/Interface/UI/atlas/achievements.png + achievements.xml | 48 × 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).
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | Version. Must be 1. 0 = empty row, no progress |
| 1 | 1 | Reserved |
| 2 | 2 | Count = number of entries (250) |
| 4 | 5 × Count | One entry per achievement Id, in Id order (entry 0 = Id 0, entry 1 = Id 1, ...) |
Each entry (5 bytes), at offset 4 + Id × 5:
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Counter, signed 32-bit. Below 0 = treat as 0 |
| 4 | 1 | Claimed, 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:
- Every
*.xmlinData/Plugins/Achievements/, in alphabetical order. - Skip
Settings.xmland every file whose name starts with_. - Each file has one
<Category Id Name Order Icon>root. - Each
<Achievement Id Name Icon Text Enabled>inside it. SkipEnabled="0". - Its
<Tier Target Points>children, in file order. A tier whoseTargetis 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:
| Value | Rule |
|---|---|
Tier i reached | Counter >= T[i].Target |
Tier i claimed | Claimed & (1 << i) is not 0 |
| Next target | Target of the first tier not reached. None = all reached |
| Progress bar | Counter / next target (full when all reached) |
| Points earned | Sum of Points of the claimed tiers |
Card state:
| State | When | Suggested look |
|---|---|---|
| Complete | Every tier claimed | Gold / done badge |
| Ready to claim | A tier is reached but not claimed | Highlight. Players claim in game (Window Menu U → Achievements) |
| In progress | Counter > 0, nothing to claim | Normal |
| Not started | Counter = 0 | Dimmed |
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);
}
Uses PDO (sqlsrv) and SimpleXML.
<?php
// achievements.php
const ACH_DIR = 'C:/MuServer/Data/Plugins/Achievements'; // your server's path
// ---------- Step 1: blob ----------
function readAchProgress(?string $blob): array {
$out = [];
if ($blob === null || strlen($blob) < 4 || ord($blob[0]) !== 1) return $out; // empty row
$count = min(unpack('v', substr($blob, 2, 2))[1], 250);
for ($id = 0; $id < $count; $id++) {
$p = 4 + $id * 5;
if ($p + 5 > strlen($blob)) break;
$counter = unpack('V', substr($blob, $p, 4))[1]; // little-endian unsigned
if ($counter >= 0x80000000) $counter = 0; // negative as signed = 0
$claimed = ord($blob[$p + 4]);
if ($counter !== 0 || $claimed !== 0) $out[$id] = ['counter' => $counter, 'claimed' => $claimed];
}
return $out;
}
// ---------- Step 2: config ----------
function loadAchievements(): array {
static $cache = null;
$files = array_filter(scandir(ACH_DIR), fn($f) =>
strtolower(pathinfo($f, PATHINFO_EXTENSION)) === 'xml' && $f[0] !== '_' && strtolower($f) !== 'settings.xml');
sort($files);
clearstatcache();
$stamp = implode('|', array_map(fn($f) => $f . ':' . filemtime(ACH_DIR . "/$f"), $files));
if ($cache !== null && $cache['stamp'] === $stamp) return $cache;
$categories = [];
$defs = [];
foreach ($files as $f) {
$cat = simplexml_load_file(ACH_DIR . "/$f");
if ($cat === false || $cat->getName() !== 'Category') continue;
$cid = (int)$cat['Id'];
if ($cid < 1 || $cid > 16 || isset($categories[$cid])) continue;
$categories[$cid] = ['id' => $cid, 'name' => substr((string)($cat['Name'] ?? 'Category'), 0, 31),
'order' => isset($cat['Order']) ? (int)$cat['Order'] : $cid, 'icon' => (string)$cat['Icon']];
foreach ($cat->Achievement as $a) {
$id = (int)$a['Id'];
if ($id < 0 || $id > 249 || (string)$a['Enabled'] === '0' || isset($defs[$id])) continue;
$tiers = [];
foreach ($a->Tier as $t) {
if (count($tiers) >= 8) break;
$target = max(1, isset($t['Target']) ? (int)$t['Target'] : 1);
if ($tiers && $target <= end($tiers)['target']) continue; // must ascend
$tiers[] = ['target' => $target, 'points' => min(65535, max(0, (int)$t['Points']))];
}
if (!$tiers) continue;
$defs[$id] = ['id' => $id, 'category' => $cid, 'name' => substr((string)$a['Name'], 0, 31),
'icon' => (string)$a['Icon'], 'text' => substr((string)$a['Text'], 0, 127), 'tiers' => $tiers];
}
}
uasort($categories, fn($x, $y) => $x['order'] <=> $y['order']);
return $cache = ['stamp' => $stamp, 'categories' => array_values($categories), 'defs' => array_values($defs)];
}
// ---------- Step 3: cards ----------
function buildAchCards(array $progress): array {
$cfg = loadAchievements();
$points = 0;
$maxPoints = 0;
$cards = [];
foreach ($cfg['defs'] as $def) {
$p = $progress[$def['id']] ?? ['counter' => 0, 'claimed' => 0];
$earned = 0;
$reached = 0;
$next = null;
$allClaimed = true;
$claimable = false;
foreach ($def['tiers'] as $i => $t) {
$isClaimed = ($p['claimed'] & (1 << $i)) !== 0;
$isReached = $p['counter'] >= $t['target'];
if ($isClaimed) $earned += $t['points'];
if ($isReached) $reached++;
elseif ($next === null) $next = $t['target'];
if (!$isClaimed) $allClaimed = false;
if ($isReached && !$isClaimed) $claimable = true;
$maxPoints += $t['points'];
}
$points += $earned;
$state = $allClaimed ? 'complete' : ($claimable ? 'claimable' : ($p['counter'] > 0 ? 'progress' : 'new'));
$cards[] = [
'def' => $def, 'counter' => $p['counter'], 'reached' => $reached, 'nextTarget' => $next,
'percent' => $next === null ? 100 : min(100, intdiv($p['counter'] * 100, $next)),
'points' => $earned, 'state' => $state,
];
}
return ['categories' => $cfg['categories'], 'cards' => $cards, 'points' => $points, 'maxPoints' => $maxPoints];
}
// ---------- Character page ----------
function characterAchievements(PDO $db, string $name): array {
$st = $db->prepare('SELECT Blob FROM AchievementProgress WHERE Name = ?');
$st->execute([$name]);
$st->bindColumn(1, $blob, PDO::PARAM_LOB, 0, PDO::SQLSRV_ENCODING_BINARY);
$found = $st->fetch(PDO::FETCH_BOUND);
if ($found && is_resource($blob)) $blob = stream_get_contents($blob);
return buildAchCards(readAchProgress($found ? $blob : null));
}
// ---------- Ladder (cache the result for a few minutes) ----------
function achievementLadder(PDO $db, int $top = 100): array {
$st = $db->query('SELECT a.Name, a.Blob, c.Class, c.cLevel FROM AchievementProgress a JOIN Character c ON c.Name = a.Name');
$st->bindColumn(1, $name);
$st->bindColumn(2, $blob, PDO::PARAM_LOB, 0, PDO::SQLSRV_ENCODING_BINARY);
$st->bindColumn(3, $class);
$st->bindColumn(4, $level);
$rows = [];
while ($st->fetch(PDO::FETCH_BOUND)) {
$data = is_resource($blob) ? stream_get_contents($blob) : $blob;
$pts = buildAchCards(readAchProgress($data))['points'];
if ($pts > 0) $rows[] = ['name' => $name, 'class' => (int)$class, 'level' => (int)$level, 'points' => $pts];
}
usort($rows, fn($a, $b) => $b['points'] <=> $a['points'] ?: strcmp($a['name'], $b['name']));
return array_slice($rows, 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.
| Counter | 0x00000258 = 600 |
| Claimed | 0x01 = tier 1 claimed |
| Reached | tiers 1 and 2 (600 ≥ 60, 600 ≥ 600) |
| Next target | 6000 → bar at 10% |
| Points earned | 5 (only tier 1 claimed) |
| State | Ready to claim (tier 2 reached, not claimed) |
Important Behavior#
- Data delay. Counters are saved every
AutosaveSecseconds (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
Idfor a different achievement. - Rewards are not needed on the website. Players claim in game; the site only shows status.
Common Issues#
Related pages#
- Achievements - triggers, tiers, rewards, settings