aboutsummaryrefslogtreecommitdiffstats
path: root/tools/gbmovie.py
blob: dc92989a5f910dbfb7d6ff38672f9d99e0881c5e (plain) (blame)
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
#!/usr/bin/env python3
"""Build TAS-style input movies for the gbc emulator's `input play` replayer.

A movie is a text file of `<frames> [buttons...]` lines: hold the given buttons
(a b start select up down left right; none/'-' = released) for that many frames.
Blank lines and `# comments` are ignored. The emulator drives one line's worth
of joypad state per emulated frame, so replay is deterministic:

    ./gbctl input play /abs/demo.gbmv reset   # reset + replay from boot
    ./gbctl record start /abs/demo.gbv 2      # (optional) capture video
    ...
    ./gbctl record stop
    python3 tools/gbgif.py demo.gbv demo.gif --scale 3

Use as a library to synthesize demos:

    from gbmovie import Movie
    m = Movie()
    m.wait(40)                      # let fastboot settle
    m.hold("select", 8)             # open the SL0P menu
    m.wait(16)
    m.tap("down", repeat=13)        # cursor down to the last item
    m.hold("a", 6)                  # select it
    m.wait(600)                     # watch it run
    m.save("demo.gbmv")

Run this file directly to emit the sample demo above to stdout.
"""
from __future__ import annotations


def _btns(buttons) -> str:
    if buttons is None:
        return ""
    if isinstance(buttons, str):
        buttons = buttons.split()
    return " ".join(buttons)


class Movie:
    def __init__(self):
        self._lines: list[str] = []
        self._frames = 0

    def hold(self, buttons, frames: int):
        """Hold `buttons` (str/list) for `frames` frames."""
        frames = int(frames)
        if frames <= 0:
            return self
        b = _btns(buttons)
        self._lines.append(f"{frames} {b}".rstrip())
        self._frames += frames
        return self

    def wait(self, frames: int):
        """Idle (no buttons) for `frames` frames."""
        return self.hold(None, frames)

    def tap(self, buttons, frames: int = 4, gap: int = 6, repeat: int = 1):
        """Press `buttons` for `frames`, release for `gap`, `repeat` times.

        The gap matters: the menu's low-sensitivity polling needs the button to
        be released between presses to register a repeat."""
        for _ in range(int(repeat)):
            self.hold(buttons, frames)
            if gap > 0:
                self.wait(gap)
        return self

    def comment(self, text: str):
        self._lines.append(f"# {text}")
        return self

    @property
    def total_frames(self) -> int:
        return self._frames

    def render(self) -> str:
        return "\n".join(self._lines) + "\n"

    def save(self, path: str):
        with open(path, "w") as f:
            f.write(self.render())
        return path


def _sample() -> Movie:
    m = Movie()
    m.comment("SL0P demo: open menu -> last item (SAVER) -> watch it wander")
    m.wait(340)                     # boot splash + fastboot -> overworld (~307f)
    m.hold("select", 8)
    m.wait(16)
    m.tap("down", repeat=13)
    m.hold("a", 6)
    m.wait(600)
    return m


if __name__ == "__main__":
    import sys
    m = _sample()
    if len(sys.argv) > 1:
        m.save(sys.argv[1])
        print(f"wrote {sys.argv[1]} ({m.total_frames} frames)")
    else:
        sys.stdout.write(m.render())