* feat(app): AgentPlayer and the snapshot's agent channel (#967) The Neuro agent's input becomes a program the emulation thread replays: InputSnapshot gains an agent channel (AgentStep / AgentProgram / AgentProgress; publishAgent, takeAgent, reportAgent, agentProgress) behind its own mutex and a gen atomic, so the per-event lock is untouched. InputQueue gains version, lineage, firstFrame, trim and program, and AgentPlayer is the pure emulation-thread replica (load, frame, progress), driven by pollAgent. Unit-level only: the app is wired in the next task. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): Neuro presses through the input snapshot (#967) The UI-side Agent now publishes the input it accepted as an AgentProgram (Agent::publishTo) and absorbs the replica's progress (Agent::absorb), which trims its queue and drives busy(), pressedNow() and framesPlayed(). The app's input poll runs the AgentPlayer replica one step per emulated frame, inline and threaded alike (pollAgent); Agent::frame() and InputManager::agentControls are gone (InputManager::addAgentControls keeps the inline hashes unmoved). The #1025 pins are ported to the replica (testAgentGating through a Replica helper, loopback with a frame-numbered record) and extended (testAgentProgram: busy covers older progress, release_all and hardcore clear the replica within one poll, Neuro off, append mid-play does not replay). New neuro_core_e2e runs the replica against the real core (inline, lock-step threaded, free-running at 600 Hz, disabled) and neuro_app_inline runs the headless app against a fake Neuro server. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): Neuro reads RAM watches through a DebugViews request (#967) Neuro no longer reads core RAM through the retro_get_memory_* externs. The profile's watches are one DebugViews request, a Memory request over the covering range or a Dram request when the span is wider than 64 KiB, and the emulation thread's copier fills it after each frame. neuro::WatchFeed (neuro_watch.h) owns the request on the UI thread: it gives a changed range a fresh id so the old range's buffer is never read as the new one, reads the buffer at most every 0.25 s, and adopts it only when it is ok, current and answers exactly the request asked. Anything else leaves the state text at "?". GameState::ram / ramSize become watchBytes / watchBase, watchValue gains a base argument, and Agent::watchRange gives the covering range. Headless inline runs move the refresh serial (syncDebug) and run the copier after each frame, and the end-of-run report prints the state line (neuro_watch_inline pins a cheat byte read through the Dram path). Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): Neuro works with the emulation thread on (#967) The gate that turned Neuro off while the emulation thread is on is gone: settingsFrom no longer disables it, neuroInit does not log "off for this run", and Settings > Neuro drops the hint. The program/replica design from the earlier 3c commits (AgentProgram through the input snapshot, AgentPlayer in the core's poll, watches as a DebugViews request) is what makes this safe. The threaded headless --neuro run ticks Neuro in lock step: one frame at a time, in real time, like the inline loop, and neuroHeadlessReport prints in both modes. neuro_off_threaded is replaced by its twins: neuro_on_threaded, neuro_watch_threaded and neuro_loopback_threaded (the loopback run with the poll on its own thread; its frame-numbered press record equals the inline run's). Docs: neuro.md, app/README.md, docs/agent/testing.md. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): Neuro final-review fixes: one lock-step headless loop, watch staleness (#967) - App::runHeadless: the threaded mock-mode and --neuro loops are one lock-step loop in the inline loop's order, so the 3b rebase meets two loops, not three. - WatchFeed::dropIfStale: neuroTick drops a previous game's bytes before it lends them to the state text, not one tick later. - WatchFeed cadence: a rejected buffer is not re-read every UI frame; the clock resets on release, a key change and a dropped epoch. - neuroHeadlessReport forces its read (comment now matches the code). - docs/neuro.md: the 2 MiB copy runs while Neuro is on, about 10 times a second. - Tests: dropIfStale and rejected-buffer cadence (testWatchFeed), a non-zero low byte for the Dram index, absorb on a stale lineage (testAgentProgram); the testAgentGating comment moves back above its function. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
8.3 KiB
Neuro API: let an AI play
The desktop app can be played by Neuro-sama, or by any AI that speaks the Neuro game API (the SDK's test bots Randy, Tony and Gary, or a backend of your own). The app connects to the API server as a game client, offers the Jaguar controller as actions, and presses what the model chooses as Player 1. Your own controls keep working at the same time.
A Jaguar game knows nothing about Neuro, so the API's game client is the emulator itself, with game profiles that name the moves of particular games ("fire", "superzapper") and describe their controls to the model.
Turning it on
- Settings > Neuro: tick Connect to Neuro and set the server (
ws://localhost:8000by default, which is where Randy listens). The connection retries every few seconds until the server is up. - Command line, one run:
virtualjaguar --neuro ws://HOST:PORT game.j64. Nothing is saved. - The default server comes from
NEURO_SDK_WS_URLwhen it is set, like the official SDKs.
Only ws:// is supported (no TLS); Neuro API servers normally run on the same machine or the LAN.
ws:// is plaintext: Settings > Neuro warns when the server (also one from NEURO_SDK_WS_URL) is not
on this machine, and the privacy page (site/pages/privacy.html) lists exactly what is sent: the game's
title, the actions, the held buttons, the profile's RAM watch values and the pause state.
RetroAchievements hardcore blocks Neuro, as it does cheats and save-state loads: an AI is not a human player. While hardcore is active the app does not connect (or drops the link and lets go of every button), Settings > Neuro says why, and a toast appears when it gets blocked. When hardcore ends, Neuro connects again by itself if it is ticked.
Emulation thread: Neuro works with Settings > Interface > Emulation thread on or off, and plays the same either
way. The UI thread keeps the connection and the list of accepted moves and hands that program to the emulation thread
through the input snapshot; the core's input poll plays it one step per emulated frame, so press_buttons frames=3 is
three emulated frames at any display rate and under fast-forward, never three UI frames. Game profile values (watch)
are copied from RAM by the emulation thread after each frame (about 10 times a second) and the state text reads that
copy four times a second, so a value can be a third of a second old; a profile whose watches lie more than 64 KiB apart
makes the emulation thread copy the whole 2 MiB of RAM about 10 times a second while Neuro is on (enabled, not
hardcore, content loaded and the profile has watches), connected or not. Menus, a mouse on Player 1, hardcore and
a dropped connection let go of every button within one UI frame (with the emulation thread on, the release reaches the
core with the next publish, which under fast-forward can be a few emulated frames later).
You can tell the AI is playing: a toast on connect and disconnect, and while it is connected with a game running a small "AI plays P1" chip in the top right corner of the picture (menus and windows cover it, like the FPS chip).
Config keys (virtualjaguar.cfg): neuro.enabled, neuro.url, neuro.auto_force (default on),
neuro.force_interval (seconds, default 1), neuro.game_name (overrides the API game name).
What she gets
On connecting the app sends startup, registers the actions, and sends the profile's about text as
silent context. The API's game name is the profile's game_name ("Tempest 2000"), else the game's
title from the library, else "Atari Jaguar". Loading a different game reconnects under its name, since
the API fixes the name for a session.
With Ask for the next move on, the app sends actions/force whenever her last input has played out
and the game is running (never while paused or halted in the debugger, and only one force at a time).
The force carries a short state text: the title, paused or running, the held buttons, and the profile's
RAM watches. A move she makes outside a force is queued too. Pausing and resuming are sent as silent
context.
Results are sent once the action is validated, before its input plays out, as the API asks. An invalid action (an unknown button, too many frames) fails with a message saying why; Neuro retries a failed forced action.
Controller actions
Every game has these unless its profile turns them off ("generic_actions": false). Button names:
up down left right a b c option pause 0-9 star hash (* and # work too). A frame is 1/60 s.
| action | parameters | does |
|---|---|---|
press_buttons |
buttons, frames (1-600) |
hold the buttons together, then let go |
button_sequence |
steps: [{buttons, frames}] (1-20 steps of 1-90 frames) |
steps back to back; empty buttons = a pause |
hold_buttons |
buttons |
keep them held until release_all or another hold_buttons |
release_all |
let go of everything and drop what is queued |
At most 30 s of input can be queued at once. A dropped connection, turning Neuro off, or hardcore starting lets go of every held button.
Actions fail (and Neuro is told why) while Player 1's buttons would not reach the game: the in-game menu is open (under netplay the game keeps running behind it), or Player 1 is set to a mouse (the core drops the pad on a port whose mouse is in use). What is queued at that moment is dropped, and no new moves are asked for until input is back.
Game profiles
A profile is a JSON file in <data>/neuro/ (<data> is the app's data folder; Settings > Storage
shows it). Files there are tried before the built-in ones, and one with the same game_name replaces
a built-in profile. Problems are printed to the log at start-up as [neuro] profile skipped: ....
{
"game_name": "Tempest 2000",
"titles": ["Tempest 2000"],
"crc32": ["0x1234ABCD"],
"about": "What the game is and how to play it, sent as context when it starts.",
"generic_actions": true,
"watch": [
{"name": "score", "address": "0x01A2B4", "size": 4, "format": "bcd"},
{"name": "lives", "address": "0x01A2B8", "size": 1, "note": "(the game starts with 3)"}
],
"actions": [
{"name": "fire", "description": "Fire down the current lane.",
"steps": [{"buttons": ["b"], "frames": 3}], "gap": 3, "max_times": 30}
]
}
titlesmatch the start of the game's title, ignoring case;crc32matches the library's CRC of the file (the one without a copier header when it has one). A profile needs one or the other.watchreads big-endian values from main RAM (0x000000-0x1FFFFF) into the state text:size1, 2 or 4,formatdec,hexorbcd. Tools > Cheat scanner finds the addresses. The values are read four times a second (see the emulation thread note above for how old one can be).actionsare button macros. Names arelowercase_with_underscoresand need a description. Withmax_timesabove 1 the action takes{"times": N}and repeats the steps N times, withgapempty frames between repeats.
Tempest 2000 (built in)
Its actions are fire, move_left, move_right, fire_moving_left, fire_moving_right,
superzapper and jump, plus the controller actions for menus. The button roles come from the game's
manual; the frame counts are a first guess and have not been tuned against the game, and it has no RAM
watches yet. Contributions with tested timings and the score/lives addresses are welcome.
Testing with Randy
Randy (in the SDK repo) answers every force with a random valid action:
cd neuro-sdk/Randy && npm install && npm start # ws://localhost:8000
virtualjaguar --neuro ws://localhost:8000 game.j64
--headless --frames N works with --neuro too; the frames then run in real time so the server can
keep up.
The client is tested by ctest -R neuro: neuro_unit, neuro_loopback and neuro_loopback_threaded (a WebSocket
server on 127.0.0.1; the second polls the buttons from another thread), neuro_core_e2e (the real core's joypad matrix,
inline and threaded), neuro_app_inline / neuro_on_threaded (the app headless against that server) and
neuro_watch_* (a profile watch read back from RAM).
The code is in app/src/neuro/. It is built by default; configure with -DVJ_APP_NEURO=OFF to leave it
out (no Settings > Neuro tab, no --neuro, no neuro_* tests).