Skip to content

RetroAchievements

codingncaffeine edited this page Jun 7, 2026 · 4 revisions

RetroAchievements

Emutastic supports RetroAchievements using the rcheevos C library (rc_client API) via P/Invoke.

Hardcore mode status

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. Emutastic's first public release was April 2026, so the earliest we can apply is October 2026.

What this means for users:

  • With Hardcore Mode on you'll get an "Unknown emulator" toast from RetroAchievements at game start. This is expected, not a bug — it's the server telling you this client isn't on the approved-emulator list yet. Hardcore Mode ships on by default (per RA's recommendation), so most people will see it until approval completes.
  • If you want to unlock standard achievements and stop the message, turn Hardcore Mode off (Preferences → RetroAchievements). We'll announce once Emutastic is approved for hardcore.
  • The Hardcore toggle still works locally either way — save states / rewind / slow-motion are disabled when it's on, mirroring the rule set RA enforces.
  • The RA server will see unlocks you earn in hardcore mode but won't credit them as hardcore on the leaderboard until Emutastic is on the approved-emulator list. They'll silently count as softcore unlocks instead.
  • 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.

As of v1.6.5 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).

CHD support for CD-based achievements

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.

Known limitations

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 (v1.6.9) — see TurboGrafx-CD
Neo Geo CD .chd / .cue+.bin Identifies + fires via descriptor-aware reading; see Neo Geo
Dreamcast .chd Validated end-to-end (v1.6.9) — see Dreamcast
Sega Saturn .chd / .cue+.bin Validated end-to-end (v1.6.9) — see Sega Saturn
Sega CD .chd / .cue+.bin Validated end-to-end (v1.6.9)
PSP .chd / .iso / .cso / .pbp Validated end-to-end (v1.6.9) DVD-format CHDs (chdman createdvd) now read correctly
3DO .chd / .cue+.bin Identifies cleanly (v1.6.9); authored sets are sparse on RA's side for this platform
GameCube .iso Validated end-to-end (v1.6.9) .rvz not supported by rcheevos; convert via DolphinTool.exe convert -i game.rvz -o game.iso -f iso

CHD format quirks

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 [DataRoot]/Logs/ra.log around 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.

Building rcheevos.dll for .NET

rcheevos must be compiled as a shared library (/DRC_SHARED) with static CRT linking (/MT). Dynamic CRT (/MD) creates a dependency on VCRUNTIME140.dll — .NET's P/Invoke loader can't find it → DllNotFoundException. Static CRT eliminates the dependency (only KERNEL32.dll remains).

Also requires /DRC_CLIENT_SUPPORTS_HASH for built-in ROM hashing.

DLL Placement

If your .csproj references the DLL from a subfolder, MSBuild preserves the relative path in the output. .NET's native DLL search doesn't look in subdirectories. Flatten it:

<Content Include="Libraries\rcheevos.dll">
  <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  <Link>rcheevos.dll</Link>
</Content>

Memory Regions Must Be Cached Before Game Load

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 vs Login Token

  • 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.

HTTP Callback Bridging

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.

Per-Game Stats via the Public Web API

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.

Live In-Game Progress

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_percentfloat 0–100
  • id, state, bucket

Subscribe to the event and accumulate updates in a ConcurrentDictionary<uint, AchievementInfo> on the emu thread. 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).

Clone this wiki locally