TL;DR (English): rct-bridge gives an LLM agent controlled read/write access to the memory of a running RollerCoaster Tycoon 1 process under Wine. A long-lived daemon owns the
/proc/<pid>/memhandle and enforces memory windows, a field-level write mask and an undo journal; a thin MCP server exposes it to Claude Code or any other MCP client. The distilled reverse-engineering reference lives inPEEP_HANDBUCH.md. Docs are in German — the demo video shows the bridge in action.
Die Bridge gibt einem LLM (oder dir in der Shell) kontrollierten Lese- und
Schreibzugriff auf den Speicher eines laufenden RCT.EXE unter Wine. Sie ist
bewusst low-level: Der Agent rechnet Adressen selbst; die Bridge erzwingt nur
die Speicherfenster, die Feld-Maske und das Undo-Journal. Die fachliche
Referenz ist PEEP_HANDBUCH.md in diesem Repo (§2 Fenster, §4 Maske,
§4.2 Namen, §11 M1-Rezept, §12 Smoke-Tests).
Besucher-Happiness in RollerCoaster Tycoon optimieren zeigt die Bridge im Einsatz, mit Claude Code als Agent. Alle Schritte sind natürlichsprachliche Anweisungen, die der Agent über die MCP-Tools umsetzt:
- Die Parkbesucher werden in zwei Teams geteilt: Team Rot und Team Blau,
jeweils komplett eingefärbt (Hemd und Hose). Team Rot bekommt
Regenschirme, Team Blau werden sie weggenommen
(
snippets/outfit_split.py). - Dann lässt der Agent es regnen (
snippets/make_rain.py, Wetter-Regie über dieglobals_rw-Allowlist). - Die blauen Besucher verlassen den Park (
snippets/leave_park.py). - Die Kleidung wird zur Happiness-Heatmap: Grün heißt sehr happy, über
Gelb und Orange bis Dunkelrot; minütlich per Cronjob aktualisiert
(
snippets/happiness_heatmap.py, als Dauerschleife auchbridge/heatmap_loop.py). - Trend-Auswertung: Werden die Besucher gerade glücklicher oder unglücklicher?
- Zum Schluss beobachtet der Agent die dreiköpfige Familie Mustermann
minütlich, optimiert ihre Interaktion auf maximale Happiness und
protokolliert Beobachtungen und Entscheidungen — das Ergebnis liegt
als
TAGEBUCH_MUSTERMANN.mdin diesem Repo.
bridge/— das Python-Paket: Daemon, CLI, MCP-Server, exec-Snippets, Tests.PEEP_HANDBUCH.md— die destillierte Reverse-Engineering-Referenz (Offsets, Regeln, Rezepte, Smoke-Tests).BNSkripte/— Binary-Ninja-Skripte aus der RE-Arbeit, mit der das Handbuch entstanden ist.TAGEBUCH_MUSTERMANN.md— Demo-Artefakt: das Tagebuch der Familie Mustermann aus dem Demo-Video oben.
Das Repo lebt außerhalb des Spielverzeichnisses; das Spiel bekommt nur
Symlinks. bridge/config.py leitet das Spielverzeichnis aus dem Paketpfad
ab, ohne Symlinks aufzulösen — deshalb funktioniert python3 -m bridge.daemon aus dem Spielverzeichnis unverändert.
- Repo klonen, z. B. nach
~/projects/rct-bridge. - Im Spielverzeichnis (neben
RCT.EXE) die zwei Pflicht-Symlinks anlegen:ln -s ~/projects/rct-bridge/bridge bridgeundln -s ~/projects/rct-bridge/PEEP_HANDBUCH.md PEEP_HANDBUCH.md— den Handbuch-Link braucht das MCP-Toolhandbook, das die Datei im Spielverzeichnis sucht. - Daemon, CLI und MCP-Server werden dann aus dem Spielverzeichnis gestartet, wie unten beschrieben.
Referenz-Setup hier: GOG-Version von RollerCoaster Tycoon Deluxe, über
Bottles installiert; gestartet wird direkt mit System-Wine und gesetztem
WINEPREFIX, gamescope übernimmt die Skalierung.
Claude Code ─stdio/JSON-RPC─▶ MCP-Server ─Unix-Socket─▶ Daemon ─/proc/<pid>/mem─▶ RCT.EXE (Wine)
(dünner Adapter) (Maske, Journal, exec-Sandbox)
Ein langlebiger Daemon startet wine RCT.EXE als Kindprozess und hält den
/proc/<pid>/mem-Dateideskriptor. Das ist keine Geschmacksfrage, sondern die
Ancestor-Falle: Bei kernel.yama.ptrace_scope=1 darf nur ein Vorfahr den
Spielspeicher öffnen. Würde ein kurzlebiger CLI-Aufruf Wine spawnen und enden,
käme der nächste Aufruf nicht mehr an den Prozess heran. Also führt der Daemon
alle Zugriffe aus (Single Writer); Maske, Journal und exec-Sandbox existieren
nur dort. Clients sprechen ein JSON-Zeilen-Protokoll über einen Unix-Socket;
die CLI ist reines Shell-Sugar darüber, und der MCP-Server
(bridge/mcp_server.py) ist nur ein weiterer dünner Client — er übersetzt
MCP-Tool-Calls eins zu eins in dieses Protokoll.
-
Umgebung wie beim manuellen Spielstart vorbereiten und ins Spielverzeichnis wechseln — der Daemon vererbt seine Umgebung an das Spiel:
export WINEPREFIX="/home/lutz/Downloads/rct_deluxe/RCT-Deluxe" cd "$WINEPREFIX/drive_c/GOG Games/RollerCoaster Tycoon Deluxe" -
Daemon starten. Einfachster Fall:
python3 -m bridge.daemon(spawntwine RCT.EXEdirekt). Referenz-Setup mit gamescope (3072×2304, Nearest-Neighbor-Skalierung, ddraw-Override für die GOG-Version):python3 -m bridge.daemon --cmd 'gamescope -w 3072 -h 2304 -W 3072 -H 2304 -F nearest -- env WINEDLLOVERRIDES=ddraw=n,b wine RCT.EXE'Der Daemon startet den Befehl als Kindprozess und sucht das Spiel unter dessen Nachkommen (Daemon → gamescope → env → wine) — die Yama-Ancestor-Regel deckt beliebig tiefe Nachkommen ab, ein Launcher dazwischen ist also kein Problem. Gefunden ist das Spiel, sobald ein Nachkomme RCT.EXE an 0x400000 eingeblendet hat („MZ"-Check); danach lauscht der Daemon auf dem Socket (Default:
$XDG_RUNTIME_DIR/rctbridge.sock). -
In einer zweiten Shell die CLI benutzen, z. B.
python3 -m bridge.cli read globals ticks. Praktisch:alias rctbridge='python3 -m bridge.cli'. -
Beenden mit
python3 -m bridge.cli shutdown— das beendet Launcher und Spiel (abschaltbar mit--leave-gamebeim Daemon-Start, aber Achtung: an einen verwaisten Prozess kommt ein neuer Daemon bei ptrace_scope=1 nicht mehr heran).
Varianten: --fake fährt den Daemon mit einem FakeMemory-Backend (zum Testen
des Protokolls ohne Spiel), --attach PID hängt sich an eine laufende Instanz
(braucht sudo sysctl kernel.yama.ptrace_scope=0).
| Op | Beispiel-Request |
|---|---|
| read (Region) | {"op":"read","region":"sprites","idx":17,"off":34,"size":2} |
| read (Global) | {"op":"read","region":"globals","name":"ticks"} |
| write | {"op":"write","region":"sprites","idx":17,"off":48,"data":"1c"} |
| write (Global) | {"op":"write","region":"globals","name":"climate_rain_level","data":"01"} |
| exec | {"op":"exec","code":"...","args":{"idx":17}} |
| rollback | {"op":"rollback","n":1} oder {"op":"rollback","all":true} |
| status | {"op":"status"} |
| shutdown | {"op":"shutdown"} |
Antworten sind {"ok":true, ...} oder {"ok":false,"error":"..."}.
Bytes laufen als Hex-String. status liefert die Selbstauskunft: alle
Regionen mit Basis/Stride/Modus und die Namen der lesbaren Globals.
Die Regionen entsprechen Handbuch §2: sprites (RW mit Feld-Maske),
strings (RW, Slot-Grenzen erzwungen), sprite_lists und rides (RO),
globals (benannte Adressen, RO bis auf die Allowlist globals_rw —
aktuell der Climate-Block fürs Wetter-Rezept, Handbuch §10; Writes nur in
voller Breite des Globals). Die Feld-Maske ist eine Allowlist
(§4): schreibbar sind nur Name-ID, Farben, Invalidate-Flag, peep_flags,
Pathfind-Goal-Reset und time_lost — plus +0xC5/+0xC6 (Heading/Countdown),
die zusätzlich nur bei belegtem Gast-Slot (peep_type == 0) beschreibbar
sind, weil Staff dieselben Bytes als Order-Felder recycelt (§4.1). Stats,
Position, State und Geld sind by design nicht schreibbar.
Für Massen-Operationen (800 Gäste einzeln poken wäre Latenz-Tod) schickt man ein Python-Snippet, das im Daemon läuft. Es sieht genau vier Dinge:
bridgemitread(region, idx, off, size),read_global(name),write(region, idx, off, bytes)undwrite_global(name, bytes)— Writes laufen durch Maske/Allowlist und Journal, die Sandbox öffnet keine Hintertür.structfür u16/u32-Gefummel.args— das JSON-Objekt aus dem Request (CLI:--args '{"idx":17}').log(...)für Zwischenmeldungen; das Ergebnis legt man inresult.
Kein import, kein open, kein os. Schutzziel sind Unfälle, nicht
Angreifer. Beispiele liegen in snippets/ (Gäste-Scan, Namens-Lookup,
M1-goto, Wetter-Regie make_rain.py). Achtung: Ein Endlos-Loop im Snippet
blockiert den Daemon — v0 hat bewusst keinen Timeout.
Wiederkehrende Sweeps gehören deshalb NICHT als Loop ins Snippet, sondern
client-seitig: python3 -m bridge.heatmap_loop (S11) schickt alle 10 Sekunden
den Sweep aus snippets/happiness_heatmap.py (Ampel-Farben nach Happiness,
Schreiben nur bei Bucket-Wechsel) — Muster „Reflex-Schicht ohne LLM": frische
Verbindung pro Request, der Daemon bleibt zwischen den Sweeps frei. Stoppen:
Ctrl-C oder touch bridge/heatmap.stop.
Jeder Write wird vor der Ausführung mit alten und neuen Bytes in eine
JSONL-Datei geschrieben (Default bridge/journal.jsonl, Audit über Läufe
hinweg) und auf einen Undo-Stack gelegt. rollback stellt LIFO die alten
Bytes wieder her — der Stack gilt für die aktuelle Daemon-Laufzeit.
python3 -m bridge.mcp_server exponiert den Daemon als MCP-Server über
stdio (JSON-RPC 2.0, eine Nachricht pro Zeile; stdlib-only wie der Rest).
Er ist ein reiner Adapter ohne eigene Logik: Jeder Tool-Call öffnet eine
frische Socket-Verbindung zum Daemon — so hängt der sequenzielle Daemon nie
in einer Dauerverbindung fest und behält zwischen zwei Requests Luft (dort
laufen später die Watcher). Der Daemon muss also bereits laufen; der
MCP-Server spawnt das Spiel bewusst nicht (Ancestor-Falle).
Registrieren in Claude Code (aus dem Spielverzeichnis):
claude mcp add rct-bridge -- python3 -m bridge.mcp_server
Abweichender Socket: claude mcp add rct-bridge -- python3 -m bridge.mcp_server --socket /pfad/zur.sock.
Die Tools entsprechen den Protokoll-Ops eins zu eins: read, read_global,
write, write_global, exec, rollback, status — plus handbook, das im
PEEP_HANDBUCH.md nachschlägt (ohne Argument das Inhaltsverzeichnis, mit
section den ersten Abschnitt, dessen Überschrift den Suchtext enthält).
shutdown ist absichtlich NICHT als Tool exponiert: Ein LLM-Client soll das
Spiel nicht beenden können; das bleibt der CLI vorbehalten.
Ausblick Observability (beschlossen, kommt nach dem MCP-Server): ein
watch-/poll_events-Tool im Daemon. In Claude Code wird der Aufruf als
Background-Bash-Prozess gestartet, der endet, sobald das Event kommt — das
weckt den Main Agent (bewusst Claude-Code-spezifisch). Wichtig fürs Design:
Ein im Daemon blockierender Wait würde den sequenziellen Serve-Loop
blockieren — entweder pollt das Skript selbst in einer Schleife, oder der
Daemon bekommt ein accept-Timeout und prüft Bedingungen zwischen den
Verbindungen.
python3 -m pytest bridge/tests/ -q — läuft komplett ohne Wine gegen
FakeMemory (Adressrechnung, Maske inkl. Gate, Journal, Rollback,
exec-Sandbox, Protokoll, MCP-Adapter inkl. Socket-E2E). Die
Live-Verifikation am Spiel ist bewusst kein pytest: sie steht als
nummeriertes Skript in bridge/SMOKE.md.
Kein Watcher (register_watch/poll_events kommen später als weitere ops
und MCP-Tools, s. o.), exec ohne Timeout, rides und die
Item-/Ballon-Farben noch RO.
Der Code steht unter der MIT-Lizenz (siehe LICENSE). Die Doku-Artefakte
PEEP_HANDBUCH.md und TAGEBUCH_MUSTERMANN.md stehen unter
CC BY 4.0 — Weiterverwendung
erlaubt, solange der Urheber genannt wird. Das Repo enthält keine
Spieldateien; RollerCoaster Tycoon gehört seinen Rechteinhabern.