Skip to content

captn3m0/playghost

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

playghost

playghost is a small fake PlaydateAPI for driving a Playdate game from an ordinary C program that you can use to run end-to-end tests for your Playdate game.

It only supports C-based games for now (that compile to pdex.so). Lua support is not planned.

How it works

playghost_open() loads your pdex.so and calls its eventHandler(kEventInit) with a PlaydateAPI whose system, graphics, file, sound, and display tables point at playghost's own functions. From there:

  • playghost_tick() advances a fake clock and runs your update callback once, the way a real 50 Hz refresh would.
  • playghost_set_buttons() sets the held state for the next tick. playghost derives pushed/released from the previous tick, same as hardware.
  • playghost_set_crank_angle() sets an absolute crank angle; getCrankChange() is derived from the delta between ticks.
  • fillRect, fillTriangle, and clear are real software rasterizers writing into an actual 1bpp framebuffer, not stubs that return success and do nothing. playghost double-buffers properly -- getFrame() is the buffer being drawn into this tick, getDisplayFrame() is what was on screen going into it, and they are genuinely different memory, because some games (see examples/life) read one while writing the other. playghost_dump_pbm() writes the current frame out as a PBM you can diff or eyeball; playghost_framebuffer() hands you the raw bytes if you'd rather assert on pixels directly.
  • playghost_seed_file() makes playdate->file->open() return an in-memory fixture instead of failing. playghost never touches your real filesystem on the game's behalf, in either direction.
  • playghost_symbol() is dlsym() against your loaded game. Export a debug function from your game and call it directly from your test. This is the intended way to assert on state that never reaches the framebuffer.

Building

playghost needs the Playdate SDK's headers and nothing else.

export PLAYDATE_SDK_PATH=/path/to/PlaydateSDK
make check

Usage

A game exports one required symbol, eventHandler, and whatever debug hooks you want your tests to call:

/* game.c */
#include "pd_api.h"

static PlaydateAPI *pd;
static int score = 0;

static int update(void *ud) {
    PDButtons pushed;
    pd->system->getButtonState(NULL, &pushed, NULL);
    if (pushed & kButtonA) score++;
    pd->graphics->clear(kColorWhite);
    pd->graphics->fillRect(10, 10, score, 10, kColorBlack);
    return 1;
}

/* Test-only. A real device build never calls this. */
int debug_score(void) { return score; }

int eventHandler(PlaydateAPI *playdate, PDSystemEvent event, uint32_t arg) {
    if (event == kEventInit) {
        pd = playdate;
        pd->system->setUpdateCallback(update, NULL);
    }
    return 0;
}

A test drives it like any other C program, because now it is one:

/* test.c */
#include <assert.h>
#include "playghost.h"

int main(void) {
    PlayghostDevice *dev = playghost_open("pdex.so");
    assert(dev);

    int (*score)(void) = playghost_symbol(dev, "debug_score");
    assert(score() == 0);

    playghost_set_buttons(dev, kButtonA);
    playghost_tick(dev, 20);
    playghost_set_buttons(dev, 0);

    assert(score() == 1);
    playghost_dump_pbm(dev, "after-press.pbm");
    playghost_close(dev);
    return 0;
}

Seeding a save file your game reads during its own init, so a test can skip a first-run tutorial path instead of steering through it:

static const char save[] = "level=3\nlives=2\n";

PlayghostDevice *dev = playghost_create("pdex.so");
playghost_seed_file(dev, "save.json", save, sizeof save - 1);
playghost_init(dev); /* only now does the game's kEventInit see the file */

Driving the crank and reading a specific frame back as pixels, e.g. to check a rotating dial lands in the right place:

playghost_set_crank_angle(dev, 90.0f);
for (int i = 0; i < 30; i++) playghost_tick(dev, 20); /* let it settle */
playghost_dump_pbm(dev, "dial-at-90.pbm");

Asserting on the frame

Rather than hand-roll pixel loops over the raw framebuffer, three helpers read the current frame directly:

long black = playghost_count_pixels(dev, kColorBlack); /* or kColorWhite */
assert(black > 0);                                     /* something drew */

/* Is a rectangle a solid color? (clipped to the screen) */
assert(playghost_region_is(dev, 0, 0, 400, 20, kColorWhite)); /* top bar clear */

/* Compare against a PBM you dumped earlier: 1 equal, 0 differ, -1 missing. */
assert(playghost_frame_equals(dev, "golden.pbm") == 1);

playghost_frame_equals() reads back exactly what playghost_dump_pbm() wrote, so a dumped frame always matches itself. Note the fidelity limits under What's faked: a golden dumped by playghost is only comparable to another playghost frame, never to one from the real simulator, because pattern fills and text don't render the same.

Recording and replaying (VCR)

playghost records every effectful call your game makes into the fake API — the draws, asset loads, file and sound calls, and log lines — as a plain-text trace. playghost_verify() snapshots that trace against a cassette on disk, the same record-then-replay idea as HTTP VCR libraries:

/* first run: no cassette yet -> records it, returns 0. commit the file.
   every run after: compares the live trace to it, returns 0 if identical,
   -1 if not (and writes "<cassette>.actual" so you can diff the two). */
assert(playghost_verify(dev, "tests/bounce.cassette") == 0);

The cassette is a readable log, so a diff on a failure is legible:

clear W
fillRect 174 228 60 6 B
fillTriangle 203 98 199 106 207 106 B
...

It captures what the game did (which coordinates, which colors, which files) rather than the pixels it produced, so it catches behavioral regressions that a framebuffer check would miss — a changed file access, a dropped sound, a draw that moved — and stays stable across runs because it never records pointers, only semantic arguments. The buttons, crank, and clock your test drives are inputs, not effects, and aren't recorded. Delete the cassette to re-record after an intended change.

Because a fresh recording passes, a cassette that isn't committed makes CI green without ever comparing — so commit your cassettes.

Examples

examples/bounce is a complete, if pointless, game written for this README: a ball bounces around the screen while a paddle answers to left and right. test.c scripts input, reads the ball's position through a debug export, and dumps a few frames to /tmp — a template for wiring playghost into your own game's test suite.

examples/life is Panic's own Conway's Game of Life example from the SDK (C_API/Examples/Life), copied in with one addition: a debug_alive_count() export so the test can assert the population actually changes instead of just checking that nothing crashed.

Both examples pass make check, and are Playdate SDK code, not playghost's — nothing about them is aware playghost exists except the one debug export in life/game.c.

Generating a GIF

examples/*/record.c runs a game for a fixed number of frames and dumps every one, instead of the sparse sampling a real test would use:

make gif

writes examples/bounce/demo.gif and examples/life/demo.gif from 180 recorded frames each, via ImageMagick. It is not a fast recipe -- 180 PBMs through convert -- and it is not meant to run in CI. It is meant to let you look at what your test actually saw.

Playdate API coverage

Very little of the API is covered. This was made for a superhexagon port, which doens't use a lot of APIs. Known gaps and planned work live in TODO.md.

License

MIT. See LICENSE.

About

A fake playdate API and test-runner for the Playdate

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages