@cruncheevos/cli
v0.0.6
Published
Maintain achievement sets for RetroAchievements.org using JavaScript, an alternative to RATools
Downloads
106
Maintainers
Readme
@cruncheevos/cli
@cruncheevos/cli is primarily an alternative to RATools. You can code achievement sets using JavaScript and use this CLI to update the assets for RAIntegration.
CLI expects Node 20 LTS and only respects working in ESM environment.
Why use this instead of RATools?
- JavaScript is mature and expressive programming language compared to RATools DSL being limited and sometimes having bugs (see changelogs)
- cruncheevos forces you to produce conditions exactly the way you want them, where you want them. You own the abstractions you write. RATools DSL can change between versions and old scripts may stop working after update, or result in different output
- And yet this is exactly why you may not like cruncheevos, because it requires more work for abstracting condition blocks
- JavaScript is widely supported by text and code editors
- Cross-platform support due to node.js
- Can reuse your own code due to module support provided by node.js, you have access to all npm packages too
- You can run scripts asynchronously. You can technically hold crucial data on Google Sheets or anywhere else remote, and fetch said data to use with your achievements directly
Getting started
Create main directory that will hold your achievement sets, then create minimal package.json
file with following contents:
{
"type": "module"
}
While inside main directory, install @cruncheevos/cli
:
> npm install @cruncheevos/cli
Set RACACHE
environment variable, it must contain absolute path to emulator directory containing RACache
. This is where data will be read and dumped into. The CLI also uses credentials specified in RAPrefs_EmulatorName.cfg
file to be able to fetch data for games you don't have in RACache
.
@cruncheevos/cli
supports dotenv, which is useful when you have several emulators installed.
In such scenario it's recommended to make a directory per emulator, and create .env
file in each directory with following contents (example for Windows):
RACACHE=D:\SharedProgramFiles\RALibRetro
Now you can run locally installed @cruncheevos/cli
using npx
(if you have .env
file, run it from directory with said file):
> npx cruncheevos --help
DO NOT install and run the CLI globally, otherwise @cruncheevos/core
dependency won't be resolved properly.
I want to create new achievement set from scratch
Let's pretend that achievement set for Sonic the Hedgehog does not exist, take note of game ID the URL which is 1
. In your main directory, you can create a JavaScript file (named sonic.js
for example) that would look like this:
import { AchievementSet, define as $ } from '@cruncheevos/core'
const set = new AchievementSet({
gameId: 1, // same ID as in https://retroachievements.org/game/1
title: 'Sonic the Hedgehog'
})
set.addAchievement({
title: 'My Achievement',
description: 'Collect 25 Rings',
points: 1,
conditions: {
core: $(
['', 'Mem', '8bit', 0xfff0, '=', 'Value', '', 0],
['', 'Mem', '8bit', 0xfffb, '=', 'Value', '', 0],
['', 'Delta', '8bit', 0xfe20, '<', 'Value', '', 25],
['', 'Mem', '8bit', 0xfe20, '>=', 'Value', '', 25],
)
}
})
export default set
Notice the default export at the bottom of the script, that's how CLI recognizes the achievement set to work with. Alternatively you can default export a function that returns the set, which is not practical. You can also default export async function.
Now you can proceed to General workflow
I want to work on existing achievement set
You can use generate command to produce a script file containing achievements and leaderboards that were already uploaded to RetroAchievements.org.
For a game you want to work on, take note of game ID the URL which in this case is 1
, and specify it in the command:
npx cruncheevos generate 1 sonic.js
generated code for achievement set for gameId 1: sonic.js
Generated file will look similar to example above, but will include all achievements and leaderboards.
General workflow
Following the example above, most of the work involves moving out conditions into functions so they are reusable:
import { AchievementSet, define as $ } from '@cruncheevos/core'
const set = new AchievementSet({ gameId: 1, title: 'Sonic the Hedgehog' })
function gotRings(amount) {
return $(
['', 'Mem', '8bit', 0xfff0, '=', 'Value', '', 0],
['', 'Mem', '8bit', 0xfffb, '=', 'Value', '', 0],
['', 'Delta', '8bit', 0xfe20, '<', 'Value', '', amount],
['', 'Mem', '8bit', 0xfe20, '>=', 'Value', '', amount],
)
}
set.addAchievement({
title: 'Super Ring Collector',
description: 'Collect 1000 rings',
points: 50,
conditions: $(
gotRings(1000)
),
badge: '250341',
id: 1,
})
It's highly recommended to use define
function exported by @cruncheevos/core
to have TypeScript support and because of additional features it provides. define
being aliased into $
is also opinionated so conditions are less verbose and to make sharing of code easier.
In this example not only unlock conditions have been tweaked, but also the title, description, and points. All these changes can be checked using diff
command:
> npx cruncheevos diff sonic.js
Assets changed:
A.ID│ 1 (compared to remote)
Title│ - Ring Collector
│ + Super Ring Collector
Desc.│ - Collect 100 rings
│ + Collect 1000 rings
Pts.│ 5 -> 50
──────┼─────────────────────────────────────────────────
Code│ Core
│ Flag Type Size Value Cmp Type Size Value Hits
──────┼─────────────────────────────────────────────────
1 1│ Mem 8bit 0xfff0 = Value 0
2 2│ Mem 8bit 0xfffb = Value 0
3 -│ Delta 8bit 0xfe20 < Value 100
4 -│ Mem 8bit 0xfe20 >= Value 100
+ 3│ Delta 8bit 0xfe20 < Value 1000
+ 4│ Mem 8bit 0xfe20 >= Value 1000
Take note that it shows difference compared to remote, which means comparing to achievements stored on server (which were downloaded to RACache/Data/1.json
). Saving the changes will dump them to local file: RACache/Data/1-User.json
, and running diff
later will compare your achievements to local ones.
If you're satisfied with changes, you can save updated assets using save command:
> npx cruncheevos save sonic.js
dumped local data for gameId: 1: D:\SharedProgramFiles\RAIntegration\RACache\Data\1-User.txt
updated: 1 achievement
> cat D:\SharedProgramFiles\RAIntegration\RACache\Data\1-User.txt
1.0
Sonic the Hedgehog
1:"0xHfff0=0_0xHfffb=0_d0xHfe20<1000_0xHfe20>=1000":Super Ring Collector:Collect 1000 rings::::cruncheevos:50:::::250341
Same could have been done using diff-save command. After saving the changes, open RAIntegration in your emulator to test your work in-game and upload the changes.
Recipes
Handling different regions or versions of a game
Suppose you want to support different revisions of some game, or both regions like PAL and NTSC. This means that values you're interested in might be located at different memory addresses. Offsets are usually consistent.
The example below is based off sonic.js
for simplicity, the 0x100
offset presented may not reflect the actual difference between game revisions. It also includes JSDoc comments at the start that allow you to infer codeFor
return value in callback for multiRegionalConditions
.
/** @typedef {'rev00' | 'rev01'} Revision */
/**
* @template T
* @typedef {(c: typeof codeFor extends (...args: any[]) => infer U ? U : any) => T} CodeCallbackTemplate
*/
/** @typedef {CodeCallbackTemplate<
import('@cruncheevos/core').ConditionBuilder |
import('@cruncheevos/core').Condition
>} CodeCallback */
/*
Make a function that accepts revision name and produces
addresses and functions that return conditions with correct offsets,
basically abusing JavaScript closures.
*/
/** @param {Revision} revision */
const codeFor = revision => {
/*
You can do any conditions here, like offsets based on address
ranges and additional revisions if you have more than two of them
*/
const offset = address => {
return revision === 'rev00' ? address : address + 0x100
}
/*
Now you can store correct addresses for certain revision.
No need to call offset function if you're certain that
address is same between all revisions.
*/
const addresses = {
demo: offset(0xfff0),
debug: offset(0xfffb),
ringCount: offset(0xfe20),
}
const regionCheck = $(
revision === 'rev00' && ['', 'Mem', '8bit', 0x100, '=', 'Value', '', 0],
revision === 'rev01' && ['', 'Mem', '8bit', 0x100, '=', 'Value', '', 1]
)
return {
// Recommended to provide addresses in case you don't want to make
// additional functions to express conditions with
addresses,
// Code is same as before, but now has applied offsets on addresses
gotRings(amount) {
return $(
regionCheck,
['', 'Mem', '8bit', addresses.demo, '=', 'Value', '', 0],
['', 'Mem', '8bit', addresses.debug, '=', 'Value', '', 0],
['', 'Delta', '8bit', addresses.ringCount, '<', 'Value', '', amount],
['', 'Mem', '8bit', addresses.ringCount, '>=', 'Value', '', amount],
)
}
}
}
// So you don't have to call `codeFor` all the time
const code = {
rev00: codeFor('rev00'),
rev01: codeFor('rev01')
}
/**
* Generic function to make multi-revisional code with.
* It assumes that you need only one alt group per revision.
* @param {CodeCallback} cb
*/
function multiRevisionalConditions(cb) {
return {
core: '1=1',
alt1: cb(code.rev00),
alt2: cb(code.rev01),
}
}
set.addAchievement({
title: 'Super Ring Collector',
description: 'Collect 1000 rings',
points: 50,
conditions: multiRevisionalConditions(c => c.gotRings(1000)),
badge: '250341',
id: 1,
})
export default set
Here's the resulting diff:
> npx cruncheevos diff sonic.js
Assets changed:
A.ID│ 1 (compared to local)
Title│ Super Ring Collector
──────┼──────────────────────────────────────────────────
Code│ Core
│ Flag Type Size Value Cmp Type Size Value Hits
──────┼──────────────────────────────────────────────────
1 -│ Mem 8bit 0xfff0 = Value 0
2 -│ Mem 8bit 0xfffb = Value 0
3 -│ Delta 8bit 0xfe20 < Value 1000
4 -│ Mem 8bit 0xfe20 >= Value 1000
+ 1│ Value 1 = Value 1
──────┼──────────────────────────────────────────────────
Code│ Alt 1
│ Flag Type Size Value Cmp Type Size Value Hits
──────┼──────────────────────────────────────────────────
+ 1│ Mem 8bit 0x100 = Value 0
+ 2│ Mem 8bit 0xfff0 = Value 0
+ 3│ Mem 8bit 0xfffb = Value 0
+ 4│ Delta 8bit 0xfe20 < Value 1000
+ 5│ Mem 8bit 0xfe20 >= Value 1000
──────┼──────────────────────────────────────────────────
Code│ Alt 2
│ Flag Type Size Value Cmp Type Size Value Hits
──────┼──────────────────────────────────────────────────
+ 1│ Mem 8bit 0x100 = Value 1
+ 2│ Mem 8bit 0x100f0 = Value 0
+ 3│ Mem 8bit 0x100fb = Value 0
+ 4│ Delta 8bit 0xff20 < Value 1000
+ 5│ Mem 8bit 0xff20 >= Value 1000
AddAddress handling
You can be quite expressive when it comes to dealing with pointers, here's an example from actual set:
const entityGroup = (group) => {
const basePointer = $(
['AddAddress', 'Mem', '32bit', address.entitiesPointer],
['AddAddress', 'Mem', '32bit', group * 0x4],
['AddAddress', 'Mem', '32bit', 0x104],
)
return {
index(index) {
const offset = index * 0x4A0
return {
becameAlive: $(
basePointer,
['AndNext', 'Delta', 'Bit1', offset + 0x1F8, '=', 'Value', '', 0],
basePointer,
['', 'Mem', 'Bit1', offset + 0x1F8, '>', 'Value', '', 0]
),
// ... and many other functions
// ... alternatively
get becameAliveAsGetter() {
// scoped variables available here
return $(
// ...
)
}
}
}
}
}
// which can be used like
$(
entityGroup(0x5D).index(1).becameAlive,
entityGroup(0x3D).index(2).becameAliveAsGetter
)
Badges
It's tiresome to manually select badges in RAIntegration. To deal with this problem, you can follow consistent naming scheme for your badge file names and put badge files into RACache\Badge\local
directory. Afterwards, define a function that produces correct file path for those badges:
const b = (s) => `local\\\\${s}.png`
for (const missionId of missionIds) {
set.addAchievement({
// ...
badge: b(`MISSION_${missionId}_COMPLETE`)
})
}
Such badge will not be applied if achievement was already uploaded on server with badge set, otherwise it would always attempt to apply a new badge.
Rich Presence
If you export an object returned by RichPresence
function and name it rich
, you can use rich-save command to transfer it to the RACache/Data/1-Rich.txt
file.
If you wish to generate Rich Presence manually, you can do so and export a string named rich
.
@cruncheevos/core
provides RichPresence
export which you can use to define Rich Presence. Check the example below, and also examples in the core package.
import { RichPresence } from '@cruncheevos/core'
// ...
export const rich = RichPresence({
lookup: {
LevelName: {
values: {
0x00: 'Green Hill Zone Act 1',
0x01: 'Green Hill Zone Act 2',
0x02: 'Green Hill Zone Act 3',
}
}
},
displays: ({ lookup, tag }) => [
`Sonic is exploring ${lookup.LevelName.at(addresses.levelId)}`
]
})
export default set
> node sonic.js rich
Lookup:LevelName
0x00=Green Hill Zone Act 1
0x01=Green Hill Zone Act 2
0x02=Green Hill Zone Act 3
Display:
Sonic is exploring @LevelName(0xFE10)
Async execution
You can export an async function (or a regular function returning promise) that resolves into AchievementSet. This is useful when you have achievement-related data stored somewhere on internet (something like Google Sheets). The actual fetching and caching of data remains your responsibility.
The example below is silly, and yet running the diff will result in different achievement title and conditions every time:
import { AchievementSet, define as $ } from '@cruncheevos/core'
const set = new AchievementSet({ gameId: 1, title: 'Sonic the Hedgehog' })
function gotRings(amount) {
return $(
['', 'Mem', '8bit', 0xfff0, '=', 'Value', '', 0],
['', 'Mem', '8bit', 0xfffb, '=', 'Value', '', 0],
['', 'Delta', '8bit', 0xfe20, '<', 'Value', '', amount],
['', 'Mem', '8bit', 0xfe20, '>=', 'Value', '', amount],
)
}
export default async () => {
const amountOfRings = await fetch(
'https://www.randomnumberapi.com/api/v1.0/random?min=100&max=1000&count=1'
).then(x => x.json())
.then(x => x[0])
set.addAchievement({
title: 'Super Ring Collector',
description: `Collect ${amountOfRings} rings`,
points: 50,
conditions: gotRings(amountOfRings),
badge: '250341',
id: 1,
})
return set
}
Extending prototypes
Due to relatively minimal API of @cruncheevos/core
and the fact that scripts are ran only once by CLI, the idea of extending prototype of native JS objects and @cruncheevos/core
classes isn't that bad if you think it allows your code to be more expressive.
The only problem is making your editor discover these extensions to provide code hints. If you don't care about that - just extend prototypes at the start of your script, implementation examples below would be the same.
If you care to make editor discover the prototype extensions, create two files:
common.d.ts
to hold type declarations that editor will pick upcommon.js
to hold actual prototype extensions, you will import this file at the top of your script
common
file name is merely a suggestion, as you can also make it export some reusable functions.
Here's how it may look like
common.d.ts
:
import type { Condition } from '@cruncheevos/core'
declare module '@cruncheevos/core' {
interface Condition {
cmpInverted(): Condition
delta(): Condition
}
}
interface Number {
toHexString(): string
}
common.js
Condition.prototype.delta = function () {
return this.with({ lvalue: { type: 'Delta' } })
}
Condition.prototype.cmpInverted = function () {
switch (this.cmp) {
case '=': return this.with({ cmp: '!=' })
case '!=': return this.with({ cmp: '=' })
case '<': return this.with({ cmp: '>=' })
case '<=': return this.with({ cmp: '>' })
case '>': return this.with({ cmp: '<=' })
case '>=': return this.with({ cmp: '<' })
default: return this
}
}
// (10).toHexString() would give you 0xa
// It's not used in `sonic.js`,
// just a reminder on how to extend native JavaScript objects
Number.prototype.toHexString = function () {
return '0x' + this.toString(16)
}
sonic.js
import './common.js' // apply prototype extensions
import { AchievementSet, define as $ } from '@cruncheevos/core'
const set = new AchievementSet({ gameId: 1, title: 'Sonic the Hedgehog' })
function gotRings(amount) {
// $.one returns instance of Condition class
const ringsMoreThan = $.one(['', 'Mem', '8bit', 0xfe20, '>=', 'Value', '', amount])
return $(
['', 'Mem', '8bit', 0xfff0, '=', 'Value', '', 0],
['', 'Mem', '8bit', 0xfffb, '=', 'Value', '', 0],
ringsMoreThan.delta().cmpInverted(),
ringsMoreThan,
)
}
Technically some of class extension ideas could be part of @cruncheevos/core
package from the start, but restrain is intentional: the library should stay minimal and it's better to see how other people use the library first.
While you can extend other @cruncheevos/core
classes: ConditionBuilder
, Achievement
, Leaderboard
, AchievementSet
, it's yet to be seen how one can benefit from that.
Commands
diff
Usage: cruncheevos diff [options] <input_file_path>
shows the difference between achievement set exported by JavaScript module and set defined in remote
and/or local files
assumes that RACACHE environment variable is set - it must contain absolute path to emulator directory
containing the RACache directory. If there's .env file locally available - RACACHE value will be read
from that.
Arguments:
input_file_path path to the JavaScript module which default exports AchievementSet or
(async) function returning AchievementSet
Options:
-f, --filter <filter:value...> only output assets that matches the filter. available filters are: id,
title, description
id accepts comma separated list of ids, everything else accepts a
regular expression
--include-unofficial do not ignore unofficial achievements on the server when executing
this operation
-c --context-lines <amount> how much conditions to show around the changed conditions, 10 max
-r --refetch force refetching of remote data
-t --timeout <number> amount of milliseconds after which the remote data fetching is
considered failed (default: 3000)
save
Usage: cruncheevos save [options] <input_file_path>
saves the achievement set exported by JavaScript module into local file in RACache directory
save command will try it's best to preserve the existing local assets that are not part of your
JavaScript module
assumes that RACACHE environment variable is set - it must contain absolute path to emulator directory
containing the RACache directory. If there's .env file locally available - RACACHE value will be read
from that.
Arguments:
input_file_path path to the JavaScript module which default exports AchievementSet or
(async) function returning AchievementSet
Options:
-f, --filter <filter:value...> only output assets that matches the filter. available filters are: id,
title, description
id accepts comma separated list of ids, everything else accepts a
regular expression
--include-unofficial do not ignore unofficial achievements on the server when executing
this operation
-r --refetch force refetching of remote data
-t --timeout <number> amount of milliseconds after which the remote data fetching is
considered failed (default: 3000)
--force-rewrite completely overwrite the local data instead of updating only matching
assets, THIS MAY RESULT IN LOSS OF LOCAL DATA!
diff-save
Usage: cruncheevos diff-save [options] <input_file_path>
shows output of 'diff' command first, if there are any changes - prompts to issue 'save' command
save command will try it's best to preserve the existing local assets that are not part of your
JavaScript module
assumes that RACACHE environment variable is set - it must contain absolute path to emulator directory
containing the RACache directory. If there's .env file locally available - RACACHE value will be read
from that.
Arguments:
input_file_path path to the JavaScript module which default exports AchievementSet or
(async) function returning AchievementSet
Options:
-f, --filter <filter:value...> only output assets that matches the filter. available filters are: id,
title, description
id accepts comma separated list of ids, everything else accepts a
regular expression
--include-unofficial do not ignore unofficial achievements on the server when executing
this operation
-c --context-lines <amount> how much conditions to show around the changed conditions, 10 max
-r --refetch force refetching of remote data
-t --timeout <number> amount of milliseconds after which the remote data fetching is
considered failed (default: 3000)
--force-rewrite completely overwrite the local data instead of updating only matching
assets, THIS MAY RESULT IN LOSS OF LOCAL DATA!
fetch
Usage: cruncheevos fetch [options] <game_id>
fetches the remote data about achievement set into RACache directoryNaN
assumes that RACACHE environment variable is set - it must contain absolute path to emulator directory
containing the RACache directory. If there's .env file locally available - RACACHE value will be read
from that.
Arguments:
game_id numeric game ID as specified on retroachievements.org
Options:
-t --timeout <number> amount of milliseconds after which the remote data fetching is considered
failed (default: 3000)
generate
Usage: cruncheevos generate [options] <game_id> <output_file_path>
generates JavaScript module based on the remote data about achievement set
assumes that RACACHE environment variable is set - it must contain absolute path to emulator directory
containing the RACache directory. If there's .env file locally available - RACACHE value will be read
from that.
Arguments:
game_id numeric game ID as specified on retroachievements.org
output_file_path
Options:
-f, --filter <filter:value...> only output assets that matches the filter. available filters are: id,
title, description
id accepts comma separated list of ids, everything else accepts a
regular expression
--include-unofficial do not ignore unofficial achievements on the server when executing
this operation
-r --refetch force refetching of remote data
-t --timeout <number> amount of milliseconds after which the remote data fetching is
considered failed (default: 3000)
rich-save
Usage: cruncheevos rich-save [options] <input_file_path>
saves the Rich Presence exported by JavaScript module as string named 'rich' or
object returned by RichPresence function, into local file in RACache directory
assumes that RACACHE environment variable is set - it must contain absolute
path to emulator directory containing the RACache directory. If there's .env
file locally available - RACACHE value will be read from that.
Arguments:
input_file_path path to the JavaScript module which default exports
AchievementSet or (async) function returning
AchievementSet
Options:
-f --force-rewrite skip prompting to overwrite local Rich Presence file if
it exists