1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
|
#!/usr/bin/env python3
"""gbdemo - scripted, screen-synced demo driver for gbos promo captures.
Runs a demo "script": spawns a sl0pboy Game Boy on the gbhub network, records
the LCD to a .gbv, injects console text (via the hub, like gbtype) and raw
button events (via the emulator control socket, like gbctl) at the right
moments, and turns the capture into a GIF.
The key trick is `waitfor`: gbos's terminal keeps an ASCII shadow of the screen
in WRAM (TERM_BUF $D600, 18 slots x 64 B, row order via wAssign[18] $D590), so
the driver READS THE SCREEN over the emulator's debug socket and syncs each
step on real output - no blind frame counting, robust against network timing.
Usage:
sudo tools/gbhub --daemon # first, in another terminal
tools/gbdemo demo/readme.gbd # then run the script (inside tmux)
Script verbs (shlex lines, '#' comments):
spawn [emu-args...] spawn GB on the hub (gbctl tmux pane + --sock)
record <out.gbv> [everyN] start LCD capture (default every 2nd frame)
recordstop stop the capture
type <text...> inject a console line at once (+ Enter)
('#' only comments at line start, so IRC
channel names pass through unquoted)
slowtype <text...> inject char-by-char, human-paced (+ Enter)
key <gbctl-input-args...> button events, e.g. `key press select`,
`key hold a`, `key release`
movie <file.gbmv> play a TAS movie (tools in ~/dev/gbc)
wait <seconds> fixed delay
waitfor <text> [timeout=30] block until <text> appears on the GB screen
waitgone <text> [timeout=30] block until <text> is NOT on the screen
screen print the decoded 40x18 screen (script tuning)
shot <out.bmp> VRAM screenshot via gbshot.py
gif <out.gif> [gbgif-args] convert the last recording (gbgif.py)
echo <text...> progress note to stderr
stop quit emulator + pane (also runs on error)
"""
import os, re, shlex, socket, subprocess, sys, time
HOME = os.path.expanduser("~")
GBC = os.environ.get("GBDEMO_GBC", os.path.join(HOME, "dev/gbc"))
GBCTL = os.path.join(GBC, "gbctl")
ROM = os.path.join(HOME, "dev/gbos/gbos.gb")
HUB = "/tmp/gbhub.sock"
CTL = "/tmp/gbhub.ctl"
TERM_BUF, ASSIGN, ROWS, COLS, SLOT = 0xD600, 0xD590, 18, 40, 64
state = {"sock": None, "gbv": None, "spawned": False}
def die(msg):
sys.stderr.write("gbdemo: %s\n" % msg)
cleanup()
sys.exit(1)
def note(msg):
sys.stderr.write("[gbdemo] %s\n" % msg)
# ---- emulator control socket (same line protocol gbctl speaks) --------------
def sock_cmd(line, timeout=5.0):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect(state["sock"])
s.sendall(line.encode() + b"\n")
# skip the connect greeting and async "event ..." pushes; keep reading
# until the actual reply line for our command arrives.
buf = b""
while True:
nl = buf.find(b"\n")
if nl >= 0:
ln, buf = buf[:nl].decode(errors="replace").strip(), buf[nl + 1:]
if ln.startswith("event ") or "control channel" in ln:
continue
s.close()
return ln
d = s.recv(65536)
if not d:
s.close()
return ""
buf += d
def read_mem(addr, n):
r = sock_cmd("read 0x%04X %d" % (addr, n))
parts = r.split() # ok read 0xD600 1152 <hex>
if len(parts) < 5 or parts[0] != "ok":
die("read 0x%04X failed: %s" % (addr, r))
return bytes.fromhex(parts[4])
def screen_text():
"""Decode the gbos terminal: 18 rows x 40 cols of ASCII, top to bottom."""
assign = read_mem(ASSIGN, ROWS)
buf = read_mem(TERM_BUF, ROWS * SLOT)
rows = []
for r in range(ROWS):
line = buf[assign[r] * SLOT: assign[r] * SLOT + COLS]
rows.append("".join(chr(c) if 32 <= c < 127 else " " for c in line))
return rows
# ---- hub console injection (same wire protocol as gbtype) -------------------
def hub_type(payload: bytes):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(5)
try:
s.connect(CTL)
except OSError as e:
die("cannot connect hub ctl %s (%s) - is gbhub running?" % (CTL, e))
s.sendall(b"type\n" + payload)
s.shutdown(socket.SHUT_WR)
out = b""
while True:
try:
d = s.recv(4096)
except OSError:
break
if not d:
break
out += d
s.close()
if out.startswith(b"err"):
die("hub: %s" % out.decode(errors="replace").strip())
# ---- verbs ------------------------------------------------------------------
def v_spawn(args):
nohub = "--nohub" in args # offline: no link (script smoke-tests)
args = [a for a in args if a != "--nohub"]
# bare --bios: resolve the emulator's default boot ROM to an absolute path
# (the tmux pane's cwd is wherever the user happens to be)
if "--bios" in args:
i = args.index("--bios")
if i + 1 >= len(args) or args[i + 1].startswith("-"):
args.insert(i + 1, os.path.join(GBC, "bios/gbc_bios.bin"))
if not nohub and not os.path.exists(HUB):
die("no hub at %s - start it first: sudo tools/gbhub --daemon" % HUB)
rom = "/tmp/gbdemo_%d.gb" % os.getpid() # private copy: no .sav clashes
subprocess.run(["cp", ROM, rom], check=True)
cmd = [GBCTL, "spawn", "--split", "v", rom]
if not nohub:
cmd += ["--serial-sock", HUB]
if "--shrink" not in args and "--sixel" not in args:
cmd += ["--shrink", "2"] # keep the monitor pane small; capture
# and recording stay full-res
cmd += args
r = subprocess.run(cmd, cwd=GBC, capture_output=True, text=True)
m = re.search(r"sock=(\S+)", r.stdout + r.stderr)
if r.returncode != 0 or not m:
die("spawn failed:\n%s%s" % (r.stdout, r.stderr))
state["sock"], state["spawned"] = m.group(1), True
note("spawned, sock=%s" % state["sock"])
def v_record(args):
if not args:
die("record needs an output path")
path = os.path.abspath(args[0])
every = args[1] if len(args) > 1 else "2"
r = sock_cmd("record start %s %s" % (path, every))
if not r.startswith("ok"):
die("record: %s" % r)
state["gbv"] = path
note("recording -> %s (every %s frames)" % (path, every))
def v_recordstop(args):
note(sock_cmd("record stop"))
def v_type(args):
text = " ".join(args)
note("type: %s" % text)
hub_type(text.encode() + b"\n")
def v_slowtype(args):
text = " ".join(args)
note("slowtype: %s" % text)
for ch in text:
hub_type(ch.encode())
time.sleep(0.06)
time.sleep(0.25)
hub_type(b"\n")
def v_key(args):
if not args:
die("key needs gbctl input args (press/hold/release ...)")
r = subprocess.run([GBCTL, "--sock", state["sock"]] + args,
cwd=GBC, capture_output=True, text=True)
note("key %s: %s" % (" ".join(args), (r.stdout + r.stderr).strip()))
def v_movie(args):
if not args:
die("movie needs a .gbmv path")
note(sock_cmd("input play %s" % os.path.abspath(args[0])))
def v_wait(args):
time.sleep(float(args[0]))
def _wait_screen(args, want_present):
if not args:
die("waitfor/waitgone need <text> [timeout]")
text = args[0]
timeout = float(args[1]) if len(args) > 1 else 30.0
deadline = time.time() + timeout
while time.time() < deadline:
present = any(text in row for row in screen_text())
if present == want_present:
note("%s %r" % ("saw" if want_present else "gone:", text))
return
time.sleep(0.25)
for row in screen_text():
sys.stderr.write(" |%s|\n" % row)
die("timeout (%gs) waiting for %r %s" %
(timeout, text, "to appear" if want_present else "to go away"))
def v_waitfor(args):
_wait_screen(args, True)
def v_waitgone(args):
_wait_screen(args, False)
def v_screen(args):
for row in screen_text():
print("|%s|" % row)
def v_shot(args):
if not args:
die("shot needs an output path")
subprocess.run(["python3", os.path.join(GBC, "tools/gbshot.py"),
state["sock"], os.path.abspath(args[0])], check=True)
note("shot -> %s" % args[0])
def v_gif(args):
if not state["gbv"]:
die("gif: nothing recorded yet")
if not args:
die("gif needs an output path")
out = os.path.abspath(args[0])
cmd = ["python3", os.path.join(GBC, "tools/gbgif.py"),
state["gbv"], out] + args[1:]
subprocess.run(cmd, check=True)
note("gif -> %s" % out)
def v_echo(args):
note(" ".join(args))
def v_stop(args):
cleanup()
def cleanup():
if state["spawned"]:
state["spawned"] = False
subprocess.run([GBCTL, "stop"], cwd=GBC,
capture_output=True, text=True)
note("emulator stopped")
VERBS = {"spawn": v_spawn, "record": v_record, "recordstop": v_recordstop,
"type": v_type, "slowtype": v_slowtype, "key": v_key,
"movie": v_movie, "wait": v_wait, "waitfor": v_waitfor,
"waitgone": v_waitgone, "screen": v_screen, "shot": v_shot,
"gif": v_gif, "echo": v_echo, "stop": v_stop}
def main():
if len(sys.argv) != 2 or sys.argv[1] in ("-h", "--help"):
sys.stdout.write(__doc__ + "\n")
sys.exit(0 if len(sys.argv) == 2 else 1)
try:
lines = open(sys.argv[1]).read().splitlines()
except OSError as e:
sys.exit("gbdemo: %s" % e)
try:
for n, raw in enumerate(lines, 1):
if raw.lstrip().startswith("#"):
continue # full-line comment
try:
toks = shlex.split(raw) # no inline comments: '#gbos' etc.
except ValueError as e:
die("line %d: %s" % (n, e))
if not toks:
continue
verb, args = toks[0].lower(), toks[1:]
if verb not in VERBS:
die("line %d: unknown verb %r" % (n, verb))
if verb not in ("spawn", "echo", "wait") and not state["sock"] \
and verb not in ("stop",):
die("line %d: %r before spawn" % (n, verb))
VERBS[verb](args)
except KeyboardInterrupt:
die("interrupted")
finally:
cleanup()
note("done")
if __name__ == "__main__":
main()
|