Achievement system
NEShim supports Steam achievements without requiring recompilation or ROM modification. Achievements are defined in achievements.json alongside the executable. On each emulated frame, NEShim reads one or more NES memory addresses and fires the Steam achievement when a configured condition is met.
How it works
- At startup,
EmulatorHostcomputes the SHA1 hash of the raw ROM bytes. AchievementConfigLoaderreadsachievements.jsonand looks up the entry for that ROM hash.- Each
AchievementDefin the entry is signature-verified with HMAC-SHA256. Any definition with a missing or invalid signature is silently dropped. AchievementManageris constructed with the verified definitions and a reference to the NES memory domain.- Once per frame (after
RunFrame()completes),AchievementManager.Tick()reads each watched address and evaluates the trigger condition. - When a condition matches and
StatsReadyis true (Steam’s initial stats snapshot has been received),SteamManager.UnlockAchievement()is called. The achievement fires at most once per session — aHashSettracks which ones have already fired.
The HMAC check prevents casual text-file editing from unlocking achievements. A player who edits achievements.json directly will invalidate the signature and the modified entry will never fire. See Signing and sealing.
achievements.json format
The file is a JSON object keyed by ROM SHA1 hash. Each value is a config block with a memory domain and a list of achievement definitions.
{
"A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4": {
"memoryDomain": "System Bus",
"achievements": [
{
"steamId": "ACH_FIRST_WIN",
"address": 255,
"bytes": 1,
"encoding": "binary",
"comparison": "equals",
"value": 1,
"sig": "base64-hmac-written-by-seal-achievements"
}
]
}
}
Field reference
Config block
| Field | Type | Default | Description |
|---|---|---|---|
memoryDomain | string | "System Bus" | Which NES memory domain to read from. "System Bus" = full 64 KB NES address space (recommended). "RAM" = the 2 KB internal RAM only (addresses 0x0000–0x07FF). |
Achievement definition (AchievementDef)
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
steamId | string | — | Yes | The Steam achievement API name, exactly as defined in the Steamworks partner dashboard (e.g. "ACH_WIN_ONE_GAME"). |
address | integer | — | Yes | NES memory address to watch. Use decimal (e.g. 255) or hexadecimal in source — JSON is always decimal. |
bytes | integer | 1 | No | Number of bytes to read starting at address. Supported values: 1, 2, 3, 4. Bytes are assembled into a single integer before comparison. |
bigEndian | boolean | false | No | When false (default), bytes are assembled little-endian (NES native — LSB at lowest address). When true, the first byte is the most significant. Required for BCD-encoded scores where the most significant digit lives at the lowest address. |
encoding | string | "binary" | No | "binary" — interpret the assembled bytes as a standard integer. "bcd" — decode as binary-coded decimal (see below). |
comparison | string | "equals" | No | Trigger condition: "equals", "greaterOrEqual", "greaterThan", "lessOrEqual", or "lessThan". |
value | integer | — | Yes | Threshold for the comparison. |
sig | string | — | Yes (to fire) | HMAC-SHA256 signature written by seal-achievements. Definitions without a valid signature are silently ignored at runtime. |
Memory domains
The NES has a 64 KB address space (0x0000–0xFFFF). The "System Bus" domain exposes the full space as seen by the CPU, including mirrors. The most useful regions are:
| Address range | Contents |
|---|---|
0x0000–0x07FF | Internal RAM (2 KB, mirrored to 0x1FFF) |
0x0100–0x01FF | Stack (inside internal RAM) |
0x2000–0x3FFF | PPU registers |
0x6000–0x7FFF | Cartridge battery RAM (save RAM, if present) |
0x8000–0xFFFF | Cartridge ROM (PRG) |
For custom-coded games, 0x00FF (the last byte of zero page) is a convenient unused sentinel address in most homebrew games.
For published ROMs with known addresses, use a RAM map for the specific game to find where scores, lives, and progress flags are stored.
Encoding modes
"binary" (default)
Bytes at address are assembled into a single integer, then compared directly against value. This is the correct choice for flags, counters, and any value that isn’t packed BCD.
Example: a custom game writes 0x01 to address 0x00FF when the player wins the first level.
{
"steamId": "ACH_LEVEL_1",
"address": 255,
"bytes": 1,
"encoding": "binary",
"comparison": "equals",
"value": 1
}
"bcd" — Binary-Coded Decimal
Many NES games store scores as BCD: each nibble holds one decimal digit. The byte 0x42 represents the decimal value 42, not 66. Games like Donkey Kong, Pac-Man, and many arcade ports use this format.
To use BCD mode:
- Set
"encoding": "bcd". - Set
"bigEndian": trueif the most-significant digit is at the lowest address (which is typical — the leading digit of the score is at the leftmost byte). - Set
"bytes"to the number of bytes in the score field. - Set
"value"to the decimal score threshold you want to trigger at.
Example: Trigger ACH_SCORE_10000 when the score reaches 10,000. The game stores a 3-byte BCD score at addresses 0x0071–0x0073 with 0x0071 holding the most-significant digit.
{
"steamId": "ACH_SCORE_10000",
"address": 113,
"bytes": 3,
"bigEndian": true,
"encoding": "bcd",
"comparison": "greaterOrEqual",
"value": 10000
}
How the BCD decode works: The engine reads 3 bytes starting at address 113 (0x71), assembles them big-endian into a 24-bit raw value, then decodes each nibble as a decimal digit. Raw bytes [0x01, 0x00, 0x00] → raw integer 0x010000 → decoded BCD value 10000.
Finding the ROM SHA1 hash
The hash is logged to the Visual Studio debug output window at startup:
[Achievements] No config found for ROM <SHA1_HASH_HERE>
Alternatively, compute it manually:
# PowerShell
(Get-FileHash mygame.nes -Algorithm SHA1).Hash
Use this hash as the key in achievements.json.
Signing and sealing
Achievement definitions must be signed before they will fire in-game. The seal-achievements tool computes an HMAC-SHA256 signature for each definition’s trigger fields and writes it into the "sig" field.
Running the sealer
# Seal the default achievements.json in the current directory
seal-achievements
# Seal a specific file
seal-achievements path/to/achievements.json
Output:
ROM A1B2C3D4E5F6... (2 achievement(s))
[sealed] ACH_FIRST_WIN
[sealed] ACH_SCORE_10000
Done. 2 sealed, 0 skipped → achievements.json
Run the sealer any time you edit achievements.json. Editing a trigger field (address, value, comparison, etc.) without re-sealing will invalidate the signature and the achievement will never fire.
What the signature covers
The HMAC is computed over a |-delimited canonical string of all trigger fields:
{steamId}|{address}|{bytes}|{bigEndian}|{encoding}|{comparison}|{value}
The "sig" field itself is excluded. Changing any trigger field without re-sealing produces a mismatch.
Rotating the HMAC key
The HMAC key is embedded in NEShim.AchievementSigning/AchievementSigner.cs. Before shipping a production release, generate a new key specific to your game:
seal-achievements --gen-key
This prints a random 32-byte key in base64:
Generated key (paste into AchievementSigner.HmacKeyBase64):
WWGKiRD2jDNaDdyA4ociUifvT2TSNZVHF4Y3HmPpxg4=
- Copy the output value.
- Replace
HmacKeyBase64inNEShim/NEShim.AchievementSigning/AchievementSigner.cs. - Rebuild the solution.
- Re-run
seal-achievementson allachievements.jsonfiles to re-stamp signatures with the new key.
Do this before any public release. The default key in the source is publicly known and provides no meaningful tamper protection.
Authoring guide: custom-coded games
If you’re building a game from scratch (homebrew), the simplest achievement pattern is a reserved sentinel address:
- Choose unused addresses in zero page or overflow RAM, e.g.
0x00E0–0x06FF. - In your game code, write
0x01to0x00E0when the player reaches the first achievement,0x02to0x00E0for the second, etc. - In
achievements.json, define each achievement with"address": 224(0x00E0),"value": 1,"comparison": "equals","bytes": 1.
This is reliable because you control exactly when the write happens. There’s no ambiguity about data format or timing — the write is atomic and permanent for the session.
Authoring guide: published ROMs with scores
For games you didn’t write:
- Use a NES RAM map (NESdev wiki or dedicated resources for the game) to locate the score variable.
- Identify the address, byte width, and encoding (binary or BCD).
- Use
"comparison": "greaterOrEqual"with the score threshold as"value".
Important: Using greaterOrEqual or greaterThan is recommended over equals for score-based triggers. If you use equals and the score advances past the target value in a single frame (e.g. a large bonus), the trigger will never fire because the exact value was skipped.
The "lessOrEqual" and "lessThan" comparisons are available for triggers based on values decreasing — for example, unlocking an achievement when a lives counter drops to zero, or when a timer falls below a threshold.
Runtime behaviour
- Thread safety:
AchievementManager.Tick()runs on the emulation thread immediately afterRunFrame(). All Steam API calls (GetAchievement,SetAchievement,StoreStats) are made on the same thread. No cross-thread synchronisation is needed. - Steam not available: If Steam is unavailable (
SteamManager.IsAvailable == false),AchievementManagerstill evaluates triggers but the_unlockdelegate is a no-op, so nothing is sent to Steam. StatsReadyguard: Achievements are suppressed untilSteamManager.StatsReadybecomes true. This is set by theUserStatsReceived_tcallback, which SDK 1.61+ is supposed to fire automatically on init (RequestCurrentStats()no longer exists). In practice the callback does not always arrive via Steamworks.NET 2025.x, so a 5-second timeout fallback forcesStatsReadytrue regardless. This prevents callingSetAchievementbefore Steam is ready, which would silently fail.- Session dedup: Once an achievement fires, it is added to
_firedThisSession. Subsequent frames that still satisfy the trigger condition are ignored without any Steam API call. - Already unlocked:
UnlockAchievementcallsSteamUserStats.GetAchievement()before setting. If the achievement is already unlocked (from a previous play session), it skips theSetAchievementcall.