-
Notifications
You must be signed in to change notification settings - Fork 0
RetroAchievements
Emutastic supports RetroAchievements using the rcheevos C library (rc_client API) via P/Invoke. On Linux, rcheevos is built from source (vendored at native/rcheevos-src, v11.6.0) into librcheevos.so, which ships next to the binary — there is no prebuilt DLL to place. RAIntegration is N/A on Linux (it's a Windows-only toolkit); the in-game achievement surface is drawn by our own GL overlay instead.
Everything RA shows during play is rendered by the game-host's GL overlay (Platform/GlOsd.cs):
-
Unlock toasts — slide-in notifications when you earn an achievement. Fully themeable via Preferences → Achievements → Toast Appearance (colors, layout, badge). The editor's preview replica and the live in-game toast read the same
AchievementToastStyle. - Challenge-indicator badges — a primed "do X without failing" achievement shows its badge in a row bottom-right above the status bar, for as long as rcheevos keeps it primed.
-
Measured-progress tracker — a transient top-right pill (e.g.
47/100+ badge) while rcheevos reports measured progress. The Linux build renders this in-game; the Windows app does not yet.
Hardcore unlocks are not yet officially supported. RetroAchievements requires an emulator to be publicly available for at least 6 months before its application for hardcore approval will even be considered.
What this means for users:
- The Hardcore toggle in Preferences → RetroAchievements still works locally — save states / rewind / slow-motion are disabled when it's on, mirroring the rule set RA enforces.
- With Hardcore Mode on, RA pops an "Unknown emulator" toast at game start. This is expected, not a bug — until Emutastic is on RA's approved-emulator list (earliest application Oct 14, 2026), the server doesn't recognize it as an approved hardcore client. Achievements still identify and unlock during play. If you'd rather not see the message, turn Hardcore Mode off (Preferences → Achievements), and we'll announce when approval lands.
- We send a proper
Emutastic/<version> (<OS>)User-Agent so the server can identify the client, but identification alone isn't approval — the formal review from RAdmin / SnowPin is what flips the switch.
Once the 6-month gate clears we'll submit the formal application. See the Hardcore Compliance page for the line-by-line audit of how Emutastic implements each requirement on RA's checklist.
The code-side blockers are closed: save-state loading and cheats are gated in hardcore, the user-agent is unique and well-formed, and a persistent HARDCORE indicator shows during play. Remaining items before application are documentation-side (privacy policy page, FOSS core listing).
Emutastic bundles libchdr (BSD 3-Clause) so RetroAchievements can identify CHD files directly. No conversion to .cue+.bin is required for hashing on any CD-based console Emutastic supports — PlayStation, Saturn, Sega CD, Dreamcast, PSP, TurboGrafx-CD, 3DO, and Neo Geo CD.
Attribution for libchdr ships in NOTICES.txt next to the executable.
How it works: rcheevos has always known how to hash CHD content (hash.c's CHD iterator lists the nine consoles above) but doesn't bundle the format reader — frontends are expected to provide one via rc_client_set_hash_callbacks. Emutastic provides a libchdr-backed cdreader bridge so the bytes rcheevos needs (track metadata, sector data) get decompressed from CHDs on demand. RetroArch gets the same capability via its own code path.
CD-based achievement testing is ongoing. The following gaps are tracked:
| Console | Format | Status | Workaround |
|---|---|---|---|
| PlayStation | .chd |
Validated end-to-end | — |
| TurboGrafx-CD / PC Engine CD |
.chd / .cue+.bin
|
Validated end-to-end — see TurboGrafx-CD | — |
| Neo Geo CD |
.chd / .cue+.bin
|
Identifies + fires via descriptor-aware reading; see Neo Geo | — |
| Dreamcast | .chd |
Validated end-to-end — see Dreamcast | — |
| Sega Saturn |
.chd / .cue+.bin
|
Validated end-to-end — see Sega Saturn | — |
| Sega CD |
.chd / .cue+.bin
|
Validated end-to-end | — |
| PSP |
.chd / .iso / .cso / .pbp
|
Validated end-to-end | DVD-format CHDs (chdman createdvd) now read correctly |
| 3DO |
.chd / .cue+.bin
|
Identifies cleanly; authored sets are sparse on RA's side for this platform | — |
| GameCube | .iso |
Validated end-to-end |
.rvz not supported by rcheevos; convert via dolphin-tool convert -i game.rvz -o game.iso -f iso
|
Getting CHD-format CDs to identify reliably across consoles required handling several layout details that aren't obvious from chdman's metadata. The findings below are baked into Services/RcheevosChdCdReader.cs and apply to every CD-based console that goes through the libchdr bridge.
1. Per-mode sector geometry
chdman stores raw CD sectors (2352 bytes, optionally + 96 bytes subchannel = 2448 per unit slot) regardless of the track-type label. But the cooked data offset within each slot depends on the track type:
| Track type | Header skip | Cooked data size |
|---|---|---|
MODE1 |
0 (data at slot offset 0) | 2048 |
MODE1_RAW |
16 (skip 12-byte sync + 4-byte header) | 2048 |
MODE2 |
0 | 2336 |
MODE2_RAW |
24 (skip sync + header + subheader) | 2048 |
MODE2_FORM2 |
24 | 2324 |
AUDIO |
0 | 2352 |
Reading at the wrong offset returns sync-pattern bytes that rcheevos parses as garbage (PCE-CD program-sector pointer ends up wildly out of range, ISO9660 PVD lookup picks up junk directory entries pointing past the end of the disc, etc.).
2. TRACK_PAD=4 alignment
When walking from track to track to compute storage offsets, chdman pads each track's frame count up to a multiple of 4. RetroArch's chd_stream.c calls this padding_frames and adds it to the per-track accumulator. Missing the alignment leaves each downstream track 0–3 frames behind where it actually starts in CHD storage, and the data track lands inside the prior audio track's tail.
3. Pregap-in-storage is per-CHD, not per-metadata
Two CHDs of similar games can disagree on whether the data track's pregap is physically stored. chdman's PGTYPE field is meant to signal this (V = virtual / not in source), but in practice the answer doesn't reliably correlate with the field value — observed counter-examples include CHDs with PGTYPE=V where the pregap silence is genuinely present in storage and CHDs with the same marker where it isn't.
Emutastic auto-detects per CHD at load time: read the would-be data sector 1 and sector 16 under each candidate dataStart (with-pregap vs without-pregap) and probe for the disc's boot signature — PC Engine CD-ROM SYSTEM at byte 32 of sector 1 for PCE-CD, or CD001 at byte 1 of sector 16 for any ISO9660 filesystem (PS1, Saturn, SegaCD, etc.). Whichever candidate yields a recognizable signature wins. The auto-probe outcome is logged once per disc open as [RcheevosChd] auto-probe: ….
4. Linear disc-LBA layout (no firstSector subtraction)
For CD-CHDs created by chdman, tracks are laid out linearly such that absolute disc LBA equals CHD frame position after alignment padding is accounted for. No track-relative subtraction is needed before the hunk lookup; currentSector / unitsPerHunk lands at the right hunk directly.
5. GD-ROM (Dreamcast) HD-area canonical anchor
Dreamcast .chds (CHGD format) need one extra rebase. The high-density data track is mastered with PVD LBAs anchored at 45000 — the standard physical-disc start of the HD area — regardless of what the LD-area frame count actually sums to. chdman packs that track at chd_frame ≈ sum_of_LD_track_frames, which for many discs equals 45000 by coincidence but for others differs by a small delta (observed up to 4 frames).
Emutastic detects CHGD by spotting any track with PAD > 0, rebases the first non-audio post-LD track's StartSector to 45000, and stores the gap as a per-handle LbaToChdShift that's added to every incoming sector before the hunk lookup. The IP.BIN check at firstSector, the PVD read at firstSector + 16, the root-directory walk, and 1ST_READ.BIN resolution all bridge the shift uniformly, so Dreamcast CHDs identify reliably across the LD-area-size spectrum.
If a CHD doesn't identify on a CD-based console with a known RA set, please open an issue with:
- Game title and console
- Source format you tested with (
.chd,.cue+.bin,.gdi, etc.) - The relevant lines from
~/.local/share/Emutastic/Logs/ra.logaround the launch attempt
The ra.log will show whether the libchdr bridge installed ([RcheevosChd] cdreader installed), whether rcheevos opened the disc ([RcheevosChd] opened ...), and the specific hash-failure reason if identification breaks.
On Linux there's no DLL/CRT dance — rcheevos is compiled from the vendored source as a shared object (librcheevos.so) and loaded by name ([DllImport("rcheevos")], which the .NET loader resolves to librcheevos.so). The build still needs:
-
RC_CLIENT_SUPPORTS_HASHfor built-in ROM hashing.
The vendored snapshot is v11.6.0, whose rc_client_* structs differ from the snapshot the Windows DLL was built against (e.g. rc_client_achievement_t has no inline badge-URL pointers — badge URLs come from rc_client_achievement_get_image_url() instead). RcheevosInterop.VerifyAbi() cross-checks every marshaled layout against the numbers the native checkabi harness printed at build time (native/rcheevos/rcheevos-abi.txt), so a version bump that shifts a field fails loudly instead of silently corrupting reads. See Services/RcheevosInterop.cs.
rcheevos validates achievement addresses during rc_client_begin_identify_and_load_game by calling the read_memory callback. If the callback relies on pointers only populated after load, every address check returns 0 → achievements disabled with "Invalid address".
Fix: Call retro_get_memory_data / retro_get_memory_size and cache pointers before rc_client_begin_identify_and_load_game.
-
Web API Key (from settings page) — read-only web API queries. Works with
API_GetUserProfile.php. -
Login Token (from
rc_client_begin_login_with_password) — for gameplay session tracking and achievement unlocks.
These are not interchangeable. Passing the Web API Key to rc_client_begin_login_with_token silently fails.
Correct flow: login with password → extract token from rc_client_get_user_info → save token → use rc_client_begin_login_with_token on subsequent launches.
rcheevos doesn't perform HTTP requests — the frontend provides a server_call callback. The native callback function pointer must survive across the async HTTP call:
// Capture raw pointer before async boundary
IntPtr callbackPtr = Marshal.GetFunctionPointerForDelegate(callback);
// In completion handler, reconstitute and invoke
var cb = Marshal.GetDelegateForFunctionPointer<rc_server_callback_t>(callbackPtr);
cb(result, ...);This prevents GC of the managed delegate wrapper from breaking the callback.
rcheevos covers unlocks and session tracking. For the per-game stats displayed on the detail card (time-to-beat, time-to-master, achievement metadata, "Coming up" suggestions), Emutastic also calls the public REST API at https://retroachievements.org/API/. The Web API key is a different secret from the rcheevos login token — users paste it once in Preferences → RetroAchievements.
Three endpoints feed the detail card:
| Endpoint | Returns | Used for |
|---|---|---|
API_GetGameProgression.php |
medianTimeToBeat, medianTimeToBeatHardcore, medianTimeToComplete, medianTimeToMaster (seconds) + per-achievement medianTimeToUnlock, numAwarded, trueRatio, badgeName + sample sizes |
Public stats, badge wall, "Coming up" fallback |
API_GetGameInfoAndUserProgress.php |
Same game shape + the user's per-achievement dateEarned / dateEarnedHardcore (only present if earned) + numAwardedToUser, userCompletion %, userTotalPlaytime
|
The user's progress numbers and earned-set filtering |
API_GetUserProgress.php |
Batch: comma-separated game IDs → per-game NumAchieved totals |
Bulk library refresh (one HTTP call for many games) |
Cache them. Progression is near-static — a 24h TTL is generous. Per-user progress changes every session — 1h TTL is reasonable, and invalidating on emulator close gets the user fresh data on their next detail-card open.
Sample-size gating. Don't show medians that come from too few data points. timesUsedInBeatMedian / timesUsedInCompletionMedian / timesUsedInMasteryMedian ≥ 20 is a defensible floor — under that you'd be showing one player's run as if it were typical.
RA game ID acquisition. The Web API takes a numeric game ID. rcheevos resolves the ROM hash to that ID at game load; capture it from rc_client_get_game_info and persist it on your library row. New users get tagged organically as they play; no bulk hash-resolve pass needed.
For achievements with measured progress ("3 of 5 rats killed"), rcheevos fires RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE whenever the value changes. The event payload's rc_client_achievement_t* exposes:
-
measured_progress— 24-byte UTF-8 string, e.g."3 of 5" -
measured_percent—float0–100 -
id,state,bucket
Subscribe to the event and accumulate updates in a ConcurrentDictionary<uint, AchievementInfo> on the emu thread; the GL overlay reads the latest snapshot and draws the tracker pill described above. Don't write to disk from inside the event handler — it runs on the latency-critical per-frame thread; a SQLite write there causes audio crackle and frame stalls. Flush the snapshot once at emulator close on a background Task.Run.
Persist hardcore-mode at capture time and gate display on a match: softcore-captured "73%" is meaningless under hardcore (different ruleset, server resets state on mode switch).
Console Notes
- Nintendo 64
- Nintendo 3DS
- GameCube
- Sega Saturn
- Dreamcast
- PlayStation
- PlayStation Portable
- TurboGrafx-CD
- Neo Geo
- Arcade
- Vectrex
- Philips CD-i
- Atari Jaguar
Features
- Artwork & Metadata
- Cheats
- Cloud Sync
- Controllers
- Disc-Based Systems
- Disk Swapping
- Portable Mode
- RetroAchievements
- ROM Hacks
- Hardcore Compliance
Technical
Platforms
Legal